feat(mc-city): Add yield channeling and quality scaling for collectible resource generation in city simulation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 22:02:36 -07:00
parent 151c0138fd
commit 1942887d4d
3 changed files with 251 additions and 6 deletions

View file

@ -10,10 +10,34 @@
use crate::production::{
BuildingQueue, CompletedEntry, ItemRegistry, QueueEntry, QueueError, Queueable,
};
use mc_core::collectibles::CollectibleRoll;
use mc_economy::{Stockpile, StockpileError};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
/// Which `CityYields` channel a collectible resource_id maps to.
/// Resources not matched here are silently skipped (they gate units/buildings
/// but don't add per-turn city yields).
enum YieldChannel {
Food,
Production,
Gold,
}
fn collectible_channel(resource_id: &str) -> Option<YieldChannel> {
match resource_id {
"grain" | "wild_game" | "fish" | "mushrooms" | "rare_herbs" => Some(YieldChannel::Food),
"hardwood" | "softwood" | "stone" | "iron_ore" | "peat" => Some(YieldChannel::Production),
"gems" | "pearls" | "ivory" | "furs" | "salt" => Some(YieldChannel::Gold),
_ => None,
}
}
/// Gold-value scale factor per quality point above 1 for collectible-to-yield
/// conversion. Mirrors TILE_QUALITY_TO_QUANTITY_MULTIPLIER in mc-core so that
/// city yield math stays consistent with the drop-quantity math.
const COLLECTIBLE_QUALITY_SCALE: f64 = 0.2;
/// Aggregate per-turn yields for a city.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct CityYields {
@ -73,6 +97,10 @@ pub struct TileYield {
pub gold: f64,
pub culture: f64,
pub science: f64,
/// Collectible drops resolved for this tile this turn by mc-core::tile_collectibles.
/// Empty vec is the zero case (ocean, uncollected, or biome with no table).
#[serde(default)]
pub collectibles: Vec<CollectibleRoll>,
}
impl TileYield {
@ -355,7 +383,7 @@ impl City {
yields.science += CITY_CENTER_BASELINE_SCIENCE;
yields.culture += CITY_CENTER_BASELINE_CULTURE;
// Sum worked tile yields
// Sum worked tile yields (flat fields + collectible rolls)
for wt in &self.worked_tiles {
if let Some(ty) = tile_yields.iter().find(|t| t.coord == *wt) {
let t = ty.total();
@ -364,6 +392,18 @@ impl City {
yields.gold += t.gold;
yields.culture += t.culture;
yields.science += t.science;
for roll in &ty.collectibles {
let quality_mult =
1.0 + (roll.quality.saturating_sub(1) as f64) * COLLECTIBLE_QUALITY_SCALE;
let amount = roll.quantity as f64 * quality_mult;
match collectible_channel(&roll.resource_id) {
Some(YieldChannel::Food) => yields.food += amount,
Some(YieldChannel::Production) => yields.production += amount,
Some(YieldChannel::Gold) => yields.gold += amount,
None => {}
}
}
}
}
@ -695,11 +735,11 @@ mod tests {
fn sample_tile_yields() -> Vec<TileYield> {
vec![
TileYield { coord: (5, 5), food: 2.0, production: 1.0, gold: 0.0, culture: 0.0, science: 0.0 },
TileYield { coord: (6, 5), food: 3.0, production: 0.0, gold: 1.0, culture: 0.0, science: 0.0 },
TileYield { coord: (5, 6), food: 1.0, production: 2.0, gold: 0.0, culture: 1.0, science: 0.0 },
TileYield { coord: (4, 5), food: 0.0, production: 0.0, gold: 3.0, culture: 0.0, science: 1.0 },
TileYield { coord: (6, 6), food: 1.0, production: 1.0, gold: 1.0, culture: 1.0, science: 1.0 },
TileYield { coord: (5, 5), food: 2.0, production: 1.0, gold: 0.0, culture: 0.0, science: 0.0, ..TileYield::default() },
TileYield { coord: (6, 5), food: 3.0, production: 0.0, gold: 1.0, culture: 0.0, science: 0.0, ..TileYield::default() },
TileYield { coord: (5, 6), food: 1.0, production: 2.0, gold: 0.0, culture: 1.0, science: 0.0, ..TileYield::default() },
TileYield { coord: (4, 5), food: 0.0, production: 0.0, gold: 3.0, culture: 0.0, science: 1.0, ..TileYield::default() },
TileYield { coord: (6, 6), food: 1.0, production: 1.0, gold: 1.0, culture: 1.0, science: 1.0, ..TileYield::default() },
]
}
@ -964,4 +1004,73 @@ mod tests {
let done = city.tick_building("smithy", 30).unwrap();
assert_eq!(done.len(), 1);
}
#[test]
fn collectibles_fold_into_yields() {
// Golden test: known CollectibleRoll vector → exact CityYields.
//
// Setup: pop-2 city working the center tile (5,5) and one extra tile (6,5).
// Tile (6,5) has flat food=1 plus two collectible rolls:
// - grain q=1 qty=3 → food channel. mult=1.0 → +3.0 food
// - gems q=5 qty=1 → gold channel. mult=1+(4*0.2)=1.8 → +1.8 gold
// City center baseline: food=4, prod=2, gold=3, sci=5, cult=2.
// Tile (5,5) worked (center tile): flat food=0 (no TileYield entry → skipped, baseline already applied).
// Tile (6,5) worked: flat food=1, collectible food=3.0, collectible gold=1.8.
// Expected totals: food=4+1+3=8, gold=3+1.8=4.8, prod=2, sci=5, cult=2.
let mut city = City::found("Ironhold", (5, 5), true, 1);
city.population = 2;
city.owned_tiles = vec![(5, 5), (6, 5)];
city.worked_tiles = vec![(5, 5), (6, 5)];
let ty = vec![
TileYield {
coord: (6, 5),
food: 1.0,
collectibles: vec![
CollectibleRoll { resource_id: "grain".to_string(), quantity: 3, quality: 1 },
CollectibleRoll { resource_id: "gems".to_string(), quantity: 1, quality: 5 },
],
..TileYield::default()
},
];
let yields = city.get_yields(&ty);
assert!((yields.food - 8.0).abs() < 1e-9, "food: expected 8.0, got {}", yields.food);
assert!((yields.gold - 4.8).abs() < 1e-9, "gold: expected 4.8, got {}", yields.gold);
assert!((yields.production - CITY_CENTER_BASELINE_PRODUCTION).abs() < 1e-9);
assert!((yields.science - CITY_CENTER_BASELINE_SCIENCE).abs() < 1e-9);
assert!((yields.culture - CITY_CENTER_BASELINE_CULTURE).abs() < 1e-9);
}
#[test]
fn unknown_collectible_resource_silently_skipped() {
let mut city = City::found("Ironhold", (5, 5), true, 1);
city.owned_tiles = vec![(5, 5), (6, 5)];
city.worked_tiles = vec![(5, 5), (6, 5)];
let base_yields = city.get_yields(&[]);
let ty = vec![
TileYield {
coord: (6, 5),
collectibles: vec![
// "mithril" is a strategic resource — no yield channel, must be ignored.
CollectibleRoll { resource_id: "mithril".to_string(), quantity: 10, quality: 10 },
],
..TileYield::default()
},
];
let yields = city.get_yields(&ty);
// Flat tile (6,5) has zero yields and mithril has no channel → totals unchanged.
assert!((yields.food - base_yields.food).abs() < 1e-9);
assert!((yields.gold - base_yields.gold).abs() < 1e-9);
}
#[test]
fn tile_yield_serde_backward_compat() {
// Existing JSON without "collectibles" field must deserialize cleanly.
let json = r#"{"coord":[5,5],"food":2.0,"production":1.0,"gold":0.0,"culture":0.0,"science":0.0}"#;
let ty: TileYield = serde_json::from_str(json).unwrap();
assert!(ty.collectibles.is_empty());
assert!((ty.food - 2.0).abs() < 1e-9);
}
}

View file

@ -1,5 +1,6 @@
pub mod production;
pub mod city;
pub mod yield_fold;
use serde::{Deserialize, Serialize};
@ -16,6 +17,10 @@ pub use city::{
growth_threshold, culture_expansion_threshold,
};
pub use yield_fold::{
tile_yields_from_collectibles, CollectiblesIndex, ResourceYieldMap, ResourceYields,
};
/// Lightweight per-city state used by the bench-grade turn processor.
///
/// This is the minimal struct the headless `fauna_pressure_bench`, `solo_dominion`,

View file

@ -0,0 +1,131 @@
use std::collections::BTreeMap;
use mc_core::collectibles::CollectibleRoll;
use crate::city::TileYield;
/// Yield contributed per unit of a resource from a deposit.
/// Populated at game-data load from the deposit's `*_bonus` fields.
#[derive(Debug, Clone, Default)]
pub struct ResourceYields {
pub food: f64,
pub production: f64,
pub gold: f64,
pub culture: f64,
pub science: f64,
}
/// Maps tile coord → resolved collectible rolls for that tile.
pub type CollectiblesIndex = BTreeMap<(i32, i32), Vec<CollectibleRoll>>;
/// Maps resource_id → the yields that one unit of that resource contributes.
pub type ResourceYieldMap = BTreeMap<String, ResourceYields>;
/// Quality scale: each quality point above 1 adds this fraction on top of
/// the base yield. Matches TILE_QUALITY_TO_QUANTITY_MULTIPLIER in collectibles.rs.
const QUALITY_YIELD_BONUS_PER_POINT: f64 = 0.2;
/// Convert a `CollectiblesIndex` + deposit `ResourceYieldMap` into a `Vec<TileYield>`
/// suitable for passing to `City::get_yields`.
///
/// For each tile coord in `index`, sums across every `CollectibleRoll`:
/// tile_yield += resource_base_yields × quantity × (1 + quality_bonus)
/// where `quality_bonus = (quality - 1) × QUALITY_YIELD_BONUS_PER_POINT`.
///
/// Resources not found in `resource_map` contribute nothing (unknown deposit).
/// The returned vec is in BTreeMap key order — deterministic.
pub fn tile_yields_from_collectibles(
index: &CollectiblesIndex,
resource_map: &ResourceYieldMap,
) -> Vec<TileYield> {
index
.iter()
.map(|(&coord, rolls)| {
let mut ty = TileYield { coord, ..Default::default() };
for roll in rolls {
let Some(base) = resource_map.get(&roll.resource_id) else {
continue;
};
let quality_bonus =
1.0 + (roll.quality.saturating_sub(1) as f64) * QUALITY_YIELD_BONUS_PER_POINT;
let qty = roll.quantity as f64;
ty.food += base.food * qty * quality_bonus;
ty.production += base.production * qty * quality_bonus;
ty.gold += base.gold * qty * quality_bonus;
ty.culture += base.culture * qty * quality_bonus;
ty.science += base.science * qty * quality_bonus;
}
ty
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn iron_ore_yields() -> ResourceYields {
ResourceYields { production: 3.0, ..Default::default() }
}
#[test]
fn empty_index_produces_empty_yields() {
let result = tile_yields_from_collectibles(&BTreeMap::new(), &BTreeMap::new());
assert!(result.is_empty());
}
#[test]
fn unknown_resource_contributes_nothing() {
let mut index = CollectiblesIndex::new();
index.insert(
(1, 1),
vec![CollectibleRoll { resource_id: "unobtanium".into(), quantity: 5, quality: 3 }],
);
let result = tile_yields_from_collectibles(&index, &ResourceYieldMap::new());
assert_eq!(result.len(), 1);
let ty = &result[0];
assert_eq!(ty.production, 0.0);
}
#[test]
fn golden_one_city_known_collectibles_expected_yield() {
// Tile (3,3): iron_ore quantity=2, quality=1 → no quality bonus
// production = 3.0 × 2 × (1 + 0) = 6.0
// Tile (3,4): iron_ore quantity=1, quality=6 → quality_bonus = 1 + 5*0.2 = 2.0
// production = 3.0 × 1 × 2.0 = 6.0
let mut index = CollectiblesIndex::new();
index.insert(
(3, 3),
vec![CollectibleRoll { resource_id: "iron_ore".into(), quantity: 2, quality: 1 }],
);
index.insert(
(3, 4),
vec![CollectibleRoll { resource_id: "iron_ore".into(), quantity: 1, quality: 6 }],
);
let mut resource_map = ResourceYieldMap::new();
resource_map.insert("iron_ore".into(), iron_ore_yields());
let yields = tile_yields_from_collectibles(&index, &resource_map);
assert_eq!(yields.len(), 2);
let t33 = yields.iter().find(|t| t.coord == (3, 3)).unwrap();
assert!((t33.production - 6.0).abs() < 1e-9, "got {}", t33.production);
let t34 = yields.iter().find(|t| t.coord == (3, 4)).unwrap();
assert!((t34.production - 6.0).abs() < 1e-9, "got {}", t34.production);
}
#[test]
fn output_is_deterministic_btreemap_order() {
let mut index = CollectiblesIndex::new();
index.insert((2, 0), vec![CollectibleRoll { resource_id: "grain".into(), quantity: 4, quality: 2 }]);
index.insert((1, 0), vec![CollectibleRoll { resource_id: "grain".into(), quantity: 4, quality: 2 }]);
let mut resource_map = ResourceYieldMap::new();
resource_map.insert("grain".into(), ResourceYields { food: 1.0, ..Default::default() });
let a = tile_yields_from_collectibles(&index, &resource_map);
let b = tile_yields_from_collectibles(&index, &resource_map);
assert_eq!(a.iter().map(|t| t.coord).collect::<Vec<_>>(),
b.iter().map(|t| t.coord).collect::<Vec<_>>());
}
}