From a9b92df51b2169ba572e9f2600ce3b3400b49bd1 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 27 Jun 2026 03:58:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=93=A1=20p3-29=20(step=202)=20=E2=80=94=20surface=20turn?= =?UTF-8?q?=20events=20in=20GdTurnProcessor.step=20result=20dict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit turn_result_to_dict now includes an "events" array (each TurnEvent mapped via the reused replay::event_to_dict, now pub(crate)) — CityGrew, CityBordersExpanded, FloraSuccession, CityBuildingCompleted, UnitCreated, CityCaptured, etc. So when turn_manager adopts GdTurnProcessor.step (the Rail-1 swap), it can translate result["events"] → EventBus signals and the GDScript turn orchestration can be deleted. gdext compiles. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../objectives/p1-43b-deep-chain-authoring.md | 2 +- .../p3-16-ai-proactive-war-declaration.md | 2 +- .../p3-18-water-crossing-embark-transport.md | 7 +- .../p3-29-rail1-turn-unification.md | 73 ++++++++++++++++++ .../p3-30-wild-creature-ai-rust-port.md | 74 +++++++++++++++++++ src/simulator/api-gdext/src/lib.rs | 9 +++ src/simulator/api-gdext/src/replay.rs | 2 +- 7 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 .project/objectives/p3-30-wild-creature-ai-rust-port.md diff --git a/.project/objectives/p1-43b-deep-chain-authoring.md b/.project/objectives/p1-43b-deep-chain-authoring.md index 5698dfd7..203d2ad3 100644 --- a/.project/objectives/p1-43b-deep-chain-authoring.md +++ b/.project/objectives/p1-43b-deep-chain-authoring.md @@ -25,7 +25,7 @@ evidence: - cargo test -p mc-city --lib test_all_authored_buildings_deserialize — green; cargo test -p mc-city -p mc-turn --lib — 197 passed; cargo check --workspace — green - "public/resources/buildings/grand_chronicle.json — re-pointed requires_existing: bardic_circle → clan_atelier" - "public/resources/buildings/gravity_press.json — re-pointed requires_existing: mithril_forge → industrial_smelter, effects re-summed" -blocked_by: [p1-43a (engine + schema + chain-extension proof landed inline in p1-43)] +blocked_by: [p1-43a] --- ## Summary diff --git a/.project/objectives/p3-16-ai-proactive-war-declaration.md b/.project/objectives/p3-16-ai-proactive-war-declaration.md index d73fbfb4..a283ad49 100644 --- a/.project/objectives/p3-16-ai-proactive-war-declaration.md +++ b/.project/objectives/p3-16-ai-proactive-war-declaration.md @@ -15,7 +15,7 @@ evidence: - public/games/age-of-dwarves/data/ai_personalities.json (aggression axis 1–10) - "src/simulator/crates/mc-player-api/tests/ai_self_play_first_contact.rs (ai_self_play_declares_war_on_discovered_weaker_rival — deterministic self-play: explore→discover→war-dec flips the relation to War within 60 turns; commit 121f88bc8)" - "src/simulator/crates/mc-ai/src/tactical/movement.rs (is_at_war:366 cleanup — absent relation defaults to PEACE per courier-diplomacy, superseding p1-01 missing→war; commit 1968e5a83)" -blocked_by: [] # p3-17 landed (done) — the exploration discovery feed is live +blocked_by: [] --- ## Summary diff --git a/.project/objectives/p3-18-water-crossing-embark-transport.md b/.project/objectives/p3-18-water-crossing-embark-transport.md index 315bd4e9..7fbd1036 100644 --- a/.project/objectives/p3-18-water-crossing-embark-transport.md +++ b/.project/objectives/p3-18-water-crossing-embark-transport.md @@ -11,12 +11,7 @@ evidence: - "Transport: data model (ef697f492); board/carry/disembark (f05aaff2a); cargo lost with hull (93ce7c848)" - "UI embark: GdTechWeb.embark_level #[func] (c90ba8719); pathfinder.gd + world_map wiring (981996529); dead UI removed (bb6eb6ee5); vestigial FFI trimmed (e42fa0d0b) — GUT 745/0 with rebuilt dylib" - "End-to-end deterministic: ford proof (4a81f0d16); cross-water-attack capstone (cccea8c90); P5b GUT proof test_pathfinder_embark.gd (18aad5cdf)" -note: | - Core feature complete + verified across sim, AI, and UI (cargo + integration + - GUT all green). Two documented, explicitly-optional remainders (NOT blocking): - a full headless 1v1-to-game_over demo (fragile; the deterministic ford+attack - proofs are stronger), and transport carried-unit individual-target protection - (1UPT-risky; rare tier-9 scenario; the hull-death prune already covers cargo loss). +note: "Core feature complete + verified across sim, AI, and UI (cargo + integration + GUT all green). Two documented, explicitly-optional remainders (NOT blocking): a full headless 1v1-to-game_over demo (fragile; the deterministic ford+attack proofs are stronger), and transport carried-unit individual-target protection (1UPT-risky; rare tier-9 scenario; the hull-death prune already covers cargo loss)." --- ## Summary diff --git a/.project/objectives/p3-29-rail1-turn-unification.md b/.project/objectives/p3-29-rail1-turn-unification.md index 179ebca7..6885a87e 100644 --- a/.project/objectives/p3-29-rail1-turn-unification.md +++ b/.project/objectives/p3-29-rail1-turn-unification.md @@ -148,3 +148,76 @@ them — flag so it is not a surprise at render-proof: it none of §A reaches the live UI. Not yet started. 4. **Decisions:** `item_produced` (event vs fold), `strategic_gate_rejected` + round-lifecycle markers (surface via `step` vs worldsim-bridge/GDScript carve-out, ties to acceptance #4). + +## Coder handoff (2026-06-27) — ordered, file-cited task list + +**Scope correction.** Commit 9e0013a02 says "step 1 complete", but it closed only the audit's +*named 3* events (`CityGrew`, `CityBordersExpanded`, `FloraSuccession`). Per the §A matrix above, +the granular event surface is **not** complete: 4 §A variants are still missing and the §B dict +keystone is not started. "Step 1" = the full §A+§C set, not the named 3. The tasks below are the +real remaining steps 1-2 (Rust + headless only, live game untouched, no render-proof needed). + +**Proven pattern (5 touch-points per new event — follow `CityGrew`/`CityBordersExpanded`):** +1. `mc-replay/src/event.rs` — add variant (+ `turn: u32`) + arm in `TurnEvent::turn()` + a serde + round-trip case in the `#[cfg(test)]` block. +2. emit at the **compute site** that already decides the thing (do NOT recompute). +3. `mc-player-api/src/dispatch.rs::translate_processor_events` — add an exhaustive arm (translate + to a `wire::Event` when one exists, else drop in the `=> {}` group; keeps the match exhaustive). +4. `api-gdext/src/replay.rs::event_to_dict` — add a `kind`-tagged dict arm. +5. `mc-turn/tests/event_collector_wiring.rs` — add the variant to BOTH name-map matches (lines + ~207 and ~325) so the 10-turn wiring test compiles + asserts it can fire. + +### BLOCKING decisions (need an owner ruling before coding T3/T-dec) + +- **D1 — `item_produced`:** is a crafted item distinct from a building, or fold into + `CityBuildingCompleted`? GDScript fires `item_produced` separately (`_process_production`, + turn_processor.gd:130) AND `city_building_completed`. Recommend a distinct `ItemProduced` + event (mirrors the separate signal); confirm. +- **D2 — `strategic_gate_rejected` + round-lifecycle markers** (`fauna_round_*`, + `worldsim_round_*`, `player_round_*`, `game_phase_changed`): surface through `step()` / + `TurnResult`, or keep the existing worldsim-bridge + GDScript path as the acceptance-#4 + carve-out? Recommend carve-out (already Rust-sourced via the bridge); confirm. + +### Tasks (each is a self-contained PR; headless-verifiable) + +- **T1 — `CultureResearched`** {turn, clan, tradition: String}. Emit in + `processor.rs::process_culture_research` on tradition completion. `wire::Event::CultureResearched` + already exists (wire.rs:293) → dispatch TRANSLATES (not drops). DoD: wiring test asserts it + fires in a run that completes a tradition; serde round-trip green. +- **T2 — `UnitHealed`** {turn, clan, unit_id, amount, hex}. Emit per healed unit in + `healing.rs`. Needs an events sink into the healing phase — reuse the same `&mut Vec` + threading the §A phases already use (NOT the registry signature). DoD: wiring test + a damaged + unit heals → exactly one event with correct `amount`. +- **T3 — `GoldenAgeStarted`** {turn, clan} (gated on D1-style confirm it's start-only; add + `GoldenAgeEnded` only if the live UI consumes `golden_age_ended` — it does, event_bus.gd:176, + so add both). Emit on the false→true (and true→false) `golden_age_active` transition in + `happiness_phase.rs::process_golden_age`. DoD: wiring test + transition fires once per edge. +- **T4 — `ItemProduced`** (pending D1). If distinct: {turn, clan, city, item_id, hex}, emit in + `process_city_production` alongside `CityBuildingCompleted`. +- **T5 — §C creature/biome:** `CreatureBorn` / `CreatureDied` / `BiomeChanged` from the ecology + tick. **Reuse the `GameState.pending_flora_events` buffer pattern** introduced for + `FloraSuccession` (7b6d24bde) — buffer in the phase, drain in `step()` — to dodge the registry + signature cascade. DoD: wiring test sees them in a multi-turn ecology run. +- **T6 — §B KEYSTONE (do this regardless of T1-T5 progress):** add a generic `events: Array` + to `turn_result_to_dict` (api-gdext/src/lib.rs:6562) by looping `result.events_emitted` through + `event_to_dict` (replay.rs — check/raise its visibility to `pub(crate)`/shared). This is what + makes ANY §A event reach the live game; currently none do. Keep the existing typed surfaces + (ambient/captured/ransom) for back-comat. DoD: a GDScript-side read (headless `view`/dict dump) + shows `events` populated with `kind`-tagged entries after a turn that grows a city. + +### Swap-phase carve-out to track (steps 3-5, not now) + +- `_process_wild_creatures` (turn_processor.gd:459) runs **GDScript wild-creature AI** + (`wild_creature_ai.gd`, 302 LOC — guard/attack/roam) inside the live turn. This is a Rail-1 + logic gap NOT covered by p0-26 (player AI) and NOT an event. The unified turn must either drive + a Rust wild-AI or keep this as a declared carve-out. **Tracked by [[p3-30-wild-creature-ai-rust-port]].** + +### Audit honesty note + +This handoff covers **event-emission parity** + the dict keystone. It does NOT assert **numeric +parity** (that each Rust phase computes the same values as its GDScript twin) — that is the swap's +render-proof + GUT job (acceptance #5/#6). Helper fns in turn_processor.gd (`_apply_building_bonuses`, +`_sum_city_building_effect`, `_grant_free_tech`, `_check_resource_reveals`, `_build_border_candidates_json`) +ride along with the orchestration deletion in steps 3-5 — verify their logic is covered by the Rust +phase before deleting, not assumed. `_get_healing_rate` is confirmed mirrored (healing.rs); +the others are unverified. diff --git a/.project/objectives/p3-30-wild-creature-ai-rust-port.md b/.project/objectives/p3-30-wild-creature-ai-rust-port.md new file mode 100644 index 00000000..be8fce4c --- /dev/null +++ b/.project/objectives/p3-30-wild-creature-ai-rust-port.md @@ -0,0 +1,74 @@ +--- +id: p3-30 +title: Port wild-creature AI from GDScript to Rust (Rail-1 compliance) +priority: p3 +status: open +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 +wild-creature AI **decision logic in GDScript**: `turn_processor.gd::_process_wild_creatures` +(line 459) calls `wild_ai.process_wild_turn(game_map)` → +`src/game/engine/src/modules/ai/wild_creature_ai.gd` (302 LOC — a guard / attack / roam state +machine over `owner == -1` units). + +This is sim logic, not presentation, so it violates Rail-1 ("GDScript is presentation only"; +"AI decision-making lives in Rust"). It is **distinct from [[p0-26-ai-tactical-rust-port]]**, +which ported *player* tactical AI (`simple_heuristic_ai.gd` / `ai_tactical.gd` / `ai_military.gd`) +and explicitly did not touch wild-creature behaviour. It is also distinct from the fauna +*population/rendering/stats* objectives (`g2-08`, `p3-12`, `p1-49`, `p2-58a`) — those model +ecology; this is the per-creature **combat behaviour AI**. + +The combat *resolution* for wilds already lives in Rust (`mc-combat::wilds`); only the +*decision* layer (who to attack, when to roam, leash enforcement) is still GDScript. + +## Why it matters now + +`p3-29` (Rail-1 turn unification) swaps the live turn onto `mc_turn::TurnProcessor::step`. The +Rust step has no wild-creature AI pass, so the swap would either (a) leave this as a +GDScript-driven appendage outside `step()` — a permanent Rail-1 carve-out — or (b) drop wild +behaviour entirely. Porting it lets `step()` own the whole turn and removes the last in-loop +GDScript decision system. + +## What `wild_creature_ai.gd` decides (port these, do not recompute) + +- `process_wild_turn` — per-`owner==-1` unit, once per game turn. +- `_act` — detection-radius target search → step toward + attack if adjacent & `has_attacked` + not set; else roam. +- `_find_attack_target` — nearest player unit within effective detection radius. +- `_find_nearest_lair` / home-lair anchoring; `roaming_leash_radius` enforcement (roam stays + within leash of home lair). +- `spawn_initial_creatures` — seeds creatures from `npc_buildings_all()` at game start + (decide: stays in the start-script/worldgen path, or moves with the AI — likely start path). +- Config keys read from the `wilds` data block (detection radius, leash, etc.) — keep + data-driven via JSON, do not hardcode. + +## Acceptance + +- [ ] `mc-ai` (or `mc-combat`/`mc-turn` per infra call) exposes a deterministic wild-creature + decision fn — `decide_wild_actions(state, rng) -> Vec` — covering target-select, + step-toward, attack, and leashed roam, reusing the existing `Action`/tactical plumbing from + p0-26 where it fits. +- [ ] Driven from inside `mc_turn::TurnProcessor::step` as a turn phase (so the p3-29 swap needs + no wild carve-out), OR via a `GdWildAiController` bridge if it must stay engine-driven — + infra decides; default is in-`step`. +- [ ] `wild_creature_ai.gd` DELETED (or reduced to a thin bridge with zero decision logic); + `_process_wild_creatures` becomes a no-op or bridge call. +- [ ] Determinism preserved — same seed + state → same wild actions (XorShift64, per the + `p1-09` contract). Regression test alongside the p0-26 tactical suite. +- [ ] Behaviour parity: headless run shows wilds still guard lairs, attack units in radius, and + roam within leash — compared against a pre-port baseline (not just "compiles"). +- [ ] GUT green; `cargo test` green. + +## Notes + +Created 2026-06-27 from the p3-29 coder handoff. Lower urgency than p3-29's event surface — the +live game still plays correctly with GDScript wild AI — but it is the **last in-turn GDScript +decision system**, so it must close before Rail-1 can be called complete. Sequence it AFTER +p3-29's event surface + dict keystone (T1-T6) so the swap has a clean target; it can land before +or alongside the p3-29 swap (steps 3-5). Cross-ref `mc-combat::wilds` (resolution already Rust) +to avoid duplicating combat math. diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 99de068e..4ac7ea36 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -6567,6 +6567,15 @@ fn turn_result_to_dict(result: &mc_turn::TurnResult, post_turn: u32) -> Dictiona d.set("units_lost_to_fauna", result.units_lost_to_fauna as i64); d.set("cities_harassed", result.cities_harassed_by_fauna as i64); + // p3-29 (step 2): surface the granular turn events (CityGrew, CityBordersExpanded, + // FloraSuccession, building/unit completion, captures, …) so the live turn_manager can + // translate them to EventBus signals when it adopts GdTurnProcessor.step (Rail-1 swap). + let mut events: Array = Array::new(); + for ev in &result.events_emitted { + events.push(&crate::replay::event_to_dict(ev)); + } + d.set("events", events); + let mut encounters = 0_i64; let mut deaths = 0_i64; let mut t4_t6_enc = 0_i64; diff --git a/src/simulator/api-gdext/src/replay.rs b/src/simulator/api-gdext/src/replay.rs index 39f8c5a1..e7d96a26 100644 --- a/src/simulator/api-gdext/src/replay.rs +++ b/src/simulator/api-gdext/src/replay.rs @@ -60,7 +60,7 @@ fn snapshot_to_dict(snap: &TurnSnapshot) -> Dictionary { d } -fn event_to_dict(evt: &TurnEvent) -> Dictionary { +pub(crate) fn event_to_dict(evt: &TurnEvent) -> Dictionary { let mut d = Dictionary::new(); match evt { TurnEvent::CityFounded { turn, clan, hex, name } => {