feat(simulator): Introduce happiness-tiered food production scaling in city simulation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-27 01:23:08 -07:00
parent c16dbe95fe
commit bb84f3f694
3 changed files with 257 additions and 8 deletions

View file

@ -1290,11 +1290,14 @@ impl GdCity {
/// Process food growth. Pass tile yields as JSON array: /// Process food growth. Pass tile yields as JSON array:
/// `[{col, row, food, production, gold, culture, science}, ...]` /// `[{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. /// Returns population delta: +1 growth, -1 starvation, 0 neither.
#[func] #[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()); 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. /// Process culture accumulation. Returns true if border expansion is ready.

View file

@ -450,9 +450,15 @@ impl City {
// ── Per-turn processing ──────────────────────────────────────── // ── Per-turn processing ────────────────────────────────────────
/// Process food accumulation and population growth/starvation. /// 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). /// Returns the population delta (+1 for growth, -1 for starvation, 0 otherwise).
pub fn process_growth(&mut self, tile_yields: &[TileYield]) -> i32 { pub fn process_growth(&mut self, tile_yields: &[TileYield], growth_modifier: f64) -> i32 {
let surplus = self.get_food_surplus(tile_yields); let surplus = self.get_food_surplus(tile_yields) * growth_modifier;
self.food_stored += surplus; self.food_stored += surplus;
if self.food_stored >= self.growth_threshold() { if self.food_stored >= self.growth_threshold() {
@ -866,15 +872,42 @@ mod tests {
// Surplus per turn: (4 + 5) - 1.0*1 = 8.0 // Surplus per turn: (4 + 5) - 1.0*1 = 8.0
// Threshold at pop 1: 15.0 // Threshold at pop 1: 15.0
// Turn 1: food_stored = 8.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); assert!((city.food_stored - 8.0).abs() < 1e-9);
// Turn 2: food_stored = 16.0 >= 15 → grow. Carryover = 0.5*15 = 7.5 // 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 // 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_eq!(city.population, 2);
assert!((city.food_stored - 8.5).abs() < 1e-9); 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<TileYield> = 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] #[test]
fn process_growth_starvation() { fn process_growth_starvation() {
let mut city = City::found("Ironhold", (5, 5), true, 1, None); 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 // Consumption = 1.0*5 = 5.0. Surplus = 4 - 5.0 = -1.0
// food_stored: 0 + (-1.0) = -1.0 < 0, pop > 1 → starve // food_stored: 0 + (-1.0) = -1.0 < 0, pop > 1 → starve
let ty: Vec<TileYield> = vec![]; let ty: Vec<TileYield> = 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.population, 4);
assert_eq!(city.food_stored, 0.0); assert_eq!(city.food_stored, 0.0);
} }
@ -895,7 +928,7 @@ mod tests {
// Even with negative surplus, pop can't drop below 1 // Even with negative surplus, pop can't drop below 1
let ty: Vec<TileYield> = vec![]; let ty: Vec<TileYield> = vec![];
// Surplus = 3 - 2 = 1 (positive), so growth not starvation at pop 1 // 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); assert_eq!(city.population, 1);
} }

View file

@ -149,6 +149,13 @@ impl MapGenerator {
let mut moisture_map: HashMap<(i32, i32), f32> = HashMap::with_capacity((w * h) as usize); 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); 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 // Stage 8: Terrain patches
assign_terrain_patches(&mut gen_tiles, &mut rng); 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 ~38 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<usize> = 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( fn assign_terrain_patches(
gen_tiles: &mut HashMap<(i32, i32), GenTile>, gen_tiles: &mut HashMap<(i32, i32), GenTile>,
rng: &mut Pcg32, 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 = gt.quality;
tile.quality_progress = (col * 7 + row * 13) % 11 - 5; // stagger tile.quality_progress = (col * 7 + row * 13) % 11 - 5; // stagger
tile.is_coastal = gt.is_coastal; 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"); 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<i32>> = g1.tiles.iter().map(|t| &t.river_edges).collect();
let edges2: Vec<&Vec<i32>> = 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] #[test]
fn test_map_gen_standard_speed() { fn test_map_gen_standard_speed() {
// Standard map must complete in reasonable time (performance regression guard). // Standard map must complete in reasonable time (performance regression guard).