Raise colonist cap to 20 and speed up job searches

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 08:48 UTC
commit
681918401f9559346c54b8dd5bfe53fb56b0b54a
parent
2f2f76394a
tree
browse at this commit

8 files changed +140 -46

CLAUDE.md +3 -1

@@ -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, 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 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.
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 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.
@@ -56,6 +56,8 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
56 56 - Hot spots fixed so far: predicate BFS scanning items per tile (use `nearest_of` with precollected sets), searches that cannot match (pre-check wanted kinds, empty farm sets), choose_jobs setup skipped unless someone is ready (idle timer ticked in a pre-pass), periodic passes over only `WorldMap::worn_tiles` / `growing_plants`, light-driven tilemap re-tints rate-limited to 100 ms, `BuildContext.occupied` set.
57 57 - 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.
58 58 - 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.
59 +- choose_jobs: wanted kinds computed once per colonist into an array (`ItemKind::index`), `occupied` set built once per pass; `find_blueprint_work` pre-collects stand tiles of affordable unreserved blueprints (none → no search); `find_haul` only counts loose stacks whose kind has a storage candidate and uses `nearest_of`; gather `find_work` failures are memoised (`GatherMemo`: same wanted-kind mask → skip for GATHER_RETRY_TICKS). `Reach::flood` (bitmap) for plan_homes' reachability.
60 +- Browser at Max on the 20-colonist day-220 save: ~8 game days per 30 s (was 3.5–6 before the pass above).
59 61 - Browser at Max: ~100–350× on a 12-colonist colony, dips at dusk (ambient steps re-tint all chunks) and on hourly ticks.
60 62
61 63 ## Gotchas

src/colonist.rs +3 -2

@@ -27,9 +27,10 @@ impl Plugin for ColonistPlugin {
27 27
28 28 const TILES_PER_SECOND: f32 = 4.0;
29 29
30 -pub const NAMES: [&str; 16] = [
30 +pub const NAMES: [&str; 26] = [
31 31 "Ada", "Bram", "Cleo", "Dana", "Emil", "Fenna", "Gijs", "Hanna", "Ivo", "Juul", "Kees",
32 - "Lotte", "Milan", "Noor", "Otto", "Pien",
32 + "Lotte", "Milan", "Noor", "Otto", "Pien", "Quinn", "Roos", "Sem", "Tess", "Ugo", "Vera",
33 + "Wout", "Xem", "Yara", "Zeno",
33 34 ];
34 35
35 36 const COLORS: [Color; 8] = [

src/family.rs +10 -25

@@ -8,7 +8,7 @@ use crate::{
8 8 colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist},
9 9 economy::in_colony,
10 10 items::{Item, ItemAssets},
11 - pathfinding::nearest_tile,
11 + pathfinding::{Reach, nearest_tile},
12 12 population::{ColonyLog, MAX_COLONISTS, food_in_store},
13 13 rng::SimRng,
14 14 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
@@ -356,13 +356,13 @@ impl HomeContext<'_> {
356 356 pub fn door_repair(
357 357 map: &WorldMap,
358 358 doors: &HashSet<UVec2>,
359 - reachable: &HashSet<UVec2>,
359 + reachable: &Reach,
360 360 home: &Home,
361 361 toward: UVec2,
362 362 ) -> Option<(UVec2, BuildKind)> {
363 363 let open_outside = |t: &UVec2| {
364 364 let out = home.outward(*t);
365 - out.min_element() >= 0 && reachable.contains(&out.as_uvec2())
365 + out.min_element() >= 0 && reachable.contains(out.as_uvec2())
366 366 };
367 367 if home
368 368 .door_sites()
@@ -484,21 +484,11 @@ fn plan_homes(
484 484 let home = homes.vacant.swap_remove(index);
485 485 homes.by_family.insert(family, home);
486 486 }
487 - let mut reachable = HashSet::new();
488 - nearest_tile(&map, colony_home, usize::MAX, |p| {
489 - reachable.insert(p);
490 - false
491 - });
487 + let reachable = Reach::flood(&map, colony_home);
492 488 let anchored = map.move_cost(colony_home.as_ivec2()).is_some();
493 - blueprints.0.retain(|tile, _| {
494 - !anchored
495 - || (-1..=1).any(|dy| {
496 - (-1..=1).any(|dx| {
497 - let near = tile.as_ivec2() + IVec2::new(dx, dy);
498 - near.min_element() >= 0 && reachable.contains(&near.as_uvec2())
499 - })
500 - })
501 - });
489 + blueprints
490 + .0
491 + .retain(|tile, _| !anchored || reachable.near(*tile));
502 492 let owned: HashSet<Home> = homes.by_family.values().copied().collect();
503 493 let door = BuildKind::Structure(StructureKind::Door);
504 494 let entries: HashSet<UVec2> = blueprints
@@ -532,7 +522,7 @@ fn plan_homes(
532 522 .count();
533 523 let communal: Vec<UVec2> = beds
534 524 .iter()
535 - .filter(|b| !homes.is_home(**b) && blueprints.get(**b).is_none() && reachable.contains(b))
525 + .filter(|b| !homes.is_home(**b) && blueprints.get(**b).is_none() && reachable.contains(**b))
536 526 .copied()
537 527 .collect();
538 528 for bed in surplus_beds(&communal, homeless_adults, colony_home) {
@@ -844,13 +834,8 @@ mod tests {
844 834 HashSet::from([door])
845 835 }
846 836
847 - fn reachable(map: &WorldMap, from: UVec2) -> HashSet<UVec2> {
848 - let mut seen = HashSet::new();
849 - nearest_tile(map, from, usize::MAX, |p| {
850 - seen.insert(p);
851 - false
852 - });
853 - seen
837 + fn reachable(map: &WorldMap, from: UVec2) -> Reach {
838 + Reach::flood(map, from)
854 839 }
855 840
856 841 // New homes keep a walkable tile all around, so any door they get opens onto open ground.

src/items.rs +4 -0

@@ -48,6 +48,10 @@ impl ItemKind {
48 48 ItemKind::Tools,
49 49 ];
50 50
51 + pub fn index(self) -> usize {
52 + self as usize
53 + }
54 +
51 55 pub fn nutrition(self) -> f32 {
52 56 match self {
53 57 ItemKind::Berries | ItemKind::Meat | ItemKind::Vegetables => 0.1,

src/jobs/mod.rs +73 -17

@@ -498,6 +498,14 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
498 498 }
499 499 }
500 500
501 +const GATHER_RETRY_TICKS: u64 = 300;
502 +
503 +#[derive(Default)]
504 +pub struct GatherMemo {
505 + mask: u32,
506 + until: u64,
507 +}
508 +
501 509 pub fn find_work(
502 510 map: &WorldMap,
503 511 reservations: &Reservations,
@@ -602,17 +610,24 @@ pub fn find_haul(
602 610 });
603 611 zones.rule(tile).priority < top
604 612 };
613 + let mut room: HashMap<ItemKind, bool> = HashMap::new();
605 614 let loose: HashSet<UVec2> = items
606 615 .iter()
607 616 .filter(|(item, pos)| {
608 - reservations.is_free(Target::Pickup(pos.0)) && misplaced(item, pos.0, &mut best)
617 + reservations.is_free(Target::Pickup(pos.0))
618 + && misplaced(item, pos.0, &mut best)
619 + && *room.entry(item.kind).or_insert_with(|| {
620 + storage_candidates(zones, reservations, &stacks, item.kind)
621 + .next()
622 + .is_some()
623 + })
609 624 })
610 625 .map(|(_, pos)| pos.0)
611 626 .collect();
612 627 if loose.is_empty() {
613 628 return None;
614 629 }
615 - let pickup = nearest_tile(map, from, SEARCH_TILES, |p| loose.contains(&p))?;
630 + let pickup = nearest_of(map, from, SEARCH_TILES, &loose)?;
616 631 let (item, _) = items.iter().find(|(_, pos)| pos.0 == pickup)?;
617 632 let refs = items.iter().map(|(i, p)| (i, p));
618 633 let to = find_storage(map, zones, reservations, refs, pickup, item.kind)?;
@@ -1053,11 +1068,36 @@ pub fn find_bed_site(ctx: &BuildContext, colonists: usize, from: UVec2) -> Optio
1053 1068 }
1054 1069
1055 1070 pub fn find_blueprint_work(ctx: &BuildContext, from: UVec2) -> Option<Task> {
1056 - if ctx.blueprints.0.is_empty() {
1071 + let mut stock: HashMap<ItemKind, u32> = HashMap::new();
1072 + for (item, pos) in ctx.items {
1073 + if ctx.reservations.is_free(Target::Pickup(pos.0)) {
1074 + *stock.entry(item.kind).or_insert(0) += item.count;
1075 + }
1076 + }
1077 + let mut stands: HashSet<UVec2> = HashSet::new();
1078 + for (&site, &kind) in &ctx.blueprints.0 {
1079 + let (material, amount) = kind.cost();
1080 + let affordable = amount == 0 || stock.get(&material).copied().unwrap_or(0) >= amount;
1081 + if !affordable || !ctx.reservations.is_free(Target::Site(site)) {
1082 + continue;
1083 + }
1084 + for d in std::iter::once(IVec2::ZERO).chain(NEIGHBORS) {
1085 + let stand = site.as_ivec2() + d;
1086 + if (d == IVec2::ZERO || ctx.map.move_cost(site.as_ivec2()).is_none())
1087 + && ctx.map.move_cost(stand).is_some()
1088 + {
1089 + stands.insert(stand.as_uvec2());
1090 + }
1091 + }
1092 + }
1093 + if stands.is_empty() {
1057 1094 return None;
1058 1095 }
1059 1096 let mut found = None;
1060 1097 nearest_tile(ctx.map, from, SEARCH_TILES, |p| {
1098 + if !stands.contains(&p) {
1099 + return false;
1100 + }
1061 1101 found = std::iter::once(IVec2::ZERO)
1062 1102 .chain(NEIGHBORS)
1063 1103 .filter_map(|d| {
@@ -1123,6 +1163,7 @@ fn choose_jobs(
1123 1163 With<Colonist>,
1124 1164 >,
1125 1165 plans: (Res<Homes>, Res<Designations>),
1166 + mut gather_memo: Local<GatherMemo>,
1126 1167 ) {
1127 1168 let (homes, designations) = plans;
1128 1169 let mut anyone_ready = false;
@@ -1188,6 +1229,11 @@ fn choose_jobs(
1188 1229 .map(|(_, _, pos)| pos.0)
1189 1230 .collect();
1190 1231 let hour = hour_of_day(*tick);
1232 + let mut occupied: HashSet<UVec2> = structures_list
1233 + .iter()
1234 + .map(|(pos, ..)| *pos)
1235 + .chain(items.iter().map(|(_, _, pos)| pos.0))
1236 + .collect();
1191 1237 for (
1192 1238 entity,
1193 1239 tile,
@@ -1223,7 +1269,8 @@ fn choose_jobs(
1223 1269 let snapshot: Vec<(Item, TilePos)> =
1224 1270 items.iter().map(|(_, item, pos)| (*item, *pos)).collect();
1225 1271 let refs = snapshot.iter().map(|(i, p)| (i, p));
1226 - let wanted = |kind: ItemKind| goals.wanted(kind, &stocks, &pending);
1272 + let wanted_kinds = ItemKind::ALL.map(|kind| goals.wanted(kind, &stocks, &pending));
1273 + let wanted = |kind: ItemKind| wanted_kinds[kind.index()];
1227 1274 let sleep = SleepContext {
1228 1275 map: &map,
1229 1276 zones: &zones,
@@ -1279,12 +1326,7 @@ fn choose_jobs(
1279 1326 {
1280 1327 Some(Task::Equip { pickup })
1281 1328 } else {
1282 - let occupied: HashSet<UVec2> = structures_list
1283 - .iter()
1284 - .map(|(pos, ..)| *pos)
1285 - .chain(planned.iter().map(|(pos, _)| *pos))
1286 - .chain(snapshot.iter().map(|(_, pos)| pos.0))
1287 - .collect();
1329 + occupied.extend(planned.iter().map(|(pos, _)| *pos));
1288 1330 let build = BuildContext {
1289 1331 map: &map,
1290 1332 zones: &zones,
@@ -1320,6 +1362,12 @@ fn choose_jobs(
1320 1362 })
1321 1363 .flatten()
1322 1364 };
1365 + let gather_mask = ItemKind::ALL
1366 + .iter()
1367 + .filter(|&&k| wanted(k))
1368 + .fold(0u32, |mask, k| mask | 1 << k.index());
1369 + let skip_gather = gather_memo.mask == gather_mask && tick.0 < gather_memo.until;
1370 + let gather_failed = std::cell::Cell::new(false);
1323 1371 let candidates: [(WorkType, &dyn Fn() -> Option<Task>); 16] = [
1324 1372 (WorkType::Haul, &|| {
1325 1373 find_expansion(&map, &zones, &reservations, &snapshot, &blueprints)
@@ -1354,11 +1402,12 @@ fn choose_jobs(
1354 1402 (WorkType::Craft, &toolmaking),
1355 1403 (WorkType::Build, &|| find_blueprint_work(&build, tile.0)),
1356 1404 (WorkType::Gather, &|| {
1357 - ItemKind::ALL
1358 - .iter()
1359 - .any(|&k| wanted(k))
1360 - .then(|| find_work(&map, &reservations, tile.0, |_, k| wanted(k)))
1361 - .flatten()
1405 + if gather_mask == 0 || skip_gather {
1406 + return None;
1407 + }
1408 + let work = find_work(&map, &reservations, tile.0, |_, k| wanted(k));
1409 + gather_failed.set(work.is_none());
1410 + work
1362 1411 }),
1363 1412 (WorkType::Food, &|| {
1364 1413 wanted(ItemKind::Meat)
@@ -1379,10 +1428,17 @@ fn choose_jobs(
1379 1428 find_haul(&map, &zones, &reservations, &snapshot, tile.0)
1380 1429 }),
1381 1430 ];
1382 - candidates
1431 + let task = candidates
1383 1432 .iter()
1384 1433 .filter(|(work, _)| priorities.allows(*work))
1385 - .find_map(|(_, find)| find())
1434 + .find_map(|(_, find)| find());
1435 + if gather_failed.get() {
1436 + *gather_memo = GatherMemo {
1437 + mask: gather_mask,
1438 + until: tick.0 + GATHER_RETRY_TICKS,
1439 + };
1440 + }
1441 + task
1386 1442 };
1387 1443 if let Some(task) = task {
1388 1444 if let Task::Work { site, .. } = task

src/pathfinding.rs +45 -0

@@ -83,6 +83,51 @@ pub fn nearest_tile(
83 83 None
84 84 }
85 85
86 +pub struct Reach {
87 + width: u32,
88 + seen: Vec<bool>,
89 +}
90 +
91 +impl Reach {
92 + pub fn flood(map: &WorldMap, start: UVec2) -> Self {
93 + let width = map.size.x;
94 + let mut seen = vec![false; map.size.element_product() as usize];
95 + let index = |p: UVec2| (p.y * width + p.x) as usize;
96 + if map.move_cost(start.as_ivec2()).is_some() {
97 + seen[index(start)] = true;
98 + let mut queue = VecDeque::from([start]);
99 + while let Some(pos) = queue.pop_front() {
100 + for step in NEIGHBORS {
101 + let next = pos.as_ivec2() + step;
102 + if can_step(map, pos.as_ivec2(), step) && !seen[index(next.as_uvec2())] {
103 + seen[index(next.as_uvec2())] = true;
104 + queue.push_back(next.as_uvec2());
105 + }
106 + }
107 + }
108 + }
109 + Self { width, seen }
110 + }
111 +
112 + pub fn contains(&self, p: UVec2) -> bool {
113 + p.x < self.width
114 + && self
115 + .seen
116 + .get((p.y * self.width + p.x) as usize)
117 + .copied()
118 + .unwrap_or(false)
119 + }
120 +
121 + pub fn near(&self, p: UVec2) -> bool {
122 + (-1..=1).any(|dy| {
123 + (-1..=1).any(|dx| {
124 + let q = p.as_ivec2() + IVec2::new(dx, dy);
125 + q.min_element() >= 0 && self.contains(q.as_uvec2())
126 + })
127 + })
128 + }
129 +}
130 +
86 131 pub fn search_reach(max_tiles: usize) -> u32 {
87 132 (max_tiles as f32).sqrt().ceil() as u32
88 133 }

src/population.rs +1 -1

@@ -32,7 +32,7 @@ impl Plugin for PopulationPlugin {
32 32 }
33 33
34 34 pub const STARVATION_TICKS: u32 = 24 * TICKS_PER_HOUR as u32;
35 -pub const MAX_COLONISTS: usize = 12;
35 +pub const MAX_COLONISTS: usize = 20;
36 36 pub const ARRIVAL_CHANCE: f32 = 0.6;
37 37 const ARRIVAL_HOUR_TICK: u64 = 4 * TICKS_PER_HOUR;
38 38 const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;

tasks.md +1 -0

@@ -42,6 +42,7 @@
42 42 - [x] Save/load (F5/F9, toolbar buttons): browser localStorage on web, simulator-save.json natively
43 43 - [x] Max speed fix: target searches respect A*'s no-corner-cutting rule (unreachable diagonal stacks caused repeated full-map path searches)
44 44 - [x] Housing upkeep: vacant homes kept and reused by new couples, abandoned enclosed homes adopted, blocked doors repaired, homes keep a walkable gap, unreachable blueprints dropped, surplus communal beds deconstructed
45 +- [x] Colonist cap raised to 20 (26 names); job search pass to keep Max speed up at 20 colonists
45 46 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
46 47
47 48 ## Planned