111 lines
4.9 KiB
Python
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())
|