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:
parent
151c0138fd
commit
1942887d4d
3 changed files with 251 additions and 6 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
131
src/simulator/crates/mc-city/src/yield_fold.rs
Normal file
131
src/simulator/crates/mc-city/src/yield_fold.rs
Normal 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<_>>());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue