From e6a2a067aa81a7625750679d08bc264789c2c2b0 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 17 Apr 2026 00:19:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20objectives=20dashboard=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/objectives/README.md | 58 +++++++ .project/objectives/p2-05-turn-latency.md | 20 +++ run | 2 + scripts/run/dev.sh | 109 ++++++++++-- tools/objectives-report.py | 192 ++++++++++++++++++++++ 5 files changed, 366 insertions(+), 15 deletions(-) create mode 100644 .project/objectives/README.md create mode 100644 .project/objectives/p2-05-turn-latency.md create mode 100644 tools/objectives-report.py diff --git a/.project/objectives/README.md b/.project/objectives/README.md new file mode 100644 index 00000000..07085068 --- /dev/null +++ b/.project/objectives/README.md @@ -0,0 +1,58 @@ +# Objectives โ€” Dashboard + +> **Generated by `tools/objectives-report.py` โ€” do not hand-edit.** Source of truth is per-file YAML frontmatter in this directory. + +## Legend + +โœ… done ยท ๐ŸŸก partial ยท ๐Ÿ”ด stub ยท โŒ missing ยท โšซ out-of-scope (Game 2) + +## Totals + +| Status | Count | +|---|---| +| โœ… done | 0 | +| ๐ŸŸก partial | 19 | +| ๐Ÿ”ด stub | 1 | +| โŒ missing | 4 | +| โšซ oos | 0 | +| **total** | **24** | + +## P0 โ€” Blockers for "completely playable" + +| ID | Status | Title | Updated | +|---|---|---|---| +| [p0-01](p0-01-mcts-wiring.md) | ๐ŸŸก partial | Wire MCTS into gameplay AI | 2026-04-17 | +| [p0-02](p0-02-clan-personalities.md) | ๐ŸŸก partial | Five AI clan personalities drive distinct playstyles | 2026-04-17 | +| [p0-03](p0-03-pvp-in-turn.md) | ๐ŸŸก partial | PvP combat resolved inside the authoritative turn processor | 2026-04-17 | +| [p0-04](p0-04-wonder-tracking.md) | ๐ŸŸก partial | World wonder tracking in PlayerState and score victory | 2026-04-17 | +| [p0-05](p0-05-culture-and-borders.md) | ๐Ÿ”ด stub | Culture generation and border expansion | 2026-04-17 | +| [p0-06](p0-06-economy-integration.md) | ๐ŸŸก partial | Fold gold income / upkeep / improvement yields into turn loop | 2026-04-17 | +| [p0-07](p0-07-tech-research-costs.md) | ๐ŸŸก partial | Tech research costs and science pool pacing | 2026-04-17 | +| [p0-08](p0-08-domination-victory.md) | ๐ŸŸก partial | Domination victory path in mc-turn::victory | 2026-04-17 | +| [p0-09](p0-09-ui-completeness.md) | ๐ŸŸก partial | City-screen UI completeness (citizen assign, queue controls, promotion picker) | 2026-04-17 | +| [p0-10](p0-10-completion-stability.md) | ๐ŸŸก partial | Game-completion stability โ€” โ‰ฅ7/10 seeds declare a winner | 2026-04-17 | +| [p0-11](p0-11-mystery-item-authoring.md) | โŒ missing | Author the four T8โ€“T10 mystery item drops | 2026-04-17 | + +## P1 โ€” Ship-readiness + +| ID | Status | Title | Updated | +|---|---|---|---| +| [p1-01](p1-01-diplomacy-lite.md) | ๐ŸŸก partial | Diplomacy-lite โ€” peace/war toggle plus one trade action | 2026-04-17 | +| [p1-02](p1-02-strategic-resource-yields.md) | ๐ŸŸก partial | Strategic resource yields feed into production bonuses | 2026-04-17 | +| [p1-03](p1-03-tutorial-overlay.md) | โŒ missing | First-run tutorial / onboarding overlay | 2026-04-17 | +| [p1-04](p1-04-sound-and-music.md) | โŒ missing | Sound effects and music | 2026-04-17 | +| [p1-05](p1-05-balance-tuning.md) | ๐ŸŸก partial | Balance tuning โ€” pop_peak โ‰ฅ30 median, worker improvements โ‰ฅ8 min | 2026-04-17 | +| [p1-06](p1-06-options-polish.md) | ๐ŸŸก partial | Options screen polish | 2026-04-17 | +| [p1-07](p1-07-chronicle-coverage.md) | ๐ŸŸก partial | Chronicle notifications coverage | 2026-04-17 | +| [p1-08](p1-08-victory-screen-content.md) | ๐ŸŸก partial | Victory/defeat screen content โ€” recap, banner, replay seed | 2026-04-17 | + +## P2 โ€” Polish + +| ID | Status | Title | Updated | +|---|---|---|---| +| [p2-01](p2-01-minimap-improvements.md) | ๐ŸŸก partial | Minimap โ€” fog reflection and unit markers | 2026-04-17 | +| [p2-02](p2-02-hud-tooltips.md) | ๐ŸŸก partial | Tooltips on all HUD elements | 2026-04-17 | +| [p2-03](p2-03-hotkey-cheat-sheet.md) | โŒ missing | Hotkey cheat sheet (F1 / ?) | 2026-04-17 | +| [p2-04](p2-04-localization-audit.md) | ๐ŸŸก partial | Localization audit โ€” no hardcoded strings | 2026-04-17 | +| [p2-05](p2-05-turn-latency.md) | ๐ŸŸก partial | Sub-second single-player turn latency | 2026-04-17 | + diff --git a/.project/objectives/p2-05-turn-latency.md b/.project/objectives/p2-05-turn-latency.md new file mode 100644 index 00000000..5fc4106b --- /dev/null +++ b/.project/objectives/p2-05-turn-latency.md @@ -0,0 +1,20 @@ +--- +id: p2-05 +title: Sub-second single-player turn latency +priority: p2 +status: partial +scope: game1 +updated_at: 2026-04-17 +evidence: + - tools/autoplay-batch.sh +--- + +## Summary + +10-seed parallel batch completes in ~7 minutes wall-clock; single-turn latency on the RUN host is unmeasured. Target: end-of-turn processing โ‰ค1 second on a 512-tile map with 3 AI opponents mid-game. + +## Acceptance + +- `tools/measure-turn-latency.py` (new) profiles 300 turns and emits `p50 / p90 / p99` latency. +- p99 โ‰ค 1.0 s for the 3-AI normal difficulty 512-tile scenario. +- Flame graph under `.project/reports/latency/` if regressions appear. diff --git a/run b/run index e5312406..10e80f5a 100755 --- a/run +++ b/run @@ -25,6 +25,7 @@ usage() { validate Validate game data JSON files against schemas" echo " format Format all (GDScript + Rust fmt + ESLint fix)" echo " test Run GUT + Rust + vitest" + echo " test:golden Cross-language golden-vector parity (Rust + WASM + GDExt)" echo " verify Full pipeline: lint + typecheck + cargo check + tests" echo " screenshot [name] [scene] [delay] Capture screenshot" echo " autoplay [seed] Run single seeded auto_play game + report (opt-in)" @@ -72,6 +73,7 @@ case "$COMMAND" in verify) cmd_verify "$@" ;; format) cmd_format "$@" ;; test) cmd_test "$@" ;; + test:golden) cmd_test_golden "$@" ;; screenshot) cmd_screenshot "$@" ;; autoplay) cmd_autoplay "$@" ;; autoplay-batch) cmd_autoplay_batch "$@" ;; diff --git a/scripts/run/dev.sh b/scripts/run/dev.sh index 3a10124c..2ff2dd29 100644 --- a/scripts/run/dev.sh +++ b/scripts/run/dev.sh @@ -192,19 +192,25 @@ cmd_verify() { } # Step 0 โ€” Game data schema validation - _verify_step 0 8 "game data JSON schemas" \ + _verify_step 0 9 "game data JSON schemas" \ python3 "$REPO_ROOT/tools/validate-game-data.py" - # Step 1 โ€” Rust build - _verify_step 1 8 "cargo build --workspace" \ + # Step 1 โ€” Objectives dashboard freshness + # Fails if .project/objectives/README.md is stale vs the per-objective + # frontmatter. Run `python3 tools/objectives-report.py` to regenerate. + _verify_step 1 9 "objectives dashboard up-to-date" \ + python3 "$REPO_ROOT/tools/objectives-report.py" --check + + # Step 2 โ€” Rust build + _verify_step 2 9 "cargo build --workspace" \ _verify_run_in_dir "$SIMULATOR_DIR" cargo build --workspace - # Step 2 โ€” Rust tests - _verify_step 2 8 "cargo test --workspace" \ + # Step 3 โ€” Rust tests + _verify_step 3 9 "cargo test --workspace" \ _verify_run_in_dir "$SIMULATOR_DIR" cargo test --workspace - # Step 3 โ€” Rust clippy - _verify_step 3 8 "cargo clippy --workspace -D warnings" \ + # Step 4 โ€” Rust clippy + _verify_step 4 9 "cargo clippy --workspace -D warnings" \ _verify_run_in_dir "$SIMULATOR_DIR" cargo clippy --workspace -- -D warnings # Apply project-local gdlint config before linting. @@ -215,20 +221,20 @@ cmd_verify() { # .project/gdlintrc.local is the source of truth โ€” copy it over before lint. cp "$REPO_ROOT/.project/gdlintrc.local" "$REPO_ROOT/gdlintrc" 2>/dev/null - # Step 4 โ€” GDScript lint: engine/src/ - _verify_step 4 8 "gdlint engine/src/" \ + # Step 5 โ€” GDScript lint: engine/src/ + _verify_step 5 9 "gdlint engine/src/" \ gdlint "$GAME_DIR/engine/src/" - # Step 5 โ€” GDScript lint: scenes/tests/ - _verify_step 5 8 "gdlint engine/scenes/tests/" \ + # Step 6 โ€” GDScript lint: scenes/tests/ + _verify_step 6 9 "gdlint engine/scenes/tests/" \ gdlint "$GAME_DIR/engine/scenes/tests/" - # Step 6 โ€” GDScript lint: tests/integration/ - _verify_step 6 8 "gdlint engine/tests/integration/" \ + # Step 7 โ€” GDScript lint: tests/integration/ + _verify_step 7 9 "gdlint engine/tests/integration/" \ gdlint "$GAME_DIR/engine/tests/integration/" - # Step 7 โ€” Godot headless boot: GDExtension + script compilation - _verify_step 7 8 "godot headless boot (no script errors)" \ + # Step 8 โ€” Godot headless boot: GDExtension + script compilation + _verify_step 8 9 "godot headless boot (no script errors)" \ _godot_headless_boot _verify_summary @@ -261,6 +267,79 @@ cmd_guide() { pnpm --prefix "$GUIDE_DIR" dev } +cmd_test_golden() { + ## Cross-language golden-vector parity gate. + ## + ## Each fixture in src/simulator/tests/golden/vectors/*.json is consumed by + ## three runners that MUST produce bitwise-identical output. Divergence = + ## release blocker (FFI marshaling / non-determinism / SOT violation). + ## + ## See src/simulator/tests/golden/README.md for the fixture shape and + ## ~/.claude/instructions/rust-code-standards.md ยง"Testing Strategy" for rationale. + + local vectors_dir="$SIMULATOR_DIR/tests/golden/vectors" + local exit_code=0 + + if [ ! -d "$vectors_dir" ]; then + echo -e "${RED}Golden vectors directory missing: $vectors_dir${NC}" + return 1 + fi + + local vectors + vectors=$(find "$vectors_dir" -maxdepth 1 -name '*.json' -type f | sort) + + if [ -z "$vectors" ]; then + echo -e "${YELLOW}No golden vectors yet โ€” add JSON fixtures to:${NC}" + echo -e " $vectors_dir" + echo -e "${YELLOW}See $SIMULATOR_DIR/tests/golden/README.md for the fixture shape.${NC}" + return 0 + fi + + local count + count=$(echo "$vectors" | wc -l | tr -d ' ') + echo -e "${BLUE}Found $count golden vector(s) โ€” running 3-consumer parity check${NC}" + echo "" + + # Consumer 1: Rust native + echo -e "${BLUE}[1/3] Rust native consumer (cargo test --test golden)${NC}" + if ! (cd "$SIMULATOR_DIR" && cargo test --workspace --test golden 2>&1); then + echo -e "${RED}FAIL: Rust golden tests${NC}" + exit_code=1 + fi + echo "" + + # Consumer 2: WASM via Vitest (guide simulation worker) + echo -e "${BLUE}[2/3] WASM consumer (pnpm test โ€” golden suite)${NC}" + if ! pnpm --prefix "$GUIDE_DIR" test -- --run golden 2>&1; then + echo -e "${RED}FAIL: WASM golden tests${NC}" + exit_code=1 + fi + echo "" + + # Consumer 3: GDExtension via headless Godot + GUT + echo -e "${BLUE}[3/3] GDExtension consumer (headless Godot + GUT ffi/)${NC}" + local ffi_dir="$GAME_DIR/engine/tests/ffi" + if [ -d "$ffi_dir" ] && [ -n "$(find "$ffi_dir" -maxdepth 1 -name 'test_golden_*.gd' -print -quit 2>/dev/null)" ]; then + WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" \ + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ + $GODOT_BIN --path "$GAME_DIR" --headless \ + --script res://addons/gut/gut_cmdln.gd \ + -gdir=res://engine/tests/ffi -gprefix=test_golden_ -gexit 2>&1 \ + || exit_code=$? + else + echo -e "${YELLOW}SKIP: No GDExt golden tests yet at $ffi_dir/test_golden_*.gd${NC}" + fi + echo "" + + if [ $exit_code -eq 0 ]; then + echo -e "${GREEN}All 3 consumers agree on $count vector(s)${NC}" + else + echo -e "${RED}Divergence detected โ€” release blocker${NC}" + echo -e "${RED}See src/simulator/tests/golden/README.md for triage guidance${NC}" + fi + return $exit_code +} + cmd_autoplay() { # Single-seed fast feedback: ./run autoplay [seed] local seed="${1:-1}" diff --git a/tools/objectives-report.py b/tools/objectives-report.py new file mode 100644 index 00000000..d3a2bdd9 --- /dev/null +++ b/tools/objectives-report.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Regenerate .project/objectives/README.md from per-objective frontmatter. + +SSoT: each .project/objectives/p[012]-NN-*.md file carries its own status in +YAML frontmatter. This script reads every such file, validates the fields, and +emits a grouped dashboard. The dashboard is regenerated, not hand-edited. + +Usage: + python3 tools/objectives-report.py # write README.md + python3 tools/objectives-report.py --check # exit 1 if README.md is stale +""" +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +OBJ_DIR = REPO / ".project" / "objectives" +OUT = OBJ_DIR / "README.md" + +VALID_STATUS = {"done", "partial", "stub", "missing", "oos"} +VALID_PRIORITY = {"p0", "p1", "p2"} +VALID_SCOPE = {"game1", "game2"} + +STATUS_ICON = { + "done": "โœ…", + "partial": "๐ŸŸก", + "stub": "๐Ÿ”ด", + "missing": "โŒ", + "oos": "โšซ", +} + + +@dataclass(frozen=True) +class Objective: + id: str + title: str + priority: str + status: str + scope: str + updated_at: str + path: Path + + +def parse_frontmatter(text: str, path: Path) -> dict[str, str]: + """Parse a minimal YAML frontmatter block (flat key: value pairs). + + We deliberately avoid a PyYAML dependency โ€” frontmatter here is flat + `key: value` plus occasional list fields we don't need to index. + """ + if not text.startswith("---\n"): + raise ValueError(f"{path}: missing frontmatter opener '---'") + end = text.find("\n---\n", 4) + if end == -1: + raise ValueError(f"{path}: missing frontmatter closer '---'") + body = text[4:end] + out: dict[str, str] = {} + current_key: str | None = None + for raw in body.splitlines(): + line = raw.rstrip() + if not line or line.lstrip().startswith("#"): + continue + # List-item continuation (we ignore list values for now) + if line.startswith((" ", "\t", "-")): + current_key = None + continue + if ":" not in line: + raise ValueError(f"{path}: malformed frontmatter line: {raw!r}") + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if value: + out[key] = value + current_key = key + return out + + +REQUIRED_FIELDS = ("id", "title", "priority", "status", "scope", "updated_at") + + +def load_objectives() -> list[Objective]: + out: list[Objective] = [] + for path in sorted(OBJ_DIR.glob("p[012]-*.md")): + if path.name == "README.md": + continue + text = path.read_text(encoding="utf-8") + fm = parse_frontmatter(text, path) + missing = [k for k in REQUIRED_FIELDS if k not in fm] + if missing: + raise ValueError(f"{path}: missing frontmatter keys: {missing}") + if fm["priority"] not in VALID_PRIORITY: + raise ValueError(f"{path}: invalid priority {fm['priority']!r}") + if fm["status"] not in VALID_STATUS: + raise ValueError(f"{path}: invalid status {fm['status']!r}") + if fm["scope"] not in VALID_SCOPE: + raise ValueError(f"{path}: invalid scope {fm['scope']!r}") + out.append(Objective( + id=fm["id"], + title=fm["title"], + priority=fm["priority"], + status=fm["status"], + scope=fm["scope"], + updated_at=fm["updated_at"], + path=path, + )) + out.sort(key=lambda o: (o.priority, o.id)) + return out + + +def render(objectives: list[Objective]) -> str: + by_priority: dict[str, list[Objective]] = {"p0": [], "p1": [], "p2": []} + for o in objectives: + by_priority[o.priority].append(o) + + counts = {s: sum(1 for o in objectives if o.status == s) for s in VALID_STATUS} + total = len(objectives) + + lines: list[str] = [] + lines.append("# Objectives โ€” Dashboard") + lines.append("") + lines.append( + "> **Generated by `tools/objectives-report.py` โ€” do not hand-edit.** " + "Source of truth is per-file YAML frontmatter in this directory." + ) + lines.append("") + lines.append("## Legend") + lines.append("") + lines.append("โœ… done ยท ๐ŸŸก partial ยท ๐Ÿ”ด stub ยท โŒ missing ยท โšซ out-of-scope (Game 2)") + lines.append("") + lines.append("## Totals") + lines.append("") + lines.append("| Status | Count |") + lines.append("|---|---|") + for status in ("done", "partial", "stub", "missing", "oos"): + lines.append(f"| {STATUS_ICON[status]} {status} | {counts[status]} |") + lines.append(f"| **total** | **{total}** |") + lines.append("") + + priority_heading = { + "p0": "P0 โ€” Blockers for \"completely playable\"", + "p1": "P1 โ€” Ship-readiness", + "p2": "P2 โ€” Polish", + } + for prio in ("p0", "p1", "p2"): + group = by_priority[prio] + if not group: + continue + lines.append(f"## {priority_heading[prio]}") + lines.append("") + lines.append("| ID | Status | Title | Updated |") + lines.append("|---|---|---|---|") + for o in group: + link = f"[{o.id}]({o.path.name})" + icon = STATUS_ICON[o.status] + lines.append(f"| {link} | {icon} {o.status} | {o.title} | {o.updated_at} |") + lines.append("") + + return "\n".join(lines) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--check", + action="store_true", + help="Exit 1 if README.md is stale instead of writing", + ) + args = ap.parse_args() + + try: + objectives = load_objectives() + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + + rendered = render(objectives) + if args.check: + existing = OUT.read_text(encoding="utf-8") if OUT.exists() else "" + if existing != rendered: + print(f"stale: {OUT.relative_to(REPO)} โ€” run tools/objectives-report.py", file=sys.stderr) + return 1 + return 0 + + OUT.write_text(rendered, encoding="utf-8") + print(f"wrote {OUT.relative_to(REPO)} ({len(objectives)} objectives)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())