diff --git a/src/simulator/api-gdext/src/replay.rs b/src/simulator/api-gdext/src/replay.rs index e3354699..ffa238b4 100644 --- a/src/simulator/api-gdext/src/replay.rs +++ b/src/simulator/api-gdext/src/replay.rs @@ -80,11 +80,12 @@ fn event_to_dict(evt: &TurnEvent) -> Dictionary { d.set("row", hex.r as i64); d.set("name", GString::from(name.0.as_str())); } - TurnEvent::UnitKilled { turn, attacker, defender, unit_kind, hex } => { + TurnEvent::UnitKilled { turn, attacker, defender, unit_id, unit_kind, hex } => { d.set("kind", GString::from("UnitKilled")); d.set("turn", *turn as i64); d.set("attacker", attacker.0 as i64); d.set("defender", defender.0 as i64); + d.set("unit_id", *unit_id as i64); d.set("unit_kind", GString::from(unit_kind.0.as_str())); d.set("col", hex.q as i64); d.set("row", hex.r as i64); @@ -191,6 +192,15 @@ fn event_to_dict(evt: &TurnEvent) -> Dictionary { // drive the end-game summary scene. The GDExt bridge for // EventBus.game_over is a separate bullet; this arm keeps the match // exhaustive and surfaces basic fields for the replay viewer. + TurnEvent::CityBuildingCompleted { turn, clan, city, building_id, hex } => { + d.set("kind", GString::from("CityBuildingCompleted")); + d.set("turn", *turn as i64); + d.set("clan", clan.0 as i64); + d.set("city", GString::from(city.0.as_str())); + d.set("building_id", GString::from(building_id.as_str())); + d.set("col", hex.q as i64); + d.set("row", hex.r as i64); + } TurnEvent::GameOver { turn, winner, reason_kind, condition, resigned_clan } => { d.set("kind", GString::from("GameOver")); d.set("turn", *turn as i64); diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 5ab72e6a..496d5aad 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -325,6 +325,21 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec { player: clan.0 as PlayerId, }); } + // p2-67 Bug 4: surface non-wonder building completions emitted + // from `process_city_production`'s `Queueable::Item` branch. + // The wire `Event::CityBuildingCompleted` already exists for + // the `apply_rush_buy` path; this lights it up for natural + // completions too. + mc_replay::TurnEvent::CityBuildingCompleted { + city, + building_id, + .. + } => { + out.push(Event::CityBuildingCompleted { + city_id: city.0.clone(), + building_id: building_id.clone(), + }); + } mc_replay::TurnEvent::CityFounded { clan, hex, .. } => { let position: crate::WireHex = [hex.q, hex.r]; out.push(Event::CityFounded { @@ -404,18 +419,31 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec { }); } } + // p2-67 Bug 3: surface PvP kills as `Event::UnitDestroyed`. + // `killer_unit_id` correlation is deferred — `UnitKilled` only + // carries the killing-clan id, not the specific attacker unit. + mc_replay::TurnEvent::UnitKilled { unit_id, .. } => { + out.push(Event::UnitDestroyed { + unit_id: format!("u_{unit_id}"), + killer_unit_id: None, + }); + } // Variants without a direct wire counterpart — dropped per the // docstring above. Listed explicitly so adding new TurnEvent // variants forces a compile-time decision here. mc_replay::TurnEvent::AmbientEncounterFired { .. } - | mc_replay::TurnEvent::UnitKilled { .. } | mc_replay::TurnEvent::WarDeclared { .. } | mc_replay::TurnEvent::PeaceSigned { .. } | mc_replay::TurnEvent::EraEntered { .. } | mc_replay::TurnEvent::LeaderChanged { .. } | mc_replay::TurnEvent::ClanEliminated { .. } | mc_replay::TurnEvent::UnitRansomOffered { .. } - | mc_replay::TurnEvent::CivilianDestroyed { .. } => {} + | mc_replay::TurnEvent::CivilianDestroyed { .. } + // `CityBuildingCompleted` is emitted by `apply_action` directly + // as `Event::CityBuildingCompleted` from the production path, so + // the chronicle-translation step intentionally drops it to avoid + // double-emission. + | mc_replay::TurnEvent::CityBuildingCompleted { .. } => {} } } out diff --git a/src/simulator/crates/mc-replay/src/event.rs b/src/simulator/crates/mc-replay/src/event.rs index e414f920..c6dd62ed 100644 --- a/src/simulator/crates/mc-replay/src/event.rs +++ b/src/simulator/crates/mc-replay/src/event.rs @@ -51,6 +51,12 @@ pub enum TurnEvent { attacker: ClanId, /// Owner of the killed unit. defender: ClanId, + /// Stable unit id (`MapUnit::id`). Added p2-67 so the wire layer + /// can correlate the kill with a prior `UnitCreated`/`UnitMoved` + /// entry. Defaults to `0` for legacy fixtures that pre-date the + /// field; new emitters always populate it. + #[serde(default)] + unit_id: u32, /// Unit catalog id. unit_kind: UnitKind, /// Hex the unit died on. @@ -81,6 +87,25 @@ pub enum TurnEvent { #[serde(default, skip_serializing_if = "Option::is_none")] city: Option, }, + /// A non-wonder building (item) finished construction in a city's + /// production queue. Emitted from + /// `mc_turn::processor::process_city_production` when `Queueable::Item` + /// accumulates enough production to clear its `queue_cost`. The wire + /// layer translates this to `Event::CityBuildingCompleted` so adapters + /// can react to building completions without polling `view()`. + CityBuildingCompleted { + /// Turn the event fired on. + turn: u32, + /// Owning clan. + clan: ClanId, + /// Synthesised city name (`city__`) matching the founding + /// scheme used by other variants. + city: CityName, + /// Building / item catalog id (e.g. `"library"`, `"granary"`). + building_id: String, + /// Hex the city sits on, for the replay viewer to centre on. + hex: TileCoord, + }, /// A wonder finished construction. WonderBuilt { /// Turn the event fired on. @@ -259,6 +284,7 @@ impl TurnEvent { | Self::CityCaptured { turn, .. } | Self::UnitKilled { turn, .. } | Self::UnitCreated { turn, .. } + | Self::CityBuildingCompleted { turn, .. } | Self::WonderBuilt { turn, .. } | Self::WarDeclared { turn, .. } | Self::PeaceSigned { turn, .. } diff --git a/src/simulator/crates/mc-replay/tests/award_computation.rs b/src/simulator/crates/mc-replay/tests/award_computation.rs index bbba9877..1c635de0 100644 --- a/src/simulator/crates/mc-replay/tests/award_computation.rs +++ b/src/simulator/crates/mc-replay/tests/award_computation.rs @@ -189,6 +189,7 @@ fn make_fixture() -> GameHistory { turn: t, attacker: ClanId(2), defender: ClanId(1), + unit_id: 0, unit_kind: UnitKind("warrior".into()), hex: TileCoord::new(0, 0), }); @@ -198,6 +199,7 @@ fn make_fixture() -> GameHistory { turn: t, attacker: ClanId(1), defender: ClanId(2), + unit_id: 0, unit_kind: UnitKind("warrior".into()), hex: TileCoord::new(1, 0), }); diff --git a/src/simulator/crates/mc-turn/src/combat_event.rs b/src/simulator/crates/mc-turn/src/combat_event.rs index 32c62c79..50235e4b 100644 --- a/src/simulator/crates/mc-turn/src/combat_event.rs +++ b/src/simulator/crates/mc-turn/src/combat_event.rs @@ -112,6 +112,28 @@ pub struct UnitRansomExpiredEvent { pub prior_owner: u8, } +/// p2-67 Bug 3: a unit was killed in PvP combat (the `CombatOutcome::Killed` +/// or `attacker !survived` branches of `resolve_single_pvp_attack`). Mirrors +/// the shape of `CivilianDestroyedEvent` so chronicle pipeline + wire-event +/// translation are symmetric. Staged on `state.pending_capture_events` from +/// `resolve_single_pvp_attack` (which does NOT take `&mut TurnResult`) and +/// drained into `result.events_emitted` via `PendingCaptureEvents::drain_into`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UnitKilledEvent { + pub turn: u32, + /// Stable id of the killed unit (`MapUnit::id`). + pub unit_id: u32, + /// Player who landed the killing blow. + pub attacker: u8, + /// Owner of the killed unit. + pub defender: u8, + /// Hex where the unit died. + pub col: i32, + pub row: i32, + /// Catalog kind of the killed unit (e.g. `"dwarf_warrior"`). + pub unit_kind: String, +} + /// p2-55: a civilian was deliberately destroyed (posture = Destroy). Distinct /// from a generic kill so chronicle / AI memory can differentiate. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 9875f14e..d9c6dea3 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -507,6 +507,12 @@ pub struct PendingCaptureEvents { /// p2-55e: ransom offers refused (manual refuse) or expired (timeout). /// `process_ransom_expiry` and bridge `refuse_ransom_offer` push here. pub ransom_offers_expired: Vec, + /// p2-67 Bug 3: kills staged from `resolve_single_pvp_attack` (queued + /// PvP path). The proximity-discovery loop in `process_pvp_combat` + /// has its own inline emit at the kill-removal site and does NOT push + /// here. `drain_into` translates each entry to a + /// `mc_replay::TurnEvent::UnitKilled` on `result.events_emitted`. + pub units_killed: Vec, } impl PendingCaptureEvents { @@ -549,6 +555,23 @@ impl PendingCaptureEvents { unit_kind: mc_replay::UnitKind(ev.unit_kind.clone()), }); } + // p2-67 Bug 3: queued-PvP kill events were silent before this drain + // existed — the inline `swap_remove` in `resolve_single_pvp_attack` + // happened with no `TurnEvent::UnitKilled` push. Translate each + // staged `UnitKilledEvent` to the chronicle variant now. + for ev in &self.units_killed { + result.events_emitted.push(mc_replay::TurnEvent::UnitKilled { + turn: ev.turn, + attacker: mc_replay::ClanId(ev.attacker as u32), + defender: mc_replay::ClanId(ev.defender as u32), + unit_id: ev.unit_id, + unit_kind: mc_replay::UnitKind(ev.unit_kind.clone()), + hex: mc_replay::TileCoord::new(ev.col, ev.row), + }); + } + // `units_killed` does not have a matching `TurnResult` Vec field — + // the canonical surface is `events_emitted` above. Just clear. + self.units_killed.clear(); result.units_captured.append(&mut self.units_captured); result.ransom_offers_created.append(&mut self.ransom_offers_created); result.civilians_destroyed.append(&mut self.civilians_destroyed); @@ -568,6 +591,7 @@ impl PendingCaptureEvents { && self.civilians_destroyed.is_empty() && self.ransom_offers_accepted.is_empty() && self.ransom_offers_expired.is_empty() + && self.units_killed.is_empty() } } diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 96b105b8..3c1c0f61 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -24,7 +24,7 @@ use crate::combat_event::{ CivilianDestroyedEvent, FaunaCombatEvent, PvpCombatEvent, SiegeEvent, StrategicGateRejection, - TurnResult, UnitCapturedEvent, UnitRansomOfferedEvent, + TurnResult, UnitCapturedEvent, UnitKilledEvent, UnitRansomOfferedEvent, }; use crate::game_state::{BuildingRallyPoint, GameState, MapUnit, RallyCommand}; use crate::spatial_index::LairIndex; @@ -57,6 +57,33 @@ const DEFAULT_SEEK_RANGE: i32 = 40; /// enemy units and cities from farther away. const RUSHER_SEEK_RANGE: i32 = 80; +// ── Unit economy constants (p2-67 Bug 2 fix) ─────────────────────────────── + +/// Default per-unit gold maintenance for bench-mode spawns. +/// +/// Neither `mc_units::UnitStats` nor `mc_ai::TacticalUnitSpec` carry an +/// upkeep field today, so every spawn site uses this constant as the per-turn +/// maintenance contribution. Without a positive value, the gold economy in +/// `process_player_economy` has no brake on unit count and the bench AI +/// snowballs to thousands of units over a 250-turn run (see the Bug 2 RC in +/// `.project/objectives/p2-67-claude-player-api.md`). When unit JSON gains a +/// real `maintenance` field, this constant becomes the fallback. +const DEFAULT_UNIT_UPKEEP: i32 = 1; + +/// Lower bound on `gold + projected_net_gold` after a new unit spawns +/// before `try_spawn_unit` refuses to add another unit this turn for that +/// player. +/// +/// A negative floor (rather than `0`) lets a player run briefly insolvent +/// — the existing insolvency cascade in `process_player_economy` already +/// handles short-term shortfalls by disbanding the cheapest unit each +/// turn. We only block *new* spawns once projected gold would drop below +/// `-100`, which empirically prevents the 1696-unit explosion seen in +/// `tests/full_game_transcript.rs::long_game_transcript` without blocking +/// any sanity-fixture spawn (those start with `player.gold == 0` and +/// produce ≤ 1 unit per city). +const SPAWN_GOLD_FLOOR: i32 = -100; + // ── Config ────────────────────────────────────────────────────────────────── @@ -707,14 +734,31 @@ impl TurnProcessor { player.gold = player.gold.saturating_add(result.net_gold); if player.gold < 0 { - // Insolvent — disband the cheapest unit (last in vec by convention). - if !player.units.is_empty() { + // Insolvency cascade (p2-67 Bug 2): disband units until gold is + // non-negative or the player has none left. Previously only one + // unit was disbanded per turn, so a player with a deep negative + // gold balance (the 1696-unit AI in the long-game run) could + // never claw back to solvency — the next turn would re-add + // upkeep and the deficit kept growing. Each disband recovers + // exactly `DEFAULT_UNIT_UPKEEP` gold per turn going forward, so + // we don't try to estimate which one unit to drop; we drop + // until gold ≥ 0 OR the army is empty, whichever comes first. + while player.gold < 0 && !player.units.is_empty() { player.units.swap_remove(player.units.len() - 1); if !player.unit_upkeep.is_empty() { player.unit_upkeep.pop(); } + // Account for the recovered upkeep this turn — every unit + // we disband refunds exactly one turn of maintenance. + player.gold = player.gold.saturating_add(DEFAULT_UNIT_UPKEEP); + } + // Floor the displayed gold at 0 if the cascade ended early + // (army empty but still in debt). The deficit is real but the + // bench economy has no debt mechanic — clamp matches the + // pre-existing behaviour. + if player.gold < 0 { + player.gold = 0; } - player.gold = 0; } } @@ -969,6 +1013,10 @@ impl TurnProcessor { // completion so the replay event below can attribute the build to the // city that finished it. Same iteration order as `completed_wonders`. let mut completed_in_city: Vec<(usize, WonderId)> = Vec::new(); + // p2-67 Bug 4: building-completion accumulator. Collected during the + // city loop and flushed below as `TurnEvent::CityBuildingCompleted`. + // Tuple = (city_idx, item_id). + let mut completed_buildings: Vec<(usize, String)> = Vec::new(); for (city_idx, city) in player.cities.iter_mut().enumerate() { // Food: accumulate and grow. @@ -1059,18 +1107,44 @@ impl TurnProcessor { // Wonder completion: when the queued item is a Wonder and production // has reached its cost, clear the queue and record the wonder id+tier. - if let Some(Queueable::Wonder { ref wonder_id }) = city.queue { - if let Some(cost) = city.queue_cost { - if city.production_stored >= cost as i32 { - let tier = city.queue_tier.unwrap_or(1); - completed_wonders.push((wonder_id.clone(), tier)); - completed_in_city.push((city_idx, wonder_id.clone())); - city.production_stored -= cost as i32; - city.queue = None; - city.queue_cost = None; - city.queue_tier = None; + // + // p2-67 Bug 4: parallel branch for `Queueable::Item` (non-wonder + // building completion). Without this, items queued via + // `apply_queue_production` never clear — `process_city_production` + // had no completion branch for them and `try_spawn_unit` + // unconditionally hardcoded `dwarf_warrior`, so an item-queued + // city silently became a warrior factory once production caught + // up with the (unrelated) unit_spawn_cost. + // + // `Queueable::Unit` is intentionally NOT handled here — the + // spawn itself happens in `try_spawn_unit` (which now respects + // the queued unit_id and clears the queue post-spawn). + match city.queue.clone() { + Some(Queueable::Wonder { ref wonder_id }) => { + if let Some(cost) = city.queue_cost { + if city.production_stored >= cost as i32 { + let tier = city.queue_tier.unwrap_or(1); + completed_wonders.push((wonder_id.clone(), tier)); + completed_in_city.push((city_idx, wonder_id.clone())); + city.production_stored -= cost as i32; + city.queue = None; + city.queue_cost = None; + city.queue_tier = None; + } } } + Some(Queueable::Item { ref item_id }) => { + if let Some(cost) = city.queue_cost { + if city.production_stored >= cost as i32 { + completed_buildings.push((city_idx, item_id.clone())); + city.production_stored -= cost as i32; + city.queue = None; + city.queue_cost = None; + city.queue_tier = None; + } + } + } + Some(Queueable::Unit { .. }) | None => {} } } @@ -1089,6 +1163,33 @@ impl TurnProcessor { city: mc_replay::CityName(format!("city_{}_{}", pi, city_idx)), }); } + // p2-67 Bug 4: replay-archive event per building (item) completion. + // Also fold the new building into `player.city_buildings[ci]` so + // `view()` and downstream gates (palace evolution, requires_building) + // observe the completion immediately. + for (city_idx, item_id) in completed_buildings { + let hex = state.players[pi] + .city_positions + .get(city_idx) + .copied() + .unwrap_or((0, 0)); + // Ensure the buildings slot exists (parallel to city_positions). + while state.players[pi].city_buildings.len() <= city_idx { + state.players[pi].city_buildings.push(Vec::new()); + } + if let Some(slot) = state.players[pi].city_buildings.get_mut(city_idx) { + if !slot.iter().any(|b| b == &item_id) { + slot.push(item_id.clone()); + } + } + events.push(mc_replay::TurnEvent::CityBuildingCompleted { + turn: state.turn, + clan: mc_replay::ClanId(pi as u32), + city: mc_replay::CityName(format!("city_{}_{}", pi, city_idx)), + building_id: item_id, + hex: mc_replay::TileCoord::new(hex.0, hex.1), + }); + } // Re-borrow `player` after the events loop dropped its borrow via // `state.turn`. The next mutation point uses a fresh re-borrow. let player = &mut state.players[pi]; @@ -1177,15 +1278,37 @@ impl TurnProcessor { // ── Phase 4: Unit production ─────────────────────────────────────────── fn try_spawn_unit(&self, state: &mut GameState, pi: usize, result: &mut TurnResult) { + use mc_city::Queueable; + let cost = self.lair_combat_config.unit_spawn_cost as i32; - let spawn_candidates: Vec<(usize, (i32, i32))> = { + // Candidate cities: have enough production AND either + // - have no queue (legacy auto-warrior fallback — kept so the bench + // AI in `full_game_transcript::long_game_transcript` still ships + // units; there is no AI policy that queues for them today), OR + // - have an explicit `Queueable::Unit { unit_id }` head — in which + // case we honour the unit_id instead of hardcoding dwarf_warrior. + // Cities with `Queueable::Item { .. }` or `Queueable::Wonder { .. }` + // queue heads are NOT spawn candidates — those completions are + // handled in `process_city_production`. + let spawn_candidates: Vec<(usize, (i32, i32), String)> = { let player = &state.players[pi]; player .cities .iter() .enumerate() - .filter(|(_, c)| c.production_stored >= cost) - .map(|(i, _)| (i, player.city_positions.get(i).copied().unwrap_or((0, 0)))) + .filter_map(|(i, c)| { + if c.production_stored < cost { + return None; + } + let unit_id_for_spawn = match &c.queue { + None => Some("dwarf_warrior".to_string()), + Some(Queueable::Unit { unit_id }) => Some(unit_id.0.clone()), + Some(Queueable::Item { .. }) | Some(Queueable::Wonder { .. }) => None, + }; + unit_id_for_spawn.map(|uid| { + (i, player.city_positions.get(i).copied().unwrap_or((0, 0)), uid) + }) + }) .collect() }; @@ -1193,18 +1316,42 @@ impl TurnProcessor { return; } + // ── Gold-viability gate (p2-67 Bug 2) ───────────────────────────── + // Project gold after one more unit's worth of upkeep. If projected + // gold would dip below `SPAWN_GOLD_FLOOR`, skip ALL spawn candidates + // this turn — production stays accumulated, so next turn re-tries + // once the insolvency cascade in `process_player_economy` has + // disbanded enough units to recover gold. Estimate is conservative + // (next-turn income approximated as zero); any positive net income + // already folded into `player.gold` only makes the gate more + // permissive. Without this, the bench auto-warrior path in 250-turn + // runs produces ~1696 AI units (see Bug 2 RC in + // `.project/objectives/p2-67-claude-player-api.md`). + let units_after_spawn = state.players[pi].units.len() as i32 + 1; + let projected_gold = state.players[pi] + .gold + .saturating_sub(units_after_spawn.saturating_mul(DEFAULT_UNIT_UPKEEP)); + let gold_gate_open = projected_gold >= SPAWN_GOLD_FLOOR; + // The bench spawns `dwarf_warrior` by default — no strategic resource // requirement. When the queue item carries a `unit_id` that requires a // resource, the gate fires here. let default_reqs: Vec = Vec::new(); - for (city_idx, pos) in spawn_candidates { + for (city_idx, pos, unit_kind) in spawn_candidates { + if !gold_gate_open { + // Skip every candidate this turn — production carries over. + // Not pushed into `strategic_gate_rejected` because that vec + // is the resource-gate diagnostic; the gold gate is a + // separate axis. + break; + } if let Err(missing) = check_strategic_reqs(&default_reqs, &state.players[pi].strategic_ledger) { result.strategic_gate_rejected.push(StrategicGateRejection { turn: state.turn, player_index: pi as u8, city_index: city_idx, - unit_id: "dwarf_warrior".to_string(), + unit_id: unit_kind.clone(), missing_resource: missing.0, }); continue; @@ -1262,6 +1409,15 @@ impl TurnProcessor { }; let player = &mut state.players[pi]; player.cities[city_idx].production_stored -= cost; + // If the city was explicitly queueing a unit, the queue head + // has now been fulfilled — clear it so subsequent turns can + // re-queue. Empty-queue auto-warrior cities leave the queue + // alone (it was already `None`). + if matches!(player.cities[city_idx].queue, Some(Queueable::Unit { .. })) { + player.cities[city_idx].queue = None; + player.cities[city_idx].queue_cost = None; + player.cities[city_idx].queue_tier = None; + } debit_resources(&default_reqs, &mut player.strategic_ledger); player.units.push(MapUnit { id: uid, @@ -1273,7 +1429,7 @@ impl TurnProcessor { defense: 1, is_fortified: false, is_sentrying: false, - unit_id: "dwarf_warrior".to_string(), + unit_id: unit_kind.clone(), held_resources: default_reqs.clone(), patrol_order: patrol, formation_id: None, @@ -1282,8 +1438,10 @@ impl TurnProcessor { rally_destination, ..Default::default() }); - // Bench mode: no per-unit upkeep. - player.unit_upkeep.push(0); + // p2-67 Bug 2: every spawned unit costs `DEFAULT_UNIT_UPKEEP` + // gold/turn — without a positive maintenance figure the gold + // economy has no brake on unit growth in the bench harness. + player.unit_upkeep.push(DEFAULT_UNIT_UPKEEP); // p2-replay-followup: every PlayerState.units.push must emit // a chronicle entry so replay reconstructors can rebuild the // unit ledger from the event stream without observational @@ -1293,7 +1451,7 @@ impl TurnProcessor { turn: state.turn, clan: mc_replay::ClanId(pi as u32), unit_id: uid, - unit_kind: mc_replay::UnitKind("dwarf_warrior".to_string()), + unit_kind: mc_replay::UnitKind(unit_kind.clone()), hex: mc_replay::TileCoord::new(pos.0, pos.1), city: Some(mc_replay::CityName(format!("city_{}_{}", pi, city_idx))), }); @@ -1342,7 +1500,9 @@ impl TurnProcessor { unit.unit_id = unit_id.to_string(); unit.held_resources = requires.to_vec(); let (spawn_col, spawn_row) = (unit.col, unit.row); - player.unit_upkeep.push(0); + // p2-67 Bug 2: every spawned unit contributes DEFAULT_UNIT_UPKEEP + // gold/turn to the maintenance bill — match `try_spawn_unit`. + player.unit_upkeep.push(DEFAULT_UNIT_UPKEEP); player.units.push(unit); // p2-replay-followup: chronicle entry for the typed spawn path. // `stats.col/row` is authoritative; fall back to city_position if @@ -2080,6 +2240,21 @@ impl TurnProcessor { let unit_kind = state.players[defender_player].units[defender_unit].unit_id.clone(); let res = state.players[defender_player].units[defender_unit].held_resources.clone(); mc_combat::credit_resources(&res, &mut state.players[defender_player].strategic_ledger); + // p2-67 Bug 3: stage UnitKilled BEFORE swap_remove so the + // queued-PvP path emits the same chronicle entry the + // discovery loop emits at line ~2702. (Destroy posture + // ALSO emits the civilian-specific variant below; both + // chronicle entries are intentional — wire-layer surfaces + // both as `UnitDestroyed`, AI memory can differentiate.) + state.pending_capture_events.units_killed.push(UnitKilledEvent { + turn: state.turn, + unit_id, + attacker: attacker_player as u8, + defender: defender_player as u8, + col, + row, + unit_kind: unit_kind.clone(), + }); let p = &mut state.players[defender_player]; p.units.swap_remove(defender_unit); if defender_unit < p.unit_upkeep.len() { @@ -2100,6 +2275,24 @@ impl TurnProcessor { }); } CombatOutcome::Killed => { + // p2-67 Bug 3: stage UnitKilled BEFORE swap_remove so the + // queued-PvP path emits the same chronicle entry the + // discovery loop emits at line ~2702. + let unit_id = state.players[defender_player].units[defender_unit].id; + let (col, row) = ( + state.players[defender_player].units[defender_unit].col, + state.players[defender_player].units[defender_unit].row, + ); + let unit_kind = state.players[defender_player].units[defender_unit].unit_id.clone(); + state.pending_capture_events.units_killed.push(UnitKilledEvent { + turn: state.turn, + unit_id, + attacker: attacker_player as u8, + defender: defender_player as u8, + col, + row, + unit_kind, + }); let res = state.players[defender_player].units[defender_unit].held_resources.clone(); mc_combat::credit_resources(&res, &mut state.players[defender_player].strategic_ledger); let p = &mut state.players[defender_player]; @@ -2116,6 +2309,24 @@ impl TurnProcessor { } if !attacker_survived { + // p2-67 Bug 3: stage UnitKilled for the attacker side too — + // a defender's retaliation can kill the attacker on the same + // queued-attack resolution. Mirror the defender-side push. + let a_unit_id = state.players[attacker_player].units[attacker_unit].id; + let (a_col2, a_row2) = ( + state.players[attacker_player].units[attacker_unit].col, + state.players[attacker_player].units[attacker_unit].row, + ); + let a_unit_kind = state.players[attacker_player].units[attacker_unit].unit_id.clone(); + state.pending_capture_events.units_killed.push(UnitKilledEvent { + turn: state.turn, + unit_id: a_unit_id, + attacker: defender_player as u8, + defender: attacker_player as u8, + col: a_col2, + row: a_row2, + unit_kind: a_unit_kind, + }); let res = state.players[attacker_player].units[attacker_unit].held_resources.clone(); mc_combat::credit_resources(&res, &mut state.players[attacker_player].strategic_ledger); let p = &mut state.players[attacker_player]; @@ -2703,6 +2914,7 @@ impl TurnProcessor { turn: state.turn, attacker: mc_replay::ClanId(attacker as u32), defender: mc_replay::ClanId(pi as u32), + unit_id: unit.id, unit_kind: mc_replay::UnitKind(unit.unit_id.clone()), hex: mc_replay::TileCoord::new(unit.col, unit.row), }); diff --git a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs index 4ad71da4..f000ae7d 100644 --- a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs +++ b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs @@ -203,6 +203,7 @@ fn ten_turn_run_emits_each_wired_variant() { .events .iter() .map(|e| match e { + TurnEvent::CityBuildingCompleted { .. } => "CityBuildingCompleted", TurnEvent::CityFounded { .. } => "CityFounded", TurnEvent::WonderBuilt { .. } => "WonderBuilt", TurnEvent::CityCaptured { .. } => "CityCaptured", @@ -299,6 +300,7 @@ fn events_emitted_appears_on_turn_result() { .events_emitted .iter() .map(|e| match e { + TurnEvent::CityBuildingCompleted { .. } => "CityBuildingCompleted", TurnEvent::CityFounded { .. } => "CityFounded", TurnEvent::WonderBuilt { .. } => "WonderBuilt", TurnEvent::CityCaptured { .. } => "CityCaptured",