feat(@projects/@magic-civilization): ✨ add building category and wonder priorities
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
be04027e9a
commit
212cd5a9ba
8 changed files with 330 additions and 11 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@
|
|||
"gender": null,
|
||||
"maintenance": 3,
|
||||
"auto_join": false,
|
||||
"capturable": true,
|
||||
"ransom_multiplier": 3.5,
|
||||
"encyclopedia": {
|
||||
"category": "civilization",
|
||||
"entry_type": "unit",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@
|
|||
"gender": null,
|
||||
"maintenance": 2,
|
||||
"auto_join": false,
|
||||
"capturable": true,
|
||||
"ransom_multiplier": 3.0,
|
||||
"encyclopedia": {
|
||||
"category": "civilization",
|
||||
"entry_type": "unit",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue