Add colony growth, starvation and an event log

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 20:28 UTC
commit
ee2c8ca353ed7efedc8b04e18774b1db451a3abf
parent
ff2a75e8e8
tree
browse at this commit

6 files changed +328 -32

CLAUDE.md +7 -5

@@ -19,22 +19,24 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
19 19 - `src/worldgen.rs` `WorldGenPlugin` inserts `WorldSeed` + `WorldMap` (256x256) at build time and writes `WorldGenerated` message at Startup; R → seed+1, regenerate, write message. `generate(size, seed)`: elevation fbm → height (min-max normalised to meters) and deep/shallow/sand/rock by **quantile** thresholds (fixed terrain shares per seed); ore = quantiles of two ore noises among rock tiles (gold 3%, iron 10%); fertility fbm top 20% → soil; rivers; trees (forest noise top 35% → 45% tree chance, else 3%; only Grass/Soil, placed after rivers). Rivers: `FlowField` = priority flood from water + non-rock edge tiles over non-rock tiles, each tile gets a downstream neighbour (spills out of depressions at lowest rim); `pick_rivers` traces 40 random mountain-foot sources, keeps the 4 longest spaced ≥40 apart; `carve_river` → ShallowWater, wider downstream, never over rock/deep water. `largest_region` (4-conn flood of walkable), `spawn_tiles(map, n)` = BFS from largest-region tile nearest map center. Everything reacting to a new world runs `.run_if(on_message::<WorldGenerated>)`: tilemap refresh, colonist respawn, camera recenter, selection clear.
20 20 - `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Chunk layers (`Layer::Terrain` z=0, `Layer::Zones` z=0.3, `Layer::Plants` z=0.5 (tileset per `PlantLook`: tree, bush, ripe bush, sprout, young crop, ripe crop), `Layer::Roofs` z=0.85 translucent striped overlay, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (full on WorldGenerated, per-chunk on `TileChanged`) (bevy's `update_tilemap_chunk_indices` re-uploads via change detection).
21 21 - `src/camera.rs` `CameraController` on Camera2d: WASD/arrows pan (shift = fast, speed scaled by ortho scale), middle/right drag pan (from cursor delta, not AccumulatedMouseMotion — that doesn't fire for web), scroll zoom toward cursor (`zoom_towards`), camera center clamped to world rect.
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, material and food stock/goal lines, 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, plant growth, farm, animals. `describe_colonist(&ColonistView)` includes rest and food. Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`.
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, colonist count, material and food stock-group/learned-goal lines, recent colony events, 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, plant growth, farm, animals. `describe_colonist(&ColonistView)` includes rest and food. 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)` (start may itself be blocked so movers can leave a tile that became a wall; `follow_path` also drops a route whose next tile became impassable), 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 -- `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/mod.rs` job system (tests in `src/jobs/tests.rs`). `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, berries/meat/vegetables 30, meals 10); `wanted(kind, stock, on_the_way)`. `choose_jobs` (SimSystems::Decide), idle colonists (exhausted/starving ones skip the idle timer), priority: sleep if exhausted → eat if hungry (drops carried load) → 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` (`BuildContext` (shared by lighting and beds; `buildable` = dry, not stockpile/plant/structure/planned/item, unreserved): 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) → cooking if meals wanted and a raw stack ≥ COOK_INPUT exists (`find_cooking` at a campfire with ≥ COOK_MIN_FUEL, else `find_campfire`) → `find_bed_site` → `find_blueprint_work` → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work, hunts and cooks in progress) → `find_hunt` if meat wanted → `find_sowing` → `find_haul` → wander. `find_bed_site`: one bed per colonist incl. planned; next to nearest bed, else campfire, else home; ≥ BED_STOCKPILE_CLEARANCE from stockpile and farm tiles so a wall ring fits. `find_blueprint_work`: nearest unreserved blueprint with material available. Cook: fetch COOK_INPUT raw units of one kind, cook COOK_WORK next to the campfire (`Target::Station`), drop 1 Meal (0.9 nutrition vs 0.6 raw). Orders: animal → ordered Hunt (checked first), work site → Work, loose item → Haul, else MoveTo. `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 -- `src/items.rs` `Item{kind: ItemKind::{Wood, Stone, Iron, Gold, Berries, Meat, Vegetables, Meal}, count}` (`nutrition`, `is_food`, `is_raw_food`) + `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.
24 +- `src/colonist.rs` `Colonist{name}` (`spawn_colonist(index)` picks name/colour; 3 spawned 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/mod.rs` job system (tests in `src/jobs/tests.rs`). `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` (economy.rs) = learned demand per stock group. `choose_jobs` (SimSystems::Decide), idle colonists (exhausted/starving ones skip the idle timer), priority: sleep if exhausted → eat if hungry (drops carried load) → 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` (`BuildContext` (shared by lighting and beds; `buildable` = dry, not stockpile/plant/structure/planned/item, unreserved): 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) → cooking if meals wanted and a raw stack ≥ COOK_INPUT exists (`find_cooking` at a campfire with ≥ COOK_MIN_FUEL, else `find_campfire`) → `find_bed_site` → `find_blueprint_work` → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work, hunts and cooks in progress) → `find_hunt` if meat wanted → `find_sowing` → `find_haul` → wander. `find_bed_site`: one bed per colonist incl. planned; next to nearest bed, else campfire, else home; ≥ BED_STOCKPILE_CLEARANCE from stockpile and farm tiles so a wall ring fits. `find_blueprint_work`: nearest unreserved blueprint with material available. Cook: fetch COOK_INPUT raw units of one kind, cook COOK_WORK next to the campfire (`Target::Station`), drop 1 Meal (0.9 nutrition vs 0.6 raw). Orders: animal → ordered Hunt (checked first), work site → Work, loose item → Haul, else MoveTo. `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 +- `src/items.rs` `Item{kind: ItemKind::{Wood, Stone, Iron, Gold, Berries, Meat, Vegetables, Meal}, count, spoil}` (`Item::new`, `merge` averages spoil by count, `with_count`; `drop_items` takes a whole Item) (`nutrition`, `is_food`, `is_raw_food`) + `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 and farm tile sets (`ZoneKind`; stockpile = walkable dry land, farm = `can_farm` fertile; never overlap; farm rendered as zone layer index 1), cleared on WorldGenerated — worlds start without stockpiles, colonists found one when needed. `plan_expansion(map, zones, excluded, count)` (excluded = blueprint tiles) 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{mode: Option<ZoneKind>}` (Z stockpile / F farm toggle, 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 lighting. `daylight(hour)` (dawn 5–7, dusk 18–20), `ambient_color` (night = blue-ish), `dark_soon(tick)` (now or within LIGHT_LOOKAHEAD_TICKS). `light_at(kind, source, tile)` falloff from `StructureKind::light()` (None for non-emitters). `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/structures not darkened).
30 30 - `src/structures.rs` `Structure{kind: StructureKind::{Campfire, Torch, Bed, Wall, Door}}` + TilePos entities; `Fuel(u32)` only on kinds with `fuel_ticks()` (fires: 12h/8h), `burn_fuel` (SimSystems::Act) despawns at 0; beds are permanent. Kind stats: `cost()` (material, amount): fires/bed/door wood, wall 2 stone; `light()` radius+color (fires only), `name()`. Visuals are Mesh2d children (campfire = stone ring + crossed logs + triangle flame, torch = stick + small flame, bed = frame + blanket + pillow, wall = stone square, door = brown square + knob; avoid plain circles, those read as colonists; z=0.8). `spawn_structure` inserts Fuel when needed. Cleared on WorldGenerated.
31 31 - `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 (1/5 in a bed, ×ROOF_REST_BONUS 1.25 under a roof) 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 bed not reserved as `Target::SleepSpot`, else 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.
32 32 - `src/building.rs` construction planning. `BuildKind::{Structure(StructureKind), Roof, Pave}` with `cost()`/`work()`/`blocks_tile()` (walls). `Blueprints(HashMap<tile, BuildKind>)` resource, drawn as gizmo outlines, cleared on WorldGenerated. `plan_shelter` (SimSystems::Decide, every 300 ticks, only while no non-Pave blueprints; merges its plan into Blueprints): for unroofed beds, if `enclosed_room` (4-conn flood bounded by walls/doors/rock, ≤ MAX_ROOM_TILES, not touching map edge) → Roof blueprints for unroofed room tiles; else `ShelterContext::plan` tries every wall ring with 0–2 tiles margin per side around the beds' bbox, cheapest (ring length + penalty per margin-0 side) where every ring tile is rock/existing wall/door or buildable (dry, no plant/item/structure; empty stockpile tiles allowed and removed from the stockpile when planned), one Door on the non-corner ring tile nearest the campfire/home.
33 -- `src/food.rs` `Hunger(f32)` on colonists (1 = fed): `update_hunger` drains 1/24 per game hour (half while sleeping). HUNGRY 0.35 → Eat job; STARVING 0.05 interrupts autonomous work like exhaustion. `find_food` prefers Meal stacks, then raw food; `portion(kind, fed)` = units to fill up. Eat: walk to pickup, take portion into `Carrying`, eat EAT_TICKS, add nutrition.
33 +- `src/food.rs` spoilage: `spoil_food` (every 60 ticks) adds `spoilage(kind, roofed, ticks)` to food stacks and carried food (shelf life meat 2 d, berries 3, meals 4, vegetables 5; ×0.5 under a roof); spoil ≥ 1 removes it. `Hunger(f32)` on colonists (1 = fed): `update_hunger` drains 1/24 per game hour (half while sleeping). HUNGRY 0.35 → Eat job; STARVING 0.05 interrupts autonomous work like exhaustion. `find_food` prefers Meal stacks, then raw food; `portion(kind, fed)` = units to fill up. Eat: walk to pickup, take portion into `Carrying`, eat EAT_TICKS, add nutrition.
34 34 - `src/plants.rs` `grow_plants` every GROWTH_INTERVAL_TICKS: plants with `ripening_ticks` (BerryBush 2 days, Crop 1.5) gain growth × `fertility(terrain)` (Soil 1, Grass .8, Sand .4, Gravel .3; wild bushes ≥ .8); writes TileChanged when `PlantLook` stage changes.
35 35 - `src/animals.rs` `Animal{kind: AnimalKind::{Deer, Rabbit}}` entities (Path/TilePos/SimPos/MoveSpeed, moved by colonist `follow_path`; ellipse-body visuals, z=0.9). `populate` on WorldGenerated (population per kind, ≥25 tiles from colony), `migrate` refills one per kind every 2 game hours, `wander` idles 2–8 s then walks ≤10 tiles; an animal reserved as `Target::Animal` (being hunted) stands still. meat 30/8, hunt_work 360/160.
36 36 - `src/farming.rs` `plan_farm` (every 600 ticks, once a stockpile exists and no farm): `FarmContext::plan` picks the FARM_SIZE² square near home (BFS corners) maximising soil − distance where every tile is farmable, plant-free and ≥ FARM_CLEARANCE from stockpile, blueprints, structures and items.
37 37 - `src/paths.rs` traffic wear. `follow_path` adds 1 wear when a colonist (not an animal) enters a tile. `update_paths` (hourly): wear × WEAR_KEPT_PER_HOUR (0.98); Grass/Soil/Sand with wear ≥ PATH_WEAR (8), no plant, not farm → `Terrain::Path`; Path with wear < REGROW_WEAR → Grass. `plan_paving` (hourly) keeps up to PAVE_BATCH `BuildKind::Pave` blueprints on the busiest Path tiles with wear ≥ PAVE_WEAR (20); built with 1 stone → `Terrain::Paved`. Path cost 80, Paved 60 (= CHEAPEST_MOVE_COST, which A*'s heuristic uses; a test enforces no terrain is cheaper), so colonists prefer and reinforce them. Hover shows wear. Tuned from observed traffic of 3 colonists: busiest tiles reach ~15–30 wear by day 3–4.
38 +- `src/economy.rs` learned stock targets. `Stock::{Wood, Stone, Iron, Gold, RawFood, Meals}` groups item kinds (raw foods are one group). `ColonyGoals` keeps `use_per_day` per stock (EMA, ~24 h memory; seeded with guesses), `record_use(kind, n)` is called by jobs for real consumption (eating, cooking input, build material — not spoilage), `learn_needs` (hourly) updates rates and goals = clamp(rate × buffer days (2 materials, 1.5 food), min, 300) + material for pending blueprints (`planned_needs`). `wanted(kind, stocks, on_the_way)` compares the whole group. `ColonyGoals::fixed` for tests.
39 +- `src/population.rs` `ColonyLog` (last 4 events, shown in the info panel). `starve`: `Starvation` ticks at zero hunger; after STARVATION_TICKS (1 day) the colonist dies (drops load, releases reservations). `arrivals` daily at 10:00: if `prosperous` (≤ MAX_COLONISTS, stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, next unused name) appears at an `edge_tile` with an unordered MoveTo home.
38 40 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
39 41
40 42 ## Gotchas

src/colonist.rs +65 -20

@@ -8,6 +8,7 @@ use crate::{
8 8 jobs::{CurrentJob, IdleTimer},
9 9 needs::{Rest, sleep_marker},
10 10 pathfinding::find_path,
11 + population::Starvation,
11 12 sim::{SimPos, SimSystems, sim_pos},
12 13 world::{TILE_SIZE, TilePos, WorldMap},
13 14 worldgen::{WorldGenerated, spawn_tiles},
@@ -17,22 +18,69 @@ pub struct ColonistPlugin;
17 18
18 19 impl Plugin for ColonistPlugin {
19 20 fn build(&self, app: &mut App) {
20 - app.add_systems(Update, spawn_colonists.run_if(on_message::<WorldGenerated>))
21 + app.add_systems(Startup, create_colonist_assets)
22 + .add_systems(Update, spawn_colonists.run_if(on_message::<WorldGenerated>))
21 23 .add_systems(FixedUpdate, follow_path.in_set(SimSystems::Move));
22 24 }
23 25 }
24 26
25 27 const TILES_PER_SECOND: f32 = 4.0;
26 28
27 -const STARTING_COLONISTS: [(&str, Color); 3] = [
28 - ("Ada", Color::srgb(0.9, 0.3, 0.3)),
29 - ("Bram", Color::srgb(0.3, 0.5, 0.95)),
30 - ("Cleo", Color::srgb(0.95, 0.75, 0.3)),
29 +pub const NAMES: [&str; 16] = [
30 + "Ada", "Bram", "Cleo", "Dana", "Emil", "Fenna", "Gijs", "Hanna", "Ivo", "Juul", "Kees",
31 + "Lotte", "Milan", "Noor", "Otto", "Pien",
31 32 ];
32 33
34 +const COLORS: [Color; 8] = [
35 + Color::srgb(0.9, 0.3, 0.3),
36 + Color::srgb(0.3, 0.5, 0.95),
37 + Color::srgb(0.95, 0.75, 0.3),
38 + Color::srgb(0.6, 0.35, 0.85),
39 + Color::srgb(0.3, 0.8, 0.75),
40 + Color::srgb(0.95, 0.5, 0.75),
41 + Color::srgb(0.55, 0.8, 0.3),
42 + Color::srgb(0.95, 0.95, 0.95),
43 +];
44 +
45 +const STARTING_COLONISTS: usize = 3;
46 +
47 +#[derive(Resource, Default)]
48 +pub struct ColonistAssets {
49 + mesh: Handle<Mesh>,
50 +}
51 +
52 +fn create_colonist_assets(mut commands: Commands, mut meshes: ResMut<Assets<Mesh>>) {
53 + commands.insert_resource(ColonistAssets {
54 + mesh: meshes.add(Circle::new(TILE_SIZE * 0.35)),
55 + });
56 +}
57 +
58 +pub fn spawn_colonist(
59 + commands: &mut Commands,
60 + assets: &ColonistAssets,
61 + item_assets: &ItemAssets,
62 + materials: &mut Assets<ColorMaterial>,
63 + index: usize,
64 + tile: UVec2,
65 +) -> Entity {
66 + let name = NAMES[index % NAMES.len()];
67 + commands
68 + .spawn((
69 + Name::new(name),
70 + Colonist { name: name.into() },
71 + TilePos(tile),
72 + Mesh2d(assets.mesh.clone()),
73 + MeshMaterial2d(materials.add(COLORS[index % COLORS.len()])),
74 + sim_pos(WorldMap::tile_center(tile)),
75 + Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
76 + children![item_assets.carried_visual(), sleep_marker()],
77 + ))
78 + .id()
79 +}
80 +
33 81 #[derive(Component, Debug)]
34 82 #[require(
35 - Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying, Rest, Hunger
83 + Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying, Rest, Hunger, Starvation
36 84 )]
37 85 pub struct Colonist {
38 86 pub name: String,
@@ -45,26 +93,23 @@ fn spawn_colonists(
45 93 mut commands: Commands,
46 94 map: Res<WorldMap>,
47 95 existing: Query<Entity, With<Colonist>>,
96 + assets: Res<ColonistAssets>,
48 97 item_assets: Res<ItemAssets>,
49 - mut meshes: ResMut<Assets<Mesh>>,
50 98 mut materials: ResMut<Assets<ColorMaterial>>,
51 99 ) {
52 100 for entity in &existing {
53 101 commands.entity(entity).despawn();
54 102 }
55 - let mesh = meshes.add(Circle::new(TILE_SIZE * 0.35));
56 - let tiles = spawn_tiles(&map, STARTING_COLONISTS.len());
57 - for ((name, color), tile) in STARTING_COLONISTS.into_iter().zip(tiles) {
58 - commands.spawn((
59 - Name::new(name),
60 - Colonist { name: name.into() },
61 - TilePos(tile),
62 - Mesh2d(mesh.clone()),
63 - MeshMaterial2d(materials.add(color)),
64 - sim_pos(WorldMap::tile_center(tile)),
65 - Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
66 - children![item_assets.carried_visual(), sleep_marker()],
67 - ));
103 + let tiles = spawn_tiles(&map, STARTING_COLONISTS);
104 + for (index, tile) in tiles.into_iter().enumerate() {
105 + spawn_colonist(
106 + &mut commands,
107 + &assets,
108 + &item_assets,
109 + &mut materials,
110 + index,
111 + tile,
112 + );
68 113 }
69 114 }
70 115

src/main.rs +2 -0

@@ -14,6 +14,7 @@ mod noise;
14 14 mod pathfinding;
15 15 mod paths;
16 16 mod plants;
17 +mod population;
17 18 mod rng;
18 19 mod selection;
19 20 mod sim;
@@ -62,6 +63,7 @@ fn main() {
62 63 farming::FarmingPlugin,
63 64 paths::PathsPlugin,
64 65 economy::EconomyPlugin,
66 + population::PopulationPlugin,
65 67 plants::PlantsPlugin,
66 68 selection::SelectionPlugin,
67 69 zones::ZonesPlugin,

src/population.rs +239 -0

@@ -0,0 +1,239 @@
1 +use std::collections::VecDeque;
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist},
7 + food::Hunger,
8 + items::{Carrying, Item, ItemAssets, ItemQuery, drop_items},
9 + jobs::{CurrentJob, Job, Reservations, Task},
10 + rng::SimRng,
11 + sim::{SimSystems, SimTick, TICKS_PER_HOUR, format_clock},
12 + structures::{Structure, StructureKind},
13 + world::{TilePos, WorldMap},
14 + worldgen::WorldGenerated,
15 + zones::Zones,
16 +};
17 +
18 +pub struct PopulationPlugin;
19 +
20 +impl Plugin for PopulationPlugin {
21 + fn build(&self, app: &mut App) {
22 + app.init_resource::<ColonyLog>()
23 + .add_systems(Update, clear_log.run_if(on_message::<WorldGenerated>))
24 + .add_systems(
25 + FixedUpdate,
26 + (starve, arrivals).chain().in_set(SimSystems::Act),
27 + );
28 + }
29 +}
30 +
31 +pub const STARVATION_TICKS: u32 = 24 * TICKS_PER_HOUR as u32;
32 +pub const MAX_COLONISTS: usize = 12;
33 +pub const ARRIVAL_CHANCE: f32 = 0.6;
34 +const ARRIVAL_HOUR_TICK: u64 = 4 * TICKS_PER_HOUR;
35 +const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;
36 +const LOG_LENGTH: usize = 4;
37 +const EDGE_ATTEMPTS: usize = 100;
38 +
39 +#[derive(Resource, Default, Debug)]
40 +pub struct ColonyLog(pub VecDeque<String>);
41 +
42 +impl ColonyLog {
43 + pub fn push(&mut self, tick: SimTick, message: String) {
44 + self.0
45 + .push_front(format!("{}: {message}", format_clock(tick)));
46 + self.0.truncate(LOG_LENGTH);
47 + }
48 +}
49 +
50 +fn clear_log(mut log: ResMut<ColonyLog>) {
51 + log.0.clear();
52 +}
53 +
54 +#[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
55 +pub struct Starvation(pub u32);
56 +
57 +#[allow(clippy::too_many_arguments)]
58 +fn starve(
59 + mut commands: Commands,
60 + tick: Res<SimTick>,
61 + item_assets: Res<ItemAssets>,
62 + mut log: ResMut<ColonyLog>,
63 + mut reservations: ResMut<Reservations>,
64 + mut items: ItemQuery,
65 + mut colonists: Query<(
66 + Entity,
67 + &Colonist,
68 + &Hunger,
69 + &TilePos,
70 + &Carrying,
71 + &mut Starvation,
72 + )>,
73 +) {
74 + for (entity, colonist, hunger, tile, carrying, mut starvation) in &mut colonists {
75 + starvation.0 = if hunger.0 <= 0.0 { starvation.0 + 1 } else { 0 };
76 + if starvation.0 < STARVATION_TICKS {
77 + continue;
78 + }
79 + if let Some(load) = carrying.0 {
80 + drop_items(&mut commands, &mut items, &item_assets, tile.0, load);
81 + }
82 + reservations.release_all(entity);
83 + commands.entity(entity).despawn();
84 + log.push(*tick, format!("{} starved to death", colonist.name));
85 + }
86 +}
87 +
88 +pub fn food_in_store<'a>(items: impl Iterator<Item = &'a Item>) -> f32 {
89 + items
90 + .map(|item| item.count as f32 * item.kind.nutrition())
91 + .sum()
92 +}
93 +
94 +pub fn prosperous(colonists: usize, food: f32, free_beds: usize, wood: u32) -> bool {
95 + colonists < MAX_COLONISTS
96 + && food >= colonists.max(1) as f32
97 + && (free_beds > 0 || wood >= StructureKind::Bed.cost().1)
98 +}
99 +
100 +pub fn edge_tile(map: &WorldMap, rng: &mut SimRng) -> Option<UVec2> {
101 + let (w, h) = (map.size.x as i32, map.size.y as i32);
102 + (0..EDGE_ATTEMPTS).find_map(|_| {
103 + let along = rng.range_i32(0..w.max(h));
104 + let tile = match rng.range_i32(0..4) {
105 + 0 => IVec2::new(along.min(w - 1), 0),
106 + 1 => IVec2::new(along.min(w - 1), h - 1),
107 + 2 => IVec2::new(0, along.min(h - 1)),
108 + _ => IVec2::new(w - 1, along.min(h - 1)),
109 + };
110 + let open = map.move_cost(tile).is_some() && !map.get(tile).is_some_and(|t| t.is_water());
111 + open.then(|| tile.as_uvec2())
112 + })
113 +}
114 +
115 +#[allow(clippy::too_many_arguments)]
116 +fn arrivals(
117 + mut commands: Commands,
118 + tick: Res<SimTick>,
119 + map: Res<WorldMap>,
120 + zones: Res<Zones>,
121 + assets: Res<ColonistAssets>,
122 + item_assets: Res<ItemAssets>,
123 + mut materials: ResMut<Assets<ColorMaterial>>,
124 + mut rng: ResMut<SimRng>,
125 + mut log: ResMut<ColonyLog>,
126 + items: Query<&Item>,
127 + structures: Query<&Structure>,
128 + colonists: Query<&Colonist>,
129 +) {
130 + if tick.0 % DAY_TICKS != ARRIVAL_HOUR_TICK {
131 + return;
132 + }
133 + let count = colonists.iter().count();
134 + let beds = structures
135 + .iter()
136 + .filter(|s| s.kind == StructureKind::Bed)
137 + .count();
138 + let wood = items
139 + .iter()
140 + .filter(|i| i.kind == crate::items::ItemKind::Wood)
141 + .map(|i| i.count)
142 + .sum();
143 + let food = food_in_store(items.iter());
144 + let lucky = rng.range_f32(0.0..1.0) < ARRIVAL_CHANCE;
145 + if !lucky || !prosperous(count, food, beds.saturating_sub(count), wood) {
146 + return;
147 + }
148 + let Some(tile) = edge_tile(&map, &mut rng) else {
149 + return;
150 + };
151 + let taken: Vec<&str> = colonists.iter().map(|c| c.name.as_str()).collect();
152 + let index = (0..NAMES.len())
153 + .find(|&i| !taken.contains(&NAMES[i]))
154 + .unwrap_or(count);
155 + let newcomer = spawn_colonist(
156 + &mut commands,
157 + &assets,
158 + &item_assets,
159 + &mut materials,
160 + index,
161 + tile,
162 + );
163 + commands.entity(newcomer).insert(CurrentJob(Some(Job {
164 + task: Task::MoveTo(zones.home(map.size / 2)),
165 + ordered: false,
166 + })));
167 + log.push(
168 + *tick,
169 + format!("{} has joined the colony", NAMES[index % NAMES.len()]),
170 + );
171 +}
172 +
173 +#[cfg(test)]
174 +mod tests {
175 + use super::*;
176 + use crate::world::Terrain;
177 +
178 + #[test]
179 + fn colonies_grow_only_when_they_can_feed_and_house_newcomers() {
180 + assert!(prosperous(3, 3.5, 1, 0));
181 + assert!(prosperous(3, 3.5, 0, 20));
182 + assert!(!prosperous(3, 2.0, 1, 50));
183 + assert!(!prosperous(3, 10.0, 0, 5));
184 + assert!(!prosperous(MAX_COLONISTS, 100.0, 5, 100));
185 + }
186 +
187 + #[test]
188 + fn newcomers_arrive_at_the_map_edge() {
189 + let map = WorldMap::filled(UVec2::new(30, 20), Terrain::Grass);
190 + let mut rng = SimRng::new(4);
191 + for _ in 0..20 {
192 + let tile = edge_tile(&map, &mut rng).unwrap();
193 + assert!(tile.x == 0 || tile.y == 0 || tile.x == 29 || tile.y == 19);
194 + }
195 + }
196 +
197 + #[test]
198 + fn colonists_starve_after_a_day_without_food() {
199 + let mut app = App::new();
200 + app.init_resource::<SimTick>()
201 + .init_resource::<ItemAssets>()
202 + .init_resource::<ColonyLog>()
203 + .init_resource::<Reservations>()
204 + .add_systems(Update, starve);
205 + let hungry = app
206 + .world_mut()
207 + .spawn((
208 + Colonist { name: "Ada".into() },
209 + Hunger(0.0),
210 + TilePos(UVec2::ONE),
211 + Carrying(Some(Item::new(crate::items::ItemKind::Wood, 5))),
212 + Starvation(STARVATION_TICKS - 2),
213 + ))
214 + .id();
215 + let fed = app
216 + .world_mut()
217 + .spawn((
218 + Colonist {
219 + name: "Bram".into(),
220 + },
221 + Hunger(0.5),
222 + TilePos(UVec2::ONE),
223 + Carrying(None),
224 + Starvation(STARVATION_TICKS - 2),
225 + ))
226 + .id();
227 + app.update();
228 + assert!(app.world().get_entity(hungry).is_ok());
229 + app.update();
230 + assert!(app.world().get_entity(hungry).is_err());
231 + assert!(app.world().get_entity(fed).is_ok());
232 + assert_eq!(app.world().get::<Starvation>(fed), Some(&Starvation(0)));
233 + let log = app.world().resource::<ColonyLog>();
234 + assert!(log.0[0].ends_with("Ada starved to death"));
235 + let world = app.world_mut();
236 + let dropped: Vec<Item> = world.query::<&Item>().iter(world).copied().collect();
237 + assert_eq!(dropped.len(), 1);
238 + }
239 +}

src/selection.rs +12 -5

@@ -12,6 +12,7 @@ use crate::{
12 12 jobs::{COOK_WORK, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
13 13 light::daylight,
14 14 needs::Rest,
15 + population::ColonyLog,
15 16 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
16 17 structures::{Fuel, Structure, StructureKind},
17 18 ui::{HideableUi, PointerOverUi},
@@ -253,14 +254,18 @@ fn update_tile_info(
253 254 animals: Query<(&Animal, &TilePos)>,
254 255 blueprints: Res<Blueprints>,
255 256 goals: Res<ColonyGoals>,
257 + log: Res<ColonyLog>,
256 258 zones: Res<Zones>,
257 259 tool: Res<ZoneTool>,
258 - time: Res<Time<Virtual>>,
259 - tick: Res<SimTick>,
260 - speed: Res<SimSpeed>,
261 - seed: Res<WorldSeed>,
260 + clock: (
261 + Res<Time<Virtual>>,
262 + Res<SimTick>,
263 + Res<SimSpeed>,
264 + Res<WorldSeed>,
265 + ),
262 266 mut text: Single<&mut Text, With<TileInfoText>>,
263 267 ) {
268 + let (time, tick, speed, seed) = clock;
264 269 let selected = match *selection {
265 270 Selection::None => "-".to_string(),
266 271 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
@@ -333,14 +338,16 @@ fn update_tile_info(
333 338 .map(|(animal, _)| format!(", {}", animal.kind.name())),
334 339 )
335 340 .collect();
341 + let events: String = log.0.iter().map(|line| format!("\n{line}")).collect();
336 342 let tool_hint = match tool.mode {
337 343 Some(ZoneKind::Stockpile) => "\nSTOCKPILE TOOL: drag to add, shift-drag to remove",
338 344 Some(ZoneKind::Farm) => "\nFARM TOOL: drag to add, shift-drag to remove",
339 345 None => "",
340 346 };
341 347 let info = format!(
342 - "{clock} {speed}\nSeed: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}{hovered_structure}\nSelected: {selected}",
348 + "{clock} {speed}\nSeed: {} Colonists: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}{hovered_structure}\nSelected: {selected}{events}",
343 349 seed.0,
350 + colonists.iter().count(),
344 351 describe_tile(&map, cursor.tile)
345 352 );
346 353 if text.0 != info {

tasks.md +3 -2

@@ -23,19 +23,20 @@
23 23 - [x] Walls (stone), doors (wood), roofs: colonists plan and build a roofed shelter around their beds; roofs improve rest
24 24 - [x] Food: hunger need and eating (meals preferred), berry bushes + foraging, wild animals + hunting, farm zones (auto-planned or F tool) with sowing/growing/harvesting, cooking meals at campfires
25 25 - [x] Paths: traffic wears grass into dirt paths (faster, regrow when unused); busiest paths get paved with stone
26 +- [x] Learned stock targets (from consumption, grouped raw food, plus planned construction); food spoilage (slower under roofs); colony growth (newcomers when prosperous) and shrinkage (starvation); event log
26 27 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
27 28
28 29 ## Planned
29 30 - [ ] Jobs: building (walls, floors; consumes materials)
30 31 - [ ] Refuelling fires instead of rebuilding; manual build orders for fires
31 32 - [ ] Manual wall/room building tool; bed ownership
32 -- [ ] Food spoilage, mood/food poisoning from raw food, starvation damage
33 +- [ ] Mood, food poisoning from raw food; colonists leaving when unhappy; old age/disease
33 34 - [ ] Animals flee from hunters; tameable animals; butchering as a separate step
34 35 - [ ] Per-colonist work priorities (busy colonies leave low-priority work such as hauling undone)
35 36 - [ ] Walls block light; indoor darkness; deconstruction
36 37 - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
37 38 - [ ] 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)
38 -- [ ] UI to edit colony goals (material targets)
39 +- [ ] UI to view/override learned stock targets
39 40 - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky)
40 41 - [ ] Colonist panel / better info UI (info panel is plain text)
41 42 - [ ] Zoom +/- buttons (camera has no buttons; WASD/drag/scroll only)