feat(@projects/@magic-civilization): ✨ restructure ai scoring logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
90bf14c649
commit
dcdc683ad3
4 changed files with 378 additions and 335 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ serde_json.workspace = true
|
|||
getrandom.workspace = true
|
||||
siphasher.workspace = true
|
||||
rayon = "1"
|
||||
thiserror = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
365
src/simulator/crates/mc-core/src/scoring_weights.rs
Normal file
365
src/simulator/crates/mc-core/src/scoring_weights.rs
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue