From 1942887d4d91c711294ba0e4667f6fb13390e71e Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 16 Apr 2026 22:02:36 -0700 Subject: [PATCH] =?UTF-8?q?feat(mc-city):=20=E2=9C=A8=20Add=20yield=20chan?= =?UTF-8?q?neling=20and=20quality=20scaling=20for=20collectible=20resource?= =?UTF-8?q?=20generation=20in=20city=20simulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-city/src/city.rs | 121 +++++++++++++++- src/simulator/crates/mc-city/src/lib.rs | 5 + .../crates/mc-city/src/yield_fold.rs | 131 ++++++++++++++++++ 3 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 src/simulator/crates/mc-city/src/yield_fold.rs diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index d9eaf8d8..9be6cbd0 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -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 { + 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, } 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 { 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); + } } diff --git a/src/simulator/crates/mc-city/src/lib.rs b/src/simulator/crates/mc-city/src/lib.rs index 97f4165a..5f8d188b 100644 --- a/src/simulator/crates/mc-city/src/lib.rs +++ b/src/simulator/crates/mc-city/src/lib.rs @@ -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`, diff --git a/src/simulator/crates/mc-city/src/yield_fold.rs b/src/simulator/crates/mc-city/src/yield_fold.rs new file mode 100644 index 00000000..1ef974f0 --- /dev/null +++ b/src/simulator/crates/mc-city/src/yield_fold.rs @@ -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>; + +/// Maps resource_id → the yields that one unit of that resource contributes. +pub type ResourceYieldMap = BTreeMap; + +/// 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` +/// 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 { + 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::>(), + b.iter().map(|t| t.coord).collect::>()); + } +}