Add herbs, doctors and bed rest for the wounded

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

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

@@ -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