Files
aetherbound-guild/tools/build_gallery_bundle.py
T

255 lines
8.3 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"
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("</script", "<\\/script")
styles = styles.replace("</style", "<\\/style")
index = index.replace('<link rel="stylesheet" href="styles.css?v=8" />', f"<style>\n{styles}\n</style>")
index = index.replace(
'<script src="app.js?v=8" 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/")
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()