From 95a2e580bcddb7ebc99257a9a28d0e20a4337218 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 27 Jun 2026 06:50:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=90=BA=20p3-30=20=E2=80=94=20Rust=20wild-creature=20decis?= =?UTF-8?q?ion=20AI=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port wild_creature_ai.gd's decision logic to a pure, deterministic Rust module (mc-ai::wild). decide_wild_actions(ctx, rng) -> Vec 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 --- src/simulator/crates/mc-ai/src/lib.rs | 1 + src/simulator/crates/mc-ai/src/wild.rs | 569 +++++++++++++++++++++++++ 2 files changed, 570 insertions(+) create mode 100644 src/simulator/crates/mc-ai/src/wild.rs diff --git a/src/simulator/crates/mc-ai/src/lib.rs b/src/simulator/crates/mc-ai/src/lib.rs index 7c159e91..abfc9a45 100644 --- a/src/simulator/crates/mc-ai/src/lib.rs +++ b/src/simulator/crates/mc-ai/src/lib.rs @@ -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 diff --git a/src/simulator/crates/mc-ai/src/wild.rs b/src/simulator/crates/mc-ai/src/wild.rs new file mode 100644 index 00000000..287aa5a9 --- /dev/null +++ b/src/simulator/crates/mc-ai/src/wild.rs @@ -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, + /// Alive player units, for target acquisition. + pub player_units: Vec, + /// 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 { + 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 { + let mut best: Option = 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, + players: Vec, + 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); + } +}