feat(@projects/@magic-civilization): 🐺 p3-30 — GdWildAiController bridge (owner-chosen drive path)

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 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 07:34:02 -04:00
parent ca31834db0
commit 8696a48aa0
2 changed files with 256 additions and 0 deletions

View file

@ -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<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdWildAiController {
fn init(base: Base<RefCounted>) -> 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::*;

View file

@ -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<WildUnitDto>,
/// Alive player-owned units (target candidates).
#[serde(default)]
pub player_units: Vec<PlayerUnitDto>,
/// 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<i32>,
/// Override for `leash_radius` (`roaming_leash_radius` in the data block).
#[serde(default)]
pub leash_radius: Option<i32>,
}
/// 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<Vec<String>, 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());
}
}