feat(@projects/@magic-civilization): add tactical building priority system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-14 23:05:32 -07:00
parent 212cd5a9ba
commit 58b76c5e89
3 changed files with 287 additions and 1 deletions

View file

@ -249,6 +249,21 @@
"destroy_civilian_aggression": 0.6,
"promotion_offense_weight": 1.0,
"promotion_defense_weight": 1.0,
"promotion_mobility_weight": 1.0
"promotion_mobility_weight": 1.0,
"building_category_weights": {
"production": 1.0,
"infrastructure": 1.1,
"military": 1.0,
"knowledge": 1.4,
"religious": 1.0,
"wonder": 1.1,
"naval": 0.9
},
"wonder_priorities": {
"the_great_forge": 1.2,
"ancestral_forge": 1.2,
"undermount_vault": 1.2,
"ancient_lighthouse": 1.1
}
}
}

View file

@ -1576,6 +1576,59 @@ mod tests {
assert_eq!(t.players[0].promotion_mobility_weight, 1.0);
}
#[test]
fn tactical_building_priors_round_trip_from_player_state() {
// p1-42b — `building_priors` is now a first-class PlayerState field
// populated by `GdGameState::set_player_personality_json`. The
// projection must read it straight through into TacticalPlayerState
// (rather than seeding `BuildingPriors::default()`) so the catalog-
// driven scorer in `mc_ai::tactical::production::score_building`
// sees per-personality `building_category_weights` and
// `wonder_priorities`.
let mut state = GameState::default();
let mut ps = PlayerState::default();
ps.player_index = 0;
ps.building_priors
.building_category_weights
.insert("production".into(), 1.6);
ps.building_priors
.building_category_weights
.insert("military".into(), 0.9);
ps.building_priors
.wonder_priorities
.insert("the_great_forge".into(), 2.0);
state.players.push(ps);
let t = project_tactical(&state, 0);
let p = &t.players[0];
assert!(
(p.building_priors.building_category_weights["production"] - 1.6).abs() < 1e-6,
"production category weight must round-trip through projection"
);
assert!(
(p.building_priors.building_category_weights["military"] - 0.9).abs() < 1e-6,
"military category weight must round-trip through projection"
);
assert!(
(p.building_priors.wonder_priorities["the_great_forge"] - 2.0).abs() < 1e-6,
"wonder priority must round-trip through projection"
);
}
#[test]
fn tactical_building_priors_default_for_unstamped_player() {
// Fixtures predating p1-42b leave `PlayerState::building_priors` at
// its serde-default (empty maps). The projection must surface that
// unchanged so the scorer's axis-driven fall-through still fires.
let state = make_state(1, 0, vec![]);
let t = project_tactical(&state, 0);
assert!(t.players[0]
.building_priors
.building_category_weights
.is_empty());
assert!(t.players[0].building_priors.wonder_priorities.is_empty());
}
#[test]
fn tactical_difficulty_mult_defaults_to_one_when_unset() {
// Same coercion logic as promotion weights — Default::default() gives

View file

@ -0,0 +1,218 @@
//! p2-55b — Caravan Master / Merchant capture mechanics (end-to-end PvP).
//!
//! Trade-route persistence (g6-02) is out-of-scope post-v10, so in Game 1
//! a captured caravan_master / merchant is structurally identical to any
//! other support civilian (worker, founder, engineer): owner flips, AP
//! resets, ransom price = build_cost × ransom_multiplier. The trade-GP
//! premium lives entirely in the JSON `ransom_multiplier` (merchant 3.0,
//! caravan_master 3.5).
//!
//! Pins three properties end-to-end through the TurnProcessor:
//!
//! 1. A captured caravan_master flips ownership like any civilian and emits
//! exactly one `TurnEvent::UnitCaptured`.
//! 2. The captor does NOT inherit a partially-charged action — AP resets
//! to zero on transfer (universal Specialist policy).
//! 3. A ransomed merchant carries the tier-1 trade-GP premium price
//! (build_cost 80 × ransom_multiplier 3.0 = 240), strictly above the
//! worker baseline at equal build cost.
use mc_core::units::ActionPoints;
use mc_replay::TurnEvent;
use mc_turn::{
capture::CapturePosture, AttackRequest, GameState, MapUnit, PlayerState, TurnProcessor,
};
use mc_units::{UnitStats as CatalogUnitStats, UnitsCatalog};
const CARAVAN_ID: u32 = 900;
const MERCHANT_ID: u32 = 901;
fn build_trade_catalog() -> UnitsCatalog {
let mut cat = UnitsCatalog::new();
cat.insert(CatalogUnitStats {
id: "dwarf_warrior".into(),
base_moves: 2,
domain: "land".into(),
action_point_capacity: None,
capturable: false,
ransom_multiplier: 2.0,
build_cost: 0,
});
// merchant.json: tier-1 trade GP — premium ransom multiplier, modest cost.
cat.insert(CatalogUnitStats {
id: "merchant".into(),
base_moves: 4,
domain: "land".into(),
action_point_capacity: Some(6),
capturable: true,
ransom_multiplier: 3.0,
build_cost: 80,
});
// caravan_master.json: tier-3 — higher cost AND higher multiplier.
cat.insert(CatalogUnitStats {
id: "caravan_master".into(),
base_moves: 4,
domain: "land".into(),
action_point_capacity: Some(6),
capturable: true,
ransom_multiplier: 3.5,
build_cost: 160,
});
cat
}
fn fixture(posture: CapturePosture, defender_unit_id: &str, defender_id: u32, ap_current: u8) -> GameState {
let mut state = GameState {
turn: 0,
players: vec![
PlayerState {
player_index: 0,
default_civilian_posture: posture,
..Default::default()
},
PlayerState {
player_index: 1,
..Default::default()
},
],
grid: None,
..Default::default()
};
state.units_catalog = build_trade_catalog();
state.players[0].units.push(MapUnit {
id: 311,
col: 10,
row: 10,
hp: 60,
max_hp: 60,
attack: 50,
defense: 10,
unit_id: "dwarf_warrior".into(),
..Default::default()
});
state.players[1].units.push(MapUnit {
id: defender_id,
col: 10,
row: 10,
hp: 1,
max_hp: 50,
attack: 0,
defense: 0,
unit_id: defender_unit_id.into(),
action_points: Some(ActionPoints {
current: ap_current,
capacity: 6,
}),
..Default::default()
});
state.pending_pvp_attacks.push(AttackRequest {
attacker_player: 0,
attacker_unit: 0,
defender_player: 1,
defender_unit: 0,
});
state
}
#[test]
fn captured_caravan_master_flips_owner_and_emits_unit_captured_event() {
let mut state = fixture(CapturePosture::Capture, "caravan_master", CARAVAN_ID, 5);
let processor = TurnProcessor::new(500);
let result = processor.step(&mut state);
assert_eq!(
state.players[1]
.units
.iter()
.filter(|u| u.id == CARAVAN_ID)
.count(),
0,
"captured caravan_master must leave prior owner's units vec"
);
let moved = state.players[0]
.units
.iter()
.find(|u| u.id == CARAVAN_ID)
.expect("caravan_master must be re-owned by captor");
assert_eq!(moved.unit_id, "caravan_master");
let captured_count = result
.events_emitted
.iter()
.filter(
|e| matches!(e, TurnEvent::UnitCaptured { unit_id, .. } if *unit_id == CARAVAN_ID),
)
.count();
assert_eq!(
captured_count, 1,
"exactly one TurnEvent::UnitCaptured for the caravan_master; got {:?}",
result.events_emitted
);
}
#[test]
fn captured_caravan_master_resets_action_points_to_zero() {
// Caravan Master carried 5/6 AP at capture time. Under the new owner,
// AP must be 0/6 — universal Specialist AP-reset policy applies to
// trade specialists the same as it does to engineers.
let mut state = fixture(CapturePosture::Capture, "caravan_master", CARAVAN_ID, 5);
let processor = TurnProcessor::new(500);
let _ = processor.step(&mut state);
let moved = state.players[0]
.units
.iter()
.find(|u| u.id == CARAVAN_ID)
.expect("captured caravan_master must land in captor's units vec");
let ap = moved
.action_points
.expect("caravan_master keeps its AP pool after capture (capacity intact)");
assert_eq!(
ap.current, 0,
"p2-55b: captured caravan_master's AP.current must reset to 0; got {ap:?}"
);
assert_eq!(
ap.capacity, 6,
"AP capacity is per-unit-type and survives capture"
);
}
#[test]
fn merchant_ransom_offer_uses_great_merchant_multiplier() {
// merchant.json: build_cost 80, ransom_multiplier 3.0 → price 240.
// Worker baseline at the same build_cost would be 80 × 2.0 = 160 — the
// great_merchant premium must surface as a strictly higher offer.
let mut state = fixture(CapturePosture::Ransom, "merchant", MERCHANT_ID, 3);
let processor = TurnProcessor::new(500);
let result = processor.step(&mut state);
let captive = state.players[1]
.units
.iter()
.find(|u| u.id == MERCHANT_ID)
.expect("ransomed merchant stays in prior-owner vec until accept/refuse/expire");
assert_eq!(
captive.captive_of,
Some(0),
"captive_of must point at captor while offer is open"
);
let offer = result
.ransom_offers_created
.iter()
.find(|o| o.unit_id == MERCHANT_ID)
.expect("UnitRansomOfferedEvent on TurnResult.ransom_offers_created");
assert_eq!(
offer.price, 240,
"merchant ransom price = build_cost (80) × great_merchant multiplier (3.0); got {}",
offer.price
);
assert!(
offer.price > 160,
"merchant ransom (240) must exceed worker baseline at equal build_cost (160); got {}",
offer.price
);
}