Add Brain enum with hunting foxes; move the A* into the sim

NPC strategy is now a Brain field on the entity (Idle, Wander, Hunt(Prey))
that Sim::think matches on, so behavior is data independent of species.
Hunt follows the first step of an A* route to the nearest prey in sight,
replanned every window; the pathfinder moves from game/ to sim/ for that.
A greedy step-toward-target was tried first and oscillated at walls. Bumps
between the same species no longer fight, and six foxes join the hens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 11:27:47 +02:00
co-authored by Claude Opus 5
parent b64c83fce0
commit 03a12c82dc
8 changed files with 254 additions and 41 deletions
+122 -20
View File
@@ -1,12 +1,15 @@
use std::collections::HashMap;
use crate::TICKS_PER_MOVE;
use crate::entity::EntityKind;
use crate::entity::{Brain, EntityKind, Prey};
use crate::player_action;
use crate::world::World;
/// How often a wandering NPC takes a step: one window in `WANDER_ODDS`.
const WANDER_ODDS: u32 = 4;
/// How far (Chebyshev) a hunter notices prey.
const SIGHT_RANGE: i32 = 8;
/// Something that happened during a tick and that the outside (rendering, sound,
/// message log, spawning) may want to react to. The sim itself never reads these
/// back — they are an output channel, drained with `Sim::take_events`.
@@ -81,9 +84,9 @@ impl Sim {
}
}
/// Schedule intents for every NPC. Wandering for now: occasionally a random king
/// move, otherwise stand still. Blocked directions are simply refused by
/// `entity_tick` — a wanderer bumping a wall just idles that window.
/// Schedule intents for every NPC according to its `Brain`. Blocked directions are
/// simply refused by `entity_tick` — a wanderer bumping a wall just idles that
/// window.
fn think(&mut self) {
let mut npcs: Vec<u32> = self.world.entities.values()
.filter(|e| e.kind == EntityKind::Npc)
@@ -91,14 +94,51 @@ impl Sim {
.collect();
npcs.sort_unstable();
for id in npcs {
if self.rand() % WANDER_ODDS != 0 {
continue;
let action = match self.world.entities[&id].brain {
Brain::Idle => None,
Brain::Wander => self.wander(),
Brain::Hunt(prey) => self.hunt(id, prey).or_else(|| self.wander()),
};
if let Some(action) = action {
self.intents.insert(id, action);
}
let action = player_action::NORTH + (self.rand() % 8) as u16;
self.intents.insert(id, action);
}
}
/// One window in `WANDER_ODDS`: a random king move.
fn wander(&mut self) -> Option<u16> {
if self.rand() % WANDER_ODDS != 0 {
return None;
}
Some(player_action::NORTH + (self.rand() % 8) as u16)
}
/// Pursuit: the first step of a route to the nearest prey within `SIGHT_RANGE`.
/// Stepping onto the prey is the bump that attacks it. Replanned every window, so
/// a moving target and other entities in the way are handled by construction;
/// unreachable prey (walled off, search budget exceeded) is ignored.
fn hunt(&mut self, id: u32, prey: Prey) -> Option<u16> {
let me = &self.world.entities[&id];
let from = (me.pos.0 as i32, me.pos.1 as i32);
let dist = |p: (i16, i16)| (p.0 as i32 - from.0).abs().max((p.1 as i32 - from.1).abs());
let (_, _, goal) = self.world.entities.values()
.filter(|e| e.id != id && match prey {
Prey::Player => e.kind == EntityKind::Player,
Prey::Kind(t) => e.type_id == t,
})
.map(|e| (dist(e.pos), e.id, (e.pos.0 as i32, e.pos.1 as i32)))
.filter(|&(d, ..)| d <= SIGHT_RANGE)
.min()?;
let w = &self.world;
let route = crate::pathfind::find_path(from, goal, |x, y| {
(x, y) != goal && w.blocked(x, y)
})?;
let step = route[0];
Some(crate::delta_action(step.0 - from.0, step.1 - from.1))
}
fn entity_tick(&mut self) {
// Lowest id first: deterministic, and the player (spawned first) wins a
// contested tile.
@@ -121,7 +161,10 @@ impl Sim {
continue;
}
match self.world.entity_at(to.0, to.1) {
None => self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16)),
None => self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16)),
// Bumping into one's own species is a refused move, not a fight.
Some(target) if self.world.entities[&target].type_id
== self.world.entities[&entity_id].type_id => {}
Some(target) => self.attack(entity_id, target),
}
}
@@ -162,7 +205,7 @@ mod tests {
#[test]
fn one_step_per_window_and_replacement() {
let mut sim = Sim::new(open_world());
let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 1, 1);
let id = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 1, 1);
sim.set_action(id, player_action::SOUTH);
sim.set_action(id, player_action::NORTH); // replaces
sim.step(); // no window yet
@@ -176,12 +219,12 @@ mod tests {
#[test]
fn blocked_and_out_of_world_steps_are_rejected() {
let mut sim = Sim::new(open_world());
let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 1, 1);
let id = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 1, 1);
sim.set_action(id, player_action::EAST); // into the wall at (5, 4)
step_window(&mut sim);
assert_eq!(sim.world.entities[&id].pos, (4, 4));
let edge = sim.world.spawn_entity(EntityKind::Player, 0, (0, 0), 1, 1);
let edge = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (0, 0), 1, 1);
sim.set_action(edge, player_action::WEST); // no chunk there
step_window(&mut sim);
assert_eq!(sim.world.entities[&edge].pos, (0, 0));
@@ -190,7 +233,7 @@ mod tests {
#[test]
fn noop_clears_intent() {
let mut sim = Sim::new(open_world());
let id = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 1, 1);
let id = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 1, 1);
sim.set_action(id, player_action::SOUTH);
sim.set_action(id, player_action::NOOP);
step_window(&mut sim);
@@ -200,8 +243,8 @@ mod tests {
#[test]
fn entities_block_each_other() {
let mut sim = Sim::new(open_world());
let a = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 5, 0);
let b = sim.world.spawn_entity(EntityKind::Player, 0, (4, 5), 5, 0);
let a = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 5, 0);
let b = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 5), 5, 0);
sim.set_action(a, player_action::SOUTH); // onto b: a bump, not a move
step_window(&mut sim);
assert_eq!(sim.world.entities[&a].pos, (4, 4));
@@ -222,7 +265,7 @@ mod tests {
#[test]
fn npc_wanders_but_never_walks_through_walls() {
let mut sim = Sim::new(open_world());
let npc = sim.world.spawn_entity(EntityKind::Npc, 1, (10, 10), 1, 1);
let npc = sim.world.spawn_entity(EntityKind::Npc, 1, Brain::Wander, (10, 10), 1, 1);
let mut prev = (10, 10);
let mut moved = false;
for _ in 0..200 {
@@ -241,8 +284,8 @@ mod tests {
#[test]
fn bump_attacks_and_kills() {
let mut sim = Sim::new(open_world());
let p = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 10, 3);
let n = sim.world.spawn_entity(EntityKind::Player, 1, (4, 5), 5, 0);
let p = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 10, 3);
let n = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 5), 5, 0);
sim.set_action(p, player_action::SOUTH);
step_window(&mut sim);
@@ -271,11 +314,70 @@ mod tests {
let mut sim = Sim::new(open_world());
// Wall at (5,4), n at (4,5): the diagonal (4,4) → (5,5) is sealed on one side
// by terrain — by the corner rule, a bump there is as illegal as a move.
let p = sim.world.spawn_entity(EntityKind::Player, 0, (4, 4), 10, 3);
let n = sim.world.spawn_entity(EntityKind::Player, 1, (5, 5), 5, 0);
let p = sim.world.spawn_entity(EntityKind::Player, 0, Brain::Idle, (4, 4), 10, 3);
let n = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (5, 5), 5, 0);
sim.set_action(p, player_action::SOUTH_EAST);
step_window(&mut sim);
assert_eq!(sim.world.entities[&n].hp, 5);
assert!(sim.take_events().is_empty());
}
#[test]
fn same_species_bump_is_not_a_fight() {
let mut sim = Sim::new(open_world());
let a = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 4), 5, 3);
let b = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (4, 5), 5, 3);
sim.set_action(a, player_action::SOUTH);
step_window(&mut sim);
assert_eq!(sim.world.entities[&b].hp, 5);
assert_eq!(sim.world.entities[&a].pos, (4, 4));
assert!(sim.take_events().is_empty());
}
#[test]
fn hunter_closes_in_and_bites() {
let mut sim = Sim::new(open_world());
let hen = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (10, 10), 5, 1);
let fox = sim.world.spawn_entity(EntityKind::Npc, 2, Brain::Hunt(Prey::Kind(1)), (14, 7), 8, 2);
// Chebyshev distance 4: three steps to get adjacent, the fourth is the bite.
for _ in 0..3 { step_window(&mut sim); }
let p = sim.world.entities[&fox].pos;
assert_eq!((p.0 - 10).abs().max((p.1 - 10).abs()), 1, "{p:?}");
assert_eq!(sim.world.entities[&hen].hp, 5);
step_window(&mut sim);
assert_eq!(sim.world.entities[&hen].hp, 3);
assert_eq!(sim.take_events(), vec![Event::Attacked { by: fox, target: hen, damage: 2 }]);
}
#[test]
fn hunter_sidesteps_an_obstacle() {
let mut sim = Sim::new(open_world());
// Wall at (5,4); fox at (6,4) wants (3,4) straight west. The diagonals past
// the wall are illegal (corner rule), so it slides sideways, walks along the
// wall and bites diagonally once it is past.
let hen = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (3, 4), 5, 1);
let fox = sim.world.spawn_entity(EntityKind::Npc, 2, Brain::Hunt(Prey::Kind(1)), (6, 4), 8, 2);
step_window(&mut sim);
let p = sim.world.entities[&fox].pos;
assert!(p == (6, 3) || p == (6, 5), "{p:?}");
step_window(&mut sim);
step_window(&mut sim);
let p = sim.world.entities[&fox].pos;
assert!(p == (4, 3) || p == (4, 5), "{p:?}");
step_window(&mut sim);
assert_eq!(sim.world.entities[&hen].hp, 3);
}
#[test]
fn hunter_ignores_prey_out_of_sight() {
let mut sim = Sim::with_seed(open_world(), 7);
let _hen = sim.world.spawn_entity(EntityKind::Player, 1, Brain::Idle, (2, 2), 5, 1);
let fox = sim.world.spawn_entity(EntityKind::Npc, 2, Brain::Hunt(Prey::Kind(1)), (25, 25), 8, 2);
// Out of sight it wanders: over a few windows it must not walk a straight line
// to the hen. Weak check, but it pins down that hunting is range-limited.
let start = sim.world.entities[&fox].pos;
for _ in 0..8 { step_window(&mut sim); }
let p = sim.world.entities[&fox].pos;
assert!((p.0 - start.0).abs().max((p.1 - start.1).abs()) < 8, "{p:?}");
}
}