src/threats.rs 12.4 K · 377 lines · raw · history

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_within, 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 const STALK_SEARCH: usize = 4000;
35 const GIVE_UP_TICKS: u64 = 2 * TICKS_PER_HOUR;
36 pub const COLONIST_DETOUR: i32 = 6;
37 pub const COLONIST_BITE: f32 = 0.25;
38 pub const ANIMAL_BITE: f32 = 0.5;
39 pub const BITE_COOLDOWN_TICKS: u32 = 60;
40 pub const FED_TICKS: u64 = 12 * TICKS_PER_HOUR;
41 pub const INJURED: f32 = 0.5;
42 pub const HURT: f32 = 0.75;
43 const HEAL_PER_DAY: f32 = 0.3;
44 const BED_HEAL_FACTOR: f32 = 3.0;
45 const HEAL_INTERVAL_TICKS: u64 = 60;
46 const PROWL_INTERVAL_TICKS: u64 = 30;
47
48 #[derive(Component, Debug, Clone, Copy, PartialEq)]
49 pub struct Health(pub f32);
50
51 impl Default for Health {
52 fn default() -> Self {
53 Self(1.0)
54 }
55 }
56
57 #[derive(Component, Debug, Clone, Copy, PartialEq)]
58 pub struct Tended {
59 pub until: u64,
60 pub quality: f32,
61 }
62
63 pub const TENDED_TICKS: u64 = 24 * TICKS_PER_HOUR;
64 pub const TEND_WORK: u32 = 240;
65 const TENDED_HEAL_BONUS: f32 = 2.0;
66
67 pub fn tend_quality(doctor_speed: f32, herb: bool) -> f32 {
68 (doctor_speed * if herb { 1.0 } else { 0.5 }).min(2.0)
69 }
70
71 pub fn needs_tending(health: f32, tended: Option<&Tended>, tick: u64) -> bool {
72 health < HURT && tended.is_none_or(|t| tick >= t.until)
73 }
74
75 #[derive(Component, Debug, Default, Clone, Copy, PartialEq)]
76 pub struct Predator {
77 pub target: Option<Entity>,
78 pub fed_until: u64,
79 pub cooldown: u32,
80 pub resting_until: u64,
81 }
82
83 #[derive(Clone, Copy, Debug, PartialEq)]
84 pub struct Prey {
85 pub entity: Entity,
86 pub tile: UVec2,
87 pub colonist: bool,
88 }
89
90 pub fn pick_prey(from: UVec2, prey: &[Prey]) -> Option<Prey> {
91 prey.iter()
92 .filter_map(|p| {
93 let distance = (p.tile.as_ivec2() - from.as_ivec2()).abs().max_element();
94 let cost = distance + if p.colonist { COLONIST_DETOUR } else { 0 };
95 (distance <= PROWL_RANGE).then_some((cost, p))
96 })
97 .min_by_key(|(cost, p)| (*cost, p.tile.y, p.tile.x))
98 .map(|(_, p)| *p)
99 }
100
101 #[allow(clippy::type_complexity)]
102 fn prowl(
103 tick: Res<SimTick>,
104 map: Res<WorldMap>,
105 mut wolves: Query<(&TilePos, &mut Path, &mut Predator), With<Animal>>,
106 animals: Query<(Entity, &TilePos), (With<Animal>, Without<Predator>)>,
107 colonists: Query<(Entity, &TilePos), With<Colonist>>,
108 ) {
109 if !tick.0.is_multiple_of(PROWL_INTERVAL_TICKS) {
110 return;
111 }
112 let prey: Vec<Prey> = animals
113 .iter()
114 .map(|(entity, tile)| Prey {
115 entity,
116 tile: tile.0,
117 colonist: false,
118 })
119 .chain(colonists.iter().map(|(entity, tile)| Prey {
120 entity,
121 tile: tile.0,
122 colonist: true,
123 }))
124 .collect();
125 for (tile, mut path, mut predator) in &mut wolves {
126 if tick.0 < predator.fed_until || tick.0 < predator.resting_until {
127 predator.target = None;
128 continue;
129 }
130 let Some(target) = pick_prey(tile.0, &prey) else {
131 predator.target = None;
132 continue;
133 };
134 predator.target = Some(target.entity);
135 let end = path.0.back().copied().unwrap_or(tile.0);
136 if is_adjacent(end.as_ivec2(), target.tile.as_ivec2()) || end == target.tile {
137 continue;
138 }
139 match find_path_adjacent_within(&map, tile.0, target.tile, STALK_SEARCH) {
140 Some(route) => {
141 let entering = path.0.front().copied();
142 path.0 = entering.into_iter().chain(route).collect();
143 }
144 None => {
145 predator.target = None;
146 predator.resting_until = tick.0 + GIVE_UP_TICKS;
147 }
148 }
149 }
150 }
151
152 #[allow(clippy::too_many_arguments, clippy::type_complexity)]
153 fn bite(
154 mut commands: Commands,
155 tick: Res<SimTick>,
156 item_assets: Res<ItemAssets>,
157 mut log: ResMut<ColonyLog>,
158 mut losses: ResMut<Losses>,
159 mut reservations: ResMut<Reservations>,
160 mut items: ItemQuery,
161 mut wolves: Query<(&TilePos, &mut Predator), With<Animal>>,
162 mut colonists: Query<
163 (&Colonist, &TilePos, &mut Health, &Carrying),
164 (Without<Animal>, Without<Predator>),
165 >,
166 mut prey: Query<(&TilePos, &mut Health), (With<Animal>, Without<Predator>, Without<Colonist>)>,
167 ) {
168 for (tile, mut predator) in &mut wolves {
169 predator.cooldown = predator.cooldown.saturating_sub(1);
170 let Some(target) = predator.target else {
171 continue;
172 };
173 if predator.cooldown > 0 {
174 continue;
175 }
176 let near = |other: &TilePos| {
177 other.0 == tile.0 || is_adjacent(other.0.as_ivec2(), tile.0.as_ivec2())
178 };
179 if let Ok((colonist, pos, mut health, carrying)) = colonists.get_mut(target) {
180 if !near(pos) {
181 continue;
182 }
183 predator.cooldown = BITE_COOLDOWN_TICKS;
184 if health.0 >= 1.0 {
185 log.push(*tick, format!("{} was attacked by a wolf", colonist.name));
186 }
187 health.0 -= COLONIST_BITE;
188 if health.0 <= 0.0 {
189 if let Some(load) = carrying.0 {
190 drop_items(&mut commands, &mut items, &item_assets, pos.0, load);
191 }
192 reservations.release_all(target);
193 commands.entity(target).despawn();
194 losses.0.push(tick.0);
195 log.push(*tick, format!("{} was killed by a wolf", colonist.name));
196 predator.fed_until = tick.0 + FED_TICKS;
197 predator.target = None;
198 }
199 } else if let Ok((pos, mut health)) = prey.get_mut(target) {
200 if !near(pos) {
201 continue;
202 }
203 predator.cooldown = BITE_COOLDOWN_TICKS;
204 health.0 -= ANIMAL_BITE;
205 if health.0 <= 0.0 {
206 reservations.release(Target::Animal(target));
207 commands.entity(target).despawn();
208 predator.fed_until = tick.0 + FED_TICKS;
209 predator.target = None;
210 }
211 } else {
212 predator.target = None;
213 }
214 }
215 }
216
217 pub fn healed(health: f32, ticks: f32, in_bed: bool, tended: f32) -> f32 {
218 let rate = HEAL_PER_DAY / (24.0 * TICKS_PER_HOUR as f32);
219 let rate = if in_bed { rate * BED_HEAL_FACTOR } else { rate };
220 let rate = rate * (1.0 + TENDED_HEAL_BONUS * tended);
221 (health + rate * ticks).min(1.0)
222 }
223
224 #[allow(clippy::type_complexity)]
225 fn heal(
226 tick: Res<SimTick>,
227 structures: Query<(&TilePos, &Structure), Without<Health>>,
228 mut patients: Query<(
229 &mut Health,
230 &TilePos,
231 Option<(&CurrentJob, &Path)>,
232 Option<&Tended>,
233 )>,
234 ) {
235 if !tick.0.is_multiple_of(HEAL_INTERVAL_TICKS) {
236 return;
237 }
238 let beds: HashSet<UVec2> = structures
239 .iter()
240 .filter(|(_, s)| s.kind == StructureKind::Bed)
241 .map(|(pos, _)| pos.0)
242 .collect();
243 for (mut health, tile, job, tended) in &mut patients {
244 if health.0 >= 1.0 {
245 continue;
246 }
247 let in_bed =
248 job.is_some_and(|(job, path)| is_sleeping(job, tile, path) && beds.contains(&tile.0));
249 let tended = tended
250 .filter(|t| tick.0 < t.until)
251 .map_or(0.0, |t| t.quality);
252 health.0 = healed(health.0, HEAL_INTERVAL_TICKS as f32, in_bed, tended);
253 }
254 }
255
256 #[cfg(test)]
257 mod tests {
258 use super::*;
259
260 // Wolves go for the nearest meal but would rather take an animal than a person, unless the
261 // person is much closer.
262 #[test]
263 fn wolves_prefer_animals_to_people() {
264 let e = |i: u32| Entity::from_raw_u32(i).unwrap();
265 let deer = Prey {
266 entity: e(1),
267 tile: UVec2::new(10, 0),
268 colonist: false,
269 };
270 let person = Prey {
271 entity: e(2),
272 tile: UVec2::new(8, 0),
273 colonist: true,
274 };
275 assert_eq!(pick_prey(UVec2::ZERO, &[deer, person]), Some(deer));
276 let close = Prey {
277 tile: UVec2::new(2, 0),
278 ..person
279 };
280 assert_eq!(pick_prey(UVec2::ZERO, &[deer, close]), Some(close));
281 let far = Prey {
282 tile: UVec2::new(PROWL_RANGE as u32 + 1, 0),
283 ..deer
284 };
285 assert_eq!(pick_prey(UVec2::ZERO, &[far]), None);
286 }
287
288 #[test]
289 fn wounds_heal_slowly_and_faster_in_bed() {
290 let day = 24.0 * TICKS_PER_HOUR as f32;
291 assert!((healed(0.5, day, false, 0.0) - 0.8).abs() < 1e-4);
292 assert_eq!(healed(0.5, day, true, 0.0), 1.0);
293 assert!(healed(0.2, day, false, 1.0) > healed(0.2, day, false, 0.5));
294 assert!(tend_quality(1.0, true) > tend_quality(1.0, false));
295 }
296
297 // A hungry wolf stalks the nearest prey, bites it to death and then rests while it digests.
298 #[test]
299 fn wolves_hunt_down_prey_and_then_rest() {
300 use crate::{
301 animals::{AnimalIdle, AnimalKind},
302 colonist::{MoveSpeed, follow_path},
303 sim::sim_pos,
304 world::Terrain,
305 };
306 let mut app = App::new();
307 app.insert_resource(WorldMap::filled(UVec2::splat(30), Terrain::Grass))
308 .init_resource::<SimTick>()
309 .init_resource::<Time>()
310 .init_resource::<ItemAssets>()
311 .init_resource::<ColonyLog>()
312 .init_resource::<Losses>()
313 .init_resource::<Reservations>()
314 .add_systems(Update, (prowl, follow_path, bite).chain());
315 let spawn = |app: &mut App, kind: AnimalKind, tile: UVec2| {
316 app.world_mut()
317 .spawn((
318 Animal { kind },
319 TilePos(tile),
320 sim_pos(WorldMap::tile_center(tile)),
321 MoveSpeed(3.0),
322 AnimalIdle::default(),
323 ))
324 .id()
325 };
326 let wolf = spawn(&mut app, AnimalKind::Wolf, UVec2::new(2, 2));
327 app.world_mut().entity_mut(wolf).insert(Predator::default());
328 let rabbit = spawn(&mut app, AnimalKind::Rabbit, UVec2::new(12, 2));
329 for t in 0..2000 {
330 app.world_mut().resource_mut::<SimTick>().0 = t;
331 app.world_mut()
332 .resource_mut::<Time>()
333 .advance_by(std::time::Duration::from_secs_f64(1.0 / 60.0));
334 app.update();
335 }
336 assert!(app.world().get_entity(rabbit).is_err());
337 let predator = app.world().get::<Predator>(wolf).unwrap();
338 assert!(predator.fed_until > 0 && predator.target.is_none());
339 }
340
341 // Prey a wolf can't reach (behind walls, across water) made it flood the whole map every 30
342 // ticks; with a few wolves and a walled-in colony that was 95% of the frame. A wolf now gives
343 // up on unreachable prey and rests before trying again.
344 #[test]
345 fn wolves_give_up_on_unreachable_prey() {
346 use crate::{animals::AnimalKind, world::Terrain};
347 let mut map = WorldMap::filled(UVec2::splat(30), Terrain::Grass);
348 for d in -1..=1 {
349 for (x, y) in [(d, -1), (d, 1), (-1, d), (1, d)] {
350 map.set_wall(UVec2::new((15 + x) as u32, (15 + y) as u32), true);
351 }
352 }
353 let mut app = App::new();
354 app.insert_resource(map)
355 .init_resource::<SimTick>()
356 .add_systems(Update, prowl);
357 let wolf = app
358 .world_mut()
359 .spawn((
360 Animal {
361 kind: AnimalKind::Wolf,
362 },
363 TilePos(UVec2::new(5, 5)),
364 Predator::default(),
365 ))
366 .id();
367 app.world_mut().spawn((
368 Animal {
369 kind: AnimalKind::Rabbit,
370 },
371 TilePos(UVec2::splat(15)),
372 ));
373 app.update();
374 let predator = app.world().get::<Predator>(wolf).unwrap();
375 assert!(predator.target.is_none() && predator.resting_until > 0);
376 }
377 }