Add wolves, health and colony defence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 12:14 UTC
commit
bd659d1db1e2d674ca753b4c60668fd2073d305a
parent
472bc95142
tree
browse at this commit

12 files changed +609 -67

CLAUDE.md +2 -0

@@ -50,6 +50,8 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
50 50 - Crafting (jobs): `Task::Craft{recipe: Recipe::{Meal, Tools}, pickup, station, progress}` (was Cook): recipe gives station kind (Campfire/Workbench), accepted input, input count, work, output; `find_crafting`, `input_source`. Tools: `WorkType::Craft` candidate when Tools wanted and TOOL_IRON iron available → craft at a workbench, else `find_workbench` (one, near home, autonomous Structure build like campfire/bed). `Task::Equip{pickup}` (before autonomous candidates) for adults without `Tool{wear}`; equip records Tools use (learned demand); `tool_rate` ×1.5 work; wear per working tick, removed at TOOL_LIFE_TICKS (2 days). Iron has min goal 6. `StructureKind::Statue` (5 gold): plan_homes adds one blueprint at `Home::statue_tile` once a home is built → gold demand via planned_needs; mood +0.1 (`beauty`) when the family home has one; +0.05 (`floored`) when its interior is all WoodFloor.
51 51 - `src/save.rs` save/load. Action::Save/Load (F5/F9). `snapshot(world) -> SaveGame` (serde_json; SAVE_VERSION): seed, tick, rng, `WorldMap::layers()` (`MapLayers`: RLE terrain/plants/walls/roofs as u8 + sparse growth/wear; heights regenerated by `generate(size, seed)` in `restore_map`), `Zones::save` (`ZonesSave`, UVec2-keyed maps become Vecs — serde_json needs string keys), blueprints Vec, Homes, Designations, ColonyGoals, log, losses, colonists (`ColonistSave`, partner as index), items, structures (+fuel), animals. Not saved: jobs, paths, reservations, Affinity, idle timers (colonists restart idle). Storage: web localStorage key `simulator-save` (~150 KB), native `simulator-save.json` in cwd. Load: `start_load` (PreUpdate, exclusive) parses, inserts WorldSeed + restored map, writes WorldGenerated (all reset systems run in Update), stores `PendingLoad`; `finish_load` (PostUpdate) despawns colonists/animals/items/structures and `apply`s the save via SystemState spawn helpers (`spawn_colonist`, `spawn_stack`, `spawn_structure` → Entity, `spawn_animal`). New persistent state = add to SaveGame + snapshot + apply (+ serde derives; bevy `serialize` feature for glam).
52 52 - `src/skills.rs` `Skills{xp: [f32; 8]}` (required on Colonist, default = level 5 everywhere so tests keep old speeds; `assign_age` gives adults `Skills::random` (levels 1–7 + one talent at 10), children level 0). `Skill::{Mining, Woodcutting, Construction, Farming, Cooking, Hunting, Crafting, Taming}`, `Skill::of(task)`; level = √(xp/600) ≤ 20; `speed(level)` = 0.5 + 0.1·level. run_jobs: rate (after light/sick/tool) × speed via `WorkCarry` fractional accumulator; +1 xp per tick the task's `Task::progress()` advances. choose_jobs candidates are `(WorkType, Option<Skill>, finder)`; candidates whose skill is < POOR_LEVEL 3 run after all others (cooking, crafting, blueprints, bed sites, hunting, taming, plain sowing; urgent food/fire work has no skill). Selection shows top 3 skills; saved in ColonistSave (serde default).
53 +- `src/threats.rs` wolves and health. `AnimalKind::Wolf` (`is_predator`, population 3, `migration_ticks` 24 h vs 2 h, not tameable, meat 12) spawns with `Predator{target, fed_until, cooldown}`. `Health(f32)` required on Colonist and Animal. `prowl` (Decide, every 30 ticks): unfed wolves `pick_prey` (nearest within PROWL_RANGE 24, colonists cost +COLONIST_DETOUR 6) and path adjacent; `bite` (Act): adjacent, cooldown 60 → colonist −0.25 (log on first bite; death drops load, releases, Losses, log), animal −0.5 (despawn); a kill feeds the wolf FED_TICKS 12 h. `heal` (every 60 ticks): 0.3/day, ×3 asleep in a bed. Wild prey flees wolves too (wander threats include predators); wolves skip flee/wander while targeting. choose_jobs: `nearest_wolf`; a wolf within DANGER_RANGE 6 makes colonists ready and interrupts autonomous jobs (except wolf hunts/MoveTo); fighters (adult, health ≥ INJURED 0.5, Food work allowed, not exhausted/hungry) Hunt a reachable (`ColonyReach::allows_near`) unreserved wolf in danger range or within DEFEND_RANGE 15 of colony home (first candidate); others in danger flee (`flee_from`, 10 tiles away). Mood −0.1 below HURT 0.75; selection shows health; saved in ColonistSave.
54 +- Arrivals get a real `Path` home (up to ARRIVAL_ATTEMPTS edge tiles tried); a bare MoveTo job without a path finishes at once, which used to strand newcomers at the map edge.
53 55 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
54 56
55 57 ## Performance notes

src/animals.rs +75 -7

@@ -9,6 +9,7 @@ use crate::{
9 9 pathfinding::find_path,
10 10 rng::SimRng,
11 11 sim::{SimPos, SimSystems, SimTick, TICKS_PER_HOUR, sim_pos},
12 + threats::{Health, Predator},
12 13 world::{TILE_SIZE, TilePos, WorldMap},
13 14 worldgen::{WorldGenerated, spawn_tiles},
14 15 zones::Zones,
@@ -46,15 +47,17 @@ const PLACEMENT_ATTEMPTS: usize = 200;
46 47 pub enum AnimalKind {
47 48 Deer,
48 49 Rabbit,
50 + Wolf,
49 51 }
50 52
51 53 impl AnimalKind {
52 - pub const ALL: [AnimalKind; 2] = [AnimalKind::Deer, AnimalKind::Rabbit];
54 + pub const ALL: [AnimalKind; 3] = [AnimalKind::Deer, AnimalKind::Rabbit, AnimalKind::Wolf];
53 55
54 56 pub fn meat(self) -> u32 {
55 57 match self {
56 58 AnimalKind::Deer => 30,
57 59 AnimalKind::Rabbit => 8,
60 + AnimalKind::Wolf => 12,
58 61 }
59 62 }
60 63
@@ -62,13 +65,26 @@ impl AnimalKind {
62 65 match self {
63 66 AnimalKind::Deer => 10,
64 67 AnimalKind::Rabbit => 16,
68 + AnimalKind::Wolf => 3,
65 69 }
66 70 }
67 71
72 + pub fn migration_ticks(self) -> u64 {
73 + match self {
74 + AnimalKind::Wolf => 24 * TICKS_PER_HOUR,
75 + _ => MIGRATION_INTERVAL_TICKS,
76 + }
77 + }
78 +
79 + pub fn is_predator(self) -> bool {
80 + self == AnimalKind::Wolf
81 + }
82 +
68 83 pub fn tame_work(self) -> u32 {
69 84 match self {
70 85 AnimalKind::Deer => 600,
71 86 AnimalKind::Rabbit => 300,
87 + AnimalKind::Wolf => u32::MAX,
72 88 }
73 89 }
74 90
@@ -76,6 +92,7 @@ impl AnimalKind {
76 92 match self {
77 93 AnimalKind::Deer => 360,
78 94 AnimalKind::Rabbit => 160,
95 + AnimalKind::Wolf => 300,
79 96 }
80 97 }
81 98
@@ -83,6 +100,7 @@ impl AnimalKind {
83 100 match self {
84 101 AnimalKind::Deer => 3.0,
85 102 AnimalKind::Rabbit => 2.5,
103 + AnimalKind::Wolf => 3.4,
86 104 }
87 105 }
88 106
@@ -90,12 +108,13 @@ impl AnimalKind {
90 108 match self {
91 109 AnimalKind::Deer => "deer",
92 110 AnimalKind::Rabbit => "rabbit",
111 + AnimalKind::Wolf => "wolf",
93 112 }
94 113 }
95 114 }
96 115
97 116 #[derive(Component, Debug, Clone, Copy, PartialEq)]
98 -#[require(Transform, Visibility, TilePos, Path, SimPos, AnimalIdle)]
117 +#[require(Transform, Visibility, TilePos, Path, SimPos, AnimalIdle, Health)]
99 118 pub struct Animal {
100 119 pub kind: AnimalKind,
101 120 }
@@ -120,6 +139,9 @@ pub struct AnimalAssets {
120 139 deer_head: Part,
121 140 rabbit_body: Part,
122 141 rabbit_ear: Part,
142 + wolf_body: Part,
143 + wolf_head: Part,
144 + wolf_tail: Part,
123 145 }
124 146
125 147 fn create_animal_assets(
@@ -147,6 +169,23 @@ fn create_animal_assets(
147 169 Rectangle::new(TILE_SIZE * 0.06, TILE_SIZE * 0.2).into(),
148 170 rabbit,
149 171 ),
172 + wolf_body: part(
173 + Ellipse::new(TILE_SIZE * 0.38, TILE_SIZE * 0.17).into(),
174 + Color::srgb_u8(95, 95, 102),
175 + ),
176 + wolf_head: part(
177 + Triangle2d::new(
178 + Vec2::new(0.0, -TILE_SIZE * 0.13),
179 + Vec2::new(0.0, TILE_SIZE * 0.13),
180 + Vec2::new(TILE_SIZE * 0.28, 0.0),
181 + )
182 + .into(),
183 + Color::srgb_u8(60, 60, 66),
184 + ),
185 + wolf_tail: part(
186 + Rectangle::new(TILE_SIZE * 0.22, TILE_SIZE * 0.07).into(),
187 + Color::srgb_u8(70, 70, 76),
188 + ),
150 189 });
151 190 }
152 191
@@ -183,7 +222,15 @@ pub fn spawn_animal(
183 222 c.spawn(part_bundle(&assets.rabbit_ear, at(0.1, 0.2)));
184 223 c.spawn(part_bundle(&assets.rabbit_ear, at(0.18, 0.2)));
185 224 }
225 + AnimalKind::Wolf => {
226 + c.spawn(part_bundle(&assets.wolf_tail, at(-0.42, 0.06)));
227 + c.spawn(part_bundle(&assets.wolf_body, at(0.0, 0.0)));
228 + c.spawn(part_bundle(&assets.wolf_head, at(0.3, 0.05)));
229 + }
186 230 });
231 + if kind.is_predator() {
232 + entity.insert(Predator::default());
233 + }
187 234 entity.id()
188 235 }
189 236
@@ -265,6 +312,9 @@ fn migrate(
265 312 }
266 313 let colony = zones.home(map.size / 2);
267 314 for kind in AnimalKind::ALL {
315 + if !tick.0.is_multiple_of(kind.migration_ticks()) {
316 + continue;
317 + }
268 318 let alive = animals.iter().filter(|a| a.kind == kind).count();
269 319 if missing_animals(kind, alive)
270 320 && let Some(tile) = wild_tile(&map, &mut rng, colony)
@@ -298,9 +348,23 @@ pub fn wander(
298 348 zones: Res<Zones>,
299 349 mut rng: ResMut<SimRng>,
300 350 colonists: Query<(&TilePos, &CurrentJob), (With<Colonist>, Without<Animal>)>,
301 - mut animals: Query<(Entity, &TilePos, &mut Path, &mut AnimalIdle, Has<Tame>), With<Animal>>,
351 + mut animals: Query<
352 + (
353 + Entity,
354 + &TilePos,
355 + &mut Path,
356 + &mut AnimalIdle,
357 + Has<Tame>,
358 + Option<&Predator>,
359 + ),
360 + With<Animal>,
361 + >,
302 362 ) {
303 - let threats: Vec<UVec2> = colonists.iter().map(|(t, _)| t.0).collect();
363 + let threats: Vec<UVec2> = colonists
364 + .iter()
365 + .map(|(t, _)| t.0)
366 + .chain(animals.iter().filter(|a| a.5.is_some()).map(|a| a.1.0))
367 + .collect();
304 368 let being_tamed: Vec<Entity> = colonists
305 369 .iter()
306 370 .filter_map(|(_, job)| match job.0?.task {
@@ -309,7 +373,10 @@ pub fn wander(
309 373 })
310 374 .collect();
311 375 let home = zones.home(map.size / 2).as_ivec2();
312 - for (entity, tile, mut path, mut idle, tame) in &mut animals {
376 + for (entity, tile, mut path, mut idle, tame, predator) in &mut animals {
377 + if predator.is_some_and(|p| p.target.is_some()) {
378 + continue;
379 + }
313 380 if being_tamed.contains(&entity) {
314 381 path.0.truncate(1);
315 382 continue;
@@ -333,11 +400,12 @@ pub fn wander(
333 400 }
334 401 continue;
335 402 }
403 + let threats: &[UVec2] = if predator.is_some() { &[] } else { &threats };
336 404 let heading_into_danger = path
337 405 .0
338 406 .back()
339 - .is_none_or(|end| flee_goal(*end, &threats).is_some());
340 - if heading_into_danger && let Some(goal) = flee_goal(tile.0, &threats) {
407 + .is_none_or(|end| flee_goal(*end, threats).is_some());
408 + if heading_into_danger && let Some(goal) = flee_goal(tile.0, threats) {
341 409 let goal = goal.clamp(IVec2::ZERO, map.size.as_ivec2() - 1).as_uvec2();
342 410 if let Some(route) = find_path(&map, tile.0, goal) {
343 411 path.0 = route.into();

src/colonist.rs +3 -1

@@ -12,6 +12,7 @@ use crate::{
12 12 population::Starvation,
13 13 sim::{SimPos, SimSystems, sim_pos},
14 14 skills::{Skills, WorkCarry},
15 + threats::Health,
15 16 world::{TILE_SIZE, TilePos, WorldMap},
16 17 worldgen::{WorldGenerated, spawn_tiles},
17 18 };
@@ -96,7 +97,8 @@ pub fn spawn_colonist(
96 97 WorkPriorities,
97 98 Warmth,
98 99 Skills,
99 - WorkCarry
100 + WorkCarry,
101 + Health
100 102 )]
101 103 pub struct Colonist {
102 104 pub name: String,

src/jobs/mod.rs +81 -11

@@ -33,6 +33,7 @@ use crate::{
33 33 sim::{SimSystems, SimTick, hour_of_day},
34 34 skills::{POOR_LEVEL, Skill, Skills, WorkCarry, speed},
35 35 structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure},
36 + threats::{Health, INJURED},
36 37 world::{Plant, Terrain, TileChanged, TilePos, WorldMap},
37 38 worldgen::WorldGenerated,
38 39 zones::{
@@ -737,7 +738,8 @@ pub fn find_taming(
737 738 if animals.iter().filter(|a| a.3).count() >= MAX_TAME {
738 739 return None;
739 740 }
740 - let (animal, _, kind, _) = nearest_reachable(map, reservations, animals, from, |a| !a.3)?;
741 + let tameable = |a: &Wildlife| !a.3 && !a.2.is_predator();
742 + let (animal, _, kind, _) = nearest_reachable(map, reservations, animals, from, tameable)?;
741 743 Some(Task::Tame {
742 744 animal,
743 745 kind,
@@ -1034,6 +1036,26 @@ pub fn find_lighting(ctx: &BuildContext, from: UVec2, dark: bool) -> Option<Task
1034 1036 ctx.build_task(from, site, BuildKind::Structure(kind))
1035 1037 }
1036 1038
1039 +pub const DANGER_RANGE: i32 = 6;
1040 +pub const DEFEND_RANGE: i32 = 15;
1041 +const FLEE_STEPS: f32 = 10.0;
1042 +
1043 +fn chebyshev(a: UVec2, b: UVec2) -> i32 {
1044 + (a.as_ivec2() - b.as_ivec2()).abs().max_element()
1045 +}
1046 +
1047 +pub fn flee_from(map: &WorldMap, from: UVec2, threat: UVec2) -> Option<Vec<UVec2>> {
1048 + let away = (from.as_ivec2() - threat.as_ivec2())
1049 + .as_vec2()
1050 + .normalize_or(Vec2::X);
1051 + let goal = (from.as_ivec2() + (away * FLEE_STEPS).round().as_ivec2())
1052 + .clamp(IVec2::ZERO, map.size.as_ivec2() - 1);
1053 + let open = nearest_tile(map, goal.as_uvec2(), 200, |p| {
1054 + map.move_cost(p.as_ivec2()).is_some()
1055 + })?;
1056 + find_path(map, from, open).filter(|route| !route.is_empty())
1057 +}
1058 +
1037 1059 pub fn chilly_soon(tick: SimTick) -> bool {
1038 1060 let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS);
1039 1061 outdoor_temperature(tick) < COLD_TEMP || outdoor_temperature(later) < COLD_TEMP
@@ -1183,6 +1205,7 @@ fn choose_jobs(
1183 1205 &Warmth,
1184 1206 Has<Tool>,
1185 1207 &Skills,
1208 + &Health,
1186 1209 ),
1187 1210 With<Colonist>,
1188 1211 >,
@@ -1190,14 +1213,28 @@ fn choose_jobs(
1190 1213 mut gather_memo: Local<GatherMemo>,
1191 1214 ) {
1192 1215 let (homes, designations, reach) = plans;
1216 + let wolves: Vec<(Entity, UVec2)> = animals
1217 + .iter()
1218 + .filter(|(_, _, animal, _)| animal.kind.is_predator())
1219 + .map(|(entity, pos, ..)| (entity, pos.0))
1220 + .collect();
1221 + let nearest_wolf = |tile: UVec2, range: i32| {
1222 + wolves
1223 + .iter()
1224 + .map(|&(entity, pos)| (entity, pos, chebyshev(pos, tile)))
1225 + .filter(|&(.., d)| d <= range)
1226 + .min_by_key(|&(_, pos, d)| (d, pos.y, pos.x))
1227 + };
1193 1228 let mut anyone_ready = false;
1194 - for (_, _, path, job, mut idle, _, rest, hunger, ..) in &mut colonists {
1229 + for (_, tile, path, job, mut idle, _, rest, hunger, ..) in &mut colonists {
1195 1230 let idle_now = job.0.is_none() && path.0.is_empty();
1196 1231 if idle_now {
1197 1232 idle.0.tick(time.delta());
1198 1233 }
1199 - anyone_ready |=
1200 - (idle_now && idle.0.is_finished()) || rest.0 < EXHAUSTED_REST || hunger.0 < STARVING;
1234 + anyone_ready |= (idle_now && idle.0.is_finished())
1235 + || rest.0 < EXHAUSTED_REST
1236 + || hunger.0 < STARVING
1237 + || nearest_wolf(tile.0, DANGER_RANGE).is_some();
1201 1238 }
1202 1239 if !anyone_ready {
1203 1240 return;
@@ -1273,16 +1310,26 @@ fn choose_jobs(
1273 1310 warmth,
1274 1311 has_tool,
1275 1312 skills,
1313 + health,
1276 1314 ) in &mut colonists
1277 1315 {
1278 1316 let exhausted = rest.0 < EXHAUSTED_REST;
1279 1317 let starving = hunger.0 < STARVING && food_to_fetch(&reservations, &food_tiles);
1280 - let urgent = exhausted || starving;
1281 - if urgent
1282 - && job.0.is_some_and(|j| {
1283 - !j.ordered && !matches!(j.task, Task::Sleep { .. } | Task::Eat { .. })
1284 - })
1285 - {
1318 + let danger = nearest_wolf(tile.0, DANGER_RANGE);
1319 + let urgent = exhausted || starving || danger.is_some();
1320 + let fighting_wolf = |task: Task| {
1321 + matches!(task, Task::Hunt { kind, .. } if kind.is_predator())
1322 + || matches!(task, Task::MoveTo(_))
1323 + };
1324 + let interrupt = |j: Job| {
1325 + !j.ordered
1326 + && if danger.is_some() {
1327 + !fighting_wolf(j.task)
1328 + } else {
1329 + !matches!(j.task, Task::Sleep { .. } | Task::Eat { .. })
1330 + }
1331 + };
1332 + if urgent && job.0.is_some_and(interrupt) {
1286 1333 reservations.release_all(entity);
1287 1334 job.0 = None;
1288 1335 path.0.truncate(1);
@@ -1310,7 +1357,30 @@ fn choose_jobs(
1310 1357 let food = wants_food(hunger.0)
1311 1358 .then(|| find_food(&map, &reservations, &snapshot, tile.0))
1312 1359 .flatten();
1313 - let task = if exhausted {
1360 + let fighter = !child && health.0 >= INJURED && priorities.allows(WorkType::Food);
1361 + let free_to_fight = fighter && !exhausted && !wants_food(hunger.0);
1362 + let defend =
1363 + danger
1364 + .or_else(|| nearest_wolf(home, DEFEND_RANGE))
1365 + .filter(|&(wolf, pos, _)| {
1366 + free_to_fight
1367 + && reach.allows_near(pos)
1368 + && reservations.is_free(Target::Animal(wolf))
1369 + });
1370 + let flee = danger
1371 + .filter(|_| !fighter)
1372 + .and_then(|(_, wolf, _)| flee_from(&map, tile.0, wolf));
1373 + let task = if let Some(route) = flee {
1374 + let goal = route.last().copied().unwrap_or(tile.0);
1375 + path.0 = route.into();
1376 + Some(Task::MoveTo(goal))
1377 + } else if let Some((wolf, ..)) = defend {
1378 + Some(Task::Hunt {
1379 + animal: wolf,
1380 + kind: AnimalKind::Wolf,
1381 + progress: 0,
1382 + })
1383 + } else if exhausted {
1314 1384 Some(Task::Sleep {
1315 1385 spot: sleep.spot(tile.0),
1316 1386 })

src/jobs/tests.rs +28 -0

@@ -1526,3 +1526,31 @@ fn colonists_try_work_they_are_poor_at_last() {
1526 1526 }
1527 1527 ));
1528 1528 }
1529 +
1530 +// A wolf near the colony is everyone's business: healthy adults drop their work to go after it,
1531 +// while children (and the badly hurt) run the other way.
1532 +#[test]
1533 +fn adults_fight_wolves_and_children_flee() {
1534 + let mut app = test_app(WorldMap::filled(UVec2::splat(40), Terrain::Grass));
1535 + let wolf = app
1536 + .world_mut()
1537 + .spawn((
1538 + Animal {
1539 + kind: AnimalKind::Wolf,
1540 + },
1541 + TilePos(UVec2::new(20, 20)),
1542 + ))
1543 + .id();
1544 + let adult = spawn_colonist(&mut app, UVec2::new(15, 20));
1545 + let child = spawn_colonist(&mut app, UVec2::new(22, 20));
1546 + app.world_mut()
1547 + .entity_mut(child)
1548 + .insert(crate::family::Child { born: 0 });
1549 + run_ticks(&mut app, 1);
1550 + assert!(matches!(
1551 + job(&app, adult).map(|j| j.task),
1552 + Some(Task::Hunt { animal, .. }) if animal == wolf
1553 + ));
1554 + let route = &app.world().get::<Path>(child).unwrap().0;
1555 + assert!(route.back().is_some_and(|end| end.x > 26));
1556 +}

src/main.rs +2 -1

@@ -26,6 +26,7 @@ mod selection;
26 26 mod sim;
27 27 mod skills;
28 28 mod structures;
29 +mod threats;
29 30 mod tilemap;
30 31 mod ui;
31 32 mod world;
@@ -81,6 +82,6 @@ fn main() {
81 82 zones::ZonesPlugin,
82 83 ui::HudPlugin,
83 84 ))
84 - .add_plugins(save::SavePlugin)
85 + .add_plugins((save::SavePlugin, threats::ThreatsPlugin))
85 86 .run();
86 87 }

src/mood.rs +10 -1

@@ -14,6 +14,7 @@ use crate::{
14 14 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
15 15 skills::Skills,
16 16 structures::{Structure, StructureKind},
17 + threats::{HURT, Health},
17 18 world::{TilePos, WorldMap},
18 19 worldgen::WorldGenerated,
19 20 };
@@ -112,6 +113,7 @@ pub struct MoodFactors {
112 113 pub cold: bool,
113 114 pub beauty: bool,
114 115 pub floored: bool,
116 + pub hurt: bool,
115 117 }
116 118
117 119 pub fn mood_target(f: MoodFactors) -> f32 {
@@ -144,6 +146,9 @@ pub fn mood_target(f: MoodFactors) -> f32 {
144 146 if f.floored {
145 147 mood += 0.05;
146 148 }
149 + if f.hurt {
150 + mood -= 0.1;
151 + }
147 152 mood.clamp(0.0, 1.0)
148 153 }
149 154
@@ -211,6 +216,7 @@ fn update_mood(
211 216 Has<Partner>,
212 217 Option<&Family>,
213 218 &Warmth,
219 + &Health,
214 220 )>,
215 221 ) {
216 222 if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
@@ -222,7 +228,9 @@ fn update_mood(
222 228 .filter(|(_, s)| s.kind == StructureKind::Statue)
223 229 .map(|(pos, _)| pos.0)
224 230 .collect();
225 - for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth) in &mut colonists {
231 + for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth, health) in
232 + &mut colonists
233 + {
226 234 let sick_now = sick.is_some_and(|s| tick.0 < s.until);
227 235 if sick.is_some() && !sick_now {
228 236 commands.entity(entity).remove::<Sick>();
@@ -244,6 +252,7 @@ fn update_mood(
244 252 cold: warmth.cold(),
245 253 beauty,
246 254 floored,
255 + hurt: health.0 < HURT,
247 256 });
248 257 mood.0 += (target - mood.0) / MOOD_HOURS;
249 258 }

src/population.rs +45 -6

@@ -4,12 +4,13 @@ use std::collections::VecDeque;
4 4 use bevy::prelude::*;
5 5
6 6 use crate::{
7 - colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist},
7 + colonist::{Colonist, ColonistAssets, NAMES, Path, spawn_colonist},
8 8 economy::in_colony,
9 9 food::Hunger,
10 10 items::{Carrying, Item, ItemAssets, ItemQuery, drop_items},
11 11 jobs::{CurrentJob, Job, Reservations, Task},
12 12 mood::Losses,
13 + pathfinding::find_path,
13 14 rng::SimRng,
14 15 sim::{SimSystems, SimTick, TICKS_PER_HOUR, format_clock},
15 16 structures::{Structure, StructureKind},
@@ -34,6 +35,7 @@ impl Plugin for PopulationPlugin {
34 35 pub const STARVATION_TICKS: u32 = 24 * TICKS_PER_HOUR as u32;
35 36 pub const MAX_COLONISTS: usize = 20;
36 37 pub const ARRIVAL_CHANCE: f32 = 0.6;
38 +const ARRIVAL_ATTEMPTS: usize = 10;
37 39 const ARRIVAL_HOUR_TICK: u64 = 4 * TICKS_PER_HOUR;
38 40 const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;
39 41 const LOG_LENGTH: usize = 4;
@@ -156,7 +158,11 @@ fn arrivals(
156 158 if !lucky || !prosperous(count, food, beds.saturating_sub(count), wood) {
157 159 return;
158 160 }
159 - let Some(tile) = edge_tile(&map, &mut rng) else {
161 + let home = zones.home(map.size / 2);
162 + let Some((tile, route)) = (0..ARRIVAL_ATTEMPTS).find_map(|_| {
163 + let tile = edge_tile(&map, &mut rng)?;
164 + find_path(&map, tile, home).map(|route| (tile, route))
165 + }) else {
160 166 return;
161 167 };
162 168 let taken: Vec<&str> = colonists.iter().map(|c| c.name.as_str()).collect();
@@ -171,10 +177,13 @@ fn arrivals(
171 177 index,
172 178 tile,
173 179 );
174 - commands.entity(newcomer).insert(CurrentJob(Some(Job {
175 - task: Task::MoveTo(zones.home(map.size / 2)),
176 - ordered: false,
177 - })));
180 + commands.entity(newcomer).insert((
181 + CurrentJob(Some(Job {
182 + task: Task::MoveTo(home),
183 + ordered: false,
184 + })),
185 + Path(route.into()),
186 + ));
178 187 log.push(
179 188 *tick,
180 189 format!("{} has joined the colony", NAMES[index % NAMES.len()]),
@@ -248,4 +257,34 @@ mod tests {
248 257 let dropped: Vec<Item> = world.query::<&Item>().iter(world).copied().collect();
249 258 assert_eq!(dropped.len(), 1);
250 259 }
260 +
261 + // Newcomers used to get a "walk home" job with no route, so they stood at the map edge until
262 + // they starved or a wolf found them. They now arrive with a path to the colony.
263 + #[test]
264 + fn newcomers_walk_to_the_colony() {
265 + use crate::{colonist::Path, items::ItemKind, world::Terrain};
266 + let map = WorldMap::filled(UVec2::splat(40), Terrain::Grass);
267 + let arrived = (0..20).find_map(|seed| {
268 + let mut zones = Zones::default();
269 + zones.add_tiles(&map, [UVec2::new(20, 20)]);
270 + let mut app = App::new();
271 + app.insert_resource(map.clone())
272 + .insert_resource(zones)
273 + .insert_resource(SimTick(ARRIVAL_HOUR_TICK))
274 + .insert_resource(SimRng::new(seed))
275 + .init_resource::<ColonistAssets>()
276 + .init_resource::<ItemAssets>()
277 + .init_resource::<Assets<ColorMaterial>>()
278 + .init_resource::<ColonyLog>()
279 + .add_systems(Update, arrivals);
280 + let world = app.world_mut();
281 + world.spawn((Item::new(ItemKind::Wood, 30), TilePos(UVec2::new(20, 20))));
282 + world.spawn((Item::new(ItemKind::Meal, 5), TilePos(UVec2::new(21, 20))));
283 + app.update();
284 + let world = app.world_mut();
285 + let mut query = world.query_filtered::<&Path, With<Colonist>>();
286 + query.iter(world).next().map(|path| path.0.back().copied())
287 + });
288 + assert_eq!(arrived, Some(Some(UVec2::new(20, 20))));
289 + }
251 290 }

src/save.rs +11 -0

@@ -19,6 +19,7 @@ use crate::{
19 19 sim::{SimPos, SimTick, sim_pos},
20 20 skills::Skills,
21 21 structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure},
22 + threats::Health,
22 23 world::{MapLayers, TilePos, WorldMap},
23 24 worldgen::{WorldGenerated, WorldSeed, generate},
24 25 zones::{Zones, ZonesSave},
@@ -57,6 +58,12 @@ pub struct ColonistSave {
57 58 starvation: u32,
58 59 #[serde(default)]
59 60 skills: Skills,
61 + #[serde(default = "full_health")]
62 + health: f32,
63 +}
64 +
65 +fn full_health() -> f32 {
66 + 1.0
60 67 }
61 68
62 69 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
@@ -110,6 +117,7 @@ pub fn snapshot(world: &mut World) -> SaveGame {
110 117 Option<&Unhappy>,
111 118 Option<&Starvation>,
112 119 Option<&Skills>,
120 + Option<&Health>,
113 121 ),
114 122 )>();
115 123 let rows: Vec<_> = colonist_query.iter(world).collect();
@@ -130,6 +138,7 @@ pub fn snapshot(world: &mut World) -> SaveGame {
130 138 unhappy,
131 139 starvation,
132 140 skills,
141 + health,
133 142 ) = extra;
134 143 ColonistSave {
135 144 name: colonist.name.clone(),
@@ -150,6 +159,7 @@ pub fn snapshot(world: &mut World) -> SaveGame {
150 159 unhappy: unhappy.map_or(0, |u| u.0),
151 160 starvation: starvation.map_or(0, |s| s.0),
152 161 skills: skills.cloned().unwrap_or_default(),
162 + health: health.map_or(1.0, |h| h.0),
153 163 }
154 164 },
155 165 )
@@ -300,6 +310,7 @@ pub fn apply(world: &mut World, save: SaveGame) {
300 310 Unhappy(c.unhappy),
301 311 Starvation(c.starvation),
302 312 c.skills.clone(),
313 + Health(c.health),
303 314 ));
304 315 if let Some(mood) = c.mood {
305 316 e.insert(Mood(mood));

src/selection.rs +49 -40

@@ -20,6 +20,7 @@ use crate::{
20 20 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
21 21 skills::Skills,
22 22 structures::{Fuel, Structure, StructureKind},
23 + threats::Health,
23 24 ui::{HideableUi, PointerOverUi},
24 25 world::{TILE_SIZE, TilePos, WorldMap},
25 26 worldgen::{WorldGenerated, WorldSeed},
@@ -283,6 +284,7 @@ fn update_tile_info(
283 284 Option<&Warmth>,
284 285 Has<Tool>,
285 286 Option<&Skills>,
287 + Option<&Health>,
286 288 )>,
287 289 mut text: Single<&mut Text, With<TileInfoText>>,
288 290 ) {
@@ -303,46 +305,53 @@ fn update_tile_info(
303 305 fed: hunger.0,
304 306 }) + &kin
305 307 .get(entity)
306 - .map(|(partner, child, mood, age, sick, warmth, tool, skills)| {
307 - let partner = partner
308 - .and_then(|p| colonists.get(p.0).ok())
309 - .map(|p| format!(", partner {}", p.0.name));
310 - let child =
311 - child.map(|c| format!(", child (grows up in {}d)", c.days_left(*tick)));
312 - let mood = mood
313 - .map(|m| format!(", mood {:.0}%", m.0 * 100.0))
314 - .unwrap_or_default();
315 - let age = age
316 - .map(|a| format!(", age {}", a.years(*tick)))
317 - .unwrap_or_default();
318 - let sick = if sick { ", sick" } else { "" };
319 - let tool = if tool { ", has tools" } else { "" };
320 - let skills = skills
321 - .filter(|_| child.is_none())
322 - .map(|s| {
323 - let best: Vec<String> = s
324 - .best(3)
325 - .iter()
326 - .map(|(skill, level)| format!("{} {level}", skill.name()))
327 - .collect();
328 - format!("\nSkills: {}", best.join(", "))
329 - })
330 - .unwrap_or_default();
331 - let warmth = warmth
332 - .map(|w| {
333 - let cold = if w.cold() { " (cold)" } else { "" };
334 - format!(", {:.0}C{cold}", w.0)
335 - })
336 - .unwrap_or_default();
337 - partner.unwrap_or_default()
338 - + &child.unwrap_or_default()
339 - + &mood
340 - + &age
341 - + sick
342 - + tool
343 - + &warmth
344 - + &skills
345 - })
308 + .map(
309 + |(partner, child, mood, age, sick, warmth, tool, skills, health)| {
310 + let partner = partner
311 + .and_then(|p| colonists.get(p.0).ok())
312 + .map(|p| format!(", partner {}", p.0.name));
313 + let child = child
314 + .map(|c| format!(", child (grows up in {}d)", c.days_left(*tick)));
315 + let mood = mood
316 + .map(|m| format!(", mood {:.0}%", m.0 * 100.0))
317 + .unwrap_or_default();
318 + let age = age
319 + .map(|a| format!(", age {}", a.years(*tick)))
320 + .unwrap_or_default();
321 + let sick = if sick { ", sick" } else { "" };
322 + let tool = if tool { ", has tools" } else { "" };
323 + let health = health
324 + .filter(|h| h.0 < 1.0)
325 + .map(|h| format!(", health {:.0}%", h.0 * 100.0))
326 + .unwrap_or_default();
327 + let skills = skills
328 + .filter(|_| child.is_none())
329 + .map(|s| {
330 + let best: Vec<String> = s
331 + .best(3)
332 + .iter()
333 + .map(|(skill, level)| format!("{} {level}", skill.name()))
334 + .collect();
335 + format!("\nSkills: {}", best.join(", "))
336 + })
337 + .unwrap_or_default();
338 + let warmth = warmth
339 + .map(|w| {
340 + let cold = if w.cold() { " (cold)" } else { "" };
341 + format!(", {:.0}C{cold}", w.0)
342 + })
343 + .unwrap_or_default();
344 + partner.unwrap_or_default()
345 + + &child.unwrap_or_default()
346 + + &mood
347 + + &age
348 + + sick
349 + + tool
350 + + &health
351 + + &warmth
352 + + &skills
353 + },
354 + )
346 355 .unwrap_or_default()
347 356 })
348 357 .unwrap_or_else(|_| "-".into()),

src/threats.rs +302 -0

@@ -0,0 +1,302 @@
1 +use std::collections::HashSet;
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + animals::Animal,
7 + colonist::{Colonist, Path},
8 + items::{Carrying, ItemAssets, ItemQuery, drop_items},
9 + jobs::{CurrentJob, Reservations, Target},
10 + mood::Losses,
11 + needs::is_sleeping,
12 + pathfinding::{find_path_adjacent, is_adjacent},
13 + population::ColonyLog,
14 + sim::{SimSystems, SimTick, TICKS_PER_HOUR},
15 + structures::{Structure, StructureKind},
16 + world::{TilePos, WorldMap},
17 +};
18 +
19 +pub struct ThreatsPlugin;
20 +
21 +impl Plugin for ThreatsPlugin {
22 + fn build(&self, app: &mut App) {
23 + app.add_systems(
24 + FixedUpdate,
25 + (
26 + prowl.in_set(SimSystems::Decide),
27 + (bite, heal).chain().in_set(SimSystems::Act),
28 + ),
29 + );
30 + }
31 +}
32 +
33 +pub const PROWL_RANGE: i32 = 24;
34 +pub const COLONIST_DETOUR: i32 = 6;
35 +pub const COLONIST_BITE: f32 = 0.25;
36 +pub const ANIMAL_BITE: f32 = 0.5;
37 +pub const BITE_COOLDOWN_TICKS: u32 = 60;
38 +pub const FED_TICKS: u64 = 12 * TICKS_PER_HOUR;
39 +pub const INJURED: f32 = 0.5;
40 +pub const HURT: f32 = 0.75;
41 +const HEAL_PER_DAY: f32 = 0.3;
42 +const BED_HEAL_FACTOR: f32 = 3.0;
43 +const HEAL_INTERVAL_TICKS: u64 = 60;
44 +const PROWL_INTERVAL_TICKS: u64 = 30;
45 +
46 +#[derive(Component, Debug, Clone, Copy, PartialEq)]
47 +pub struct Health(pub f32);
48 +
49 +impl Default for Health {
50 + fn default() -> Self {
51 + Self(1.0)
52 + }
53 +}
54 +
55 +#[derive(Component, Debug, Default, Clone, Copy, PartialEq)]
56 +pub struct Predator {
57 + pub target: Option<Entity>,
58 + pub fed_until: u64,
59 + pub cooldown: u32,
60 +}
61 +
62 +#[derive(Clone, Copy, Debug, PartialEq)]
63 +pub struct Prey {
64 + pub entity: Entity,
65 + pub tile: UVec2,
66 + pub colonist: bool,
67 +}
68 +
69 +pub fn pick_prey(from: UVec2, prey: &[Prey]) -> Option<Prey> {
70 + prey.iter()
71 + .filter_map(|p| {
72 + let distance = (p.tile.as_ivec2() - from.as_ivec2()).abs().max_element();
73 + let cost = distance + if p.colonist { COLONIST_DETOUR } else { 0 };
74 + (distance <= PROWL_RANGE).then_some((cost, p))
75 + })
76 + .min_by_key(|(cost, p)| (*cost, p.tile.y, p.tile.x))
77 + .map(|(_, p)| *p)
78 +}
79 +
80 +#[allow(clippy::type_complexity)]
81 +fn prowl(
82 + tick: Res<SimTick>,
83 + map: Res<WorldMap>,
84 + mut wolves: Query<(&TilePos, &mut Path, &mut Predator), With<Animal>>,
85 + animals: Query<(Entity, &TilePos), (With<Animal>, Without<Predator>)>,
86 + colonists: Query<(Entity, &TilePos), With<Colonist>>,
87 +) {
88 + if !tick.0.is_multiple_of(PROWL_INTERVAL_TICKS) {
89 + return;
90 + }
91 + let prey: Vec<Prey> = animals
92 + .iter()
93 + .map(|(entity, tile)| Prey {
94 + entity,
95 + tile: tile.0,
96 + colonist: false,
97 + })
98 + .chain(colonists.iter().map(|(entity, tile)| Prey {
99 + entity,
100 + tile: tile.0,
101 + colonist: true,
102 + }))
103 + .collect();
104 + for (tile, mut path, mut predator) in &mut wolves {
105 + if tick.0 < predator.fed_until {
106 + predator.target = None;
107 + continue;
108 + }
109 + let Some(target) = pick_prey(tile.0, &prey) else {
110 + predator.target = None;
111 + continue;
112 + };
113 + predator.target = Some(target.entity);
114 + let end = path.0.back().copied().unwrap_or(tile.0);
115 + if is_adjacent(end.as_ivec2(), target.tile.as_ivec2()) || end == target.tile {
116 + continue;
117 + }
118 + if let Some(route) = find_path_adjacent(&map, tile.0, target.tile) {
119 + let entering = path.0.front().copied();
120 + path.0 = entering.into_iter().chain(route).collect();
121 + }
122 + }
123 +}
124 +
125 +#[allow(clippy::too_many_arguments, clippy::type_complexity)]
126 +fn bite(
127 + mut commands: Commands,
128 + tick: Res<SimTick>,
129 + item_assets: Res<ItemAssets>,
130 + mut log: ResMut<ColonyLog>,
131 + mut losses: ResMut<Losses>,
132 + mut reservations: ResMut<Reservations>,
133 + mut items: ItemQuery,
134 + mut wolves: Query<(&TilePos, &mut Predator), With<Animal>>,
135 + mut colonists: Query<
136 + (&Colonist, &TilePos, &mut Health, &Carrying),
137 + (Without<Animal>, Without<Predator>),
138 + >,
139 + mut prey: Query<(&TilePos, &mut Health), (With<Animal>, Without<Predator>, Without<Colonist>)>,
140 +) {
141 + for (tile, mut predator) in &mut wolves {
142 + predator.cooldown = predator.cooldown.saturating_sub(1);
143 + let Some(target) = predator.target else {
144 + continue;
145 + };
146 + if predator.cooldown > 0 {
147 + continue;
148 + }
149 + let near = |other: &TilePos| {
150 + other.0 == tile.0 || is_adjacent(other.0.as_ivec2(), tile.0.as_ivec2())
151 + };
152 + if let Ok((colonist, pos, mut health, carrying)) = colonists.get_mut(target) {
153 + if !near(pos) {
154 + continue;
155 + }
156 + predator.cooldown = BITE_COOLDOWN_TICKS;
157 + if health.0 >= 1.0 {
158 + log.push(*tick, format!("{} was attacked by a wolf", colonist.name));
159 + }
160 + health.0 -= COLONIST_BITE;
161 + if health.0 <= 0.0 {
162 + if let Some(load) = carrying.0 {
163 + drop_items(&mut commands, &mut items, &item_assets, pos.0, load);
164 + }
165 + reservations.release_all(target);
166 + commands.entity(target).despawn();
167 + losses.0.push(tick.0);
168 + log.push(*tick, format!("{} was killed by a wolf", colonist.name));
169 + predator.fed_until = tick.0 + FED_TICKS;
170 + predator.target = None;
171 + }
172 + } else if let Ok((pos, mut health)) = prey.get_mut(target) {
173 + if !near(pos) {
174 + continue;
175 + }
176 + predator.cooldown = BITE_COOLDOWN_TICKS;
177 + health.0 -= ANIMAL_BITE;
178 + if health.0 <= 0.0 {
179 + reservations.release(Target::Animal(target));
180 + commands.entity(target).despawn();
181 + predator.fed_until = tick.0 + FED_TICKS;
182 + predator.target = None;
183 + }
184 + } else {
185 + predator.target = None;
186 + }
187 + }
188 +}
189 +
190 +pub fn healed(health: f32, ticks: f32, in_bed: bool) -> f32 {
191 + let rate = HEAL_PER_DAY / (24.0 * TICKS_PER_HOUR as f32);
192 + let rate = if in_bed { rate * BED_HEAL_FACTOR } else { rate };
193 + (health + rate * ticks).min(1.0)
194 +}
195 +
196 +#[allow(clippy::type_complexity)]
197 +fn heal(
198 + tick: Res<SimTick>,
199 + structures: Query<(&TilePos, &Structure), Without<Health>>,
200 + mut patients: Query<(&mut Health, &TilePos, Option<(&CurrentJob, &Path)>)>,
201 +) {
202 + if !tick.0.is_multiple_of(HEAL_INTERVAL_TICKS) {
203 + return;
204 + }
205 + let beds: HashSet<UVec2> = structures
206 + .iter()
207 + .filter(|(_, s)| s.kind == StructureKind::Bed)
208 + .map(|(pos, _)| pos.0)
209 + .collect();
210 + for (mut health, tile, job) in &mut patients {
211 + if health.0 >= 1.0 {
212 + continue;
213 + }
214 + let in_bed =
215 + job.is_some_and(|(job, path)| is_sleeping(job, tile, path) && beds.contains(&tile.0));
216 + health.0 = healed(health.0, HEAL_INTERVAL_TICKS as f32, in_bed);
217 + }
218 +}
219 +
220 +#[cfg(test)]
221 +mod tests {
222 + use super::*;
223 +
224 + // Wolves go for the nearest meal but would rather take an animal than a person, unless the
225 + // person is much closer.
226 + #[test]
227 + fn wolves_prefer_animals_to_people() {
228 + let e = |i: u32| Entity::from_raw_u32(i).unwrap();
229 + let deer = Prey {
230 + entity: e(1),
231 + tile: UVec2::new(10, 0),
232 + colonist: false,
233 + };
234 + let person = Prey {
235 + entity: e(2),
236 + tile: UVec2::new(8, 0),
237 + colonist: true,
238 + };
239 + assert_eq!(pick_prey(UVec2::ZERO, &[deer, person]), Some(deer));
240 + let close = Prey {
241 + tile: UVec2::new(2, 0),
242 + ..person
243 + };
244 + assert_eq!(pick_prey(UVec2::ZERO, &[deer, close]), Some(close));
245 + let far = Prey {
246 + tile: UVec2::new(PROWL_RANGE as u32 + 1, 0),
247 + ..deer
248 + };
249 + assert_eq!(pick_prey(UVec2::ZERO, &[far]), None);
250 + }
251 +
252 + #[test]
253 + fn wounds_heal_slowly_and_faster_in_bed() {
254 + let day = 24.0 * TICKS_PER_HOUR as f32;
255 + assert!((healed(0.5, day, false) - 0.8).abs() < 1e-4);
256 + assert_eq!(healed(0.5, day, true), 1.0);
257 + }
258 +
259 + // A hungry wolf stalks the nearest prey, bites it to death and then rests while it digests.
260 + #[test]
261 + fn wolves_hunt_down_prey_and_then_rest() {
262 + use crate::{
263 + animals::{AnimalIdle, AnimalKind},
264 + colonist::{MoveSpeed, follow_path},
265 + sim::sim_pos,
266 + world::Terrain,
267 + };
268 + let mut app = App::new();
269 + app.insert_resource(WorldMap::filled(UVec2::splat(30), Terrain::Grass))
270 + .init_resource::<SimTick>()
271 + .init_resource::<Time>()
272 + .init_resource::<ItemAssets>()
273 + .init_resource::<ColonyLog>()
274 + .init_resource::<Losses>()
275 + .init_resource::<Reservations>()
276 + .add_systems(Update, (prowl, follow_path, bite).chain());
277 + let spawn = |app: &mut App, kind: AnimalKind, tile: UVec2| {
278 + app.world_mut()
279 + .spawn((
280 + Animal { kind },
281 + TilePos(tile),
282 + sim_pos(WorldMap::tile_center(tile)),
283 + MoveSpeed(3.0),
284 + AnimalIdle::default(),
285 + ))
286 + .id()
287 + };
288 + let wolf = spawn(&mut app, AnimalKind::Wolf, UVec2::new(2, 2));
289 + app.world_mut().entity_mut(wolf).insert(Predator::default());
290 + let rabbit = spawn(&mut app, AnimalKind::Rabbit, UVec2::new(12, 2));
291 + for t in 0..2000 {
292 + app.world_mut().resource_mut::<SimTick>().0 = t;
293 + app.world_mut()
294 + .resource_mut::<Time>()
295 + .advance_by(std::time::Duration::from_secs_f64(1.0 / 60.0));
296 + app.update();
297 + }
298 + assert!(app.world().get_entity(rabbit).is_err());
299 + let predator = app.world().get::<Predator>(wolf).unwrap();
300 + assert!(predator.fed_until > 0 && predator.target.is_none());
301 + }
302 +}

tasks.md +1 -0

@@ -46,6 +46,7 @@
46 46 - [x] Wood floors: colonists floor finished homes (mood bonus), Build tool floor mode; light only recomputes on wall/roof changes; search reach fix for far stacks
47 47 - [x] Connected wall rendering (wall tops join neighbouring walls and doors)
48 48 - [x] Skills: eight work skills set work speed, improve with practice, newcomers have a talent; poor-skill work is tried last
49 +- [x] Wolves: predators hunt animals and colonists; health, bites, healing; adults fight wolves near the colony, children and the hurt flee; newcomers now walk to the colony instead of stranding at the edge
49 50 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
50 51
51 52 ## Planned