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:
parent
ca31834db0
commit
8696a48aa0
2 changed files with 256 additions and 0 deletions
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue