feat(@projects/@magic-civilization): 📡 p3-29 (step 2) — surface turn events in GdTurnProcessor.step result dict
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) <noreply@anthropic.com>
This commit is contained in:
parent
7f4b69eac1
commit
a9b92df51b
7 changed files with 160 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<TurnEvent>`
|
||||
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.
|
||||
|
|
|
|||
74
.project/objectives/p3-30-wild-creature-ai-rust-port.md
Normal file
74
.project/objectives/p3-30-wild-creature-ai-rust-port.md
Normal file
|
|
@ -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<Action>` — 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.
|
||||
|
|
@ -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<Dictionary> = 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;
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue