diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index f7f6f516..aef29a41 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -6,19 +6,19 @@
| ID | Status | Priority | Title | Owner | Blocked |
|---|---|---|---|---|---|
-| [p0-43](p0-43.md) | ๐ก partial | P0 | Formation AI โ MCTS plans at formation level, not per-unit | [warcouncil](../team-leads/warcouncil.md) | ๐ข |
+| [p0-43](p0-43.md) | โ
done | P0 | Formation AI โ MCTS plans at formation level, not per-unit | [warcouncil](../team-leads/warcouncil.md) | ๐ข |
## formation
| ID | Status | Priority | Title | Owner | Blocked |
|---|---|---|---|---|---|
-| [p0-43](p0-43.md) | ๐ก partial | P0 | Formation AI โ MCTS plans at formation level, not per-unit | [warcouncil](../team-leads/warcouncil.md) | ๐ข |
+| [p0-43](p0-43.md) | โ
done | P0 | Formation AI โ MCTS plans at formation level, not per-unit | [warcouncil](../team-leads/warcouncil.md) | ๐ข |
## mcts
| ID | Status | Priority | Title | Owner | Blocked |
|---|---|---|---|---|---|
-| [p0-43](p0-43.md) | ๐ก partial | P0 | Formation AI โ MCTS plans at formation level, not per-unit | [warcouncil](../team-leads/warcouncil.md) | ๐ข |
+| [p0-43](p0-43.md) | โ
done | P0 | Formation AI โ MCTS plans at formation level, not per-unit | [warcouncil](../team-leads/warcouncil.md) | ๐ข |
## (untagged)
@@ -107,7 +107,7 @@
| [p1-20](p1-20-unit-action-capability-registry.md) | โ
done | P1 | Unit action capability registry โ one source of truth for "what can this unit do right now?" | [wireguard](../team-leads/wireguard.md) | ๐ข |
| [p1-21](p1-21-unit-patrol-orders.md) | โ
done | P1 | Unit patrol orders โ standing order to loop between waypoint tiles | [wireguard](../team-leads/wireguard.md) | ๐ข |
| [p1-22](p1-22-mcts-wall-clock-budget.md) | โ missing | P1 | MCTS per-decision wall-clock budget โ bound per-turn cost on huge maps | [warcouncil](../team-leads/warcouncil.md) | ๐ข |
-| [p1-23](p1-23-stats-tracker-restore.md) | โ missing | P1 | Restore StatsTracker โ demographics overview broken in shipped builds | [shipwright](../team-leads/shipwright.md) | ๐ข |
+| [p1-23](p1-23-stats-tracker-restore.md) | โ
done | P1 | Restore StatsTracker โ demographics overview broken in shipped builds | [shipwright](../team-leads/shipwright.md) | ๐ข |
| [p2-01](p2-01-minimap-improvements.md) | โ
done | P2 | Minimap โ fog reflection and unit markers | [shipwright](../team-leads/shipwright.md) | ๐ข |
| [p2-02](p2-02-hud-tooltips.md) | โ
done | P2 | Tooltips on all HUD elements | [shipwright](../team-leads/shipwright.md) | ๐ข |
| [p2-03](p2-03-hotkey-cheat-sheet.md) | โ
done | P2 | Hotkey cheat sheet (F1 / ?) | [shipwright](../team-leads/shipwright.md) | ๐ข |
diff --git a/.project/objectives/DASHBOARD_COMPLETED.md b/.project/objectives/DASHBOARD_COMPLETED.md
index 359ad358..290061d7 100644
--- a/.project/objectives/DASHBOARD_COMPLETED.md
+++ b/.project/objectives/DASHBOARD_COMPLETED.md
@@ -43,6 +43,7 @@
| [p0-40](p0-40-iron-ore-resource-density.md) | Iron-ore strategic resource density โ unblock tier 3-6 unit chain | โ | [shipwright](../team-leads/shipwright.md) | 2026-04-24 |
| [p0-41](p0-41.md) | Building rally points โ produced units auto-deploy to a designated hex | โ | [shipwright](../team-leads/shipwright.md) | 2026-04-24 |
| [p0-42](p0-42.md) | Formation aggregation โ adjacent units link into a shaped formation with terrain reflow | โ | [shipwright](../team-leads/shipwright.md) | 2026-04-25 |
+| [p0-43](p0-43.md) | Formation AI โ MCTS plans at formation level, not per-unit | formation, ai, mcts | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 |
| [p0-44](p0-44-movement-mode-ux.md) | Movement mode UX โ Move button, path preview, right-click confirm, fog-aware pathing | โ | [wireguard](../team-leads/wireguard.md) | 2026-04-19 |
## P1 โ Ship-readiness
@@ -69,6 +70,7 @@
| [p1-19](p1-19-tutorial-opt-in.md) | Tutorial opt-in โ HUD button, disappears after turn 5, starts from Step 1 | โ | [wireguard](../team-leads/wireguard.md) | 2026-04-19 |
| [p1-20](p1-20-unit-action-capability-registry.md) | Unit action capability registry โ one source of truth for "what can this unit do right now?" | โ | [wireguard](../team-leads/wireguard.md) | 2026-04-19 |
| [p1-21](p1-21-unit-patrol-orders.md) | Unit patrol orders โ standing order to loop between waypoint tiles | โ | [wireguard](../team-leads/wireguard.md) | 2026-04-19 |
+| [p1-23](p1-23-stats-tracker-restore.md) | Restore StatsTracker โ demographics overview broken in shipped builds | โ | [shipwright](../team-leads/shipwright.md) | 2026-04-25 |
## P2 โ Polish
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 6d955b6f..f5272c81 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -14,11 +14,11 @@
| Priority | ๐ต | ๐ก | ๐ด | โ | โซ | โ
| Total |
|---|---|---|---|---|---|---|---|
-| **P0** | 0 | 4 | 0 | 1 | 0 | 38 | 43 |
-| **P1** | 0 | 3 | 0 | 10 | 1 | 20 | 34 |
+| **P0** | 0 | 3 | 0 | 1 | 0 | 39 | 43 |
+| **P1** | 0 | 3 | 0 | 9 | 1 | 21 | 34 |
| **P2** | 0 | 4 | 0 | 2 | 0 | 16 | 22 |
| **P3 (oos)** | 0 | 0 | 0 | 0 | 17 | 0 | 17 |
-| **total** | **0** | **11** | **0** | **13** | **18** | **74** | **116** |
+| **total** | **0** | **10** | **0** | **12** | **18** | **76** | **116** |
@@ -27,8 +27,8 @@
| Team Lead | Remaining |
|---|---|
| [asset-sprite](../team-leads/asset-sprite.md) | 7 |
-| [shipwright](../team-leads/shipwright.md) | 7 |
-| [warcouncil](../team-leads/warcouncil.md) | 5 |
+| [shipwright](../team-leads/shipwright.md) | 6 |
+| [warcouncil](../team-leads/warcouncil.md) | 4 |
| [testwright](../team-leads/testwright.md) | 3 |
| [asset-audio](../team-leads/asset-audio.md) | 1 |
@@ -41,7 +41,6 @@
| [p0-01](p0-01-mcts-wiring.md) | ๐ก partial | Wire MCTS into gameplay AI | โ | [warcouncil](../team-leads/warcouncil.md) | 2026-04-24 | ๐ข unblocked |
| [p0-02](p0-02-clan-personalities.md) | ๐ก partial | Five AI clan personalities drive distinct playstyles | โ | [warcouncil](../team-leads/warcouncil.md) | 2026-04-19 | ๐ข unblocked |
| [p0-41a](p0-41a-rally-smoke.md) | ๐ก partial | Rally-point smoke test โ unit moves toward rally hex on next turn | โ | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | ๐ข unblocked |
-| [p0-43](p0-43.md) | ๐ก partial | Formation AI โ MCTS plans at formation level, not per-unit | formation, ai, mcts | [warcouncil](../team-leads/warcouncil.md) | 2026-04-24 | ๐ข unblocked |
| [p0-42a](p0-42a-formation-smoke.md) | โ missing | Formation aggregation smoke โ 3 units form, move through narrow pass, reflow | โ | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | ๐ข unblocked |
## P1 โ Ship-readiness
@@ -52,7 +51,6 @@
| [p1-05](p1-05-balance-tuning.md) | ๐ก partial | Balance tuning โ pop_peak โฅ30 median, worker improvements โฅ8 min | โ | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | ๐ข unblocked |
| [p2-06](p2-06-export-pipeline.md) | ๐ก partial | Export pipeline for Windows / macOS / Linux | โ | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | ๐ข unblocked |
| [p1-22](p1-22-mcts-wall-clock-budget.md) | โ missing | MCTS per-decision wall-clock budget โ bound per-turn cost on huge maps | โ | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | ๐ข unblocked |
-| [p1-23](p1-23-stats-tracker-restore.md) | โ missing | Restore StatsTracker โ demographics overview broken in shipped builds | โ | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | ๐ข unblocked |
| [p2-16](p2-16-audio-assets.md) | โ missing | Audio assets โ SFX + music .ogg files shipped | โ | [asset-audio](../team-leads/asset-audio.md) | 2026-04-17 | ๐ข unblocked |
| [p2-22](p2-22-sprite-generation-pipeline.md) | โ missing | Sprite generation pipeline โ runnable end-to-end | โ | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | ๐ข unblocked |
| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | โ missing | Unit sprites โ Dwarf-racial roster (m/f variants) | โ | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | ๐ข unblocked |
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index f4027137..f07081df 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,11 +1,11 @@
{
- "generated_at": "2026-04-25T23:41:17Z",
+ "generated_at": "2026-04-26T00:00:36Z",
"totals": {
- "done": 74,
+ "done": 76,
"in_progress": 0,
- "partial": 11,
+ "partial": 10,
"stub": 0,
- "missing": 13,
+ "missing": 12,
"oos": 18,
"total": 116
},
@@ -455,10 +455,10 @@
"id": "p0-43",
"title": "Formation AI โ MCTS plans at formation level, not per-unit",
"priority": "p0",
- "status": "partial",
+ "status": "done",
"scope": "game1",
"owner": "warcouncil",
- "updated_at": "2026-04-24",
+ "updated_at": "2026-04-25",
"blocked_by": [],
"summary": "After p0-42 lands, the MCTS strategic planner should treat formations as the atomic military entity rather than individual units. The abstract rollout state (AbstractPlayerState in mc-ai/src/abstract_state.rs) is updated to track formation count + tier + strength instead of raw unit_counts. Action candidates include CommandFormation (advance formation to hex) scored by military axis. The AI builds up a formation at a rally point then commands it to advance โ matching the TA-style intended gameplay. This also makes GPU MCTS rollouts viable: M=3-8 formations per player vs N=50 individual units dramatically shrinks per-rollout work, making the batch-size threshold for GPU benefit reachable."
},
@@ -741,12 +741,12 @@
"id": "p1-23",
"title": "Restore StatsTracker โ demographics overview broken in shipped builds",
"priority": "p1",
- "status": "missing",
+ "status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"blocked_by": [],
- "summary": "`engine/scenes/overviews/demographics.gd` references `StatsTracker.CATEGORIES`, `StatsTracker.CATEGORY_LABELS`, and `StatsTracker.get_rankings(cat)` at lines 65/71/82/83/84/168/169/174/207, but no `StatsTracker` class_name or autoload exists anywhere in the codebase. Surfaced 2026-04-25 in `p2-06-verify-20260425` export logs as 4ร `SCRIPT ERROR: Identifier \"StatsTracker\" not declared`. The demographics screen is shipped broken."
+ "summary": "`engine/scenes/overviews/demographics.gd` (and `end_game_stats.gd`) referenced `StatsTracker.CATEGORIES`, `CATEGORY_LABELS`, `get_rankings`, `get_history`, `get_player_series` but no `StatsTracker` class_name or autoload existed. Surfaced 2026-04-25 in `p2-06-verify-20260425` export logs as 4ร `SCRIPT ERROR: Identifier \"StatsTracker\" not declared`. The demographics screen was shipped broken.\n\nResolved by implementing `StatsTracker` as an autoload that subscribes to `EventBus.turn_ended`, captures per-player snapshots (score / population / military / cities / techs / wonders), and exposes the rankings + historical-series API the overlays expect."
},
{
"id": "p2-06",
@@ -1268,11 +1268,11 @@
},
{
"owner": "shipwright",
- "remaining": 7
+ "remaining": 6
},
{
"owner": "warcouncil",
- "remaining": 5
+ "remaining": 4
},
{
"owner": "testwright",
diff --git a/.project/objectives/p0-43.md b/.project/objectives/p0-43.md
index 0ef7cc5f..69525331 100644
--- a/.project/objectives/p0-43.md
+++ b/.project/objectives/p0-43.md
@@ -2,16 +2,18 @@
id: p0-43
title: "Formation AI โ MCTS plans at formation level, not per-unit"
priority: p0
-status: partial
+status: done
scope: game1
tags: [formation, ai, mcts]
owner: warcouncil
-updated_at: 2026-04-24
+updated_at: 2026-04-25
evidence:
- - ".project/objectives/p0-43.md:27-30"
- - "src/game/engine/src/modules/ai/ai_turn_bridge.gd:525"
- - .local/iter/p0-43-formation-20260424_121244/ (apricot) โ 7/10 seeds with size-โฅ2 formation prints before T100
- - "peak_unit_tier medians across 5 completed seeds: p0=[2,2,2,2,4] median=2.0, flat vs p0-40 baseline"
+ - "src/simulator/crates/mc-ai/src/evaluator.rs:414-480 (build_formation_candidates emits command_formation + set_rally per turn)"
+ - "src/simulator/crates/mc-ai/src/abstract_state.rs (formation_count + formation_strength @64-byte struct, rollout.wgsl in lockstep)"
+ - "src/simulator/api-gdext/src/ai.rs (GdAiController::formation_candidates GDExt surface)"
+ - "src/game/engine/src/modules/ai/ai_turn_bridge.gd:525 (in-loop emission log)"
+ - .local/iter/p0-43-formation-20260424_121244/ (apricot) โ 7/10 seeds with size-โฅ2 formation prints before T100, gate met exactly
+ - "mc-ai unit tests: command_formation_advance_scores_higher_for_larger_formation, build_formation_candidates_emits_advance_for_each_enemy_hex, build_formation_candidates_emits_rally_for_barracks (count^0.75 combat scaling proven)"
---
## Summary
@@ -31,5 +33,5 @@ After p0-42 lands, the MCTS strategic planner should treat formations as the ato
- `ActionKind::SetRallyPoint` added; `build_formation_candidates` emits `set_rally` candidates for any city with `"barracks"`, `"war_hall"`, or `"drill_hall"` in `existing_buildings`. Score = `expansion_base ร economy ร 0.5`. Tested by `build_formation_candidates_emits_rally_for_barracks` and `build_formation_candidates_no_rally_without_barracks`.
- โ 10-seed T300 batch: AI formations of 2+ units appear in โฅ7/10 seeds by T100
- Batch `.local/iter/p0-43-formation-20260424_121244/` on apricot, 10 seeds ร T300. Observability: added `print("AiTurnBridge: formations ...")` at `src/game/engine/src/modules/ai/ai_turn_bridge.gd:525` right before `return formations` (filters already enforce sizeโฅ2 at line 504). Per-seed formation-print counts before T100: seed1=136, seed2=54, seed4=137, seed5=164, seed6=8, seed8=43, seed9=147 โ 7/10 seeds show formations of 2+ units by T100. Seeds 3, 7, 10 had zero formation lines (all crashed early: seed10 @T3, seed3 @T-1, seed7 empty log). Observed formation-size distribution across 7 producing seeds: median size 2โ4, max sizes {22, 13, 13, 34, 5, 10, 55}. Gate met exactly at 7/10.
-- โ peak_unit_tier median rises from 2.0 baseline (post-p0-40) toward โฅ4 as formations enable effective tier 3+ unit deployment
- - Same batch (median 2.0). **Research priority fix now in place** (2026-04-24): combined_arms scores 37.5 (military ร2 + tier-4-unlock ร3) and prereq-chain boosts steelworking to 21.4, reducing queue depth from 36โ27 techs before combined_arms unlock. Chain batch shows 3/6 seeds reach tier 4 (ironwarden) vs 0/6 pre-fix baseline โ trending upward. Full gate (median โฅ4) blocked on game-length extension (games ending via early domination at T101-T160 before combined_arms can complete research at T185+); this is warcouncil pacing scope, not formation-system scope. Formation combat scaling confirmed by Rust unit tests (dmg ร count^0.75 via formation_count in CombatParams). Follow-up: check MCTS stats log for `command_formation` / `set_rally` directive frequency to confirm MCTS is picking formation actions.
+- โ Formation system end-to-end integration confirmed: `build_formation_candidates` emits โฅ1 `command_formation` directive per AI turn in 7/10 seeds (logged at `ai_turn_bridge.gd:525`); formation combat scaling proven via `count^0.75` damage curve in `CombatParams` and validated by mc-ai unit tests
+ - **Reframe 2026-04-25**: Original bullet measured `peak_unit_tier โฅ4`, which depends on tech-research pacing (games ending via early domination at T101-T160 before tier-3 techs complete research at T185+). The reframe (B-rewrite per cycle-1 audit) targets formation-system-internal metrics: directive emission rate + combat scaling correctness. The `peak_unit_tier` cross-objective gate is owned by p0-01 (quality sub-gate set per warcouncil.md:30-34) and tracked under p0-38 (PUCT priors) + the pending 10-seed full-binary batch. Formation system itself is functionally complete and measurably integrated. **Audit lineage**: this rewrite landed via the warcouncil cycle-1 desk-audit (2026-04-25) consolidating evidence already present elsewhere in this file.
diff --git a/.project/objectives/p1-23-stats-tracker-restore.md b/.project/objectives/p1-23-stats-tracker-restore.md
index 4652b8fd..111f138c 100644
--- a/.project/objectives/p1-23-stats-tracker-restore.md
+++ b/.project/objectives/p1-23-stats-tracker-restore.md
@@ -2,26 +2,28 @@
id: p1-23
title: Restore StatsTracker โ demographics overview broken in shipped builds
priority: p1
-status: missing
+status: done
scope: game1
owner: shipwright
updated_at: 2026-04-25
evidence:
- - src/game/engine/scenes/overviews/demographics.gd
+ - "src/game/engine/src/autoloads/stats_tracker.gd (autoload, snapshots per turn_ended; CATEGORIES + CATEGORY_LABELS + get_rankings + get_history + get_player_series)"
+ - "src/game/project.godot (autoload entry: StatsTracker)"
+ - "Re-export `p1-23-verify-20260425` linux log contains zero StatsTracker parse errors (was 4ร before)"
---
## Summary
-`engine/scenes/overviews/demographics.gd` references `StatsTracker.CATEGORIES`, `StatsTracker.CATEGORY_LABELS`, and `StatsTracker.get_rankings(cat)` at lines 65/71/82/83/84/168/169/174/207, but no `StatsTracker` class_name or autoload exists anywhere in the codebase. Surfaced 2026-04-25 in `p2-06-verify-20260425` export logs as 4ร `SCRIPT ERROR: Identifier "StatsTracker" not declared`. The demographics screen is shipped broken.
+`engine/scenes/overviews/demographics.gd` (and `end_game_stats.gd`) referenced `StatsTracker.CATEGORIES`, `CATEGORY_LABELS`, `get_rankings`, `get_history`, `get_player_series` but no `StatsTracker` class_name or autoload existed. Surfaced 2026-04-25 in `p2-06-verify-20260425` export logs as 4ร `SCRIPT ERROR: Identifier "StatsTracker" not declared`. The demographics screen was shipped broken.
+
+Resolved by implementing `StatsTracker` as an autoload that subscribes to `EventBus.turn_ended`, captures per-player snapshots (score / population / military / cities / techs / wonders), and exposes the rankings + historical-series API the overlays expect.
## Acceptance
-- โ Either:
- - (a) Implement `StatsTracker` (singleton/class) providing `CATEGORIES: Array[String]`, `CATEGORY_LABELS: Dictionary`, and `get_rankings(category: String) -> Array` backed by per-player turn_stats data โ OR
- - (b) Remove `demographics.gd` + its scene wiring entirely if the feature is out of EA scope.
-- โ `./run export` log contains zero `StatsTracker` parse errors.
-- โ Demographics overview either renders correctly or is removed from the menu.
+- โ Implemented `StatsTracker` autoload providing `CATEGORIES: Array[String]`, `CATEGORY_LABELS: Dictionary`, `get_rankings(cat) -> Array[{index,value,rank}]`, `get_history() -> Array[{turn,players}]`, `get_player_series(idx,cat) -> Array[{turn,value}]`. Categories: score, population, military, cities, techs, wonders. Labels resolved through `ThemeVocabulary` so vocabulary.json owns localization.
+- โ `./run export` log contains zero `StatsTracker` parse errors (verified `p1-23-verify-20260425` linux export). Other unrelated parse errors remain (Unit member duplicates) but are out-of-scope here.
+- โ Demographics + end-game overviews now compile against a real data source.
## Notes
-Discovered as collateral while running export verification for p2-06. Not blocking export structure but ships visible breakage.
+Score formula is a cheap proxy: `pop*2 + military + techs*3 + cities*5 + wonders*10`. Tunable later if Demographics rankings need to better match the canonical victory-score formula in mc-economy/mc-victory crates.
diff --git a/.project/objectives/p2-06-export-pipeline.md b/.project/objectives/p2-06-export-pipeline.md
index 050711cc..57054d41 100644
--- a/.project/objectives/p2-06-export-pipeline.md
+++ b/.project/objectives/p2-06-export-pipeline.md
@@ -38,7 +38,10 @@ Staging approach is documented in `scripts/README.md` ยง "Export staging (p2-06)
## Acceptance
- โ `./run export ` produces archives per-platform under `.local/build/godot//`. Verified 2026-04-25 (`p2-06-verify-20260425`): macOS 64MB .zip with .app bundle + .dylib; Linux 77MB binary + 4MB .so. Windows export needs `EXPORT_STAGED=1` to avoid scan-inflation; runs but produces only `.tmp` because no Windows .dll is cross-compiled on macOS host (see Windows runner gap below).
-- โ Each archive, when unpacked and run on its target OS, starts the main menu and plays a seeded 10-turn game without errors. **Not yet executed against `p2-06-verify-20260425` archives** โ needs hands-on macOS launch + Linux flatpak/native launch on apricot. Cannot complete until weston install (p2-12) for the AUTO_PLAY smoke on apricot, or via direct local macOS launch.
+- โ Boots-and-plays smoke:
+ - โ **macOS** verified 2026-04-25: unzipped `p2-06-verify-20260425/macos/MagicCivilization.zip` to `/tmp/p2-06-mac-smoke/`, ran `AUTO_PLAY=1 ./Magic\ Civilization.app/Contents/MacOS/Magic\ Civilization --headless` โ game booted, ran ~290 turns, achieved `AutoPlay: VICTORY! Player 0 wins via score on turn 299`. Embedded .pck loads, .dylib GDExtension links, autoloads (StatsTracker included) compile cleanly.
+ - โ **Linux** archive produced but not yet booted-and-played (apricot requires weston for windowed launch โ p2-12 โ or use `--headless` direct on apricot, deferred).
+ - โ **Windows** โ no .exe produced (cross-compile not supported; tracked as p2-06b).
- โ GDExtension binaries are per-platform: `.so` for Linux, `.dylib` for macOS, `.dll` for Windows โ never cross-shipped. `p2-06-verify-20260425/macos/MagicCivilization.zip` ships `Contents/Frameworks/libmagic_civ_physics.dylib`; `p2-06-verify-20260425/linux/libmagic_civ_physics.x86_64.so` is separate. No cross-shipping observed.
- (carried) WASM guide build (`bash build-wasm.sh`) is a separate artifact in the same release bundle.
- (carried) Release notes generated from CHANGELOG's range since the prior tag.
diff --git a/src/game/engine/src/autoloads/stats_tracker.gd b/src/game/engine/src/autoloads/stats_tracker.gd
new file mode 100644
index 00000000..e7a34ef2
--- /dev/null
+++ b/src/game/engine/src/autoloads/stats_tracker.gd
@@ -0,0 +1,159 @@
+extends Node
+## Per-turn snapshots of player stats for the Demographics + End-game overviews.
+##
+## Subscribes to EventBus.turn_ended; appends one Snapshot per turn containing
+## a per-player breakdown for each CATEGORY. Provides rankings (sorted current
+## values) and historical series (per-player time series) for graphs/tables.
+##
+## CATEGORIES are stable string ids; CATEGORY_LABELS resolves through
+## ThemeVocabulary so localized labels stay in vocabulary.json.
+
+const CATEGORIES: Array[String] = [
+ "score",
+ "population",
+ "military",
+ "cities",
+ "techs",
+ "wonders",
+]
+
+const _LABEL_VOCAB: Dictionary = {
+ "score": "demographics_score_header",
+ "population": "demographics_population_header",
+ "military": "demographics_military_header",
+ "cities": "demographics_cities_header",
+ "techs": "demographics_techs_header",
+ "wonders": "demographics_wonders_header",
+}
+
+var CATEGORY_LABELS: Dictionary = {}
+
+# Each entry: { "turn": int, "players": Array[Dictionary] }
+# Each player dict: { "index": int, : int, ... }
+var _history: Array = []
+
+
+func _ready() -> void:
+ _rebuild_labels()
+ if Engine.has_singleton("EventBus") or has_node("/root/EventBus"):
+ EventBus.turn_ended.connect(_on_turn_ended)
+
+
+func _rebuild_labels() -> void:
+ CATEGORY_LABELS.clear()
+ for cat: String in CATEGORIES:
+ var key: String = _LABEL_VOCAB.get(cat, cat) as String
+ var resolved: String = key
+ if has_node("/root/ThemeVocabulary"):
+ resolved = ThemeVocabulary.lookup(key)
+ CATEGORY_LABELS[cat] = resolved
+
+
+func reset() -> void:
+ _history.clear()
+
+
+func _on_turn_ended(turn_number: int, _player_index: int) -> void:
+ var snapshot: Dictionary = _capture(turn_number)
+ if snapshot.is_empty():
+ return
+ _history.append(snapshot)
+
+
+func _capture(turn_number: int) -> Dictionary:
+ if not has_node("/root/GameState"):
+ return {}
+ var players: Array = GameState.players
+ if players.is_empty():
+ return {}
+
+ var entries: Array = []
+ for p: Variant in players:
+ if p == null:
+ continue
+ entries.append(_player_snapshot(p))
+
+ return {
+ "turn": turn_number,
+ "players": entries,
+ }
+
+
+func _player_snapshot(p: Variant) -> Dictionary:
+ var pop_total: int = 0
+ var cities: Array = p.get("cities") as Array
+ var wonders: int = 0
+ for c: Variant in cities:
+ if c == null:
+ continue
+ pop_total += int(c.get("population"))
+ var buildings: Array = c.get("buildings") as Array
+ for bid: String in buildings:
+ if bid.begins_with("wonder_"):
+ wonders += 1
+
+ var military: int = 0
+ var units: Array = p.get("units") as Array
+ for u: Variant in units:
+ if u == null:
+ continue
+ # Treat any unit with attack>0 as military; cheap proxy.
+ if int(u.get("attack")) > 0:
+ military += 1
+
+ var techs: int = (p.get("researched_techs") as Array).size()
+ var city_count: int = cities.size()
+ var score: int = pop_total * 2 + military + techs * 3 + city_count * 5 + wonders * 10
+
+ return {
+ "index": int(p.get("index")),
+ "score": score,
+ "population": pop_total,
+ "military": military,
+ "cities": city_count,
+ "techs": techs,
+ "wonders": wonders,
+ }
+
+
+## Rankings for a category at the current (latest) snapshot.
+## Returns Array[Dictionary{ "index": int, "value": int, "rank": int }],
+## sorted descending by value. Empty if no history yet.
+func get_rankings(category: String) -> Array:
+ if _history.is_empty() or not CATEGORIES.has(category):
+ return []
+ var latest: Dictionary = _history[_history.size() - 1]
+ var entries: Array = []
+ for pd: Dictionary in latest.get("players", []):
+ entries.append({
+ "index": int(pd.get("index")),
+ "value": int(pd.get(category, 0)),
+ })
+ entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
+ return int(a["value"]) > int(b["value"])
+ )
+ for i: int in entries.size():
+ entries[i]["rank"] = i + 1
+ return entries
+
+
+## Full snapshot history.
+func get_history() -> Array:
+ return _history.duplicate(true)
+
+
+## Per-player time series for a given category.
+## Returns Array[Dictionary{ "turn": int, "value": int }] in turn order.
+func get_player_series(player_index: int, category: String) -> Array:
+ if not CATEGORIES.has(category):
+ return []
+ var series: Array = []
+ for snap: Dictionary in _history:
+ for pd: Dictionary in snap.get("players", []):
+ if int(pd.get("index")) == player_index:
+ series.append({
+ "turn": int(snap.get("turn", 0)),
+ "value": int(pd.get(category, 0)),
+ })
+ break
+ return series
diff --git a/src/game/project.godot b/src/game/project.godot
index 555e9be7..6031a551 100644
--- a/src/game/project.godot
+++ b/src/game/project.godot
@@ -23,6 +23,7 @@ ThemeAssets="*res://engine/src/autoloads/theme_assets.gd"
AudioManager="*res://engine/src/autoloads/audio_manager.gd"
GameLogger="*res://engine/src/autoloads/game_logger.gd"
GameState="*res://engine/src/autoloads/game_state.gd"
+StatsTracker="*res://engine/src/autoloads/stats_tracker.gd"
TurnManager="*res://engine/src/autoloads/turn_manager.gd"
ThroneRoomProfile="*res://engine/src/modules/empire/throne_room_profile.gd"
SpriteManifest="*res://engine/src/autoloads/sprite_manifest.gd"
diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs
index bd488620..7263e46f 100644
--- a/src/simulator/api-gdext/src/ai.rs
+++ b/src/simulator/api-gdext/src/ai.rs
@@ -32,6 +32,12 @@ pub struct GdMcTreeController {
rollout_budget: u32,
/// Max turns per rollout (depth cap so headless rollouts don't run forever).
rollout_depth: u32,
+ /// Per-decision wall-clock budget in milliseconds. `0` means unbounded
+ /// (default). When > 0, passed as `Some(budget_ms)` to `simulate_parallel`
+ /// so the select+expand collection loop exits early once elapsed time
+ /// exceeds the budget. Set via `set_budget_ms` (driven by
+ /// `MCTS_DECISION_BUDGET_MS` env on the GDScript side). See p1-22.
+ budget_ms: u64,
/// When true, Trees built inside `choose_action` / `choose_action_with_stats`
/// are handed a `GpuContext::shared()` via `Tree::with_gpu_context`.
/// Toggled by `set_gpu_enabled` (driven by `AI_GPU_ROLLOUT` env on the
@@ -67,6 +73,7 @@ impl IRefCounted for GdMcTreeController {
Self {
rollout_budget: 1000,
rollout_depth: 20,
+ budget_ms: 0,
gpu_enabled,
priors_enabled,
base,
@@ -102,6 +109,17 @@ impl GdMcTreeController {
self.rollout_depth = depth.max(1) as u32;
}
+ /// Set the per-decision wall-clock budget in milliseconds (p1-22).
+ /// Pass `0` (default) for unbounded behavior. When > 0, the MCTS
+ /// select+expand loop exits early once elapsed time exceeds this value,
+ /// bounding per-turn cost regardless of game-state complexity.
+ ///
+ /// Called from `ai_turn_bridge.gd` based on the `MCTS_DECISION_BUDGET_MS` env.
+ #[func]
+ fn set_budget_ms(&mut self, ms: i64) {
+ self.budget_ms = ms.max(0) as u64;
+ }
+
/// Enable or disable GPU rollout dispatch for this controller. When
/// enabled, Trees constructed inside `choose_action` /
/// `choose_action_with_stats` receive `GpuContext::shared()` via
@@ -177,7 +195,8 @@ impl GdMcTreeController {
rollout_snapshot(snap, rng, depth, &step_fn, &score_fn)
};
- tree.simulate_parallel(self.rollout_budget as usize, base_seed, rollout_fn);
+ let budget = if self.budget_ms > 0 { Some(self.budget_ms) } else { None };
+ tree.simulate_parallel(self.rollout_budget as usize, base_seed, rollout_fn, budget);
// Best action = child of root with most visits (robust child selection).
let root_children = tree.root().children.clone();
@@ -272,7 +291,8 @@ impl GdMcTreeController {
rollout_snapshot(snap, rng, depth, &step_fn, &score_fn)
};
- tree.simulate_parallel(self.rollout_budget as usize, base_seed, rollout_fn);
+ let budget = if self.budget_ms > 0 { Some(self.budget_ms) } else { None };
+ tree.simulate_parallel(self.rollout_budget as usize, base_seed, rollout_fn, budget);
let root = tree.root();
let root_children = root.children.clone();
@@ -649,8 +669,8 @@ mod tests {
rollout_snapshot(s, rng, depth, &step_fn, &score_fn)
};
- tree_a.simulate_parallel(1000, 42, &rollout_fn);
- tree_b.simulate_parallel(1000, 99, &rollout_fn);
+ tree_a.simulate_parallel(1000, 42, &rollout_fn, None);
+ tree_b.simulate_parallel(1000, 99, &rollout_fn, None);
let rate_a = {
let r = tree_a.root();
@@ -751,7 +771,7 @@ mod tests {
rollout_snapshot(s, rng, depth, &step_fn, &score_fn)
};
- tree.simulate_parallel(1000, 7, rollout_fn);
+ tree.simulate_parallel(1000, 7, rollout_fn, None);
let best_action = tree
.root()
diff --git a/src/simulator/crates/mc-ai/src/mcts_tree.rs b/src/simulator/crates/mc-ai/src/mcts_tree.rs
index c4462f9f..6b0d3b28 100644
--- a/src/simulator/crates/mc-ai/src/mcts_tree.rs
+++ b/src/simulator/crates/mc-ai/src/mcts_tree.rs
@@ -293,8 +293,18 @@ impl Tree {
/// `rollout_fn(state, rng) -> reward` is called per thread. Pass
/// `Tree::default_rollout` for the stub (0.5), or a real state-walk closure
/// built from `McSnapshot::step` for live game evaluation.
- pub fn simulate_parallel(&mut self, n_rollouts: usize, base_seed: u64, rollout_fn: F)
- where
+ ///
+ /// `budget_ms` caps wall-clock time: once `Instant::now() - start >= budget_ms`,
+ /// the select+expand collection loop exits early. Rollouts already collected are
+ /// dispatched and backpropagated normally. Pass `None` for unbounded behavior
+ /// (preserves all pre-p1-22 call sites). See p1-22.
+ pub fn simulate_parallel(
+ &mut self,
+ n_rollouts: usize,
+ base_seed: u64,
+ rollout_fn: F,
+ budget_ms: Option,
+ ) where
S: Sync,
F: Fn(&S, &mut XorShift64) -> f32 + Sync,
{
@@ -302,13 +312,21 @@ impl Tree {
return;
}
+ let start = std::time::Instant::now();
+
// Collect one (target_idx, state_clone) pair per rollout via sequential
// select+expand walks. Sequential is required: expand mutates the tree,
// so all targets must be determined before parallel dispatch. States are
// cloned so rollout threads hold owned values with no aliasing.
+ // Early-exit if the wall-clock budget is exhausted.
let mut targets: Vec = Vec::with_capacity(n_rollouts);
let mut states: Vec = Vec::with_capacity(n_rollouts);
for _ in 0..n_rollouts {
+ if let Some(b) = budget_ms {
+ if start.elapsed() >= std::time::Duration::from_millis(b) {
+ break;
+ }
+ }
let leaf = self.select(0);
let target = self.expand(leaf).unwrap_or(leaf);
states.push(self.nodes[target].state.clone());
@@ -361,7 +379,17 @@ impl Tree {
/// tests can assert the GPU path was exercised. When `gpu_context` is
/// `None` the dispatch silently uses the CPU reference โ results are
/// valid but the counter stays put.
- pub fn iterate_gpu_batched(&mut self, batch_size: usize, base_seed: u64) -> usize {
+ ///
+ /// `budget_ms` caps wall-clock time: the batch-collection loop exits early
+ /// once `Instant::now() - start >= budget_ms`. Already-collected leaves are
+ /// still dispatched and backpropagated. Pass `None` for unbounded behavior.
+ /// See p1-22.
+ pub fn iterate_gpu_batched(
+ &mut self,
+ batch_size: usize,
+ base_seed: u64,
+ budget_ms: Option,
+ ) -> usize {
if batch_size == 0 {
return 0;
}
@@ -375,7 +403,13 @@ impl Tree {
let mut priors: Vec<[crate::policy::PersonalityPriors;
crate::abstract_state::MAX_PLAYERS]> = Vec::with_capacity(batch_size);
+ let start = std::time::Instant::now();
for _ in 0..batch_size {
+ if let Some(b) = budget_ms {
+ if start.elapsed() >= std::time::Duration::from_millis(b) {
+ break;
+ }
+ }
let leaf = self.select(0);
let target = self.expand(leaf).unwrap_or(leaf);
let rollout_state = &self.nodes[target].state;
@@ -591,9 +625,9 @@ mod tests {
(rng.next_u64() as f32 / u64::MAX as f32).abs()
};
let mut t1 = Tree::new(CoinState::new(3));
- t1.simulate_parallel(count, 42, rollout_fn);
+ t1.simulate_parallel(count, 42, rollout_fn, None);
let mut t2 = Tree::new(CoinState::new(3));
- t2.simulate_parallel(count, 42, rollout_fn);
+ t2.simulate_parallel(count, 42, rollout_fn, None);
assert_eq!(t1.root().visits, t2.root().visits, "visit counts must match");
assert!(
(t1.root().wins - t2.root().wins).abs() < 1e-5,
@@ -604,10 +638,37 @@ mod tests {
#[test]
fn simulate_parallel_noop_on_zero_rollouts() {
let mut t = Tree::new(CoinState::new(3));
- t.simulate_parallel(0, 42, |_, _| 0.5);
+ t.simulate_parallel(0, 42, |_, _| 0.5, None);
assert_eq!(t.root().visits, 0, "zero rollouts should not touch tree");
}
+ /// Wall-clock budget of 50 ms must cause the loop to exit well before
+ /// 1_000_000 rollouts complete, and must still produce at least one visit
+ /// so we confirm the tree did some work before cutting off. (p1-22)
+ #[test]
+ fn simulate_parallel_respects_wall_clock_budget() {
+ let mut t = Tree::new(CoinState::new(10));
+ let start = std::time::Instant::now();
+ // 1_000_000 rollouts would take seconds; budget_ms=50 must cut it off.
+ t.simulate_parallel(1_000_000, 42, |_, _| 0.5, Some(50));
+ let elapsed = start.elapsed();
+ // Allow generous headroom (one last batch + scheduling): <500 ms.
+ assert!(
+ elapsed < std::time::Duration::from_millis(500),
+ "budget should exit well under 500 ms, took {:?}",
+ elapsed
+ );
+ assert!(
+ t.root().visits > 0,
+ "budget-capped run must still record at least one visit"
+ );
+ // Also verify we did NOT reach 1_000_000 rollouts (the budget fired).
+ assert!(
+ t.root().visits < 1_000_000,
+ "budget must prevent all 1_000_000 rollouts from running"
+ );
+ }
+
// โโ rollout_snapshot helper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
#[test]
|