feat(@projects/@magic-civilization): add building category and wonder priorities

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-14 23:03:49 -07:00
parent be04027e9a
commit 212cd5a9ba
8 changed files with 330 additions and 11 deletions

View file

@ -36,7 +36,21 @@
"destroy_civilian_aggression": 0.5,
"promotion_offense_weight": 0.7,
"promotion_defense_weight": 1.6,
"promotion_mobility_weight": 0.7
"promotion_mobility_weight": 0.7,
"building_category_weights": {
"production": 1.6,
"infrastructure": 1.1,
"military": 1.2,
"knowledge": 0.8,
"religious": 0.8,
"wonder": 1.1,
"naval": 0.7
},
"wonder_priorities": {
"the_great_forge": 1.8,
"ancestral_forge": 1.5,
"undermount_vault": 1.2
}
},
"goldvein": {
"id": "goldvein",
@ -75,7 +89,20 @@
"destroy_civilian_aggression": 0.4,
"promotion_offense_weight": 1.3,
"promotion_defense_weight": 0.9,
"promotion_mobility_weight": 0.8
"promotion_mobility_weight": 0.8,
"building_category_weights": {
"production": 0.9,
"infrastructure": 1.4,
"military": 0.9,
"knowledge": 1.0,
"religious": 0.8,
"wonder": 1.0,
"naval": 1.1
},
"wonder_priorities": {
"undermount_vault": 1.8,
"ancient_lighthouse": 1.4
}
},
"blackhammer": {
"id": "blackhammer",
@ -115,7 +142,20 @@
"destroy_civilian_aggression": 1.4,
"promotion_offense_weight": 1.8,
"promotion_defense_weight": 0.5,
"promotion_mobility_weight": 0.7
"promotion_mobility_weight": 0.7,
"building_category_weights": {
"production": 1.1,
"infrastructure": 0.8,
"military": 1.7,
"knowledge": 0.7,
"religious": 0.7,
"wonder": 0.8,
"naval": 0.9
},
"wonder_priorities": {
"the_great_forge": 1.3,
"ancestral_forge": 1.2
}
},
"deepforge": {
"id": "deepforge",
@ -156,7 +196,21 @@
"destroy_civilian_aggression": 0.7,
"promotion_offense_weight": 0.7,
"promotion_defense_weight": 1.5,
"promotion_mobility_weight": 0.8
"promotion_mobility_weight": 0.8,
"building_category_weights": {
"production": 1.5,
"infrastructure": 1.0,
"military": 0.9,
"knowledge": 1.0,
"religious": 0.9,
"wonder": 1.6,
"naval": 0.6
},
"wonder_priorities": {
"the_great_forge": 2.0,
"ancestral_forge": 1.8,
"undermount_vault": 1.6
}
},
"runesmith": {
"id": "runesmith",

View file

@ -33,6 +33,8 @@
"gender": null,
"maintenance": 3,
"auto_join": false,
"capturable": true,
"ransom_multiplier": 3.5,
"encyclopedia": {
"category": "civilization",
"entry_type": "unit",

View file

@ -32,6 +32,8 @@
"gender": null,
"maintenance": 2,
"auto_join": false,
"capturable": true,
"ransom_multiplier": 3.0,
"encyclopedia": {
"category": "civilization",
"entry_type": "unit",

View file

@ -3645,6 +3645,47 @@ impl GdGameState {
} else {
self.inner.players[pi].promotion_mobility_weight = 1.0;
}
// p1-42b — pull per-clan `building_category_weights`
// and `wonder_priorities` from the same JSON envelope
// and stamp them onto `PlayerState::building_priors`.
// The tactical projector
// (`mc_player_api::projection::project_tactical`)
// reads this straight through into
// `TacticalPlayerState::building_priors`, so the
// catalog-driven scorer in
// `mc_ai::tactical::production::score_building`
// sees real personality weights instead of
// `BuildingPriors::default()`. Absent maps reset to
// empty so re-stamping a slot with a personality
// that omits the keys can never leak the previous
// clan's priors.
let mut priors =
mc_ai::tactical::state::BuildingPriors::default();
if let Some(cw) = entry
.get("building_category_weights")
.and_then(|v| v.as_object())
{
for (k, v) in cw {
if let Some(f) = v.as_f64() {
priors
.building_category_weights
.insert(k.clone(), f as f32);
}
}
}
if let Some(wp) = entry
.get("wonder_priorities")
.and_then(|v| v.as_object())
{
for (k, v) in wp {
if let Some(f) = v.as_f64() {
priors
.wonder_priorities
.insert(k.clone(), f as f32);
}
}
}
self.inner.players[pi].building_priors = priors;
}
}
true

View file

@ -315,6 +315,111 @@ fn engineer_ransom_score_exceeds_worker_ransom_score_at_equal_cost() {
);
}
// ── p2-55b — Caravan Master / Merchant scoring ─────────────────────────────
//
// Same shape as the engineer trio above: trade specialists carry a GP /
// trade premium via `ransom_multiplier` (merchant 3.0, caravan_master 3.5).
// `score_capture_postures` consumes the multiplier as a plain primitive,
// so once `merchant.json` / `caravan_master.json` ship with the premium,
// the scoring layer sees it automatically. Tests pin three properties:
//
// 1. A merchant captor against a rich opponent picks Ransom for a captured
// Merchant (the trade GP) — high multiplier × high opponent gold beats
// Capture and Destroy.
// 2. Caravan Master ransom score strictly exceeds Merchant ransom score
// under captor/opponent-equal inputs — the tier-3 premium reaches scoring.
// 3. A captured Caravan Master against a low-gold opponent still routes
// correctly (Destroy or Capture, but not Ransom) — the function is
// monotonic in opponent gold even with the inflated multiplier.
#[test]
fn merchant_picks_ransom_for_a_merchant_against_rich_opponent() {
// merchant.json: build_cost 80, ransom_multiplier 3.0 → ransom_price 240.
// Opponent with 1000g → accept_prob = 0.85 × 0.9 = 0.765.
// Ransom score ≈ 240 × 0.765 ≈ 183.6. Capture score ≈ 80 × 1.2 = 96.
// Destroy ≈ 0.5 × 80 × 0.4 + 4 = 20. Ransom wins clearly.
let balance = CombatBalance::default();
let me = merchant();
let opponent = merchant();
let (posture, score) = score_capture_postures(
/* build_cost */ 80,
/* ransom_mult */ Some(3.0),
/* opponent_gold */ 1_000,
&opponent,
&me,
/* own_worker_count */ 4,
&balance,
);
assert!(score > 0.0);
assert_eq!(
posture,
PostureResolution::Ransom,
"merchant captor with rich opponent should ransom a great merchant, got {posture:?}"
);
}
#[test]
fn caravan_master_ransom_score_exceeds_merchant_ransom_at_equal_cost() {
// Hold build_cost fixed; only the multiplier changes. Caravan Master
// (3.5) must produce a strictly higher Ransom expected value than a
// Merchant (3.0). Drive captor/opponent into Ransom-favouring priors.
let balance = CombatBalance::default();
let me = merchant();
let opponent = merchant();
let opponent_gold = 1_000;
let build_cost = 80;
let (merch_posture, merch_score) = score_capture_postures(
build_cost,
Some(3.0),
opponent_gold,
&opponent,
&me,
4,
&balance,
);
let (caravan_posture, caravan_score) = score_capture_postures(
build_cost,
Some(3.5),
opponent_gold,
&opponent,
&me,
4,
&balance,
);
assert_eq!(merch_posture, PostureResolution::Ransom);
assert_eq!(caravan_posture, PostureResolution::Ransom);
assert!(
caravan_score > merch_score,
"caravan_master ransom score ({caravan_score}) must exceed merchant ransom score ({merch_score})"
);
}
#[test]
fn caravan_master_against_broke_opponent_does_not_ransom() {
// caravan_master.json premium (3.5) × build_cost 160 → ransom_price 560.
// Opponent with 5g → accept_prob → ~0, ransom collapses. Capture or
// Destroy must win. Use a raider captor so Destroy outranks Capture.
let balance = CombatBalance::default();
let me = raider();
let opponent = raider();
let (posture, score) = score_capture_postures(
/* build_cost */ 160,
Some(3.5),
/* opponent_gold */ 5,
&opponent,
&me,
/* own_worker_count */ 4,
&balance,
);
assert!(score > 0.0);
assert_ne!(
posture,
PostureResolution::Ransom,
"broke opponent collapses ransom expected value even for a tier-3 trade GP, got {posture:?}"
);
}
#[test]
fn ascendant_engineer_high_multiplier_routes_to_ransom_over_destroy() {
// dwarf_ascendant_engineer: build_cost 330, ransom_multiplier 5.0. The

View file

@ -247,6 +247,106 @@ fn engineer_ransom_premium_exceeds_worker_for_same_build_cost() {
);
}
// ── p2-55b — Caravan Master / Merchant (trade Great-Person) capture math ───
//
// Merchant (`merchant.json`) is a Great Merchant — `great_person_class:
// great_merchant`. Caravan Master (`caravan_master.json`) is the tier-3
// trade specialist. Trade routes themselves are out-of-scope post-v10
// (g6-02), so in Game 1 the strategic premium lives entirely in
// `ransom_multiplier` (merchant 3.0, caravan_master 3.5). The resolver math
// is unit-type-agnostic — these tests pin that the JSON multipliers flow
// through cleanly, same as the engineer triad above.
/// Merchant combat stats — mirrors `merchant.json` (HP 30, no attack).
fn merchant() -> UnitStats {
UnitStats {
hp: 30,
max_hp: 30,
attack: 0,
defense: 2,
ranged_attack: 0,
range: 0,
movement: 4,
}
}
/// Caravan Master combat stats — mirrors `caravan_master.json` (HP 50,
/// modest defence). Higher HP still falls to a 100-attack warrior swing.
fn caravan_master() -> UnitStats {
UnitStats {
hp: 50,
max_hp: 50,
attack: 0,
defense: 4,
ranged_attack: 0,
range: 0,
movement: 4,
}
}
#[test]
fn merchant_ransom_price_uses_great_merchant_multiplier() {
// merchant.json: cost=80, ransom_multiplier=3.0 → ransom 240.
let params = CombatParams {
attacker: warrior(),
defender: merchant(),
combat_type: CombatType::Melee,
defender_capturable: true,
posture_resolution: PostureResolution::Ransom,
defender_build_cost: 80,
defender_ransom_multiplier: 3.0,
..CombatParams::default()
};
let result = CombatResolver::resolve(&params);
assert_eq!(result.defender_outcome, CombatOutcome::RansomOffered);
assert_eq!(
result.ransom_price, 240,
"merchant ransom price = build_cost (80) × great_merchant multiplier (3.0)"
);
}
#[test]
fn caravan_master_ransom_price_uses_tier3_multiplier() {
// caravan_master.json: cost=160, ransom_multiplier=3.5 → ransom 560.
let params = CombatParams {
attacker: warrior(),
defender: caravan_master(),
combat_type: CombatType::Melee,
defender_capturable: true,
posture_resolution: PostureResolution::Ransom,
defender_build_cost: 160,
defender_ransom_multiplier: 3.5,
..CombatParams::default()
};
let result = CombatResolver::resolve(&params);
assert_eq!(result.defender_outcome, CombatOutcome::RansomOffered);
assert_eq!(
result.ransom_price, 560,
"caravan_master ransom price = build_cost (160) × tier-3 multiplier (3.5)"
);
}
#[test]
fn caravan_master_capture_clamps_hp_and_suppresses_xp() {
// Capture posture against tier-3 trade specialist — owner-flip path, no
// ransom price, HP clamps to 1, no XP. Mirrors worker/engineer contracts.
let params = CombatParams {
attacker: warrior(),
defender: caravan_master(),
combat_type: CombatType::Melee,
defender_capturable: true,
posture_resolution: PostureResolution::Capture,
defender_build_cost: 160,
defender_ransom_multiplier: 3.5,
..CombatParams::default()
};
let result = CombatResolver::resolve(&params);
assert_eq!(result.defender_outcome, CombatOutcome::Captured);
assert_eq!(result.defender_hp, 1, "capture clamps caravan_master HP to 1");
assert_eq!(result.attacker_xp, 0, "no XP for capturing trade specialists");
assert_eq!(result.ransom_price, 0, "Capture posture suppresses ransom price");
}
#[test]
fn ascendant_engineer_capture_clamps_hp_and_suppresses_xp() {
// Top-tier engineer (cost 330) — verifies the capture path holds for the

View file

@ -1150,13 +1150,14 @@ fn project_tactical_player(
promotion_offense_weight: coerce_weight(player.promotion_offense_weight),
promotion_defense_weight: coerce_weight(player.promotion_defense_weight),
promotion_mobility_weight: coerce_weight(player.promotion_mobility_weight),
// p1-42 — `building_priors` is populated by the GDExtension bridge
// (`ai_turn_bridge_state.gd::build_building_catalog` and the planned
// `set_building_priors` call) and is not derivable from `PlayerState`
// alone. The projection seeds it with the neutral default; the
// tactical scorer falls through to axis-driven multipliers when the
// maps are empty (see `score_building`).
building_priors: mc_ai::tactical::state::BuildingPriors::default(),
// p1-42b — `building_priors` is now a first-class `PlayerState`
// field stamped by `GdGameState::set_player_personality_json` from
// the personality JSON envelope. We project it straight through into
// `TacticalPlayerState` so the catalog-driven scorer sees the
// per-clan `building_category_weights` + `wonder_priorities`. The
// default (empty maps) preserves cycle-5 fall-through to axis-driven
// multipliers for fixtures that don't stamp a personality.
building_priors: player.building_priors.clone(),
}
}

View file

@ -701,6 +701,20 @@ pub struct PlayerState {
/// p2-71 — Mobility / utility promotion weight.
#[serde(default = "default_promotion_weight")]
pub promotion_mobility_weight: f32,
/// p1-42b — Per-clan production-side priors loaded from
/// `ai_personalities.json` (`building_category_weights` +
/// `wonder_priorities`). Projected straight through
/// `mc_player_api::projection::project_tactical` into
/// `TacticalPlayerState::building_priors` so the catalog-driven scorer
/// (`mc_ai::tactical::production::score_building`) sees real
/// per-personality weights instead of the neutral default.
///
/// Stamped by `GdGameState::set_player_personality_json` from the
/// personality JSON envelope. Empty maps (default) preserve cycle-5
/// fall-through to axis-driven multipliers — fixtures predating p1-42b
/// keep their current behaviour without any changes.
#[serde(default)]
pub building_priors: mc_ai::tactical::state::BuildingPriors,
/// Accumulated expansion capacity (earned from the `expansion` axis).
pub expansion_points: u32,
/// Per-city list of constructed building IDs. Aligned with `cities`.