From 9d2c72051f11290d6da5626b06eab1fbf50cff0e Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 24 Jun 2026 21:31:09 -0400 Subject: [PATCH] =?UTF-8?q?fix(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=90=9B=20charge=20units=20their=20authored=20build=5Fcost?= =?UTF-8?q?=20in=20try=5Fspawn=5Funit=20(Rail=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit City unit production spawned every unit at the flat `lair_combat_config.unit_spawn_cost` (8) regardless of the unit's authored production cost — `dwarf_warrior` costs 40 in public/resources/units/dwarf_warrior.json ("cost", loaded as UnitStats.build_cost). Buildings/wonders already pay their real queue_cost in process_city_production, but Queueable::Unit is routed to try_spawn_unit, which hardcoded the flat 8. Result: units were ~5x too cheap, so a starter city (~6 prod/turn) spawned a warrior every ~1.3 turns instead of ~7 and the AI stack ballooned in the opening turns. try_spawn_unit now charges `units_catalog[unit].build_cost` when the runtime catalog knows it (>0), falling back to the flat unit_spawn_cost only for un-costed bench fixtures and the no-queue auto-warrior. The data-loaded game (build_cost=40) now prices units correctly; bench tests with build_cost=0 are unchanged (flat 8). This restores Rail 2 — the JSON cost is canonical, not a hardcoded Rust constant. Validated: mc-turn lib 248/0 (incl. all spawn + gold-gate tests), mc-player-api + mc-turn 138/0, claude_vs_ai_full_game_transcript green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/crates/mc-turn/src/processor.rs | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 9499f3ce..d0681a52 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -1479,24 +1479,42 @@ impl TurnProcessor { // 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 spawn_candidates: Vec<(usize, (i32, i32), String, i32)> = { + let catalog = &state.units_catalog; let player = &state.players[pi]; player .cities .iter() .enumerate() .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) - }) + }?; + // Rail 2: charge the unit's AUTHORED production cost + // (`UnitStats.build_cost`, JSON `"cost"`) when the runtime + // catalog knows it. The flat `unit_spawn_cost` is a + // lair-combat constant; it remains only as the fallback for + // un-costed bench fixtures and the no-queue auto-warrior + // (build_cost 0). Before this, every city unit spawned at the + // flat 8 regardless of the data cost (40 for dwarf_warrior), + // so units were ~5x too cheap — the AI's stack ballooned in + // the opening turns. + let unit_cost = catalog + .get(&unit_id_for_spawn) + .map(|s| s.build_cost as i32) + .filter(|&bc| bc > 0) + .unwrap_or(cost); + if c.production_stored < unit_cost { + return None; + } + Some(( + i, + player.city_positions.get(i).copied().unwrap_or((0, 0)), + unit_id_for_spawn, + unit_cost, + )) }) .collect() }; @@ -1527,7 +1545,7 @@ impl TurnProcessor { // resource, the gate fires here. let default_reqs: Vec = Vec::new(); - for (city_idx, pos, unit_kind) in spawn_candidates { + for (city_idx, pos, unit_kind, unit_cost) in spawn_candidates { if !gold_gate_open { // Skip every candidate this turn — production carries over. // Not pushed into `strategic_gate_rejected` because that vec @@ -1608,7 +1626,7 @@ impl TurnProcessor { let (u_hp, u_max_hp, u_atk, u_def) = self.resolve_spawn_combat(state, &unit_kind, None); let player = &mut state.players[pi]; - player.cities[city_idx].production_stored -= cost; + player.cities[city_idx].production_stored -= unit_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