275 lines
11 KiB
Python
Executable File
275 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate the six consolidated Aetherbound Guild owner documents."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable, Sequence
|
|
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
|
REQUIRED_DOCUMENTS = (
|
|
Path("docs/01_GAME_DESIGN.md"),
|
|
Path("docs/02_GAMEPLAY_AND_BALANCE.md"),
|
|
Path("docs/03_PAGES_AND_UX.md"),
|
|
Path("docs/04_ART_ANIMATION_AUDIO.md"),
|
|
Path("docs/05_CONTENT_CATALOG.md"),
|
|
Path("docs/06_DEVELOPMENT_AND_REVIEW.md"),
|
|
)
|
|
PAGE_DOCUMENT = Path("docs/03_PAGES_AND_UX.md")
|
|
CONTENT_DOCUMENT = Path("docs/05_CONTENT_CATALOG.md")
|
|
APPROVED_BATTLE_REFERENCE = Path(
|
|
"docs/prototype/generation/phase6_ui_direction_01/outputs/"
|
|
"05_battle_ui_japanese_scale.png"
|
|
)
|
|
ANTI_DOMINANCE_FIXTURE = Path(
|
|
"docs/product/fixtures/ANTI_DOMINANCE_FIXED_SEEDS.json"
|
|
)
|
|
HISTORICAL_SECTION_MARKERS = {
|
|
"AETHERBOUND_GUILD_COMPLETE_DEVELOPMENT_GOAL",
|
|
"ARTIFACT_CATALOG",
|
|
"ART_ASSET_AND_ANIMATION_CATALOG",
|
|
"AUDIO_MUSIC_AND_VOICE_CATALOG",
|
|
"CONTENT_INDEX",
|
|
"DEVELOPMENT_LEDGER",
|
|
"ECONOMY_AND_BALANCE",
|
|
"ENEMY_CATALOG",
|
|
"EQUIPMENT_CATALOG",
|
|
"FG4_HANDDRAWN_PRESENTATION_REPAIR_CONTRACT",
|
|
"FG4_HANDDRAWN_PRESENTATION_REPAIR_GOAL",
|
|
"FULL_GAME_IMPLEMENTATION_ROADMAP",
|
|
"GAME_PRODUCT_CONTRACT",
|
|
"IMPLEMENTATION_PLAN",
|
|
"ORIGINALITY_BOUNDARY",
|
|
"PAGE_DEVELOPMENT_PLAN",
|
|
"PROFESSION_CATALOG",
|
|
"RECRUIT_GENERATION",
|
|
"REGION_CATALOG",
|
|
"SAVE_AND_FAILURE_CONTRACT",
|
|
"SCREEN_AND_STATE_MAP",
|
|
"SYSTEMS_AND_BATTLE",
|
|
"TRAIT_CATALOG",
|
|
"UI_AND_VISUAL_SYSTEM",
|
|
"VISUAL_DIRECTION",
|
|
}
|
|
|
|
LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
|
|
PAGE_ID_RE = re.compile(r"`([A-Z]{3}-\d{3})`")
|
|
STALE_DOCUMENT_RE = re.compile(
|
|
r"(?:docs/)?(?:content|goals|presentation|product|reviews|runtime)/"
|
|
r"[^\s`)]+\.md"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Issue:
|
|
code: str
|
|
path: str
|
|
message: str
|
|
|
|
def as_dict(self) -> dict[str, str]:
|
|
return {"code": self.code, "path": self.path, "message": self.message}
|
|
|
|
|
|
def unique_matches(text: str, pattern: str) -> set[str]:
|
|
return set(re.findall(pattern, text, flags=re.MULTILINE))
|
|
|
|
|
|
def section_between(text: str, start: str, end: str) -> str | None:
|
|
start_index = text.find(start)
|
|
if start_index < 0:
|
|
return None
|
|
end_index = text.find(end, start_index + len(start))
|
|
if end_index < 0:
|
|
return None
|
|
return text[start_index:end_index]
|
|
|
|
|
|
def local_link_targets(text: str) -> Iterable[str]:
|
|
for raw_target in LINK_RE.findall(text):
|
|
target = raw_target.strip()
|
|
if target.startswith("<") and target.endswith(">"):
|
|
target = target[1:-1]
|
|
else:
|
|
target = target.split(maxsplit=1)[0]
|
|
if target.startswith(("#", "http://", "https://", "mailto:", "data:")):
|
|
continue
|
|
target = target.split("#", 1)[0]
|
|
if target:
|
|
yield target
|
|
|
|
|
|
def expected_ids(prefix: str, count: int, width: int = 3) -> set[str]:
|
|
return {"{}-{:0{}d}".format(prefix, value, width) for value in range(1, count + 1)}
|
|
|
|
|
|
def audit(root: Path) -> tuple[list[Issue], dict[str, int]]:
|
|
issues: list[Issue] = []
|
|
counts: dict[str, int] = {}
|
|
|
|
docs_root = root / "docs"
|
|
actual_documents = set(docs_root.glob("*.md"))
|
|
supporting_documents = set(docs_root.glob("**/*.md")) - actual_documents
|
|
required_documents = {root / path for path in REQUIRED_DOCUMENTS}
|
|
counts["documents"] = len(actual_documents)
|
|
counts["supporting_documents"] = len(supporting_documents)
|
|
for missing in sorted(required_documents - actual_documents):
|
|
issues.append(Issue("MISSING_DOCUMENT", str(missing.relative_to(root)), "required owner document is missing"))
|
|
for extra in sorted(actual_documents - required_documents):
|
|
issues.append(Issue("EXTRA_DOCUMENT", str(extra.relative_to(root)), "only the six numbered owner documents may remain directly under docs/"))
|
|
|
|
loaded: dict[Path, str] = {}
|
|
for relative_path in REQUIRED_DOCUMENTS:
|
|
path = root / relative_path
|
|
if not path.is_file():
|
|
continue
|
|
text = path.read_text(encoding="utf-8")
|
|
loaded[relative_path] = text
|
|
if not text.startswith("# "):
|
|
issues.append(Issue("MISSING_TITLE", str(relative_path), "document must begin with one H1 title"))
|
|
if len(re.findall(r"^# ", text, flags=re.MULTILINE)) != 1:
|
|
issues.append(Issue("H1_COUNT", str(relative_path), "document must contain exactly one H1 title"))
|
|
stale = sorted(set(STALE_DOCUMENT_RE.findall(text)))
|
|
if stale:
|
|
issues.append(Issue("STALE_DOCUMENT_REFERENCE", str(relative_path), ", ".join(stale)))
|
|
for target in local_link_targets(text):
|
|
destination = (path.parent / target).resolve()
|
|
try:
|
|
destination.relative_to(root.resolve())
|
|
except ValueError:
|
|
issues.append(Issue("LINK_OUTSIDE_REPOSITORY", str(relative_path), target))
|
|
continue
|
|
if not destination.exists():
|
|
issues.append(Issue("BROKEN_LINK", str(relative_path), target))
|
|
|
|
marker_corpus = "\n".join(loaded.values())
|
|
marker_rows = re.findall(
|
|
r"Historical section source: ([A-Z0-9_]+);", marker_corpus
|
|
)
|
|
marker_set = set(marker_rows)
|
|
counts["historical_sections"] = len(marker_rows)
|
|
if marker_set != HISTORICAL_SECTION_MARKERS or len(marker_rows) != len(marker_set):
|
|
issues.append(
|
|
Issue(
|
|
"HISTORICAL_SECTION_SET",
|
|
"docs/",
|
|
"missing={} extra={} duplicates={}".format(
|
|
sorted(HISTORICAL_SECTION_MARKERS - marker_set),
|
|
sorted(marker_set - HISTORICAL_SECTION_MARKERS),
|
|
len(marker_rows) - len(marker_set),
|
|
),
|
|
)
|
|
)
|
|
|
|
page_text = loaded.get(PAGE_DOCUMENT, "")
|
|
plan = section_between(
|
|
page_text,
|
|
"Historical section source: PAGE_DEVELOPMENT_PLAN",
|
|
"Historical section source: SCREEN_AND_STATE_MAP",
|
|
)
|
|
state_map = section_between(
|
|
page_text,
|
|
"Historical section source: SCREEN_AND_STATE_MAP",
|
|
"Historical section source: UI_AND_VISUAL_SYSTEM",
|
|
)
|
|
if plan is None or state_map is None:
|
|
issues.append(Issue("PAGE_SECTIONS", str(PAGE_DOCUMENT), "page plan or state-map section marker is missing"))
|
|
else:
|
|
plan_ids = set(PAGE_ID_RE.findall(plan))
|
|
state_ids = set(PAGE_ID_RE.findall(state_map))
|
|
counts["page_plan_ids"] = len(plan_ids)
|
|
counts["page_state_ids"] = len(state_ids)
|
|
if len(plan_ids) != 50:
|
|
issues.append(Issue("PAGE_PLAN_COUNT", str(PAGE_DOCUMENT), "expected 50 unique page IDs, found {}".format(len(plan_ids))))
|
|
if len(state_ids) != 50:
|
|
issues.append(Issue("PAGE_STATE_COUNT", str(PAGE_DOCUMENT), "expected 50 unique page IDs, found {}".format(len(state_ids))))
|
|
if plan_ids != state_ids:
|
|
missing_state = sorted(plan_ids - state_ids)
|
|
missing_plan = sorted(state_ids - plan_ids)
|
|
issues.append(Issue("PAGE_ID_MISMATCH", str(PAGE_DOCUMENT), "missing from state={} missing from plan={}".format(missing_state, missing_plan)))
|
|
|
|
content = loaded.get(CONTENT_DOCUMENT, "")
|
|
content_contracts = (
|
|
("base_professions", r"^\| `(PF-B\d{2})`", {"PF-B{:02d}".format(i) for i in range(1, 13)}),
|
|
("advanced_professions", r"^\| `(PF-A\d{2})`", {"PF-A{:02d}".format(i) for i in range(1, 25)}),
|
|
("hidden_professions", r"^\| `(PF-H\d{2})`", {"PF-H{:02d}".format(i) for i in range(1, 7)}),
|
|
("traits", r"^\| `(TR-\d{3})`", expected_ids("TR", 36)),
|
|
("equipment", r"^\| `(EQ-\d{3})`", expected_ids("EQ", 320)),
|
|
("artifacts", r"^\| `(AR-\d{3})`", expected_ids("AR", 60)),
|
|
("enemies", r"^\| `(EN-R\d-\d{2})`", {"EN-R{}-{:02d}".format(region, row) for region in range(1, 9) for row in range(1, 13 if region <= 4 else 14)}),
|
|
("bosses", r"^\| `(BO-R\d-\d{2})`", {"BO-R{}-{:02d}".format(region, row) for region in range(1, 9) for row in range(1, 3)}),
|
|
("regions", r"^\| `(RG-\d{2})`", expected_ids("RG", 8, width=2)),
|
|
)
|
|
for name, pattern, expected in content_contracts:
|
|
actual = unique_matches(content, pattern)
|
|
counts[name] = len(actual)
|
|
if actual != expected:
|
|
missing = sorted(expected - actual)
|
|
extra = sorted(actual - expected)
|
|
issues.append(Issue("CONTENT_IDS", str(CONTENT_DOCUMENT), "{} missing={} extra={}".format(name, missing, extra)))
|
|
counts["professions"] = counts.get("base_professions", 0) + counts.get("advanced_professions", 0) + counts.get("hidden_professions", 0)
|
|
|
|
for required_path, code in (
|
|
(APPROVED_BATTLE_REFERENCE, "APPROVED_REFERENCE"),
|
|
(ANTI_DOMINANCE_FIXTURE, "ANTI_DOMINANCE_FIXTURE"),
|
|
):
|
|
if not (root / required_path).is_file():
|
|
issues.append(Issue(code, str(required_path), "required non-Markdown authority is missing"))
|
|
|
|
return issues, counts
|
|
|
|
|
|
def run_self_test(root: Path) -> None:
|
|
issues, counts = audit(root)
|
|
if issues:
|
|
raise AssertionError("repository fixture failed: {}".format([item.as_dict() for item in issues]))
|
|
checks = (
|
|
counts.get("documents") == 6,
|
|
counts.get("historical_sections") == len(HISTORICAL_SECTION_MARKERS),
|
|
counts.get("page_plan_ids") == 50,
|
|
counts.get("page_state_ids") == 50,
|
|
counts.get("professions") == 42,
|
|
counts.get("equipment") == 320,
|
|
counts.get("enemies") == 100,
|
|
)
|
|
if not all(checks):
|
|
raise AssertionError("self-test count contract failed: {}".format(counts))
|
|
print("ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases={}".format(len(checks)))
|
|
|
|
|
|
def parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("root", nargs="?", type=Path, default=REPOSITORY_ROOT)
|
|
parser.add_argument("--json", action="store_true", help="emit deterministic JSON")
|
|
parser.add_argument("--self-test", action="store_true", help="verify the repository fixture and count contract")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
arguments = parse_arguments(argv)
|
|
root = arguments.root.resolve()
|
|
if arguments.self_test:
|
|
run_self_test(root)
|
|
return 0
|
|
issues, counts = audit(root)
|
|
if arguments.json:
|
|
print(json.dumps({"ok": not issues, "errors": len(issues), "counts": counts, "diagnostics": [item.as_dict() for item in issues]}, indent=2, sort_keys=True))
|
|
else:
|
|
for issue in issues:
|
|
print("ERROR {} {} {}".format(issue.code, issue.path, issue.message))
|
|
print("COUNTS {}".format(json.dumps(counts, sort_keys=True, separators=(",", ":"))))
|
|
if issues:
|
|
print("ABG_PREDEVELOPMENT_DESIGN_FAILED errors={}".format(len(issues)))
|
|
else:
|
|
print("ABG_PREDEVELOPMENT_DESIGN_OK errors=0 docs=6 pages=50")
|
|
return 1 if issues else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|