feat: expand growth, equipment, and mail

- add character promotion and costume potential flows
- add equipment management and enhancement
- harden development mail and save tools
This commit is contained in:
2026-09-21 22:57:53 +08:00
parent a7c065eaa5
commit f55a71ac52
47 changed files with 6237 additions and 97 deletions
@@ -0,0 +1,127 @@
#!/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())