diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 161c9e1c..2066cb21 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -1290,11 +1290,14 @@ impl GdCity { /// Process food growth. Pass tile yields as JSON array: /// `[{col, row, food, production, gold, culture, science}, ...]` + /// `growth_modifier` is the happiness-tier scalar from + /// `GdHappiness.calculate().growth_modifier` (1.25/1.0/0.5/0.0). Pass + /// `1.0` from contexts that don't model happiness (proof scenes, tests). /// Returns population delta: +1 growth, -1 starvation, 0 neither. #[func] - fn process_growth(&mut self, tile_yields_json: GString) -> i64 { + fn process_growth(&mut self, tile_yields_json: GString, growth_modifier: f64) -> i64 { let ty = Self::parse_tile_yields(&tile_yields_json.to_string()); - self.inner.process_growth(&ty) as i64 + self.inner.process_growth(&ty, growth_modifier) as i64 } /// Process culture accumulation. Returns true if border expansion is ready. diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index 278445df..6b994b04 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -450,9 +450,15 @@ impl City { // ── Per-turn processing ──────────────────────────────────────── /// Process food accumulation and population growth/starvation. + /// + /// `growth_modifier` scales the food surplus before it accumulates into + /// `food_stored` — sourced from `mc_happiness::get_growth_modifier` + /// (`1.25` Happy / Golden Age, `1.00` Content, `0.50` Unhappy, `0.00` + /// Revolt). Callers that don't want the modifier (proof scenes, unit + /// tests, Rust-only entry points) pass `1.0` for full-rate growth. /// Returns the population delta (+1 for growth, -1 for starvation, 0 otherwise). - pub fn process_growth(&mut self, tile_yields: &[TileYield]) -> i32 { - let surplus = self.get_food_surplus(tile_yields); + pub fn process_growth(&mut self, tile_yields: &[TileYield], growth_modifier: f64) -> i32 { + let surplus = self.get_food_surplus(tile_yields) * growth_modifier; self.food_stored += surplus; if self.food_stored >= self.growth_threshold() { @@ -866,15 +872,42 @@ mod tests { // Surplus per turn: (4 + 5) - 1.0*1 = 8.0 // Threshold at pop 1: 15.0 // Turn 1: food_stored = 8.0 - assert_eq!(city.process_growth(&ty), 0); + assert_eq!(city.process_growth(&ty, 1.0), 0); assert!((city.food_stored - 8.0).abs() < 1e-9); // Turn 2: food_stored = 16.0 >= 15 → grow. Carryover = 0.5*15 = 7.5 // Surplus-over = 16.0 - 15.0 = 1.0. New stored = 7.5 + 1.0 = 8.5 - assert_eq!(city.process_growth(&ty), 1); + assert_eq!(city.process_growth(&ty, 1.0), 1); assert_eq!(city.population, 2); assert!((city.food_stored - 8.5).abs() < 1e-9); } + #[test] + fn process_growth_modifier_scales_surplus() { + // Happy / Golden Age modifier (1.25) accumulates 25% more food per + // turn than Content (1.0). Mirrors mc_happiness::get_growth_modifier. + let mut content = City::found("Anvil", (5, 5), true, 1, None); + let mut happy = City::found("Anvil", (5, 5), true, 1, None); + let ty: Vec = vec![]; + // Surplus per turn at pop 1: 4 (city center) - 1.0*1 = 3.0 + content.process_growth(&ty, 1.0); + happy.process_growth(&ty, 1.25); + assert!((content.food_stored - 3.0).abs() < 1e-9); + assert!((happy.food_stored - 3.75).abs() < 1e-9); + + // Revolt modifier (0.0) freezes accumulation entirely. + let mut revolt = City::found("Anvil", (5, 5), true, 1, None); + revolt.process_growth(&ty, 0.0); + assert!(revolt.food_stored.abs() < 1e-9); + + // Unhappy modifier (0.5) negative surplus still drives starvation — + // a half-rate deficit is still a deficit. pop 5, no worked tiles: + // surplus = 4 - 5 = -1, scaled = -0.5 → still triggers starve. + let mut unhappy = City::found("Anvil", (5, 5), true, 1, None); + unhappy.population = 5; + assert_eq!(unhappy.process_growth(&ty, 0.5), -1); + assert_eq!(unhappy.population, 4); + } + #[test] fn process_growth_starvation() { let mut city = City::found("Ironhold", (5, 5), true, 1, None); @@ -883,7 +916,7 @@ mod tests { // Consumption = 1.0*5 = 5.0. Surplus = 4 - 5.0 = -1.0 // food_stored: 0 + (-1.0) = -1.0 < 0, pop > 1 → starve let ty: Vec = vec![]; - assert_eq!(city.process_growth(&ty), -1); + assert_eq!(city.process_growth(&ty, 1.0), -1); assert_eq!(city.population, 4); assert_eq!(city.food_stored, 0.0); } @@ -895,7 +928,7 @@ mod tests { // Even with negative surplus, pop can't drop below 1 let ty: Vec = vec![]; // Surplus = 3 - 2 = 1 (positive), so growth not starvation at pop 1 - assert_eq!(city.process_growth(&ty), 0); + assert_eq!(city.process_growth(&ty, 1.0), 0); assert_eq!(city.population, 1); } diff --git a/src/simulator/crates/mc-mapgen/src/lib.rs b/src/simulator/crates/mc-mapgen/src/lib.rs index 31f38978..edac1dea 100644 --- a/src/simulator/crates/mc-mapgen/src/lib.rs +++ b/src/simulator/crates/mc-mapgen/src/lib.rs @@ -149,6 +149,13 @@ impl MapGenerator { let mut moisture_map: HashMap<(i32, i32), f32> = HashMap::with_capacity((w * h) as usize); compute_moisture(&mut gen_tiles, &mut moisture_map, &mut rng); + // Stage 7.5: Rivers — flow downhill from high-moisture / high-elevation + // sources toward the sea. Marks `river_edges` symmetrically on both adjacent + // tiles. Runs after moisture (so we know which tiles are wet) and after + // tectonic relief (so elevation is final), but before terrain patches so + // riverside biome flavour can hook in later. + generate_rivers(&mut gen_tiles, &mut rng); + // Stage 8: Terrain patches assign_terrain_patches(&mut gen_tiles, &mut rng); @@ -535,6 +542,144 @@ fn compute_moisture( } } +/// Source-elevation threshold (post-`normalize_elevation`, range `[0, 1]`). +/// 0.55 is "upper-middle land" — above sea level by a comfortable margin +/// but well below mountain peaks. Tiles below this never become river sources. +const RIVER_SOURCE_ELEVATION_MIN: f32 = 0.55; + +/// Source-moisture threshold (post-`compute_moisture`, range `[0, 1]`). +/// 0.50 is the median; combined with the elevation threshold this picks +/// uplands with above-average moisture as headwaters. +const RIVER_SOURCE_MOISTURE_MIN: f32 = 0.50; + +/// Maximum tiles a single river will traverse before terminating. +/// Caps runaway flow on flat terrain. A 40×24 duel map has at most 24 tile +/// hops along its long axis; 30 is generous enough to reach the sea from +/// any source while still bounding cost. +const RIVER_MAX_LENGTH: usize = 30; + +/// Fraction of eligible (high-elevation, high-moisture, non-water) tiles +/// that become river sources. ~5% gives ~3–8 rivers on a duel map and +/// scales linearly with map area. +const RIVER_SOURCE_FRACTION: f32 = 0.05; + +/// Reverse a hex direction index. Direction `dir ∈ [0..6)` corresponds to +/// `AXIAL_DIRECTIONS[dir]`; the reverse is offset by 3 (E↔W, NE↔SW, NW↔SE). +#[inline] +fn reverse_dir(dir: usize) -> i32 { + ((dir + 3) % 6) as i32 +} + +/// Generate rivers by flowing downhill from high-moisture / high-elevation +/// sources to the sea. Marks `river_edges` on **both** adjacent tiles using +/// reciprocal direction indices so river presence is symmetric from either +/// side of the boundary. +/// +/// Determinism: PRNG is consumed in a stable order (sorted candidate pool, +/// `rng.randi_range` indexing). Repeated calls with the same seed produce +/// identical river edges. +fn generate_rivers(gen_tiles: &mut HashMap<(i32, i32), GenTile>, rng: &mut Pcg32) { + // Gather eligible source candidates — sorted for deterministic RNG consumption. + let mut candidates: Vec<(i32, i32)> = gen_tiles + .values() + .filter(|gt| { + gt.biome_id != "ocean" + && gt.biome_id != "coast" + && gt.elevation >= RIVER_SOURCE_ELEVATION_MIN + && gt.moisture >= RIVER_SOURCE_MOISTURE_MIN + }) + .map(|gt| (gt.q, gt.r)) + .collect(); + candidates.sort_unstable(); + + if candidates.is_empty() { + return; + } + + let target = ((candidates.len() as f32 * RIVER_SOURCE_FRACTION).round() as usize).max(1); + + // Fisher-Yates style draw without replacement — preserves determinism while + // ensuring no source is picked twice. + let mut sources: Vec<(i32, i32)> = Vec::with_capacity(target); + while sources.len() < target && !candidates.is_empty() { + let idx = rng.randi_range(0, candidates.len() as i32 - 1) as usize; + sources.push(candidates.remove(idx)); + } + + // Trace each river from source toward the sea. + for (start_q, start_r) in sources { + let mut q = start_q; + let mut r = start_r; + let mut visited: HashSet<(i32, i32)> = HashSet::new(); + visited.insert((q, r)); + + for _step in 0..RIVER_MAX_LENGTH { + let cur_elev = match gen_tiles.get(&(q, r)) { + Some(t) => t.elevation, + None => break, + }; + + // Pick the unvisited neighbour with the lowest elevation strictly + // below the current tile. Ties broken by lowest direction index + // (deterministic under sorted iteration). + let mut best_dir: Option = None; + let mut best_elev = cur_elev; + + for (dir, &(dq, dr)) in hex::AXIAL_DIRECTIONS.iter().enumerate() { + let nq = q + dq; + let nr = r + dr; + if visited.contains(&(nq, nr)) { + continue; + } + if let Some(nt) = gen_tiles.get(&(nq, nr)) { + if nt.elevation < best_elev { + best_elev = nt.elevation; + best_dir = Some(dir); + } + } + } + + let dir = match best_dir { + Some(d) => d, + None => break, // Local minimum — no downhill option. + }; + + let (dq, dr) = hex::AXIAL_DIRECTIONS[dir]; + let nq = q + dq; + let nr = r + dr; + + // Mark the edge symmetrically: this tile records the outgoing + // direction; the neighbour records the reverse. + let dir_i32 = dir as i32; + let rev = reverse_dir(dir); + if let Some(gt) = gen_tiles.get_mut(&(q, r)) { + if !gt.river_edges.contains(&dir_i32) { + gt.river_edges.push(dir_i32); + } + } + if let Some(nt) = gen_tiles.get_mut(&(nq, nr)) { + if !nt.river_edges.contains(&rev) { + nt.river_edges.push(rev); + } + } + + // Stop once the river hits open water — the river has reached the + // sea (or a coastal lake-mouth equivalent) and disperses there. + let neighbour_biome = gen_tiles + .get(&(nq, nr)) + .map(|t| t.biome_id.clone()) + .unwrap_or_default(); + if neighbour_biome == "ocean" || neighbour_biome == "coast" { + break; + } + + visited.insert((nq, nr)); + q = nq; + r = nr; + } + } +} + fn assign_terrain_patches( gen_tiles: &mut HashMap<(i32, i32), GenTile>, rng: &mut Pcg32, @@ -664,6 +809,7 @@ fn to_grid_state(gen_tiles: &HashMap<(i32, i32), GenTile>, w: i32, h: i32) -> Gr tile.quality = gt.quality; tile.quality_progress = (col * 7 + row * 13) % 11 - 5; // stagger tile.is_coastal = gt.is_coastal; + tile.river_edges = gt.river_edges.clone(); } } } @@ -853,6 +999,73 @@ mod tests { assert!(land > 0, "Map should have land tiles"); } + #[test] + fn river_gen_produces_at_least_one_river_on_duel() { + // A 40×24 duel map has enough land + relief that we expect rivers to + // appear on most seeds. If this test ever flakes, raise the seed + // diversity rather than weakening the assertion. + let gen = MapGenerator::new("{}"); + let grid = gen.generate(42, "duel"); + let total: usize = grid.tiles.iter().map(|t| t.river_edges.len()).sum(); + assert!( + total > 0, + "expected at least one river edge on a 40×24 duel map, got 0" + ); + } + + #[test] + fn river_gen_is_deterministic_across_runs() { + let gen = MapGenerator::new("{}"); + let g1 = gen.generate(123, "duel"); + let g2 = gen.generate(123, "duel"); + let edges1: Vec<&Vec> = g1.tiles.iter().map(|t| &t.river_edges).collect(); + let edges2: Vec<&Vec> = g2.tiles.iter().map(|t| &t.river_edges).collect(); + assert_eq!( + edges1, edges2, + "river_edges differ between runs of the same seed — non-determinism in generate_rivers" + ); + } + + #[test] + fn river_edges_are_symmetric_between_neighbours() { + // Every river edge marked on tile A in direction `dir` must have the + // reciprocal direction marked on the neighbour tile B. Asymmetry would + // break combat and pathfinding consultation that addresses an edge + // from either side. + let gen = MapGenerator::new("{}"); + let grid = gen.generate(7, "duel"); + let w = grid.width; + let h = grid.height; + + for row in 0..h { + for col in 0..w { + let idx = grid.idx(col, row); + let tile = &grid.tiles[idx]; + let (q, r) = mc_core::algorithms::hex::offset_to_axial(col, row); + + for &dir in &tile.river_edges { + let (dq, dr) = mc_core::algorithms::hex::AXIAL_DIRECTIONS[dir as usize]; + let nq = q + dq; + let nr = r + dr; + let (ncol, nrow) = mc_core::algorithms::hex::axial_to_offset(nq, nr); + + if ncol < 0 || ncol >= w || nrow < 0 || nrow >= h { + continue; // edge to off-map tile is allowed (river ends at boundary) + } + + let nidx = grid.idx(ncol, nrow); + let ntile = &grid.tiles[nidx]; + let rev = ((dir as usize + 3) % 6) as i32; + assert!( + ntile.river_edges.contains(&rev), + "asymmetric river edge: tile ({col},{row}) has dir {dir} but neighbour \ + ({ncol},{nrow}) does not have reverse dir {rev}" + ); + } + } + } + } + #[test] fn test_map_gen_standard_speed() { // Standard map must complete in reasonable time (performance regression guard).