Add bridges, built where wading or detours around water are common

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 17:28 UTC
commit
b092f10569d145458fe7f2fecd3aa8d4691321a4
parent
fc106c9431
tree
browse at this commit

12 files changed +255 -29

CLAUDE.md +1 -1

@@ -39,7 +39,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
39 39 - `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.
40 40 - Hunting/animals (2nd pass): wild animals flee (`flee_goal`, FLEE_RANGE 4, run FLEE_DISTANCE) from colonists, abandoning paths that end near a threat; hunters shoot from ≤ HUNT_RANGE 6 with `line_of_sight` (repath when the path end drifts > HUNT_RANGE/2 from prey); kills drop `ItemKind::Carcass` (meat count, RawFood stock group, spoils like meat, not edible); `find_butchering` (before food gathering) → `Task::Butcher{site}` converts the carcass stack into Meat in place after BUTCHER_WORK. `Tame` animals: `Task::Tame` (adjacent, kind.tame_work; target stands still while being tamed) via `find_taming` when meat isn't wanted and < MAX_TAME 6; tame animals don't flee, wander within PEN_RADIUS of home, `breed` daily (≥2 of a kind, 50%), excluded from migration counts. `find_hunt` (`Wildlife` = (entity, tile, kind, tame)) takes spare tame animals (herd > 2) before wild ones.
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 -- `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`. `busy_path` (Paved or wear ≥ BUSY_WEAR = PAVE_WEAR): colonists don't plan homes/expansions (`HomeContext::open`), shelter walls or BuildContext sites (fires, beds, workbench) there; manual placement is unaffected. 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.
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`. Bridges: `Terrain::Bridge` (cost 80 like Path, never generated, not zonable, counts as busy path) via `BuildKind::Bridge` (2 wood, 180 work, not blocked by structures; completion sets terrain + clears wear). `bridging_candidates` (hourly with paving): water tiles (shallow or deep) with wear ≥ BRIDGE_WEAR next to walkable ground, busiest first, up to BRIDGE_BATCH pending. Wading adds wear to shallow water; `note_detour` (in run_jobs' `walk_to`, which now takes `&mut WorldMap`) runs `find_bridged_path` (A* `astar_core` with `bridging`: water passable at bridge cost) when a route of ≥ DETOUR_MIN_TILES is ≥1.5× the straight distance (or missing) and adds 1 wear to every water tile of a bridged route that's ≥1.5× shorter — demand for a currently impossible/indirect crossing; bridges then grow from the shore. Build tool Bridge mode (`place_bridges`, water only). `busy_path` (Paved, Bridge or wear ≥ BUSY_WEAR = PAVE_WEAR): colonists don't plan homes/expansions (`HomeContext::open`), shelter walls or BuildContext sites (fires, beds, workbench) there; manual placement is unaffected. 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 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`), hearth at `Home::hearth_tile` (hi−1), 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). Households (`Household{partners, children}` per family; `adults()` = max(partners,1)) size homes via `desired_interior` (solo 3×2, couple 4×3, +1 child 5×3, 2+ children 5×4). Couples get a vacant home (preferring ones big enough) or `plan_sized`; otherwise one solo adult per pass (no Partner, not Child, family None / parents' family / no home) gets a new `Family` (Commands) and a vacant home ≤ 4×3 or a planned solo room. Owned built homes smaller than desired get `HomeContext::expansion`: grow one side (width first, then height) where the new ring may reuse walls and the new strip is free; opened old wall/door tiles get Deconstruct, missing new ring tiles Wall; Homes entry updated at once. Owned built homes get Bed blueprints up to `adults()` at `bed_tiles_for`. New homes via `HomeContext::plan_sized`: BFS from colony home (center ≥ HOME_MIN_DISTANCE), first HOME_CANDIDATES fits, pick the one sharing most walls (`shared_walls`: ring tiles may be existing walls or planned wall blueprints, else open and not in a room; interior open and not in a room; some door site must open onto walkable, non-room ground). `Home::blueprints(map, toward)` plans only missing ring walls, door via `door_site` (non-wall, walkable outward), beds. Adoption: any rectangular room (`home_of_room`, interior ≥ MIN_HOME_INTERIOR 3×2) with ≥2 beds not already a home → vacant. `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.

src/building.rs +30 -3

@@ -59,6 +59,7 @@ pub enum BuildKind {
59 59 Roof,
60 60 Pave,
61 61 Floor,
62 + Bridge,
62 63 Refuel(StructureKind),
63 64 Deconstruct,
64 65 }
@@ -70,6 +71,7 @@ impl BuildKind {
70 71 BuildKind::Roof => (ItemKind::Wood, 1),
71 72 BuildKind::Pave => (ItemKind::Stone, 1),
72 73 BuildKind::Floor => (ItemKind::Wood, 1),
74 + BuildKind::Bridge => (ItemKind::Wood, 2),
73 75 BuildKind::Refuel(kind) => kind.refuel_cost(),
74 76 BuildKind::Deconstruct => (ItemKind::Wood, 0),
75 77 }
@@ -88,6 +90,7 @@ impl BuildKind {
88 90 BuildKind::Roof => 120,
89 91 BuildKind::Pave => 120,
90 92 BuildKind::Floor => 90,
93 + BuildKind::Bridge => 180,
91 94 BuildKind::Refuel(_) => 60,
92 95 BuildKind::Deconstruct => 200,
93 96 }
@@ -99,6 +102,7 @@ impl BuildKind {
99 102 BuildKind::Roof => "roof",
100 103 BuildKind::Pave => "paving",
101 104 BuildKind::Floor => "floor",
105 + BuildKind::Bridge => "bridge",
102 106 BuildKind::Refuel(_) => "refuelling",
103 107 BuildKind::Deconstruct => "deconstruction",
104 108 }
@@ -276,7 +280,7 @@ pub fn plan_shelter(
276 280 let shelter_pending = blueprints
277 281 .0
278 282 .values()
279 - .any(|k| !matches!(k, BuildKind::Pave | BuildKind::Floor));
283 + .any(|k| !matches!(k, BuildKind::Pave | BuildKind::Floor | BuildKind::Bridge));
280 284 if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) || shelter_pending {
281 285 return;
282 286 }
@@ -320,10 +324,11 @@ pub fn plan_shelter(
320 324 pub enum BuildMode {
321 325 Place(StructureKind),
322 326 Floor,
327 + Bridge,
323 328 Deconstruct,
324 329 }
325 330
326 -pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [
331 +pub const BUILD_TOOL_CYCLE: [BuildMode; 10] = [
327 332 BuildMode::Place(StructureKind::Wall),
328 333 BuildMode::Place(StructureKind::Door),
329 334 BuildMode::Place(StructureKind::Campfire),
@@ -333,6 +338,7 @@ pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [
333 338 BuildMode::Place(StructureKind::Statue),
334 339 BuildMode::Place(StructureKind::Hearth),
335 340 BuildMode::Floor,
341 + BuildMode::Bridge,
336 342 ];
337 343
338 344 #[derive(Resource, Default, Debug)]
@@ -375,6 +381,15 @@ pub fn mark_deconstruction(
375 381 }
376 382 }
377 383
384 +pub fn place_bridges(map: &WorldMap, blueprints: &mut Blueprints, start: UVec2, end: UVec2) {
385 + for tile in rect(start, end) {
386 + let water = map.get(tile.as_ivec2()).is_some_and(|t| t.is_water());
387 + if water && blueprints.get(tile).is_none() {
388 + blueprints.0.insert(tile, BuildKind::Bridge);
389 + }
390 + }
391 +}
392 +
378 393 pub fn place_floors(map: &WorldMap, blueprints: &mut Blueprints, start: UVec2, end: UVec2) {
379 394 for tile in rect(start, end) {
380 395 let p = tile.as_ivec2();
@@ -477,6 +492,7 @@ fn build_tool(
477 492 place_blueprints(&map, &mut zones, &mut blueprints, &occupied, kind, &tiles);
478 493 }
479 494 BuildMode::Floor => place_floors(&map, &mut blueprints, start, end),
495 + BuildMode::Bridge => place_bridges(&map, &mut blueprints, start, end),
480 496 BuildMode::Deconstruct => mark_deconstruction(&map, &mut blueprints, &occupied, start, end),
481 497 }
482 498 }
@@ -515,7 +531,7 @@ fn draw_blueprints(mut gizmos: Gizmos, blueprints: Res<Blueprints>) {
515 531 BuildKind::Structure(StructureKind::Door) => DOOR_BLUEPRINT_COLOR,
516 532 BuildKind::Roof => ROOF_BLUEPRINT_COLOR,
517 533 BuildKind::Pave => PAVE_BLUEPRINT_COLOR,
518 - BuildKind::Floor => FLOOR_BLUEPRINT_COLOR,
534 + BuildKind::Floor | BuildKind::Bridge => FLOOR_BLUEPRINT_COLOR,
519 535 BuildKind::Deconstruct => DECONSTRUCT_COLOR,
520 536 _ => WALL_BLUEPRINT_COLOR,
521 537 };
@@ -703,4 +719,15 @@ mod tests {
703 719 assert_eq!(blueprints.0.len(), 9 - 3);
704 720 assert!(blueprints.0.values().all(|k| *k == BuildKind::Floor));
705 721 }
722 +
723 + #[test]
724 + fn the_bridge_tool_only_plans_over_water() {
725 + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
726 + map.set(UVec2::new(2, 2), Terrain::ShallowWater);
727 + map.set(UVec2::new(3, 2), Terrain::DeepWater);
728 + let mut blueprints = Blueprints::default();
729 + place_bridges(&map, &mut blueprints, UVec2::new(1, 1), UVec2::new(4, 3));
730 + assert_eq!(blueprints.0.len(), 2);
731 + assert!(blueprints.0.values().all(|k| *k == BuildKind::Bridge));
732 + }
706 733 }

src/jobs/mod.rs +45 -15

@@ -27,8 +27,8 @@ use crate::{
27 27 mood::{Ate, Sick},
28 28 needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
29 29 pathfinding::{
30 - ColonyReach, WANDER_SEARCH, can_step, find_nearby_path, find_path, find_path_adjacent,
31 - is_adjacent, nearest_of, nearest_tile, search_reach,
30 + ColonyReach, WANDER_SEARCH, can_step, find_bridged_path, find_nearby_path, find_path,
31 + find_path_adjacent, is_adjacent, nearest_of, nearest_tile, search_reach,
32 32 },
33 33 paths::busy_path,
34 34 rng::SimRng,
@@ -1812,8 +1812,33 @@ enum Step {
1812 1812 Done,
1813 1813 }
1814 1814
1815 -fn walk_to(map: &WorldMap, from: UVec2, to: UVec2, path: &mut Path) -> Step {
1816 - match find_path(map, from, to) {
1815 +pub const DETOUR_MIN_TILES: usize = 24;
1816 +const DETOUR_SEARCH: usize = 4000;
1817 +
1818 +pub fn note_detour(map: &mut WorldMap, from: UVec2, to: UVec2, route: Option<&[UVec2]>) {
1819 + let straight = (to.as_ivec2() - from.as_ivec2()).abs().max_element() as usize;
1820 + if route.is_some_and(|r| r.len() < DETOUR_MIN_TILES || r.len() * 2 < straight * 3) {
1821 + return;
1822 + }
1823 + let Some(bridged) = find_bridged_path(map, from, to, DETOUR_SEARCH) else {
1824 + return;
1825 + };
1826 + let shorter = route.is_none_or(|r| bridged.len() * 3 / 2 < r.len());
1827 + if !shorter {
1828 + return;
1829 + }
1830 + for tile in bridged {
1831 + let p = tile.as_ivec2();
1832 + if map.get(p).is_some_and(|t| t.is_water()) {
1833 + map.set_wear(tile, map.wear(p) + 1.0);
1834 + }
1835 + }
1836 +}
1837 +
1838 +fn walk_to(map: &mut WorldMap, from: UVec2, to: UVec2, path: &mut Path) -> Step {
1839 + let route = find_path(map, from, to);
1840 + note_detour(map, from, to, route.as_deref());
1841 + match route {
1817 1842 Some(p) if !p.is_empty() => {
1818 1843 path.0 = p.into();
1819 1844 Step::Continue
@@ -1936,7 +1961,7 @@ fn run_jobs(
1936 1961 Some(_) if pickup.is_some() => {
1937 1962 let from = pickup.expect("checked above");
1938 1963 if tile.0 != from {
1939 - walk_to(&map, tile.0, from, &mut path)
1964 + walk_to(&mut map, tile.0, from, &mut path)
1940 1965 } else {
1941 1966 reservations.release(Target::Pickup(from));
1942 1967 carrying.0 = take_items(&mut commands, &mut items, from, 1)
@@ -1979,7 +2004,7 @@ fn run_jobs(
1979 2004 if reservations.holder(Target::Pickup(from)) != Some(entity) {
1980 2005 Step::Done
1981 2006 } else if tile.0 != from {
1982 - walk_to(&map, tile.0, from, &mut path)
2007 + walk_to(&mut map, tile.0, from, &mut path)
1983 2008 } else {
1984 2009 reservations.release(Target::Pickup(from));
1985 2010 let stack = items
@@ -2010,7 +2035,7 @@ fn run_jobs(
2010 2035 if reservations.holder(Target::Pickup(from)) != Some(entity) {
2011 2036 Step::Done
2012 2037 } else if tile.0 != from {
2013 - walk_to(&map, tile.0, from, &mut path)
2038 + walk_to(&mut map, tile.0, from, &mut path)
2014 2039 } else {
2015 2040 let load = take_items(&mut commands, &mut items, from, input)
2016 2041 .filter(|load| recipe.accepts(load.kind) && load.count > 0);
@@ -2140,7 +2165,7 @@ fn run_jobs(
2140 2165 if reservations.holder(Target::Pickup(site)) != Some(entity) {
2141 2166 Step::Done
2142 2167 } else if tile.0 != site {
2143 - walk_to(&map, tile.0, site, &mut path)
2168 + walk_to(&mut map, tile.0, site, &mut path)
2144 2169 } else {
2145 2170 *progress += rate;
2146 2171 if *progress < BUTCHER_WORK {
@@ -2168,7 +2193,7 @@ fn run_jobs(
2168 2193 if reservations.holder(Target::Pickup(from)) != Some(entity) {
2169 2194 Step::Done
2170 2195 } else if tile.0 != from {
2171 - walk_to(&map, tile.0, from, &mut path)
2196 + walk_to(&mut map, tile.0, from, &mut path)
2172 2197 } else {
2173 2198 let kind = items
2174 2199 .iter()
@@ -2209,7 +2234,7 @@ fn run_jobs(
2209 2234 if reservations.holder(Target::SleepSpot(*spot)) != Some(entity) {
2210 2235 Step::Done
2211 2236 } else if tile.0 != *spot {
2212 - let step = walk_to(&map, tile.0, *spot, &mut path);
2237 + let step = walk_to(&mut map, tile.0, *spot, &mut path);
2213 2238 if matches!(step, Step::Done) {
2214 2239 reservations.release(Target::SleepSpot(*spot));
2215 2240 *spot = tile.0;
@@ -2270,7 +2295,7 @@ fn run_jobs(
2270 2295 }
2271 2296 Task::ExpandStockpile { site, progress } => {
2272 2297 if tile.0 != *site {
2273 - walk_to(&map, tile.0, *site, &mut path)
2298 + walk_to(&mut map, tile.0, *site, &mut path)
2274 2299 } else {
2275 2300 *progress += rate;
2276 2301 if *progress >= EXPAND_WORK {
@@ -2308,7 +2333,7 @@ fn run_jobs(
2308 2333 let structure_there = structures.iter().any(|(_, pos, ..)| pos.0 == site);
2309 2334 let blocked = map.plant(site.as_ivec2()).is_some()
2310 2335 || match kind {
2311 - BuildKind::Roof | BuildKind::Floor => false,
2336 + BuildKind::Roof | BuildKind::Floor | BuildKind::Bridge => false,
2312 2337 BuildKind::Refuel(fire) => !structures
2313 2338 .iter()
2314 2339 .any(|(_, pos, s, _)| pos.0 == site && s.kind == fire),
@@ -2324,7 +2349,7 @@ fn run_jobs(
2324 2349 if reservations.holder(Target::Pickup(from)) != Some(entity) {
2325 2350 Step::Done
2326 2351 } else if tile.0 != from {
2327 - walk_to(&map, tile.0, from, &mut path)
2352 + walk_to(&mut map, tile.0, from, &mut path)
2328 2353 } else if carrying.0.is_some_and(|load| load.kind != material) {
2329 2354 Step::Done
2330 2355 } else {
@@ -2401,6 +2426,11 @@ fn run_jobs(
2401 2426 map.set(site, Terrain::WoodFloor);
2402 2427 changed.write(TileChanged(site));
2403 2428 }
2429 + BuildKind::Bridge => {
2430 + map.set(site, Terrain::Bridge);
2431 + map.set_wear(site, 0.0);
2432 + changed.write(TileChanged(site));
2433 + }
2404 2434 BuildKind::Deconstruct => {
2405 2435 for (entity, pos, structure, _) in &structures {
2406 2436 if pos.0 != site {
@@ -2449,7 +2479,7 @@ fn run_jobs(
2449 2479 if reservations.holder(Target::Pickup(from)) != Some(entity) {
2450 2480 Step::Done
2451 2481 } else if tile.0 != from {
2452 - walk_to(&map, tile.0, from, &mut path)
2482 + walk_to(&mut map, tile.0, from, &mut path)
2453 2483 } else {
2454 2484 let kind = items
2455 2485 .iter()
@@ -2472,7 +2502,7 @@ fn run_jobs(
2472 2502 }
2473 2503 } else if let Some(load) = carrying.0 {
2474 2504 if tile.0 != to {
2475 - walk_to(&map, tile.0, to, &mut path)
2505 + walk_to(&mut map, tile.0, to, &mut path)
2476 2506 } else {
2477 2507 let room = room_for(items.iter().map(|(_, i, p)| (i, p)), to, load.kind);
2478 2508 let dropped = load.count.min(room);

src/jobs/tests.rs +26 -0

@@ -1636,3 +1636,29 @@ fn doctors_tend_the_wounded_with_herbs() {
1636 1636 Some(Task::Sleep { .. })
1637 1637 ));
1638 1638 }
1639 +
1640 +// Bridges over deep water are built from the shore outwards: each finished span is the footing
1641 +// for the next, until the far bank can be reached.
1642 +#[test]
1643 +fn colonists_bridge_deep_water_from_the_shore() {
1644 + let mut map = WorldMap::filled(UVec2::new(30, 20), Terrain::Grass);
1645 + for y in 0..20 {
1646 + for x in 12..16 {
1647 + map.set(UVec2::new(x, y), Terrain::DeepWater);
1648 + }
1649 + }
1650 + let mut app = test_app(map);
1651 + put_beds(&mut app, 1);
1652 + put_items(&mut app, UVec2::new(5, 5), ItemKind::Wood, 20);
1653 + let mut blueprints = app.world_mut().resource_mut::<Blueprints>();
1654 + for x in 12..16 {
1655 + blueprints.0.insert(UVec2::new(x, 8), BuildKind::Bridge);
1656 + }
1657 + spawn_colonist(&mut app, UVec2::new(6, 8));
1658 + run_ticks(&mut app, 60 * 60);
1659 + let map = app.world().resource::<WorldMap>();
1660 + for x in 12..16 {
1661 + assert_eq!(map.get(IVec2::new(x, 8)), Some(Terrain::Bridge));
1662 + }
1663 + assert!(find_path(map, UVec2::new(6, 8), UVec2::new(20, 8)).is_some());
1664 +}

src/pathfinding.rs +49 -4

@@ -5,7 +5,7 @@ use std::{
5 5
6 6 use bevy::prelude::*;
7 7
8 -use crate::world::{CHEAPEST_MOVE_COST, WorldMap};
8 +use crate::world::{CHEAPEST_MOVE_COST, Terrain, WorldMap};
9 9
10 10 const STRAIGHT: u32 = 10;
11 11 const DIAGONAL: u32 = 14;
@@ -229,6 +229,25 @@ fn astar_limited(
229 229 heuristic: impl Fn(IVec2) -> u32,
230 230 max_expanded: usize,
231 231 ) -> Option<Vec<UVec2>> {
232 + astar_core(map, start, is_goal, heuristic, max_expanded, false)
233 +}
234 +
235 +fn bridgeable(map: &WorldMap, p: IVec2) -> bool {
236 + !map.is_wall(p) && map.get(p).is_some_and(|t| t.is_water())
237 +}
238 +
239 +fn passable(map: &WorldMap, p: IVec2, bridging: bool) -> bool {
240 + map.move_cost(p).is_some() || (bridging && bridgeable(map, p))
241 +}
242 +
243 +fn astar_core(
244 + map: &WorldMap,
245 + start: UVec2,
246 + is_goal: impl Fn(IVec2) -> bool,
247 + heuristic: impl Fn(IVec2) -> u32,
248 + max_expanded: usize,
249 + bridging: bool,
250 +) -> Option<Vec<UVec2>> {
232 251 let start = start.as_ivec2();
233 252 if !map.in_bounds(start) {
234 253 return None;
@@ -286,11 +305,20 @@ fn astar_limited(
286 305 let current_cost = cost[index(current)];
287 306 for step in NEIGHBORS {
288 307 let next = current + step;
289 - if !can_step(map, current, step) {
308 + let corners_open = step.x == 0
309 + || step.y == 0
310 + || (passable(map, current + IVec2::new(step.x, 0), bridging)
311 + && passable(map, current + IVec2::new(0, step.y), bridging));
312 + if !passable(map, next, bridging) || !corners_open {
290 313 continue;
291 314 }
292 - let Some(terrain_cost) = map.step_cost(current, next) else {
293 - continue;
315 + let terrain_cost = if bridging && bridgeable(map, next) {
316 + Terrain::Bridge.move_cost().unwrap_or(STRAIGHT)
317 + } else {
318 + let Some(cost) = map.step_cost(current, next) else {
319 + continue;
320 + };
321 + cost
294 322 };
295 323 let diagonal = step.x != 0 && step.y != 0;
296 324 let base = if diagonal { DIAGONAL } else { STRAIGHT };
@@ -310,6 +338,23 @@ fn astar_limited(
310 338
311 339 pub const WANDER_SEARCH: usize = 800;
312 340
341 +pub fn find_bridged_path(
342 + map: &WorldMap,
343 + start: UVec2,
344 + goal: UVec2,
345 + max_expanded: usize,
346 +) -> Option<Vec<UVec2>> {
347 + let goal = goal.as_ivec2();
348 + astar_core(
349 + map,
350 + start,
351 + |p| p == goal,
352 + |p| octile(p, goal),
353 + max_expanded,
354 + true,
355 + )
356 +}
357 +
313 358 pub fn find_nearby_path(
314 359 map: &WorldMap,
315 360 start: UVec2,

src/paths.rs +79 -1

@@ -28,7 +28,40 @@ pub const PAVE_WEAR: f32 = 20.0;
28 28 pub const BUSY_WEAR: f32 = PAVE_WEAR;
29 29
30 30 pub fn busy_path(map: &WorldMap, tile: IVec2) -> bool {
31 - map.get(tile) == Some(Terrain::Paved) || map.wear(tile) >= BUSY_WEAR
31 + matches!(map.get(tile), Some(Terrain::Paved | Terrain::Bridge)) || map.wear(tile) >= BUSY_WEAR
32 +}
33 +
34 +pub const BRIDGE_WEAR: f32 = PATH_WEAR;
35 +pub const BRIDGE_BATCH: usize = 6;
36 +
37 +pub fn bridging_candidates(map: &WorldMap, blueprints: &Blueprints) -> Vec<UVec2> {
38 + let pending = blueprints
39 + .0
40 + .values()
41 + .filter(|k| **k == BuildKind::Bridge)
42 + .count();
43 + let reachable_from_land = |p: UVec2| {
44 + [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y]
45 + .iter()
46 + .any(|d| map.move_cost(p.as_ivec2() + *d).is_some())
47 + };
48 + let mut busiest: Vec<UVec2> = map
49 + .worn_tiles()
50 + .into_iter()
51 + .filter(|&p| {
52 + map.get(p.as_ivec2()).is_some_and(|t| t.is_water())
53 + && map.wear(p.as_ivec2()) >= BRIDGE_WEAR
54 + && blueprints.get(p).is_none()
55 + && reachable_from_land(p)
56 + })
57 + .collect();
58 + busiest.sort_by(|a, b| {
59 + map.wear(b.as_ivec2())
60 + .total_cmp(&map.wear(a.as_ivec2()))
61 + .then((a.y, a.x).cmp(&(b.y, b.x)))
62 + });
63 + busiest.truncate(BRIDGE_BATCH.saturating_sub(pending));
64 + busiest
32 65 }
33 66 pub const REGROW_WEAR: f32 = 1.0;
34 67 pub const WEAR_KEPT_PER_HOUR: f32 = 0.98;
@@ -119,6 +152,9 @@ fn plan_paving(
119 152 for tile in paving_candidates(&map, &blueprints, &occupied) {
120 153 blueprints.0.insert(tile, BuildKind::Pave);
121 154 }
155 + for tile in bridging_candidates(&map, &blueprints) {
156 + blueprints.0.insert(tile, BuildKind::Bridge);
157 + }
122 158 }
123 159
124 160 #[cfg(test)]
@@ -199,4 +235,46 @@ mod tests {
199 235 assert_eq!(picked[0], UVec2::new(17, 0));
200 236 assert!(!picked.contains(&UVec2::new(18, 0)));
201 237 }
238 +
239 + fn lake_map() -> WorldMap {
240 + let mut map = WorldMap::filled(UVec2::new(40, 30), Terrain::Grass);
241 + for y in 0..25 {
242 + for x in 18..22 {
243 + map.set(UVec2::new(x, y), Terrain::DeepWater);
244 + }
245 + }
246 + map
247 + }
248 +
249 + // A lake forces a long walk around; colonists notice the much shorter route a bridge would
250 + // give and put demand on its water tiles, then bridge them from the shore outwards.
251 + #[test]
252 + fn detours_around_water_lead_to_bridges() {
253 + let mut map = lake_map();
254 + let (from, to) = (UVec2::new(10, 5), UVec2::new(30, 5));
255 + let route = crate::pathfinding::find_path(&map, from, to).unwrap();
256 + let bridged = crate::pathfinding::find_bridged_path(&map, from, to, 4000).unwrap();
257 + assert!(bridged.len() * 2 < route.len());
258 + for _ in 0..BRIDGE_WEAR as usize {
259 + crate::jobs::note_detour(&mut map, from, to, Some(&route));
260 + }
261 + let blueprints = Blueprints::default();
262 + let plan = bridging_candidates(&map, &blueprints);
263 + assert!(!plan.is_empty());
264 + assert!(plan.iter().all(|t| t.x == 18 || t.x == 21), "{plan:?}");
265 + assert!(plan.iter().all(|t| (3..=7).contains(&t.y)), "{plan:?}");
266 + }
267 +
268 + #[test]
269 + fn short_or_straight_walks_put_no_demand_on_water() {
270 + let mut map = lake_map();
271 + let route = crate::pathfinding::find_path(&map, UVec2::new(10, 28), UVec2::new(30, 28));
272 + crate::jobs::note_detour(
273 + &mut map,
274 + UVec2::new(10, 28),
275 + UVec2::new(30, 28),
276 + route.as_deref(),
277 + );
278 + assert!(map.worn_tiles().is_empty());
279 + }
202 280 }

src/selection.rs +3 -0

@@ -453,6 +453,9 @@ fn update_tile_info(
453 453 "\nSTOCKPILE TOOL: drag to add, shift-drag to remove".to_string()
454 454 }
455 455 (Some(ZoneKind::Farm), _) => "\nFARM TOOL: drag to add, shift-drag to remove".to_string(),
456 + (None, Some(BuildMode::Bridge)) => {
457 + "\nBUILD BRIDGE: drag over water, shift-drag to remove plans, B for next".to_string()
458 + }
456 459 (None, Some(BuildMode::Floor)) => {
457 460 "\nBUILD FLOOR: drag an area, shift-drag to remove plans, B for next".to_string()
458 461 }

src/tilemap.rs +6 -0

@@ -370,10 +370,16 @@ fn terrain_texture(terrain: Terrain) -> Texture {
370 370 let mortar = terrain == Terrain::Paved
371 371 && (y % 5 == 0 || (x + if (y / 5) % 2 == 0 { 0 } else { 4 }) % 8 == 0);
372 372 let seam = terrain == Terrain::WoodFloor && (y % 4 == 0 || (x + (y / 4) * 5) % 16 == 0);
373 + let rail = terrain == Terrain::Bridge && (x <= 1 || x >= TEXTURE_SIZE - 2);
374 + let gap = terrain == Terrain::Bridge && y % 4 == 0;
373 375 let c = if mortar {
374 376 Srgba::rgb_u8(110, 106, 100)
375 377 } else if seam {
376 378 Srgba::rgb_u8(100, 70, 42)
379 + } else if rail {
380 + Srgba::rgb_u8(80, 56, 34)
381 + } else if gap {
382 + Terrain::ShallowWater.color()
377 383 } else {
378 384 speck.unwrap_or(base)
379 385 };

src/world.rs +5 -2

@@ -23,10 +23,11 @@ pub enum Terrain {
23 23 Path,
24 24 Paved,
25 25 WoodFloor,
26 + Bridge,
26 27 }
27 28
28 29 impl Terrain {
29 - pub const ALL: [Terrain; 12] = [
30 + pub const ALL: [Terrain; 13] = [
30 31 Terrain::Grass,
31 32 Terrain::Soil,
32 33 Terrain::Sand,
@@ -39,6 +40,7 @@ impl Terrain {
39 40 Terrain::Path,
40 41 Terrain::Paved,
41 42 Terrain::WoodFloor,
43 + Terrain::Bridge,
42 44 ];
43 45
44 46 pub fn index(self) -> u16 {
@@ -48,7 +50,7 @@ impl Terrain {
48 50 pub fn move_cost(self) -> Option<u32> {
49 51 match self {
50 52 Terrain::Paved | Terrain::WoodFloor => Some(CHEAPEST_MOVE_COST),
51 - Terrain::Path => Some(80),
53 + Terrain::Path | Terrain::Bridge => Some(80),
52 54 Terrain::Grass | Terrain::Soil => Some(100),
53 55 Terrain::Sand | Terrain::Gravel => Some(130),
54 56 Terrain::ShallowWater => Some(300),
@@ -76,6 +78,7 @@ impl Terrain {
76 78 Terrain::Path => Srgba::rgb_u8(160, 128, 86),
77 79 Terrain::Paved => Srgba::rgb_u8(168, 164, 156),
78 80 Terrain::WoodFloor => Srgba::rgb_u8(150, 108, 66),
81 + Terrain::Bridge => Srgba::rgb_u8(132, 96, 60),
79 82 }
80 83 }
81 84

src/worldgen.rs +5 -1

@@ -381,7 +381,11 @@ mod tests {
381 381 let generated = |t: &Terrain| {
382 382 !matches!(
383 383 t,
384 - Terrain::Gravel | Terrain::Path | Terrain::Paved | Terrain::WoodFloor
384 + Terrain::Gravel
385 + | Terrain::Path
386 + | Terrain::Paved
387 + | Terrain::WoodFloor
388 + | Terrain::Bridge
385 389 )
386 390 };
387 391 for terrain in Terrain::ALL.into_iter().filter(generated) {

src/zones.rs +5 -2

@@ -11,7 +11,7 @@ use crate::{
11 11 plants::fertility,
12 12 selection::{Cursor, update_cursor},
13 13 ui::PointerOverUi,
14 - world::{TILE_SIZE, WorldMap},
14 + world::{TILE_SIZE, Terrain, WorldMap},
15 15 worldgen::WorldGenerated,
16 16 };
17 17
@@ -319,7 +319,10 @@ pub fn plan_expansion(
319 319 }
320 320
321 321 pub fn can_zone(map: &WorldMap, tile: IVec2) -> bool {
322 - map.move_cost(tile).is_some() && !map.get(tile).is_some_and(|t| t.is_water())
322 + map.move_cost(tile).is_some()
323 + && !map
324 + .get(tile)
325 + .is_some_and(|t| t.is_water() || t == Terrain::Bridge)
323 326 }
324 327
325 328 pub fn can_farm(map: &WorldMap, tile: IVec2) -> bool {

tasks.md +1 -0

@@ -56,6 +56,7 @@
56 56 - [x] Variable home sizes (solo room, couple, more space per child); solo colonists and grown children get their own room; families expand homes by moving a wall out
57 57 - [x] Colonists keep busy paths (paved or heavily worn) free when planning buildings
58 58 - [x] Optimization: wolves give up on unreachable prey, cached warm spots, cheaper heat checks
59 +- [x] Bridges: colonists bridge busy wading spots and water on much shorter (or otherwise impossible) routes, over deep water from the shore outwards; Build tool Bridge mode
59 60 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
60 61
61 62 ## Planned