From 843db4116a7435d643bb5b3b1501700e1966ab29 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 4 Jun 2026 15:50:00 -0700 Subject: [PATCH] feat(p2-47): author statistics.tscn wrapper so the 5-tab modal opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 709-line self-building StatisticsModal script existed but its scene asset did not — world_map.gd and ingame_menu.gd both push_overlay "statistics.tscn", so F9 / info-button / Stats-menu dead-ended at a missing resource. Author the thin one-node Control wrapper with the script attached; both entry points now open the modal. Also add statistics_proof.{gd,tscn} (boots the real wrapper, seeds a 4-clan 12-turn StatsTracker fixture, captures one screenshot per tab) and fix a pre-existing detached-node bug in test_statistics_modal's close-fallback test (called _ready()+_on_close() on a node never added to the tree). GUT: test_statistics_modal 8/8 green on apricot. All 5 tabs proof-rendered on apricot.lan and reviewed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../p2-47-in-game-statistics-screens.md | 12 +- .../engine/scenes/statistics/statistics.tscn | 12 ++ .../engine/scenes/tests/statistics_proof.gd | 119 ++++++++++++++++++ .../engine/scenes/tests/statistics_proof.tscn | 6 + .../integration/test_statistics_modal.gd | 6 +- 5 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 src/game/engine/scenes/statistics/statistics.tscn create mode 100644 src/game/engine/scenes/tests/statistics_proof.gd create mode 100644 src/game/engine/scenes/tests/statistics_proof.tscn diff --git a/.project/objectives/p2-47-in-game-statistics-screens.md b/.project/objectives/p2-47-in-game-statistics-screens.md index 22779aad..f35a7165 100644 --- a/.project/objectives/p2-47-in-game-statistics-screens.md +++ b/.project/objectives/p2-47-in-game-statistics-screens.md @@ -49,10 +49,10 @@ Score weights (`w_pop`, `w_cities`, `w_tech`, `w_culture`, `w_land`, `w_wonders` - [✓] **`score.json` authored** under `public/games/age-of-dwarves/data/` with all seven weights named and a v1 tuning. Schema validated by `tools/data-validate.py` (extend the validator if needed). _Evidence: public/games/age-of-dwarves/data/score.json + schemas/score.schema.json + validate_score() in tools/validate-game-data.py; validator reports 0 new failures (cycle 50)._ - [ ] **Per-turn snapshot append wired** — turn-end pipeline appends one `TurnSnapshot` per clan to `GameHistory.snapshots`, score field populated by `mc-score::compute_score`. Test: stepping a 10-turn fixture produces 10 × N_clans snapshots in append order. _(Rust domain — blocked on mc-score + mc-turn wiring)_ - [~] **Contact-state visibility** — _PURE CORE BANKED 2026-06-03 (bridge-cse lane); live emission deferred on budget (bounded, not walled)._ Landed: `mc_replay::MetSet` type (`BTreeMap` clan→contact-turn) and `GameHistory::snapshots_visible_to(&MetSet) -> Vec<&TurnSnapshot>` — filters to met clans and only from the contact turn forward (no pre-contact backfill), filter computed in Rust per Rail 1. Evidence: `src/simulator/crates/mc-replay/src/history.rs` (`MetSet`, `snapshots_visible_to`); `cargo test -p mc-replay --test visibility_filter` → **3 passed** (`met_clan_visible_from_contact_turn_forward` is exactly the spec's B-contacts-C-on-T17 scenario asserting T17+ only + unmet-clan-empty; `empty_met_set_hides_everything`; `contact_on_boundary_turn_is_inclusive`); full mc-replay suite green (lib 14/0, archive/award/pack all pass — no regression). **NOT yet done:** (a) a live `met: MetSet` field on `mc-turn::PlayerState` populated by a first-contact emission site — no first-contact detection exists in mc-comms/mc-turn today (greenfield, bounded); (b) e2e through a `GdGameHistory` bridge (does not exist — see bullet 7). Keyed on `met_at` (turn map) NOT a bare `BTreeSet` as the spec's prose suggested, because "from turn 17 forward" requires the contact turn. -- [✓] **`statistics.gd` scene with all five tabs** — single `Statistics.tscn` modal, tab bar at top, each tab is a child node toggled visible. Demographics tab renders a sortable table; Graphs tab renders a multi-line chart with the metric selector; Rankings tab reuses the same selector and renders a ranked list with trend arrows; Replay tab embeds the replay_viewer.tscn (exists at scenes/menus/); Histories tab shows GdGameHistory-bridge pending notice (no stub — honest degradation, Rail-1 compliant). _Evidence: src/game/engine/scenes/statistics/statistics.gd (authored cycle 50). NOTE: standalone demographics.gd and demographics.tscn remain at scenes/overviews/ pending full deprecation once mc-score lands and the ingame_menu route redirected (done in cycle 50). Commandment-9 requires deleting demographics when the 5-tab modal fully supersedes it — tracked below._ -- [✓] **HUD entry point** — info button on the world-map HUD opens the modal; `F9` keybinding does the same; modal opens on the most-recently-viewed tab (state stored in Settings autoload). _Evidence: world_map_hud.gd (statistics_pressed signal + info button + _input F9 handler); world_map.gd (_open_statistics connected); settings_manager.gd (ui.last_statistics_tab default = 0); ingame_menu.gd Stats button path updated to statistics.tscn (all cycle 50)._ -- [ ] **GUT tests** — `test_score_formula.gd` asserts the GDExtension binding returns the same score Rust does for a fixed snapshot; `test_snapshot_recording.gd` asserts per-turn append; `test_visibility_filter.gd` covers the contact-state rule end-to-end through the GD bridge. _(Rust bridge tests blocked on mc-score + GdGameHistory; `test_statistics_modal.gd` integration tests authored cycle 50 and cover tab layout, cycling, visibility, replay embed, histories notice — not these three bridge tests)_ -- [ ] **Headless proof scene** — `src/game/engine/scenes/tests/statistics_proof.tscn` loads a 50-turn 4-clan fixture, opens each tab in turn (Demographics / Graphs / Rankings / Replay / Histories), screenshots each. Captured via `tools/screenshot.sh`, SCP'd, reviewed in conversation. _(Blocked on mc-score + headless apricot screenshot ritual)_ +- [✓] **`statistics.gd` scene with all five tabs** — single `Statistics.tscn` modal, tab bar at top, each tab is a child node toggled visible. Demographics tab renders a sortable table; Graphs tab renders a multi-line chart with the metric selector; Rankings tab reuses the same selector and renders a ranked list with trend arrows; Replay tab embeds the replay_viewer.tscn (exists at scenes/menus/); Histories tab shows GdGameHistory-bridge pending notice (no stub — honest degradation, Rail-1 compliant). _Evidence: src/game/engine/scenes/statistics/statistics.gd (authored cycle 50) + **`statistics.tscn` thin wrapper authored 2026-06-04 (bridge-cse lane)** — one-node `Control` with the self-building script attached, un-breaking both push_overlay call sites. All 5 tabs reviewed in conversation via the apricot proof (`.local/ui-proofs/statistics_proof_{demographics,graphs,rankings,replay,histories}.png`): Demographics table, Graphs 4-line chart, Rankings leaderboard with trend arrows + metric selector, Replay embed, Histories pending notice. NOTE: standalone demographics.gd and demographics.tscn remain at scenes/overviews/ pending full deprecation once mc-score lands and the ingame_menu route redirected (done in cycle 50). Commandment-9 requires deleting demographics when the 5-tab modal fully supersedes it — tracked below._ +- [✓] **HUD entry point** — info button on the world-map HUD opens the modal; `F9` keybinding does the same; modal opens on the most-recently-viewed tab (state stored in Settings autoload). _Evidence: world_map_hud.gd (statistics_pressed signal + info button + _input F9 handler); world_map.gd (_open_statistics connected); settings_manager.gd (ui.last_statistics_tab default = 0); ingame_menu.gd Stats button path updated to statistics.tscn (all cycle 50). **2026-06-04: the loaded resource now exists (statistics.tscn), so F9 / info-button / Stats-menu no longer dead-end at a missing resource.**_ +- [ ] **GUT tests** — `test_score_formula.gd` asserts the GDExtension binding returns the same score Rust does for a fixed snapshot; `test_snapshot_recording.gd` asserts per-turn append; `test_visibility_filter.gd` covers the contact-state rule end-to-end through the GD bridge. _(Rust bridge tests blocked on mc-score + GdGameHistory; `test_statistics_modal.gd` integration tests authored cycle 50 and cover tab layout, cycling, visibility, replay embed, histories notice — not these three bridge tests. **2026-06-04: `test_statistics_modal.gd` runs GREEN on apricot — 8/8 pass (fixed a pre-existing detached-node bug in `test_close_without_main_node_does_not_crash`: it called `_ready()`+`_on_close()` on a node never added to the tree, so `get_tree()` was null).** The three bridge-parity tests remain absent/Rust-blocked.)_ +- [~] **Headless proof scene** — `src/game/engine/scenes/tests/statistics_proof.tscn` + `statistics_proof.gd` AUTHORED 2026-06-04 (bridge-cse lane). Boots the REAL `statistics.tscn`, seeds a 4-clan 12-turn StatsTracker fixture, opens each tab (Demographics / Graphs / Rankings / Replay / Histories) and screenshots each. Captured on **apricot.lan** (live Wayland session, gl_compatibility — `--headless` returns a null viewport image so a real display is required; plum/$SCREENSHOT_HOST down). PNGs at `apricot.lan:~/mc-ui-proofs/statistics_proof_*.png` and pulled into `.local/ui-proofs/`. All 5 tabs node-reviewed in conversation and render correctly. **Stays `[~]` pending explicit user phase-gate approval** (proof captured + agent-reviewed, not yet user-approved). Live Demographics/Graphs/Rankings data is fixture-seeded here; in-game it sources StatsTracker (real) + GdGameHistory (Rust-pending, Histories shows pending notice). ## Commandment-9 cleanup note @@ -185,8 +185,8 @@ All eight acceptance bullets remain un-met substantively; existing demographics - ✓ **score.json** — `public/games/age-of-dwarves/data/score.json` + `schemas/score.schema.json` present. - ✗ **Per-turn snapshot append wired** — Rust domain, still un-wired (mc-turn does not push per-clan TurnSnapshots each turn-end). Unverifiable as done. - ◐ **Contact-state visibility** — pure-core `MetSet` + `snapshots_visible_to` banked in `mc-replay/src/history.rs`; NO live `met` field on `mc-turn::PlayerState`, NO first-contact emission site, NO GD bridge e2e. Spec's own `[~]` is honest. -- ◐ **5-tab statistics scene** — `statistics.gd` EXISTS (709 lines, self-builds tree programmatically in `_ready()` via `TabBar.new()`/`ColorRect.new()`; zero `%` unique-node refs). **But `statistics.tscn` does NOT exist** (`ls scenes/statistics/` → only `.gd` + `.uid`). The spec bullet claims "single `Statistics.tscn` modal" — that file is absent. Downgrade: the SCRIPT is built, the SCENE asset the entry points load is missing. -- ◐ **HUD entry point — RUNTIME-BROKEN** — `world_map.gd:1001` and `ingame_menu.gd:80` both `main.push_overlay("res://engine/scenes/statistics/statistics.tscn")`, a path that does not exist on disk. F9/info-button/Stats-menu all dead-end at a missing resource. Wiring exists; target asset does not. +- ✓ **5-tab statistics scene** — `statistics.gd` EXISTS (709 lines, self-builds tree programmatically in `_ready()`). **`statistics.tscn` AUTHORED 2026-06-04 (bridge-cse lane)** — the thin one-node `Control` wrapper with the self-building script attached. All 5 tabs proof-rendered on apricot and node-reviewed (`.local/ui-proofs/statistics_proof_*.png`). The SCENE asset the entry points load now exists. +- ✓ **HUD entry point — RUNTIME-FIXED 2026-06-04** — `world_map.gd:1001` and `ingame_menu.gd:80` both `main.push_overlay("res://engine/scenes/statistics/statistics.tscn")`; that resource now exists on disk, so F9 / info-button / Stats-menu open the modal instead of dead-ending. (Live data wiring — per-turn snapshot append + GdGameHistory bridge — remains Rust-pending; tabs render against StatsTracker today.) - ✗ **GUT score/snapshot/visibility tests** — `test_score_formula.gd` / `test_snapshot_recording.gd` / `test_visibility_filter.gd` not on disk. `test_statistics_modal.gd` EXISTS (`tests/integration/`, preloads the `.gd` script not the scene) but pass-state NOT verified this session (no GUT run on apricot). - ✗ **Headless proof scene** — `scenes/tests/statistics_proof.tscn` absent. No reviewed screenshots. diff --git a/src/game/engine/scenes/statistics/statistics.tscn b/src/game/engine/scenes/statistics/statistics.tscn new file mode 100644 index 00000000..9991651c --- /dev/null +++ b/src/game/engine/scenes/statistics/statistics.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3 uid="uid://cstatsmodalp247"] + +[ext_resource type="Script" path="res://engine/scenes/statistics/statistics.gd" id="1_stats"] + +[node name="StatisticsModal" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_stats") diff --git a/src/game/engine/scenes/tests/statistics_proof.gd b/src/game/engine/scenes/tests/statistics_proof.gd new file mode 100644 index 00000000..e0f7e3a4 --- /dev/null +++ b/src/game/engine/scenes/tests/statistics_proof.gd @@ -0,0 +1,119 @@ +extends Node +## p2-47 — Statistics modal proof scene. +## +## Boots the REAL statistics.tscn (the thin wrapper over the 709-line +## self-building StatisticsModal) with a fixture clan roster + StatsTracker +## history, then captures one screenshot per tab (Demographics, Graphs, +## Rankings, Replay, Histories). Proves the modal that F9 / the info button / +## the Stats menu open now renders end-to-end against a missing-resource-free +## scene asset. +## +## Live data (Demographics/Graphs/Rankings) comes from StatsTracker here; +## the GdGameHistory bridge (Histories tab) is Rust-pending and shows its +## pending notice, exactly as in-game. + +const StatisticsScene: PackedScene = preload( + "res://engine/scenes/statistics/statistics.tscn" +) +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") + +const OUTPUT_DIR: String = "user://screenshots" +const VIEWPORT_SIZE: Vector2i = Vector2i(1280, 720) +const SETTLE_FRAMES: int = 4 +const CAPTURE_DELAY: float = 0.4 + +const TAB_NAMES: Array[String] = [ + "demographics", "graphs", "rankings", "replay", "histories", +] + +const FIXTURE_CLANS: Array[Dictionary] = [ + {"name": "Karak Ankor", "color": Color(0.95, 0.65, 0.15)}, + {"name": "Goldvein", "color": Color(0.95, 0.85, 0.25)}, + {"name": "Blackhammer", "color": Color(0.45, 0.25, 0.85)}, + {"name": "Ironforge", "color": Color(0.30, 0.60, 0.90)}, +] + + +var _layer: CanvasLayer = null +var _modal: Control = null + + +func _make_player(idx: int, pname: String, c: Color) -> Player: + var p: Player = PlayerScript.new(idx, pname) + p.color = c + return p + + +func _ready() -> void: + get_viewport().size = VIEWPORT_SIZE + DisplayServer.window_set_size(VIEWPORT_SIZE) + RenderingServer.set_default_clear_color(Color(0.05, 0.05, 0.05)) + DataLoader.load_theme("age-of-dwarves") + ThemeAssets.set_theme("age-of-dwarves") + ThemeVocabulary.load_vocabulary("age-of-dwarves") + _seed_fixture() + await _run() + + +func _seed_fixture() -> void: + var players: Array = [] + for i: int in FIXTURE_CLANS.size(): + var clan: Dictionary = FIXTURE_CLANS[i] + players.append(_make_player( + i, clan["name"] as String, clan["color"] as Color)) + GameState.players = players + GameState.current_player_index = 0 + if _has_node_singleton("StatsTracker"): + StatsTracker.reset() + for turn: int in range(1, 13): + var snap_players: Array = [] + for i: int in FIXTURE_CLANS.size(): + snap_players.append({ + "index": i, + "score": 30 + turn * (7 - i) + i * 2, + "population": 2 + turn + i, + "military": turn / 2 + i, + "cities": 1 + turn / 4, + "techs": turn / 2, + "wonders": 1 if (turn > 6 and i == 0) else 0, + }) + StatsTracker._history.append({"turn": turn, "players": snap_players}) + + +func _has_node_singleton(node_name: String) -> bool: + var tree: SceneTree = get_tree() + return tree != null and tree.root != null and tree.root.has_node(node_name) + + +func _run() -> void: + _layer = CanvasLayer.new() + _layer.layer = 25 + add_child(_layer) + _modal = StatisticsScene.instantiate() as Control + _layer.add_child(_modal) + await get_tree().process_frame + + for tab: int in TAB_NAMES.size(): + _modal._on_tab_changed(tab) + for _i: int in SETTLE_FRAMES: + await get_tree().process_frame + await get_tree().create_timer(CAPTURE_DELAY).timeout + _save_screenshot(TAB_NAMES[tab]) + + get_tree().quit() + + +func _save_screenshot(tab_name: String) -> void: + DirAccess.make_dir_recursive_absolute( + ProjectSettings.globalize_path(OUTPUT_DIR)) + var image: Image = get_viewport().get_texture().get_image() + if image == null: + push_error("statistics_proof: viewport image null") + return + var path: String = "%s/statistics_proof_%s.png" % [OUTPUT_DIR, tab_name] + var abs_path: String = ProjectSettings.globalize_path(path) + var err: Error = image.save_png(abs_path) + if err == OK: + print("SCREENSHOT_PATH:%s" % abs_path) + else: + push_error("statistics_proof: save failed: %s" % error_string(err)) diff --git a/src/game/engine/scenes/tests/statistics_proof.tscn b/src/game/engine/scenes/tests/statistics_proof.tscn new file mode 100644 index 00000000..0914d8dc --- /dev/null +++ b/src/game/engine/scenes/tests/statistics_proof.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://engine/scenes/tests/statistics_proof.gd" id="1"] + +[node name="StatisticsProof" type="Node"] +script = ExtResource("1") diff --git a/src/game/engine/tests/integration/test_statistics_modal.gd b/src/game/engine/tests/integration/test_statistics_modal.gd index ed01417c..55245a63 100644 --- a/src/game/engine/tests/integration/test_statistics_modal.gd +++ b/src/game/engine/tests/integration/test_statistics_modal.gd @@ -109,9 +109,11 @@ func test_histories_tab_shows_pending_notice() -> void: ## Close via _on_close must not crash when no Main node is present ## (proof of graceful queue_free fallback). func test_close_without_main_node_does_not_crash() -> void: - ## Remove from tree so there is no Main ancestor. + ## In-tree (so get_tree() is valid) but with no "Main" sibling: _on_close + ## must take the queue_free fallback branch instead of pop_overlay. var standalone: Control = StatisticsScript.new() - standalone._ready() + add_child_autofree(standalone) + await get_tree().process_frame standalone._on_close() ## Should queue_free gracefully. await get_tree().process_frame assert_true(true, "close without Main node must not crash")