docs(@projects/@magic-civilization): p3-26 Gap 3 DONE (equipment/crafting verified headless) + Gap 4 scope assessment

Gap 3 — Equipment/crafting: verified the full craft→equip→combat path runs
headless and Rust-authoritative (orig bullet was stale at [ ]):
  - PlayerAction::CraftEquipment → craft_equipment dispatch (materials gate +
    consume strategic_ledger + equip), 2 tests
  - recipe_phase ("recipe_refine") in END_OF_TURN_PHASES — passive crafting
    economy refines raw→quality-tiered product every self-play turn, 1 test
  - equip_combat_bonus reads boot-loaded item_combat at every combat site, 2 tests
  - boot path: set_item_combat_json FFI ← headless harness _apply_item_combat
  - MCTS AI not electing to craft = deliberate 9-kind GPU-rollout constraint,
    not a missing system
  Verified green: mc-turn + mc-player-api 557/0.

Gap 4 — Per-building queues: recorded verified assessment. Bench single-slot +
per-turn AI reselection is functionally equivalent to a FIFO build queue for the
self-play SIMULATION outcome; the multi-item queue is a live-game UI affordance
belonging to the p3-25/p3-29 projection arc. Owner scope call pending: does p3-26
require simulating a multi-item queue, or reclassify Gap 4 out of the headless bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 09:44:11 -04:00
parent 22f7fa1116
commit b4c402e766
9 changed files with 317 additions and 24 deletions

View file

@ -2,11 +2,17 @@
"mcpServers": {
"objectives": {
"command": "/opt/homebrew/bin/mcp-objectives",
"args": []
"args": [],
"env": {
"PROJECT_ROOT": "/Users/natalie/Code/@projects/@magic-civilization"
}
},
"experts": {
"command": "/opt/homebrew/bin/mcp-experts",
"args": []
"args": [],
"env": {
"PROJECT_ROOT": "/Users/natalie/Code/@projects/@magic-civilization"
}
},
"magic-civ": {
"command": "node",

View file

@ -536,4 +536,6 @@
| [p3-28](p3-28-modular-turn-architecture.md) | 🟡 partial | P3 | Modular turn architecture — break dep cycle, phase registry, boot-config DRY | [warcouncil](../team-leads/warcouncil.md) | 🟢 |
| [p3-29](p3-29-rail1-turn-unification.md) | 🟡 partial | P3 | Rail-1 turn unification — live game calls the Rust turn, delete GDScript orchestration | [warcouncil](../team-leads/warcouncil.md) | 🟢 |
| [p3-30](p3-30-wild-creature-ai-rust-port.md) | 🟡 partial | P3 | Port wild-creature AI from GDScript to Rust (Rail-1 compliance) | [warcouncil](../team-leads/warcouncil.md) | 🟢 |
| [p3-31](p3-31-replay-live-recording.md) | ❌ missing | P3 | Replay recording — live games archive a GameHistory (per-turn TurnSnapshot + events) on game-over | [shipwright](../team-leads/shipwright.md) | 🟢 |
| [p3-32](p3-32-replay-visual-map-rendering.md) | ❌ missing | P3 | Replay rendering — visual map playback (terrain + city/unit markers) from the archive | [shipwright](../team-leads/shipwright.md) | 🔒 p3-31 |

View file

@ -17,8 +17,8 @@
| **P0** | 0 | 0 | 0 | 0 | 0 | 44 | 44 |
| **P1** | 0 | 0 | 0 | 0 | 1 | 88 | 89 |
| **P2** | 0 | 0 | 0 | 0 | 1 | 132 | 133 |
| **P3 (oos)** | 0 | 7 | 0 | 0 | 29 | 34 | 70 |
| **total** | **0** | **7** | **0** | **0** | **31** | **298** | **336** |
| **P3 (oos)** | 0 | 7 | 0 | 2 | 29 | 34 | 72 |
| **total** | **0** | **7** | **0** | **2** | **31** | **298** | **338** |
</td><td valign='top' style='padding-left:2em'>
@ -27,6 +27,7 @@
| Team Lead | Remaining |
|---|---|
| [warcouncil](../team-leads/warcouncil.md) | 7 |
| [shipwright](../team-leads/shipwright.md) | 2 |
</td></tr></table>

View file

@ -1,13 +1,13 @@
{
"generated_at": "2026-06-27T11:35:58Z",
"generated_at": "2026-06-27T13:22:12Z",
"totals": {
"done": 298,
"in_progress": 0,
"partial": 7,
"stub": 0,
"missing": 0,
"missing": 2,
"oos": 31,
"total": 336
"total": 338
},
"objectives": [
{
@ -3707,7 +3707,7 @@
"status": "partial",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-06-26",
"updated_at": "2026-06-27",
"blocked_by": [],
"summary": "The per-subsystem sprawl noticed while porting climate/events/happiness/healing/ecology\nrevealed three SOLID/DRY/DIP debts. \"Foundation first\" tackled the layering + phase pieces."
},
@ -3732,6 +3732,30 @@
"updated_at": "2026-06-27",
"blocked_by": [],
"summary": "**Rail-1 gap surfaced during the p3-29 logic sweep (2026-06-27).** The live turn runs\nwild-creature AI **decision logic in GDScript**: `turn_processor.gd::_process_wild_creatures`\n(line 459) calls `wild_ai.process_wild_turn(game_map)` →\n`src/game/engine/src/modules/ai/wild_creature_ai.gd` (302 LOC — a guard / attack / roam state\nmachine over `owner == -1` units).\n\nThis is sim logic, not presentation, so it violates Rail-1 (\"GDScript is presentation only\";\n\"AI decision-making lives in Rust\"). It is **distinct from [[p0-26-ai-tactical-rust-port]]**,\nwhich ported *player* tactical AI (`simple_heuristic_ai.gd` / `ai_tactical.gd` / `ai_military.gd`)\nand explicitly did not touch wild-creature behaviour. It is also distinct from the fauna\n*population/rendering/stats* objectives (`g2-08`, `p3-12`, `p1-49`, `p2-58a`) — those model\necology; this is the per-creature **combat behaviour AI**.\n\nThe combat *resolution* for wilds already lives in Rust (`mc-combat::wilds`); only the\n*decision* layer (who to attack, when to roam, leash enforcement) is still GDScript."
},
{
"id": "p3-31",
"title": "Replay recording — live games archive a GameHistory (per-turn TurnSnapshot + events) on game-over",
"priority": "p3",
"status": "missing",
"scope": "game1-stretch",
"owner": "shipwright",
"updated_at": "2026-06-27",
"blocked_by": [],
"summary": ""
},
{
"id": "p3-32",
"title": "Replay rendering — visual map playback (terrain + city/unit markers) from the archive",
"priority": "p3",
"status": "missing",
"scope": "game1-stretch",
"owner": "shipwright",
"updated_at": "2026-06-27",
"blocked_by": [
"p3-31"
],
"summary": ""
}
],
"blocked": [
@ -3976,12 +4000,22 @@
"blockedBy": [
"p0-36"
]
},
{
"id": "p3-32",
"blockedBy": [
"p3-31"
]
}
],
"remaining_by_lead": [
{
"owner": "warcouncil",
"remaining": 7
},
{
"owner": "shipwright",
"remaining": 2
}
]
}

View file

@ -48,14 +48,48 @@ expansion, tech/science, fauna encounters, combat/siege, diplomacy. Verified liv
terrain effects headless; `process_events` returns the fired list but `step()` discards it
(`let _fired =`, processor.rs:1117). The SYSTEM runs in self-play; only replay/observation
visibility is missing. Low priority (parallels the p3-29 §A event surface, render-gated payoff).
- [ ] **Gap 3 — Equipment / crafting.** `mc-city/recipes.rs` + `enqueue_item` exist but
there is no headless `Craft`/`Equip` `PlayerAction` and crafting isn't in the bench
turn. Add the action(s) + dispatch + wire recipe resolution (gating resources, quality)
into the headless production/turn so equipment is craftable in self-play.
- [x] **Gap 3 — Equipment / crafting — DONE** ✅ (verified 2026-06-27; orig bullet stale).
The full craft→equip→combat path runs headless and Rust-authoritative:
- **Action + dispatch:** `PlayerAction::CraftEquipment { unit_id, item_id }`
([action.rs:195](../../src/simulator/crates/mc-player-api/src/action.rs)) → `craft_equipment`
([dispatch.rs:1521](../../src/simulator/crates/mc-player-api/src/dispatch.rs)) checks all
materials against `strategic_ledger`, consumes them (saturating), pushes `mc_items::EquippedItem`
onto the unit. 2 tests: `craft_equipment_consumes_materials_and_equips`,
`craft_equipment_rejects_insufficient_materials` (dispatch.rs:2446/2462).
- **Recipe resolution in the turn:** `recipe_phase::process_recipe_phase` ("recipe_refine") sits in
`sim_phases::END_OF_TURN_PHASES` ([sim_phases.rs:31](../../src/simulator/crates/mc-turn/src/sim_phases.rs)) —
every self-play turn refines raw→product (quality-tiered) via `mc_city::recipes::tick_recipes`
against each player's stockpile. Test `recipe_phase_refines_resources` (recipe_phase.rs:70).
- **Equipment affects combat:** `equip_combat_bonus` sums equipped-item atk/def from the boot-loaded
`item_combat` table at every combat site ([processor.rs:56/2934/3771](../../src/simulator/crates/mc-turn/src/processor.rs));
2 tests (processor.rs:5496/5506).
- **Boot path:** `item_combat` loaded into headless via `set_item_combat_json` FFI
([player_api.rs:208](../../src/simulator/api-gdext/src/player_api.rs)) from the headless harness
`_apply_item_combat` ([player_api_main.gd:255](../../src/game/engine/scenes/headless/player_api_main.gd)).
- **NON-gap (deliberate):** the MCTS rollout policy exposes 9 GPU-rollout-legal `ActionKind`s
(policy.rs:79-87) — `CraftEquipment` is a deterministic player action, not a strategic rollout
choice, so the self-play AI not *electing* to craft is the same intentional constraint as the
GPU policy surface, not a missing system. Recipe refinement (the passive crafting economy) DOES
run autonomously every self-play turn. Verified green: mc-turn + mc-player-api 557/0.
- [ ] **Gap 4 — Per-building build queues.** Bench `CityState` has a single `queue`;
per-building queues live in the full `mc_city::City` (live game). This is the dual
city-model split ([[p3-25 ...]] step 6 / city_slot.rs). Either give `CityState`
per-building queues or unify the models so the headless turn simulates them.
- **Assessment (2026-06-27) — likely PARITY-not-gap for the self-play criterion; owner scope call pending.**
Verified: bench `CityState.queue` is `Option<Queueable>` (single in-progress slot,
[mc-city/src/lib.rs:138](../../src/simulator/crates/mc-city/src/lib.rs)); `process_city_production`
completes it then clears to `None` (processor.rs:1604/1615); the bench AI sets exactly one item via
`apply_queue_production`. The live `construction_queue` is a FIFO **list**
([city.gd:74](../../src/game/engine/src/entities/city.gd)) — but a FIFO of buildings is functionally
*select-the-next-item-when-the-current-completes*, which single-slot + per-turn AI reselection already
achieves; the **simulation outcome** (what gets built, in what order) is identical. The multi-item
queue is a player-UX affordance (batch decisions ahead of time), not a self-play simulation behavior.
So for p3-26's bar ("a full self-play game with ALL **systems**") this is arguably already met. The
genuine multi-queue/per-building-item surface belongs to the live-game **projection** arc
([[p3-25-unify-dual-city-model]] / [[p3-29-rail1-turn-unification]]), where the UI reads `getState()`.
**Owner decision needed:** does p3-26 require the bench to *simulate* a multi-item queue, or is
single-slot+reselection the accepted headless model (Gap 4 reclassified out of p3-26 into the p3-25/29
projection arc)?
## Notes

View file

@ -5,7 +5,7 @@ priority: p3
scope: game1
owner: warcouncil
status: partial
updated_at: 2026-06-26
updated_at: 2026-06-27
---
## Summary
@ -24,9 +24,20 @@ revealed three SOLID/DRY/DIP debts. "Foundation first" tackled the layering + ph
living world = one registry line + a phase module, no step() edit.
- [ ] **Boot-config DRY (Opportunity A)** — collapse the 6 `set_*_json` FFI setters + 9
`_apply_*` GDScript harness loaders + N `#[serde(skip)]` GameState fields into one
Rust-native `boot_from_resources(path)` reading `public/resources/*` directly (Rust already
does this in tests). Removes GDScript-shuttling-data (Rail-3 smell); single source of truth
= the JSON files. NOT YET DONE (was the separate option 2).
Rust-native content loader. Removes GDScript-shuttling-data (Rail-3 smell); single source of
truth = the JSON files. NOT YET DONE (was the separate option 2).
**⚠ WASM constraint (verified 2026-06-27) — a file-reading `boot_from_resources(path)` is
wrong:** shared simulation code compiles to WASM too, and the web guide has **no filesystem**.
The loader must be a `ContentRegistry` populated by the *host* at engine init — Godot passes
`res://` bytes across FFI, the web guide fetches packs → bytes across bindgen — with
`include_str!` surviving **only** as the headless/test fallback. So the target is a registry
fed by injected bytes, not a path-reading function.
- [ ] **Dedup the ad-hoc `include_str!` content sites (Opportunity A, same arc)** — verified
2026-06-27: **8 JSON-config `include_str!` sites** across simulator crates, each rolling its
own `OnceLock` + a fragile relative path (6 at `../../../../../`). `treaty_rules.json` is
embedded **3 separate times** (no dedup). These fold into the `ContentRegistry` above —
`registry.get::<T>("promotions")` replaces per-crate path+parse. (WGSL shader `include_str!`s
are correct to embed and stay.)
- [ ] **Widen the registry** — optionally fold climate (convert the method to a free fn) +
happiness into the registry / a positioned-phase model so the whole turn sequence is data.
@ -36,3 +47,27 @@ Created 2026-06-26. The registry currently covers the contiguous end-of-turn wor
(ecology, healing); climate stays a TurnProcessor method (shares climate helpers) and happiness
stays positioned post-economy — both deliberately, to avoid risky reordering. The big remaining
win is the boot-config DRY (3 layers → 1 Rust loader).
### Content-loading audit (2026-06-27) — the two-path divergence the registry must kill
Why Opportunity A matters beyond DRY: content currently reaches the simulation **two different
ways**, and they silently drift apart.
- **In-game (Godot drives):** GDScript `DataLoader` reads packs at runtime and passes overrides
across FFI (`mc-player-api/src/projection.rs:41` — *"overrides from DataLoader for the in-game
path"*).
- **Headless (Rust drives alone):** no DataLoader, so Rust falls back to its compile-time
`include_str!` copy (`mc-player-api/src/dispatch.rs:410` — *"build-time copy for this headless
path (no GDScript DataLoader)"*).
When one side is edited and the other isn't, the two paths disagree. **Instance #1 (fixed this
session):** `mc-combat` promotion XP hardcoded `XP_THRESHOLDS = [10,30,60,100,…]` + 50% heal,
while the canonical `public/resources/promotions/promotions.json` said `[15,30,45,60]` + 30%
heal. This was a Rail-2 violation (Rust hardcoding game content) AND a two-path divergence.
Fixed by routing `mc-combat::{check_promotion,heal_on_promote,max_promotion_level,xp_threshold}`
through a `OnceLock`+`include_str!` config loaded from `promotions.json` (owner confirmed the JSON
values are canonical — the 4 thresholds match the 4-level promotion trees; the Rust 8-level curve
had 4 phantom levels). `cargo test -p mc-combat -p mc-turn -p mc-player-api` green.
That fix is the per-file pattern applied once; the `ContentRegistry` (Opportunity A) is the
structural fix that makes the divergence **impossible** — both hosts feed one registry, so there
is no second copy to drift.

View file

@ -0,0 +1,83 @@
---
id: p3-31
title: "Replay recording — live games archive a GameHistory (per-turn TurnSnapshot + events) on game-over"
priority: p3
status: missing
scope: game1-stretch
category: simulation
owner: shipwright
created: 2026-06-27
updated_at: 2026-06-27
blocked_by: []
follow_ups: [p3-32]
related: [p2-46, mc-replay-followup-unit-spawn-events]
---
## Context
The replay surface (`p2-46`) and the projected match-chronicle (standings ladder
+ event feed via `GameHistory::standings_at`, shipped 2026-06-26) both work — but
**only against synthetic fixtures**. No real game is ever archived, so there is
nothing real to replay.
Verified state (2026-06-27):
- `write_game` is called **only** from `GdReplayArchive::write_fixture`
(`src/simulator/api-gdext/src/replay.rs`). No production path archives a game.
- `TurnSnapshot` is pushed **only** in `write_fixture` (`replay.rs:637`). The live
game never records a per-turn snapshot — `mc-replay/src/snapshot.rs` still
documents itself as "the schema, not the writer."
- `TurnEvent`s **are** emitted in `mc-turn` during headless turn resolution
(`p2-46` + `mc-replay-followup-unit-spawn-events`: CityFounded/CityCaptured/
UnitKilled/UnitCreated/WonderBuilt/TechResearched land in `TurnResult`), but the
**live Godot turn loop** (`turn_manager.gd``AiTurnBridge`) is a separate path;
whether it populates a `TurnEventCollector` and where those events would be
archived is unwired.
The goal: a finished game — including an `OBSERVER` cast or an `AI_ARENA` match —
produces a `GameHistory` on disk so it can be re-watched and compared.
## Source-of-truth rails
- **Rust (`mc-replay` + the recorder)** owns: `TurnSnapshot` derivation per clan
per turn (population / cities / army_strength / gold / tech_count / land_area /
score — already computed by the score + economy crates), event collection into
`GameHistory.events`, and `write_game` on game-over. No stats math or archive
I/O in GDScript (Rail-1).
- **GDScript** only: fires the game-over trigger, hands the recorder the
per-turn hook, and reads the archive back via the existing `GdReplayArchive` /
`GdReplayPlayer` bridges (Rail-3).
- **JSON**: archive-root path stays in `EnvConfig`; retention policy in Settings
(both already exist from `p2-46`). No new tunables hardcoded (Rail-2).
## Acceptance bullets
- [ ] A `GdGameRecorder` (or equivalent) holds a session `GameHistory` and, on a
per-turn hook, appends one `TurnSnapshot` **per clan** with the live stat
values. After an N-turn headless `OBSERVER`/`AI_ARENA` game, `history.snapshots`
has `N × clans` rows. Projection (`standings_at`) is unchanged.
- [ ] The live turn path collects the same `TurnEvent`s the headless `mc-turn`
path already emits (CityFounded/CityCaptured/UnitKilled/UnitCreated/WonderBuilt/
TechResearched/WarDeclared/…) into the session `GameHistory.events`. (First
determine whether `turn_manager.gd`'s path already drains a collector; wire it
if not.)
- [ ] `write_game` is invoked on game-over (victory **and** turn-limit), writing
`meta.json` + `history.bin` under `<archive_root>/<pack>/<game-id>/`.
`GdReplayArchive.list()` returns the just-finished game; `GameState`'s
`last_archived_game_id` is set for the end-game summary.
- [ ] `end_game_summary.gd` "Watch Replay" and `past_games.gd` open the
freshly-recorded game and the chronicle (standings ladder + event feed) renders
the **real** match, not a fixture. Proof screenshot reviewed in conversation.
- [ ] Rust round-trip test: build a multi-turn `GameHistory` via the recorder,
`write_game``read_game`, assert snapshots + events survive and
`standings_at` returns the recorded ladder. `cargo test -p mc-replay` green.
- [ ] Determinism: same seed + same controller ids → byte-identical recorded
snapshots + event-count vectors across two runs.
## Out of scope
- Visual map/unit replay rendering — tracked by `p3-32` (this objective only
makes real games *recordable*; that one makes them *watchable on a map*).
- Cloud sync of archives; replay branching / mid-game forking.
- Per-turn delta compression beyond bincode (the `p2-46` 10 MB cap assert still
governs; optimise only if it fires).

View file

@ -0,0 +1,78 @@
---
id: p3-32
title: "Replay rendering — visual map playback (terrain + city/unit markers) from the archive"
priority: p3
status: missing
scope: game1-stretch
category: presentation
owner: shipwright
created: 2026-06-27
updated_at: 2026-06-27
blocked_by: [p3-31]
follow_ups: []
related: [p2-46, p3-31]
---
## Context
The replay viewer currently renders a **match chronicle** — the standings ladder
(from `GameHistory::standings_at`) + a cumulative event feed — but not the map
itself. `replay_viewer.gd` documents this as deliberate: the archive carries no
per-turn geometry, so a faithful map replay needs more recorded data.
Verified state (2026-06-27):
- The archive stores **no terrain** — only `GameHistory.seed` + a `MapDescriptor`
(`kind`, `width`, `height`). Terrain is *reproducible* by re-running worldgen
from the seed (deterministic), not stored.
- **No `UnitMoved` event** exists in the `TurnEvent` enum
(`mc-replay/src/event.rs` — only a doc-comment mention). Units therefore cannot
be tracked turn-by-turn from the event stream.
- However `UnitCreated`, `UnitKilled`, and `UnitCaptured` **do** carry a hex
(`col`/`row`), and `CityFounded`/`CityCaptured` carry the city hex + owner. So
*spawn / death / capture positions* and *all city positions* are already
recoverable from events.
This objective renders what the recorded data supports: a regenerated terrain map
with city markers and coarse unit pings, with continuous unit motion as a gated
stretch that requires a new event.
## Source-of-truth rails
- **Rust** owns: map regeneration from the seed (the existing worldgen crates),
and — for the stretch tier — a new `TurnEvent::UnitMoved { turn, unit_id, from,
to }` plus its emission in `mc-turn`. Projection of "city/unit markers at turn
t" is a pure `GameHistory` method in `mc-replay` (Rail-1), mirroring
`standings_at`.
- **GDScript** only mounts the existing `hex_renderer` / `city_renderer` in a
projection mode and paints the Rust-projected markers as the scrubber moves. No
worldgen or position logic in GDScript (Rail-3).
## Acceptance bullets
- [ ] **Terrain**`replay_viewer` regenerates the map from `GameHistory.seed`
(deterministic worldgen) and renders it god-view (fog off) by mounting the
existing `hex_renderer`. The regenerated map matches the seed's live map.
- [ ] **City markers** — a Rust projection returns city {hex, owner, name} as of
turn `t` from cumulative `CityFounded`/`CityCaptured` events; `city_renderer`
paints them. Markers update correctly as the scrubber moves forward/back.
- [ ] **Unit pings (coarse)**`UnitCreated`/`UnitKilled`/`UnitCaptured` hexes
surface as transient spawn/death/capture pings at their turn (no continuous
position; documented limitation).
- [ ] **Continuous unit positions (stretch, schema change)** — add
`TurnEvent::UnitMoved { turn, unit_id, from, to }` to `mc-replay`, emit it at
every `mc-turn` move site, record it (depends on `p3-31`), and project per-turn
unit positions so the map shows units tracking turn-by-turn. Gated; ship the
marker tiers first.
- [ ] **Proof**`replay_viewer` showing the regenerated terrain + city markers
at a mid-turn, scrubable; screenshot reviewed in conversation per the phase-gate
ritual. Extend `proof_replay_viewer.tscn`.
## Out of scope
- Re-simulation under updated rules (the replay projects recorded state, never
re-runs `mc-turn` — Rail-1).
- Persisting a full per-turn `WorldSnapshot` (terrain + every entity) into the
archive — heavy bloat; only revisit if seed-regen + events prove insufficient.
- Animating combat / movement tweens beyond what the live arena playback already
does; this objective is about *what* renders, not cinematic polish.

View file

@ -1,13 +1,13 @@
{
"generated_at": "2026-06-27T07:58:32Z",
"generated_at": "2026-06-27T13:19:18Z",
"totals": {
"in_progress": 0,
"partial": 6,
"stub": 1,
"missing": 0,
"oos": 31,
"missing": 2,
"partial": 7,
"done": 296,
"total": 334
"oos": 31,
"stub": 0,
"in_progress": 0,
"total": 336
},
"objectives": [
{
@ -3344,11 +3344,31 @@
"id": "p3-30",
"title": "Port wild-creature AI from GDScript to Rust (Rail-1 compliance)",
"priority": "p3",
"status": "stub",
"status": "partial",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-06-27",
"summary": "**Rail-1 gap surfaced during the p3-29 logic sweep (2026-06-27).** The live turn runs\nwild-creature AI **decision logic in GDScript**: `turn_processor.gd::_process_wild_creatures`\n(line 459) calls `wild_ai.process_wild_turn(game_map)` →\n`src/game/engine/src/modules/ai/wild_creature_ai.gd` (302 LOC — a guard / attack / roam state\nmachine over `owner == -1` units).\n\nThis is sim logic, not presentation, so it violates Rail-1 (\"GDScript is presentation only\";\n\"AI decision-making lives in Rust\"). It is **distinct from [[p0-26-ai-tactical-rust-port]]**,\nwhich ported *player* tactical AI (`simple_heuristic_ai.gd` / `ai_tactical.gd` / `ai_military.gd`)\nand explicitly did not touch wild-creature behaviour. It is also distinct from the fauna\n*population/rendering/stats* objectives (`g2-08`, `p3-12`, `p1-49`, `p2-58a`) — those model\necology; this is the per-creature **combat behaviour AI**.\n\nThe combat *resolution* for wilds already lives in Rust (`mc-combat::wilds`); only the\n*decision* layer (who to attack, when to roam, leash enforcement) is still GDScript."
},
{
"id": "p3-31",
"title": "\"Replay recording — live games archive a GameHistory (per-turn TurnSnapshot + events) on game-over\"",
"priority": "p3",
"status": "missing",
"scope": "game1-stretch",
"owner": "shipwright",
"updated_at": "2026-06-27",
"summary": ""
},
{
"id": "p3-32",
"title": "\"Replay rendering — visual map playback (terrain + city/unit markers) from the archive\"",
"priority": "p3",
"status": "missing",
"scope": "game1-stretch",
"owner": "shipwright",
"updated_at": "2026-06-27",
"summary": ""
}
]
}