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:
parent
cbc68a68c1
commit
95a2e580bc
2 changed files with 570 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
569
src/simulator/crates/mc-ai/src/wild.rs
Normal file
569
src/simulator/crates/mc-ai/src/wild.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue