Add raiders and colony defence

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 18:50 UTC
commit
cd9d4d96525590dc3ea39e6d2e83b2fd8311652b
parent
e47049d32a
tree
browse at this commit

9 files changed +454 -8

CLAUDE.md +1 -0

@@ -52,6 +52,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
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 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 54 - Medicine: `Plant::Herb` (worldgen 1% of fertile tiles, ripens in 3 days, harvest → 3 `ItemKind::Herbs`, Stock::Medicine "Herbs", min goal 4, no spoilage). `Skill::Medicine`, `WorkType::Doctor`. choose_jobs collects patients (`needs_tending`: health < HURT and no active `Tended`) via a query in the `plans` tuple; Doctor candidate (2nd, after refuel) → `find_tending` (nearest reachable unreserved patient ≠ self, `Target::Patient`; herb pickup via `ColonyReach::nearest_unbounded`, optional). `Task::Tend{patient, pickup, progress}`: fetch 1 herb, walk adjacent, TEND_WORK → insert `Tended{until: +1 day, quality: tend_quality(speed(Medicine), herb)}`; `heal` multiplies by 1 + 2·quality. Colonists below INJURED choose Sleep (bed rest) and keep sleeping until health ≥ INJURED.
55 +- `src/raids.rs` raiders. `raid_arrivals` daily at 09:00 once a stockpile exists: `raid_due` (day ≥ FIRST_RAID_DAY 10, ≥ RAID_GAP_DAYS 6 since `RaidState.last_day`) and RAID_CHANCE 0.5 → `raid_size` (2 + colonists/12, ≤ 8) `Raider{loot, fleeing, cooldown}` entities (Health, MoveSpeed, dark red body + blade) at an `edge_tile` with a path home. `raider_plans` (every 20 ticks): below RAIDER_FLEE_HEALTH or carrying loot → walk to `nearest_edge`, despawn there (logs loot); else target the nearest in-colony item stack (keep the arrival route while > CLOSE_IN 20 away, then path adjacent ≤ RAIDER_SEARCH), take it when adjacent; no target → flee. `raider_attacks`: adjacent colonist −RAIDER_HIT per 60 ticks (death logged, Losses). `raider_deaths` drops loot. Colonists: raiders join wolves in choose_jobs' threat list (danger/defend/flee); defending a raider is `Task::Fight{enemy, progress}` (no reservation, several fighters allowed): within HUNT_RANGE + LOS every SHOT_WORK work units −SHOT_DAMAGE (rate includes Hunting skill via `Skill::of`), else path up to FIGHT_STEPS toward it. Raiders aren't saved (cleared on load and new world).
55 56 - 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.
56 57 - Hearths: `StructureKind::Hearth` (6 stone, fuel 24 h, `refuel_cost` 4 wood vs build cost, light 3.5, heat 22 → warms the home), planned in every owned built home. `Recipe::stations()` (Meal: Campfire or Hearth; Tools: Workbench); `find_crafting(ctx, from, recipe, own_home)` only allows a Hearth inside the cook's own home and prefers it. `find_refuel` covers hearths anywhere. `find_haul` leaves Meal stacks on home tiles (`Homes::is_home`) so family meals stay home.
57 58 - Growth: `prosperous` and births need GROWTH_FOOD_DAYS 3 × colonists of stored nutrition (1× let hearth cooking boom births to 120 then famine). `find_food` takes `ColonyReach` (nearest by distance in reach); a colonist whose food search fails skips food searches and starvation urgency for FOOD_RETRY_TICKS 60 (`GatherMemo.food` per entity) — starving colonists re-searching every tick made a famine cost ~4.8 ms/tick.

src/jobs/mod.rs +51 -6

@@ -28,9 +28,11 @@ use crate::{
28 28 needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
29 29 pathfinding::{
30 30 ColonyReach, WANDER_SEARCH, can_step, find_bridged_path, find_nearby_path, find_path,
31 - find_path_adjacent, is_adjacent, nearest_of, nearest_tile, search_reach,
31 + find_path_adjacent, find_path_adjacent_within, is_adjacent, nearest_of, nearest_tile,
32 + search_reach,
32 33 },
33 34 paths::busy_path,
35 + raids::Raider,
34 36 rng::SimRng,
35 37 sim::{SimSystems, SimTick, hour_of_day},
36 38 skills::{POOR_LEVEL, Skill, Skills, WorkCarry, speed},
@@ -200,6 +202,10 @@ pub enum Task {
200 202 pickup: Option<UVec2>,
201 203 progress: u32,
202 204 },
205 + Fight {
206 + enemy: Entity,
207 + progress: u32,
208 + },
203 209 Butcher {
204 210 site: UVec2,
205 211 progress: u32,
@@ -274,6 +280,7 @@ impl Task {
274 280 | Task::Butcher { progress, .. }
275 281 | Task::Tame { progress, .. }
276 282 | Task::Tend { progress, .. }
283 + | Task::Fight { progress, .. }
277 284 | Task::Eat { progress, .. } => Some(progress),
278 285 _ => None,
279 286 }
@@ -504,7 +511,7 @@ fn handle_orders(
504 511
505 512 fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
506 513 match task {
507 - Task::MoveTo(_) => {}
514 + Task::MoveTo(_) | Task::Fight { .. } => {}
508 515 Task::Work { site, .. } => reservations.reserve(Target::Site(site), by),
509 516 Task::Haul { pickup, to } => {
510 517 if let Some(pickup) = pickup {
@@ -1102,6 +1109,10 @@ pub fn find_lighting(ctx: &BuildContext, from: UVec2, dark: bool) -> Option<Task
1102 1109 ctx.build_task(from, site, BuildKind::Structure(kind))
1103 1110 }
1104 1111
1112 +pub const SHOT_WORK: u32 = 90;
1113 +pub const SHOT_DAMAGE: f32 = 0.2;
1114 +const FIGHT_SEARCH: usize = 4000;
1115 +const FIGHT_STEPS: usize = 4;
1105 1116 pub const DANGER_RANGE: i32 = 6;
1106 1117 pub const DEFEND_RANGE: i32 = 15;
1107 1118 const FLEE_STEPS: f32 = 10.0;
@@ -1339,10 +1350,12 @@ fn choose_jobs(
1339 1350 Res<Designations>,
1340 1351 Res<ColonyReach>,
1341 1352 Query<(Entity, &TilePos, &Health, Option<&Tended>), With<Colonist>>,
1353 + Query<(Entity, &TilePos), With<Raider>>,
1342 1354 ),
1343 1355 mut gather_memo: Local<GatherMemo>,
1344 1356 ) {
1345 - let (homes, designations, reach, patient_query) = plans;
1357 + let (homes, designations, reach, patient_query, raider_query) = plans;
1358 + let raiders: HashSet<Entity> = raider_query.iter().map(|(e, _)| e).collect();
1346 1359 let patients: Vec<(Entity, UVec2)> = patient_query
1347 1360 .iter()
1348 1361 .filter(|(_, _, health, tended)| needs_tending(health.0, *tended, tick.0))
@@ -1359,6 +1372,7 @@ fn choose_jobs(
1359 1372 .iter()
1360 1373 .filter(|(_, _, animal, _)| animal.kind.is_predator())
1361 1374 .map(|(entity, pos, ..)| (entity, pos.0))
1375 + .chain(raider_query.iter().map(|(entity, pos)| (entity, pos.0)))
1362 1376 .collect();
1363 1377 let nearest_wolf = |tile: UVec2, range: i32| {
1364 1378 wolves
@@ -1464,7 +1478,7 @@ fn choose_jobs(
1464 1478 let urgent = exhausted || starving || danger.is_some();
1465 1479 let fighting_wolf = |task: Task| {
1466 1480 matches!(task, Task::Hunt { kind, .. } if kind.is_predator())
1467 - || matches!(task, Task::MoveTo(_))
1481 + || matches!(task, Task::MoveTo(_) | Task::Fight { .. })
1468 1482 };
1469 1483 let interrupt = |j: Job| {
1470 1484 !j.ordered
@@ -1513,7 +1527,7 @@ fn choose_jobs(
1513 1527 .filter(|&(wolf, pos, _)| {
1514 1528 free_to_fight
1515 1529 && reach.allows_near(pos)
1516 - && reservations.is_free(Target::Animal(wolf))
1530 + && (raiders.contains(&wolf) || reservations.is_free(Target::Animal(wolf)))
1517 1531 });
1518 1532 let flee = danger
1519 1533 .filter(|_| !fighter)
@@ -1522,6 +1536,8 @@ fn choose_jobs(
1522 1536 let goal = route.last().copied().unwrap_or(tile.0);
1523 1537 path.0 = route.into();
1524 1538 Some(Task::MoveTo(goal))
1539 + } else if let Some((enemy, ..)) = defend.filter(|(e, ..)| raiders.contains(e)) {
1540 + Some(Task::Fight { enemy, progress: 0 })
1525 1541 } else if let Some((wolf, ..)) = defend {
1526 1542 Some(Task::Hunt {
1527 1543 animal: wolf,
@@ -1862,7 +1878,10 @@ fn run_jobs(
1862 1878 mut goals: ResMut<ColonyGoals>,
1863 1879 mut items: ItemQuery,
1864 1880 mut structures: Query<(Entity, &TilePos, &Structure, Option<&mut Fuel>), Without<Colonist>>,
1865 - animals: Query<(&TilePos, &Path), (With<Animal>, Without<Colonist>)>,
1881 + creatures: (
1882 + Query<(&TilePos, &Path), (With<Animal>, Without<Colonist>)>,
1883 + Query<(&TilePos, &mut Health), (With<Raider>, Without<Colonist>)>,
1884 + ),
1866 1885 messages: (MessageWriter<TileChanged>, MessageWriter<Ate>),
1867 1886 mut colonists: Query<
1868 1887 (
@@ -1882,6 +1901,7 @@ fn run_jobs(
1882 1901 >,
1883 1902 ) {
1884 1903 let (mut changed, mut ate) = messages;
1904 + let (animals, mut raiders) = creatures;
1885 1905 let hour = hour_of_day(*tick);
1886 1906 let tending = colonists
1887 1907 .iter()
@@ -1999,6 +2019,31 @@ fn run_jobs(
1999 2019 }
2000 2020 }
2001 2021 }
2022 + Task::Fight { enemy, progress } => match raiders.get_mut(*enemy) {
2023 + Ok((at, mut enemy_health)) if enemy_health.0 > 0.0 => {
2024 + let at = at.0;
2025 + let in_range = tile.0.as_vec2().distance(at.as_vec2()) <= HUNT_RANGE
2026 + && line_of_sight(&map, tile.0, at);
2027 + if in_range {
2028 + *progress += rate;
2029 + if *progress >= SHOT_WORK {
2030 + *progress = 0;
2031 + enemy_health.0 -= SHOT_DAMAGE;
2032 + }
2033 + Step::Continue
2034 + } else {
2035 + match find_path_adjacent_within(&map, tile.0, at, FIGHT_SEARCH) {
2036 + Some(p) if !p.is_empty() => {
2037 + path.0 = p.into();
2038 + path.0.truncate(FIGHT_STEPS);
2039 + Step::Continue
2040 + }
2041 + _ => Step::Done,
2042 + }
2043 + }
2044 + }
2045 + _ => Step::Done,
2046 + },
2002 2047 Task::Equip { pickup } => {
2003 2048 let from = *pickup;
2004 2049 if reservations.holder(Target::Pickup(from)) != Some(entity) {

src/jobs/tests.rs +20 -0

@@ -1662,3 +1662,23 @@ fn colonists_bridge_deep_water_from_the_shore() {
1662 1662 }
1663 1663 assert!(find_path(map, UVec2::new(6, 8), UVec2::new(20, 8)).is_some());
1664 1664 }
1665 +
1666 +// Raiders are fought like wolves near the colony: healthy adults shoot at them from range until
1667 +// they go down (and drop what they stole), skill making each shot count for more.
1668 +#[test]
1669 +fn colonists_fight_raiders() {
1670 + let mut app = test_app(WorldMap::filled(UVec2::splat(40), Terrain::Grass));
1671 + let raider = app
1672 + .world_mut()
1673 + .spawn((crate::raids::Raider::default(), TilePos(UVec2::new(20, 20))))
1674 + .id();
1675 + let fighter = spawn_colonist(&mut app, UVec2::new(16, 20));
1676 + run_ticks(&mut app, 1);
1677 + assert!(matches!(
1678 + job(&app, fighter).map(|j| j.task),
1679 + Some(Task::Fight { enemy, .. }) if enemy == raider
1680 + ));
1681 + run_ticks(&mut app, 60 * 20);
1682 + let health = app.world().get::<crate::threats::Health>(raider).unwrap();
1683 + assert!(health.0 <= 0.0, "{health:?}");
1684 +}

src/main.rs +7 -1

@@ -20,6 +20,7 @@ mod pathfinding;
20 20 mod paths;
21 21 mod plants;
22 22 mod population;
23 +mod raids;
23 24 mod rng;
24 25 mod rooms;
25 26 mod save;
@@ -83,6 +84,11 @@ fn main() {
83 84 zones::ZonesPlugin,
84 85 ui::HudPlugin,
85 86 ))
86 - .add_plugins((save::SavePlugin, threats::ThreatsPlugin, rooms::RoomsPlugin))
87 + .add_plugins((
88 + save::SavePlugin,
89 + threats::ThreatsPlugin,
90 + rooms::RoomsPlugin,
91 + raids::RaidsPlugin,
92 + ))
87 93 .run();
88 94 }

src/raids.rs +371 -0

@@ -0,0 +1,371 @@
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 +}

src/save.rs +1 -0

@@ -247,6 +247,7 @@ pub fn apply(world: &mut World, save: SaveGame) {
247 247 despawn_all::<Animal>(world);
248 248 despawn_all::<Item>(world);
249 249 despawn_all::<Structure>(world);
250 + despawn_all::<crate::raids::Raider>(world);
250 251 world.insert_resource(SimTick(save.tick));
251 252 world.insert_resource(save.rng);
252 253 world.insert_resource(Zones::load(save.zones));

src/selection.rs +1 -0

@@ -674,6 +674,7 @@ pub fn describe_colonist(view: &ColonistView) -> String {
674 674 ) => format!("cooking a meal, {}%", percent(task)),
675 675 (Some(task @ Task::Craft { .. }), _) => format!("making tools, {}%", percent(task)),
676 676 (Some(Task::Equip { .. }), _) => "fetching tools".to_string(),
677 + (Some(Task::Fight { .. }), _) => "fighting a raider".to_string(),
677 678 (
678 679 Some(Task::Tend {
679 680 pickup: Some(_), ..

src/skills.rs +1 -1

@@ -60,7 +60,7 @@ impl Skill {
60 60 }
61 61 | Task::Butcher { .. } => Skill::Cooking,
62 62 Task::Craft { .. } => Skill::Crafting,
63 - Task::Hunt { .. } => Skill::Hunting,
63 + Task::Hunt { .. } | Task::Fight { .. } => Skill::Hunting,
64 64 Task::Tame { .. } => Skill::Taming,
65 65 Task::Tend { .. } => Skill::Medicine,
66 66 _ => return None,

tasks.md +1 -0

@@ -58,6 +58,7 @@
58 58 - [x] Optimization: wolves give up on unreachable prey, cached warm spots, cheaper heat checks
59 59 - [x] Bridges: colonists bridge busy wading spots and water on much shorter (or otherwise impossible) routes, over deep water from the shore outwards; Build tool Bridge mode
60 60 - [x] Colonist panel for big colonies: households as collapsible, flagged rows in a scrollable list
61 +- [x] Raiders: periodic bands that steal from stores and fight colonists; colonists defend (ranged, Hunting skill), raiders flee when hurt or loaded, killed raiders drop loot
61 62 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
62 63
63 64 ## Planned