fix(@projects/@magic-civilization): 🐛 charge units their authored build_cost in try_spawn_unit (Rail 2)

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-24 21:31:09 -04:00
parent 12de49a16a
commit 9d2c72051f

View file

@ -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<String> = 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