diff --git a/src/simulator/crates/mc-ai/src/evaluator.rs b/src/simulator/crates/mc-ai/src/evaluator.rs index e1cd6325..6f669804 100644 --- a/src/simulator/crates/mc-ai/src/evaluator.rs +++ b/src/simulator/crates/mc-ai/src/evaluator.rs @@ -1,330 +1,17 @@ use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use serde::Deserialize; -use thiserror::Error; +use std::path::Path; use crate::game_state::{ AiCityState, AiFormationState, AiPlayerState, AiProductionCandidate, AiTechCandidate, StrategicWeights, }; -/// Errors produced when resolving a clan personality into scoring weights. -#[derive(Debug, Error)] -pub enum LoadError { - #[error("failed to read ai_personalities.json at {path}: {source}")] - Io { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to parse ai_personalities.json at {path}: {source}")] - Parse { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("unknown clan personality id: {0}")] - UnknownClan(String), -} +// p2-65 Phase 0b — moved to mc-core. Re-exported here so existing +// `mc_ai::evaluator::{LoadError, PersonalityDef, ScoringWeights}` callers +// (~12 sites across mc-balance, mc-sim, mc-turn, mc-ecology and tests) +// keep compiling without an import sweep. +pub use mc_core::scoring_weights::{LoadError, PersonalityDef, ScoringWeights}; -/// Shape of one entry in `public/games/age-of-dwarves/data/ai_personalities.json`. -/// Only the fields the evaluator consumes are decoded; unknown fields are ignored. -#[derive(Debug, Clone, Deserialize)] -pub struct PersonalityDef { - #[serde(default)] - pub id: String, - #[serde(default)] - pub name: String, - #[serde(default)] - pub strategic_axes: HashMap, - /// p2-55 Wave 2 — capture-posture scoring scalar (`PersonalityPriors::capture_weight`). - /// Top-level on the personality record (sibling of `strategic_axes`); `None` - /// means the loader uses the neutral default. - #[serde(default)] - pub capture_weight: Option, - /// p2-55 Wave 2 — ransom acceptance probability scalar. - #[serde(default)] - pub ransom_accept_threshold: Option, - /// p2-55 Wave 2 — multiplier on the destroy-posture denial-value score. - #[serde(default)] - pub destroy_civilian_aggression: Option, - /// p2-44 — Offense-category promotion weight (`PersonalityPriors::promotion_offense_weight`). - /// `None` means the loader uses the neutral default (1.0). - #[serde(default)] - pub promotion_offense_weight: Option, - /// p2-44 — Defense-category promotion weight. - #[serde(default)] - pub promotion_defense_weight: Option, - /// p2-44 — Mobility / utility promotion weight. - #[serde(default)] - pub promotion_mobility_weight: Option, -} - -/// All tunable scoring constants — the optimizer target for CMA-ES. -/// -/// Effect-based weights replace the old per-building-ID weights (granary_base, etc.). -/// This means new buildings/units added to JSON get reasonable scores automatically -/// without Rust code changes. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(default)] -pub struct ScoringWeights { - // ── City site scoring (used by CitySiteScorer in GDScript) ────────────── - pub site_food: f32, - pub site_prod: f32, - pub site_gold: f32, - pub site_quality: f32, - pub site_resource: f32, - - // ── Effect-based building scoring ──────────────────────────────────────── - pub effect_food: f32, - pub effect_prod: f32, - pub effect_gold: f32, - pub effect_science: f32, - pub effect_culture: f32, - pub effect_defense: f32, - pub food_deficit_bonus: f32, - - // ── Unit scoring ───────────────────────────────────────────────────────── - pub military_base: f32, - pub military_threat_scale: f32, - pub military_melee_need: f32, - pub military_ranged_need: f32, - pub military_flying_need: f32, - - // ── Expansion ──────────────────────────────────────────────────────────── - pub expansion_base: f32, - pub expansion_diminish_rate: f32, - pub expansion_econ_threshold: f32, - - // ── State evaluation (MCTS leaf evaluator) ─────────────────────────────── - pub yield_food: f32, - pub yield_prod: f32, - pub yield_gold: f32, - pub yield_science: f32, - pub yield_culture: f32, - pub pop_value: f32, - pub city_expansion: f32, - pub threat_penalty: f32, -} - -impl Default for ScoringWeights { - fn default() -> Self { - Self { - site_food: 3.0, - site_prod: 2.5, - site_gold: 1.5, - site_quality: 2.0, - site_resource: 3.5, - effect_food: 0.7, - effect_prod: 0.5, - effect_gold: 0.4, - effect_science: 0.45, - effect_culture: 0.35, - effect_defense: 0.7, - food_deficit_bonus: 0.6, - military_base: 0.55, - military_threat_scale: 1.0, - military_melee_need: 0.6, - military_ranged_need: 0.5, - military_flying_need: 0.4, - expansion_base: 0.5, - expansion_diminish_rate: 0.3, - expansion_econ_threshold: 30.0, - yield_food: 1.5, - yield_prod: 2.0, - yield_gold: 1.0, - yield_science: 1.2, - yield_culture: 0.8, - pop_value: 2.0, - city_expansion: 5.0, - threat_penalty: 8.0, - } - } -} - -impl ScoringWeights { - /// Number of tunable parameters — dimensionality for CMA-ES. - pub const N: usize = 28; - - /// Baseline weights — alias for `Self::default()`, used as the starting - /// point before per-clan axis deltas are applied. - pub fn baseline() -> Self { - Self::default() - } - - /// Load scoring weights for a specific clan personality from - /// `/ai_personalities.json`. Applies the six-axis delta mapping - /// defined in `apply_axes` to the baseline weights. - /// - /// Errors: - /// - `LoadError::Io` if the file is missing or unreadable - /// - `LoadError::Parse` if the JSON is malformed - /// - `LoadError::UnknownClan` if `id` is not a key in the file - pub fn from_personality(id: &str, data_dir: &Path) -> Result { - let path = data_dir.join("ai_personalities.json"); - let json = std::fs::read_to_string(&path).map_err(|source| LoadError::Io { - path: path.clone(), - source, - })?; - Self::from_personality_json(id, &json) - } - - /// Same as `from_personality` but takes the JSON string directly. Use this - /// from contexts where the file lives inside a Godot .pck and `std::fs` - /// cannot reach it (any packed export — macOS .app, Linux binary, Windows - /// .exe). The GDScript caller reads the file via `FileAccess` and hands the - /// string in. p1-24. - pub fn from_personality_json(id: &str, json: &str) -> Result { - let personalities: HashMap = - serde_json::from_str(json).map_err(|source| LoadError::Parse { - path: PathBuf::from(""), - source, - })?; - let p = personalities - .get(id) - .ok_or_else(|| LoadError::UnknownClan(id.to_string()))?; - let mut weights = Self::baseline(); - weights.apply_axes(&p.strategic_axes); - Ok(weights) - } - - /// Mutate this weight set to reflect clan personality axes. - /// - /// Axes are read on the JSON 1..=10 scale where 5 is neutral; each knob is - /// scaled by `1 + K · (axis - 5)` for positive-direction pushes or - /// `1 - K · (axis - 5)` where the axis should reduce the knob. The - /// multiplier is clamped to `[0.25, 2.5]` so an extreme axis can't zero a - /// weight out nor balloon it past the optimizer's search bounds. - /// - /// Mapping follows the Task B1 plan: - /// - `aggression` → military base/need/threat-scale (+), threat_penalty (−) - /// - `expansion` → expansion_base / site_food / site_resource (+), - /// expansion_diminish_rate (−) - /// - `production` → site_prod / effect_prod / yield_prod (+) - /// - `wealth` → site_gold / effect_gold / yield_gold (+) - /// - `trade_willingness` → effect_gold mild (+) (gold infra more valued when - /// trade is a posture; diplomacy hooks owned by Task B3) - /// - `grudge_persistence` → threat_penalty (−) (high-grudge clans stay in - /// fights rather than backing off under threat; diplomacy hooks owned by - /// Task B3) - pub fn apply_axes(&mut self, axes: &HashMap) { - let a = |key: &str| -> f32 { axis_delta(axes, key) }; - - let aggression = a("aggression"); - let expansion = a("expansion"); - let production = a("production"); - let wealth = a("wealth"); - let trade = a("trade_willingness"); - let grudge = a("grudge_persistence"); - - // ── Aggression ────────────────────────────────────────────────────── - scale(&mut self.military_base, 1.0 + 0.15 * aggression); - scale(&mut self.military_threat_scale, 1.0 + 0.15 * aggression); - scale(&mut self.military_melee_need, 1.0 + 0.12 * aggression); - scale(&mut self.military_ranged_need, 1.0 + 0.10 * aggression); - // Belligerent clans tolerate more threat → smaller threat_penalty. - scale(&mut self.threat_penalty, 1.0 - 0.08 * aggression); - - // ── Expansion ─────────────────────────────────────────────────────── - scale(&mut self.expansion_base, 1.0 + 0.20 * expansion); - scale(&mut self.site_food, 1.0 + 0.10 * expansion); - scale(&mut self.site_resource, 1.0 + 0.10 * expansion); - // Tall clans (low expansion) feel the diminishing-returns penalty more - // strongly — high expansion clans feel it less. - scale(&mut self.expansion_diminish_rate, 1.0 - 0.15 * expansion); - - // ── Production ────────────────────────────────────────────────────── - scale(&mut self.site_prod, 1.0 + 0.15 * production); - scale(&mut self.effect_prod, 1.0 + 0.18 * production); - scale(&mut self.yield_prod, 1.0 + 0.15 * production); - - // ── Wealth ────────────────────────────────────────────────────────── - scale(&mut self.site_gold, 1.0 + 0.15 * wealth); - scale(&mut self.effect_gold, 1.0 + 0.18 * wealth); - scale(&mut self.yield_gold, 1.0 + 0.15 * wealth); - - // ── Trade willingness ─────────────────────────────────────────────── - // Mercantile postures value gold-producing infrastructure a bit more; - // full diplomacy plumbing is Task B3's scope. - scale(&mut self.effect_gold, 1.0 + 0.06 * trade); - - // ── Grudge persistence ────────────────────────────────────────────── - // High-grudge clans keep fighting under pressure instead of retreating, - // so the threat_penalty shrinks (they care less about staying safe). - scale(&mut self.threat_penalty, 1.0 - 0.06 * grudge); - } - - /// Serialize to a flat Vec for CMA-ES. Order must match `from_vec`. - pub fn to_vec(&self) -> Vec { - vec![ - f64::from(self.site_food), - f64::from(self.site_prod), - f64::from(self.site_gold), - f64::from(self.site_quality), - f64::from(self.site_resource), - f64::from(self.effect_food), - f64::from(self.effect_prod), - f64::from(self.effect_gold), - f64::from(self.effect_science), - f64::from(self.effect_culture), - f64::from(self.effect_defense), - f64::from(self.food_deficit_bonus), - f64::from(self.military_base), - f64::from(self.military_threat_scale), - f64::from(self.military_melee_need), - f64::from(self.military_ranged_need), - f64::from(self.military_flying_need), - f64::from(self.expansion_base), - f64::from(self.expansion_diminish_rate), - f64::from(self.expansion_econ_threshold), - f64::from(self.yield_food), - f64::from(self.yield_prod), - f64::from(self.yield_gold), - f64::from(self.yield_science), - f64::from(self.yield_culture), - f64::from(self.pop_value), - f64::from(self.city_expansion), - f64::from(self.threat_penalty), - ] - } - - /// Deserialize from a flat Vec. Values are clamped to sane ranges. - pub fn from_vec(v: &[f64]) -> Self { - assert!(v.len() >= Self::N, "weight vector too short"); - let pos = |x: f64| x.max(0.01) as f32; - Self { - site_food: pos(v[0]), - site_prod: pos(v[1]), - site_gold: pos(v[2]), - site_quality: pos(v[3]), - site_resource: pos(v[4]), - effect_food: pos(v[5]), - effect_prod: pos(v[6]), - effect_gold: pos(v[7]), - effect_science: pos(v[8]), - effect_culture: pos(v[9]), - effect_defense: pos(v[10]), - food_deficit_bonus: pos(v[11]), - military_base: pos(v[12]), - military_threat_scale: pos(v[13]), - military_melee_need: pos(v[14]), - military_ranged_need: pos(v[15]), - military_flying_need: pos(v[16]), - expansion_base: pos(v[17]), - expansion_diminish_rate: pos(v[18]), - expansion_econ_threshold: pos(v[19]), - yield_food: pos(v[20]), - yield_prod: pos(v[21]), - yield_gold: pos(v[22]), - yield_science: pos(v[23]), - yield_culture: pos(v[24]), - pop_value: pos(v[25]), - city_expansion: pos(v[26]), - threat_penalty: pos(v[27]), - } - } -} /// Scores a candidate action or overall game state. pub trait AiEvaluator: Send + Sync { @@ -971,22 +658,10 @@ fn nearest_hex( }) } -/// Read a strategic axis (1..=10 JSON scale) and return its neutral-centered -/// delta as an `f32`. Missing axes default to 5 (neutral) so a partial JSON -/// entry degrades gracefully instead of producing an extreme clan. -fn axis_delta(axes: &HashMap, key: &str) -> f32 { - let raw = *axes.get(key).unwrap_or(&5); - let clamped = raw.clamp(1, 10); - (clamped as f32) - 5.0 -} - -/// Multiply a weight by `factor`, clamping the result to `[0.25x, 2.5x]` of -/// the pre-scaled value so extreme axes can't zero out or blow up a knob -/// past what CMA-ES expects to search over. -fn scale(weight: &mut f32, factor: f32) { - let clamped = factor.clamp(0.25, 2.5); - *weight *= clamped; -} +// p2-65 Phase 0b — `axis_delta` and `scale` helpers moved to +// `mc_core::scoring_weights` alongside `ScoringWeights::apply_axes`. +// `StrategicWeights::from_axes_1to10` below uses its own local `norm` +// closure (different scaling) so it does not need either helper. impl StrategicWeights { /// Load strategic weights for a clan from `/ai_personalities.json`. diff --git a/src/simulator/crates/mc-core/Cargo.toml b/src/simulator/crates/mc-core/Cargo.toml index 47ec109a..3da7193a 100644 --- a/src/simulator/crates/mc-core/Cargo.toml +++ b/src/simulator/crates/mc-core/Cargo.toml @@ -9,6 +9,7 @@ serde_json.workspace = true getrandom.workspace = true siphasher.workspace = true rayon = "1" +thiserror = "1" [lints] workspace = true diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 2fff7787..1cb1d79f 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod perf; pub mod player; pub mod production_origin; pub mod resources; +pub mod scoring_weights; pub mod seed; pub mod tech; pub mod units; @@ -45,6 +46,7 @@ pub use derived_stats::{DerivedStats, InequalityStat}; pub use ids::{BuildingId, GreatPersonClass, HarvestPolicyId, ResourceId, SpecialistId, SpeciesId, StackMode, UnitId}; pub use lair::{LairCombatMode, LairId, SiegeOutcome, SiegePressure, SiegeState}; pub use resources::{ResourceKind, ResourceStockpile, StockpileError as ResourceStockpileError}; +pub use scoring_weights::{LoadError, PersonalityDef, ScoringWeights}; pub use player::{HexCoord, PlayerPrologue}; pub use production_origin::ProductionOrigin; pub use tech::TechDomain; diff --git a/src/simulator/crates/mc-core/src/scoring_weights.rs b/src/simulator/crates/mc-core/src/scoring_weights.rs new file mode 100644 index 00000000..555657f8 --- /dev/null +++ b/src/simulator/crates/mc-core/src/scoring_weights.rs @@ -0,0 +1,365 @@ +//! Tunable scoring weights + clan-personality loader. Single source of truth. +//! +//! Hosted in `mc-core` (rather than `mc-ai`) because both `mc-ai` +//! (heuristic evaluator) and `mc-turn` / `mc-balance` / `mc-sim` (consumers +//! that need to construct or pass-through a `GameState.scoring_weights`) +//! reference the same struct. Putting it on the leaf crate avoids the +//! reverse-direction dep where mc-turn imports mc-ai purely for one field +//! shape. +//! +//! Items moved from `mc-ai::evaluator` (p2-65 Phase 0b): +//! - `LoadError` — typed errors from the personality JSON loader. +//! - `PersonalityDef` — JSON-decoded shape of one entry in +//! `public/games/age-of-dwarves/data/ai_personalities.json`. +//! - `ScoringWeights` — 28-dimensional weight vector with `default`, +//! `baseline`, `from_personality`, `from_personality_json`, `apply_axes`, +//! `to_vec`, `from_vec`. The CMA-ES optimizer target. +//! - Private helpers `axis_delta`, `scale` used only by `apply_axes`. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use thiserror::Error; + +/// Errors produced when resolving a clan personality into scoring weights. +#[derive(Debug, Error)] +pub enum LoadError { + /// Failed to read the personality JSON file from disk. + #[error("failed to read ai_personalities.json at {path}: {source}")] + Io { + /// Path that the loader attempted to open. + path: PathBuf, + /// Underlying I/O error. + #[source] + source: std::io::Error, + }, + /// JSON failed to parse into the expected `HashMap`. + #[error("failed to parse ai_personalities.json at {path}: {source}")] + Parse { + /// Path that produced the malformed JSON. + path: PathBuf, + /// Underlying serde error. + #[source] + source: serde_json::Error, + }, + /// JSON parsed cleanly but did not contain the requested clan id. + #[error("unknown clan personality id: {0}")] + UnknownClan(String), +} + +/// Shape of one entry in `public/games/age-of-dwarves/data/ai_personalities.json`. +/// Only the fields the evaluator + policy priors consume are decoded; unknown +/// fields are ignored. +#[derive(Debug, Clone, Deserialize)] +pub struct PersonalityDef { + /// Stable id (matches the JSON key). + #[serde(default)] + pub id: String, + /// Display name. + #[serde(default)] + pub name: String, + /// Six raw axes on the 1..=10 scale (`aggression`, `expansion`, + /// `production`, `wealth`, `trade_willingness`, `grudge_persistence`). + #[serde(default)] + pub strategic_axes: HashMap, + /// p2-55 Wave 2 — capture-posture scoring scalar (`PersonalityPriors::capture_weight`). + /// Top-level on the personality record (sibling of `strategic_axes`); `None` + /// means the loader uses the neutral default. + #[serde(default)] + pub capture_weight: Option, + /// p2-55 Wave 2 — ransom acceptance probability scalar. + #[serde(default)] + pub ransom_accept_threshold: Option, + /// p2-55 Wave 2 — multiplier on the destroy-posture denial-value score. + #[serde(default)] + pub destroy_civilian_aggression: Option, + /// p2-44 — Offense-category promotion weight (`PersonalityPriors::promotion_offense_weight`). + /// `None` means the loader uses the neutral default (1.0). + #[serde(default)] + pub promotion_offense_weight: Option, + /// p2-44 — Defense-category promotion weight. + #[serde(default)] + pub promotion_defense_weight: Option, + /// p2-44 — Mobility / utility promotion weight. + #[serde(default)] + pub promotion_mobility_weight: Option, +} + +/// All tunable scoring constants — the optimizer target for CMA-ES. +/// +/// Effect-based weights replace the old per-building-ID weights (granary_base, etc.). +/// This means new buildings/units added to JSON get reasonable scores automatically +/// without Rust code changes. +#[allow(missing_docs)] // 28 self-describing field names; comments in original source. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct ScoringWeights { + // ── City site scoring (used by CitySiteScorer in GDScript) ────────────── + pub site_food: f32, + pub site_prod: f32, + pub site_gold: f32, + pub site_quality: f32, + pub site_resource: f32, + + // ── Effect-based building scoring ──────────────────────────────────────── + pub effect_food: f32, + pub effect_prod: f32, + pub effect_gold: f32, + pub effect_science: f32, + pub effect_culture: f32, + pub effect_defense: f32, + pub food_deficit_bonus: f32, + + // ── Unit scoring ───────────────────────────────────────────────────────── + pub military_base: f32, + pub military_threat_scale: f32, + pub military_melee_need: f32, + pub military_ranged_need: f32, + pub military_flying_need: f32, + + // ── Expansion ──────────────────────────────────────────────────────────── + pub expansion_base: f32, + pub expansion_diminish_rate: f32, + pub expansion_econ_threshold: f32, + + // ── State evaluation (MCTS leaf evaluator) ─────────────────────────────── + pub yield_food: f32, + pub yield_prod: f32, + pub yield_gold: f32, + pub yield_science: f32, + pub yield_culture: f32, + pub pop_value: f32, + pub city_expansion: f32, + pub threat_penalty: f32, +} + +impl Default for ScoringWeights { + fn default() -> Self { + Self { + site_food: 3.0, + site_prod: 2.5, + site_gold: 1.5, + site_quality: 2.0, + site_resource: 3.5, + effect_food: 0.7, + effect_prod: 0.5, + effect_gold: 0.4, + effect_science: 0.45, + effect_culture: 0.35, + effect_defense: 0.7, + food_deficit_bonus: 0.6, + military_base: 0.55, + military_threat_scale: 1.0, + military_melee_need: 0.6, + military_ranged_need: 0.5, + military_flying_need: 0.4, + expansion_base: 0.5, + expansion_diminish_rate: 0.3, + expansion_econ_threshold: 30.0, + yield_food: 1.5, + yield_prod: 2.0, + yield_gold: 1.0, + yield_science: 1.2, + yield_culture: 0.8, + pop_value: 2.0, + city_expansion: 5.0, + threat_penalty: 8.0, + } + } +} + +impl ScoringWeights { + /// Number of tunable parameters — dimensionality for CMA-ES. + pub const N: usize = 28; + + /// Baseline weights — alias for `Self::default()`, used as the starting + /// point before per-clan axis deltas are applied. + pub fn baseline() -> Self { + Self::default() + } + + /// Load scoring weights for a specific clan personality from + /// `/ai_personalities.json`. Applies the six-axis delta mapping + /// defined in `apply_axes` to the baseline weights. + /// + /// Errors: + /// - `LoadError::Io` if the file is missing or unreadable + /// - `LoadError::Parse` if the JSON is malformed + /// - `LoadError::UnknownClan` if `id` is not a key in the file + pub fn from_personality(id: &str, data_dir: &Path) -> Result { + let path = data_dir.join("ai_personalities.json"); + let json = std::fs::read_to_string(&path).map_err(|source| LoadError::Io { + path: path.clone(), + source, + })?; + Self::from_personality_json(id, &json) + } + + /// Same as `from_personality` but takes the JSON string directly. Use this + /// from contexts where the file lives inside a Godot .pck and `std::fs` + /// cannot reach it (any packed export — macOS .app, Linux binary, Windows + /// .exe). The GDScript caller reads the file via `FileAccess` and hands the + /// string in. p1-24. + pub fn from_personality_json(id: &str, json: &str) -> Result { + let personalities: HashMap = + serde_json::from_str(json).map_err(|source| LoadError::Parse { + path: PathBuf::from(""), + source, + })?; + let p = personalities + .get(id) + .ok_or_else(|| LoadError::UnknownClan(id.to_string()))?; + let mut weights = Self::baseline(); + weights.apply_axes(&p.strategic_axes); + Ok(weights) + } + + /// Mutate this weight set to reflect clan personality axes. + /// + /// Axes are read on the JSON 1..=10 scale where 5 is neutral; each knob is + /// scaled by `1 + K · (axis - 5)` for positive-direction pushes or + /// `1 - K · (axis - 5)` where the axis should reduce the knob. The + /// multiplier is clamped to `[0.25, 2.5]` so an extreme axis can't zero a + /// weight out nor balloon it past the optimizer's search bounds. + /// + /// Mapping follows the Task B1 plan: + /// - `aggression` → military base/need/threat-scale (+), threat_penalty (−) + /// - `expansion` → expansion_base / site_food / site_resource (+), + /// expansion_diminish_rate (−) + /// - `production` → site_prod / effect_prod / yield_prod (+) + /// - `wealth` → site_gold / effect_gold / yield_gold (+) + /// - `trade_willingness` → effect_gold mild (+) (gold infra more valued when + /// trade is a posture; diplomacy hooks owned by Task B3) + /// - `grudge_persistence` → threat_penalty (−) (high-grudge clans stay in + /// fights rather than backing off under threat; diplomacy hooks owned by + /// Task B3) + pub fn apply_axes(&mut self, axes: &HashMap) { + let a = |key: &str| -> f32 { axis_delta(axes, key) }; + + let aggression = a("aggression"); + let expansion = a("expansion"); + let production = a("production"); + let wealth = a("wealth"); + let trade = a("trade_willingness"); + let grudge = a("grudge_persistence"); + + // ── Aggression ────────────────────────────────────────────────────── + scale(&mut self.military_base, 1.0 + 0.15 * aggression); + scale(&mut self.military_threat_scale, 1.0 + 0.15 * aggression); + scale(&mut self.military_melee_need, 1.0 + 0.12 * aggression); + scale(&mut self.military_ranged_need, 1.0 + 0.10 * aggression); + scale(&mut self.threat_penalty, 1.0 - 0.08 * aggression); + + // ── Expansion ─────────────────────────────────────────────────────── + scale(&mut self.expansion_base, 1.0 + 0.20 * expansion); + scale(&mut self.site_food, 1.0 + 0.10 * expansion); + scale(&mut self.site_resource, 1.0 + 0.10 * expansion); + scale(&mut self.expansion_diminish_rate, 1.0 - 0.15 * expansion); + + // ── Production ────────────────────────────────────────────────────── + scale(&mut self.site_prod, 1.0 + 0.15 * production); + scale(&mut self.effect_prod, 1.0 + 0.18 * production); + scale(&mut self.yield_prod, 1.0 + 0.15 * production); + + // ── Wealth ────────────────────────────────────────────────────────── + scale(&mut self.site_gold, 1.0 + 0.15 * wealth); + scale(&mut self.effect_gold, 1.0 + 0.18 * wealth); + scale(&mut self.yield_gold, 1.0 + 0.15 * wealth); + + // ── Trade willingness ─────────────────────────────────────────────── + scale(&mut self.effect_gold, 1.0 + 0.06 * trade); + + // ── Grudge persistence ────────────────────────────────────────────── + scale(&mut self.threat_penalty, 1.0 - 0.06 * grudge); + } + + /// Serialize to a flat Vec for CMA-ES. Order must match `from_vec`. + pub fn to_vec(&self) -> Vec { + vec![ + f64::from(self.site_food), + f64::from(self.site_prod), + f64::from(self.site_gold), + f64::from(self.site_quality), + f64::from(self.site_resource), + f64::from(self.effect_food), + f64::from(self.effect_prod), + f64::from(self.effect_gold), + f64::from(self.effect_science), + f64::from(self.effect_culture), + f64::from(self.effect_defense), + f64::from(self.food_deficit_bonus), + f64::from(self.military_base), + f64::from(self.military_threat_scale), + f64::from(self.military_melee_need), + f64::from(self.military_ranged_need), + f64::from(self.military_flying_need), + f64::from(self.expansion_base), + f64::from(self.expansion_diminish_rate), + f64::from(self.expansion_econ_threshold), + f64::from(self.yield_food), + f64::from(self.yield_prod), + f64::from(self.yield_gold), + f64::from(self.yield_science), + f64::from(self.yield_culture), + f64::from(self.pop_value), + f64::from(self.city_expansion), + f64::from(self.threat_penalty), + ] + } + + /// Deserialize from a flat Vec. Values are clamped to sane ranges. + /// + /// Panics if `v.len() < Self::N`. + pub fn from_vec(v: &[f64]) -> Self { + assert!(v.len() >= Self::N, "weight vector too short"); + let pos = |x: f64| x.max(0.01) as f32; + Self { + site_food: pos(v[0]), + site_prod: pos(v[1]), + site_gold: pos(v[2]), + site_quality: pos(v[3]), + site_resource: pos(v[4]), + effect_food: pos(v[5]), + effect_prod: pos(v[6]), + effect_gold: pos(v[7]), + effect_science: pos(v[8]), + effect_culture: pos(v[9]), + effect_defense: pos(v[10]), + food_deficit_bonus: pos(v[11]), + military_base: pos(v[12]), + military_threat_scale: pos(v[13]), + military_melee_need: pos(v[14]), + military_ranged_need: pos(v[15]), + military_flying_need: pos(v[16]), + expansion_base: pos(v[17]), + expansion_diminish_rate: pos(v[18]), + expansion_econ_threshold: pos(v[19]), + yield_food: pos(v[20]), + yield_prod: pos(v[21]), + yield_gold: pos(v[22]), + yield_science: pos(v[23]), + yield_culture: pos(v[24]), + pop_value: pos(v[25]), + city_expansion: pos(v[26]), + threat_penalty: pos(v[27]), + } + } +} + +/// Read a strategic axis (1..=10 JSON scale) and return its neutral-centered +/// delta as an `f32`. Missing axes default to 5 (neutral) so a partial JSON +/// entry degrades gracefully instead of producing an extreme clan. +pub fn axis_delta(axes: &HashMap, key: &str) -> f32 { + let raw = *axes.get(key).unwrap_or(&5); + let clamped = raw.clamp(1, 10); + (clamped as f32) - 5.0 +} + +/// Multiply a weight by `factor`, clamping the result to `[0.25x, 2.5x]` of +/// the pre-scaled value so extreme axes can't zero out or blow up a knob +/// past what CMA-ES expects to search over. +pub fn scale(weight: &mut f32, factor: f32) { + let clamped = factor.clamp(0.25, 2.5); + *weight *= clamped; +}