feat(simulator): ✨ Introduce happiness-tiered food production scaling in city simulation
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c16dbe95fe
commit
bb84f3f694
3 changed files with 257 additions and 8 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ~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<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).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue