Files
bd2/tools/python/repair_failed_collection_growth.py
T
Flechazo f55a71ac52 feat: expand growth, equipment, and mail
- add character promotion and costume potential flows
- add equipment management and enhancement
- harden development mail and save tools
2026-09-21 22:57:53 +08:00

128 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Undo the six rejected 2026-09-21 20:19–20:20 collection promotions.
The six /CharGrowth requests for collection character 920000054 (6510/1)
charged inventory and one gold payment before failing to persist a changed
character ID. This one-time repair only accepts the exact observed save and
six matching server errors, and removes the refund items those failed requests
created. Dry-run by default; applying requires stopped client/server, backs up
and verifies all nine account files, then writes only items.json/wallet.json.
"""
from __future__ import annotations
import argparse
from datetime import datetime
import json
from pathlib import Path
import save_checkpoint
from set_first_gacha_state import atomic_write_json
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
DEFAULT_LOG = Path(__file__).resolve().parents[2] / "logs" / "server-20260921-201837.err.log"
ERROR = "persist promoted collection character: player: collection character 920000054 not found"
IDENTITY = "char-promote:920000054:6510"
CONSUMED = {
900000038: (11, 93, 1),
900000041: (12, 86, 2),
900000042: (9, 94738, 753),
900000045: (14, 99973, 4),
900000046: (13, 99980, 3),
}
REFUNDS = {index: (8 if index % 2 else 7, 1 if index % 2 else 3) for index in range(900000049, 900000061)}
def repair(items: dict, wallet: dict, collection: dict, log: str) -> tuple[dict, dict]:
errors = [line for line in log.splitlines() if ERROR in line]
if len(errors) != 6 or any("path=/CharGrowth" not in line for line in errors):
raise ValueError(f"expected exactly six rejected promotion requests, got {len(errors)}")
if any(value.get("version") != "2.34.13" for value in (items, wallet, collection)):
raise ValueError("unexpected save version")
owner = [char for char in collection.get("characters", []) if char.get("inven_index") == 920000054]
if len(owner) != 1 or owner[0].get("id") != 6510 or owner[0].get("level") != 1:
raise ValueError("collection character no longer matches the rejected request")
if wallet.get("gold") != 56050 or wallet.get("spent", {}).get(IDENTITY) is not True:
raise ValueError("wallet differs from the observed failed payment")
inventory = {item.get("inven_index"): item for item in items.get("items", [])}
for index, (item_id, remaining, _) in CONSUMED.items():
item = inventory.get(index)
if not item or item.get("id") != item_id or item.get("type") != 8 or item.get("count") != remaining:
raise ValueError(f"item stack {index} differs from the observed failed requests")
for index, (item_id, count) in REFUNDS.items():
item = inventory.get(index)
if not item or item.get("id") != item_id or item.get("type") != 8 or item.get("count") != count:
raise ValueError(f"failed-request refund {index} was changed or used")
for indices in items.get("grant_items", {}).values():
if any(index in REFUNDS for index in indices):
raise ValueError("a failed-request refund has a separate grant reference")
corrected_items = dict(items)
corrected_items["items"] = []
for original in items["items"]:
index = original["inven_index"]
if index in REFUNDS:
continue
item = dict(original)
if index in CONSUMED:
item["count"] += 6 * CONSUMED[index][2]
corrected_items["items"].append(item)
corrected_wallet = dict(wallet)
corrected_wallet["gold"] = wallet["gold"] + 10000
corrected_wallet["spent"] = dict(wallet["spent"])
del corrected_wallet["spent"][IDENTITY]
return corrected_items, corrected_wallet
def run(state: Path, log_path: Path, apply: bool) -> Path | None:
before = save_checkpoint.inspect_files(state)
values = {}
for name in ("items", "wallet", "collection"):
with (state / f"{name}.json").open(encoding="utf-8") as stream:
values[name] = json.load(stream)
repaired_items, repaired_wallet = repair(values["items"], values["wallet"], values["collection"], log_path.read_text(encoding="utf-8"))
print(json.dumps({
"failed_requests": 6,
"character": "920000054 / 6510 / level 1 (unchanged)",
"restored_gold": 10000,
"restored_item_counts": {str(index): 6 * value[2] for index, value in CONSUMED.items()},
"removed_unearned_refund_indices": sorted(REFUNDS),
}, indent=2))
if not apply:
return None
running = save_checkpoint.running_processes()
if running:
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-failed-collection-growth-repair"
save_checkpoint.create(state, backup, "before undoing six rejected collection CharGrowth requests")
atomic_write_json(state / "items.json", repaired_items)
atomic_write_json(state / "wallet.json", repaired_wallet)
after = save_checkpoint.inspect_files(state)
if any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name not in {"items.json", "wallet.json"}):
raise OSError(f"unrelated account state changed; backup: {backup}")
for name, expected in (("items.json", repaired_items), ("wallet.json", repaired_wallet)):
with (state / name).open(encoding="utf-8") as stream:
if json.load(stream) != expected:
raise OSError(f"failed verification of {name}; backup: {backup}")
save_checkpoint.verify(backup)
return backup
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
parser.add_argument("--log", type=Path, default=DEFAULT_LOG)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
try:
backup = run(args.state.resolve(), args.log.resolve(), args.apply)
print(f"Repaired with complete verified backup: {backup}" if backup else "Dry-run only; pass --apply after stopping client/server")
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
parser.exit(1, f"repair_failed_collection_growth: {exc}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())