From 2eb86c0406943ead70e7417f91eb5622557eea8f Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 4 Jun 2026 08:53:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(combat):=20=E2=9C=A8=20Adjust=20combat=20b?= =?UTF-8?q?alance=20formulas=20and=20update=20core=20exports=20for=20new?= =?UTF-8?q?=20parameters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-core/src/combat_balance.rs | 121 ++++++++++++++++++ src/simulator/crates/mc-core/src/lib.rs | 4 +- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/simulator/crates/mc-core/src/combat_balance.rs b/src/simulator/crates/mc-core/src/combat_balance.rs index 1b0f083d..de475b0b 100644 --- a/src/simulator/crates/mc-core/src/combat_balance.rs +++ b/src/simulator/crates/mc-core/src/combat_balance.rs @@ -63,6 +63,78 @@ pub struct CombatBalance { /// city loss (`cities_lost ≥ 1`). #[serde(default)] pub solo_city_grace: SoloCityGrace, + /// p2-57c — production-quality stat deltas. The **global default rule** + /// applied to a unit's base combat stats when it spawns at a given + /// `QualityTier` (derived from the producing city's stockpile depth of the + /// gating resource, per PRODUCTION_CHAIN.md's well-stocked / low / empty + /// band table). These are the falsifiable single-source magnitudes; an + /// individual unit JSON may override them with a per-unit `quality_chain` + /// block (p2-57b authoring), but in the absence of one this default rule + /// governs — so quality magnitudes derive from one documented rule, not + /// 158 invented per-unit numbers. + #[serde(default)] + pub quality_deltas: QualityDeltas, +} + +/// p2-57c — additive combat-stat deltas applied per production-quality band. +/// +/// PRODUCTION_CHAIN.md §"Unit Quality from Processing" gives the *directions* +/// (Forge → +attack, Tannery → +armor/defense, Sawmill → +HP) but no +/// magnitudes; these defaults supply the canonical numeric rule. Each band +/// carries an additive `(attack, defense, hp)` delta layered on the unit's +/// base stats. `Levy` (empty stockpile) is the no-bonus baseline (all zero); +/// `Regular` (low stockpile) a modest bonus; `Veteran` (well-stocked + +/// master workers) the full bonus. Values are additive integers so a tier-1 +/// warrior (atk 14 / def 8 / hp 80) and a tier-5 unit scale the same flat +/// amount — quality is an equipment bonus, not a percentage of base. +/// +/// This block is the *contract shape* that per-unit `quality_chain` overrides +/// must match (`validate-game-data.py::validate_unit_quality_chain`). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct QualityDeltas { + /// Well-stocked: full equipment + master-worker bonus. + #[serde(default = "default_veteran_delta")] + pub veteran: StatDelta, + /// Low stockpile: reduced quality, partial bonus. + #[serde(default = "default_regular_delta")] + pub regular: StatDelta, + /// Empty stockpile: base quality, no equipment bonus. + #[serde(default = "default_levy_delta")] + pub levy: StatDelta, +} + +/// Additive `(attack, defense, hp)` stat delta for one quality band. +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] +pub struct StatDelta { + /// Additive attack bonus (Forge / master smiths — better weapons). + #[serde(default)] + pub attack: i32, + /// Additive defense / armor bonus (Tannery / master tanners — better armor). + #[serde(default)] + pub defense: i32, + /// Additive max-HP bonus (Sawmill / master carpenters — better materials). + #[serde(default)] + pub hp: i32, +} + +fn default_veteran_delta() -> StatDelta { + StatDelta { attack: 3, defense: 3, hp: 10 } +} +fn default_regular_delta() -> StatDelta { + StatDelta { attack: 1, defense: 1, hp: 4 } +} +fn default_levy_delta() -> StatDelta { + StatDelta { attack: 0, defense: 0, hp: 0 } +} + +impl Default for QualityDeltas { + fn default() -> Self { + Self { + veteran: default_veteran_delta(), + regular: default_regular_delta(), + levy: default_levy_delta(), + } + } } /// p1-29d solo-city grace tuning block. @@ -131,6 +203,21 @@ impl Default for CombatBalance { base_xp_value: default_base_xp_value(), low_worker_pool_threshold: default_low_worker_pool_threshold(), solo_city_grace: SoloCityGrace::default(), + quality_deltas: QualityDeltas::default(), + } + } +} + +impl QualityDeltas { + /// Look up the additive stat delta for a named quality band. Callers map + /// `mc_city::QualityTier` → `&'static str` (`"veteran"` / `"regular"` / + /// `"levy"`) to keep this block free of an mc-city dependency edge. + #[must_use] + pub fn for_band(&self, band: &str) -> StatDelta { + match band { + "veteran" => self.veteran, + "regular" => self.regular, + _ => self.levy, } } } @@ -196,4 +283,38 @@ mod tests { let cb = parse_combat_balance(json).unwrap(); assert_eq!(cb.ransom_offer_duration_turns, 4); } + + #[test] + fn quality_deltas_default_matches_canonical_json() { + // p2-57c — the Rust default rule must match the canonical + // combat_balance.json quality_deltas block exactly. + let q = QualityDeltas::default(); + assert_eq!(q.veteran, StatDelta { attack: 3, defense: 3, hp: 10 }); + assert_eq!(q.regular, StatDelta { attack: 1, defense: 1, hp: 4 }); + assert_eq!(q.levy, StatDelta { attack: 0, defense: 0, hp: 0 }); + } + + #[test] + fn quality_deltas_loads_from_json() { + let json = r#"{ + "quality_deltas": { + "veteran": { "attack": 5, "defense": 4, "hp": 15 }, + "regular": { "attack": 2, "defense": 2, "hp": 6 }, + "levy": { "attack": 0, "defense": 0, "hp": 0 } + } + }"#; + let cb = parse_combat_balance(json).unwrap(); + assert_eq!(cb.quality_deltas.veteran, StatDelta { attack: 5, defense: 4, hp: 15 }); + assert_eq!(cb.quality_deltas.regular.hp, 6); + // for_band maps tier names to deltas. + assert_eq!(cb.quality_deltas.for_band("veteran").attack, 5); + assert_eq!(cb.quality_deltas.for_band("levy"), StatDelta::default()); + assert_eq!(cb.quality_deltas.for_band("unknown"), cb.quality_deltas.levy); + } + + #[test] + fn quality_deltas_absent_falls_through_to_default() { + let cb = parse_combat_balance(r#"{"ransom_offer_duration_turns": 3}"#).unwrap(); + assert_eq!(cb.quality_deltas, QualityDeltas::default()); + } } diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 0f4f28c2..24356ad8 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -35,7 +35,9 @@ pub mod worker; pub use building::{BuildingEntity, Placement}; pub use city_action::{CityAction, CityId}; -pub use combat_balance::{parse_combat_balance, CombatBalance, SoloCityGrace}; +pub use combat_balance::{ + parse_combat_balance, CombatBalance, QualityDeltas, SoloCityGrace, StatDelta, +}; pub use damage_channel::{ChannelDamageBundle, DamageChannel}; pub use diplomacy::{AgreementType, MechanicKey}; pub use civic::{AxisChoice, CivicAxis, CivicState, ANARCHY_DURATION, ANARCHY_SENTINEL};