Add herbs, doctors and bed rest for the wounded
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 15:24 UTC
- commit
- 76019b8ad5af7396b8361575e7510f98952d9e7d
- parent
- d1f6a4bb12
- tree
- browse at this commit
13 files changed +248 -21
| CLAUDE.md | +1 -0 | |
| src/economy.rs | +7 -1 | |
| src/items.rs | +4 -1 | |
| src/jobs/mod.rs | +131 -9 | |
| src/jobs/tests.rs | +24 -0 | |
| src/pathfinding.rs | +11 -0 | |
| src/selection.rs | +10 -1 | |
| src/skills.rs | +5 -1 | |
| src/threats.rs | +35 -6 | |
| src/tilemap.rs | +11 -1 | |
| src/world.rs | +4 -1 | |
| src/worldgen.rs | +4 -0 | |
| tasks.md | +1 -0 |
CLAUDE.md +1 -0
| @@ -51,6 +51,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 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 | 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 | +- 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. | |
| 54 | 55 | - 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. |
| 55 | 56 | - 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. |
| 56 | 57 | - 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/economy.rs +7 -1
| @@ -32,10 +32,11 @@ pub enum Stock { | ||
| 32 | 32 | RawFood, |
| 33 | 33 | Meals, |
| 34 | 34 | Tools, |
| 35 | + Medicine, | |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | 38 | impl Stock { |
| 38 | - pub const ALL: [Stock; 7] = [ | |
| 39 | + pub const ALL: [Stock; 8] = [ | |
| 39 | 40 | Stock::Wood, |
| 40 | 41 | Stock::Stone, |
| 41 | 42 | Stock::Iron, |
| @@ -43,6 +44,7 @@ impl Stock { | ||
| 43 | 44 | Stock::RawFood, |
| 44 | 45 | Stock::Meals, |
| 45 | 46 | Stock::Tools, |
| 47 | + Stock::Medicine, | |
| 46 | 48 | ]; |
| 47 | 49 | |
| 48 | 50 | pub fn of(kind: ItemKind) -> Stock { |
| @@ -56,6 +58,7 @@ impl Stock { | ||
| 56 | 58 | } |
| 57 | 59 | ItemKind::Meal => Stock::Meals, |
| 58 | 60 | ItemKind::Tools => Stock::Tools, |
| 61 | + ItemKind::Herbs => Stock::Medicine, | |
| 59 | 62 | } |
| 60 | 63 | } |
| 61 | 64 | |
| @@ -78,6 +81,7 @@ impl Stock { | ||
| 78 | 81 | Stock::RawFood => "Raw food", |
| 79 | 82 | Stock::Meals => "Meals", |
| 80 | 83 | Stock::Tools => "Tools", |
| 84 | + Stock::Medicine => "Herbs", | |
| 81 | 85 | } |
| 82 | 86 | } |
| 83 | 87 | |
| @@ -98,6 +102,7 @@ impl Stock { | ||
| 98 | 102 | Stock::RawFood => 10.0, |
| 99 | 103 | Stock::Meals => 3.0, |
| 100 | 104 | Stock::Tools => 1.0, |
| 105 | + Stock::Medicine => 4.0, | |
| 101 | 106 | } |
| 102 | 107 | } |
| 103 | 108 | |
| @@ -110,6 +115,7 @@ impl Stock { | ||
| 110 | 115 | Stock::RawFood => 30.0, |
| 111 | 116 | Stock::Meals => 3.0, |
| 112 | 117 | Stock::Tools => 1.0, |
| 118 | + Stock::Medicine => 1.0, | |
| 113 | 119 | } |
| 114 | 120 | } |
| 115 | 121 | } |
src/items.rs +4 -1
| @@ -32,10 +32,11 @@ pub enum ItemKind { | ||
| 32 | 32 | Meal, |
| 33 | 33 | Carcass, |
| 34 | 34 | Tools, |
| 35 | + Herbs, | |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | 38 | impl ItemKind { |
| 38 | - pub const ALL: [ItemKind; 10] = [ | |
| 39 | + pub const ALL: [ItemKind; 11] = [ | |
| 39 | 40 | ItemKind::Wood, |
| 40 | 41 | ItemKind::Stone, |
| 41 | 42 | ItemKind::Iron, |
| @@ -46,6 +47,7 @@ impl ItemKind { | ||
| 46 | 47 | ItemKind::Meal, |
| 47 | 48 | ItemKind::Carcass, |
| 48 | 49 | ItemKind::Tools, |
| 50 | + ItemKind::Herbs, | |
| 49 | 51 | ]; |
| 50 | 52 | |
| 51 | 53 | pub fn index(self) -> usize { |
| @@ -80,6 +82,7 @@ impl ItemKind { | ||
| 80 | 82 | ItemKind::Meal => Color::srgb_u8(240, 210, 150), |
| 81 | 83 | ItemKind::Carcass => Color::srgb_u8(120, 60, 50), |
| 82 | 84 | ItemKind::Tools => Color::srgb_u8(90, 100, 120), |
| 85 | + ItemKind::Herbs => Color::srgb_u8(150, 120, 200), | |
| 83 | 86 | } |
| 84 | 87 | } |
| 85 | 88 | } |
src/jobs/mod.rs +131 -9
| @@ -34,7 +34,7 @@ use crate::{ | ||
| 34 | 34 | sim::{SimSystems, SimTick, hour_of_day}, |
| 35 | 35 | skills::{POOR_LEVEL, Skill, Skills, WorkCarry, speed}, |
| 36 | 36 | structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, |
| 37 | - threats::{Health, INJURED}, | |
| 37 | + threats::{Health, INJURED, TEND_WORK, TENDED_TICKS, Tended, needs_tending, tend_quality}, | |
| 38 | 38 | world::{Plant, Terrain, TileChanged, TilePos, WorldMap}, |
| 39 | 39 | worldgen::WorldGenerated, |
| 40 | 40 | zones::{ |
| @@ -96,16 +96,18 @@ pub enum WorkType { | ||
| 96 | 96 | Haul, |
| 97 | 97 | Tame, |
| 98 | 98 | Craft, |
| 99 | + Doctor, | |
| 99 | 100 | } |
| 100 | 101 | |
| 101 | 102 | impl WorkType { |
| 102 | - pub const ALL: [WorkType; 6] = [ | |
| 103 | + pub const ALL: [WorkType; 7] = [ | |
| 103 | 104 | WorkType::Food, |
| 104 | 105 | WorkType::Build, |
| 105 | 106 | WorkType::Gather, |
| 106 | 107 | WorkType::Haul, |
| 107 | 108 | WorkType::Tame, |
| 108 | 109 | WorkType::Craft, |
| 110 | + WorkType::Doctor, | |
| 109 | 111 | ]; |
| 110 | 112 | |
| 111 | 113 | pub fn label(self) -> &'static str { |
| @@ -116,6 +118,7 @@ impl WorkType { | ||
| 116 | 118 | WorkType::Haul => "Haul", |
| 117 | 119 | WorkType::Tame => "Tame", |
| 118 | 120 | WorkType::Craft => "Craft", |
| 121 | + WorkType::Doctor => "Doctor", | |
| 119 | 122 | } |
| 120 | 123 | } |
| 121 | 124 | } |
| @@ -191,6 +194,11 @@ pub enum Task { | ||
| 191 | 194 | Equip { |
| 192 | 195 | pickup: UVec2, |
| 193 | 196 | }, |
| 197 | + Tend { | |
| 198 | + patient: Entity, | |
| 199 | + pickup: Option<UVec2>, | |
| 200 | + progress: u32, | |
| 201 | + }, | |
| 194 | 202 | Butcher { |
| 195 | 203 | site: UVec2, |
| 196 | 204 | progress: u32, |
| @@ -264,6 +272,7 @@ impl Task { | ||
| 264 | 272 | | Task::Craft { progress, .. } |
| 265 | 273 | | Task::Butcher { progress, .. } |
| 266 | 274 | | Task::Tame { progress, .. } |
| 275 | + | Task::Tend { progress, .. } | |
| 267 | 276 | | Task::Eat { progress, .. } => Some(progress), |
| 268 | 277 | _ => None, |
| 269 | 278 | } |
| @@ -345,6 +354,9 @@ pub fn site_work(map: &WorldMap, site: UVec2) -> Option<SiteWork> { | ||
| 345 | 354 | Some(Plant::Crop) if map.ripe_plant(p).is_some() => { |
| 346 | 355 | return Some(harvest(ItemKind::Vegetables, 10)); |
| 347 | 356 | } |
| 357 | + Some(Plant::Herb) if map.ripe_plant(p).is_some() => { | |
| 358 | + return Some(harvest(ItemKind::Herbs, 3)); | |
| 359 | + } | |
| 348 | 360 | _ => {} |
| 349 | 361 | } |
| 350 | 362 | let (item, count) = match map.get(p)? { |
| @@ -385,6 +397,7 @@ pub enum Target { | ||
| 385 | 397 | SleepSpot(UVec2), |
| 386 | 398 | Animal(Entity), |
| 387 | 399 | Station(UVec2), |
| 400 | + Patient(Entity), | |
| 388 | 401 | } |
| 389 | 402 | |
| 390 | 403 | #[derive(Resource, Default, Debug)] |
| @@ -510,6 +523,14 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) { | ||
| 510 | 523 | } |
| 511 | 524 | Task::Butcher { site, .. } => reservations.reserve(Target::Pickup(site), by), |
| 512 | 525 | Task::Equip { pickup } => reservations.reserve(Target::Pickup(pickup), by), |
| 526 | + Task::Tend { | |
| 527 | + patient, pickup, .. | |
| 528 | + } => { | |
| 529 | + reservations.reserve(Target::Patient(patient), by); | |
| 530 | + if let Some(pickup) = pickup { | |
| 531 | + reservations.reserve(Target::Pickup(pickup), by); | |
| 532 | + } | |
| 533 | + } | |
| 513 | 534 | Task::Craft { |
| 514 | 535 | pickup, station, .. |
| 515 | 536 | } => { |
| @@ -1098,6 +1119,42 @@ pub fn flee_from(map: &WorldMap, from: UVec2, threat: UVec2) -> Option<Vec<UVec2 | ||
| 1098 | 1119 | find_path(map, from, open).filter(|route| !route.is_empty()) |
| 1099 | 1120 | } |
| 1100 | 1121 | |
| 1122 | +pub fn find_tending( | |
| 1123 | + patients: &[(Entity, UVec2)], | |
| 1124 | + reservations: &Reservations, | |
| 1125 | + reach: &ColonyReach, | |
| 1126 | + items: &[(Item, TilePos)], | |
| 1127 | + doctor: Entity, | |
| 1128 | + from: UVec2, | |
| 1129 | +) -> Option<Task> { | |
| 1130 | + let &(patient, _) = patients | |
| 1131 | + .iter() | |
| 1132 | + .filter(|&&(patient, at)| { | |
| 1133 | + patient != doctor | |
| 1134 | + && reach.allows_near(at) | |
| 1135 | + && reservations.is_free(Target::Patient(patient)) | |
| 1136 | + }) | |
| 1137 | + .min_by_key(|(_, at)| { | |
| 1138 | + let d = at.as_ivec2() - from.as_ivec2(); | |
| 1139 | + (d.length_squared(), at.y, at.x) | |
| 1140 | + })?; | |
| 1141 | + let herbs: HashSet<UVec2> = items | |
| 1142 | + .iter() | |
| 1143 | + .filter(|(item, pos)| { | |
| 1144 | + item.kind == ItemKind::Herbs && reservations.is_free(Target::Pickup(pos.0)) | |
| 1145 | + }) | |
| 1146 | + .map(|(_, pos)| pos.0) | |
| 1147 | + .collect(); | |
| 1148 | + let pickup = (!herbs.is_empty()) | |
| 1149 | + .then(|| reach.nearest_unbounded(from, &herbs)) | |
| 1150 | + .flatten(); | |
| 1151 | + Some(Task::Tend { | |
| 1152 | + patient, | |
| 1153 | + pickup, | |
| 1154 | + progress: 0, | |
| 1155 | + }) | |
| 1156 | +} | |
| 1157 | + | |
| 1101 | 1158 | pub fn chilly_soon(tick: SimTick) -> bool { |
| 1102 | 1159 | let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS); |
| 1103 | 1160 | outdoor_temperature(tick) < COLD_TEMP || outdoor_temperature(later) < COLD_TEMP |
| @@ -1268,10 +1325,20 @@ fn choose_jobs( | ||
| 1268 | 1325 | ), |
| 1269 | 1326 | With<Colonist>, |
| 1270 | 1327 | >, |
| 1271 | - plans: (Res<Homes>, Res<Designations>, Res<ColonyReach>), | |
| 1328 | + plans: ( | |
| 1329 | + Res<Homes>, | |
| 1330 | + Res<Designations>, | |
| 1331 | + Res<ColonyReach>, | |
| 1332 | + Query<(Entity, &TilePos, &Health, Option<&Tended>), With<Colonist>>, | |
| 1333 | + ), | |
| 1272 | 1334 | mut gather_memo: Local<GatherMemo>, |
| 1273 | 1335 | ) { |
| 1274 | - let (homes, designations, reach) = plans; | |
| 1336 | + let (homes, designations, reach, patient_query) = plans; | |
| 1337 | + let patients: Vec<(Entity, UVec2)> = patient_query | |
| 1338 | + .iter() | |
| 1339 | + .filter(|(_, _, health, tended)| needs_tending(health.0, *tended, tick.0)) | |
| 1340 | + .map(|(entity, pos, ..)| (entity, pos.0)) | |
| 1341 | + .collect(); | |
| 1275 | 1342 | if zones.is_changed() { |
| 1276 | 1343 | gather_memo.haul = Memo::default(); |
| 1277 | 1344 | } |
| @@ -1464,7 +1531,7 @@ fn choose_jobs( | ||
| 1464 | 1531 | pickup: Some(pickup), |
| 1465 | 1532 | progress: 0, |
| 1466 | 1533 | }) |
| 1467 | - } else if wants_sleep(rest.0, hour) { | |
| 1534 | + } else if wants_sleep(rest.0, hour) || health.0 < INJURED { | |
| 1468 | 1535 | Some(Task::Sleep { |
| 1469 | 1536 | spot: sleep.spot(tile.0), |
| 1470 | 1537 | }) |
| @@ -1561,11 +1628,14 @@ fn choose_jobs( | ||
| 1561 | 1628 | let gather_failed = std::cell::Cell::new(false); |
| 1562 | 1629 | let forage_failed = std::cell::Cell::new(false); |
| 1563 | 1630 | let haul_failed = std::cell::Cell::new(false); |
| 1564 | - let candidates: [(WorkType, Option<Skill>, &dyn Fn() -> Option<Task>); 16] = [ | |
| 1631 | + let candidates: [(WorkType, Option<Skill>, &dyn Fn() -> Option<Task>); 17] = [ | |
| 1565 | 1632 | (WorkType::Haul, None, &|| { |
| 1566 | 1633 | find_expansion(&map, &zones, &reservations, &snapshot, &blueprints) |
| 1567 | 1634 | }), |
| 1568 | 1635 | (WorkType::Build, None, &|| find_refuel(&build, tile.0)), |
| 1636 | + (WorkType::Doctor, Some(Skill::Medicine), &|| { | |
| 1637 | + find_tending(&patients, &reservations, &reach, &snapshot, entity, tile.0) | |
| 1638 | + }), | |
| 1569 | 1639 | (WorkType::Build, None, &|| { |
| 1570 | 1640 | (dark || chilly) |
| 1571 | 1641 | .then(|| find_lighting(&build, tile.0, dark)) |
| @@ -1757,13 +1827,14 @@ fn run_jobs( | ||
| 1757 | 1827 | &mut Hunger, |
| 1758 | 1828 | Has<Sick>, |
| 1759 | 1829 | Option<&mut Tool>, |
| 1760 | - (&mut Skills, &mut WorkCarry), | |
| 1830 | + (&mut Skills, &mut WorkCarry, &Health), | |
| 1761 | 1831 | ), |
| 1762 | 1832 | With<Colonist>, |
| 1763 | 1833 | >, |
| 1764 | 1834 | ) { |
| 1765 | 1835 | let (mut changed, mut ate) = messages; |
| 1766 | 1836 | let hour = hour_of_day(*tick); |
| 1837 | + let positions: HashMap<Entity, UVec2> = colonists.iter().map(|c| (c.0, c.1.0)).collect(); | |
| 1767 | 1838 | let walling = colonists.iter().any(|c| { |
| 1768 | 1839 | c.3.0 |
| 1769 | 1840 | .is_some_and(|j| matches!(j.task, Task::Build { kind, .. } if kind.blocks_tile())) |
| @@ -1791,7 +1862,7 @@ fn run_jobs( | ||
| 1791 | 1862 | mut hunger, |
| 1792 | 1863 | sick, |
| 1793 | 1864 | mut tool, |
| 1794 | - (mut skills, mut carry), | |
| 1865 | + (mut skills, mut carry, health), | |
| 1795 | 1866 | ) in &mut colonists |
| 1796 | 1867 | { |
| 1797 | 1868 | let Some(mut job) = current.0 else { |
| @@ -1821,6 +1892,57 @@ fn run_jobs( | ||
| 1821 | 1892 | } |
| 1822 | 1893 | let step = match &mut job.task { |
| 1823 | 1894 | Task::MoveTo(_) => Step::Done, |
| 1895 | + Task::Tend { | |
| 1896 | + patient, | |
| 1897 | + pickup, | |
| 1898 | + progress, | |
| 1899 | + } => { | |
| 1900 | + let patient = *patient; | |
| 1901 | + let ours = reservations.holder(Target::Patient(patient)) == Some(entity); | |
| 1902 | + match positions.get(&patient) { | |
| 1903 | + _ if !ours => Step::Done, | |
| 1904 | + None => Step::Done, | |
| 1905 | + Some(_) if pickup.is_some() => { | |
| 1906 | + let from = pickup.expect("checked above"); | |
| 1907 | + if tile.0 != from { | |
| 1908 | + walk_to(&map, tile.0, from, &mut path) | |
| 1909 | + } else { | |
| 1910 | + reservations.release(Target::Pickup(from)); | |
| 1911 | + carrying.0 = take_items(&mut commands, &mut items, from, 1) | |
| 1912 | + .filter(|load| load.kind == ItemKind::Herbs && load.count > 0); | |
| 1913 | + *pickup = None; | |
| 1914 | + Step::Continue | |
| 1915 | + } | |
| 1916 | + } | |
| 1917 | + Some(&at) if at != tile.0 && !is_adjacent(tile.0.as_ivec2(), at.as_ivec2()) => { | |
| 1918 | + match find_path_adjacent(&map, tile.0, at) { | |
| 1919 | + Some(p) if !p.is_empty() => { | |
| 1920 | + path.0 = p.into(); | |
| 1921 | + Step::Continue | |
| 1922 | + } | |
| 1923 | + _ => Step::Done, | |
| 1924 | + } | |
| 1925 | + } | |
| 1926 | + Some(_) => { | |
| 1927 | + *progress += rate; | |
| 1928 | + if *progress < TEND_WORK { | |
| 1929 | + Step::Continue | |
| 1930 | + } else { | |
| 1931 | + let herb = carrying.0.is_some_and(|l| l.kind == ItemKind::Herbs); | |
| 1932 | + if herb { | |
| 1933 | + goals.record_use(ItemKind::Herbs, 1); | |
| 1934 | + carrying.0 = None; | |
| 1935 | + } | |
| 1936 | + let speed = speed(skills.level(Skill::Medicine)); | |
| 1937 | + commands.entity(patient).insert(Tended { | |
| 1938 | + until: tick.0 + TENDED_TICKS, | |
| 1939 | + quality: tend_quality(speed, herb), | |
| 1940 | + }); | |
| 1941 | + Step::Done | |
| 1942 | + } | |
| 1943 | + } | |
| 1944 | + } | |
| 1945 | + } | |
| 1824 | 1946 | Task::Equip { pickup } => { |
| 1825 | 1947 | let from = *pickup; |
| 1826 | 1948 | if reservations.holder(Target::Pickup(from)) != Some(entity) { |
| @@ -2063,7 +2185,7 @@ fn run_jobs( | ||
| 2063 | 2185 | reservations.reserve(Target::SleepSpot(*spot), entity); |
| 2064 | 2186 | } |
| 2065 | 2187 | Step::Continue |
| 2066 | - } else if done_sleeping(rest.0, hour) { | |
| 2188 | + } else if done_sleeping(rest.0, hour) && health.0 >= INJURED { | |
| 2067 | 2189 | Step::Done |
| 2068 | 2190 | } else { |
| 2069 | 2191 | Step::Continue |
src/jobs/tests.rs +24 -0
| @@ -1611,3 +1611,27 @@ fn families_cook_at_their_own_hearth_and_keep_the_meals() { | ||
| 1611 | 1611 | ); |
| 1612 | 1612 | assert_eq!(haul, None); |
| 1613 | 1613 | } |
| 1614 | + | |
| 1615 | +// Wounded colonists rest in bed while a doctor fetches a herb and tends them; tending speeds up | |
| 1616 | +// healing for a day, more with a herb and a skilled doctor. | |
| 1617 | +#[test] | |
| 1618 | +fn doctors_tend_the_wounded_with_herbs() { | |
| 1619 | + let mut app = test_app(WorldMap::filled(UVec2::splat(30), Terrain::Grass)); | |
| 1620 | + put_beds(&mut app, 2); | |
| 1621 | + app.world_mut() | |
| 1622 | + .spawn((Item::new(ItemKind::Herbs, 3), TilePos(UVec2::new(8, 8)))); | |
| 1623 | + let patient = spawn_colonist(&mut app, UVec2::new(15, 15)); | |
| 1624 | + app.world_mut() | |
| 1625 | + .entity_mut(patient) | |
| 1626 | + .insert(crate::threats::Health(0.3)); | |
| 1627 | + let doctor = spawn_colonist(&mut app, UVec2::new(5, 5)); | |
| 1628 | + run_ticks(&mut app, 60 * 30); | |
| 1629 | + let tended = app.world().get::<crate::threats::Tended>(patient); | |
| 1630 | + assert!(tended.is_some_and(|t| t.quality >= 1.0), "{tended:?}"); | |
| 1631 | + assert_eq!(items_of(&mut app, ItemKind::Herbs), 2); | |
| 1632 | + assert!(app.world().get::<crate::threats::Tended>(doctor).is_none()); | |
| 1633 | + assert!(matches!( | |
| 1634 | + job(&app, patient).map(|j| j.task), | |
| 1635 | + Some(Task::Sleep { .. }) | |
| 1636 | + )); | |
| 1637 | +} | |
src/pathfinding.rs +11 -0
| @@ -101,6 +101,17 @@ impl ColonyReach { | ||
| 101 | 101 | self.0.as_ref().is_none_or(|reach| reach.near(tile)) |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | + pub fn nearest_unbounded(&self, from: UVec2, targets: &HashSet<UVec2>) -> Option<UVec2> { | |
| 105 | + targets | |
| 106 | + .iter() | |
| 107 | + .filter(|t| self.allows(**t)) | |
| 108 | + .min_by_key(|t| { | |
| 109 | + let d = t.as_ivec2() - from.as_ivec2(); | |
| 110 | + (d.length_squared(), t.y, t.x) | |
| 111 | + }) | |
| 112 | + .copied() | |
| 113 | + } | |
| 114 | + | |
| 104 | 115 | pub fn nearest( |
| 105 | 116 | &self, |
| 106 | 117 | map: &WorldMap, |
src/selection.rs +10 -1
| @@ -20,7 +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 | + threats::{Health, TEND_WORK}, | |
| 24 | 24 | ui::{HideableUi, PointerOverUi}, |
| 25 | 25 | world::{TILE_SIZE, TilePos, WorldMap}, |
| 26 | 26 | worldgen::{WorldGenerated, WorldSeed}, |
| @@ -536,6 +536,7 @@ pub fn progress_fraction(task: Task) -> Option<f32> { | ||
| 536 | 536 | } => Some(progress as f32 / recipe.work() as f32), |
| 537 | 537 | Task::Butcher { progress, .. } => Some(progress as f32 / BUTCHER_WORK as f32), |
| 538 | 538 | Task::Tame { kind, progress, .. } => Some(progress as f32 / kind.tame_work() as f32), |
| 539 | + Task::Tend { progress, .. } => Some(progress as f32 / TEND_WORK as f32), | |
| 539 | 540 | _ => None, |
| 540 | 541 | } |
| 541 | 542 | } |
| @@ -651,6 +652,14 @@ pub fn describe_colonist(view: &ColonistView) -> String { | ||
| 651 | 652 | ) => format!("cooking a meal, {}%", percent(task)), |
| 652 | 653 | (Some(task @ Task::Craft { .. }), _) => format!("making tools, {}%", percent(task)), |
| 653 | 654 | (Some(Task::Equip { .. }), _) => "fetching tools".to_string(), |
| 655 | + ( | |
| 656 | + Some(Task::Tend { | |
| 657 | + pickup: Some(_), .. | |
| 658 | + }), | |
| 659 | + _, | |
| 660 | + ) => "fetching herbs".to_string(), | |
| 661 | + (Some(Task::Tend { .. }), n) if n > 0 => "going to a patient".to_string(), | |
| 662 | + (Some(task @ Task::Tend { .. }), _) => format!("tending a patient, {}%", percent(task)), | |
| 654 | 663 | (Some(Task::Butcher { .. }), n) if n > 0 => "walking to a carcass".to_string(), |
| 655 | 664 | (Some(Task::Tame { kind, .. }), n) if n > 0 => format!("approaching a {}", kind.name()), |
| 656 | 665 | (Some(task @ Task::Tame { kind, .. }), _) => { |
src/skills.rs +5 -1
| @@ -16,10 +16,11 @@ pub enum Skill { | ||
| 16 | 16 | Hunting, |
| 17 | 17 | Crafting, |
| 18 | 18 | Taming, |
| 19 | + Medicine, | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | impl Skill { |
| 22 | - pub const ALL: [Skill; 8] = [ | |
| 23 | + pub const ALL: [Skill; 9] = [ | |
| 23 | 24 | Skill::Mining, |
| 24 | 25 | Skill::Woodcutting, |
| 25 | 26 | Skill::Construction, |
| @@ -28,6 +29,7 @@ impl Skill { | ||
| 28 | 29 | Skill::Hunting, |
| 29 | 30 | Skill::Crafting, |
| 30 | 31 | Skill::Taming, |
| 32 | + Skill::Medicine, | |
| 31 | 33 | ]; |
| 32 | 34 | |
| 33 | 35 | pub fn name(self) -> &'static str { |
| @@ -40,6 +42,7 @@ impl Skill { | ||
| 40 | 42 | Skill::Hunting => "Hunting", |
| 41 | 43 | Skill::Crafting => "Crafting", |
| 42 | 44 | Skill::Taming => "Taming", |
| 45 | + Skill::Medicine => "Medicine", | |
| 43 | 46 | } |
| 44 | 47 | } |
| 45 | 48 | |
| @@ -59,6 +62,7 @@ impl Skill { | ||
| 59 | 62 | Task::Craft { .. } => Skill::Crafting, |
| 60 | 63 | Task::Hunt { .. } => Skill::Hunting, |
| 61 | 64 | Task::Tame { .. } => Skill::Taming, |
| 65 | + Task::Tend { .. } => Skill::Medicine, | |
| 62 | 66 | _ => return None, |
| 63 | 67 | }) |
| 64 | 68 | } |
src/threats.rs +35 -6
| @@ -52,6 +52,24 @@ impl Default for Health { | ||
| 52 | 52 | } |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | +#[derive(Component, Debug, Clone, Copy, PartialEq)] | |
| 56 | +pub struct Tended { | |
| 57 | + pub until: u64, | |
| 58 | + pub quality: f32, | |
| 59 | +} | |
| 60 | + | |
| 61 | +pub const TENDED_TICKS: u64 = 24 * TICKS_PER_HOUR; | |
| 62 | +pub const TEND_WORK: u32 = 240; | |
| 63 | +const TENDED_HEAL_BONUS: f32 = 2.0; | |
| 64 | + | |
| 65 | +pub fn tend_quality(doctor_speed: f32, herb: bool) -> f32 { | |
| 66 | + (doctor_speed * if herb { 1.0 } else { 0.5 }).min(2.0) | |
| 67 | +} | |
| 68 | + | |
| 69 | +pub fn needs_tending(health: f32, tended: Option<&Tended>, tick: u64) -> bool { | |
| 70 | + health < HURT && tended.is_none_or(|t| tick >= t.until) | |
| 71 | +} | |
| 72 | + | |
| 55 | 73 | #[derive(Component, Debug, Default, Clone, Copy, PartialEq)] |
| 56 | 74 | pub struct Predator { |
| 57 | 75 | pub target: Option<Entity>, |
| @@ -187,9 +205,10 @@ fn bite( | ||
| 187 | 205 | } |
| 188 | 206 | } |
| 189 | 207 | |
| 190 | -pub fn healed(health: f32, ticks: f32, in_bed: bool) -> f32 { | |
| 208 | +pub fn healed(health: f32, ticks: f32, in_bed: bool, tended: f32) -> f32 { | |
| 191 | 209 | let rate = HEAL_PER_DAY / (24.0 * TICKS_PER_HOUR as f32); |
| 192 | 210 | let rate = if in_bed { rate * BED_HEAL_FACTOR } else { rate }; |
| 211 | + let rate = rate * (1.0 + TENDED_HEAL_BONUS * tended); | |
| 193 | 212 | (health + rate * ticks).min(1.0) |
| 194 | 213 | } |
| 195 | 214 | |
| @@ -197,7 +216,12 @@ pub fn healed(health: f32, ticks: f32, in_bed: bool) -> f32 { | ||
| 197 | 216 | fn heal( |
| 198 | 217 | tick: Res<SimTick>, |
| 199 | 218 | structures: Query<(&TilePos, &Structure), Without<Health>>, |
| 200 | - mut patients: Query<(&mut Health, &TilePos, Option<(&CurrentJob, &Path)>)>, | |
| 219 | + mut patients: Query<( | |
| 220 | + &mut Health, | |
| 221 | + &TilePos, | |
| 222 | + Option<(&CurrentJob, &Path)>, | |
| 223 | + Option<&Tended>, | |
| 224 | + )>, | |
| 201 | 225 | ) { |
| 202 | 226 | if !tick.0.is_multiple_of(HEAL_INTERVAL_TICKS) { |
| 203 | 227 | return; |
| @@ -207,13 +231,16 @@ fn heal( | ||
| 207 | 231 | .filter(|(_, s)| s.kind == StructureKind::Bed) |
| 208 | 232 | .map(|(pos, _)| pos.0) |
| 209 | 233 | .collect(); |
| 210 | - for (mut health, tile, job) in &mut patients { | |
| 234 | + for (mut health, tile, job, tended) in &mut patients { | |
| 211 | 235 | if health.0 >= 1.0 { |
| 212 | 236 | continue; |
| 213 | 237 | } |
| 214 | 238 | let in_bed = |
| 215 | 239 | 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); | |
| 240 | + let tended = tended | |
| 241 | + .filter(|t| tick.0 < t.until) | |
| 242 | + .map_or(0.0, |t| t.quality); | |
| 243 | + health.0 = healed(health.0, HEAL_INTERVAL_TICKS as f32, in_bed, tended); | |
| 217 | 244 | } |
| 218 | 245 | } |
| 219 | 246 | |
| @@ -252,8 +279,10 @@ mod tests { | ||
| 252 | 279 | #[test] |
| 253 | 280 | fn wounds_heal_slowly_and_faster_in_bed() { |
| 254 | 281 | 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); | |
| 282 | + assert!((healed(0.5, day, false, 0.0) - 0.8).abs() < 1e-4); | |
| 283 | + assert_eq!(healed(0.5, day, true, 0.0), 1.0); | |
| 284 | + assert!(healed(0.2, day, false, 1.0) > healed(0.2, day, false, 0.5)); | |
| 285 | + assert!(tend_quality(1.0, true) > tend_quality(1.0, false)); | |
| 257 | 286 | } |
| 258 | 287 | |
| 259 | 288 | // A hungry wolf stalks the nearest prey, bites it to death and then rests while it digests. |
src/tilemap.rs +11 -1
| @@ -424,16 +424,20 @@ pub enum PlantLook { | ||
| 424 | 424 | Sprout, |
| 425 | 425 | YoungCrop, |
| 426 | 426 | RipeCrop, |
| 427 | + Herb, | |
| 428 | + RipeHerb, | |
| 427 | 429 | } |
| 428 | 430 | |
| 429 | 431 | impl PlantLook { |
| 430 | - const ALL: [PlantLook; 6] = [ | |
| 432 | + const ALL: [PlantLook; 8] = [ | |
| 431 | 433 | PlantLook::Tree, |
| 432 | 434 | PlantLook::Bush, |
| 433 | 435 | PlantLook::RipeBush, |
| 434 | 436 | PlantLook::Sprout, |
| 435 | 437 | PlantLook::YoungCrop, |
| 436 | 438 | PlantLook::RipeCrop, |
| 439 | + PlantLook::Herb, | |
| 440 | + PlantLook::RipeHerb, | |
| 437 | 441 | ]; |
| 438 | 442 | |
| 439 | 443 | pub fn of(plant: Plant, growth: f32) -> Self { |
| @@ -444,6 +448,8 @@ impl PlantLook { | ||
| 444 | 448 | Plant::Crop if plant.is_ripe(growth) => PlantLook::RipeCrop, |
| 445 | 449 | Plant::Crop if growth >= SPROUT_UNTIL => PlantLook::YoungCrop, |
| 446 | 450 | Plant::Crop => PlantLook::Sprout, |
| 451 | + Plant::Herb if plant.is_ripe(growth) => PlantLook::RipeHerb, | |
| 452 | + Plant::Herb => PlantLook::Herb, | |
| 447 | 453 | } |
| 448 | 454 | } |
| 449 | 455 | } |
| @@ -470,6 +476,8 @@ fn plant_texture(look: PlantLook) -> Texture { | ||
| 470 | 476 | let berry = Srgba::rgb_u8(190, 40, 90); |
| 471 | 477 | let stalk = Srgba::rgb_u8(110, 160, 60); |
| 472 | 478 | let grain = Srgba::rgb_u8(220, 190, 80); |
| 479 | + let herb = Srgba::rgb_u8(90, 150, 110); | |
| 480 | + let bloom = Srgba::rgb_u8(200, 170, 240); | |
| 473 | 481 | match look { |
| 474 | 482 | PlantLook::Tree => tree_texture(), |
| 475 | 483 | PlantLook::Bush => blob_texture(5.0, leaves, None), |
| @@ -477,6 +485,8 @@ fn plant_texture(look: PlantLook) -> Texture { | ||
| 477 | 485 | PlantLook::Sprout => rows_texture(3, stalk, None), |
| 478 | 486 | PlantLook::YoungCrop => rows_texture(7, stalk, None), |
| 479 | 487 | PlantLook::RipeCrop => rows_texture(10, stalk, Some(grain)), |
| 488 | + PlantLook::Herb => blob_texture(3.0, herb, None), | |
| 489 | + PlantLook::RipeHerb => blob_texture(3.5, herb, Some(bloom)), | |
| 480 | 490 | } |
| 481 | 491 | } |
| 482 | 492 | |
src/world.rs +4 -1
| @@ -94,15 +94,17 @@ pub enum Plant { | ||
| 94 | 94 | Tree, |
| 95 | 95 | BerryBush, |
| 96 | 96 | Crop, |
| 97 | + Herb, | |
| 97 | 98 | } |
| 98 | 99 | |
| 99 | 100 | impl Plant { |
| 100 | - pub const ALL: [Plant; 3] = [Plant::Tree, Plant::BerryBush, Plant::Crop]; | |
| 101 | + pub const ALL: [Plant; 4] = [Plant::Tree, Plant::BerryBush, Plant::Crop, Plant::Herb]; | |
| 101 | 102 | |
| 102 | 103 | pub fn extra_move_cost(self) -> u32 { |
| 103 | 104 | match self { |
| 104 | 105 | Plant::Tree => 100, |
| 105 | 106 | Plant::BerryBush => 60, |
| 107 | + Plant::Herb => 20, | |
| 106 | 108 | Plant::Crop => 30, |
| 107 | 109 | } |
| 108 | 110 | } |
| @@ -111,6 +113,7 @@ impl Plant { | ||
| 111 | 113 | let days = match self { |
| 112 | 114 | Plant::Tree => return None, |
| 113 | 115 | Plant::BerryBush => 2.0, |
| 116 | + Plant::Herb => 3.0, | |
| 114 | 117 | Plant::Crop => 1.5, |
| 115 | 118 | }; |
| 116 | 119 | Some(days * 24.0 * crate::sim::TICKS_PER_HOUR as f32) |
src/worldgen.rs +4 -0
| @@ -30,6 +30,7 @@ const FOREST_SHARE: f32 = 0.35; | ||
| 30 | 30 | const FOREST_TREE_PERCENT: u32 = 45; |
| 31 | 31 | const LONE_TREE_PERCENT: u32 = 3; |
| 32 | 32 | const BERRY_BUSH_PERCENT: u32 = 2; |
| 33 | +const HERB_PERCENT: u32 = 1; | |
| 33 | 34 | const RIVER_COUNT: usize = 4; |
| 34 | 35 | const RIVER_CANDIDATES: usize = 40; |
| 35 | 36 | const RIVER_SOURCE_SPACING: f32 = 40.0; |
| @@ -149,6 +150,9 @@ pub fn generate(size: UVec2, seed: u32) -> WorldMap { | ||
| 149 | 150 | } else if fertile && roll >= 100 - BERRY_BUSH_PERCENT { |
| 150 | 151 | map.set_plant(pos, Some(Plant::BerryBush)); |
| 151 | 152 | map.set_growth(pos, (hash2(pos.y ^ seed, pos.x) % 140) as f32 / 100.0); |
| 153 | + } else if fertile && roll >= 100 - BERRY_BUSH_PERCENT - HERB_PERCENT { | |
| 154 | + map.set_plant(pos, Some(Plant::Herb)); | |
| 155 | + map.set_growth(pos, (hash2(pos.y ^ seed, pos.x) % 140) as f32 / 100.0); | |
| 152 | 156 | } |
| 153 | 157 | } |
| 154 | 158 | map |
tasks.md +1 -0
| @@ -51,6 +51,7 @@ | ||
| 51 | 51 | - [x] Colonist cap removed (numbered names past the list); job search pass for 40+ colonists (reach-based nearest targets, cheaper cooking/blueprint checks) |
| 52 | 52 | - [x] Family hearths: every home gets a hearth where the family cooks (meals stay home) and which heats the home; growth needs 3 days of food per colonist; failed food searches back off |
| 53 | 53 | - [x] Demolish tool (Q / toolbar): drag to have colonists take down structures and roofs; torn-down homes are forgotten |
| 54 | +- [x] Medicine: healing herbs, Medicine skill and Doctor work; doctors tend the wounded (faster healing), the badly wounded rest in bed | |
| 54 | 55 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 55 | 56 | |
| 56 | 57 | ## Planned |