feat(combat): Adjust combat balance formulas and update core exports for new parameters

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

View file

@ -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());
}
}

View file

@ -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};