diff --git a/src/simulator/crates/mc-turn/src/combat_event.rs b/src/simulator/crates/mc-turn/src/combat_event.rs index a02da72a..4ea8af46 100644 --- a/src/simulator/crates/mc-turn/src/combat_event.rs +++ b/src/simulator/crates/mc-turn/src/combat_event.rs @@ -1,5 +1,6 @@ //! Per-turn combat events produced by the turn processor. +use crate::victory::VictoryType; use serde::{Deserialize, Serialize}; /// A single fauna encounter: one of the player's units walked into the @@ -25,9 +26,9 @@ pub struct FaunaCombatEvent { /// Aggregate result of one call to `TurnProcessor::step`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TurnResult { - /// First player to satisfy the victory condition, if any. Bench binaries - /// typically ignore this and run the full turn budget regardless. - pub winner: Option, + /// First player to satisfy a victory condition (player index + type), + /// or `None` if no victory was achieved this turn. + pub winner: Option<(u8, VictoryType)>, /// Sum of unit deaths across all players this turn. pub units_lost_to_fauna: u32, /// Number of cities that were harassed by adjacent fauna lairs this turn. diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 1a357818..eb68b333 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -50,6 +50,10 @@ pub struct PlayerState { pub units: Vec, /// World-space (col, row) positions of each city, aligned with `cities`. pub city_positions: Vec<(i32, i32)>, + /// Position of this player's original capital (used for domination victory). + pub capital_position: Option<(i32, i32)>, + /// Cumulative culture generated across all cities. + pub culture_total: i64, /// One-time flag: has the arcane-lore population cost already been paid? pub arcane_lore_pop_deducted: bool, } diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 0431fc3d..616a0e75 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -21,6 +21,7 @@ pub mod game_state; pub mod combat_event; pub mod processor; pub mod spatial_index; +pub mod victory; #[cfg(test)] mod processor_invariants; @@ -31,3 +32,4 @@ mod bridge_contract_tests; pub use game_state::{CityEcology, GameState, MapUnit, PlayerState, TechState}; pub use combat_event::{FaunaCombatEvent, TurnResult}; pub use processor::{LairCombatConfig, TurnProcessor}; +pub use victory::{VictoryConfig, VictoryType}; diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index febbb5fe..5e0f5786 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -85,10 +85,12 @@ impl Default for LairCombatConfig { tier_kill_exponent: 2.0, fortify_divisor: 2.0, encounter_probability_per_turn: 0.04, - // Calibrated to Phase 7: militarist (wealth=2, 17 cities, 500 turns) - // should finish around 25,000 gold. Observed at multiplier=5 was - // 58,650, so 5 × 25,142/58,650 ≈ 2.14 → round to 2. - gold_per_wealth_per_city: 2, + // Calibrated: merchant (wealth=5, 17 cities) should reach 30K + // gold by ~T150-180. At multiplier=4: 5*17*4 = 340 gold/turn + // at peak, ~200 avg with ramp-up → 30K in ~150T. Militarist + // (wealth=2) gets 2*17*4=136/turn → 30K in ~220T, well after + // domination fires. + gold_per_wealth_per_city: 4, prod_per_axis_per_city: 2, expansion_per_axis_per_turn: 1, city_founding_cost: 25, @@ -136,6 +138,9 @@ pub struct TurnProcessor { /// Placeholder — real tech-web evaluation lives in mc-tech. pub tech_web: Option, pub lair_combat_config: LairCombatConfig, + /// When set, enables multi-condition victory checks (economic, culture, + /// science, domination, city-count) instead of the simple city-count only. + pub victory_config: Option, } impl TurnProcessor { @@ -150,6 +155,7 @@ impl TurnProcessor { building_protection_table: HashMap::new(), tech_web: None, lair_combat_config: LairCombatConfig::default(), + victory_config: None, } } @@ -160,13 +166,16 @@ impl TurnProcessor { state.turn += 1; let mut result = TurnResult::default(); - // Phase 1-4: per-player economy, production, founding, unit spawn. + // Phase 1-4: per-player economy, production, founding, unit spawn, + // culture accumulation, science/tech progression. let n_players = state.players.len(); for pi in 0..n_players { self.process_economy(state, pi); self.process_city_production(state, pi); self.try_found_city(state, pi); self.try_spawn_unit(state, pi); + self.process_culture(state, pi); + self.process_science(state, pi); } // Phase 5-6: movement + fauna encounters (need immutable grid ref + @@ -175,11 +184,18 @@ impl TurnProcessor { self.process_fauna_encounters_inner(state, &mut result, true); } - // Phase 7: victory check. - for (pi, p) in state.players.iter().enumerate() { - if p.cities.len() >= self.victory_city_count as usize { - result.winner = Some(pi as u8); - break; + // Phase 7: victory check — use full VictoryConfig when available, + // otherwise fall back to simple city-count check. + if let Some(ref vc) = self.victory_config { + if let Some((wi, vt)) = crate::victory::check_victory(&state.players, vc) { + result.winner = Some((wi, vt)); + } + } else { + for (pi, p) in state.players.iter().enumerate() { + if p.cities.len() >= self.victory_city_count as usize { + result.winner = Some((pi as u8, crate::victory::VictoryType::CityCount)); + break; + } } } @@ -240,6 +256,55 @@ impl TurnProcessor { } } + // ── Phase 1b: Culture accumulation ────────────────────────────────── + + fn process_culture(&self, state: &mut GameState, pi: usize) { + let player = &mut state.players[pi]; + let culture = *player.strategic_axes.get("culture").unwrap_or(&2); + let city_count = player.cities.len() as i64; + let culture_per_turn = culture as i64 * city_count * 25; + player.culture_total += culture_per_turn; + } + + // ── Phase 1c: Science/tech accumulation ───────────────────────────── + + fn process_science(&self, state: &mut GameState, pi: usize) { + let player = &mut state.players[pi]; + let culture = *player.strategic_axes.get("culture").unwrap_or(&2); + let city_count = player.cities.len() as u32; + let science_per_turn = culture as u32 * city_count * 25; + player.science_yield = science_per_turn; + + if let Some(ref vc) = self.victory_config { + // Auto-initialize tech state when the victory config requires + // science techs — ensures tournament matches can progress + // toward science victory without manual setup. + if !vc.science_techs_required.is_empty() && player.tech_state.is_none() { + player.tech_state = Some(crate::game_state::TechState::default()); + } + + if let Some(ref mut ts) = player.tech_state { + // Accumulate science toward each required tech in order. + // Cost scales with position: base * index^1.4 (index 1-based). + let next_tech_pos = vc + .science_techs_required + .iter() + .position(|t| !ts.researched.contains(t)); + if let Some(idx) = next_tech_pos { + let tech_id = vc.science_techs_required[idx].clone(); + let cost = (vc.science_cost_base as f64 + * ((idx + 1) as f64).powf(1.4)) + as u32; + let progress = ts.progress.entry(tech_id.clone()).or_insert(0); + *progress += science_per_turn; + if *progress >= cost { + ts.researched.push(tech_id); + } + } + } + } + } + // ── Phase 2: City production ─────────────────────────────────────────── fn process_city_production(&self, state: &mut GameState, pi: usize) { @@ -622,6 +687,8 @@ impl TurnProcessor { self.process_city_production(state, pi); self.try_found_city(state, pi); self.try_spawn_unit(state, pi); + self.process_culture(state, pi); + self.process_science(state, pi); } if state.grid.is_some() { @@ -630,7 +697,7 @@ impl TurnProcessor { for (pi, p) in state.players.iter().enumerate() { if p.cities.len() >= self.victory_city_count as usize { - result.winner = Some(pi as u8); + result.winner = Some((pi as u8, crate::victory::VictoryType::CityCount)); break; } } @@ -759,6 +826,8 @@ mod tests { unit_id: "dwarf_warrior".into(), }], city_positions: vec![(4, 4)], + capital_position: None, + culture_total: 0, arcane_lore_pop_deducted: false, }], grid: Some(grid), @@ -807,6 +876,8 @@ mod tests { science_yield: 0, units: vec![], city_positions: vec![(10, 10)], + capital_position: None, + culture_total: 0, arcane_lore_pop_deducted: false, }], grid: Some(GridState::new(48, 48)), @@ -883,6 +954,8 @@ mod tests { is_fortified: false, unit_id: "dwarf_warrior".to_string() }, ], city_positions: vec![(5, 5)], + capital_position: None, + culture_total: 0, arcane_lore_pop_deducted: false, }], grid: Some(grid), @@ -1235,6 +1308,8 @@ mod tests { science_yield: 0, units, city_positions: vec![(cx, cy)], + capital_position: None, + culture_total: 0, arcane_lore_pop_deducted: false, }); } diff --git a/src/simulator/crates/mc-turn/src/processor_invariants.rs b/src/simulator/crates/mc-turn/src/processor_invariants.rs index f9ac6301..c9082b8e 100644 --- a/src/simulator/crates/mc-turn/src/processor_invariants.rs +++ b/src/simulator/crates/mc-turn/src/processor_invariants.rs @@ -142,6 +142,8 @@ prop_compose! { science_yield: 0, units, city_positions, + capital_position: None, + culture_total: 0, arcane_lore_pop_deducted: false, } }