"""Regenerate resources/GachaBannerMapping.json from the live import. Run from the repository root after the roster changes: python tools/build_gacha_banner_mapping.py Stable-ID rules: existing (gacha_id -> avatar_id) pairs are preserved exactly; only avatars missing from the current mapping receive NEW IDs (max+1). Never re-sort or renumber, so a banner ID always means the same avatar. The mapping is the backend half of the custom per-character banner presentation: each banner ID (900001+) maps to one imported avatar with a configured first Eidolon cost. Nothing here is an official Client banner row. """ import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent MAPPING = ROOT / "resources" / "GachaBannerMapping.json" BANNER_ID_BASE = 900001 def main() -> int: freesr = json.loads((ROOT / "freesr-data.json").read_text(encoding="utf-8")) avatars = sorted(int(k) for k in freesr.get("avatars", {}).keys()) eidolon = json.loads( (ROOT / "resources" / "EidolonConfig.json").read_text(encoding="utf-8") ).get("avatars", {}) catalog = json.loads( (ROOT / "resources" / "PreviewGameplayCatalog.json").read_text(encoding="utf-8") ) client_digest = catalog.get("client_assembly_sha256", "") if not re.fullmatch(r"[0-9a-f]{64}", client_digest) or not catalog.get("evidence_id"): raise ValueError("Current captured catalog identity/provenance is missing") captured_ids = {row["GachaID"] for row in catalog.get("tables", {}).get("gacha", [])} drawable = [] skipped_no_eidolon = [] for avatar in avatars: row = eidolon.get(str(avatar)) ranks = row.get("ranks", []) if row else [] if not ranks or not ranks[0].get("cost"): skipped_no_eidolon.append(avatar) continue drawable.append(avatar) existing = {} if MAPPING.exists(): previous = json.loads(MAPPING.read_text(encoding="utf-8")) if previous.get("client_assembly_sha256") != client_digest: raise ValueError("Mapping and current captured catalog belong to different Clients") known_ids = set() for banner in previous.get("banners", []): avatar_id, banner_id = banner["avatar_id"], banner["gacha_id"] if ( type(avatar_id) is not int or avatar_id <= 0 or type(banner_id) is not int or not BANNER_ID_BASE <= banner_id <= 0xFFFFFFFF or avatar_id in existing or banner_id in known_ids or banner_id in captured_ids ): raise ValueError("Duplicate, invalid or colliding existing banner identity") existing[avatar_id] = banner_id known_ids.add(banner_id) used = set(existing.values()) next_id = max(used, default=BANNER_ID_BASE - 1) + 1 banners = [] added = [] # Retain unavailable identities as disabled reservations. Dropping them # would allow a smaller future roster to reuse an old character's ID. for avatar in sorted(set(drawable) | set(existing)): gacha_id = existing.get(avatar) if gacha_id is None: while next_id in used or next_id in captured_ids: next_id += 1 if next_id > 0xFFFFFFFF: raise ValueError("Custom banner identity namespace exhausted") gacha_id = next_id used.add(gacha_id) next_id += 1 added.append((gacha_id, avatar)) banners.append( { "gacha_id": gacha_id, "avatar_id": avatar, "roster_available": avatar in drawable, "provenance": "sandbox custom banner; avatar from imported roster; not official Client banner config", } ) banners.sort(key=lambda b: b["gacha_id"]) out = { "format_version": 1, "source": "Generated from freesr-data.json avatars + resources/EidolonConfig.json by tools/build_gacha_banner_mapping.py; stable IDs: existing pairs preserved, new avatars get max+1.", "note": "Custom per-character banner mapping for the custom gacha presentation. Banner IDs use a 900000+ sandbox namespace that cannot collide with the Client captured GachaBasicInfo rows (1001/4001/2001/2002/3001/3002). Draws grant the mapped avatar; duplicates convert at its configured first Eidolon cost; first acquisition of a not-yet-owned avatar is rejected until the avatar acquisition path exists.", "client_assembly_sha256": client_digest, "banners": banners, } MAPPING.write_text( json.dumps(out, ensure_ascii=False, indent=1) + chr(10), encoding="utf-8" ) print( f"active banners: {len(drawable)} (reserved identities {len(banners)}, kept {len(existing)}, newly assigned {len(added)}, " f"skipped without eidolon config: {skipped_no_eidolon})" ) return 0 if __name__ == "__main__": sys.exit(main())