Add raiders and colony defence
- 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 | |
| src/jobs/mod.rs | +51 -6 | |
| src/jobs/tests.rs | +20 -0 | |
| src/main.rs | +7 -1 | |
| src/raids.rs | added | +371 -0 |
| src/save.rs | +1 -0 | |
| src/selection.rs | +1 -0 | |
| src/skills.rs | +1 -1 | |
| tasks.md | +1 -0 |
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 |