feat(p2-47): author statistics.tscn wrapper so the 5-tab modal opens

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) <noreply@anthropic.com>
This commit is contained in:
autocommit 2026-06-04 15:50:00 -07:00
parent 5503f04b25
commit 843db4116a
5 changed files with 147 additions and 8 deletions

View file

@ -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<ClanId, u32>` 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.

View file

@ -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")

View file

@ -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))

View file

@ -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")

View file

@ -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")