#!/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" SCREEN_DOCUMENT = ROOT / "docs/03_PAGES_AND_UX.md" CONTENT_DOCUMENT = ROOT / "docs/05_CONTENT_CATALOG.md" 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 = [ row for row in table_rows( SCREEN_DOCUMENT, r"^(?:SYS|HUB|SHP|REC|EQP|ART|PTY|RSK|BAT|OUT|PRO|PST|SET)-\d{3}\b", ) if len(row) == 6 and re.match(r"^[A-Z]+-\d{3} .+ / [A-Z]+$", row[0]) ] 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(CONTENT_DOCUMENT, 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(CONTENT_DOCUMENT, r"^TR-\d{3}$") ] equipment = [] for row in table_rows(CONTENT_DOCUMENT, r"^EQ-\d{3} .+$"): 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(CONTENT_DOCUMENT, r"^AR-\d{3} .+$"): 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(CONTENT_DOCUMENT, 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(CONTENT_DOCUMENT, r"^RG-\d{2}$"): if len(row) != 6: continue 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": 7, "status": "phase8-p8-3r2-frontline-behavior-prototype", "authorityRevision": "phase8-p8-3r2-contact-rear-actions-replacement", "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 = {".webp": "image/webp", ".png": "image/png"}.get( path.suffix.lower(), "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( path for path in (SITE / "assets").rglob("*") if path.is_file() and path.suffix.lower() in {".webp", ".png"} ) 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("', f"") index = index.replace( '', f'\n', ) forbidden = ('href="styles.css', 'src="app.js', "assets/") 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()