feat(@projects/@magic-civilization): ✨ add tactical building priority system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
212cd5a9ba
commit
58b76c5e89
3 changed files with 287 additions and 1 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
218
src/simulator/crates/mc-turn/tests/capture_caravan.rs
Normal file
218
src/simulator/crates/mc-turn/tests/capture_caravan.rs
Normal 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
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue