feat(mc-turn): ✨ Introduce quality-based entity spawning logic in the Processor struct with new methods, update GameState support, and add comprehensive tests for edge cases
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
ec26b905bf
commit
4988842c6c
3 changed files with 325 additions and 9 deletions
|
|
@ -1,7 +1,7 @@
|
|||
//! Game state types — data only, no logic. All mutation happens through
|
||||
//! `TurnProcessor::step`.
|
||||
|
||||
use mc_ai::evaluator::ScoringWeights;
|
||||
use mc_core::ScoringWeights;
|
||||
use mc_replay;
|
||||
use mc_city::CityState;
|
||||
use mc_combat::StatusEffect;
|
||||
|
|
@ -359,13 +359,13 @@ pub struct GameState {
|
|||
/// tier-1-fallback behaviour. `#[serde(skip)]` because the catalog is
|
||||
/// boot-loaded, not save-persisted.
|
||||
#[serde(skip)]
|
||||
pub ai_unit_catalog: Vec<mc_ai::tactical::state::TacticalUnitSpec>,
|
||||
pub ai_unit_catalog: Vec<mc_core::tactical_types::TacticalUnitSpec>,
|
||||
/// p2-71: tactical-AI view of the producible-building catalog. Companion
|
||||
/// to `ai_unit_catalog` — populated by the harness via
|
||||
/// `GdPlayerApi::set_buildings_catalog_json`. Empty falls back to the
|
||||
/// no-building behaviour.
|
||||
#[serde(skip)]
|
||||
pub ai_building_catalog: Vec<mc_ai::tactical::state::TacticalBuildingSpec>,
|
||||
pub ai_building_catalog: Vec<mc_core::tactical_types::TacticalBuildingSpec>,
|
||||
/// p2-71: difficulty multiplier projected into
|
||||
/// `TacticalState::difficulty_threshold_mult`. Defaults to 1.0
|
||||
/// (normal). Populated by `GdPlayerApi::set_difficulty_threshold_mult`.
|
||||
|
|
@ -769,7 +769,7 @@ pub struct PlayerState {
|
|||
/// fall-through to axis-driven multipliers — fixtures predating p1-42b
|
||||
/// keep their current behaviour without any changes.
|
||||
#[serde(default)]
|
||||
pub building_priors: mc_ai::tactical::state::BuildingPriors,
|
||||
pub building_priors: mc_core::tactical_types::BuildingPriors,
|
||||
/// Stage 3 (mod system) — controller registry id used by
|
||||
/// `mc_player_api::dispatch::drive_ai_slot` to look up which
|
||||
/// `AiController` impl decides this slot's turn. Defaults to
|
||||
|
|
@ -788,7 +788,7 @@ pub struct PlayerState {
|
|||
/// (at most one no-lock turn) after a save/load rather than persisted into
|
||||
/// the save format, keeping the `mc-save` contract unchanged.
|
||||
#[serde(skip)]
|
||||
pub tactical_memory: mc_ai::tactical::TacticalMemory,
|
||||
pub tactical_memory: mc_core::tactical_types::TacticalMemory,
|
||||
/// Accumulated expansion capacity (earned from the `expansion` axis).
|
||||
pub expansion_points: u32,
|
||||
/// Per-city list of constructed building IDs. Aligned with `cities`.
|
||||
|
|
|
|||
|
|
@ -1366,6 +1366,66 @@ impl TurnProcessor {
|
|||
|
||||
// ── Phase 4: Unit production ───────────────────────────────────────────
|
||||
|
||||
/// Resolve a spawning unit's base combat stat-line (`hp`, `max_hp`,
|
||||
/// `attack`, `defense`) from the units catalog, then apply the
|
||||
/// production-`QualityTier` stat delta when one was stamped.
|
||||
///
|
||||
/// Before this, `try_spawn_unit` hardcoded `60/12/1` (the dwarf_warrior
|
||||
/// stat-line) onto **every** unit type — a queued non-warrior spawned with
|
||||
/// warrior stats (latent bug). The base line now comes from the unit's own
|
||||
/// `units/<id>.json` (`mc_units::UnitsCatalog`, p2-57c catalog extension).
|
||||
///
|
||||
/// When `quality` is `Some`, the stamped tier's delta is applied via
|
||||
/// `crate::quality::apply_quality`, reading the global default rule from
|
||||
/// `state.combat_balance.quality_deltas` (Rail 2 — data-driven, p2-57c).
|
||||
/// `quality == None` (bench / non-recipe spawn) leaves the base line as-is.
|
||||
///
|
||||
/// Fallback: if the catalog has no entry for `unit_id` (empty bench
|
||||
/// catalogs, or the 3 known building→unit referential gaps), the prior
|
||||
/// `60/12/1` stat-line is retained so existing transcript fixtures keep
|
||||
/// shipping units. `max_hp` is the unit's resolved full-health ceiling.
|
||||
fn resolve_spawn_combat(
|
||||
&self,
|
||||
state: &GameState,
|
||||
unit_id: &str,
|
||||
quality: Option<mc_city::QualityTier>,
|
||||
) -> (i32, i32, i32, i32) {
|
||||
// Base line from the catalog, else the legacy warrior fallback so
|
||||
// catalog-less bench fixtures still ship units.
|
||||
let base = match state.units_catalog.get(unit_id) {
|
||||
Some(s) if s.combat != mc_units::CombatStats::default() => UnitStats {
|
||||
hp: s.combat.hp,
|
||||
max_hp: s.combat.resolved_max_hp(),
|
||||
attack: s.combat.attack,
|
||||
defense: s.combat.defense,
|
||||
ranged_attack: s.combat.ranged_attack,
|
||||
range: s.combat.range,
|
||||
movement: s.base_moves,
|
||||
},
|
||||
// No catalog entry, or an entry with a zeroed combat block: keep
|
||||
// the historical warrior stat-line so transcript fixtures that
|
||||
// never load a catalog are unchanged.
|
||||
_ => UnitStats {
|
||||
hp: 60,
|
||||
max_hp: 60,
|
||||
attack: 12,
|
||||
defense: 1,
|
||||
ranged_attack: 0,
|
||||
range: 0,
|
||||
movement: 2,
|
||||
},
|
||||
};
|
||||
let stats = match quality {
|
||||
Some(tier) => crate::quality::apply_quality(
|
||||
base,
|
||||
tier,
|
||||
&state.combat_balance.quality_deltas,
|
||||
),
|
||||
None => base,
|
||||
};
|
||||
(stats.hp, stats.max_hp, stats.attack, stats.defense)
|
||||
}
|
||||
|
||||
fn try_spawn_unit(&self, state: &mut GameState, pi: usize, result: &mut TurnResult) {
|
||||
use mc_city::Queueable;
|
||||
|
||||
|
|
@ -1496,6 +1556,17 @@ impl TurnProcessor {
|
|||
}
|
||||
}
|
||||
};
|
||||
// Resolve base combat stats from the catalog (by the queued
|
||||
// unit_kind, not a hardcoded warrior line) and apply the stamped
|
||||
// production quality if any. Computed under an immutable `state`
|
||||
// borrow before the mutable `player` borrow below. The live spawn
|
||||
// path carries no stamp source yet (the per-city typed stockpile +
|
||||
// per-unit gating that would set `quality` is out of this lane —
|
||||
// p2-57a/b), so `quality` is `None` here today and the resolver's
|
||||
// value is the base-stat-by-id fix; the `apply_quality` branch is
|
||||
// exercised by `spawn_unit_typed` + the integration tests.
|
||||
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;
|
||||
// If the city was explicitly queueing a unit, the queue head
|
||||
|
|
@ -1512,10 +1583,10 @@ impl TurnProcessor {
|
|||
id: uid,
|
||||
col: pos.0,
|
||||
row: pos.1,
|
||||
hp: 60,
|
||||
max_hp: 60,
|
||||
attack: 12,
|
||||
defense: 1,
|
||||
hp: u_hp,
|
||||
max_hp: u_max_hp,
|
||||
attack: u_atk,
|
||||
defense: u_def,
|
||||
is_fortified: false,
|
||||
is_sentrying: false,
|
||||
unit_id: unit_kind.clone(),
|
||||
|
|
@ -1581,10 +1652,25 @@ impl TurnProcessor {
|
|||
.get(city_idx)
|
||||
.copied()
|
||||
.unwrap_or((0, 0));
|
||||
// p2-57c: if the caller stamped a production-quality tier, resolve the
|
||||
// unit's base combat line from the catalog and apply the band's delta
|
||||
// here so the spawned unit's `attack`/`defense`/`max_hp` actually carry
|
||||
// the quality adjustment (the `MapUnit.quality` field doc's contract).
|
||||
// This is the live spawn path that proves the apply-layer end-to-end.
|
||||
// `quality == None` leaves the caller-supplied stats untouched.
|
||||
let quality_combat = stats
|
||||
.quality
|
||||
.map(|tier| self.resolve_spawn_combat(state, unit_id, Some(tier)));
|
||||
let player = &mut state.players[pi];
|
||||
player.cities[city_idx].production_stored -= cost;
|
||||
debit_resources(requires, &mut player.strategic_ledger);
|
||||
let mut unit = stats;
|
||||
if let Some((q_hp, q_max_hp, q_atk, q_def)) = quality_combat {
|
||||
unit.hp = q_hp;
|
||||
unit.max_hp = q_max_hp;
|
||||
unit.attack = q_atk;
|
||||
unit.defense = q_def;
|
||||
}
|
||||
unit.id = uid;
|
||||
unit.unit_id = unit_id.to_string();
|
||||
unit.held_resources = requires.to_vec();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
//! p2-57c bullet 2 + p2-57b pipeline — quality consumer wired into the LIVE
|
||||
//! spawn path (not just the standalone `quality_spawn_divergence.rs` pipeline
|
||||
//! test).
|
||||
//!
|
||||
//! Two things are proven here, both *through `TurnProcessor`*, not via the
|
||||
//! free functions in isolation:
|
||||
//!
|
||||
//! 1. **Base-stat-by-id resolution (latent-bug fix).** `try_spawn_unit`
|
||||
//! previously hardcoded `60/12/1` onto every spawned unit regardless of
|
||||
//! type, so a queued non-warrior spawned with warrior stats. With the
|
||||
//! `mc_units::UnitsCatalog` combat-stat extension + `resolve_spawn_combat`,
|
||||
//! a city queueing unit type A and a city queueing unit type B now spawn
|
||||
//! units carrying A's and B's *own* `units/<id>.json` base lines.
|
||||
//!
|
||||
//! 2. **Apply-quality in the live spawn path.** `spawn_unit_typed` resolves the
|
||||
//! base line from the catalog and applies the stamped `QualityTier` delta
|
||||
//! (`combat_balance.quality_deltas`, Rail 2) so a Veteran-stamped unit
|
||||
//! spawns with strictly higher attack/defense/HP than a Levy-stamped unit
|
||||
//! of the same type — the resourced-vs-starved divergence, realised at the
|
||||
//! spawn boundary the live turn loop uses.
|
||||
//!
|
||||
//! HONEST SCOPE: the *causation* "resourced city → Veteran, starved → Levy"
|
||||
//! through the live loop is NOT closed here — the per-city typed
|
||||
//! `ResourceStockpile` (p2-57a) and per-unit gating-resource assignment
|
||||
//! (p2-57b) are not wired into `process_city_production`, so nothing in the
|
||||
//! live loop SETS `MapUnit.quality` yet. That stockpile→tier half is proven at
|
||||
//! pipeline level in `quality_spawn_divergence.rs`. This file proves the
|
||||
//! remaining half: once a tier is stamped, the live spawn path applies it.
|
||||
|
||||
use mc_ai::evaluator::ScoringWeights;
|
||||
use mc_city::{CityState, Queueable, QualityTier};
|
||||
use mc_turn::{GameState, MapUnit, PlayerState, TurnProcessor};
|
||||
use mc_units::{CombatStats, UnitStats as CatalogUnitStats};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Build a single-city player at `pos` with production seeded above the spawn
|
||||
/// cost so `try_spawn_unit` fires on the first step.
|
||||
fn player(pi: u8, pos: (i32, i32), queue: Option<Queueable>) -> PlayerState {
|
||||
let mut axes: BTreeMap<String, u8> = BTreeMap::new();
|
||||
axes.insert("production".into(), 9);
|
||||
axes.insert("expansion".into(), 1);
|
||||
|
||||
let mut city = CityState::starter();
|
||||
city.production_stored = 500;
|
||||
city.prod_yield = 200;
|
||||
city.queue = queue;
|
||||
|
||||
PlayerState {
|
||||
player_index: pi,
|
||||
gold: 1000,
|
||||
cities: vec![city],
|
||||
unit_upkeep: vec![],
|
||||
strategic_axes: axes,
|
||||
scoring_weights: ScoringWeights::default(),
|
||||
expansion_points: 0,
|
||||
city_buildings: vec![vec![]],
|
||||
city_improvements: Default::default(),
|
||||
city_ecology: vec![Default::default()],
|
||||
tech_state: None,
|
||||
science_yield: 0,
|
||||
science_pool: 0,
|
||||
player_tech: None,
|
||||
units: vec![],
|
||||
city_positions: vec![pos],
|
||||
capital_position: Some(pos),
|
||||
culture_total: 0,
|
||||
culture_pool: mc_culture::CulturePool::default(),
|
||||
arcane_lore_pop_deducted: false,
|
||||
traded_luxuries: Default::default(),
|
||||
relations: Default::default(),
|
||||
strategic_ledger: Default::default(),
|
||||
wonders_built: Default::default(),
|
||||
explored_deposits: Default::default(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a catalog entry carrying an explicit combat line.
|
||||
fn catalog_unit(id: &str, hp: i32, attack: i32, defense: i32) -> CatalogUnitStats {
|
||||
CatalogUnitStats {
|
||||
id: id.to_string(),
|
||||
base_moves: 2,
|
||||
domain: "land".into(),
|
||||
action_point_capacity: None,
|
||||
capturable: false,
|
||||
ransom_multiplier: 2.0,
|
||||
build_cost: 0,
|
||||
logistics: None,
|
||||
combat: CombatStats {
|
||||
hp,
|
||||
max_hp: None,
|
||||
attack,
|
||||
defense,
|
||||
ranged_attack: 0,
|
||||
range: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Bullet 1: two cities queueing *different* unit types must spawn units with
|
||||
/// each type's own base line — not a shared hardcoded `60/12/1`.
|
||||
#[test]
|
||||
fn live_spawn_resolves_distinct_base_stats_per_unit_type() {
|
||||
// Two distinct unit types with deliberately different combat lines.
|
||||
let mut state = GameState::default();
|
||||
state.units_catalog.insert(catalog_unit("dwarf_warrior", 60, 12, 1));
|
||||
state.units_catalog.insert(catalog_unit("dwarf_crossbow", 45, 20, 3));
|
||||
|
||||
state.players.push(player(
|
||||
0,
|
||||
(0, 0),
|
||||
Some(Queueable::Unit { unit_id: mc_core::UnitId::new("dwarf_warrior") }),
|
||||
));
|
||||
state.players.push(player(
|
||||
1,
|
||||
(16, 0),
|
||||
Some(Queueable::Unit { unit_id: mc_core::UnitId::new("dwarf_crossbow") }),
|
||||
));
|
||||
state.next_unit_id = 1;
|
||||
|
||||
let processor = TurnProcessor::new(200);
|
||||
let _ = processor.step(&mut state);
|
||||
|
||||
let warrior = state.players[0]
|
||||
.units
|
||||
.iter()
|
||||
.find(|u| u.unit_id == "dwarf_warrior")
|
||||
.expect("player 0 spawned a dwarf_warrior");
|
||||
let crossbow = state.players[1]
|
||||
.units
|
||||
.iter()
|
||||
.find(|u| u.unit_id == "dwarf_crossbow")
|
||||
.expect("player 1 spawned a dwarf_crossbow");
|
||||
|
||||
// Each unit carries ITS OWN catalog base line — the bug was that both got
|
||||
// 60/12/1.
|
||||
assert_eq!(
|
||||
(warrior.hp, warrior.max_hp, warrior.attack, warrior.defense),
|
||||
(60, 60, 12, 1),
|
||||
"warrior must spawn with warrior base stats",
|
||||
);
|
||||
assert_eq!(
|
||||
(crossbow.hp, crossbow.max_hp, crossbow.attack, crossbow.defense),
|
||||
(45, 45, 20, 3),
|
||||
"crossbow must spawn with ITS OWN base stats, not the warrior line — \
|
||||
this is the 60/12/1 hardcode bug the resolver fixes",
|
||||
);
|
||||
// Live spawn carries no stamp source today → quality stays None.
|
||||
assert_eq!(warrior.quality, None);
|
||||
assert_eq!(crossbow.quality, None);
|
||||
}
|
||||
|
||||
/// Empty catalog (legacy bench fixtures that never load units) falls back to
|
||||
/// the historical warrior line so transcript fixtures keep shipping units.
|
||||
#[test]
|
||||
fn live_spawn_falls_back_to_warrior_line_when_catalog_empty() {
|
||||
let mut state = GameState::default();
|
||||
// No catalog inserts. Auto-warrior (no queue) path.
|
||||
state.players.push(player(0, (0, 0), None));
|
||||
state.next_unit_id = 1;
|
||||
|
||||
let processor = TurnProcessor::new(200);
|
||||
let _ = processor.step(&mut state);
|
||||
|
||||
let u = state.players[0].units.first().expect("auto-warrior spawned");
|
||||
assert_eq!(
|
||||
(u.hp, u.max_hp, u.attack, u.defense),
|
||||
(60, 60, 12, 1),
|
||||
"catalog-less fixtures retain the legacy warrior stat-line",
|
||||
);
|
||||
}
|
||||
|
||||
/// Bullet 2: a Veteran-stamped unit and a Levy-stamped unit of the *same* type
|
||||
/// spawned through `spawn_unit_typed` (the live typed spawn path) diverge in
|
||||
/// attack/defense/HP per the global `quality_deltas` rule.
|
||||
#[test]
|
||||
fn live_typed_spawn_applies_quality_tier_divergently() {
|
||||
let mut state = GameState::default();
|
||||
state.units_catalog.insert(catalog_unit("dwarf_warrior", 60, 12, 1));
|
||||
// Two cities so both spawns have production.
|
||||
state.players.push(player(0, (0, 0), None));
|
||||
state.players[0].cities.push(CityState::starter());
|
||||
state.players[0].cities[0].production_stored = 500;
|
||||
state.players[0].cities[1].production_stored = 500;
|
||||
state.players[0].city_positions.push((4, 4));
|
||||
state.players[0].city_buildings.push(vec![]);
|
||||
state.players[0].city_ecology.push(Default::default());
|
||||
state.next_unit_id = 1;
|
||||
|
||||
let processor = TurnProcessor::new(200);
|
||||
let mut result = mc_turn::TurnResult::default();
|
||||
|
||||
// Veteran stamp on the unit headed for city 0.
|
||||
let mut vet = MapUnit::default();
|
||||
vet.col = 0;
|
||||
vet.row = 0;
|
||||
vet.quality = Some(QualityTier::Veteran);
|
||||
assert!(processor.spawn_unit_typed(
|
||||
&mut state, 0, 0, "dwarf_warrior", &[], vet, &mut result,
|
||||
));
|
||||
|
||||
// Levy stamp on the unit headed for city 1.
|
||||
let mut levy = MapUnit::default();
|
||||
levy.col = 4;
|
||||
levy.row = 4;
|
||||
levy.quality = Some(QualityTier::Levy);
|
||||
assert!(processor.spawn_unit_typed(
|
||||
&mut state, 0, 1, "dwarf_warrior", &[], levy, &mut result,
|
||||
));
|
||||
|
||||
let units = &state.players[0].units;
|
||||
assert_eq!(units.len(), 2, "both typed spawns landed");
|
||||
let v = units.iter().find(|u| u.quality == Some(QualityTier::Veteran)).unwrap();
|
||||
let l = units.iter().find(|u| u.quality == Some(QualityTier::Levy)).unwrap();
|
||||
|
||||
// Default quality_deltas: veteran +3/+3/+10, levy +0/+0/+0 over the
|
||||
// warrior base 12/1/60.
|
||||
assert_eq!(
|
||||
(v.attack, v.defense, v.max_hp),
|
||||
(15, 4, 70),
|
||||
"veteran stamp applies the +3/+3/+10 delta in the live spawn path",
|
||||
);
|
||||
assert_eq!(
|
||||
(l.attack, l.defense, l.max_hp),
|
||||
(12, 1, 60),
|
||||
"levy is the base line (no bonus)",
|
||||
);
|
||||
// Strict divergence — the whole point of quality coupling.
|
||||
assert!(v.attack > l.attack && v.defense > l.defense && v.max_hp > l.max_hp);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue