feat(mc-turn): Add quality control checks for spawn divergence and enhance turn-based game state management

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-04 08:53:00 -07:00
parent 2eb86c0406
commit 46bb93b0be
4 changed files with 313 additions and 0 deletions

View file

@ -1172,6 +1172,16 @@ pub struct MapUnit {
/// Serde `default = None` keeps old saves loadable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_points: Option<mc_core::units::ActionPoints>,
/// p2-57c: production-quality band stamped at unit-completion time, derived
/// from the producing city's stockpile depth of the gating resource
/// (`mc_city::recipes::tick_and_stamp` → `StampedUnit.quality`). `None` for
/// units that were not produced through a quality-bearing recipe (bench /
/// legacy auto-warrior spawns, captured units, old saves). When set, the
/// unit's `attack` / `defense` / `max_hp` were already adjusted via
/// [`crate::apply_quality`] at spawn — this field records *which* band the
/// adjustment used (for UI badges + replay), it is not re-applied per turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quality: Option<mc_city::QualityTier>,
}
impl MapUnit {

View file

@ -32,6 +32,7 @@ pub mod game_state;
pub mod combat_event;
pub mod processor;
pub mod prologue;
pub mod quality;
pub mod spatial_index;
pub mod victory;
pub mod courier_resolver;
@ -53,6 +54,7 @@ mod derived_stats_tests;
pub use action::{legal_actions, ActionAvailability, ActionKind, DisabledReason, UnitCapability};
pub use action_handlers::{invoke as invoke_action, ActionError};
pub use chronicle::{Chronicle, ChronicleEntry};
pub use quality::{apply_quality, band_name, resolve_deltas, UnitQualityChain};
pub use game_state::{AttackRequest, BombardRequest, BuildingRallyPoint, ChargeRequest, CityEcology, EscortRequest, GameState, MapUnit, MoveRequest, PillageRequest, PlayerState, TechState, VolleyRequest};
pub use mc_core::improvement::{RawImprovementJson, TileImprovement, TileImprovementSpec};
pub use capture::{resolve_posture, CapturePosture, PromptUnresolved};

View file

@ -0,0 +1,204 @@
//! p2-57c — production-quality consumer: turn a `mc_city::QualityTier` (stamped
//! at unit-production completion from the producing city's stockpile depth)
//! into concrete combat-stat deltas on a unit's base stats.
//!
//! This is the missing consumer the `quality_chain` JSON data needs a contract
//! against (see `.project/objectives/p2-57c-mc-units-quality-consumer.md`). The
//! producer side (`mc_city::recipes::{stamp_unit_quality, tick_and_stamp,
//! StampedUnit}`) already ships; `QualityTier` was "produced and consumed
//! nowhere" until this module.
//!
//! ## Where the magnitudes come from (Rail 2 — data-driven, not hardcoded)
//!
//! 1. **Global default rule** — `mc_core::CombatBalance::quality_deltas`
//! (`QualityDeltas`), loaded from
//! `public/games/age-of-dwarves/data/combat_balance.json`. One falsifiable
//! source: Veteran / Regular / Levy each carry an additive `(attack,
//! defense, hp)` delta. This is the rule that governs every producible unit
//! that does not author an explicit override.
//! 2. **Per-unit override** — a unit JSON may carry a `quality_chain` block
//! (`{ veteran, regular, levy }`, each an additive `StatDelta`) to override
//! the global rule for that one unit type. This is the field
//! `p2-57b` authors against; its shape is validated by
//! `tools/validate-game-data.py::validate_unit_quality_chain` and mirrors
//! `QualityDeltas` exactly.
//!
//! `apply_quality` lives in `mc-turn` because it transforms a
//! `mc_combat::resolver::UnitStats` using a `mc_city::QualityTier`, and `mc-turn`
//! is the one crate that already depends on both (mc-combat is a mc-core-only
//! leaf, so it cannot reach `QualityTier`). No new dependency edges are added.
use mc_city::QualityTier;
use mc_core::{QualityDeltas, StatDelta};
/// Map a `QualityTier` to the lowercase band name used by
/// `QualityDeltas::for_band` and the per-unit `quality_chain` JSON keys.
#[must_use]
pub fn band_name(tier: QualityTier) -> &'static str {
match tier {
QualityTier::Veteran => "veteran",
QualityTier::Regular => "regular",
QualityTier::Levy => "levy",
}
}
/// Per-unit `quality_chain` override block, parsed from a unit JSON. Each band
/// is an additive [`StatDelta`]; the wire shape is identical to
/// [`mc_core::QualityDeltas`] so a unit either uses the global default or
/// supplies a full per-tier table that replaces it.
#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize)]
pub struct UnitQualityChain {
#[serde(default)]
pub veteran: StatDelta,
#[serde(default)]
pub regular: StatDelta,
#[serde(default)]
pub levy: StatDelta,
}
impl From<UnitQualityChain> for QualityDeltas {
fn from(c: UnitQualityChain) -> Self {
QualityDeltas { veteran: c.veteran, regular: c.regular, levy: c.levy }
}
}
/// Resolve the active per-band delta table for a unit: the unit's own
/// `quality_chain` override when present, else the global default rule.
#[must_use]
pub fn resolve_deltas(global: &QualityDeltas, per_unit: Option<UnitQualityChain>) -> QualityDeltas {
per_unit.map_or(*global, QualityDeltas::from)
}
/// Apply a production-quality band's stat delta to a unit's *base* combat
/// stats, returning the quality-adjusted stats.
///
/// The delta is **additive** (equipment bonus, not a percentage of base) and
/// touches `attack`, `defense`, and `max_hp`. `hp` is raised in lockstep with
/// `max_hp` so a freshly-produced unit spawns at full quality-adjusted health;
/// stats are clamped at 0 so a (hypothetical) negative authored delta can never
/// drive a stat below zero. `ranged_attack`, `range`, and `movement` are not
/// quality-modified (PRODUCTION_CHAIN.md only routes Forge/Tannery/Sawmill into
/// attack/armor/HP).
#[must_use]
pub fn apply_quality(
base: mc_combat::resolver::UnitStats,
tier: QualityTier,
deltas: &QualityDeltas,
) -> mc_combat::resolver::UnitStats {
let d: StatDelta = deltas.for_band(band_name(tier));
let max_hp = (base.max_hp + d.hp).max(0);
mc_combat::resolver::UnitStats {
max_hp,
// Fresh spawn: hp rises with max_hp. A pre-damaged unit would clamp to
// the new max, but production always stamps a full-health unit.
hp: (base.hp + d.hp).clamp(0, max_hp),
attack: (base.attack + d.attack).max(0),
defense: (base.defense + d.defense).max(0),
ranged_attack: base.ranged_attack,
range: base.range,
movement: base.movement,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> mc_combat::resolver::UnitStats {
// tier-1 warrior base shape (resources/units/warrior.json).
mc_combat::resolver::UnitStats {
hp: 80,
max_hp: 80,
attack: 14,
defense: 8,
ranged_attack: 0,
range: 0,
movement: 2,
}
}
#[test]
fn band_name_round_trips_all_three_tiers() {
assert_eq!(band_name(QualityTier::Veteran), "veteran");
assert_eq!(band_name(QualityTier::Regular), "regular");
assert_eq!(band_name(QualityTier::Levy), "levy");
}
#[test]
fn veteran_adds_full_default_bonus() {
let g = QualityDeltas::default();
let s = apply_quality(base(), QualityTier::Veteran, &g);
// default veteran delta = +3 atk / +3 def / +10 hp.
assert_eq!(s.attack, 17);
assert_eq!(s.defense, 11);
assert_eq!(s.max_hp, 90);
assert_eq!(s.hp, 90, "fresh spawn is at full quality-adjusted health");
// unmodified channels pass through.
assert_eq!(s.movement, 2);
assert_eq!(s.ranged_attack, 0);
}
#[test]
fn levy_is_the_no_bonus_baseline() {
let g = QualityDeltas::default();
let s = apply_quality(base(), QualityTier::Levy, &g);
assert_eq!((s.attack, s.defense, s.max_hp), (14, 8, 80));
}
#[test]
fn regular_is_between_levy_and_veteran() {
let g = QualityDeltas::default();
let levy = apply_quality(base(), QualityTier::Levy, &g);
let reg = apply_quality(base(), QualityTier::Regular, &g);
let vet = apply_quality(base(), QualityTier::Veteran, &g);
assert!(levy.attack < reg.attack && reg.attack < vet.attack);
assert!(levy.max_hp < reg.max_hp && reg.max_hp < vet.max_hp);
}
#[test]
fn per_unit_override_replaces_global_rule() {
let g = QualityDeltas::default();
let override_chain = UnitQualityChain {
veteran: StatDelta { attack: 20, defense: 0, hp: 0 },
regular: StatDelta::default(),
levy: StatDelta::default(),
};
let deltas = resolve_deltas(&g, Some(override_chain));
let s = apply_quality(base(), QualityTier::Veteran, &deltas);
assert_eq!(s.attack, 34, "per-unit override (+20) replaces global (+3)");
assert_eq!(s.max_hp, 80, "override veteran hp delta is 0");
}
#[test]
fn absent_override_falls_through_to_global() {
let g = QualityDeltas::default();
let deltas = resolve_deltas(&g, None);
assert_eq!(deltas, g);
}
#[test]
fn parses_quality_chain_json_shape() {
let raw = r#"{
"veteran": { "attack": 4, "defense": 4, "hp": 12 },
"regular": { "attack": 2, "defense": 1, "hp": 5 },
"levy": { "attack": 0, "defense": 0, "hp": 0 }
}"#;
let c: UnitQualityChain = serde_json::from_str(raw).expect("parse");
assert_eq!(c.veteran.attack, 4);
assert_eq!(c.regular.hp, 5);
assert_eq!(c.levy, StatDelta::default());
}
#[test]
fn negative_delta_clamps_at_zero() {
let g = QualityDeltas::default();
let punish = UnitQualityChain {
veteran: StatDelta::default(),
regular: StatDelta::default(),
levy: StatDelta { attack: -100, defense: -100, hp: -100 },
};
let deltas = resolve_deltas(&g, Some(punish));
let s = apply_quality(base(), QualityTier::Levy, &deltas);
assert_eq!((s.attack, s.defense, s.max_hp, s.hp), (0, 0, 0, 0));
}
}

View file

@ -0,0 +1,97 @@
//! p2-57c bullet 2 — end-to-end production-quality divergence.
//!
//! Proves that a **resourced** city and a **starved** city, producing the same
//! unit type, yield stat-divergent units: the producer-side stamp
//! (`mc_city::recipes::tick_and_stamp` / `stamp_unit_quality`) derives a
//! `QualityTier` from the city's stockpile depth of the gating resource, and the
//! consumer (`mc_turn::apply_quality`) turns that tier into divergent
//! `attack` / `defense` / `max_hp` on the spawned unit.
//!
//! This drives the producer → tier → consumer pipeline directly (the
//! integration the objective grades) without depending on the live
//! GDExtension-driven catalog→spawn resolver, which is a separate surface
//! (see p2-57c resume note).
use mc_city::recipes::stamp_unit_quality;
use mc_city::QualityTier;
use mc_core::{ResourceId, ResourceStockpile, UnitId};
use mc_turn::{apply_quality, band_name};
/// tier-1 warrior base stats (resources/units/warrior.json).
fn warrior_base() -> mc_combat::resolver::UnitStats {
mc_combat::resolver::UnitStats {
hp: 80,
max_hp: 80,
attack: 14,
defense: 8,
ranged_attack: 0,
range: 0,
movement: 2,
}
}
/// Build a stockpile holding `depth` of the gating resource `weapons`.
fn stockpile_with(depth: u32) -> ResourceStockpile {
let mut sp = ResourceStockpile::default();
if depth > 0 {
sp.add(ResourceId::new("weapons"), depth);
}
sp
}
#[test]
fn resourced_vs_starved_city_produce_stat_divergent_units() {
let global = mc_core::QualityDeltas::default();
let gating = ResourceId::new("weapons");
let unit_id = UnitId::new("warrior");
// Well-stocked city (depth 6 ≥ WELL_STOCKED_MIN=4 → Veteran).
let resourced = stockpile_with(6);
let stamped_hi = stamp_unit_quality(unit_id.clone(), &resourced, &gating);
assert_eq!(stamped_hi.quality, QualityTier::Veteran);
// Starved city (depth 0 → Levy).
let starved = stockpile_with(0);
let stamped_lo = stamp_unit_quality(unit_id, &starved, &gating);
assert_eq!(stamped_lo.quality, QualityTier::Levy);
// Consume the stamped tiers: same base, divergent results.
let hi = apply_quality(warrior_base(), stamped_hi.quality, &global);
let lo = apply_quality(warrior_base(), stamped_lo.quality, &global);
assert!(
hi.attack > lo.attack,
"veteran-produced unit must out-attack levy: {} vs {}",
hi.attack,
lo.attack
);
assert!(hi.defense > lo.defense, "veteran armor must exceed levy");
assert!(hi.max_hp > lo.max_hp, "veteran HP must exceed levy");
// The Levy unit is exactly the base (no equipment bonus).
let base = warrior_base();
assert_eq!((lo.attack, lo.defense, lo.max_hp), (base.attack, base.defense, base.max_hp));
// Band names round-trip for the badge/replay surface.
assert_eq!(band_name(stamped_hi.quality), "veteran");
assert_eq!(band_name(stamped_lo.quality), "levy");
}
#[test]
fn low_stockpile_produces_the_middle_regular_band() {
let global = mc_core::QualityDeltas::default();
let gating = ResourceId::new("weapons");
let unit_id = UnitId::new("warrior");
// depth 2 → 1..=3 → Regular.
let low = stockpile_with(2);
let stamped = stamp_unit_quality(unit_id, &low, &gating);
assert_eq!(stamped.quality, QualityTier::Regular);
let regular = apply_quality(warrior_base(), QualityTier::Regular, &global);
let levy = apply_quality(warrior_base(), QualityTier::Levy, &global);
let veteran = apply_quality(warrior_base(), QualityTier::Veteran, &global);
// Monotone band ordering end-to-end.
assert!(levy.attack < regular.attack && regular.attack < veteran.attack);
assert!(levy.max_hp < regular.max_hp && regular.max_hp < veteran.max_hp);
}