From 8696a48aa05275ede1d7a17ceb7aef7b950aac3f Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 27 Jun 2026 07:34:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=90=BA=20p3-30=20=E2=80=94=20GdWildAiController=20bridge?= =?UTF-8?q?=20(owner-chosen=20drive=20path)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner chose the bridge over a headless wild-unit substrate. Adds the JSON contract + a pure mc_ai::wild::decide_wild_actions_json(json, seed) helper (parses a WildContextDto — wilds, player_units, lairs, cities, config, passable set — runs the decision core, returns per-action JSON strings, the GdAiController envelope), and a thin GdWildAiController GDExtension shim (set_rng_seed + decide_actions → PackedStringArray) over it. The live game keeps its roaming owner==-1 units; GDScript projects them into the DTO and dispatches the returned move/attack Actions via the existing AI-action path — so the wild DECISION logic is fully Rust (Rail-1), no duplicated headless model. 16 wild tests (4 new JSON-bridge: chase/attack/ passable-roam/malformed), mc-ai lib 305/0; gdext cdylib links with the class registered. Remaining (render-gated): GDScript rewire of _process_wild_creatures + wild_creature_ai.gd deletion + render-proof. Co-Authored-By: Claude Opus 4.8 --- src/simulator/api-gdext/src/ai.rs | 67 +++++++++ src/simulator/crates/mc-ai/src/wild.rs | 189 +++++++++++++++++++++++++ 2 files changed, 256 insertions(+) diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index 8b2d8df7..f14b518c 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -930,6 +930,73 @@ pub fn run_tactical( .collect() } +// ── GdWildAiController ─────────────────────────────────────────────────────── + +/// Wild-creature decision controller (p3-30). +/// +/// Thin shim over [`mc_ai::wild::decide_wild_actions_json`]. The GDScript turn's +/// wild-creatures phase builds a `WildContextDto` JSON — its roaming `owner == -1` +/// creatures, the player units, lair + city hexes, the `wilds` config block, and +/// the set of passable hexes (in-bounds, not water, not player-occupied) derived +/// from the live `game_map` — and calls [`Self::decide_actions`]. The returned +/// `PackedStringArray` of JSON-encoded [`Action`] records is dispatched by the +/// same GDScript path that applies player-AI actions (move / attack), so the +/// wild DECISION logic lives entirely in Rust (Rail-1) while the live game keeps +/// its roaming wild units (the `GdWildAiController` bridge path, p3-30). +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdWildAiController { + /// Deterministic RNG seed, advanced per `decide_actions` call so successive + /// wild turns draw distinct xorshift streams (matches `GdAiController`). + rng_seed: u64, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdWildAiController { + fn init(base: Base) -> Self { + Self { + rng_seed: 0x9E37_79B9_7F4A_7C15, + base, + } + } +} + +#[godot_api] +impl GdWildAiController { + /// Override the xorshift seed used by the next `decide_actions` call. The + /// seed advances deterministically after each call, so pinning it pins the + /// wild action sequence for testing. GDScript seeds it from the game RNG. + #[func] + fn set_rng_seed(&mut self, seed: i64) { + self.rng_seed = seed as u64; + } + + /// Decide one action per wild creature from a `WildContextDto` JSON payload. + /// Returns each emitted `Action` as its own JSON string (same envelope as + /// `GdAiController::decide_actions`). Returns an empty array on a malformed + /// payload (logged), never panicking across the FFI boundary. + #[func] + fn decide_actions(&mut self, context_json: GString) -> PackedStringArray { + let seed = self.rng_seed; + // Advance deterministically (SplitMix64 step, matches GdAiController). + self.rng_seed = self.rng_seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + + let mut out = PackedStringArray::new(); + match mc_ai::wild::decide_wild_actions_json(&context_json.to_string(), seed) { + Ok(strings) => { + for s in strings { + out.push(&GString::from(s)); + } + } + Err(e) => { + godot_error!("GdWildAiController::decide_actions parse error: {}", e); + } + } + out + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/simulator/crates/mc-ai/src/wild.rs b/src/simulator/crates/mc-ai/src/wild.rs index 287aa5a9..1dd6483f 100644 --- a/src/simulator/crates/mc-ai/src/wild.rs +++ b/src/simulator/crates/mc-ai/src/wild.rs @@ -24,6 +24,10 @@ //! Keeping it a projection rather than a `GameState` borrow keeps this crate //! dependency-light and the logic trivially unit-testable. +use std::collections::HashSet; + +use serde::Deserialize; + use mc_core::algorithms::hex::{axial_distance, axial_neighbors}; use crate::mcts::XorShift64; @@ -306,6 +310,121 @@ fn roll(rng: &mut XorShift64, chance: u32) -> bool { r <= chance } +// ── Bridge contract (GdWildAiController ⇄ GDScript) ────────────────────────── + +/// JSON projection the GDExtension bridge sends — the owned, serde-friendly +/// mirror of [`WildContext`]. `passable` is the explicit set of hexes a creature +/// may enter (the GDScript bridge derives it from the live `game_map`: +/// in-bounds, not water, not occupied by a player unit), so the decision core +/// stays grid-free. All fields default, so a partial payload still parses. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct WildContextDto { + /// Creatures to decide for (the live `owner == -1` units). + #[serde(default)] + pub wilds: Vec, + /// Alive player-owned units (target candidates). + #[serde(default)] + pub player_units: Vec, + /// Lair hexes (home anchors; villages/ruins excluded by the bridge). + #[serde(default)] + pub lairs: Vec<(i32, i32)>, + /// City hexes (drift attractors). + #[serde(default)] + pub cities: Vec<(i32, i32)>, + /// Data-driven tunables (from the `wilds` JSON block). + #[serde(default)] + pub config: WildConfigDto, + /// Hexes a creature may step onto (in-bounds, not water, not player-occupied). + #[serde(default)] + pub passable: Vec<(i32, i32)>, +} + +/// Serde mirror of [`WildUnit`]. +#[derive(Debug, Clone, Deserialize)] +pub struct WildUnitDto { + /// Stable unit id. + pub id: u32, + /// Axial `(col, row)`. + pub hex: (i32, i32), + /// Movement points left this turn. + #[serde(default)] + pub movement_remaining: i32, + /// Already attacked this turn? + #[serde(default)] + pub has_attacked: bool, +} + +/// Serde mirror of [`PlayerUnitRef`]. +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct PlayerUnitDto { + /// Stable unit id. + pub id: u32, + /// Axial `(col, row)`. + pub hex: (i32, i32), +} + +/// Data-driven config overrides. Absent fields keep the GDScript defaults. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct WildConfigDto { + /// Override for `detection_radius`. + #[serde(default)] + pub detection_radius: Option, + /// Override for `leash_radius` (`roaming_leash_radius` in the data block). + #[serde(default)] + pub leash_radius: Option, +} + +/// Bridge entry point: parse a [`WildContextDto`] JSON payload, run +/// [`decide_wild_actions`] seeded by `seed`, and return each emitted [`Action`] +/// as its own JSON string — the same per-action envelope `GdAiController` +/// returns (`run_tactical`), so the GDScript action dispatch is shared. The +/// `GdWildAiController` GDExtension shim is a one-line wrapper over this. +pub fn decide_wild_actions_json( + context_json: &str, + seed: u64, +) -> Result, serde_json::Error> { + let dto: WildContextDto = serde_json::from_str(context_json)?; + + let passable: HashSet<(i32, i32)> = dto.passable.into_iter().collect(); + let predicate = |hex: (i32, i32)| passable.contains(&hex); + + let mut config = WildConfig::default(); + if let Some(d) = dto.config.detection_radius { + config.detection_radius = d; + } + if let Some(l) = dto.config.leash_radius { + config.leash_radius = l; + } + + let ctx = WildContext { + wilds: dto + .wilds + .into_iter() + .map(|w| WildUnit { + id: w.id, + hex: w.hex, + movement_remaining: w.movement_remaining, + has_attacked: w.has_attacked, + }) + .collect(), + player_units: dto + .player_units + .into_iter() + .map(|p| PlayerUnitRef { id: p.id, hex: p.hex }) + .collect(), + lairs: dto.lairs, + cities: dto.cities, + config, + passable: &predicate, + }; + + let mut rng = XorShift64::new(seed); + decide_wild_actions(&ctx, &mut rng) + .iter() + .map(serde_json::to_string) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -566,4 +685,74 @@ mod tests { assert_eq!(cfg.detection_radius, DEFAULT_DETECTION_RADIUS); assert_eq!(cfg.leash_radius, DEFAULT_LEASH_RADIUS); } + + #[test] + fn json_bridge_chases_distant_target() { + // Mirror `chases_distant_player_unit_in_range` through the JSON contract. + let payload = serde_json::json!({ + "wilds": [{ "id": 1, "hex": [0, 0], "movement_remaining": 2, "has_attacked": false }], + "player_units": [{ "id": 9, "hex": [3, 0] }], + "lairs": [[0, 0]], + "cities": [], + "config": { "detection_radius": 8, "leash_radius": 5 }, + "passable": [] + }) + .to_string(); + let out = decide_wild_actions_json(&payload, 1).expect("valid payload"); + assert_eq!(out.len(), 1); + // Externally-tagged enum JSON, matching the GdAiController envelope. + let v: serde_json::Value = serde_json::from_str(&out[0]).unwrap(); + assert_eq!(v["MoveUnit"]["unit_id"], 1); + assert_eq!(v["MoveUnit"]["to_hex"], serde_json::json!([3, 0])); + } + + #[test] + fn json_bridge_attacks_adjacent_target() { + let payload = serde_json::json!({ + "wilds": [{ "id": 1, "hex": [5, 5], "movement_remaining": 2 }], + "player_units": [{ "id": 9, "hex": [6, 5] }], + "lairs": [[5, 5]] + }) + .to_string(); + let out = decide_wild_actions_json(&payload, 1).expect("valid payload"); + assert_eq!(out.len(), 1); + let v: serde_json::Value = serde_json::from_str(&out[0]).unwrap(); + assert_eq!(v["AttackTarget"]["attacker_id"], 1); + assert_eq!(v["AttackTarget"]["target_id"], 9); + } + + #[test] + fn json_bridge_roam_respects_passable_set() { + // Force roam; only ONE neighbour of (0,0) is passable — the move must land there. + let only = (1, 0); // axial-East neighbour of (0,0) + let payload = serde_json::json!({ + "wilds": [{ "id": 1, "hex": [0, 0], "movement_remaining": 2 }], + "player_units": [], + "lairs": [[0, 0]], + "cities": [], + "config": { "leash_radius": 5 }, + "passable": [[only.0, only.1]] + }) + .to_string(); + // Many seeds: the move is always the single passable tile (or nothing if + // the drift/roam rolls both fail) — never an impassable tile. + for seed in 0..50u64 { + let out = decide_wild_actions_json(&payload, seed).unwrap(); + for s in &out { + let v: serde_json::Value = serde_json::from_str(s).unwrap(); + if let Some(mv) = v.get("MoveUnit") { + assert_eq!( + mv["to_hex"], + serde_json::json!([only.0, only.1]), + "roam may only enter a passable tile" + ); + } + } + } + } + + #[test] + fn json_bridge_rejects_malformed_payload() { + assert!(decide_wild_actions_json("{ not json", 1).is_err()); + } }