- add character promotion and costume potential flows - add equipment management and enhancement - harden development mail and save tools
274 lines
9.8 KiB
Python
274 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Narrowly roll the local account back to the pack21 quest-38 boundary.
|
|
|
|
This is a one-off development/recovery tool, not server code. It understands
|
|
both legacy progress saves and the v2 ``pack:quest`` schema. By default it only
|
|
prints the planned changes; pass --apply after stopping the client and server.
|
|
Before applying it copies all nine account files to a timestamped checkpoint
|
|
and verifies every SHA-256 hash.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import copy
|
|
from datetime import datetime
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
STATE_FILES = (
|
|
"characters.json",
|
|
"collection.json",
|
|
"deck.json",
|
|
"equipment.json",
|
|
"items.json",
|
|
"mail.json",
|
|
"missions.json",
|
|
"progress.json",
|
|
"wallet.json",
|
|
)
|
|
QUEST_ITEMS = {
|
|
900000033: (122, 19, 1),
|
|
900000034: (11, 19, 1),
|
|
900000035: (501, 27, 6),
|
|
900000036: (101, 27, 3),
|
|
}
|
|
ITEM_GRANT = "pack21:quest38:items"
|
|
WALLET_GRANT = "pack21:quest38"
|
|
WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state"
|
|
|
|
|
|
def load_json(path: Path):
|
|
with path.open("r", encoding="utf-8") as stream:
|
|
return json.load(stream)
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest().upper()
|
|
|
|
|
|
def quest_key(progress: dict, pack_id: int, quest_id: int) -> str:
|
|
version = int(progress.get("version", 0))
|
|
return f"{pack_id}:{quest_id}" if version >= 2 else str(quest_id)
|
|
|
|
|
|
def quest_cleared(progress: dict, pack_id: int, quest_id: int) -> bool:
|
|
value = progress.get("cleared_quests", {}).get(quest_key(progress, pack_id, quest_id))
|
|
if int(progress.get("version", 0)) >= 2:
|
|
return value is True
|
|
return value == pack_id
|
|
|
|
|
|
def running_game_processes() -> list[str]:
|
|
if os.name != "nt":
|
|
return []
|
|
result = subprocess.run(
|
|
["tasklist", "/FO", "CSV", "/NH"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
names = []
|
|
for line in result.stdout.splitlines():
|
|
lowered = line.lower()
|
|
if "browndust2" in lowered or "brown dust ii" in lowered or "bd2server.exe" in lowered:
|
|
names.append(line)
|
|
return names
|
|
|
|
|
|
def build_changes(state: Path, reference: Path) -> dict[str, dict]:
|
|
progress = load_json(state / "progress.json")
|
|
prior_progress = load_json(reference / "progress.json")
|
|
if not quest_cleared(progress, 21, 38):
|
|
raise ValueError("current save has not cleared pack21 quest38")
|
|
if not quest_cleared(prior_progress, 21, 37) or quest_cleared(prior_progress, 21, 38):
|
|
raise ValueError("reference is not at the quest37-complete/quest38-pending boundary")
|
|
|
|
progress = copy.deepcopy(progress)
|
|
progress.get("cleared_quests", {}).pop(quest_key(progress, 21, 38), None)
|
|
progress.get("quests", {}).pop(quest_key(progress, 21, 38), None)
|
|
progress["position"] = copy.deepcopy(prior_progress["position"])
|
|
|
|
items = load_json(state / "items.json")
|
|
indices = items.get("grant_items", {}).get(ITEM_GRANT)
|
|
if (
|
|
items.get("granted", {}).get(ITEM_GRANT) is not True
|
|
or not isinstance(indices, list)
|
|
or set(map(int, indices)) != set(QUEST_ITEMS)
|
|
):
|
|
raise ValueError("quest38 item grant does not match the expected four instances")
|
|
remaining = []
|
|
removed: set[int] = set()
|
|
for item in items.get("items", []):
|
|
index = int(item.get("inven_index", 0))
|
|
if index not in QUEST_ITEMS:
|
|
remaining.append(item)
|
|
continue
|
|
expected = QUEST_ITEMS[index]
|
|
actual = (int(item.get("id", 0)), int(item.get("type", 0)), int(item.get("count", 0)))
|
|
if actual != expected:
|
|
raise ValueError(f"unexpected quest38 reward item {item!r}; expected {expected}")
|
|
removed.add(index)
|
|
if removed != set(QUEST_ITEMS):
|
|
raise ValueError(f"quest38 reward instances are incomplete: found {sorted(removed)}")
|
|
items = copy.deepcopy(items)
|
|
items["items"] = remaining
|
|
items["granted"].pop(ITEM_GRANT, None)
|
|
items["grant_items"].pop(ITEM_GRANT, None)
|
|
items["next_index"] = max(
|
|
(int(item.get("inven_index", 0)) + 1 for item in remaining), default=1
|
|
)
|
|
|
|
wallet = load_json(state / "wallet.json")
|
|
if wallet.get("granted", {}).get(WALLET_GRANT) is not True or int(wallet.get("gold", 0)) < 1500:
|
|
raise ValueError("quest38 wallet grant is absent or gold is below 1500")
|
|
wallet = copy.deepcopy(wallet)
|
|
wallet["gold"] = int(wallet["gold"]) - 1500
|
|
wallet["granted"].pop(WALLET_GRANT, None)
|
|
|
|
deck = load_json(state / "deck.json")
|
|
prior_deck = load_json(reference / "deck.json")
|
|
if "field_char_control_deck_type" not in prior_deck:
|
|
raise ValueError("reference deck has no field_char_control_deck_type")
|
|
deck = copy.deepcopy(deck)
|
|
deck["field_char_control_deck_type"] = prior_deck["field_char_control_deck_type"]
|
|
|
|
return {
|
|
"progress.json": progress,
|
|
"items.json": items,
|
|
"wallet.json": wallet,
|
|
"deck.json": deck,
|
|
}
|
|
|
|
|
|
def checkpoint_all(state: Path, target: Path) -> None:
|
|
if target.exists():
|
|
raise FileExistsError(f"checkpoint already exists: {target}")
|
|
target.mkdir(parents=True)
|
|
try:
|
|
for name in STATE_FILES:
|
|
source = state / name
|
|
if not source.is_file():
|
|
raise FileNotFoundError(f"missing state file: {source}")
|
|
shutil.copy2(source, target / name)
|
|
mismatches = [
|
|
name for name in STATE_FILES if sha256(state / name) != sha256(target / name)
|
|
]
|
|
if mismatches:
|
|
raise OSError(f"checkpoint SHA-256 mismatch: {mismatches}")
|
|
except Exception:
|
|
shutil.rmtree(target, ignore_errors=True)
|
|
raise
|
|
|
|
|
|
def atomic_write_json(path: Path, value: dict) -> None:
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
try:
|
|
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
|
|
json.dump(value, stream, ensure_ascii=False, indent=2)
|
|
stream.write("\n")
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def describe(changes: dict[str, dict]) -> dict:
|
|
progress = changes["progress.json"]
|
|
wallet = changes["wallet.json"]
|
|
items = changes["items.json"]
|
|
deck = changes["deck.json"]
|
|
return {
|
|
"quest37_cleared": quest_cleared(progress, 21, 37),
|
|
"quest38_cleared": quest_cleared(progress, 21, 38),
|
|
"position": progress.get("position"),
|
|
"gold": wallet.get("gold"),
|
|
"next_item_index": items.get("next_index"),
|
|
"field_char_control_deck_type": deck.get("field_char_control_deck_type"),
|
|
"files_to_modify": sorted(changes),
|
|
}
|
|
|
|
|
|
def verify_written_state(state: Path) -> dict:
|
|
progress = load_json(state / "progress.json")
|
|
items = load_json(state / "items.json")
|
|
wallet = load_json(state / "wallet.json")
|
|
deck = load_json(state / "deck.json")
|
|
if not quest_cleared(progress, 21, 37) or quest_cleared(progress, 21, 38):
|
|
raise ValueError("written progress is not at the quest38 boundary")
|
|
if ITEM_GRANT in items.get("granted", {}) or ITEM_GRANT in items.get("grant_items", {}):
|
|
raise ValueError("written inventory still contains the quest38 grant")
|
|
if any(int(item.get("inven_index", 0)) in QUEST_ITEMS for item in items.get("items", [])):
|
|
raise ValueError("written inventory still contains a quest38 reward instance")
|
|
if WALLET_GRANT in wallet.get("granted", {}):
|
|
raise ValueError("written wallet still contains the quest38 grant")
|
|
return describe(
|
|
{
|
|
"progress.json": progress,
|
|
"items.json": items,
|
|
"wallet.json": wallet,
|
|
"deck.json": deck,
|
|
}
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("state", nargs="?", default=DEFAULT_STATE, type=Path)
|
|
parser.add_argument(
|
|
"reference",
|
|
nargs="?",
|
|
default=DEFAULT_STATE / "checkpoints" / "20260920-1801-before-regular-gacha",
|
|
type=Path,
|
|
)
|
|
parser.add_argument("--apply", action="store_true", help="perform the rollback")
|
|
parser.add_argument(
|
|
"--backup",
|
|
type=Path,
|
|
help="checkpoint destination; defaults below state/checkpoints",
|
|
)
|
|
args = parser.parse_args()
|
|
state = args.state.resolve()
|
|
reference = args.reference.resolve()
|
|
try:
|
|
changes = build_changes(state, reference)
|
|
print(json.dumps(describe(changes), ensure_ascii=False, indent=2))
|
|
if not args.apply:
|
|
print("dry run only; stop client/server and pass --apply to write", file=sys.stderr)
|
|
return 0
|
|
running = running_game_processes()
|
|
if running:
|
|
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
|
|
backup = args.backup
|
|
if backup is None:
|
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
backup = state / "checkpoints" / f"{stamp}-before-quest38-rollback"
|
|
backup = backup.resolve()
|
|
checkpoint_all(state, backup)
|
|
for name, value in changes.items():
|
|
atomic_write_json(state / name, value)
|
|
verified = verify_written_state(state)
|
|
print(f"rollback applied; verified checkpoint: {backup}")
|
|
print(json.dumps(verified, ensure_ascii=False, indent=2))
|
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
|
parser.exit(1, f"rollback_quest38: {exc}\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|