feat(@projects/@magic-civilization): 🐺 p3-30 — Rust wild-creature decision AI core

Port wild_creature_ai.gd's decision logic to a pure, deterministic Rust
module (mc-ai::wild). decide_wild_actions(ctx, rng) -> Vec<Action> mirrors
process_wild_turn → _act: chase+attack a player unit in detection range,
drive home when leashed out, drift toward the nearest city, else roam a
leashed neighbour. One action per creature (the player-tactical convention:
attack iff adjacent, else move). Reuses mc_core hex helpers + XorShift64 +
the existing Action taxonomy; combat resolution stays in mc_combat::wilds.

Fork-neutral: WildContext is a flat projection, identical whether the
integration drives it inside mc_turn::step or via a GdWildAiController bridge
(p3-30 leaves that drive-site to infra). 12 unit tests: target-select,
chase, attack-iff-adjacent, leash return, leashed roam, city drift, passable
gating, no-movement skip, determinism, wilds.json config parse. mc-ai lib
301/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 06:50:44 -04:00
parent cbc68a68c1
commit 95a2e580bc
2 changed files with 570 additions and 0 deletions

View file

@ -16,6 +16,7 @@ pub mod mcts_tree;
pub mod policy;
pub mod rollout;
pub mod tactical;
pub mod wild;
/// Shared parity-test fixtures (5-clan priors + 209-input deterministic
/// batch builder). Gated behind the `test-fixtures` feature so it ships only

View file

@ -0,0 +1,569 @@
//! Wild-creature decision AI (p3-30) — Rail-1 port of the live
//! `src/game/engine/src/modules/ai/wild_creature_ai.gd` decision logic.
//!
//! Wild creatures (the live game's `owner == -1` units) guard their home lair,
//! attack player units within a detection radius, and roam within a leash
//! radius of home. This module is the pure DECISION layer: it emits [`Action`]s
//! — the same taxonomy the player tactical AI uses — for a dispatcher to apply.
//! Combat RESOLUTION already lives in [`mc_combat::wilds`]; this never resolves
//! combat itself (no formula drift; Rail-1 single source).
//!
//! At most one action per creature per turn, matching the player tactical
//! convention (`movement::decide_movement`): attack iff adjacent, else move.
//!
//! Determinism: every random branch draws from the caller's [`XorShift64`], so a
//! given `(context, seed)` yields the same actions (the p1-09 RNG contract). It
//! is deliberately NOT bit-identical to the GDScript `RandomNumberGenerator`
//! stream — behavioural parity (guard / attack-in-radius / leashed-roam), not
//! RNG parity, is the bar (the engines run different PRNGs).
//!
//! ## Where the input comes from
//! [`WildContext`] is a flat projection the caller builds from game state — the
//! in-`step` turn phase or a `GdWildAiController` bridge (p3-30 leaves the
//! drive-site choice to infra; this decision core is identical either way).
//! Keeping it a projection rather than a `GameState` borrow keeps this crate
//! dependency-light and the logic trivially unit-testable.
use mc_core::algorithms::hex::{axial_distance, axial_neighbors};
use crate::mcts::XorShift64;
use crate::tactical::Action;
/// `WildCreatureAI.DEFAULT_DETECTION_RADIUS`.
const DEFAULT_DETECTION_RADIUS: i32 = 4;
/// `WildCreatureAI.DEFAULT_LEASH_RADIUS`.
const DEFAULT_LEASH_RADIUS: i32 = 5;
/// `WildCreatureAI.AGGRO_OVERRIDE_RADIUS` — chase range can exceed the leash, so
/// the effective detection floor is this constant.
const AGGRO_OVERRIDE_RADIUS: i32 = 8;
/// `WildCreatureAI.ROAM_CHANCE` (percent).
const ROAM_CHANCE: u32 = 40;
/// `WildCreatureAI.CITY_DRIFT_CHANCE` (percent).
const CITY_DRIFT_CHANCE: u32 = 20;
/// Tunables drawn from the `wilds` data block
/// (`public/resources/wilds/wilds.json`). `detection_radius` and `leash_radius`
/// are data-driven; the remaining constants are hardcoded in the GDScript and
/// stay fixed here (overridable for tests).
#[derive(Debug, Clone)]
pub struct WildConfig {
/// Player-unit search radius for target acquisition.
pub detection_radius: i32,
/// Maximum distance a creature may roam from its home lair.
pub leash_radius: i32,
/// Floor on the effective detection radius so a chaser can always find home.
pub aggro_override_radius: i32,
/// Percent chance to drift one step toward the nearest city when idle.
pub city_drift_chance: u32,
/// Percent chance to roam a leashed neighbour when idle (rolled only after
/// the drift roll fails, mirroring the GDScript `elif` chain).
pub roam_chance: u32,
}
impl Default for WildConfig {
fn default() -> Self {
Self {
detection_radius: DEFAULT_DETECTION_RADIUS,
leash_radius: DEFAULT_LEASH_RADIUS,
aggro_override_radius: AGGRO_OVERRIDE_RADIUS,
city_drift_chance: CITY_DRIFT_CHANCE,
roam_chance: ROAM_CHANCE,
}
}
}
impl WildConfig {
/// Parse the data-driven keys from the `wilds` JSON block, mirroring
/// `WildCreatureAI._get_wilds_config`. Accepts either the wrapper object
/// (`{"wilds": {...}}`, as `wilds.json` ships) or the inner block directly.
/// Unset keys fall back to the GDScript constant defaults.
pub fn from_wilds_json(v: &serde_json::Value) -> Self {
let block = v.get("wilds").unwrap_or(v);
let mut cfg = Self::default();
if let Some(d) = block.get("detection_radius").and_then(serde_json::Value::as_i64) {
cfg.detection_radius = d as i32;
}
if let Some(l) = block
.get("roaming_leash_radius")
.and_then(serde_json::Value::as_i64)
{
cfg.leash_radius = l as i32;
}
cfg
}
}
/// A wild creature awaiting a decision this turn (the live `owner == -1` unit).
#[derive(Debug, Clone)]
pub struct WildUnit {
/// Stable unit id (becomes `Action`'s `unit_id` / `attacker_id`).
pub id: u32,
/// Axial `(col, row)` position.
pub hex: (i32, i32),
/// Movement points left this turn; `<= 0` skips the creature.
pub movement_remaining: i32,
/// Whether the creature has already attacked this turn (one attack/turn).
pub has_attacked: bool,
}
/// A player-owned, alive unit a wild may target. Occupancy (a player unit
/// blocking a tile) is handled by the caller's `passable` predicate, not here.
#[derive(Debug, Clone, Copy)]
pub struct PlayerUnitRef {
/// Stable unit id (becomes `Action::AttackTarget::target_id`).
pub id: u32,
/// Axial `(col, row)` position.
pub hex: (i32, i32),
}
/// Flat projection of everything the wild decision needs. The caller builds it
/// from game state (lairs already filtered to exclude villages/ruins).
pub struct WildContext<'a> {
/// Creatures to decide for, in stable order (decision + RNG draws follow it).
pub wilds: Vec<WildUnit>,
/// Alive player units, for target acquisition.
pub player_units: Vec<PlayerUnitRef>,
/// Lair hexes (home anchors). Villages/ruins excluded by the caller.
pub lairs: Vec<(i32, i32)>,
/// City hexes (drift-wander attractors).
pub cities: Vec<(i32, i32)>,
/// Tunables.
pub config: WildConfig,
/// True when a creature may step onto `hex`: in-bounds, not water, and not
/// occupied by a player unit. Folds the GDScript `is_valid_position` /
/// `is_water` / `_has_player_unit_at` triad into one caller-supplied
/// predicate, so this fn needs no grid or unit borrow.
pub passable: &'a dyn Fn((i32, i32)) -> bool,
}
/// Decide one action per wild creature this turn, in `ctx.wilds` order.
///
/// Mirrors `WildCreatureAI.process_wild_turn` → `_act`:
/// 1. chase + attack the nearest player unit within effective detection range,
/// 2. else drive home when leashed out,
/// 3. else (drift roll) drift one step toward the nearest city,
/// 4. else (roam roll) roam to a random passable leashed neighbour.
pub fn decide_wild_actions(ctx: &WildContext, rng: &mut XorShift64) -> Vec<Action> {
let mut actions = Vec::new();
let cfg = &ctx.config;
// Chase range can exceed the leash, so search at least the aggro floor.
let effective_detection = cfg.detection_radius.max(cfg.aggro_override_radius);
for unit in &ctx.wilds {
if unit.movement_remaining <= 0 {
continue;
}
// Home search reaches past the leash so a chaser can always find its way
// back. Defaults to the creature's own hex when no lair is in range
// (GDScript `_find_nearest_lair`), so an unanchored creature free-roams.
let home = nearest_lair(unit.hex, cfg.leash_radius + effective_detection, &ctx.lairs);
let target = nearest_target(unit.hex, effective_detection, &ctx.player_units);
if let Some(t) = target {
let dist = axial_distance(unit.hex.0, unit.hex.1, t.hex.0, t.hex.1);
if dist <= 1 {
if !unit.has_attacked {
actions.push(Action::AttackTarget {
attacker_id: unit.id,
target_id: t.id,
posture: None,
});
}
} else {
actions.push(Action::MoveUnit {
unit_id: unit.id,
to_hex: t.hex,
});
}
} else if axial_distance(unit.hex.0, unit.hex.1, home.0, home.1) > cfg.leash_radius {
// Leashed out → drive home.
actions.push(Action::MoveUnit {
unit_id: unit.id,
to_hex: home,
});
} else if roll(rng, cfg.city_drift_chance) {
if let Some(dest) = drift_toward_city(unit.hex, ctx) {
actions.push(Action::MoveUnit {
unit_id: unit.id,
to_hex: dest,
});
}
} else if roll(rng, cfg.roam_chance) {
if let Some(dest) = roam(unit.hex, home, cfg.leash_radius, ctx, rng) {
actions.push(Action::MoveUnit {
unit_id: unit.id,
to_hex: dest,
});
}
}
}
actions
}
/// Nearest lair within `search_radius`, defaulting to `from` when none is in
/// range — mirrors `_find_nearest_lair` (home defaults to the creature's own hex
/// so an unanchored creature free-roams). Strict `<` tie-break keeps the
/// first-in-order lair, so the result is deterministic given a stable `lairs`
/// order.
fn nearest_lair(from: (i32, i32), search_radius: i32, lairs: &[(i32, i32)]) -> (i32, i32) {
let mut best = from;
let mut best_dist = i32::MAX;
for &l in lairs {
let d = axial_distance(from.0, from.1, l.0, l.1);
if d <= search_radius && d < best_dist {
best_dist = d;
best = l;
}
}
best
}
/// Nearest alive player unit within `detection_radius`, else `None` — mirrors
/// `_find_attack_target` (`dist <= radius && dist < best`).
fn nearest_target(
from: (i32, i32),
detection_radius: i32,
players: &[PlayerUnitRef],
) -> Option<PlayerUnitRef> {
let mut best: Option<PlayerUnitRef> = None;
let mut best_dist = i32::MAX;
for &p in players {
let d = axial_distance(from.0, from.1, p.hex.0, p.hex.1);
if d <= detection_radius && d < best_dist {
best_dist = d;
best = Some(p);
}
}
best
}
/// One step toward the nearest city through a passable neighbour — mirrors
/// `_drift_toward_city`. `None` when no city exists, the creature is already on
/// the nearest city hex, or no neighbour is strictly closer to it.
fn drift_toward_city(from: (i32, i32), ctx: &WildContext) -> Option<(i32, i32)> {
// Nearest city across all players; ties keep the running best (GDScript).
let mut city = from;
let mut best_d = i32::MAX;
for &c in &ctx.cities {
let d = axial_distance(from.0, from.1, c.0, c.1);
if d < best_d {
best_d = d;
city = c;
}
}
if city == from {
return None;
}
// `best_d` now holds the creature's own distance to that city; a neighbour
// must beat it (GDScript reuses the same accumulator).
let mut best = from;
for n in axial_neighbors(from.0, from.1) {
if !(ctx.passable)(n) {
continue;
}
let d = axial_distance(n.0, n.1, city.0, city.1);
if d < best_d {
best_d = d;
best = n;
}
}
if best == from {
None
} else {
Some(best)
}
}
/// A random passable neighbour within `leash_radius` of `home` — mirrors
/// `_roam`. `None` when no neighbour qualifies.
fn roam(
from: (i32, i32),
home: (i32, i32),
leash_radius: i32,
ctx: &WildContext,
rng: &mut XorShift64,
) -> Option<(i32, i32)> {
let mut valid: Vec<(i32, i32)> = Vec::new();
for n in axial_neighbors(from.0, from.1) {
if !(ctx.passable)(n) {
continue;
}
if axial_distance(n.0, n.1, home.0, home.1) > leash_radius {
continue;
}
valid.push(n);
}
if valid.is_empty() {
return None;
}
let idx = (rng.next_u64() % valid.len() as u64) as usize;
Some(valid[idx])
}
/// `_rng.randi_range(1, 100) <= chance` — a percent roll in `1..=100`.
fn roll(rng: &mut XorShift64, chance: u32) -> bool {
let r = (rng.next_u64() % 100) as u32 + 1;
r <= chance
}
#[cfg(test)]
mod tests {
use super::*;
/// Every tile passable — isolates the decision logic from terrain in tests
/// that don't exercise the `passable` gate.
fn all_passable(_: (i32, i32)) -> bool {
true
}
fn ctx<'a>(
wilds: Vec<WildUnit>,
players: Vec<PlayerUnitRef>,
lairs: Vec<(i32, i32)>,
cities: Vec<(i32, i32)>,
passable: &'a dyn Fn((i32, i32)) -> bool,
) -> WildContext<'a> {
WildContext {
wilds,
player_units: players,
lairs,
cities,
config: WildConfig::default(),
passable,
}
}
fn wild(id: u32, hex: (i32, i32)) -> WildUnit {
WildUnit {
id,
hex,
movement_remaining: 2,
has_attacked: false,
}
}
#[test]
fn attacks_adjacent_player_unit() {
let c = ctx(
vec![wild(1, (5, 5))],
vec![PlayerUnitRef { id: 9, hex: (6, 5) }], // distance 1
vec![(5, 5)],
vec![],
&all_passable,
);
let mut rng = XorShift64::new(1);
let actions = decide_wild_actions(&c, &mut rng);
assert_eq!(actions.len(), 1);
assert!(
matches!(actions[0], Action::AttackTarget { attacker_id: 1, target_id: 9, .. }),
"adjacent target → attack; got {:?}",
actions[0]
);
}
#[test]
fn does_not_attack_when_already_attacked() {
let mut w = wild(1, (5, 5));
w.has_attacked = true;
let c = ctx(
vec![w],
vec![PlayerUnitRef { id: 9, hex: (6, 5) }],
vec![(5, 5)],
vec![],
&all_passable,
);
let mut rng = XorShift64::new(1);
// Adjacent target but attack spent → no action (GDScript skips the
// attack and the chase branch returns no move when already adjacent).
assert!(decide_wild_actions(&c, &mut rng).is_empty());
}
#[test]
fn chases_distant_player_unit_in_range() {
let c = ctx(
vec![wild(1, (0, 0))],
vec![PlayerUnitRef { id: 9, hex: (3, 0) }], // distance 3 (< aggro 8)
vec![(0, 0)],
vec![],
&all_passable,
);
let mut rng = XorShift64::new(1);
let actions = decide_wild_actions(&c, &mut rng);
assert_eq!(actions.len(), 1);
assert!(
matches!(actions[0], Action::MoveUnit { unit_id: 1, to_hex: (3, 0) }),
"in-range distant target → move toward it; got {:?}",
actions[0]
);
}
#[test]
fn ignores_player_unit_outside_effective_detection() {
// Target 12 hexes away — beyond the aggro floor (8) — so no chase.
// Creature is at home and within leash, so it idles/roams, never chases.
let c = ctx(
vec![wild(1, (0, 0))],
vec![PlayerUnitRef { id: 9, hex: (12, 0) }],
vec![(0, 0)],
vec![],
&all_passable,
);
let mut rng = XorShift64::new(1);
let actions = decide_wild_actions(&c, &mut rng);
// Whatever it does, it must NOT be an attack or a move onto the target.
for a in &actions {
assert!(
!matches!(a, Action::AttackTarget { .. }),
"must not attack an out-of-range unit"
);
if let Action::MoveUnit { to_hex, .. } = a {
assert_ne!(*to_hex, (12, 0), "must not chase an out-of-range unit");
}
}
}
#[test]
fn returns_home_when_leashed_out() {
// No target; creature is 7 hexes from its only lair (> leash 5) → drive home.
let c = ctx(
vec![wild(1, (7, 0))],
vec![],
vec![(0, 0)],
vec![],
&all_passable,
);
let mut rng = XorShift64::new(1);
let actions = decide_wild_actions(&c, &mut rng);
assert_eq!(actions.len(), 1);
assert!(
matches!(actions[0], Action::MoveUnit { unit_id: 1, to_hex: (0, 0) }),
"leashed out → move toward home lair; got {:?}",
actions[0]
);
}
#[test]
fn skips_creature_with_no_movement() {
let mut w = wild(1, (5, 5));
w.movement_remaining = 0;
let c = ctx(
vec![w],
vec![PlayerUnitRef { id: 9, hex: (6, 5) }],
vec![(5, 5)],
vec![],
&all_passable,
);
let mut rng = XorShift64::new(1);
assert!(
decide_wild_actions(&c, &mut rng).is_empty(),
"0 movement → no action even with an adjacent target"
);
}
#[test]
fn roam_stays_within_leash_of_home() {
// Force the roam branch: drift chance 0, roam chance 100, no city.
let home = (0, 0);
let mut c = ctx(
vec![wild(1, home)],
vec![],
vec![home],
vec![],
&all_passable,
);
c.config.city_drift_chance = 0;
c.config.roam_chance = 100;
c.config.leash_radius = 1; // tight leash — only immediate neighbours qualify
let mut rng = XorShift64::new(42);
let actions = decide_wild_actions(&c, &mut rng);
assert_eq!(actions.len(), 1, "roam emits one move");
let Action::MoveUnit { to_hex, .. } = actions[0] else {
panic!("roam must emit MoveUnit; got {:?}", actions[0]);
};
assert!(
axial_distance(to_hex.0, to_hex.1, home.0, home.1) <= 1,
"roamed tile {to_hex:?} must stay within the leash of home {home:?}"
);
}
#[test]
fn roam_blocked_by_impassable_neighbours_emits_nothing() {
let home = (0, 0);
let block_all = |_: (i32, i32)| false; // every neighbour impassable
let mut c = ctx(vec![wild(1, home)], vec![], vec![home], vec![], &block_all);
c.config.city_drift_chance = 0;
c.config.roam_chance = 100;
let mut rng = XorShift64::new(7);
assert!(
decide_wild_actions(&c, &mut rng).is_empty(),
"no passable neighbour → no roam move"
);
}
#[test]
fn drift_steps_toward_nearest_city() {
// Idle creature at home, a city to the east; force the drift branch.
let home = (0, 0);
let mut c = ctx(
vec![wild(1, home)],
vec![],
vec![home],
vec![(6, 0)], // within leash+aggro search; drives drift direction
&all_passable,
);
c.config.leash_radius = 10; // keep it in-leash so it reaches the drift branch
c.config.city_drift_chance = 100;
let mut rng = XorShift64::new(3);
let actions = decide_wild_actions(&c, &mut rng);
assert_eq!(actions.len(), 1);
let Action::MoveUnit { to_hex, .. } = actions[0] else {
panic!("drift must emit MoveUnit; got {:?}", actions[0]);
};
assert!(
axial_distance(to_hex.0, to_hex.1, 6, 0) < axial_distance(home.0, home.1, 6, 0),
"drift tile {to_hex:?} must be strictly closer to the city than home"
);
}
#[test]
fn is_deterministic_for_same_context_and_seed() {
let build = || {
let mut cc = ctx(
vec![wild(1, (0, 0)), wild(2, (4, 4))],
vec![],
vec![(0, 0), (4, 4)],
vec![(2, 2)],
&all_passable,
);
cc.config.leash_radius = 6;
cc
};
let c1 = build();
let c2 = build();
let a1 = decide_wild_actions(&c1, &mut XorShift64::new(12345));
let a2 = decide_wild_actions(&c2, &mut XorShift64::new(12345));
// Actions serialise deterministically — compare via debug repr.
assert_eq!(format!("{a1:?}"), format!("{a2:?}"));
}
#[test]
fn config_from_real_wilds_json() {
// The shipped wilds.json: detection_radius 8, roaming_leash_radius 5.
let v = serde_json::json!({
"wilds": { "detection_radius": 8, "roaming_leash_radius": 5 }
});
let cfg = WildConfig::from_wilds_json(&v);
assert_eq!(cfg.detection_radius, 8);
assert_eq!(cfg.leash_radius, 5);
// Non-data constants keep their GDScript defaults.
assert_eq!(cfg.aggro_override_radius, AGGRO_OVERRIDE_RADIUS);
assert_eq!(cfg.roam_chance, ROAM_CHANCE);
}
#[test]
fn config_defaults_when_keys_absent() {
let cfg = WildConfig::from_wilds_json(&serde_json::json!({ "wilds": {} }));
assert_eq!(cfg.detection_radius, DEFAULT_DETECTION_RADIUS);
assert_eq!(cfg.leash_radius, DEFAULT_LEASH_RADIUS);
}
}