feat(mc-turn): Introduce CombatEvent type, GameState updates, and ProcessorInvariants for combat event validation logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-10 08:20:01 -07:00
parent cc1383402c
commit 12d87af42f
5 changed files with 98 additions and 14 deletions

View file

@ -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<u8>,
/// 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.

View file

@ -50,6 +50,10 @@ pub struct PlayerState {
pub units: Vec<MapUnit>,
/// 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,
}

View file

@ -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};

View file

@ -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<String>,
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<crate::victory::VictoryConfig>,
}
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,
});
}

View file

@ -142,6 +142,8 @@ prop_compose! {
science_yield: 0,
units,
city_positions,
capital_position: None,
culture_total: 0,
arcane_lore_pop_deducted: false,
}
}