241 lines
8.1 KiB
Python
241 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Build current prototype data and a self-contained Gallery artifact."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SITE = ROOT / "prototype" / "site"
|
|
DATA_PATH = SITE / "data" / "catalog.json"
|
|
DEFAULT_OUTPUT = ROOT / "prototype" / "gallery" / "aetherbound-guild-prototype.html"
|
|
|
|
|
|
def clean(value: str) -> str:
|
|
value = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", value)
|
|
value = value.replace("**", "").replace("`", "")
|
|
return re.sub(r"\s+", " ", value).strip()
|
|
|
|
|
|
def table_rows(path: Path, id_pattern: str) -> list[list[str]]:
|
|
matcher = re.compile(id_pattern)
|
|
rows: list[list[str]] = []
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
if not line.startswith("|"):
|
|
continue
|
|
cells = [clean(cell) for cell in line.strip().strip("|").split("|")]
|
|
if cells and matcher.match(cells[0]):
|
|
rows.append(cells)
|
|
return rows
|
|
|
|
|
|
def split_identity(value: str, pattern: str) -> tuple[str, str]:
|
|
match = re.match(pattern, value)
|
|
if not match:
|
|
raise ValueError(f"invalid catalog identity: {value}")
|
|
return match.group(1), match.group(2)
|
|
|
|
|
|
def require_unique(entries: list[dict[str, str]], expected: int, label: str) -> None:
|
|
ids = [entry["id"] for entry in entries]
|
|
if len(entries) != expected or len(set(ids)) != expected:
|
|
raise ValueError(f"{label}: expected {expected} unique rows, received {len(entries)} / {len(set(ids))}")
|
|
|
|
|
|
def build_catalog() -> dict[str, object]:
|
|
screen_rows = table_rows(
|
|
ROOT / "docs/presentation/SCREEN_AND_STATE_MAP.md",
|
|
r"^(?:SYS|HUB|SHP|REC|EQP|ART|PTY|RSK|BAT|OUT|PRO|PST|SET)-\d{3}\b",
|
|
)
|
|
screens = []
|
|
for row in screen_rows:
|
|
match = re.match(r"^([A-Z]+-\d{3}) (.+) / ([A-Z]+)$", row[0])
|
|
if not match or len(row) != 6:
|
|
raise ValueError(f"invalid screen row: {row[0]}")
|
|
screens.append(
|
|
{
|
|
"id": match.group(1),
|
|
"name": match.group(2),
|
|
"input": match.group(3),
|
|
"purpose": row[1],
|
|
"action": row[2],
|
|
"emptyLoading": row[3],
|
|
"errorConfirmation": row[4],
|
|
"interruptionReturn": row[5],
|
|
}
|
|
)
|
|
|
|
profession_rows = table_rows(ROOT / "docs/content/PROFESSION_CATALOG.md", r"^PF-[BAH]\d{2}$")
|
|
professions = []
|
|
for row in profession_rows:
|
|
if len(row) < 6:
|
|
continue
|
|
family = {"B": "base", "A": "advanced", "H": "hidden"}[row[0][3]]
|
|
identity = row[1].split(";", 1)
|
|
cadence_index = 4 if len(row) == 7 else 3
|
|
strategy_index = 5 if len(row) == 7 else 4
|
|
professions.append(
|
|
{
|
|
"id": row[0],
|
|
"name": identity[0].strip(),
|
|
"family": family,
|
|
"role": identity[1].strip() if len(identity) > 1 else family,
|
|
"rules": row[2],
|
|
"cadence": row[cadence_index],
|
|
"strategy": row[strategy_index],
|
|
"production": row[-1],
|
|
}
|
|
)
|
|
|
|
traits = [
|
|
{
|
|
"id": row[0],
|
|
"name": row[1],
|
|
"benefit": row[2],
|
|
"cost": row[3],
|
|
"evidence": row[4],
|
|
"prohibited": row[5],
|
|
}
|
|
for row in table_rows(ROOT / "docs/content/TRAIT_CATALOG.md", r"^TR-\d{3}$")
|
|
]
|
|
|
|
equipment = []
|
|
for row in table_rows(ROOT / "docs/content/EQUIPMENT_CATALOG.md", r"^EQ-\d{3}\b"):
|
|
item_id, name = split_identity(row[0], r"^(EQ-\d{3}) (.+)$")
|
|
equipment.append(
|
|
{
|
|
"id": item_id,
|
|
"name": name,
|
|
"fit": row[1],
|
|
"class": row[2],
|
|
"effect": row[3],
|
|
"tradeoff": row[4],
|
|
"production": row[5],
|
|
}
|
|
)
|
|
|
|
artifacts = []
|
|
for row in table_rows(ROOT / "docs/content/ARTIFACT_CATALOG.md", r"^AR-\d{3}\b"):
|
|
artifact_id, name = split_identity(row[0], r"^(AR-\d{3}) (.+)$")
|
|
artifacts.append(
|
|
{
|
|
"id": artifact_id,
|
|
"name": name,
|
|
"rule": row[1],
|
|
"tradeoff": row[2],
|
|
"production": row[3],
|
|
}
|
|
)
|
|
|
|
enemies = []
|
|
bosses = []
|
|
for row in table_rows(ROOT / "docs/content/ENEMY_CATALOG.md", r"^(?:EN-R\d-\d{2}|BO-R\d-\d{2})$"):
|
|
enemy_class, name = (part.strip() for part in row[1].split(";", 1))
|
|
entry = {
|
|
"id": row[0],
|
|
"name": name,
|
|
"class": enemy_class,
|
|
"rule": row[2],
|
|
"responses": row[3],
|
|
"production": row[4],
|
|
}
|
|
(bosses if row[0].startswith("BO-") else enemies).append(entry)
|
|
|
|
regions = []
|
|
for row in table_rows(ROOT / "docs/content/REGION_CATALOG.md", r"^RG-\d{2}$"):
|
|
identity = row[1].split(";", 1)
|
|
regions.append(
|
|
{
|
|
"id": row[0],
|
|
"name": identity[0].strip(),
|
|
"framing": identity[1].strip() if len(identity) > 1 else "",
|
|
"pressure": row[2],
|
|
"progression": row[3],
|
|
"assignment": row[4],
|
|
"production": row[5],
|
|
}
|
|
)
|
|
|
|
expected = {
|
|
"screens": 50,
|
|
"professions": 42,
|
|
"traits": 36,
|
|
"equipment": 320,
|
|
"artifacts": 60,
|
|
"enemies": 100,
|
|
"bosses": 16,
|
|
"regions": 8,
|
|
}
|
|
entries = {
|
|
"screens": screens,
|
|
"professions": professions,
|
|
"traits": traits,
|
|
"equipment": equipment,
|
|
"artifacts": artifacts,
|
|
"enemies": enemies,
|
|
"bosses": bosses,
|
|
"regions": regions,
|
|
}
|
|
for label, count in expected.items():
|
|
require_unique(entries[label], count, label)
|
|
|
|
payload: dict[str, object] = {
|
|
"version": 2,
|
|
"status": "design-prototype-current-authority",
|
|
"authorityRevision": "guild-charter-integration-r4",
|
|
"counts": expected,
|
|
**entries,
|
|
}
|
|
DATA_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
DATA_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
return payload
|
|
|
|
|
|
def data_uri(path: Path) -> str:
|
|
mime = "image/webp" if path.suffix == ".webp" else "application/octet-stream"
|
|
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
return f"data:{mime};base64,{encoded}"
|
|
|
|
|
|
def main() -> None:
|
|
catalog = build_catalog()
|
|
index = (SITE / "index.html").read_text(encoding="utf-8")
|
|
styles = (SITE / "styles.css").read_text(encoding="utf-8")
|
|
app = (SITE / "app.js").read_text(encoding="utf-8")
|
|
|
|
asset_paths = sorted((SITE / "assets" / "web").glob("*.webp"))
|
|
asset_paths.append(SITE / "favicon.webp")
|
|
for path in asset_paths:
|
|
relative = path.relative_to(SITE).as_posix()
|
|
uri = data_uri(path)
|
|
index = index.replace(relative, uri)
|
|
app = app.replace(relative, uri)
|
|
|
|
catalog_json = json.dumps(catalog, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c")
|
|
app = app.replace("</script", "<\\/script")
|
|
styles = styles.replace("</style", "<\\/style")
|
|
index = index.replace('<link rel="stylesheet" href="styles.css" />', f"<style>\n{styles}\n</style>")
|
|
index = index.replace(
|
|
'<script src="app.js" defer></script>',
|
|
f'<script id="prototype-data" type="application/json">{catalog_json}</script>\n<script>\n{app}\n</script>',
|
|
)
|
|
|
|
forbidden = ('href="styles.css"', 'src="app.js"', "assets/web/")
|
|
remaining = [token for token in forbidden if token in index]
|
|
if remaining:
|
|
raise RuntimeError(f"external prototype dependency remains: {remaining}")
|
|
|
|
DEFAULT_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
DEFAULT_OUTPUT.write_text(index, encoding="utf-8")
|
|
counts = catalog["counts"]
|
|
print(f"ABG_PROTOTYPE_DATA_OK counts={json.dumps(counts, separators=(',', ':'))}")
|
|
print(f"ABG_GALLERY_BUNDLE_OK bytes={DEFAULT_OUTPUT.stat().st_size} output={DEFAULT_OUTPUT}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|