feat(@projects/@magic-civilization): ✨ add unit_id and building_id serialization
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
410c1a05be
commit
9aafea9c76
8 changed files with 353 additions and 27 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, .. }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue