Remove colonist cap; cheaper job searches for large colonies

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

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

@@ -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