feat(@projects/@magic-civilization): restructure ai scoring logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-10 02:48:36 -07:00
parent 90bf14c649
commit dcdc683ad3
4 changed files with 378 additions and 335 deletions

View file

@ -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<String, i32>,
/// 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<f32>,
/// p2-55 Wave 2 — ransom acceptance probability scalar.
#[serde(default)]
pub ransom_accept_threshold: Option<f32>,
/// p2-55 Wave 2 — multiplier on the destroy-posture denial-value score.
#[serde(default)]
pub destroy_civilian_aggression: Option<f32>,
/// 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<f32>,
/// p2-44 — Defense-category promotion weight.
#[serde(default)]
pub promotion_defense_weight: Option<f32>,
/// p2-44 — Mobility / utility promotion weight.
#[serde(default)]
pub promotion_mobility_weight: Option<f32>,
}
/// 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
/// `<data_dir>/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<Self, LoadError> {
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<Self, LoadError> {
let personalities: HashMap<String, PersonalityDef> =
serde_json::from_str(json).map_err(|source| LoadError::Parse {
path: PathBuf::from("<inline-json>"),
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<String, i32>) {
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<f64> {
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<String, i32>, 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 `<data_dir>/ai_personalities.json`.

View file

@ -9,6 +9,7 @@ serde_json.workspace = true
getrandom.workspace = true
siphasher.workspace = true
rayon = "1"
thiserror = "1"
[lints]
workspace = true

View file

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

View file

@ -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<String, PersonalityDef>`.
#[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<String, i32>,
/// 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<f32>,
/// p2-55 Wave 2 — ransom acceptance probability scalar.
#[serde(default)]
pub ransom_accept_threshold: Option<f32>,
/// p2-55 Wave 2 — multiplier on the destroy-posture denial-value score.
#[serde(default)]
pub destroy_civilian_aggression: Option<f32>,
/// 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<f32>,
/// p2-44 — Defense-category promotion weight.
#[serde(default)]
pub promotion_defense_weight: Option<f32>,
/// p2-44 — Mobility / utility promotion weight.
#[serde(default)]
pub promotion_mobility_weight: Option<f32>,
}
/// 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
/// `<data_dir>/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<Self, LoadError> {
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<Self, LoadError> {
let personalities: HashMap<String, PersonalityDef> =
serde_json::from_str(json).map_err(|source| LoadError::Parse {
path: PathBuf::from("<inline-json>"),
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<String, i32>) {
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<f64> {
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<String, i32>, 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;
}