Files
GaCy-GO/tools/build_gacha_banner_mapping.py
2026-10-28 18:54:43 +07:00

111 lines
4.9 KiB
Python

"""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())