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