From 212cd5a9baf2872ad0b20e8ed3630c9b2d0c0993 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 14 May 2026 23:03:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20building=20category=20and=20wonder=20prioriti?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../age-of-dwarves/data/ai_personalities.json | 62 ++++++++++- public/resources/units/caravan_master.json | 2 + public/resources/units/merchant.json | 2 + src/simulator/api-gdext/src/lib.rs | 41 +++++++ .../crates/mc-ai/tests/capture_scoring.rs | 105 ++++++++++++++++++ .../crates/mc-combat/tests/capture.rs | 100 +++++++++++++++++ .../crates/mc-player-api/src/projection.rs | 15 +-- .../crates/mc-turn/src/game_state.rs | 14 +++ 8 files changed, 330 insertions(+), 11 deletions(-) diff --git a/public/games/age-of-dwarves/data/ai_personalities.json b/public/games/age-of-dwarves/data/ai_personalities.json index 87d174c4..13154501 100644 --- a/public/games/age-of-dwarves/data/ai_personalities.json +++ b/public/games/age-of-dwarves/data/ai_personalities.json @@ -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", diff --git a/public/resources/units/caravan_master.json b/public/resources/units/caravan_master.json index 78b5d841..d230e1f6 100644 --- a/public/resources/units/caravan_master.json +++ b/public/resources/units/caravan_master.json @@ -33,6 +33,8 @@ "gender": null, "maintenance": 3, "auto_join": false, + "capturable": true, + "ransom_multiplier": 3.5, "encyclopedia": { "category": "civilization", "entry_type": "unit", diff --git a/public/resources/units/merchant.json b/public/resources/units/merchant.json index 8ae94f66..4cb7d9c2 100644 --- a/public/resources/units/merchant.json +++ b/public/resources/units/merchant.json @@ -32,6 +32,8 @@ "gender": null, "maintenance": 2, "auto_join": false, + "capturable": true, + "ransom_multiplier": 3.0, "encyclopedia": { "category": "civilization", "entry_type": "unit", diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index dcb04938..452d39e2 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -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 diff --git a/src/simulator/crates/mc-ai/tests/capture_scoring.rs b/src/simulator/crates/mc-ai/tests/capture_scoring.rs index 872c58e9..5aabf116 100644 --- a/src/simulator/crates/mc-ai/tests/capture_scoring.rs +++ b/src/simulator/crates/mc-ai/tests/capture_scoring.rs @@ -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 diff --git a/src/simulator/crates/mc-combat/tests/capture.rs b/src/simulator/crates/mc-combat/tests/capture.rs index 4cd7bba7..849b48a7 100644 --- a/src/simulator/crates/mc-combat/tests/capture.rs +++ b/src/simulator/crates/mc-combat/tests/capture.rs @@ -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(¶ms); + 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(¶ms); + 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(¶ms); + 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 diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index f7e09a2d..24a134f6 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -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(), } } diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 71fbeab3..4862dc44 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -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`.