216 lines
10 KiB
Python
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())
|