src/raids.rs 12.6 K · 371 lines · raw · history

1 use bevy::prelude::*;
2
3 use crate::{
4 climate::day_of,
5 colonist::{Colonist, MoveSpeed, Path},
6 economy::in_colony,
7 items::{Carrying, Item, ItemAssets, ItemQuery, drop_items},
8 jobs::Reservations,
9 mood::Losses,
10 pathfinding::{find_path, find_path_adjacent_within, is_adjacent},
11 population::{ColonyLog, edge_tile},
12 rng::SimRng,
13 sim::{SimSystems, SimTick, TICKS_PER_HOUR, sim_pos},
14 threats::Health,
15 world::{TILE_SIZE, TilePos, WorldMap},
16 worldgen::WorldGenerated,
17 zones::Zones,
18 };
19
20 pub struct RaidsPlugin;
21
22 impl Plugin for RaidsPlugin {
23 fn build(&self, app: &mut App) {
24 app.init_resource::<RaidState>()
25 .add_systems(Startup, create_raider_assets)
26 .add_systems(Update, clear_raiders.run_if(on_message::<WorldGenerated>))
27 .add_systems(
28 FixedUpdate,
29 (
30 raider_plans.in_set(SimSystems::Decide),
31 (raid_arrivals, raider_attacks, raider_deaths)
32 .chain()
33 .in_set(SimSystems::Act),
34 ),
35 );
36 }
37 }
38
39 pub const FIRST_RAID_DAY: u64 = 10;
40 pub const RAID_GAP_DAYS: u64 = 6;
41 pub const RAID_CHANCE: f32 = 0.5;
42 const RAID_HOUR_TICK: u64 = 9 * TICKS_PER_HOUR;
43 const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;
44 pub const RAIDER_HIT: f32 = 0.2;
45 const HIT_COOLDOWN_TICKS: u32 = 60;
46 pub const RAIDER_FLEE_HEALTH: f32 = 0.4;
47 const PLAN_INTERVAL_TICKS: u64 = 20;
48 const RAIDER_SEARCH: usize = 6000;
49 const CLOSE_IN: i32 = 20;
50 const RAIDER_SPEED: f32 = 3.2;
51 const ARRIVAL_ATTEMPTS: usize = 10;
52
53 #[derive(Component, Debug, Default, Clone, Copy, PartialEq)]
54 #[require(Transform, Visibility, TilePos, Path, Health)]
55 pub struct Raider {
56 pub loot: Option<Item>,
57 pub fleeing: bool,
58 pub cooldown: u32,
59 }
60
61 #[derive(Resource, Default, Debug)]
62 pub struct RaidState {
63 pub last_day: Option<u64>,
64 }
65
66 #[derive(Resource, Default)]
67 pub struct RaiderAssets {
68 body: (Handle<Mesh>, Handle<ColorMaterial>),
69 blade: (Handle<Mesh>, Handle<ColorMaterial>),
70 }
71
72 fn create_raider_assets(
73 mut commands: Commands,
74 mut meshes: ResMut<Assets<Mesh>>,
75 mut materials: ResMut<Assets<ColorMaterial>>,
76 ) {
77 commands.insert_resource(RaiderAssets {
78 body: (
79 meshes.add(Circle::new(TILE_SIZE * 0.33)),
80 materials.add(Color::srgb_u8(110, 20, 20)),
81 ),
82 blade: (
83 meshes.add(Rectangle::new(TILE_SIZE * 0.08, TILE_SIZE * 0.45)),
84 materials.add(Color::srgb_u8(30, 30, 34)),
85 ),
86 });
87 }
88
89 fn clear_raiders(mut commands: Commands, raiders: Query<Entity, With<Raider>>) {
90 for raider in &raiders {
91 commands.entity(raider).despawn();
92 }
93 }
94
95 pub fn raid_size(colonists: usize) -> usize {
96 (2 + colonists / 12).min(8)
97 }
98
99 pub fn raid_due(day: u64, last: Option<u64>) -> bool {
100 day >= FIRST_RAID_DAY && last.is_none_or(|last| day >= last + RAID_GAP_DAYS)
101 }
102
103 pub fn spawn_raider(commands: &mut Commands, assets: &RaiderAssets, tile: UVec2) -> Entity {
104 let part = |(mesh, material): &(Handle<Mesh>, Handle<ColorMaterial>), at: Transform| {
105 (Mesh2d(mesh.clone()), MeshMaterial2d(material.clone()), at)
106 };
107 commands
108 .spawn((
109 Name::new("Raider"),
110 Raider::default(),
111 MoveSpeed(RAIDER_SPEED),
112 TilePos(tile),
113 sim_pos(WorldMap::tile_center(tile)),
114 Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
115 children![
116 part(&assets.body, Transform::default()),
117 part(
118 &assets.blade,
119 Transform::from_xyz(TILE_SIZE * 0.3, 0.0, 0.01)
120 .with_rotation(Quat::from_rotation_z(0.6)),
121 ),
122 ],
123 ))
124 .id()
125 }
126
127 #[allow(clippy::too_many_arguments)]
128 fn raid_arrivals(
129 mut commands: Commands,
130 tick: Res<SimTick>,
131 map: Res<WorldMap>,
132 zones: Res<Zones>,
133 assets: Res<RaiderAssets>,
134 mut rng: ResMut<SimRng>,
135 mut state: ResMut<RaidState>,
136 mut log: ResMut<ColonyLog>,
137 colonists: Query<(), With<Colonist>>,
138 ) {
139 if tick.0 % DAY_TICKS != RAID_HOUR_TICK || !zones.has_stockpile() {
140 return;
141 }
142 let day = day_of(*tick);
143 if !raid_due(day, state.last_day) || rng.range_f32(0.0..1.0) >= RAID_CHANCE {
144 return;
145 }
146 let home = zones.home(map.size / 2);
147 let Some((tile, route)) = (0..ARRIVAL_ATTEMPTS).find_map(|_| {
148 let tile = edge_tile(&map, &mut rng)?;
149 find_path(&map, tile, home).map(|route| (tile, route))
150 }) else {
151 return;
152 };
153 let size = raid_size(colonists.iter().count());
154 for _ in 0..size {
155 let raider = spawn_raider(&mut commands, &assets, tile);
156 commands.entity(raider).insert(Path(route.clone().into()));
157 }
158 state.last_day = Some(day);
159 log.push(*tick, format!("{size} raiders are coming!"));
160 }
161
162 pub fn nearest_edge(map: &WorldMap, tile: UVec2) -> UVec2 {
163 let max = map.size - UVec2::ONE;
164 let options = [
165 (tile.x, UVec2::new(0, tile.y)),
166 (max.x - tile.x, UVec2::new(max.x, tile.y)),
167 (tile.y, UVec2::new(tile.x, 0)),
168 (max.y - tile.y, UVec2::new(tile.x, max.y)),
169 ];
170 options
171 .into_iter()
172 .min_by_key(|(d, _)| *d)
173 .map(|(_, t)| t)
174 .unwrap()
175 }
176
177 fn at_edge(map: &WorldMap, tile: UVec2) -> bool {
178 tile.x == 0 || tile.y == 0 || tile.x == map.size.x - 1 || tile.y == map.size.y - 1
179 }
180
181 #[allow(clippy::type_complexity, clippy::too_many_arguments)]
182 fn raider_plans(
183 mut commands: Commands,
184 tick: Res<SimTick>,
185 map: Res<WorldMap>,
186 zones: Res<Zones>,
187 mut log: ResMut<ColonyLog>,
188 items: Query<(Entity, &Item, &TilePos), Without<Raider>>,
189 mut raiders: Query<(Entity, &TilePos, &mut Path, &mut Raider, &Health)>,
190 ) {
191 if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) {
192 return;
193 }
194 let home = zones.home(map.size / 2);
195 let mut taken = Vec::new();
196 for (entity, tile, mut path, mut raider, health) in &mut raiders {
197 if health.0 < RAIDER_FLEE_HEALTH {
198 raider.fleeing = true;
199 }
200 if raider.fleeing || raider.loot.is_some() {
201 if at_edge(&map, tile.0) && path.0.len() <= 1 {
202 let message = match raider.loot {
203 Some(loot) => format!("A raider got away with {} {:?}", loot.count, loot.kind),
204 None => "A raider fled".to_string(),
205 };
206 log.push(*tick, message);
207 commands.entity(entity).despawn();
208 continue;
209 }
210 let heading_out = path.0.back().is_some_and(|end| at_edge(&map, *end));
211 if !heading_out {
212 let exit = nearest_edge(&map, tile.0);
213 if let Some(route) = find_path(&map, tile.0, exit) {
214 path.0 = route.into();
215 }
216 }
217 continue;
218 }
219 let target = items
220 .iter()
221 .filter(|(e, item, pos)| item.count > 0 && in_colony(home, pos.0) && !taken.contains(e))
222 .min_by_key(|(_, _, pos)| {
223 let d = pos.0.as_ivec2() - tile.0.as_ivec2();
224 (d.length_squared(), pos.0.y, pos.0.x)
225 });
226 let Some((item_entity, item, pos)) = target else {
227 raider.fleeing = true;
228 continue;
229 };
230 if pos.0 == tile.0 || is_adjacent(pos.0.as_ivec2(), tile.0.as_ivec2()) {
231 raider.loot = Some(*item);
232 taken.push(item_entity);
233 commands.entity(item_entity).despawn();
234 path.0.clear();
235 continue;
236 }
237 let end = path.0.back().copied().unwrap_or(tile.0);
238 let far = (pos.0.as_ivec2() - tile.0.as_ivec2()).abs().max_element() > CLOSE_IN;
239 if is_adjacent(end.as_ivec2(), pos.0.as_ivec2()) || (far && !path.0.is_empty()) {
240 continue;
241 }
242 let search = if far { usize::MAX } else { RAIDER_SEARCH };
243 match find_path_adjacent_within(&map, tile.0, pos.0, search) {
244 Some(route) => path.0 = route.into(),
245 None if path.0.is_empty() => raider.fleeing = true,
246 None => {}
247 }
248 }
249 }
250
251 #[allow(clippy::type_complexity, clippy::too_many_arguments)]
252 fn raider_attacks(
253 mut commands: Commands,
254 tick: Res<SimTick>,
255 item_assets: Res<ItemAssets>,
256 mut log: ResMut<ColonyLog>,
257 mut losses: ResMut<Losses>,
258 mut reservations: ResMut<Reservations>,
259 mut items: ItemQuery,
260 mut raiders: Query<(&TilePos, &mut Raider), Without<Colonist>>,
261 mut colonists: Query<(Entity, &Colonist, &TilePos, &mut Health, &Carrying), Without<Raider>>,
262 ) {
263 for (tile, mut raider) in &mut raiders {
264 raider.cooldown = raider.cooldown.saturating_sub(1);
265 if raider.cooldown > 0 || raider.fleeing {
266 continue;
267 }
268 let victim = colonists.iter_mut().find(|(_, _, pos, health, _)| {
269 health.0 > 0.0 && (pos.0 == tile.0 || is_adjacent(pos.0.as_ivec2(), tile.0.as_ivec2()))
270 });
271 let Some((entity, colonist, pos, mut health, carrying)) = victim else {
272 continue;
273 };
274 raider.cooldown = HIT_COOLDOWN_TICKS;
275 health.0 -= RAIDER_HIT;
276 if health.0 <= 0.0 {
277 if let Some(load) = carrying.0 {
278 drop_items(&mut commands, &mut items, &item_assets, pos.0, load);
279 }
280 reservations.release_all(entity);
281 commands.entity(entity).despawn();
282 losses.0.push(tick.0);
283 log.push(*tick, format!("{} was killed by raiders", colonist.name));
284 }
285 }
286 }
287
288 fn raider_deaths(
289 mut commands: Commands,
290 tick: Res<SimTick>,
291 item_assets: Res<ItemAssets>,
292 mut log: ResMut<ColonyLog>,
293 mut items: ItemQuery,
294 raiders: Query<(Entity, &TilePos, &Raider, &Health)>,
295 ) {
296 for (entity, tile, raider, health) in &raiders {
297 if health.0 > 0.0 {
298 continue;
299 }
300 if let Some(loot) = raider.loot {
301 drop_items(&mut commands, &mut items, &item_assets, tile.0, loot);
302 }
303 commands.entity(entity).despawn();
304 log.push(*tick, "A raider was killed".to_string());
305 }
306 }
307
308 #[cfg(test)]
309 mod tests {
310 use super::*;
311
312 // Raids start once the colony has had time to settle, then come at most every few days,
313 // and grow with the colony.
314 #[test]
315 fn raids_wait_for_a_settled_colony_and_grow_with_it() {
316 assert!(!raid_due(FIRST_RAID_DAY - 1, None));
317 assert!(raid_due(FIRST_RAID_DAY, None));
318 assert!(!raid_due(20, Some(20 - RAID_GAP_DAYS + 1)));
319 assert!(raid_due(20, Some(20 - RAID_GAP_DAYS)));
320 assert!(raid_size(40) > raid_size(5));
321 assert_eq!(raid_size(1000), 8);
322 }
323
324 #[test]
325 fn fleeing_raiders_head_for_the_nearest_map_edge() {
326 let map = WorldMap::filled(UVec2::new(50, 40), crate::world::Terrain::Grass);
327 assert_eq!(nearest_edge(&map, UVec2::new(3, 20)), UVec2::new(0, 20));
328 assert_eq!(nearest_edge(&map, UVec2::new(25, 37)), UVec2::new(25, 39));
329 }
330
331 // A raider goes for the colony's stores, grabs a stack and makes for the nearest map edge,
332 // hitting any colonist standing next to it on the way.
333 #[test]
334 fn raiders_steal_from_stores_and_hit_colonists() {
335 use crate::items::ItemKind;
336 let map = WorldMap::filled(UVec2::new(40, 40), crate::world::Terrain::Grass);
337 let mut zones = Zones::default();
338 zones.add_tiles(&map, [UVec2::new(20, 20)]);
339 let mut app = App::new();
340 app.insert_resource(map)
341 .insert_resource(zones)
342 .init_resource::<SimTick>()
343 .init_resource::<ColonyLog>()
344 .init_resource::<Losses>()
345 .init_resource::<Reservations>()
346 .init_resource::<ItemAssets>()
347 .add_systems(Update, (raider_plans, raider_attacks).chain());
348 let world = app.world_mut();
349 world.spawn((Item::new(ItemKind::Gold, 5), TilePos(UVec2::new(20, 20))));
350 let raider = world
351 .spawn((Raider::default(), TilePos(UVec2::new(21, 20))))
352 .id();
353 let victim = world
354 .spawn((
355 Colonist { name: "Ada".into() },
356 TilePos(UVec2::new(22, 20)),
357 Carrying::default(),
358 ))
359 .id();
360 app.update();
361 let state = app.world().get::<Raider>(raider).unwrap();
362 assert_eq!(state.loot.map(|l| l.count), Some(5));
363 assert!(app.world().get::<Health>(victim).unwrap().0 < 1.0);
364 app.update();
365 let path = &app.world().get::<Path>(raider).unwrap().0;
366 assert!(
367 path.back()
368 .is_some_and(|end| end.x == 0 || end.x == 39 || end.y == 0 || end.y == 39)
369 );
370 }
371 }