diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 4c1d1708..2e69fe02 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -1286,6 +1286,105 @@ mod tests { ); } + /// p2-67 Phase 11. `apply_end_turn` now runs `TurnProcessor::step` + /// between the AI driver loop and the closing `TurnStarted` emit. + /// Step accumulates food into `CityState.food_stored`; once the + /// threshold is crossed, `population` grows. This test seeds a city + /// with food_yield = 12 (well above the pop-1 maintenance of 2) so + /// the food_stored crosses the threshold in 2 turns and population + /// rises from 1 → 2. + #[test] + fn end_turn_ticks_city_food_growth_via_turn_processor() { + use mc_city::CityState; + let mut state = GameState::default(); + let mut ps = PlayerState::default(); + ps.player_index = 0; + let mut city = CityState::starter(); + // Starter has population=1, food_stored=0, food_yield=4 (net +2 / turn). + // Threshold at pop=1 is `15 + 6*0 + 0 = 15`. With a juiced food_yield + // of 16 (net +14 / turn) we cross in 2 turns deterministically. + city.food_yield = 16; + ps.cities.push(city); + ps.city_positions.push((0, 0)); + ps.city_buildings.push(Vec::new()); + ps.city_improvements.push(Vec::new()); + ps.city_ecology.push(Default::default()); + state.players.push(ps); + // Turn 0 → state.turn becomes 1, food_stored = 14, population still 1. + let _ = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap(); + assert_eq!(state.turn, 1); + assert_eq!(state.players[0].cities[0].population, 1); + assert_eq!(state.players[0].cities[0].food_stored, 14); + // Turn 1 → food_stored crosses 15, pop grows to 2. + let _ = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap(); + assert_eq!(state.turn, 2); + assert_eq!( + state.players[0].cities[0].population, 2, + "city should have grown to population 2" + ); + // food_stored after growth = 14 (pre-grow) + 14 (this turn's net) - 15 (threshold) = 13. + // (The threshold check happens AFTER this turn's food is added but + // BEFORE the maintenance is rebilled for the new pop — `process_city_production` + // accumulates pre-grow.) + assert_eq!(state.players[0].cities[0].food_stored, 13); + } + + /// p2-67 Phase 11. With a queue carrying a `Queueable::Unit` and + /// enough `production_stored`, `try_spawn_unit` in `TurnProcessor::step` + /// spawns a real unit at the city's hex. Asserts via state inspection + /// (no `CityUnitCompleted` wire event exists today — the production + /// path mutates `player.units` silently). + #[test] + fn end_turn_completes_queued_unit_via_turn_processor() { + use mc_city::{CityState, Queueable}; + let mut state = GameState::default(); + let mut ps = PlayerState::default(); + ps.player_index = 0; + let mut city = CityState::starter(); + // Pre-stuff production_stored above the spawn cost (4 by default + // — see `LairCombatConfig::default`'s `unit_spawn_cost`). Anything + // ≥ 4 lets `try_spawn_unit` trigger. + city.production_stored = 100; + city.queue = Some(Queueable::Unit { + unit_id: "dwarf_warrior".into(), + }); + city.queue_cost = Some(8); + ps.cities.push(city); + ps.city_positions.push((5, 5)); + ps.city_buildings.push(Vec::new()); + ps.city_improvements.push(Vec::new()); + ps.city_ecology.push(Default::default()); + state.players.push(ps); + let unit_count_before = state.players[0].units.len(); + let _ = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap(); + assert_eq!(state.turn, 1); + assert!( + state.players[0].units.len() > unit_count_before, + "queued unit should have spawned (units before={}, after={})", + unit_count_before, + state.players[0].units.len() + ); + } + + /// p2-67 Phase 11. Unit movement_remaining refresh is now owned by + /// `TurnProcessor::step` (DRY rule — the dispatch-level `refresh_units` + /// call was deleted in this same patch). Asserts a fortified unit that + /// exhausted its movement budget gets a fresh budget after EndTurn. + #[test] + fn end_turn_refreshes_unit_movement_via_turn_processor() { + let mut state = make_state_with_units(vec![(0, 1, 3, 3)]); + // make_state_with_units gives every unit `with_moves(32)` — + // simulate a consumed budget. + state.players[0].units[0].movement_remaining = 0; + assert_eq!(state.players[0].units[0].base_moves, 32); + let _ = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap(); + assert_eq!(state.turn, 1); + assert_eq!( + state.players[0].units[0].movement_remaining, 32, + "movement should refresh to base_moves after step" + ); + } + #[test] fn noop_makes_no_state_change_and_no_events() { let mut state = GameState::default();