Add adaptive max simulation speed

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 21:43 UTC
commit
dae07f6ccc95d830cd5d8c6b3f940179fafa29c5
parent
047e40b9a3
tree
browse at this commit

6 files changed +184 -19

CLAUDE.md +5 -5

@@ -20,22 +20,22 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
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 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 -- `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.
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 (bitmap visited set). `nearest_of(start, max_tiles, &HashSet targets)` = nearest_tile over a precollected target set, returning None at once when no target is within `search_reach(max_tiles)` Chebyshev — use it instead of predicates that scan all items per visited tile (that was the dominant per-tick cost). Paths exclude start, include goal.
24 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).
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`) → if food wanted: `find_work` restricted to food yields (harvest/forage) then `find_sowing` → `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 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 -- `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.
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/Max = 1x/3x/6x/adaptive via `Time<Virtual>::set_relative_speed`; keys 1/2/3/4 set speed + unpause, Space toggles pause. Max: `SimClock` times the fixed loop (start/stop systems around RunFixedMainLoop) and whole frames; `adapt_max_speed` (`next_max_speed`) aims for ticks filling `MAX_SPEED_BUDGET_SHARE` of a 30 fps frame from measured cost per tick, backs off proportionally on slow frames, smoothed, rise ≤1.5x/frame, clamped `MAX_SPEED_LIMITS`; `max_delta` = frame budget × speed in Max (prevents death spiral), 250 ms otherwise. Info panel shows `Max (Nx)`. 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` 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.
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, but only when unreserved food exists (`food_to_fetch`), else colonists thrashed their job every tick. `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.
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. Stock counts only ground items `in_colony` (Chebyshev ≤ `COLONY_RADIUS` 60 of home; colonist searches cover it, tested) plus carried ones — far stray stacks used to satisfy goals while unreachable (jobs, info panel and arrivals all use this). `ColonyGoals::fixed` for tests.
39 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.
40 40 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
41 41

src/actions.rs +3 -1

@@ -22,11 +22,12 @@ pub enum Action {
22 22 ToggleUi,
23 23 }
24 24
25 -pub const KEYBINDS: [(KeyCode, Action); 9] = [
25 +pub const KEYBINDS: [(KeyCode, Action); 10] = [
26 26 (KeyCode::Space, Action::TogglePause),
27 27 (KeyCode::Digit1, Action::SetSpeed(SimSpeed::Normal)),
28 28 (KeyCode::Digit2, Action::SetSpeed(SimSpeed::Fast)),
29 29 (KeyCode::Digit3, Action::SetSpeed(SimSpeed::Fastest)),
30 + (KeyCode::Digit4, Action::SetSpeed(SimSpeed::Max)),
30 31 (KeyCode::KeyZ, Action::ToggleStockpileTool),
31 32 (KeyCode::KeyF, Action::ToggleFarmTool),
32 33 (KeyCode::Escape, Action::Cancel),
@@ -41,6 +42,7 @@ impl Action {
41 42 Action::SetSpeed(SimSpeed::Normal) => "1x",
42 43 Action::SetSpeed(SimSpeed::Fast) => "3x",
43 44 Action::SetSpeed(SimSpeed::Fastest) => "6x",
45 + Action::SetSpeed(SimSpeed::Max) => "Max",
44 46 Action::ToggleStockpileTool => "Stockpile",
45 47 Action::ToggleFarmTool => "Farm",
46 48 Action::Cancel => "Cancel",

src/selection.rs +8 -7

@@ -395,11 +395,11 @@ pub fn describe_item(item: &Item) -> String {
395 395 format!(", {:?} x{}{spoil}", item.kind, item.count)
396 396 }
397 397
398 -pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {
399 - if paused {
400 - "PAUSED".into()
401 - } else {
402 - format!("{}x", speed.multiplier())
398 +pub fn describe_speed(speed: SimSpeed, paused: bool, relative: f32) -> String {
399 + match (paused, speed) {
400 + (true, _) => "PAUSED".into(),
401 + (false, SimSpeed::Max) => format!("Max ({relative:.0}x)"),
402 + (false, _) => format!("{}x", speed.multiplier()),
403 403 }
404 404 }
405 405
@@ -719,8 +719,9 @@ mod tests {
719 719
720 720 #[test]
721 721 fn describe_speed_shows_multiplier_or_paused() {
722 - assert_eq!(describe_speed(SimSpeed::Fastest, false), "6x");
723 - assert_eq!(describe_speed(SimSpeed::Fast, true), "PAUSED");
722 + assert_eq!(describe_speed(SimSpeed::Fastest, false, 6.0), "6x");
723 + assert_eq!(describe_speed(SimSpeed::Fast, true, 3.0), "PAUSED");
724 + assert_eq!(describe_speed(SimSpeed::Max, false, 123.4), "Max (123x)");
724 725 }
725 726
726 727 fn view(job: Option<Job>, steps_left: usize, carrying: Option<Item>) -> ColonistView<'static> {

src/sim.rs +162 -4

@@ -1,4 +1,6 @@
1 -use bevy::{app::RunFixedMainLoopSystems, prelude::*};
1 +use std::time::Duration;
2 +
3 +use bevy::{app::RunFixedMainLoopSystems, platform::time::Instant, prelude::*};
2 4
3 5 use crate::actions::Action;
4 6
@@ -18,11 +20,16 @@ impl Plugin for SimPlugin {
18 20 FixedUpdate,
19 21 (SimSystems::Decide, SimSystems::Act, SimSystems::Move).chain(),
20 22 )
21 - .add_systems(Update, speed_controls)
23 + .init_resource::<SimClock>()
24 + .add_systems(Update, (speed_controls, adapt_max_speed).chain())
22 25 .add_systems(FixedFirst, (advance_tick, remember_positions))
23 26 .add_systems(
24 27 RunFixedMainLoop,
25 - interpolate_transforms.in_set(RunFixedMainLoopSystems::AfterFixedMainLoop),
28 + (
29 + start_sim_clock.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
30 + (stop_sim_clock, interpolate_transforms)
31 + .in_set(RunFixedMainLoopSystems::AfterFixedMainLoop),
32 + ),
26 33 );
27 34 }
28 35 }
@@ -70,6 +77,7 @@ pub enum SimSpeed {
70 77 Normal,
71 78 Fast,
72 79 Fastest,
80 + Max,
73 81 }
74 82
75 83 impl SimSpeed {
@@ -78,10 +86,82 @@ impl SimSpeed {
78 86 SimSpeed::Normal => 1.0,
79 87 SimSpeed::Fast => 3.0,
80 88 SimSpeed::Fastest => 6.0,
89 + SimSpeed::Max => MAX_SPEED_START,
81 90 }
82 91 }
83 92 }
84 93
94 +pub const MAX_SPEED_START: f32 = 10.0;
95 +pub const MAX_SPEED_LIMITS: (f32, f32) = (6.0, 2000.0);
96 +pub const MAX_SPEED_FRAME_SECS: f32 = 1.0 / 30.0;
97 +const DEFAULT_MAX_DELTA: Duration = Duration::from_millis(250);
98 +
99 +pub fn max_mode_max_delta(speed: f32) -> Duration {
100 + Duration::from_secs_f32(MAX_SPEED_FRAME_SECS * speed)
101 +}
102 +
103 +const MAX_SPEED_BUDGET_SHARE: f32 = 0.6;
104 +const MAX_SPEED_SMOOTHING: f32 = 0.3;
105 +const MAX_SPEED_RISE: f32 = 1.5;
106 +
107 +pub fn next_max_speed(current: f32, clock: &SimClock, ticks_in_frame: u64) -> f32 {
108 + let sim_target = if ticks_in_frame == 0 {
109 + current * 1.1
110 + } else {
111 + let secs_per_tick = clock.last_sim_secs / ticks_in_frame as f32;
112 + let affordable_ticks = MAX_SPEED_FRAME_SECS * MAX_SPEED_BUDGET_SHARE / secs_per_tick;
113 + affordable_ticks / (TICKS_PER_SECOND as f32 * MAX_SPEED_FRAME_SECS)
114 + };
115 + let frame_target = current * MAX_SPEED_FRAME_SECS / clock.last_frame_secs.max(f32::EPSILON);
116 + let target = if clock.last_frame_secs > MAX_SPEED_FRAME_SECS {
117 + sim_target.min(frame_target)
118 + } else {
119 + sim_target
120 + };
121 + (current + (target.min(current * MAX_SPEED_RISE) - current) * MAX_SPEED_SMOOTHING)
122 + .clamp(MAX_SPEED_LIMITS.0, MAX_SPEED_LIMITS.1)
123 +}
124 +
125 +#[derive(Resource, Default)]
126 +pub struct SimClock {
127 + started: Option<Instant>,
128 + frame_started: Option<Instant>,
129 + pub last_sim_secs: f32,
130 + pub last_frame_secs: f32,
131 +}
132 +
133 +fn start_sim_clock(mut clock: ResMut<SimClock>) {
134 + let now = Instant::now();
135 + if let Some(previous) = clock.frame_started {
136 + clock.last_frame_secs = now.duration_since(previous).as_secs_f32();
137 + }
138 + clock.frame_started = Some(now);
139 + clock.started = Some(now);
140 +}
141 +
142 +fn stop_sim_clock(mut clock: ResMut<SimClock>) {
143 + if let Some(started) = clock.started.take() {
144 + clock.last_sim_secs = started.elapsed().as_secs_f32();
145 + }
146 +}
147 +
148 +fn adapt_max_speed(
149 + speed: Res<SimSpeed>,
150 + clock: Res<SimClock>,
151 + tick: Res<SimTick>,
152 + mut time: ResMut<Time<Virtual>>,
153 + mut last_tick: Local<u64>,
154 +) {
155 + let ticks = tick.0.saturating_sub(*last_tick);
156 + *last_tick = tick.0;
157 + if *speed != SimSpeed::Max || time.is_paused() {
158 + return;
159 + }
160 + let next = next_max_speed(time.relative_speed(), &clock, ticks);
161 + time.set_relative_speed(next);
162 + time.set_max_delta(max_mode_max_delta(next));
163 +}
164 +
85 165 fn advance_tick(mut tick: ResMut<SimTick>) {
86 166 tick.0 += 1;
87 167 }
@@ -98,6 +178,11 @@ fn speed_controls(
98 178 Action::SetSpeed(new_speed) => {
99 179 *speed = new_speed;
100 180 time.set_relative_speed(new_speed.multiplier());
181 + time.set_max_delta(if new_speed == SimSpeed::Max {
182 + max_mode_max_delta(MAX_SPEED_START)
183 + } else {
184 + DEFAULT_MAX_DELTA
185 + });
101 186 time.unpause();
102 187 }
103 188 _ => {}
@@ -172,8 +257,10 @@ mod tests {
172 257 let mut app = App::new();
173 258 app.add_message::<Action>()
174 259 .init_resource::<Time<Virtual>>()
260 + .init_resource::<SimTick>()
261 + .init_resource::<SimClock>()
175 262 .init_resource::<SimSpeed>()
176 - .add_systems(Update, speed_controls);
263 + .add_systems(Update, (speed_controls, adapt_max_speed).chain());
177 264 app
178 265 }
179 266
@@ -193,6 +280,77 @@ mod tests {
193 280 );
194 281 }
195 282
283 + fn clock(sim_secs: f32, frame_secs: f32) -> SimClock {
284 + SimClock {
285 + last_sim_secs: sim_secs,
286 + last_frame_secs: frame_secs,
287 + ..default()
288 + }
289 + }
290 +
291 + // The controller aims at the speed the measured cost per tick can afford instead of nudging
292 + // by a fixed step per frame: a fixed step overshot for many fast frames and then needed many
293 + // slow frames to recover, freezing the browser for seconds at a time. It measures time spent
294 + // in the fixed loop rather than whole frames, because whole frames include waiting for vsync,
295 + // which made cheap ticks look expensive and pinned the speed at its minimum.
296 + #[test]
297 + fn max_speed_settles_on_what_the_machine_can_afford() {
298 + let secs_per_tick = 0.0005;
299 + let mut speed = MAX_SPEED_START;
300 + for _ in 0..60 {
301 + let ticks = (speed * TICKS_PER_SECOND as f32 * MAX_SPEED_FRAME_SECS) as u64;
302 + let sim = ticks as f32 * secs_per_tick;
303 + speed = next_max_speed(speed, &clock(sim, sim.max(1.0 / 120.0)), ticks);
304 + }
305 + let frame = speed * TICKS_PER_SECOND as f32 * MAX_SPEED_FRAME_SECS * secs_per_tick;
306 + assert!(
307 + (frame - MAX_SPEED_FRAME_SECS * MAX_SPEED_BUDGET_SHARE).abs() < 0.002,
308 + "{frame}"
309 + );
310 + }
311 +
312 + // Work outside the fixed loop (rendering, tilemap refreshes) also grows with speed, so slow
313 + // frames back the speed off even when ticks themselves look cheap.
314 + #[test]
315 + fn max_speed_backs_off_when_frames_are_slow() {
316 + let slow = clock(0.001, MAX_SPEED_FRAME_SECS * 4.0);
317 + assert!(next_max_speed(400.0, &slow, 800) < 400.0);
318 + }
319 +
320 + // Browsers coarsen timers, so a frame's ticks can measure as taking no time at all; that
321 + // must not launch the speed into a range the next frames cannot afford.
322 + #[test]
323 + fn max_speed_rises_gradually_on_suspiciously_cheap_ticks() {
324 + assert!(next_max_speed(10.0, &clock(0.0, 0.01), 7) <= 10.0 * MAX_SPEED_RISE);
325 + }
326 +
327 + #[test]
328 + fn max_speed_stays_within_limits() {
329 + assert_eq!(
330 + next_max_speed(MAX_SPEED_LIMITS.1, &clock(0.0001, 0.001), 1000),
331 + MAX_SPEED_LIMITS.1
332 + );
333 + assert_eq!(
334 + next_max_speed(MAX_SPEED_LIMITS.0, &clock(10.0, 10.0), 10),
335 + MAX_SPEED_LIMITS.0
336 + );
337 + assert!(next_max_speed(MAX_SPEED_START, &clock(0.0, 0.01), 0) > MAX_SPEED_START);
338 + }
339 +
340 + // Bevy drops virtual time beyond max_delta per frame; at high speeds that cap would silently
341 + // limit the simulation, so Max mode lifts it and other speeds restore it.
342 + #[test]
343 + fn max_speed_lifts_the_per_frame_time_cap() {
344 + let mut app = controls_app();
345 + trigger(&mut app, Action::SetSpeed(SimSpeed::Max));
346 + assert!(app.world().resource::<Time<Virtual>>().max_delta() > DEFAULT_MAX_DELTA);
347 + trigger(&mut app, Action::SetSpeed(SimSpeed::Normal));
348 + assert_eq!(
349 + app.world().resource::<Time<Virtual>>().max_delta(),
350 + DEFAULT_MAX_DELTA
351 + );
352 + }
353 +
196 354 #[test]
197 355 fn choosing_a_speed_while_paused_resumes() {
198 356 let mut app = controls_app();

src/ui.rs +2 -1

@@ -34,11 +34,12 @@ impl Plugin for HudPlugin {
34 34 }
35 35 }
36 36
37 -const TOOLBAR: [Action; 9] = [
37 +const TOOLBAR: [Action; 10] = [
38 38 Action::TogglePause,
39 39 Action::SetSpeed(SimSpeed::Normal),
40 40 Action::SetSpeed(SimSpeed::Fast),
41 41 Action::SetSpeed(SimSpeed::Fastest),
42 + Action::SetSpeed(SimSpeed::Max),
42 43 Action::ToggleStockpileTool,
43 44 Action::ToggleFarmTool,
44 45 Action::Cancel,

tasks.md +4 -1

@@ -8,7 +8,7 @@
8 8 - [x] Tile hover + selection (outlines, info text, Esc to clear)
9 9 - [x] A* pathfinding with terrain costs
10 10 - [x] Colonists: wander, select, right-click move orders, path display
11 -- [x] Simulation tick (60 Hz fixed timestep), game clock, speeds 1x/3x/6x (keys 1-3), pause (Space), render interpolation
11 +- [x] Simulation tick (60 Hz fixed timestep), game clock, speeds 1x/3x/6x and adaptive Max (keys 1-4; Max runs as fast as frames allow), pause (Space), render interpolation
12 12 - [x] Procedural world generation (noise elevation/fertility, seeds, R to regenerate, spawn in largest walkable region)
13 13 - [x] Heightmap: hill shading, uphill movement cost, height in tile info
14 14 - [x] Rivers (flow field), iron/gold ore veins, trees
@@ -24,6 +24,8 @@
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 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
27 +- [x] Performance: food/material searches use precollected tile sets and skip out-of-reach stacks; starving colonists without food keep working
28 +- [x] Food security: stock counts only items within colony reach; harvesting, foraging and sowing take priority when food is short
27 29 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
28 30
29 31 ## Planned
@@ -37,6 +39,7 @@
37 39 - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
38 40 - [ ] 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)
39 41 - [ ] UI to view/override learned stock targets
42 +- [ ] Sim performance: per-tile scans in `BuildContext::buildable` and site searches; wasm ticks still ~1 ms each with a full colony
40 43 - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky)
41 44 - [ ] Colonist panel / better info UI (info panel is plain text)
42 45 - [ ] Zoom +/- buttons (camera has no buttons; WASD/drag/scroll only)