Remove colonist cap; cheaper job searches for large colonies
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 14:15 UTC
- commit
- 28fbce3a75f57dfc47fff20225952d8471aec596
- parent
- bef876c2ec
- tree
- browse at this commit
9 files changed +182 -67
| CLAUDE.md | +4 -2 | |
| src/colonist.rs | +28 -3 | |
| src/family.rs | +5 -9 | |
| src/jobs/mod.rs | +92 -44 | |
| src/jobs/tests.rs | +1 -1 | |
| src/pathfinding.rs | +40 -0 | |
| src/population.rs | +5 -7 | |
| src/sim.rs | +6 -1 | |
| tasks.md | +1 -0 |
CLAUDE.md +4 -2
| @@ -41,7 +41,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 41 | 41 | - `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. |
| 42 | 42 | - `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. |
| 43 | 43 | - `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. |
| 44 | -- `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 20, 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. | |
| 44 | +- `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 = usize::MAX, i.e. uncapped; stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, `free_name_index`; `colonist_name` numbers repeats past the 26 NAMES, e.g. "Ada 2") appears at an `edge_tile` with an unordered MoveTo home. | |
| 45 | 45 | - `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): homes of extinct families move to `Homes.vacant` (serde default; `all()`, `is_home()` cover owned + vacant; `owner()` owned only, so vacant beds count as communal for sleeping); `abandoned_home` adopts enclosed HOME_INTERIOR rooms around unowned beds as vacant (old saves); couples without a home take the vacant home nearest the colony before a new one is planned. Floods `reachable` from colony home (if walkable) and drops blueprints with no reachable tile in their 3×3 (sealed pockets inflated goals forever). Per home: `door_repair` (no door/planned door whose `outward` tile is reachable → Door on an empty door site, else Deconstruct the best wall; runs even with blueprints pending), else roofs (`roofs_needed`), statue + `BuildKind::Floor` on unfloored interior tiles (`floored`; doesn't overwrite other blueprints) for owned built homes. `surplus_beds`: reachable communal beds beyond homeless adults → Deconstruct (nearest kept). New homes via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt unclaimed AND walkable — homes used to be packed wall to wall with doors facing a neighbour's wall) → wall/door/bed blueprints (door nearest colony home). `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes (owned or vacant). Selection text shows partner / child days left. |
| 46 | 46 | - `src/mood.rs` `Mood(f32)` (default 0.6) eases hourly (1/MOOD_HOURS) toward `mood_target(MoodFactors)`: hunger, rest, last meal cooked/raw within a day, `Sick`, partner, built family home, grief (a `Losses` tick within 2 days; starvation and old age push losses). `Ate{colonist, kind}` message from run_jobs' Eat completion → `digest`: `LastMeal`, raw food rolls `poison_chance` (meat 15%, berries/veg 4%) → `Sick{until}` 12 h (work rate halved in run_jobs). `leave`: adults below LEAVE_MOOD for LEAVE_AFTER_HOURS (48, `Unhappy` hours) leave (despawn, drop load). `Age{born}` (1 day = 1 year; `assign_age` gives new adults 18–40, children 0); `old_age` daily: from OLD_AGE 60, death chance 3%/year over. Selection shows mood/age/sick. |
| 47 | 47 | - Storage rules (zones.rs): `Zones.rules` per stockpile tile `StorageRule{refused: Vec<Stock>, priority: StoragePriority::{Low, Normal, High}}` (absent = default), `accepts(tile, kind)`, `update_region_rule` applies to the 4-connected stockpile region; removing tiles drops rules. `find_storage` tries priorities High→Normal→Low, nearest accepting free tile with room; `find_haul` treats items as loose when their tile doesn't accept them or a higher-priority accepting tile with room exists (`storage_candidates`), so stock moves up the priorities and settles. `ColonyGoals` overrides (`adjust` ±, `reset`, `is_overridden`) win over learned goals. |
| @@ -63,7 +63,9 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 63 | 63 | - Fixed schedules (FixedFirst..FixedLast) use `SingleThreadedExecutor`: with hundreds of tiny ticks per frame the multi-threaded executor spent most time parking/handing off between threads (5.6× faster natively; wasm was already single-threaded). |
| 64 | 64 | - A* (`astar_limited`) reuses thread-local buffers with generation stamps (no 800 KB clear per call); `find_nearby_path(.., WANDER_SEARCH 800)` caps wander searches. Failed wander attempts (animals, colonists) still restart the idle timer and failed flees wait FLEE_RETRY_SECS — retrying every tick for boxed-in animals made late games ~15× slower per tick. |
| 65 | 65 | - plan_homes caches its reach flood by (`shape_version`, colony home, size); `WorldMap::set` bumps `shape_version` when walkability changes. `GatherMemo` also memoises forage (food mask) and haul failures (HAUL_RETRY_TICKS 120, reset when Zones change — checked before choose_jobs' early return). choose_jobs builds `occupied` lazily; run_jobs only collects mover positions while someone builds a wall. |
| 66 | -- 20-colonist save natively: ~17–20 µs per tick, ~23 game days per 10 s. | |
| 66 | +- 20-colonist save natively: ~17–20 µs per tick, ~23 game days per 10 s. Uncapped, that save grows to ~40–46 colonists (births vs old age at 60) at ~36 µs per tick, ~12 game days per 10 s. | |
| 67 | +- `ColonyReach::nearest(map, from, max_tiles, targets)`: when `from` is inside the colony reach, picks the nearest reachable target by distance (no flood); else falls back to `nearest_of`. Used by find_haul, find_storage (orders pass an empty reach), material_source, input_source. Cooking/toolmaking check stations before searching inputs; find_blueprint_work caches material lookups per call, has a reach precheck and a failure memo (reset when Blueprints change). Stock totals are one pass over items; the starving food-tile set is built lazily. | |
| 68 | +- `ColonyLog::push` also logs at debug level; run benchmarks with `RUST_LOG=info,simulator=debug` to see colony events. | |
| 67 | 69 | - Browser timings are only valid while the pane is visible: a hidden/popped-out-behind pane throttles rAF to ~1 Hz (check `tabs_context`); otherwise measure natively (temporary Update system logging SimTick per 10 s real time). |
| 68 | 70 | - Searches and A* share `can_step` (no corner cutting): a target the BFS could reach diagonally but A* couldn't made A* exhaust the map (~10 ms) many times a second on a 50-day save. Mined yields drop on the miner's tile when the site is only diagonally reachable. |
| 69 | 71 | - Profiling a saved colony: copy the browser save to `simulator-save.json` (gitignored; the native build reads it with F9), temporarily add a Startup system writing `Action::Load` + `SetSpeed(Max)`. `sample` sometimes captures only a handful of samples on the first try — rerun. |
src/colonist.rs +28 -3
| @@ -35,6 +35,20 @@ pub const NAMES: [&str; 26] = [ | ||
| 35 | 35 | "Wout", "Xem", "Yara", "Zeno", |
| 36 | 36 | ]; |
| 37 | 37 | |
| 38 | +pub fn colonist_name(index: usize) -> String { | |
| 39 | + let base = NAMES[index % NAMES.len()]; | |
| 40 | + match index / NAMES.len() { | |
| 41 | + 0 => base.to_string(), | |
| 42 | + n => format!("{base} {}", n + 1), | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +pub fn free_name_index<'a>(taken: impl Iterator<Item = &'a str> + Clone) -> usize { | |
| 47 | + (0..) | |
| 48 | + .find(|&i| !taken.clone().any(|name| name == colonist_name(i))) | |
| 49 | + .unwrap_or(0) | |
| 50 | +} | |
| 51 | + | |
| 38 | 52 | const COLORS: [Color; 8] = [ |
| 39 | 53 | Color::srgb(0.9, 0.3, 0.3), |
| 40 | 54 | Color::srgb(0.3, 0.5, 0.95), |
| @@ -67,11 +81,11 @@ pub fn spawn_colonist( | ||
| 67 | 81 | index: usize, |
| 68 | 82 | tile: UVec2, |
| 69 | 83 | ) -> Entity { |
| 70 | - let name = NAMES[index % NAMES.len()]; | |
| 84 | + let name = colonist_name(index); | |
| 71 | 85 | commands |
| 72 | 86 | .spawn(( |
| 73 | - Name::new(name), | |
| 74 | - Colonist { name: name.into() }, | |
| 87 | + Name::new(name.clone()), | |
| 88 | + Colonist { name }, | |
| 75 | 89 | TilePos(tile), |
| 76 | 90 | Mesh2d(assets.mesh.clone()), |
| 77 | 91 | MeshMaterial2d(materials.add(COLORS[index % COLORS.len()])), |
| @@ -201,6 +215,17 @@ pub fn step_towards(pos: Vec2, target: Vec2, max_distance: f32) -> (Vec2, bool) | ||
| 201 | 215 | #[cfg(test)] |
| 202 | 216 | mod tests { |
| 203 | 217 | use super::*; |
| 218 | + | |
| 219 | + // The colony has no size cap, so once the name list runs out names repeat with a number. | |
| 220 | + #[test] | |
| 221 | + fn names_stay_unique_past_the_name_list() { | |
| 222 | + assert_eq!(colonist_name(0), "Ada"); | |
| 223 | + assert_eq!(colonist_name(NAMES.len()), "Ada 2"); | |
| 224 | + let taken = ["Ada".to_string(), "Bram".to_string()]; | |
| 225 | + assert_eq!(free_name_index(taken.iter().map(String::as_str)), 2); | |
| 226 | + let all: Vec<String> = (0..NAMES.len()).map(colonist_name).collect(); | |
| 227 | + assert_eq!(free_name_index(all.iter().map(String::as_str)), NAMES.len()); | |
| 228 | + } | |
| 204 | 229 | use crate::world::Terrain; |
| 205 | 230 | use std::time::Duration; |
| 206 | 231 | |
src/family.rs +5 -9
| @@ -5,7 +5,7 @@ use bevy::prelude::*; | ||
| 5 | 5 | |
| 6 | 6 | use crate::{ |
| 7 | 7 | building::{Blueprints, BuildKind, enclosed_room}, |
| 8 | - colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist}, | |
| 8 | + colonist::{Colonist, ColonistAssets, colonist_name, free_name_index, spawn_colonist}, | |
| 9 | 9 | economy::in_colony, |
| 10 | 10 | items::{Item, ItemAssets}, |
| 11 | 11 | pathfinding::{ColonyReach, Reach, nearest_tile}, |
| @@ -602,7 +602,7 @@ fn births( | ||
| 602 | 602 | .map(|(item, _)| item), |
| 603 | 603 | ); |
| 604 | 604 | let mut born = 0; |
| 605 | - let mut taken: Vec<&str> = colonists.iter().map(|c| c.0.name.as_str()).collect(); | |
| 605 | + let mut taken: Vec<String> = colonists.iter().map(|c| c.0.name.clone()).collect(); | |
| 606 | 606 | let mut families: Vec<(&u32, &Home)> = homes.by_family.iter().collect(); |
| 607 | 607 | families.sort_by_key(|(family, _)| **family); |
| 608 | 608 | for (&family, home) in families { |
| @@ -628,12 +628,8 @@ fn births( | ||
| 628 | 628 | if rng.range_f32(0.0..1.0) >= BIRTH_CHANCE { |
| 629 | 629 | continue; |
| 630 | 630 | } |
| 631 | - let index = (0..NAMES.len()) | |
| 632 | - .find(|&i| !taken.contains(&NAMES[i])) | |
| 633 | - .unwrap_or(count + born); | |
| 634 | - if let Some(name) = NAMES.get(index) { | |
| 635 | - taken.push(name); | |
| 636 | - } | |
| 631 | + let index = free_name_index(taken.iter().map(String::as_str)); | |
| 632 | + taken.push(colonist_name(index)); | |
| 637 | 633 | let tile = home |
| 638 | 634 | .interior() |
| 639 | 635 | .find(|t| !home.bed_tiles().contains(t)) |
| @@ -659,7 +655,7 @@ fn births( | ||
| 659 | 655 | "{} and {} had a child, {}", |
| 660 | 656 | parents[0], |
| 661 | 657 | parents[1], |
| 662 | - NAMES[index % NAMES.len()] | |
| 658 | + colonist_name(index) | |
| 663 | 659 | ), |
| 664 | 660 | ); |
| 665 | 661 | } |
src/jobs/mod.rs +92 -44
| @@ -18,7 +18,7 @@ use crate::{ | ||
| 18 | 18 | food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food}, |
| 19 | 19 | items::{ |
| 20 | 20 | CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, STACK_LIMIT, drop_items, |
| 21 | - room_for, stock, take_items, | |
| 21 | + room_for, take_items, | |
| 22 | 22 | }, |
| 23 | 23 | light::{ |
| 24 | 24 | LIGHT_LOOKAHEAD_TICKS, LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from, |
| @@ -28,7 +28,7 @@ use crate::{ | ||
| 28 | 28 | needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep}, |
| 29 | 29 | pathfinding::{ |
| 30 | 30 | ColonyReach, WANDER_SEARCH, can_step, find_nearby_path, find_path, find_path_adjacent, |
| 31 | - is_adjacent, nearest_of, nearest_tile, | |
| 31 | + is_adjacent, nearest_of, nearest_tile, search_reach, | |
| 32 | 32 | }, |
| 33 | 33 | rng::SimRng, |
| 34 | 34 | sim::{SimSystems, SimTick, hour_of_day}, |
| @@ -452,11 +452,19 @@ fn handle_orders( | ||
| 452 | 452 | progress: 0, |
| 453 | 453 | }) |
| 454 | 454 | } else if let Some((item, _)) = loose_item { |
| 455 | - find_storage(&map, &zones, &reservations, &items, order.tile, item.kind).map(|to| { | |
| 456 | - Task::Haul { | |
| 457 | - pickup: Some(order.tile), | |
| 458 | - to, | |
| 459 | - } | |
| 455 | + let anywhere = ColonyReach::default(); | |
| 456 | + find_storage( | |
| 457 | + &map, | |
| 458 | + &zones, | |
| 459 | + &reservations, | |
| 460 | + &anywhere, | |
| 461 | + &items, | |
| 462 | + order.tile, | |
| 463 | + item.kind, | |
| 464 | + ) | |
| 465 | + .map(|to| Task::Haul { | |
| 466 | + pickup: Some(order.tile), | |
| 467 | + to, | |
| 460 | 468 | }) |
| 461 | 469 | } else { |
| 462 | 470 | None |
| @@ -539,6 +547,7 @@ pub struct GatherMemo { | ||
| 539 | 547 | gather: Memo, |
| 540 | 548 | forage: Memo, |
| 541 | 549 | haul: Memo, |
| 550 | + build: Memo, | |
| 542 | 551 | } |
| 543 | 552 | |
| 544 | 553 | pub fn find_work( |
| @@ -609,6 +618,7 @@ pub fn find_storage<'a>( | ||
| 609 | 618 | map: &WorldMap, |
| 610 | 619 | zones: &Zones, |
| 611 | 620 | reservations: &Reservations, |
| 621 | + reach: &ColonyReach, | |
| 612 | 622 | items: impl IntoIterator<Item = (&'a Item, &'a TilePos)> + Clone, |
| 613 | 623 | from: UVec2, |
| 614 | 624 | kind: ItemKind, |
| @@ -620,7 +630,7 @@ pub fn find_storage<'a>( | ||
| 620 | 630 | let candidates: HashSet<UVec2> = storage_candidates(zones, reservations, &stacks, kind) |
| 621 | 631 | .filter(|&t| zones.rule(t).priority == priority) |
| 622 | 632 | .collect(); |
| 623 | - nearest_of(map, from, SEARCH_TILES, &candidates) | |
| 633 | + reach.nearest(map, from, SEARCH_TILES, &candidates) | |
| 624 | 634 | }) |
| 625 | 635 | } |
| 626 | 636 | |
| @@ -664,10 +674,10 @@ pub fn find_haul( | ||
| 664 | 674 | if loose.is_empty() { |
| 665 | 675 | return None; |
| 666 | 676 | } |
| 667 | - let pickup = nearest_of(map, from, SEARCH_TILES, &loose)?; | |
| 677 | + let pickup = reach.nearest(map, from, SEARCH_TILES, &loose)?; | |
| 668 | 678 | let (item, _) = items.iter().find(|(_, pos)| pos.0 == pickup)?; |
| 669 | 679 | let refs = items.iter().map(|(i, p)| (i, p)); |
| 670 | - let to = find_storage(map, zones, reservations, refs, pickup, item.kind)?; | |
| 680 | + let to = find_storage(map, zones, reservations, reach, refs, pickup, item.kind)?; | |
| 671 | 681 | Some(Task::Haul { |
| 672 | 682 | pickup: Some(pickup), |
| 673 | 683 | to, |
| @@ -916,7 +926,7 @@ impl BuildContext<'_> { | ||
| 916 | 926 | return None; |
| 917 | 927 | } |
| 918 | 928 | let tiles: HashSet<UVec2> = stacks.into_iter().map(|(_, tile)| tile).collect(); |
| 919 | - nearest_of(self.map, from, SEARCH_TILES, &tiles) | |
| 929 | + self.reach.nearest(self.map, from, SEARCH_TILES, &tiles) | |
| 920 | 930 | } |
| 921 | 931 | |
| 922 | 932 | fn build_task(&self, from: UVec2, site: UVec2, kind: BuildKind) -> Option<Task> { |
| @@ -963,7 +973,7 @@ pub fn input_source(ctx: &BuildContext, from: UVec2, recipe: Recipe) -> Option<U | ||
| 963 | 973 | }) |
| 964 | 974 | .map(|(_, pos)| pos.0) |
| 965 | 975 | .collect(); |
| 966 | - nearest_of(ctx.map, from, SEARCH_TILES, &sources) | |
| 976 | + ctx.reach.nearest(ctx.map, from, SEARCH_TILES, &sources) | |
| 967 | 977 | } |
| 968 | 978 | |
| 969 | 979 | pub const BUTCHER_WORK: u32 = 240; |
| @@ -1150,9 +1160,12 @@ pub fn find_blueprint_work(ctx: &BuildContext, from: UVec2) -> Option<Task> { | ||
| 1150 | 1160 | } |
| 1151 | 1161 | } |
| 1152 | 1162 | } |
| 1153 | - if stands.is_empty() { | |
| 1163 | + let reach = search_reach(SEARCH_TILES) as i32; | |
| 1164 | + let in_reach = |p: &UVec2| (p.as_ivec2() - from.as_ivec2()).abs().max_element() <= reach; | |
| 1165 | + if !stands.iter().any(in_reach) { | |
| 1154 | 1166 | return None; |
| 1155 | 1167 | } |
| 1168 | + let mut sources: HashMap<(ItemKind, u32), Option<UVec2>> = HashMap::new(); | |
| 1156 | 1169 | let mut found = None; |
| 1157 | 1170 | nearest_tile(ctx.map, from, SEARCH_TILES, |p| { |
| 1158 | 1171 | if !stands.contains(&p) { |
| @@ -1170,7 +1183,20 @@ pub fn find_blueprint_work(ctx: &BuildContext, from: UVec2) -> Option<Task> { | ||
| 1170 | 1183 | if !ctx.reservations.is_free(Target::Site(site)) { |
| 1171 | 1184 | return None; |
| 1172 | 1185 | } |
| 1173 | - ctx.build_task(from, site, kind) | |
| 1186 | + let pickup = match kind.cost() { | |
| 1187 | + (_, 0) => None, | |
| 1188 | + cost => Some( | |
| 1189 | + (*sources | |
| 1190 | + .entry(cost) | |
| 1191 | + .or_insert_with(|| ctx.material_source(from, cost)))?, | |
| 1192 | + ), | |
| 1193 | + }; | |
| 1194 | + Some(Task::Build { | |
| 1195 | + site, | |
| 1196 | + kind, | |
| 1197 | + pickup, | |
| 1198 | + progress: 0, | |
| 1199 | + }) | |
| 1174 | 1200 | }); |
| 1175 | 1201 | found.is_some() |
| 1176 | 1202 | })?; |
| @@ -1231,6 +1257,9 @@ fn choose_jobs( | ||
| 1231 | 1257 | if zones.is_changed() { |
| 1232 | 1258 | gather_memo.haul = Memo::default(); |
| 1233 | 1259 | } |
| 1260 | + if blueprints.is_changed() { | |
| 1261 | + gather_memo.build = Memo::default(); | |
| 1262 | + } | |
| 1234 | 1263 | let wolves: Vec<(Entity, UVec2)> = animals |
| 1235 | 1264 | .iter() |
| 1236 | 1265 | .filter(|(_, _, animal, _)| animal.kind.is_predator()) |
| @@ -1283,17 +1312,15 @@ fn choose_jobs( | ||
| 1283 | 1312 | let sowing = sowing_season(*tick); |
| 1284 | 1313 | let carried: Vec<Item> = colonists.iter().filter_map(|c| c.5.0).collect(); |
| 1285 | 1314 | let home = zones.home(map.size / 2); |
| 1286 | - let stocks: HashMap<ItemKind, u32> = ItemKind::ALL | |
| 1315 | + let mut stocks: HashMap<ItemKind, u32> = ItemKind::ALL.iter().map(|&k| (k, 0)).collect(); | |
| 1316 | + let in_store = items | |
| 1287 | 1317 | .iter() |
| 1288 | - .map(|&kind| { | |
| 1289 | - let all = items | |
| 1290 | - .iter() | |
| 1291 | - .filter(|(_, _, pos)| in_colony(home, pos.0)) | |
| 1292 | - .map(|(_, item, _)| item) | |
| 1293 | - .chain(carried.iter()); | |
| 1294 | - (kind, stock(all, kind)) | |
| 1295 | - }) | |
| 1296 | - .collect(); | |
| 1318 | + .filter(|(_, _, pos)| in_colony(home, pos.0)) | |
| 1319 | + .map(|(_, item, _)| item) | |
| 1320 | + .chain(carried.iter()); | |
| 1321 | + for item in in_store { | |
| 1322 | + *stocks.entry(item.kind).or_insert(0) += item.count; | |
| 1323 | + } | |
| 1297 | 1324 | let mut pending = on_the_way(&map, colonists.iter().map(|c| c.3.0)); |
| 1298 | 1325 | for job in colonists.iter().filter_map(|c| c.3.0) { |
| 1299 | 1326 | match job.task { |
| @@ -1302,11 +1329,7 @@ fn choose_jobs( | ||
| 1302 | 1329 | _ => {} |
| 1303 | 1330 | } |
| 1304 | 1331 | } |
| 1305 | - let food_tiles: HashSet<UVec2> = items | |
| 1306 | - .iter() | |
| 1307 | - .filter(|(_, item, _)| item.kind.is_food()) | |
| 1308 | - .map(|(_, _, pos)| pos.0) | |
| 1309 | - .collect(); | |
| 1332 | + let mut food_tiles: Option<HashSet<UVec2>> = None; | |
| 1310 | 1333 | let hour = hour_of_day(*tick); |
| 1311 | 1334 | let mut occupied: Option<HashSet<UVec2>> = None; |
| 1312 | 1335 | for ( |
| @@ -1328,7 +1351,16 @@ fn choose_jobs( | ||
| 1328 | 1351 | ) in &mut colonists |
| 1329 | 1352 | { |
| 1330 | 1353 | let exhausted = rest.0 < EXHAUSTED_REST; |
| 1331 | - let starving = hunger.0 < STARVING && food_to_fetch(&reservations, &food_tiles); | |
| 1354 | + let starving = hunger.0 < STARVING && { | |
| 1355 | + let tiles = food_tiles.get_or_insert_with(|| { | |
| 1356 | + items | |
| 1357 | + .iter() | |
| 1358 | + .filter(|(_, item, _)| item.kind.is_food()) | |
| 1359 | + .map(|(_, _, pos)| pos.0) | |
| 1360 | + .collect() | |
| 1361 | + }); | |
| 1362 | + food_to_fetch(&reservations, tiles) | |
| 1363 | + }; | |
| 1332 | 1364 | let danger = nearest_wolf(tile.0, DANGER_RANGE); |
| 1333 | 1365 | let urgent = exhausted || starving || danger.is_some(); |
| 1334 | 1366 | let fighting_wolf = |task: Task| { |
| @@ -1423,7 +1455,8 @@ fn choose_jobs( | ||
| 1423 | 1455 | } else if child { |
| 1424 | 1456 | None |
| 1425 | 1457 | } else if let Some(load) = carrying.0 { |
| 1426 | - let storage = find_storage(&map, &zones, &reservations, refs, tile.0, load.kind); | |
| 1458 | + let storage = | |
| 1459 | + find_storage(&map, &zones, &reservations, &reach, refs, tile.0, load.kind); | |
| 1427 | 1460 | if storage.is_none() { |
| 1428 | 1461 | drop_items(&mut commands, &mut items, &item_assets, tile.0, load); |
| 1429 | 1462 | carrying.0 = None; |
| @@ -1454,21 +1487,26 @@ fn choose_jobs( | ||
| 1454 | 1487 | blueprints: &blueprints, |
| 1455 | 1488 | occupied, |
| 1456 | 1489 | }; |
| 1490 | + let has_input = |recipe| input_source(&build, tile.0, recipe).is_some(); | |
| 1457 | 1491 | let cooking = || { |
| 1458 | - (wanted(ItemKind::Meal) && input_source(&build, tile.0, Recipe::Meal).is_some()) | |
| 1459 | - .then(|| { | |
| 1460 | - find_crafting(&build, tile.0, Recipe::Meal) | |
| 1461 | - .or_else(|| find_campfire(&build, tile.0)) | |
| 1462 | - }) | |
| 1463 | - .flatten() | |
| 1492 | + if !wanted(ItemKind::Meal) { | |
| 1493 | + return None; | |
| 1494 | + } | |
| 1495 | + find_crafting(&build, tile.0, Recipe::Meal).or_else(|| { | |
| 1496 | + (build.campfire_needed(zones.home(tile.0)) && has_input(Recipe::Meal)) | |
| 1497 | + .then(|| find_campfire(&build, tile.0)) | |
| 1498 | + .flatten() | |
| 1499 | + }) | |
| 1464 | 1500 | }; |
| 1465 | 1501 | let toolmaking = || { |
| 1466 | - (wanted(ItemKind::Tools) && input_source(&build, tile.0, Recipe::Tools).is_some()) | |
| 1467 | - .then(|| { | |
| 1468 | - find_crafting(&build, tile.0, Recipe::Tools) | |
| 1469 | - .or_else(|| find_workbench(&build, tile.0)) | |
| 1470 | - }) | |
| 1471 | - .flatten() | |
| 1502 | + if !wanted(ItemKind::Tools) { | |
| 1503 | + return None; | |
| 1504 | + } | |
| 1505 | + find_crafting(&build, tile.0, Recipe::Tools).or_else(|| { | |
| 1506 | + (build.count(StructureKind::Workbench) == 0 && has_input(Recipe::Tools)) | |
| 1507 | + .then(|| find_workbench(&build, tile.0)) | |
| 1508 | + .flatten() | |
| 1509 | + }) | |
| 1472 | 1510 | }; |
| 1473 | 1511 | let designated = || { |
| 1474 | 1512 | (!designations.0.is_empty()) |
| @@ -1491,6 +1529,8 @@ fn choose_jobs( | ||
| 1491 | 1529 | let skip_gather = gather_memo.gather.skips(gather_mask, tick.0); |
| 1492 | 1530 | let skip_forage = gather_memo.forage.skips(food_mask, tick.0); |
| 1493 | 1531 | let skip_haul = gather_memo.haul.skips(0, tick.0); |
| 1532 | + let skip_build = gather_memo.build.skips(0, tick.0); | |
| 1533 | + let build_failed = std::cell::Cell::new(false); | |
| 1494 | 1534 | let gather_failed = std::cell::Cell::new(false); |
| 1495 | 1535 | let forage_failed = std::cell::Cell::new(false); |
| 1496 | 1536 | let haul_failed = std::cell::Cell::new(false); |
| @@ -1529,7 +1569,12 @@ fn choose_jobs( | ||
| 1529 | 1569 | }), |
| 1530 | 1570 | (WorkType::Craft, Some(Skill::Crafting), &toolmaking), |
| 1531 | 1571 | (WorkType::Build, Some(Skill::Construction), &|| { |
| 1532 | - find_blueprint_work(&build, tile.0) | |
| 1572 | + if skip_build { | |
| 1573 | + return None; | |
| 1574 | + } | |
| 1575 | + let work = find_blueprint_work(&build, tile.0); | |
| 1576 | + build_failed.set(work.is_none()); | |
| 1577 | + work | |
| 1533 | 1578 | }), |
| 1534 | 1579 | (WorkType::Gather, None, &|| { |
| 1535 | 1580 | if gather_mask == 0 || skip_gather { |
| @@ -1585,6 +1630,9 @@ fn choose_jobs( | ||
| 1585 | 1630 | if haul_failed.get() { |
| 1586 | 1631 | gather_memo.haul = retry(0, HAUL_RETRY_TICKS); |
| 1587 | 1632 | } |
| 1633 | + if build_failed.get() { | |
| 1634 | + gather_memo.build = retry(0, HAUL_RETRY_TICKS); | |
| 1635 | + } | |
| 1588 | 1636 | task |
| 1589 | 1637 | }; |
| 1590 | 1638 | if let Some(task) = task { |
src/jobs/tests.rs +1 -1
| @@ -6,7 +6,7 @@ use crate::{ | ||
| 6 | 6 | colonist::follow_path, |
| 7 | 7 | economy::{COLONY_RADIUS, Stock}, |
| 8 | 8 | food::Hunger, |
| 9 | - items::STACK_LIMIT, | |
| 9 | + items::{STACK_LIMIT, stock}, | |
| 10 | 10 | sim::{SimPos, sim_pos}, |
| 11 | 11 | world::Terrain, |
| 12 | 12 | zones::NEW_STOCKPILE_SIZE, |
src/pathfinding.rs +40 -0
| @@ -100,6 +100,26 @@ impl ColonyReach { | ||
| 100 | 100 | pub fn allows_near(&self, tile: UVec2) -> bool { |
| 101 | 101 | self.0.as_ref().is_none_or(|reach| reach.near(tile)) |
| 102 | 102 | } |
| 103 | + | |
| 104 | + pub fn nearest( | |
| 105 | + &self, | |
| 106 | + map: &WorldMap, | |
| 107 | + from: UVec2, | |
| 108 | + max_tiles: usize, | |
| 109 | + targets: &HashSet<UVec2>, | |
| 110 | + ) -> Option<UVec2> { | |
| 111 | + match &self.0 { | |
| 112 | + Some(reach) if reach.contains(from) => targets | |
| 113 | + .iter() | |
| 114 | + .filter(|t| reach.contains(**t)) | |
| 115 | + .min_by_key(|t| { | |
| 116 | + let d = t.as_ivec2() - from.as_ivec2(); | |
| 117 | + (d.length_squared(), t.y, t.x) | |
| 118 | + }) | |
| 119 | + .copied(), | |
| 120 | + _ => nearest_of(map, from, max_tiles, targets), | |
| 121 | + } | |
| 122 | + } | |
| 103 | 123 | } |
| 104 | 124 | |
| 105 | 125 | impl Reach { |
| @@ -292,6 +312,26 @@ mod tests { | ||
| 292 | 312 | use super::*; |
| 293 | 313 | use crate::world::Terrain; |
| 294 | 314 | |
| 315 | + // Inside the colony's flooded reach, the nearest reachable target by distance stands in for | |
| 316 | + // a fresh flood fill per search; with 40+ colonists those floods dominated job choice. | |
| 317 | + #[test] | |
| 318 | + fn colony_reach_picks_the_nearest_reachable_target() { | |
| 319 | + let mut map = WorldMap::filled(UVec2::splat(30), Terrain::Grass); | |
| 320 | + for y in 0..30 { | |
| 321 | + map.set(UVec2::new(10, y), Terrain::DeepWater); | |
| 322 | + } | |
| 323 | + let reach = ColonyReach(Some(Reach::flood(&map, UVec2::new(5, 5)))); | |
| 324 | + let across = UVec2::new(12, 5); | |
| 325 | + let far = UVec2::new(5, 20); | |
| 326 | + let targets = HashSet::from([across, far]); | |
| 327 | + assert_eq!( | |
| 328 | + reach.nearest(&map, UVec2::new(5, 5), 100, &targets), | |
| 329 | + Some(far) | |
| 330 | + ); | |
| 331 | + let elsewhere = UVec2::new(20, 5); | |
| 332 | + assert_eq!(reach.nearest(&map, elsewhere, 1000, &targets), Some(across)); | |
| 333 | + } | |
| 334 | + | |
| 295 | 335 | // Wandering picks random nearby goals; one walled off by a river or cliff made A* search the |
| 296 | 336 | // whole connected region before failing. Short trips cap the search instead. |
| 297 | 337 | #[test] |
src/population.rs +5 -7
| @@ -4,7 +4,7 @@ use std::collections::VecDeque; | ||
| 4 | 4 | use bevy::prelude::*; |
| 5 | 5 | |
| 6 | 6 | use crate::{ |
| 7 | - colonist::{Colonist, ColonistAssets, NAMES, Path, spawn_colonist}, | |
| 7 | + colonist::{Colonist, ColonistAssets, Path, colonist_name, free_name_index, spawn_colonist}, | |
| 8 | 8 | economy::in_colony, |
| 9 | 9 | food::Hunger, |
| 10 | 10 | items::{Carrying, Item, ItemAssets, ItemQuery, drop_items}, |
| @@ -33,7 +33,7 @@ impl Plugin for PopulationPlugin { | ||
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | pub const STARVATION_TICKS: u32 = 24 * TICKS_PER_HOUR as u32; |
| 36 | -pub const MAX_COLONISTS: usize = 20; | |
| 36 | +pub const MAX_COLONISTS: usize = usize::MAX; | |
| 37 | 37 | pub const ARRIVAL_CHANCE: f32 = 0.6; |
| 38 | 38 | const ARRIVAL_ATTEMPTS: usize = 10; |
| 39 | 39 | const ARRIVAL_HOUR_TICK: u64 = 4 * TICKS_PER_HOUR; |
| @@ -46,6 +46,7 @@ pub struct ColonyLog(pub VecDeque<String>); | ||
| 46 | 46 | |
| 47 | 47 | impl ColonyLog { |
| 48 | 48 | pub fn push(&mut self, tick: SimTick, message: String) { |
| 49 | + debug!("{} {message}", format_clock(tick)); | |
| 49 | 50 | self.0 |
| 50 | 51 | .push_front(format!("{}: {message}", format_clock(tick))); |
| 51 | 52 | self.0.truncate(LOG_LENGTH); |
| @@ -165,10 +166,7 @@ fn arrivals( | ||
| 165 | 166 | }) else { |
| 166 | 167 | return; |
| 167 | 168 | }; |
| 168 | - let taken: Vec<&str> = colonists.iter().map(|c| c.name.as_str()).collect(); | |
| 169 | - let index = (0..NAMES.len()) | |
| 170 | - .find(|&i| !taken.contains(&NAMES[i])) | |
| 171 | - .unwrap_or(count); | |
| 169 | + let index = free_name_index(colonists.iter().map(|c| c.name.as_str())); | |
| 172 | 170 | let newcomer = spawn_colonist( |
| 173 | 171 | &mut commands, |
| 174 | 172 | &assets, |
| @@ -186,7 +184,7 @@ fn arrivals( | ||
| 186 | 184 | )); |
| 187 | 185 | log.push( |
| 188 | 186 | *tick, |
| 189 | - format!("{} has joined the colony", NAMES[index % NAMES.len()]), | |
| 187 | + format!("{} has joined the colony", colonist_name(index)), | |
| 190 | 188 | ); |
| 191 | 189 | } |
| 192 | 190 | |
src/sim.rs +6 -1
| @@ -75,6 +75,7 @@ fn report_bench( | ||
| 75 | 75 | tick: Res<SimTick>, |
| 76 | 76 | real: Res<Time<Real>>, |
| 77 | 77 | clock: Res<SimClock>, |
| 78 | + colonists: Query<(), With<crate::colonist::Colonist>>, | |
| 78 | 79 | mut window: Local<Option<BenchWindow>>, |
| 79 | 80 | ) { |
| 80 | 81 | let now = real.elapsed_secs(); |
| @@ -92,7 +93,11 @@ fn report_bench( | ||
| 92 | 93 | if now - window.since >= BENCH_REPORT_SECS { |
| 93 | 94 | let days = window.ticks as f32 / (HOURS_PER_DAY * TICKS_PER_HOUR) as f32; |
| 94 | 95 | let micros = window.sim_secs * 1e6 / window.ticks.max(1) as f32; |
| 95 | - info!("bench: {micros:.1} us per tick, {days:.2} game days per {BENCH_REPORT_SECS} s"); | |
| 96 | + let day = tick.0 / (HOURS_PER_DAY * TICKS_PER_HOUR); | |
| 97 | + let count = colonists.iter().count(); | |
| 98 | + info!( | |
| 99 | + "bench: day {day}, {count} colonists, {micros:.1} us per tick, {days:.2} game days per {BENCH_REPORT_SECS} s" | |
| 100 | + ); | |
| 96 | 101 | *window = BenchWindow { |
| 97 | 102 | since: now, |
| 98 | 103 | last_tick: tick.0, |
tasks.md +1 -0
| @@ -48,6 +48,7 @@ | ||
| 48 | 48 | - [x] Skills: eight work skills set work speed, improve with practice, newcomers have a talent; poor-skill work is tried last |
| 49 | 49 | - [x] Wolves: predators hunt animals and colonists; health, bites, healing; adults fight wolves near the colony, children and the hurt flee; newcomers now walk to the colony instead of stranding at the edge |
| 50 | 50 | - [x] Optimization pass: single-threaded fixed schedules, A* scratch buffers, capped wander searches, no retry-every-tick for boxed-in animals, cached reach, more search memos; native SIM_BENCH mode |
| 51 | +- [x] Colonist cap removed (numbered names past the list); job search pass for 40+ colonists (reach-based nearest targets, cheaper cooking/blueprint checks) | |
| 51 | 52 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 52 | 53 | |
| 53 | 54 | ## Planned |