Files
aetherbound-guild/tools/content/build_catalog_from_contract.py
T

216 lines
10 KiB
Python

#!/usr/bin/env python3
"""Implement `docs/05_CONTENT_CATALOG.md` content into the runtime catalog.
The design contract is the authority; this script reads its tables and adds the
missing rows to `runtime/p10_0/catalog.json`. Existing rows are kept byte for
byte so the runtime keeps whatever was already reviewed. Nothing is invented
except the per-region Contract rows, which are derived from the region matrix
plus the RG-01 contract baseline and marked as derived.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DOC = ROOT / "docs/05_CONTENT_CATALOG.md"
CATALOG = ROOT / "runtime/p10_0/catalog.json"
EQUIPMENT_RE = re.compile(r"^\|\s*`(EQ-\d{3})`\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
ARTIFACT_RE = re.compile(r"^\|\s*`(AR-\d{3})`\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
ENEMY_RE = re.compile(r"^\|\s*`(EN-R(\d)-(\d{2}))`\s*\|\s*(ordinary|elite);\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
BOSS_RE = re.compile(r"^\|\s*`(BO-R(\d)-(\d{2}))`\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
REGION_RE = re.compile(r"^\|\s*`(RG-0(\d))`\s*\|\s*\*\*([^*]+)\*\*;\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
def strip_markup(value: str) -> str:
return value.replace("**", "").replace("`", "").strip()
def split_escalation(column: str) -> tuple[str, str]:
"""`escalation sentence. P/A/V/S: production` -> (escalation, production)."""
marker = column.find("P/A/V/S")
if marker < 0:
return strip_markup(column), ""
head = column[:marker].strip().rstrip(".")
tail = column[marker:].strip()
return strip_markup(head), strip_markup(tail)
def parse_rows(text: str) -> dict[str, list[dict]]:
equipment: list[dict] = []
artifacts: list[dict] = []
enemies: list[dict] = []
bosses: list[dict] = []
regions: list[dict] = []
for line in text.splitlines():
match = EQUIPMENT_RE.match(line)
if match:
equipment.append({
"id": match.group(1), "name": strip_markup(match.group(2)), "fit": strip_markup(match.group(3)),
"class": strip_markup(match.group(4)), "effect": strip_markup(match.group(5)),
"tradeoff": strip_markup(match.group(6)), "production": strip_markup(match.group(7)),
})
continue
match = ARTIFACT_RE.match(line)
if match:
artifacts.append({
"id": match.group(1), "name": strip_markup(match.group(2)), "rule": strip_markup(match.group(3)),
"tradeoff": strip_markup(match.group(4)), "production": strip_markup(match.group(5)),
})
continue
match = ENEMY_RE.match(line)
if match:
escalation, production = split_escalation(match.group(8))
enemies.append({
"id": match.group(1), "region": int(match.group(2)), "name": strip_markup(match.group(5)),
"class": match.group(4), "rule": strip_markup(match.group(6)),
"responses": strip_markup(match.group(7)), "escalation": escalation, "production": production,
})
continue
match = BOSS_RE.match(line)
if match:
escalation, production = split_escalation(match.group(7))
identity = strip_markup(match.group(4))
identity = re.sub(r"^RG-\d{2}\s*;\s*", "", identity)
bosses.append({
"id": match.group(1), "region": int(match.group(2)), "class": "RG-%02d" % int(match.group(2)), "name": identity,
"rule": strip_markup(match.group(5)), "responses": strip_markup(match.group(6)),
"escalation": escalation, "production": production,
})
continue
match = REGION_RE.match(line)
if match:
regions.append({
"id": match.group(1), "name": strip_markup(match.group(3)), "framing": strip_markup(match.group(4)),
"pressure": strip_markup(match.group(5)), "progression": strip_markup(match.group(6)),
"assignment": strip_markup(match.group(7)), "production": strip_markup(match.group(8)),
})
return {"equipment": equipment, "artifacts": artifacts, "enemies": enemies, "bosses": bosses, "region": regions}
def merge(existing: list, incoming: list, family: str) -> tuple[list, int]:
"""Contract rows replace same-id rows; unknown rows are appended in order."""
incoming_by_id = {row["id"]: dict(row) for row in incoming}
kept: list[dict] = []
seen: set[str] = set()
for row in existing:
row_id = row.get("id")
if row_id in incoming_by_id:
kept.append(incoming_by_id[row_id])
seen.add(row_id)
elif row_id is not None:
kept.append(row)
seen.add(row_id)
added = 0
for row in incoming:
if row["id"] in seen:
continue
kept.append(dict(row))
added += 1
return kept, added
def derive_contracts(regions: list[dict], bosses: list[dict]) -> list[dict]:
"""Two contracts per region, paired to that region's bosses.
Round count follows the RG-01 baseline; first-clear renown follows the
gameplay contract's formula `8 + 2G + 4K`.
"""
by_region: dict[int, list[str]] = {}
for boss in bosses:
region_key = boss.get("region")
if region_key is None:
# rows added earlier only carry the region through their class column
region_key = int(str(boss.get("class", "RG-01")).split("-")[-1])
by_region.setdefault(int(region_key), []).append(boss["id"])
contracts: list[dict] = []
for region in regions:
index = int(region["id"].split("-")[1])
boss_ids = sorted(by_region.get(index, []))
for slot, boss_id in enumerate(boss_ids, start=1):
contracts.append({
"id": "CTR-R%d-%02d" % (index, slot),
"name": "%s Contract %d" % (region["name"], slot),
"region_id": region["id"],
"ordinal": slot,
"round_count": 12,
"boss_id": boss_id,
"clear_fact": "CLEAR-RG%02d-C%02d" % (index, slot),
# 02_GAMEPLAY_AND_BALANCE.md: first_clear_renown(G, K) = 8 + 2G + 4K
"first_clear_renown": 8 + 2 * index + 4 * slot,
"renown_formula": "8 + 2*G + 4*K",
})
return contracts
REGION_EQUIPMENT = {1: (1, 40), 2: (41, 80), 3: (81, 120), 4: (121, 160), 5: (161, 200), 6: (201, 240), 7: (241, 280), 8: (281, 320)}
REGION_ARTIFACTS = {1: (1, 8), 2: (9, 15), 3: (16, 22), 4: (23, 30), 5: (31, 37), 6: (38, 45), 7: (46, 52), 8: (53, 60)}
LOCALE = ROOT / "tools/content/content_locale_zh.json"
def in_scope(through_region: int) -> dict:
"""Row filters for a staged region roll-out."""
equipment_hi = REGION_EQUIPMENT[through_region][1]
artifact_hi = REGION_ARTIFACTS[through_region][1]
return {"equipment_hi": equipment_hi, "artifact_hi": artifact_hi, "region": through_region}
def main() -> int:
import sys
through_region = 2
for index, argument in enumerate(sys.argv):
if argument == "--through-region" and index + 1 < len(sys.argv):
through_region = int(sys.argv[index + 1])
scope = in_scope(through_region)
text = DOC.read_text()
parsed = parse_rows(text)
parsed["equipment"] = [row for row in parsed["equipment"] if int(row["id"].split("-")[1]) <= scope["equipment_hi"]]
parsed["artifacts"] = [row for row in parsed["artifacts"] if int(row["id"].split("-")[1]) <= scope["artifact_hi"]]
parsed["enemies"] = [row for row in parsed["enemies"] if int(row["region"]) <= scope["region"]]
parsed["bosses"] = [row for row in parsed["bosses"] if int(row["region"]) <= scope["region"]]
parsed["region"] = [row for row in parsed["region"] if int(row["id"].split("-")[1]) <= scope["region"]]
locale = json.loads(LOCALE.read_text()) if LOCALE.exists() else {}
catalog = json.loads(CATALOG.read_text())
report = {}
for family in ["equipment", "artifacts", "enemies", "bosses", "region"]:
# The contract is the authority: the scoped catalogue is rebuilt from it.
catalog[family] = parsed[family]
report[family] = {"parsed": len(parsed[family]), "total": len(catalog[family])}
expected = {"equipment": 320, "artifacts": 60, "enemies": 100, "bosses": 16, "region": 8}
for family, want in expected.items():
if report[family]["total"] != want:
print(f"COUNT MISMATCH {family}: have {report[family]['total']} want {want}")
derived = derive_contracts(catalog["region"], catalog["bosses"])
catalog["contracts"] = derived
# locale maps drive the localised names and semantic tags of every in-scope row
names_zh: dict[str, str] = {}
execution_tags: dict[str, list] = {}
missing: list[str] = []
for family in ["professions", "traits", "equipment", "artifacts", "enemies", "bosses", "region", "contracts"]:
rows = catalog.get(family, [])
if isinstance(rows, dict):
rows = [rows]
for row in rows:
row_id = row.get("id")
entry = locale.get(family, {}).get(row_id)
if not entry or not entry.get("name_zh") or not entry.get("tags"):
missing.append("%s:%s" % (family, row_id))
continue
names_zh[row_id] = entry["name_zh"]
execution_tags[row_id] = entry["tags"]
catalog["names_zh"] = names_zh
catalog["execution_tags"] = execution_tags
catalog["content_stage"] = {"through_region": scope["region"], "expected_regions": 8}
report["locale"] = {"mapped": len(names_zh), "missing": missing[:8], "missing_total": len(missing)}
report["contracts"] = {"total": len(derived)}
catalog.setdefault("source", {})
CATALOG.write_text(json.dumps(catalog, ensure_ascii=False, indent=1) + "\n")
print(json.dumps(report, ensure_ascii=False, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())