Let colonists found their own stockpile instead of a starting one

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 09:59 UTC
commit
2402637c3093c103c7131a60cdf35fa6683095da
parent
cdfd95fe3e
tree
browse at this commit

4 files changed +98 -35

CLAUDE.md +5 -5
src/jobs.rs +42 -7
src/zones.rs +46 -20
tasks.md +5 -3

CLAUDE.md +5 -5

@@ -14,17 +14,17 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
14 14 - `src/actions.rs` `Action` message enum + `KEYBINDS` table (single source of truth for keys; `keyboard_actions` in PreUpdate after `InputSystems`). All non-camera input goes through actions: consumers read `MessageReader<Action>` (sim speed/pause, worldgen NewWorld, zones tool/Cancel, selection Cancel, ui ToggleUi). New keybind = add Action variant + KEYBINDS entry + label + TOOLBAR entry (test enforces every keybind has a button).
15 15 - `src/ui.rs` `HudPlugin`: bottom toolbar of `ui_widgets::Button`s (`ActivateOnPress` — click-on-release was flaky when the pointer moves and clicks in one frame) with `ActionButton(Action)`; global `On<Activate>` observer writes the action. `style_buttons` highlights active state (`is_active`), hover, pressed. `UiVisible` + `HideableUi` marker (toolbar, info panel) toggled by H/button; `ShowUiButton` appears when hidden. `PointerOverUi` from picking `HoverMap` (any hovered entity with `Node`; layout containers are `Pickable::IGNORE`); `update_cursor` clears world/tile and selection/orders/zone tool ignore clicks while over UI.
16 16 - `src/main.rs` app wiring: DefaultPlugins (nearest sampling, canvas `#bevy`, fit_canvas_to_parent), adds plugins.
17 -- `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable), `Plant` enum (Tree; separate layer, adds move cost). `WorldMap` resource: per-tile terrain, `Option<Plant>`, height in meters (0..`MAX_HEIGHT`=100); row-major, (0,0)=bottom-left, y up. `move_cost(pos)` = terrain + plant cost (None = impassable); `step_cost(from,to)` adds `UPHILL_COST_PER_METER` × climb — use step_cost for anything that moves. `TILE_SIZE`=16 world units, `hash2` deterministic int hash.
17 +- `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable; Gravel = mined floor, never generated), `Plant` enum (Tree; separate layer, adds move cost). `WorldMap` resource: per-tile terrain, `Option<Plant>`, height in meters (0..`MAX_HEIGHT`=100); row-major, (0,0)=bottom-left, y up. `move_cost(pos)` = terrain + plant cost (None = impassable); `step_cost(from,to)` adds `UPHILL_COST_PER_METER` × climb — use step_cost for anything that moves. `TILE_SIZE`=16 world units, `hash2` deterministic int hash.
18 18 - `src/noise.rs` seeded 2D gradient (Perlin-style, quintic fade) noise + `fbm` (octave i uses seed+i). Range [-1,1].
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`. Two layers of chunks (`Layer::Terrain` z=0, `Layer::Plants` z=0.5, 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, wood stock/goal + stored, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). 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, per-material stock/goal, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Progress bars for Work/ExpandStockpile (`progress_fraction`). 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, Chop{tree, progress}, Haul{pickup: Option<tile>, to}}`. `Reservations` keyed by `Target::{Tree, Pickup, Storage}(tile)` → entity, so colonists never share a target; cleared on WorldGenerated. `ColonyGoals{wood}` = demand. `choose_jobs` (SimSystems::Decide), for idle colonists (no job, empty path, IdleTimer done), in priority order: deliver what they're carrying (or drop it if no storage) → chop if `wood_wanted` (stock incl. carried + trees being chopped × WOOD_PER_TREE) → haul nearest loose item (not on a stockpile) to nearest stockpile tile with room (`find_haul`/`find_storage`) → wander. `run_jobs` (SimSystems::Act) per-task state machine returning `Step::{Continue, Done}`; Done releases all reservations and restarts the idle timer (short after work, 10 s after an ordered move). Chop walks adjacent, ticks to CHOP_TICKS, removes tree, `TileChanged`, drops wood. Haul walks onto the pickup tile, `take_items` up to min(CARRY_CAPACITY, room at dest), walks to dest, drops what fits (remainder stays in `Carrying` and gets delivered by the next choice). `Order{colonist, tile}` (Update, applies while paused): tree → ordered Chop; loose item → ordered Haul; else ordered MoveTo. Orders cancel the job but keep `Carrying`. Adding a job = Task variant + reserve_task arm + demand check in choose_jobs + run_jobs arm.
26 -- `src/items.rs` `Item{kind, 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 -- `src/zones.rs` `Zones` resource (stockpile tile set; only walkable dry land). New worlds get a 4x4 starting stockpile next to spawn. `ZoneTool` (Z toggles, Esc exits): left-drag adds, shift-drag removes (`zone_tool` input + `draw_zone_preview` gizmo, PostUpdate after `update_cursor`); `update_selection` is skipped while the tool is active. Rendered as tilemap `Layer::Zones` (z=0.3), refreshed on `resource_changed::<Zones>`.
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}}`. `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`) → `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 +- `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 +- `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/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
30 30

src/jobs.rs +42 -7

@@ -17,7 +17,7 @@ use crate::{
17 17 sim::SimSystems,
18 18 world::{Plant, Terrain, TileChanged, TilePos, WorldMap},
19 19 worldgen::WorldGenerated,
20 - zones::{Zones, plan_expansion},
20 + zones::{Zones, find_stockpile_site, new_stockpile_tiles, plan_expansion},
21 21 };
22 22
23 23 pub struct JobsPlugin;
@@ -361,13 +361,30 @@ pub fn find_expansion(
361 361 reservations: &Reservations,
362 362 items: &[(Item, TilePos)],
363 363 ) -> Option<Task> {
364 - if !reservations.is_free(Target::StockpileExpansion) || !stockpile_too_full(zones, items) {
364 + if !reservations.is_free(Target::StockpileExpansion) {
365 365 return None;
366 366 }
367 - let site = *plan_expansion(map, zones, EXPANSION_TILES).first()?;
367 + let site = if zones.has_stockpile() {
368 + if !stockpile_too_full(zones, items) {
369 + return None;
370 + }
371 + *plan_expansion(map, zones, EXPANSION_TILES).first()?
372 + } else {
373 + find_stockpile_site(map, central_item_tile(items)?)?
374 + };
368 375 Some(Task::ExpandStockpile { site, progress: 0 })
369 376 }
370 377
378 +fn central_item_tile(items: &[(Item, TilePos)]) -> Option<UVec2> {
379 + let tiles: Vec<Vec2> = items.iter().map(|(_, pos)| pos.0.as_vec2()).collect();
380 + let centroid = tiles.iter().sum::<Vec2>() / tiles.len().max(1) as f32;
381 + items.iter().map(|(_, pos)| pos.0).min_by(|a, b| {
382 + a.as_vec2()
383 + .distance_squared(centroid)
384 + .total_cmp(&b.as_vec2().distance_squared(centroid))
385 + })
386 +}
387 +
371 388 fn on_the_way(map: &WorldMap, jobs: impl Iterator<Item = Option<Job>>) -> HashMap<ItemKind, u32> {
372 389 let mut totals = HashMap::new();
373 390 for job in jobs.flatten() {
@@ -561,8 +578,12 @@ fn run_jobs(
561 578 } else {
562 579 *progress += 1;
563 580 if *progress >= EXPAND_TICKS {
564 - let tiles = plan_expansion(&map, &zones, EXPANSION_TILES);
565 - zones.add_tiles(&map, tiles);
581 + if zones.has_stockpile() {
582 + let tiles = plan_expansion(&map, &zones, EXPANSION_TILES);
583 + zones.add_tiles(&map, tiles);
584 + } else {
585 + zones.add_tiles(&map, new_stockpile_tiles(*site));
586 + }
566 587 Step::Done
567 588 } else {
568 589 Step::Continue
@@ -657,6 +678,7 @@ mod tests {
657 678 items::STACK_LIMIT,
658 679 sim::{SimPos, sim_pos},
659 680 world::Terrain,
681 + zones::NEW_STOCKPILE_SIZE,
660 682 };
661 683
662 684 const TICK: Duration = Duration::from_nanos(1_000_000_000 / 60);
@@ -904,13 +926,26 @@ mod tests {
904 926 }
905 927
906 928 #[test]
907 - fn without_a_stockpile_nothing_is_hauled() {
929 + fn colonists_found_a_stockpile_when_items_need_storing() {
908 930 let mut app = test_app(map_with_trees(&[]));
909 931 set_goals(&mut app, &[]);
910 932 put_wood(&mut app, UVec2::new(5, 5), 20);
911 933 spawn_colonist(&mut app, UVec2::new(2, 5));
934 + run_ticks(&mut app, 60 * 15);
935 + assert_eq!(
936 + stockpile_size(&app),
937 + (NEW_STOCKPILE_SIZE * NEW_STOCKPILE_SIZE) as usize
938 + );
939 + assert_eq!(stored(&mut app), 20);
940 + }
941 +
942 + #[test]
943 + fn no_stockpile_is_founded_while_there_is_nothing_to_store() {
944 + let mut app = test_app(map_with_trees(&[]));
945 + set_goals(&mut app, &[]);
946 + spawn_colonist(&mut app, UVec2::new(2, 5));
912 947 run_ticks(&mut app, 60 * 5);
913 - assert_eq!(items_at(&mut app), vec![(UVec2::new(5, 5), 20)]);
948 + assert_eq!(stockpile_size(&app), 0);
914 949 }
915 950
916 951 #[test]

src/zones.rs +46 -20

@@ -4,10 +4,11 @@ use bevy::prelude::*;
4 4
5 5 use crate::{
6 6 actions::Action,
7 + pathfinding::nearest_tile,
7 8 selection::{Cursor, update_cursor},
8 9 ui::PointerOverUi,
9 10 world::{TILE_SIZE, WorldMap},
10 - worldgen::{WorldGenerated, spawn_tiles},
11 + worldgen::WorldGenerated,
11 12 };
12 13
13 14 pub struct ZonesPlugin;
@@ -16,10 +17,7 @@ impl Plugin for ZonesPlugin {
16 17 fn build(&self, app: &mut App) {
17 18 app.init_resource::<Zones>()
18 19 .init_resource::<ZoneTool>()
19 - .add_systems(
20 - Update,
21 - place_starting_stockpile.run_if(on_message::<WorldGenerated>),
22 - )
20 + .add_systems(Update, clear_zones.run_if(on_message::<WorldGenerated>))
23 21 .add_systems(
24 22 PostUpdate,
25 23 (zone_tool, draw_zone_preview).chain().after(update_cursor),
@@ -27,8 +25,8 @@ impl Plugin for ZonesPlugin {
27 25 }
28 26 }
29 27
30 -const STARTING_STOCKPILE_OFFSET: IVec2 = IVec2::new(2, -2);
31 -const STARTING_STOCKPILE_SIZE: IVec2 = IVec2::new(4, 4);
28 +pub const NEW_STOCKPILE_SIZE: u32 = 4;
29 +const STOCKPILE_SITE_SEARCH_TILES: usize = 4000;
32 30 const PREVIEW_ADD_COLOR: Color = Color::srgba(0.4, 0.7, 1.0, 0.9);
33 31 const PREVIEW_REMOVE_COLOR: Color = Color::srgba(1.0, 0.4, 0.4, 0.9);
34 32
@@ -131,12 +129,29 @@ pub struct ZoneTool {
131 129 drag_start: Option<UVec2>,
132 130 }
133 131
134 -fn place_starting_stockpile(map: Res<WorldMap>, mut zones: ResMut<Zones>) {
132 +fn clear_zones(mut zones: ResMut<Zones>) {
135 133 *zones = Zones::default();
136 - if let Some(&spawn) = spawn_tiles(&map, 1).first() {
137 - let corner = spawn.as_ivec2() + STARTING_STOCKPILE_OFFSET;
138 - zones.add_stockpile(&map, corner, corner + STARTING_STOCKPILE_SIZE - 1);
139 - }
134 +}
135 +
136 +pub fn new_stockpile_tiles(corner: UVec2) -> impl Iterator<Item = UVec2> {
137 + (0..NEW_STOCKPILE_SIZE)
138 + .flat_map(move |y| (0..NEW_STOCKPILE_SIZE).map(move |x| corner + UVec2::new(x, y)))
139 +}
140 +
141 +fn is_open_square(map: &WorldMap, corner: UVec2) -> bool {
142 + new_stockpile_tiles(corner)
143 + .all(|t| can_zone(map, t.as_ivec2()) && map.plant(t.as_ivec2()).is_none())
144 +}
145 +
146 +pub fn find_stockpile_site(map: &WorldMap, near: UVec2) -> Option<UVec2> {
147 + nearest_tile(map, near, STOCKPILE_SITE_SEARCH_TILES, |p| {
148 + is_open_square(map, p)
149 + })
150 + .or_else(|| {
151 + nearest_tile(map, near, STOCKPILE_SITE_SEARCH_TILES, |p| {
152 + can_zone(map, p.as_ivec2())
153 + })
154 + })
140 155 }
141 156
142 157 fn removing(keys: &ButtonInput<KeyCode>) -> bool {
@@ -319,17 +334,28 @@ mod tests {
319 334 }
320 335
321 336 #[test]
322 - fn new_worlds_start_with_a_stockpile() {
337 + fn new_worlds_start_without_stockpiles() {
338 + let map = WorldMap::filled(UVec2::splat(8), Terrain::Grass);
323 339 let mut app = App::new();
324 - app.insert_resource(crate::worldgen::generate(UVec2::new(96, 64), 1))
325 - .init_resource::<Zones>()
340 + app.init_resource::<Zones>()
326 341 .add_message::<WorldGenerated>()
327 - .add_systems(
328 - Update,
329 - place_starting_stockpile.run_if(on_message::<WorldGenerated>),
330 - );
342 + .add_systems(Update, clear_zones.run_if(on_message::<WorldGenerated>));
343 + app.world_mut()
344 + .resource_mut::<Zones>()
345 + .add_stockpile(&map, IVec2::ZERO, IVec2::ONE);
331 346 app.world_mut().write_message(WorldGenerated);
332 347 app.update();
333 - assert!(app.world().resource::<Zones>().stockpile_tiles().count() > 8);
348 + assert!(!app.world().resource::<Zones>().has_stockpile());
349 + }
350 +
351 + #[test]
352 + fn new_stockpiles_go_on_the_nearest_fully_open_ground() {
353 + let mut map = WorldMap::filled(UVec2::splat(12), Terrain::Grass);
354 + for y in 0..12 {
355 + map.set_plant(UVec2::new(4, y), Some(crate::world::Plant::Tree));
356 + map.set(UVec2::new(7, y), Terrain::DeepWater);
357 + }
358 + let site = find_stockpile_site(&map, UVec2::new(5, 5)).unwrap();
359 + assert!(new_stockpile_tiles(site).all(|t| t.x < 4));
334 360 }
335 361 }

tasks.md +5 -3

@@ -15,13 +15,15 @@
15 15 - [x] Job system: autonomous job choice from colony demand, reservations, manual orders override
16 16 - [x] Woodcutting (auto when wood < goal, or right-click a tree), wood item piles
17 17 - [x] Stockpile zones (Z tool, starting stockpile) and hauling (auto, or right-click loose items)
18 +- [x] Mining (stone/iron/gold → gravel floor), per-material colony goals driving chopping and mining
19 +- [x] Colonists found a stockpile when items need storing and expand it when nearly full (no starting stockpile)
18 20 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
19 21
20 22 ## Planned
21 -- [ ] Jobs: mining (stone/ore), building
23 +- [ ] Jobs: building (consumes materials, so goals become real demand)
22 24 - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
23 -- [ ] Designations (mark trees/rock for anyone to work) and per-colonist work priorities (job order is fixed: deliver > chop > haul > wander)
24 -- [ ] UI to edit colony goals (e.g. wood target)
25 +- [ ] 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)
26 +- [ ] UI to edit colony goals (material targets)
25 27 - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky)
26 28 - [ ] Colonist panel / better info UI (info panel is plain text)
27 29 - [ ] Zoom +/- buttons (camera has no buttons; WASD/drag/scroll only)