feat(@projects/@magic-civilization): add unit_id and building_id serialization

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-12 15:41:10 -07:00
parent 410c1a05be
commit 9aafea9c76
8 changed files with 353 additions and 27 deletions

View file

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

View file

@ -325,6 +325,21 @@ fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec<Event> {
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<Event> {
});
}
}
// 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

View file

@ -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<CityName>,
},
/// 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_<pi>_<idx>`) 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, .. }

View file

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

View file

@ -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)]

View file

@ -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<crate::combat_event::UnitRansomExpiredEvent>,
/// 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<crate::combat_event::UnitKilledEvent>,
}
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()
}
}

View file

@ -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<String> = 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),
});

View file

@ -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",