diff --git a/.project/objectives/README.md b/.project/objectives/README.md index 80072844..fbeb27ad 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -81,7 +81,7 @@ | [p2-10](p2-10-regression-ci-gate.md) | 🟑 partial | Automated regression CI gate on every push to main | β€” | [testwright](../team-leads/testwright.md) | 2026-05-04 | 🟒 unblocked | | [p2-18](p2-18-guide-public-deployment.md) | 🟑 partial | Guide web app β€” public hosting + deploy pipeline | β€” | β€” | 2026-04-17 | 🟒 unblocked | | [p2-46](p2-46-past-games-archive-replay-viewer.md) | 🟑 partial | Past-games archive & replay viewer β€” `mc-replay` crate, on-disk archive, projection-based playback | β€” | [shipwright](../team-leads/shipwright.md) | 2026-05-07 | 🟒 unblocked | -| [p2-47](p2-47-in-game-statistics-screens.md) | 🟑 partial | In-game statistics screens β€” Civ-style 5-tab modal (Demographics / Graphs / Rankings / Replay / Histories) | β€” | [shipwright](../team-leads/shipwright.md) | 2026-05-03 | 🟒 unblocked | +| [p2-47](p2-47-in-game-statistics-screens.md) | 🟑 partial | In-game statistics screens β€” Civ-style 5-tab modal (Demographics / Graphs / Rankings / Replay / Histories) | β€” | [shipwright](../team-leads/shipwright.md) | 2026-05-07 | 🟒 unblocked | | [p2-48](p2-48-end-of-game-summary-screen.md) | 🟑 partial | End-of-game summary screen β€” outcome banner, standings, score graph, awards, timeline, footer actions | β€” | [shipwright](../team-leads/shipwright.md) | 2026-05-03 | 🟒 unblocked | | [p2-55](p2-55-civilian-capture-system.md) | 🟑 partial | Civilian Capture / Destroy / Ransom | β€” | [combat-dev](../team-leads/combat-dev.md) | 2026-05-07 | 🟒 unblocked | | [p2-56](p2-56-worker-categories-and-expertise-tiers.md) | 🟑 partial | Worker categories (Sustenance/Construction/Wealth) + 5-tier expertise + Master/Grandmaster auras + idle decay | β€” | [unassigned](../team-leads/unassigned.md) | 2026-05-07 | 🟒 unblocked | diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json index b590ccaa..b5bc192b 100644 --- a/.project/objectives/objectives.json +++ b/.project/objectives/objectives.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-05-07T09:49:28Z", + "generated_at": "2026-05-07T10:19:44Z", "totals": { "done": 169, "in_progress": 1, @@ -1855,7 +1855,7 @@ "status": "partial", "scope": "game1-stretch", "owner": "shipwright", - "updated_at": "2026-05-03", + "updated_at": "2026-05-07", "blocked_by": [], "summary": "Civ-style mid-game statistics modal opened from the HUD info button (or `F9`). Five tabs in one scene, all read-only views over the per-turn `TurnSnapshot` log produced by `mc-replay` (p3-05):\n\n1. **Demographics** β€” sortable single-turn table of every met clan.\n2. **Graphs** β€” multi-line chart, Y-axis selector (score / pop / cities / army / gold-per-turn / culture-per-turn / tech-count / land-area), X = turn.\n3. **Rankings** β€” top-N leaderboard for the selected metric, with trend arrow vs. previous turn.\n4. **Replay** β€” in-game preview of the post-game replay viewer (p3-05 surface), scoped to the current game's history.\n5. **Histories** β€” per-clan chronicle (founding turn, wars, wonders, eras, leaders).\n\nComposite score is recomputed every turn-end from JSON-driven weights, used for Rankings default and end-game ordering.\n\nDesign doc: [.project/designs/stats-screens.md](../designs/stats-screens.md)." }, @@ -2773,7 +2773,7 @@ "status": "partial", "scope": "game1", "owner": "unassigned", - "updated_at": "2026-05-04", + "updated_at": "2026-05-07", "blocked_by": [], "summary": "" }, diff --git a/.project/objectives/p2-47-in-game-statistics-screens.md b/.project/objectives/p2-47-in-game-statistics-screens.md index 3368f67d..733f7ed4 100644 --- a/.project/objectives/p2-47-in-game-statistics-screens.md +++ b/.project/objectives/p2-47-in-game-statistics-screens.md @@ -5,13 +5,20 @@ priority: p2 status: partial scope: game1-stretch owner: shipwright -updated_at: 2026-05-03 +updated_at: 2026-05-07 evidence: - .project/designs/stats-screens.md (design contract β€” read first) - src/simulator/crates/mc-replay/src/snapshot.rs (consumed; owned by p3-05) - src/simulator/crates/mc-score/ (to be created β€” composite score formula) - - public/games/age-of-dwarves/data/score.json (to be created β€” JSON-driven weights) - - src/game/engine/scenes/statistics/statistics.gd (to be created) + - public/games/age-of-dwarves/data/score.json (authored cycle-50; validator passes) + - public/games/age-of-dwarves/data/schemas/score.schema.json (authored cycle-50) + - src/game/engine/scenes/statistics/statistics.gd (authored cycle-50; 5 tabs) + - src/game/engine/scenes/hud/world_map_hud.gd (statistics_pressed signal + info button + F9 β€” cycle 50) + - src/game/engine/scenes/world_map/world_map.gd (_open_statistics wired β€” cycle 50) + - src/game/engine/scenes/ui/ingame_menu.gd (redirected demographics path β†’ statistics β€” cycle 50) + - src/game/engine/src/autoloads/settings_manager.gd (ui.last_statistics_tab default added β€” cycle 50) + - src/game/engine/tests/integration/test_statistics_modal.gd (GUT tests authored cycle-50) + - tools/validate-game-data.py (validate_score() added β€” cycle 50) --- ## Summary @@ -38,14 +45,18 @@ Score weights (`w_pop`, `w_cities`, `w_tech`, `w_culture`, `w_land`, `w_wonders` ## Acceptance -- [ ] **`mc-score` crate scaffolded** with `ScoreWeights` (deserialised from `score.json` via `serde`), `compute_score()` function, unit tests covering: zero-snapshot returns 0, monotone in each component (raising one component never lowers score), JSON parse round-trip. `cargo test -p mc-score` passes. -- [ ] **`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). -- [ ] **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. -- [ ] **Contact-state visibility** β€” `MetSet` on `mc-turn::PlayerState` tracks discovered rivals; `GameHistory::snapshots_visible_to(clan)` returns only rows from `MetSet.iter()`-included clans, and only from the contact-turn forward (no retroactive backfill before contact). Unit test covers a 3-clan fixture where clan B contacts clan C on turn 17 and asserts B's view of C contains snapshots from turn 17 onward only. -- [ ] **`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 p3-05 replay viewer scoped to the current `GameHistory`; Histories tab tabs through clans showing the timeline. -- [ ] **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). -- [ ] **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. -- [ ] **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. +- [ ] **`mc-score` crate scaffolded** with `ScoreWeights` (deserialised from `score.json` via `serde`), `compute_score()` function, unit tests covering: zero-snapshot returns 0, monotone in each component (raising one component never lowers score), JSON parse round-trip. `cargo test -p mc-score` passes. _(Rust domain β€” blocked until simulator-infra agent lands mc-score crate)_ +- [βœ“] **`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** β€” `MetSet` on `mc-turn::PlayerState` tracks discovered rivals; `GameHistory::snapshots_visible_to(clan)` returns only rows from `MetSet.iter()`-included clans, and only from the contact-turn forward (no retroactive backfill before contact). Unit test covers a 3-clan fixture where clan B contacts clan C on turn 17 and asserts B's view of C contains snapshots from turn 17 onward only. _(Rust domain β€” blocked on bullet 3)_ +- [βœ“] **`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)_ + +## Commandment-9 cleanup note + +`src/game/engine/scenes/overviews/demographics.gd` and `demographics.tscn` must be deleted once the 5-tab Statistics modal fully supersedes them (i.e., once mc-score lands and the GdGameHistory bridge exposes the data the Demographics tab needs). The standalone scene is NOT to be kept alongside. The ingame_menu.gd Stats button now points to statistics.tscn (cycle 50). Delete demographics.gd/tscn in the same cycle mc-score is merged. ## Dependencies diff --git a/.project/objectives/p3-11-pioneer-engineer-action-points.md b/.project/objectives/p3-11-pioneer-engineer-action-points.md index 12547fe6..c60b38c6 100644 --- a/.project/objectives/p3-11-pioneer-engineer-action-points.md +++ b/.project/objectives/p3-11-pioneer-engineer-action-points.md @@ -5,13 +5,21 @@ priority: p3 status: partial scope: game1 owner: unassigned -updated_at: 2026-05-04 +updated_at: 2026-05-07 evidence: - "src/simulator/crates/mc-core/src/units.rs:48-119 ActionPoints typed newtype with current/capacity invariants" - "src/simulator/crates/mc-core/src/units.rs:18-44 ActionCost typed wrapper" - "src/simulator/crates/mc-core/src/units.rs:121-129 pioneer_engineer_ap_capacity tier ladder 6/10/14" - "src/simulator/crates/mc-core/src/units.rs:131-188 5 unit tests passing (cargo test -p mc-core units::)" - "src/simulator/crates/mc-core/src/lib.rs:22 module wired into mc-core" + - "public/resources/units/dwarf_engineer.json action_point_capacity:6 (tier 1)" + - "public/resources/units/dwarf_high_engineer.json action_point_capacity:10 (tier 2)" + - "public/resources/units/dwarf_grand_engineer.json action_point_capacity:14 (tier 3)" + - "public/resources/units/dwarf_ascendant_engineer.json action_point_capacity:16 (tier 8, Chief Engineer tier)" + - "public/resources/units/dwarf_founder.json action_point_capacity:10 (tier 2 Pioneer)" + - "public/resources/unit_actions/found_city.json ap_cost:all" + - "public/resources/unit_actions/prepare_land.json ap_cost:2" + - "public/resources/unit_actions/build_improvement.json ap_cost:per_improvement table" blocked_by: [] --- ## Context @@ -28,8 +36,8 @@ This replaces the current binary "did the worker act this turn" model. - βœ“ `mc-core::ActionPoints { current: u8, capacity: u8 }` newtype in `src/simulator/crates/mc-core/src/units.rs` β€” `src/simulator/crates/mc-core/src/units.rs:48-119`. Companion `ActionCost(u8)` newtype at `units.rs:18-44` and tier-ladder helper `pioneer_engineer_ap_capacity` at `units.rs:121-129`. - ❌ `mc-units::Unit` adds `action_points: Option` (None for non-AP units). **Blocked**: no `mc-units` crate exists in the workspace; the `Unit` struct location is undecided. See follow-ups. -- ❌ Unit JSON under `public/resources/units/{pioneer,engineer}*.json` declares `action_point_capacity: 6 | 10 | 14`. **Blocked**: no `pioneer*.json` exists yet β€” only `dwarf_engineer.json` / `dwarf_high_engineer.json` / `dwarf_grand_engineer.json` / `dwarf_ascendant_engineer.json`. Capacity-ladder authoring deferred to dedicated content ticket. -- ❌ `mc-units::action::cost_for(unit, action) -> u8` returns documented AP cost; action JSON `public/resources/unit_actions/*.json` carries `ap_cost` (Found City = full capacity, Prepare Land = 2, Build Improvement = per-improvement). **Blocked**: `public/resources/unit_actions/` directory does not exist; registry shape and loader undefined. +- βœ“ Unit JSON under `public/resources/units/{pioneer,engineer}*.json` declares `action_point_capacity`. All four engineer tiers and `dwarf_founder.json` carry the field: tier 1β†’6, tier 2β†’10, tier 3β†’14, tier 8β†’16. No `dwarf_pioneer.json` exists; `dwarf_founder.json` is the current stand-in (tier 2 Veteran Pioneer = 10 AP). Pioneer-specific unit authoring deferred to content ticket. +- βœ“ (JSON half) Action registry `public/resources/unit_actions/*.json` created: `found_city.json` (ap_cost: all), `prepare_land.json` (ap_cost: 2), `build_improvement.json` (ap_cost: per-improvement table). **Rust half still blocked**: `mc-units::action::cost_for(unit, action) -> u8` cannot land until `mc-units` crate exists. - ❌ `mc-turn::processor` recharges to capacity on turn-end-in-friendly-city; otherwise no recharge. **Blocked**: depends on a `Unit` type carrying `action_points`. The `recharge_full` mutator is implemented (`units.rs:103-106`) and ready to be called by `mc-turn` once the unit type lands. - βœ“ `cargo test -p mc-core units::tests::test_ap_capacity_tier_ladder`, `units::tests::test_found_city_consumes_all_ap`, `units::tests::test_in_city_recharges` green β€” `src/simulator/crates/mc-core/src/units.rs:131-188` (also covers `test_try_spend_saturates`, `test_can_afford`). Per-unit `_pioneer_found_city_consumes_pool` / `_engineer_recharges_in_city` integration tests deferred until the unit module exists. diff --git a/public/games/age-of-dwarves/data/schemas/score.schema.json b/public/games/age-of-dwarves/data/schemas/score.schema.json new file mode 100644 index 00000000..eff91e74 --- /dev/null +++ b/public/games/age-of-dwarves/data/schemas/score.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "score.schema.json", + "title": "ScoreWeights", + "description": "Composite score formula weights consumed by mc-score and the p2-47 Statistics modal.", + "type": "object", + "required": ["version", "weights"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "minimum": 1 + }, + "description": { + "type": "string" + }, + "weights": { + "type": "object", + "required": [ + "w_pop", "w_cities", "w_tech", "w_culture", + "w_land", "w_wonders", "w_military" + ], + "additionalProperties": false, + "properties": { + "w_pop": { "type": "number", "minimum": 0 }, + "w_cities": { "type": "number", "minimum": 0 }, + "w_tech": { "type": "number", "minimum": 0 }, + "w_culture": { "type": "number", "minimum": 0 }, + "w_land": { "type": "number", "minimum": 0 }, + "w_wonders": { "type": "number", "minimum": 0 }, + "w_military": { "type": "number", "minimum": 0 } + } + } + } +} diff --git a/public/games/age-of-dwarves/data/score.json b/public/games/age-of-dwarves/data/score.json new file mode 100644 index 00000000..893ddcd2 --- /dev/null +++ b/public/games/age-of-dwarves/data/score.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "description": "Composite score formula weights for the p2-47 Statistics modal and end-game ordering. Field names mirror mc-score::ScoreWeights.", + "weights": { + "w_pop": 0.20, + "w_cities": 0.15, + "w_tech": 0.20, + "w_culture": 0.10, + "w_land": 0.10, + "w_wonders": 0.15, + "w_military": 0.10 + } +} diff --git a/src/game/engine/scenes/hud/world_map_hud.gd b/src/game/engine/scenes/hud/world_map_hud.gd index e8b42afe..98681513 100644 --- a/src/game/engine/scenes/hud/world_map_hud.gd +++ b/src/game/engine/scenes/hud/world_map_hud.gd @@ -16,6 +16,8 @@ signal chronicle_pressed signal tutorial_requested ## p2-53c: emitted when the player confirms a rally command in the picker panel. signal rally_command_chosen(command: String) +## p2-47: emitted when the player clicks the Statistics (Info) button or presses F9. +signal statistics_pressed ## p1-19: turns 1-5 are the on-ramp window during which the Tutorial button ## remains available. After turn 5 (or once the player has completed the @@ -108,6 +110,15 @@ func _build_top_bar() -> void: _tutorial_button_dismissed = true top_bar.add_child(_tutorial_button) + ## p2-47: Statistics (Info) button β€” opens the 5-tab statistics modal. + var info_button: Button = Button.new() + info_button.name = "InfoButton" + info_button.text = ThemeVocabulary.lookup("hud_info_button") + info_button.tooltip_text = ThemeVocabulary.lookup("tooltip_statistics") + info_button.custom_minimum_size = Vector2(80, 36) + info_button.pressed.connect(_on_info_button_pressed) + top_bar.add_child(info_button) + _tech_button = Button.new() _tech_button.name = "TechButton" _tech_button.text = ThemeVocabulary.lookup("tech_tree") @@ -448,3 +459,19 @@ func _on_tutorial_button_pressed() -> void: hide_tutorial_button() tutorial_requested.emit() EventBus.tutorial_requested.emit() + + +## p2-47: info/statistics button handler. +func _on_info_button_pressed() -> void: + statistics_pressed.emit() + + +## p2-47: F9 global shortcut opens statistics modal. ESC is reserved for the +## ingame pause menu so only F9 fires here. +func _input(event: InputEvent) -> void: + if not event is InputEventKey: + return + var key: InputEventKey = event as InputEventKey + if key.pressed and key.keycode == KEY_F9: + get_viewport().set_input_as_handled() + statistics_pressed.emit() diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index c9810c27..5210bf19 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -204,6 +204,7 @@ func _connect_signals() -> void: _hud.end_turn_pressed.connect(_on_end_turn_pressed) _hud.tech_tree_pressed.connect(_toggle_tech_tree) _hud.chronicle_pressed.connect(_toggle_chronicle) + _hud.statistics_pressed.connect(_open_statistics) _hud.tutorial_requested.connect(_on_tutorial_requested) _hud.rally_command_chosen.connect(_on_rally_command_chosen) if _unit_panel != null: @@ -993,6 +994,13 @@ func _toggle_chronicle() -> void: _chronicle_panel.open(GameState.current_player_index) +## p2-47: open the 5-tab statistics modal via the overlay stack. +func _open_statistics() -> void: + var main: Node = get_tree().root.get_node_or_null("Main") + if main != null and main.has_method("push_overlay"): + main.push_overlay("res://engine/scenes/statistics/statistics.tscn") + + func _on_end_turn_pressed() -> void: _deselect_unit() _hud.set_end_turn_disabled(true) diff --git a/src/game/engine/src/autoloads/settings_manager.gd b/src/game/engine/src/autoloads/settings_manager.gd index 088924db..f5560adb 100644 --- a/src/game/engine/src/autoloads/settings_manager.gd +++ b/src/game/engine/src/autoloads/settings_manager.gd @@ -67,6 +67,12 @@ const DEFAULTS: Dictionary = { "include_screenshots": true, "include_game_state": true, }, + ## p2-47: persists the last-viewed tab index of the 5-tab statistics modal + ## so reopening the modal resumes on the same tab. + "ui": + { + "last_statistics_tab": 0, + }, } var save_path: String = "user://settings.cfg" diff --git a/src/game/engine/tests/integration/test_statistics_modal.gd b/src/game/engine/tests/integration/test_statistics_modal.gd new file mode 100644 index 00000000..ed01417c --- /dev/null +++ b/src/game/engine/tests/integration/test_statistics_modal.gd @@ -0,0 +1,117 @@ +extends GutTest +## p2-47: Integration tests for the 5-tab Statistics modal. +## +## These tests verify tab layout, metric cycling, and last-tab persistence +## through SettingsManager. They are headless-compatible β€” no GdGameHistory +## bridge is required because the modal gracefully degrades to StatsTracker +## (which may return empty data in test context). + +const StatisticsScript: GDScript = preload( + "res://engine/scenes/statistics/statistics.gd" +) + +var _modal: Control = null + + +func before_each() -> void: + _modal = StatisticsScript.new() + add_child_autofree(_modal) + ## Force _ready to complete layout before assertions. + await get_tree().process_frame + + +func after_each() -> void: + if is_instance_valid(_modal): + _modal.queue_free() + _modal = null + + +## The modal must expose exactly 5 tab panels after _ready. +func test_five_tab_panels_present() -> void: + assert_eq(_modal._tab_panels.size(), 5, + "expected 5 tab panels (Demographics/Graphs/Rankings/Replay/Histories)") + + +## Only the active tab's panel should be visible after construction. +func test_only_active_tab_visible_on_start() -> void: + var active: int = _modal._current_tab + for i: int in _modal._tab_panels.size(): + var panel: Control = _modal._tab_panels[i] + if i == active: + assert_true(panel.visible, "active tab panel %d should be visible" % i) + else: + assert_false(panel.visible, "inactive tab panel %d should be hidden" % i) + + +## Switching tab must hide the previous panel and show the new one. +func test_switching_tabs_updates_visibility() -> void: + _modal._on_tab_changed(StatisticsScript.TAB_GRAPHS) + await get_tree().process_frame + assert_true( + _modal._tab_panels[StatisticsScript.TAB_GRAPHS].visible, + "Graphs panel should be visible after switching to tab 1" + ) + assert_false( + _modal._tab_panels[StatisticsScript.TAB_DEMOGRAPHICS].visible, + "Demographics panel should be hidden after leaving tab 0" + ) + + +## Switching tabs must preserve _metric_idx across Graphs ↔ Rankings. +func test_metric_index_preserved_across_tab_switches() -> void: + _modal._on_tab_changed(StatisticsScript.TAB_GRAPHS) + _modal._on_metric_next() + var idx_after_next: int = _modal._metric_idx + _modal._on_tab_changed(StatisticsScript.TAB_RANKINGS) + assert_eq(_modal._metric_idx, idx_after_next, + "_metric_idx must survive a Graphsβ†’Rankings tab switch") + + +## Cycling metrics wraps correctly. +func test_metric_cycling_wraps() -> void: + _modal._metric_idx = 0 + _modal._on_tab_changed(StatisticsScript.TAB_GRAPHS) + var count: int = StatsTracker.CATEGORIES.size() + _modal._metric_idx = count - 1 + _modal._on_metric_next() + assert_eq(_modal._metric_idx, 0, "metric index should wrap from last back to 0") + _modal._on_metric_prev() + assert_eq(_modal._metric_idx, count - 1, + "metric index should wrap from 0 back to last") + + +## Tab 3 (Replay) must have the embedded replay_viewer Control present. +func test_replay_viewer_embedded() -> void: + var replay_panel: Control = _modal._tab_panels[StatisticsScript.TAB_REPLAY] + assert_not_null(replay_panel, "Replay tab panel must exist") + var has_viewer: bool = false + for child: Node in replay_panel.get_children(): + if child.name == "ReplayViewer": + has_viewer = true + break + assert_true(has_viewer, "Replay tab panel must contain a ReplayViewer child") + + +## Tab 4 (Histories) must show the pending-bridge notice label. +func test_histories_tab_shows_pending_notice() -> void: + var hist_panel: Control = _modal._tab_panels[StatisticsScript.TAB_HISTORIES] + assert_not_null(_modal._hist_notice, "Histories notice label must be created") + assert_true( + hist_panel.is_ancestor_of(_modal._hist_notice), + "Histories notice must be inside the Histories tab panel" + ) + assert_false( + _modal._hist_notice.text.is_empty(), + "Histories notice must have non-empty text" + ) + + +## 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. + var standalone: Control = StatisticsScript.new() + standalone._ready() + standalone._on_close() ## Should queue_free gracefully. + await get_tree().process_frame + assert_true(true, "close without Main node must not crash") diff --git a/tools/validate-game-data.py b/tools/validate-game-data.py index bf0dc053..5d89ea70 100644 --- a/tools/validate-game-data.py +++ b/tools/validate-game-data.py @@ -563,6 +563,49 @@ class GameDataValidator: else: self._ok(f"biomes/{biome_id} β†’ resources/{res_ref}") + # ── p2-47: score.json ──────────────────────────────────────────── + # Validates the composite score formula weights file that mc-score reads at + # startup. All seven weights must be present and non-negative; the file must + # parse against score.schema.json. No silent defaults β€” fail if absent. + REQUIRED_SCORE_WEIGHTS: tuple[str, ...] = ( + "w_pop", "w_cities", "w_tech", "w_culture", + "w_land", "w_wonders", "w_military", + ) + + def validate_score(self) -> None: + """score.json: all seven weights present, non-negative, schema-valid.""" + path = self.game_data / "score.json" + if not path.exists(): + self._fail("score.json", f"missing at {path.relative_to(self.root)}") + return + data, err = load_json_safe(path) + if err: + self._fail("score.json", f"parse error: {err}") + return + print("\n score.json") + rel = str(path.relative_to(self.root)) + + # Schema validation. + schema = self._load_schema("score") + if schema is not None: + self._validate_entry(schema, data, rel) + + # Per-weight presence + non-negative check (belt-and-suspenders over schema). + weights = data.get("weights") if isinstance(data, dict) else None + if not isinstance(weights, dict): + self._fail(f"{rel}/weights", "must be an object") + return + for key in self.REQUIRED_SCORE_WEIGHTS: + w_label = f"{rel}/weights/{key}" + if key not in weights: + self._fail(w_label, f"required weight '{key}' is absent") + elif not isinstance(weights[key], (int, float)): + self._fail(w_label, f"must be a number, got {type(weights[key]).__name__}") + elif weights[key] < 0: + self._fail(w_label, f"weight must be >= 0, got {weights[key]}") + else: + self._ok(w_label) + # ── Main ───────────────────────────────────────────────────────── def run(self): @@ -606,6 +649,7 @@ class GameDataValidator: self.validate_guide_data() self.validate_building_requires_existing() self.validate_cross_refs() + self.validate_score() def report(self) -> int: print(f"\n{'=' * 60}")