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:
parent
cc1383402c
commit
12d87af42f
5 changed files with 98 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,8 @@ prop_compose! {
|
|||
science_yield: 0,
|
||||
units,
|
||||
city_positions,
|
||||
capital_position: None,
|
||||
culture_total: 0,
|
||||
arcane_lore_pop_deducted: false,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue