Add sleeping with a rest need

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 10:27 UTC
commit
b02b916b6bd07eedcf425d51aa987705ccb283b7
parent
5a18005298
tree
browse at this commit

8 files changed +408 -41

CLAUDE.md +2 -1

@@ -22,11 +22,12 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
22 22 - `src/selection.rs` (`update_cursor` is pub; other modules order `.after(update_cursor)`) `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, per-material stock/goal, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Progress bars for Work/ExpandStockpile/Build (`progress_fraction`). Clock shows day/dawn/dusk/night; hover shows structures with remaining fuel. Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`.
23 23 - `src/pathfinding.rs` generic `astar(map, start, is_goal, heuristic)`, 8-dir, no corner cutting, cost = (10|14) * `map.step_cost` (100 = normal flat grass), octile heuristic. `find_path(start, goal)`, `find_path_adjacent(start, target)` (ends on a tile Chebyshev-adjacent to target; works for impassable targets; empty if already adjacent), `nearest_tile(start, max_tiles, pred)` BFS over walkable tiles. Paths exclude start, include goal.
24 24 - `src/colonist.rs` `Colonist{name}` (spawned/respawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (world.rs; last reached tile), `Path(VecDeque)`, `CurrentJob`, `IdleTimer`. `follow_path` (SimSystems::Move) moves `SimPos`, speed / step cost. `repath` keeps the tile being entered at the front.
25 -- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Work{site, kind: WorkKind::{Chop, Mine}, progress}, Haul{pickup: Option<tile>, to}, ExpandStockpile{site, progress}, Build{site, kind: StructureKind, pickup: Option<tile>, progress}}`. Progress is in work units: `work_rate` = 2/tick when the colonist's tile is lit, 1 in the dark (`WorkKind::work`, `EXPAND_WORK`, `build_work`). `site_work(map, tile)` is the single source of what a tile yields (tree → 10 wood; Stone/IronOre/GoldOre → 10 stone / 10 iron / 5 gold, mined tile becomes Gravel). `Reservations` keyed by `Target::{Site, Pickup, Storage}(tile)` and `Target::StockpileExpansion` (one expander at a time); cleared on WorldGenerated. `ColonyGoals(HashMap<ItemKind, u32>)` = demand (wood 50, stone 40, iron 20, gold 10); `wanted(kind, stock, on_the_way)`. `choose_jobs` (SimSystems::Decide), idle colonists, priority: deliver carried load (or drop if no storage) → `find_expansion` (no stockpile + loose items → found a NEW_STOCKPILE_SIZE² stockpile at `find_stockpile_site` near the central loose item; or stockpile has < MIN_EMPTY_STOCKPILE_TILES empty tiles → grow EXPANSION_TILES via `plan_expansion`) → if `dark_soon`: `find_lighting` (`LightingContext`: campfire within HOME_RADIUS of home = stockpile tile nearest its centroid unless one burns with ≥ CAMPFIRE_MIN_FUEL or is planned; else a torch next to the stockpile tile nearest home that existing + planned fires leave dark; needs a reachable unreserved wood stack ≥ cost; Build fetches wood, walks adjacent, builds, consumes wood) → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work in progress) → `find_haul` → wander. `run_jobs` (SimSystems::Act) per-task state machine → `Step::{Continue, Done}`; Done releases reservations, restarts idle timer. Orders: work site (tree/rock) → ordered Work; loose item → ordered Haul; else MoveTo. Orders keep `Carrying`. New job = Task variant + reserve_task arm + choose_jobs candidate + run_jobs arm (+ `site_work` entry for site work).
25 +- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Work{site, kind: WorkKind::{Chop, Mine}, progress}, Haul{pickup: Option<tile>, to}, ExpandStockpile{site, progress}, Build{site, kind: StructureKind, pickup: Option<tile>, progress}}`. Progress is in work units: `work_rate` = 2/tick when the colonist's tile is lit, 1 in the dark (`WorkKind::work`, `EXPAND_WORK`, `build_work`). `site_work(map, tile)` is the single source of what a tile yields (tree → 10 wood; Stone/IronOre/GoldOre → 10 stone / 10 iron / 5 gold, mined tile becomes Gravel). `Reservations` keyed by `Target::{Site, Pickup, Storage}(tile)` and `Target::StockpileExpansion` (one expander at a time); cleared on WorldGenerated. `ColonyGoals(HashMap<ItemKind, u32>)` = demand (wood 50, stone 40, iron 20, gold 10); `wanted(kind, stock, on_the_way)`. `choose_jobs` (SimSystems::Decide), idle colonists (exhausted ones skip the idle timer), priority: sleep if `wants_sleep` → deliver carried load (or drop if no storage) → `find_expansion` (no stockpile + loose items → found a NEW_STOCKPILE_SIZE² stockpile at `find_stockpile_site` near the central loose item; or stockpile has < MIN_EMPTY_STOCKPILE_TILES empty tiles → grow EXPANSION_TILES via `plan_expansion`) → if `dark_soon`: `find_lighting` (`LightingContext`: campfire within HOME_RADIUS of home = stockpile tile nearest its centroid unless one burns with ≥ CAMPFIRE_MIN_FUEL or is planned; else a torch next to the stockpile tile nearest home that existing + planned fires leave dark; needs a reachable unreserved wood stack ≥ cost; Build fetches wood, walks adjacent, builds, consumes wood) → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work in progress) → `find_haul` → wander. `run_jobs` (SimSystems::Act) per-task state machine → `Step::{Continue, Done}`; Done releases reservations, restarts idle timer. Orders: work site (tree/rock) → ordered Work; loose item → ordered Haul; else MoveTo. Orders keep `Carrying`. New job = Task variant + reserve_task arm + choose_jobs candidate + run_jobs arm (+ `site_work` entry for site work).
26 26 - `src/items.rs` `Item{kind: ItemKind::{Wood, Stone, Iron, Gold}, count}` + `TilePos` entities (Mesh2d rect, z=0.7), one stack per tile, `STACK_LIMIT`=50; `ItemQuery` alias; `drop_items` stacks, `take_items`, `room_for(tile, kind)` (0 if other kind present), `stock()`. `Carrying(Option<Item>)` on colonists, shown via hidden/visible `CarriedVisual` child. Items cleared on WorldGenerated.
27 27 - `src/zones.rs` `Zones` resource (stockpile tile set; only walkable dry land), cleared on WorldGenerated — worlds start without stockpiles, colonists found one when needed. `plan_expansion` grows one tile at a time next to stockpile/planned tiles, preferring tree-free tiles near the stockpile centroid (deterministic tiebreak). `find_stockpile_site` = BFS for nearest fully open (zonable, tree-free) square, fallback any zonable tile. `ZoneTool` (Z/button toggles, Cancel exits): left-drag adds, shift-drag removes (`zone_tool` input + `draw_zone_preview` gizmo, PostUpdate after `update_cursor`). Rendered as tilemap `Layer::Zones` (z=0.3), refreshed on `resource_changed::<Zones>`.
28 28 - `src/sim.rs` `SimPlugin`: `SimSystems::{Decide, Act, Move}` chained sets in FixedUpdate; fixed timestep `Time<Fixed>` 60 Hz = sim ticks; all sim logic in `FixedUpdate` (Res<Time> there = fixed delta). `SimTick` counter (FixedFirst), `format_clock` (600 ticks/hour = 10 s real at 1x, starts Day 1 06:00). `SimSpeed` Normal/Fast/Fastest = 1x/3x/6x via `Time<Virtual>::set_relative_speed`; keys 1/2/3 set speed + unpause, Space toggles pause. Render interpolation: sim entities hold `SimPos` (+ `PrevSimPos`, copied in FixedFirst); `interpolate_transforms` lerps Transform by `overstep_fraction` in `RunFixedMainLoop`/AfterFixedMainLoop. Spawn with `sim_pos(v)` so prev==cur. Never write Transform of sim entities directly.
29 29 - `src/light.rs` day/night + fires. `daylight(hour)` (dawn 5–7, dusk 18–20), `ambient_color` (night = blue-ish), `dark_soon(tick)` (now or within LIGHT_LOOKAHEAD_TICKS). `Structure{kind: StructureKind::{Campfire, Torch}, fuel}` + TilePos entities (Mesh2d children: campfire = stone ring + crossed logs + triangle flame, torch = stick + small flame; avoid plain circles, those read as colonists; z=0.8); `burn_fuel` (SimSystems::Act) despawns at 0. Kind stats: wood_cost 10/2, fuel 12h/8h, radius 7/4, `light_at` falloff. `LightMap` resource = ambient + per-tile source light, recomputed in `update_light_map` (SimSystems::Decide) only when the ambient step (16 levels) changes or structures are added/removed; default (empty) = fully lit. `is_lit` = brightness ≥ LIT_BRIGHTNESS. Tilemap tints terrain/plant tiles by `LightMap::color` and refreshes all chunks on `LightMap` change (zones overlay unlit, colonists/items/fires not darkened).
30 +- `src/needs.rs` `Rest(f32)` on colonists (1 = rested): `update_rest` (SimSystems::Act) drains 1/24 per game hour awake, recovers 1/7 per hour while `is_sleeping` (Sleep job, at spot, path empty). `wants_sleep`: rest < NAP_REST (0.3) any time, or night (21–6) and rest < BEDTIME_REST (0.9). `done_sleeping`: rest full and not night (sleep lasts until morning). EXHAUSTED_REST (0.1) makes choose_jobs drop autonomous (not ordered) jobs. `SleepContext::spot` = nearest free tile (dry, no plant/stockpile/fire/item, unreserved `Target::SleepSpot`) to the nearest campfire, else home. Sleeping colonists show a `SleepMarker` Text2d "z" child.
30 31 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
31 32
32 33 ## Gotchas

src/colonist.rs +5 -2

@@ -5,6 +5,7 @@ use bevy::prelude::*;
5 5 use crate::{
6 6 items::{Carrying, ItemAssets},
7 7 jobs::{CurrentJob, IdleTimer},
8 + needs::{Rest, sleep_marker},
8 9 pathfinding::find_path,
9 10 sim::{SimPos, SimSystems, sim_pos},
10 11 world::{TILE_SIZE, TilePos, WorldMap},
@@ -29,7 +30,9 @@ const STARTING_COLONISTS: [(&str, Color); 3] = [
29 30 ];
30 31
31 32 #[derive(Component, Debug)]
32 -#[require(Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying)]
33 +#[require(
34 + Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying, Rest
35 +)]
33 36 pub struct Colonist {
34 37 pub name: String,
35 38 }
@@ -59,7 +62,7 @@ fn spawn_colonists(
59 62 MeshMaterial2d(materials.add(color)),
60 63 sim_pos(WorldMap::tile_center(tile)),
61 64 Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
62 - children![item_assets.carried_visual()],
65 + children![item_assets.carried_visual(), sleep_marker()],
63 66 ));
64 67 }
65 68 }

src/jobs.rs +159 -24

@@ -16,9 +16,10 @@ use crate::{
16 16 LIT_BRIGHTNESS, LightMap, Structure, StructureAssets, StructureKind, brightness, dark_soon,
17 17 light_from, spawn_structure,
18 18 },
19 + needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
19 20 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile},
20 21 rng::SimRng,
21 - sim::{SimSystems, SimTick},
22 + sim::{SimSystems, SimTick, hour_of_day},
22 23 world::{Plant, Terrain, TileChanged, TilePos, WorldMap},
23 24 worldgen::WorldGenerated,
24 25 zones::{Zones, can_zone, find_stockpile_site, new_stockpile_tiles, plan_expansion},
@@ -99,6 +100,9 @@ pub enum Task {
99 100 pickup: Option<UVec2>,
100 101 progress: u32,
101 102 },
103 + Sleep {
104 + spot: UVec2,
105 + },
102 106 }
103 107
104 108 pub fn build_work(kind: StructureKind) -> u32 {
@@ -188,6 +192,7 @@ pub enum Target {
188 192 Pickup(UVec2),
189 193 Storage(UVec2),
190 194 StockpileExpansion,
195 + SleepSpot(UVec2),
191 196 }
192 197
193 198 #[derive(Resource, Default, Debug)]
@@ -310,6 +315,7 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
310 315 reservations.reserve(Target::Storage(to), by);
311 316 }
312 317 Task::ExpandStockpile { .. } => reservations.reserve(Target::StockpileExpansion, by),
318 + Task::Sleep { spot } => reservations.reserve(Target::SleepSpot(spot), by),
313 319 Task::Build { site, pickup, .. } => {
314 320 reservations.reserve(Target::Site(site), by);
315 321 if let Some(pickup) = pickup {
@@ -439,23 +445,6 @@ impl LightingContext<'_> {
439 445 && self.reservations.is_free(Target::Site(tile))
440 446 }
441 447
442 - fn home(&self, fallback: UVec2) -> UVec2 {
443 - let tiles: Vec<UVec2> = self.zones.stockpile_tiles().collect();
444 - if tiles.is_empty() {
445 - return fallback;
446 - }
447 - let centroid = tiles.iter().map(|t| t.as_vec2()).sum::<Vec2>() / tiles.len() as f32;
448 - *tiles
449 - .iter()
450 - .min_by(|a, b| {
451 - a.as_vec2()
452 - .distance_squared(centroid)
453 - .total_cmp(&b.as_vec2().distance_squared(centroid))
454 - .then((a.y, a.x).cmp(&(b.y, b.x)))
455 - })
456 - .unwrap()
457 - }
458 -
459 448 fn lights(&self) -> impl Iterator<Item = (UVec2, StructureKind)> + Clone + '_ {
460 449 self.fires
461 450 .iter()
@@ -504,7 +493,7 @@ impl LightingContext<'_> {
504 493 }
505 494
506 495 pub fn find_lighting(ctx: &LightingContext, from: UVec2) -> Option<Task> {
507 - let home = ctx.home(from);
496 + let home = ctx.zones.home(from);
508 497 let (kind, site) = if ctx.campfire_needed(home) {
509 498 (StructureKind::Campfire, ctx.site_near(home, HOME_RADIUS)?)
510 499 } else {
@@ -554,6 +543,7 @@ fn choose_jobs(
554 543 &mut CurrentJob,
555 544 &mut IdleTimer,
556 545 &mut Carrying,
546 + &Rest,
557 547 ),
558 548 With<Colonist>,
559 549 >,
@@ -579,8 +569,20 @@ fn choose_jobs(
579 569 })
580 570 .collect();
581 571 let mut pending = on_the_way(&map, colonists.iter().map(|c| c.3.0));
582 - for (entity, tile, mut path, mut job, mut idle, mut carrying) in &mut colonists {
583 - if job.0.is_some() || !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
572 + let hour = hour_of_day(*tick);
573 + for (entity, tile, mut path, mut job, mut idle, mut carrying, rest) in &mut colonists {
574 + let exhausted = rest.0 < EXHAUSTED_REST;
575 + if exhausted
576 + && job
577 + .0
578 + .is_some_and(|j| !j.ordered && !matches!(j.task, Task::Sleep { .. }))
579 + {
580 + reservations.release_all(entity);
581 + job.0 = None;
582 + path.0.truncate(1);
583 + }
584 + let rested = idle.0.tick(time.delta()).is_finished();
585 + if job.0.is_some() || !path.0.is_empty() || !(rested || exhausted) {
584 586 continue;
585 587 }
586 588 let snapshot: Vec<(Item, TilePos)> =
@@ -593,7 +595,18 @@ fn choose_jobs(
593 595 pending.get(&kind).copied().unwrap_or(0),
594 596 )
595 597 };
596 - let task = if let Some(load) = carrying.0 {
598 + let sleep = SleepContext {
599 + map: &map,
600 + zones: &zones,
601 + reservations: &reservations,
602 + items: &snapshot,
603 + fires: &fires,
604 + };
605 + let task = if wants_sleep(rest.0, hour) {
606 + Some(Task::Sleep {
607 + spot: sleep.spot(tile.0),
608 + })
609 + } else if let Some(load) = carrying.0 {
597 610 let storage = find_storage(&map, &zones, &reservations, refs, tile.0, load.kind);
598 611 if storage.is_none() {
599 612 drop_items(
@@ -676,6 +689,7 @@ fn run_jobs(
676 689 item_assets: Res<ItemAssets>,
677 690 structure_assets: Res<StructureAssets>,
678 691 light: Res<LightMap>,
692 + tick: Res<SimTick>,
679 693 mut items: ItemQuery,
680 694 structures: Query<&TilePos, (With<Structure>, Without<Colonist>)>,
681 695 mut changed: MessageWriter<TileChanged>,
@@ -687,11 +701,13 @@ fn run_jobs(
687 701 &mut CurrentJob,
688 702 &mut IdleTimer,
689 703 &mut Carrying,
704 + &Rest,
690 705 ),
691 706 With<Colonist>,
692 707 >,
693 708 ) {
694 - for (entity, tile, mut path, mut current, mut idle, mut carrying) in &mut colonists {
709 + let hour = hour_of_day(*tick);
710 + for (entity, tile, mut path, mut current, mut idle, mut carrying, rest) in &mut colonists {
695 711 let Some(mut job) = current.0 else {
696 712 continue;
697 713 };
@@ -700,6 +716,23 @@ fn run_jobs(
700 716 }
701 717 let step = match &mut job.task {
702 718 Task::MoveTo(_) => Step::Done,
719 + Task::Sleep { spot } => {
720 + if reservations.holder(Target::SleepSpot(*spot)) != Some(entity) {
721 + Step::Done
722 + } else if tile.0 != *spot {
723 + let step = walk_to(&map, tile.0, *spot, &mut path);
724 + if matches!(step, Step::Done) {
725 + reservations.release(Target::SleepSpot(*spot));
726 + *spot = tile.0;
727 + reservations.reserve(Target::SleepSpot(*spot), entity);
728 + }
729 + Step::Continue
730 + } else if done_sleeping(rest.0, hour) {
731 + Step::Done
732 + } else {
733 + Step::Continue
734 + }
735 + }
703 736 Task::Work {
704 737 site,
705 738 kind,
@@ -927,7 +960,14 @@ mod tests {
927 960 .add_message::<TileChanged>()
928 961 .add_systems(
929 962 Update,
930 - (handle_orders, choose_jobs, run_jobs, follow_path).chain(),
963 + (
964 + handle_orders,
965 + choose_jobs,
966 + run_jobs,
967 + follow_path,
968 + crate::needs::update_rest,
969 + )
970 + .chain(),
931 971 );
932 972 app
933 973 }
@@ -1421,6 +1461,101 @@ mod tests {
1421 1461 );
1422 1462 }
1423 1463
1464 + const NAP_TEST_REST: f32 = 0.25;
1465 +
1466 + fn set_rest(app: &mut App, colonist: Entity, rest: f32) {
1467 + app.world_mut().get_mut::<Rest>(colonist).unwrap().0 = rest;
1468 + }
1469 +
1470 + fn rest(app: &App, colonist: Entity) -> f32 {
1471 + app.world().get::<Rest>(colonist).unwrap().0
1472 + }
1473 +
1474 + fn task(app: &App, colonist: Entity) -> Option<Task> {
1475 + job(app, colonist).map(|j| j.task)
1476 + }
1477 +
1478 + #[test]
1479 + fn tired_colonists_nap_and_get_up_when_rested() {
1480 + let mut app = test_app(map_with_trees(&[]));
1481 + set_goals(&mut app, &[]);
1482 + set_hour(&mut app, 10);
1483 + let colonist = spawn_colonist(&mut app, UVec2::new(5, 5));
1484 + set_rest(&mut app, colonist, NAP_TEST_REST);
1485 + run_ticks(&mut app, 61);
1486 + assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. })));
1487 + let mut ticks = 0;
1488 + while matches!(task(&app, colonist), Some(Task::Sleep { .. })) && ticks < 60 * 60 * 10 {
1489 + run_ticks(&mut app, 60);
1490 + ticks += 60;
1491 + }
1492 + assert!(!matches!(task(&app, colonist), Some(Task::Sleep { .. })));
1493 + assert!(rest(&app, colonist) > 0.95);
1494 + }
1495 +
1496 + #[test]
1497 + fn colonists_go_to_bed_at_night_and_sleep_until_morning() {
1498 + let mut app = test_app(map_with_trees(&[]));
1499 + set_goals(&mut app, &[]);
1500 + set_hour(&mut app, 22);
1501 + let colonist = spawn_colonist(&mut app, UVec2::new(5, 5));
1502 + set_rest(&mut app, colonist, 0.8);
1503 + run_ticks(&mut app, 61);
1504 + assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. })));
1505 + run_ticks(&mut app, 3 * crate::sim::TICKS_PER_HOUR as u32);
1506 + assert_eq!(rest(&app, colonist), 1.0);
1507 + assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. })));
1508 + }
1509 +
1510 + #[test]
1511 + fn rested_colonists_stay_up_during_the_day() {
1512 + let mut app = test_app(map_with_trees(&[]));
1513 + set_goals(&mut app, &[]);
1514 + set_hour(&mut app, 12);
1515 + let colonist = spawn_colonist(&mut app, UVec2::new(5, 5));
1516 + set_rest(&mut app, colonist, 0.8);
1517 + run_ticks(&mut app, 60 * 5);
1518 + assert!(!matches!(task(&app, colonist), Some(Task::Sleep { .. })));
1519 + }
1520 +
1521 + #[test]
1522 + fn exhausted_colonists_drop_their_work_to_sleep() {
1523 + let tree = UVec2::new(15, 5);
1524 + let mut app = test_app(map_with_trees(&[tree]));
1525 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
1526 + run_ticks(&mut app, 61);
1527 + assert!(matches!(task(&app, colonist), Some(Task::Work { .. })));
1528 + set_rest(&mut app, colonist, EXHAUSTED_REST / 2.0);
1529 + run_ticks(&mut app, 30);
1530 + assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. })));
1531 + assert!(
1532 + app.world()
1533 + .resource::<Reservations>()
1534 + .is_free(Target::Site(tree))
1535 + );
1536 + }
1537 +
1538 + #[test]
1539 + fn exhaustion_does_not_override_orders() {
1540 + let tree = UVec2::new(15, 5);
1541 + let mut app = test_app(map_with_trees(&[tree]));
1542 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
1543 + app.world_mut().write_message(Order {
1544 + colonist,
1545 + tile: tree,
1546 + });
1547 + run_ticks(&mut app, 2);
1548 + set_rest(&mut app, colonist, EXHAUSTED_REST / 2.0);
1549 + run_ticks(&mut app, 30);
1550 + assert!(matches!(
1551 + job(&app, colonist),
1552 + Some(Job {
1553 + task: Task::Work { .. },
1554 + ordered: true
1555 + })
1556 + ));
1557 + }
1558 +
1424 1559 #[test]
1425 1560 fn colonists_chop_from_a_neighbouring_tile() {
1426 1561 let tree = UVec2::new(8, 5);

src/main.rs +2 -0

@@ -4,6 +4,7 @@ mod colonist;
4 4 mod items;
5 5 mod jobs;
6 6 mod light;
7 +mod needs;
7 8 mod noise;
8 9 mod pathfinding;
9 10 mod rng;
@@ -43,6 +44,7 @@ fn main() {
43 44 items::ItemsPlugin,
44 45 jobs::JobsPlugin,
45 46 light::LightPlugin,
47 + needs::NeedsPlugin,
46 48 selection::SelectionPlugin,
47 49 zones::ZonesPlugin,
48 50 ui::HudPlugin,

src/needs.rs +203 -0

@@ -0,0 +1,203 @@
1 +use bevy::prelude::*;
2 +
3 +use crate::{
4 + colonist::{Colonist, Path},
5 + items::Item,
6 + jobs::{CurrentJob, Job, Reservations, Target, Task},
7 + light::StructureKind,
8 + pathfinding::nearest_tile,
9 + sim::{SimSystems, TICKS_PER_HOUR},
10 + world::{TILE_SIZE, TilePos, WorldMap},
11 + zones::{Zones, can_zone},
12 +};
13 +
14 +pub struct NeedsPlugin;
15 +
16 +impl Plugin for NeedsPlugin {
17 + fn build(&self, app: &mut App) {
18 + app.add_systems(FixedUpdate, update_rest.in_set(SimSystems::Act))
19 + .add_systems(Update, show_sleep_markers);
20 + }
21 +}
22 +
23 +pub const REST_DRAIN_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32);
24 +pub const REST_RECOVERY_PER_TICK: f32 = 1.0 / (7.0 * TICKS_PER_HOUR as f32);
25 +pub const BEDTIME_REST: f32 = 0.9;
26 +pub const NAP_REST: f32 = 0.3;
27 +pub const EXHAUSTED_REST: f32 = 0.1;
28 +const SLEEP_SPOT_SEARCH_TILES: usize = 3000;
29 +
30 +#[derive(Component, Debug, Clone, Copy, PartialEq)]
31 +pub struct Rest(pub f32);
32 +
33 +impl Default for Rest {
34 + fn default() -> Self {
35 + Self(1.0)
36 + }
37 +}
38 +
39 +pub fn is_night(hour: f32) -> bool {
40 + !(6.0..21.0).contains(&hour)
41 +}
42 +
43 +pub fn wants_sleep(rest: f32, hour: f32) -> bool {
44 + rest < NAP_REST || (is_night(hour) && rest < BEDTIME_REST)
45 +}
46 +
47 +pub fn done_sleeping(rest: f32, hour: f32) -> bool {
48 + rest >= 1.0 && !is_night(hour)
49 +}
50 +
51 +pub fn is_sleeping(job: &CurrentJob, tile: &TilePos, path: &Path) -> bool {
52 + matches!(
53 + job.0,
54 + Some(Job {
55 + task: Task::Sleep { spot },
56 + ..
57 + }) if spot == tile.0 && path.0.is_empty()
58 + )
59 +}
60 +
61 +pub fn update_rest(mut colonists: Query<(&mut Rest, &CurrentJob, &TilePos, &Path)>) {
62 + for (mut rest, job, tile, path) in &mut colonists {
63 + let change = if is_sleeping(job, tile, path) {
64 + REST_RECOVERY_PER_TICK
65 + } else {
66 + -REST_DRAIN_PER_TICK
67 + };
68 + rest.0 = (rest.0 + change).clamp(0.0, 1.0);
69 + }
70 +}
71 +
72 +pub struct SleepContext<'a> {
73 + pub map: &'a WorldMap,
74 + pub zones: &'a Zones,
75 + pub reservations: &'a Reservations,
76 + pub items: &'a [(Item, TilePos)],
77 + pub fires: &'a [(UVec2, StructureKind, u32)],
78 +}
79 +
80 +impl SleepContext<'_> {
81 + fn free(&self, tile: UVec2) -> bool {
82 + can_zone(self.map, tile.as_ivec2())
83 + && self.map.plant(tile.as_ivec2()).is_none()
84 + && !self.zones.is_stockpile(tile)
85 + && !self.fires.iter().any(|(pos, ..)| *pos == tile)
86 + && !self.items.iter().any(|(_, pos)| pos.0 == tile)
87 + && self.reservations.is_free(Target::SleepSpot(tile))
88 + }
89 +
90 + pub fn spot(&self, from: UVec2) -> UVec2 {
91 + let campfire = self
92 + .fires
93 + .iter()
94 + .filter(|(_, kind, _)| *kind == StructureKind::Campfire)
95 + .map(|(pos, ..)| *pos)
96 + .min_by(|a, b| {
97 + a.as_vec2()
98 + .distance_squared(from.as_vec2())
99 + .total_cmp(&b.as_vec2().distance_squared(from.as_vec2()))
100 + });
101 + let anchor = campfire.unwrap_or_else(|| self.zones.home(from));
102 + nearest_tile(self.map, anchor, SLEEP_SPOT_SEARCH_TILES, |p| self.free(p)).unwrap_or(from)
103 + }
104 +}
105 +
106 +#[derive(Component)]
107 +pub struct SleepMarker;
108 +
109 +pub fn sleep_marker() -> impl Bundle {
110 + (
111 + SleepMarker,
112 + Text2d::new("z"),
113 + TextFont::from_font_size(12.0),
114 + TextColor(Color::srgb(0.85, 0.9, 1.0)),
115 + Transform::from_xyz(TILE_SIZE * 0.35, TILE_SIZE * 0.45, 0.2),
116 + Visibility::Hidden,
117 + )
118 +}
119 +
120 +fn show_sleep_markers(
121 + colonists: Query<(&CurrentJob, &TilePos, &Path, &Children), With<Colonist>>,
122 + mut markers: Query<&mut Visibility, With<SleepMarker>>,
123 +) {
124 + for (job, tile, path, children) in &colonists {
125 + let shown = if is_sleeping(job, tile, path) {
126 + Visibility::Inherited
127 + } else {
128 + Visibility::Hidden
129 + };
130 + for &child in children {
131 + if let Ok(mut visibility) = markers.get_mut(child) {
132 + visibility.set_if_neq(shown);
133 + }
134 + }
135 + }
136 +}
137 +
138 +#[cfg(test)]
139 +mod tests {
140 + use super::*;
141 + use crate::world::Terrain;
142 +
143 + #[test]
144 + fn colonists_sleep_at_night_and_nap_only_when_tired() {
145 + assert!(wants_sleep(0.8, 22.0));
146 + assert!(!wants_sleep(0.95, 22.0));
147 + assert!(!wants_sleep(0.8, 12.0));
148 + assert!(wants_sleep(0.2, 12.0));
149 + }
150 +
151 + // A colonist that is fully rested at 3 am keeps resting rather than wandering off into the
152 + // dark; the night ends sleep, a full rest bar alone does not.
153 + #[test]
154 + fn sleep_lasts_until_morning() {
155 + assert!(!done_sleeping(1.0, 3.0));
156 + assert!(done_sleeping(1.0, 7.0));
157 + assert!(!done_sleeping(0.7, 7.0));
158 + }
159 +
160 + #[test]
161 + fn rest_drains_awake_and_recovers_asleep() {
162 + let mut app = App::new();
163 + app.add_systems(Update, update_rest);
164 + let tile = UVec2::new(3, 3);
165 + let awake = app
166 + .world_mut()
167 + .spawn((Rest(0.5), CurrentJob(None), TilePos(tile), Path::default()))
168 + .id();
169 + let asleep = app
170 + .world_mut()
171 + .spawn((
172 + Rest(0.5),
173 + CurrentJob(Some(Job {
174 + task: Task::Sleep { spot: tile },
175 + ordered: false,
176 + })),
177 + TilePos(tile),
178 + Path::default(),
179 + ))
180 + .id();
181 + app.update();
182 + assert!(app.world().get::<Rest>(awake).unwrap().0 < 0.5);
183 + assert!(app.world().get::<Rest>(asleep).unwrap().0 > 0.5);
184 + }
185 +
186 + #[test]
187 + fn sleepers_bed_down_by_the_campfire() {
188 + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
189 + let zones = Zones::default();
190 + let reservations = Reservations::default();
191 + let fires = [(UVec2::new(15, 15), StructureKind::Campfire, 1000)];
192 + let ctx = SleepContext {
193 + map: &map,
194 + zones: &zones,
195 + reservations: &reservations,
196 + items: &[],
197 + fires: &fires,
198 + };
199 + let spot = ctx.spot(UVec2::new(2, 2));
200 + assert!(spot.as_vec2().distance(Vec2::splat(15.0)) < 2.0);
201 + assert_ne!(spot, UVec2::new(15, 15));
202 + }
203 +}

src/selection.rs +20 -13

@@ -7,6 +7,7 @@ use crate::{
7 7 items::{Carrying, Item, ItemKind, stock},
8 8 jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind, build_work},
9 9 light::{Structure, StructureKind, daylight},
10 + needs::Rest,
10 11 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
11 12 ui::{HideableUi, PointerOverUi},
12 13 world::{TILE_SIZE, TilePos, WorldMap},
@@ -233,7 +234,7 @@ fn update_tile_info(
233 234 map: Res<WorldMap>,
234 235 cursor: Res<Cursor>,
235 236 selection: Res<Selection>,
236 - colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying)>,
237 + colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying, &Rest)>,
237 238 items: Query<(&Item, &TilePos)>,
238 239 structures: Query<(&Structure, &TilePos)>,
239 240 goals: Res<ColonyGoals>,
@@ -250,8 +251,8 @@ fn update_tile_info(
250 251 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
251 252 Selection::Colonist(entity) => colonists
252 253 .get(entity)
253 - .map(|(colonist, tile, path, job, carrying)| {
254 - describe_colonist(colonist, tile.0, job.0, path.0.len(), carrying.0)
254 + .map(|(colonist, tile, path, job, carrying, rest)| {
255 + describe_colonist(colonist, tile.0, job.0, path.0.len(), carrying.0, rest.0)
255 256 })
256 257 .unwrap_or_else(|_| "-".into()),
257 258 };
@@ -364,6 +365,7 @@ pub fn describe_colonist(
364 365 job: Option<Job>,
365 366 steps_left: usize,
366 367 carrying: Option<Item>,
368 + rest: f32,
367 369 ) -> String {
368 370 let activity = match (job.map(|j| j.task), steps_left) {
369 371 (None, 0) => "idle".to_string(),
@@ -414,6 +416,8 @@ pub fn describe_colonist(
414 416 (Some(Task::Build { kind, .. }), n) if n > 0 => {
415 417 format!("carrying wood to build a {}", structure_name(kind))
416 418 }
419 + (Some(Task::Sleep { .. }), n) if n > 0 => "going to sleep".to_string(),
420 + (Some(Task::Sleep { .. }), _) => "sleeping".to_string(),
417 421 (Some(task @ Task::Build { kind, .. }), _) => {
418 422 format!("building a {}, {}%", structure_name(kind), percent(task))
419 423 }
@@ -427,8 +431,11 @@ pub fn describe_colonist(
427 431 ""
428 432 };
429 433 format!(
430 - "{} at ({}, {}), {activity}{ordered}{load}",
431 - colonist.name, tile.x, tile.y
434 + "{} at ({}, {}), {activity}{ordered}{load}, rest {:.0}%",
435 + colonist.name,
436 + tile.x,
437 + tile.y,
438 + rest * 100.0
432 439 )
433 440 }
434 441
@@ -610,16 +617,16 @@ mod tests {
610 617 let ada = Colonist { name: "Ada".into() };
611 618 let tile = UVec2::new(1, 2);
612 619 assert_eq!(
613 - describe_colonist(&ada, tile, None, 0, None),
614 - "Ada at (1, 2), idle"
620 + describe_colonist(&ada, tile, None, 0, None, 1.0),
621 + "Ada at (1, 2), idle, rest 100%"
615 622 );
616 623 let ordered_move = Job {
617 624 task: Task::MoveTo(UVec2::ZERO),
618 625 ordered: true,
619 626 };
620 627 assert_eq!(
621 - describe_colonist(&ada, tile, Some(ordered_move), 3, None),
622 - "Ada at (1, 2), moving, 3 steps left (ordered)"
628 + describe_colonist(&ada, tile, Some(ordered_move), 3, None, 1.0),
629 + "Ada at (1, 2), moving, 3 steps left (ordered), rest 100%"
623 630 );
624 631 let chop = Job {
625 632 task: Task::Work {
@@ -630,8 +637,8 @@ mod tests {
630 637 ordered: false,
631 638 };
632 639 assert_eq!(
633 - describe_colonist(&ada, tile, Some(chop), 0, None),
634 - "Ada at (1, 2), chopping a tree, 50%"
640 + describe_colonist(&ada, tile, Some(chop), 0, None, 0.5),
641 + "Ada at (1, 2), chopping a tree, 50%, rest 50%"
635 642 );
636 643 let haul = Job {
637 644 task: Task::Haul {
@@ -645,8 +652,8 @@ mod tests {
645 652 count: 10,
646 653 };
647 654 assert_eq!(
648 - describe_colonist(&ada, tile, Some(haul), 4, Some(wood)),
649 - "Ada at (1, 2), hauling to the stockpile, carrying 10 Wood"
655 + describe_colonist(&ada, tile, Some(haul), 4, Some(wood), 0.64),
656 + "Ada at (1, 2), hauling to the stockpile, carrying 10 Wood, rest 64%"
650 657 );
651 658 }
652 659 }

src/zones.rs +15 -0

@@ -44,6 +44,21 @@ impl Zones {
44 44 self.stockpile.iter().copied()
45 45 }
46 46
47 + pub fn home(&self, fallback: UVec2) -> UVec2 {
48 + let centroid = self.stockpile.iter().map(|t| t.as_vec2()).sum::<Vec2>()
49 + / self.stockpile.len().max(1) as f32;
50 + self.stockpile
51 + .iter()
52 + .copied()
53 + .min_by(|a, b| {
54 + a.as_vec2()
55 + .distance_squared(centroid)
56 + .total_cmp(&b.as_vec2().distance_squared(centroid))
57 + .then((a.y, a.x).cmp(&(b.y, b.x)))
58 + })
59 + .unwrap_or(fallback)
60 + }
61 +
47 62 pub fn has_stockpile(&self) -> bool {
48 63 !self.stockpile.is_empty()
49 64 }

tasks.md +2 -1

@@ -18,12 +18,13 @@
18 18 - [x] Mining (stone/iron/gold → gravel floor), per-material colony goals driving chopping and mining
19 19 - [x] Colonists found a stockpile when items need storing and expand it when nearly full (no starting stockpile)
20 20 - [x] Day/night cycle with per-tile lighting; colonists build campfires and torches (wood, burn out); work is slower in the dark
21 +- [x] Sleep: rest need, bedtime/naps/exhaustion, colonists sleep by the campfire until morning
21 22 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
22 23
23 24 ## Planned
24 25 - [ ] Jobs: building (walls, floors; consumes materials)
25 26 - [ ] Refuelling fires instead of rebuilding; manual build orders for fires
26 -- [ ] Sleep/needs (night currently only affects light and work speed)
27 +- [ ] Beds/shelter (sleep spots are just ground near the campfire); more needs (food)
27 28 - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
28 29 - [ ] Designations (mark trees/rock for anyone to work) and per-colonist work priorities (job order is fixed: deliver > found/expand stockpile > chop/mine > haul > wander)
29 30 - [ ] UI to edit colony goals (material targets)