diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index cf02915aff..5e3e3c0e95 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -9956,6 +9956,24 @@ fn handle_equip_activation( /// CR 702.122a: Activate a Vehicle's crew ability from Priority. /// Unlike Equip (CR 702.6a) and Saddle (CR 702.171a), Crew has NO "Activate only as a /// sorcery" restriction — it can be activated any time the controller has priority. +/// CR 702.122a + CR 702.122d: can this creature legally be tapped to pay a crew +/// cost right now? +/// +/// Composes the two halves the crew payment path enforces — the tappability rule +/// in [`is_tappable_creature_for_cost`] (controlled, untapped, a creature, and +/// not under a `CantTap` restriction) and the `CantCrew` static — into one +/// named authority. +/// +/// `pub` so consumers outside this module (`phase-ai`'s +/// `VehicleDeploymentPolicy`) ask THIS question instead of assembling their own +/// filter from the parts. A partial duplicate silently over-counts: omitting the +/// `object_cant_tap` term alone makes a `CantTap` 3/3 look like it can pay +/// Crew 3, which it cannot. +pub fn creature_can_pay_crew(state: &GameState, id: ObjectId, player: PlayerId) -> bool { + is_tappable_creature_for_cost(state, id, player) + && !super::static_abilities::object_has_cant_crew(state, id) +} + fn is_tappable_creature_for_cost(state: &GameState, id: ObjectId, player: PlayerId) -> bool { state.objects.get(&id).is_some_and(|o| { o.controller == player diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index dc30557a9f..bf859827cf 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -533,6 +533,10 @@ pub struct PolicyPenalties { /// draw" engine (preference band, per engine). #[serde(default = "default_draw_payoff_bonus")] pub draw_payoff_bonus: f64, + /// CR 702.122a: card-equivalent value of casting a Vehicle the board can + /// already crew, scaled by surplus crew power. + #[serde(default = "default_vehicle_deployment_bonus")] + pub vehicle_deployment_bonus: f64, /// CR 601.2f: card-equivalent value of ONE generic mana saved by deploying a /// cost reducer, multiplied by the capped saved-mana total. #[serde(default = "default_cost_reduction_deploy_bonus")] @@ -620,6 +624,7 @@ impl Default for PolicyPenalties { devotion_pip_progress: default_devotion_pip_progress(), devotion_god_activation: default_devotion_god_activation(), draw_payoff_bonus: default_draw_payoff_bonus(), + vehicle_deployment_bonus: default_vehicle_deployment_bonus(), cost_reduction_deploy_bonus: default_cost_reduction_deploy_bonus(), cost_reduction_defer_penalty: default_cost_reduction_defer_penalty(), discard_payoff_bonus: default_discard_payoff_bonus(), @@ -751,6 +756,9 @@ fn default_devotion_god_activation() -> f64 { fn default_draw_payoff_bonus() -> f64 { 0.6 } +fn default_vehicle_deployment_bonus() -> f64 { + 0.5 +} fn default_cost_reduction_deploy_bonus() -> f64 { 0.2 } @@ -908,6 +916,11 @@ pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ "draw_payoff_bonus", "CR 121.1 per-engine draw-payoff weight — awaiting a paired-seed ai-gate calibration.", ), + ( + "vehicle_deployment_bonus", + "CR 702.122a crewable-Vehicle deployment weight — awaiting a paired-seed \ + ai-gate calibration.", + ), ( "cost_reduction_deploy_bonus", "CR 601.2f per-saved-mana deployment weight — awaiting a paired-seed ai-gate calibration.", diff --git a/crates/phase-ai/src/features/mod.rs b/crates/phase-ai/src/features/mod.rs index 278aa182de..da42c27b74 100644 --- a/crates/phase-ai/src/features/mod.rs +++ b/crates/phase-ai/src/features/mod.rs @@ -30,6 +30,7 @@ pub mod reanimator; pub mod spellslinger_prowess; pub mod tokens_wide; pub mod tribal; +pub mod vehicles; #[cfg(test)] pub mod tests; @@ -57,6 +58,7 @@ pub use reanimator::ReanimatorFeature; pub use spellslinger_prowess::SpellslingerProwessFeature; pub use tokens_wide::TokensWideFeature; pub use tribal::TribalFeature; +pub use vehicles::VehiclesFeature; use engine::game::bracket_estimate::CommanderBracketTier; @@ -103,6 +105,9 @@ pub struct DeckFeatures { /// CR 701.9: "whenever you discard" payoff density (self-discard outlets + /// engines). Disjoint from `hand_disruption`, which scores OPPONENT discard. pub discard_matters: DiscardMattersFeature, + /// CR 702.122: crewed-Vehicle density paired with the creature bench needed + /// to tap for it. Gives `CrewTimingPolicy` the deck signal it lacked. + pub vehicles: VehiclesFeature, /// Declaration-derived: the deck's declared bracket tier. Unlike the /// other fields here, this is not structurally detected from card text — /// it is a per-deck declaration set at deck-analysis time from deck @@ -153,6 +158,7 @@ impl DeckFeatures { graveyard_types: graveyard_types::detect(deck), draw_matters: draw_matters::detect(deck), discard_matters: discard_matters::detect(deck), + vehicles: vehicles::detect(deck), bracket_tier: tier, } } diff --git a/crates/phase-ai/src/features/reanimator.rs b/crates/phase-ai/src/features/reanimator.rs index 41ae1fe9bd..ce64bb6a4c 100644 --- a/crates/phase-ai/src/features/reanimator.rs +++ b/crates/phase-ai/src/features/reanimator.rs @@ -69,7 +69,10 @@ const ENABLER_BONUS_CAP: f32 = 0.20; /// CR 205.3 / CR 301.7: the artifact subtype a reanimation target/payoff can be /// besides a creature. Compared case-insensitively against `TypeFilter::Subtype` /// and `CardType.subtypes`. -const VEHICLE_SUBTYPE: &str = "Vehicle"; +/// `pub(crate)` so `features::vehicles` matches on the SAME string rather than +/// redefining it — two features disagreeing about what a Vehicle is would be a +/// silent classification split. +pub(crate) const VEHICLE_SUBTYPE: &str = "Vehicle"; /// Per-deck reanimator classification. /// diff --git a/crates/phase-ai/src/features/tests/mod.rs b/crates/phase-ai/src/features/tests/mod.rs index 3ab65d79cf..e81f4b2b85 100644 --- a/crates/phase-ai/src/features/tests/mod.rs +++ b/crates/phase-ai/src/features/tests/mod.rs @@ -16,3 +16,4 @@ pub mod mill; pub mod no_name_matching; pub mod poison; pub mod reanimator; +pub mod vehicles; diff --git a/crates/phase-ai/src/features/tests/vehicles.rs b/crates/phase-ai/src/features/tests/vehicles.rs new file mode 100644 index 0000000000..7411ba5d9c --- /dev/null +++ b/crates/phase-ai/src/features/tests/vehicles.rs @@ -0,0 +1,275 @@ +//! Unit tests for `features::vehicles` — CR 702.122 crewed-Vehicle detection. +//! No `#[cfg(test)]` in SOURCE files; tests live here. + +use engine::game::DeckEntry; +use engine::types::ability::PtValue; +use engine::types::card::CardFace; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::keywords::Keyword; + +use crate::features::vehicles::*; + +fn face(name: &str, core: CoreType) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![core], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn entry(card: CardFace, count: u32) -> DeckEntry { + DeckEntry { card, count } +} + +/// A Vehicle carrying `Keyword::Crew { power }`. +fn vehicle(name: &str, crew: u32) -> CardFace { + let mut f = face(name, CoreType::Artifact); + f.card_type.subtypes.push("Vehicle".to_string()); + f.keywords.push(Keyword::Crew { + power: crew, + once_per_turn: None, + }); + f +} + +/// A creature that can be tapped to crew. +fn body(name: &str, power: i32) -> CardFace { + let mut f = face(name, CoreType::Creature); + f.power = Some(PtValue::Fixed(power)); + f.toughness = Some(PtValue::Fixed(power.max(1))); + f +} + +/// `vehicles` Vehicles + `bodies` crew-capable creatures, padded to 36 nonland. +fn deck(vehicles: u32, bodies: u32) -> Vec { + let filler = 36u32.saturating_sub(vehicles + bodies); + vec![ + entry(vehicle("Copter", 1), vehicles), + entry(body("Bear", 2), bodies), + entry(face("Filler", CoreType::Enchantment), filler), + ] +} + +#[test] +fn empty_deck_produces_defaults() { + let f = detect(&[]); + assert_eq!(f.vehicle_count, 0); + assert_eq!(f.crew_body_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn detects_vehicles_and_crew_cost() { + let f = detect(&[ + entry(vehicle("Skysovereign", 3), 5), + entry(body("Bear", 2), 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.vehicle_count, 5); + assert_eq!(f.total_crew_cost, 15, "Crew 3 x 5 copies"); +} + +#[test] +fn detects_crew_bodies_and_power() { + let f = detect(&deck(5, 10)); + assert_eq!(f.crew_body_count, 10); + assert_eq!(f.total_crew_power, 20, "power 2 x 10 bodies"); +} + +#[test] +fn a_vehicle_is_not_its_own_crew_body() { + // CR 702.122a: crew taps OTHER creatures. A Vehicle that is also printed as + // a creature card must not count toward its own bench. + let mut creature_vehicle = vehicle("Creature Vehicle", 2); + creature_vehicle + .card_type + .core_types + .push(CoreType::Creature); + creature_vehicle.power = Some(PtValue::Fixed(4)); + let f = detect(&[ + entry(creature_vehicle, 8), + entry(face("Filler", CoreType::Enchantment), 28), + ]); + assert_eq!(f.vehicle_count, 8); + assert_eq!(f.crew_body_count, 0, "a Vehicle can never crew itself"); + assert_eq!(f.commitment, 0.0, "no bench ⇒ the axis collapses"); +} + +#[test] +fn zero_power_creature_is_not_a_crew_body() { + // Tapping a 0-power creature contributes nothing toward `N`. + let f = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(body("Wall", 0), 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.crew_body_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn variable_power_creature_is_not_counted() { + // A `*` power has no deck-time value; the axis under-counts rather than + // inventing a bench. + let mut star = face("Tarmogoyf", CoreType::Creature); + star.power = Some(PtValue::Variable("*".to_string())); + let f = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(star, 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.crew_body_count, 0); +} + +#[test] +fn subtype_only_vehicle_has_no_crew_requirement() { + // CR 702.122a: Crew is an activated ability; the subtype does not grant it. + // Archetype membership is the broader question and stays true — see the + // sibling test below — but the live authority must report `None`. + let mut subtype_only = face("Odd Vehicle", CoreType::Artifact); + subtype_only.card_type.subtypes.push("Vehicle".to_string()); + assert_eq!(crew_requirement(&subtype_only), None); + assert!(vehicle_archetype_member(&subtype_only)); + + let real = vehicle("Copter", 2); + assert_eq!(crew_requirement(&real), Some(2)); + assert!(vehicle_archetype_member(&real)); +} + +#[test] +fn vehicle_subtype_without_the_keyword_still_registers() { + // A Vehicle whose crew keyword the parser has not attached is still part of + // the archetype — it just contributes no crew cost. + let mut subtype_only = face("Odd Vehicle", CoreType::Artifact); + subtype_only.card_type.subtypes.push("Vehicle".to_string()); + let f = detect(&[ + entry(subtype_only, 5), + entry(body("Bear", 2), 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.vehicle_count, 5); + assert_eq!(f.total_crew_cost, 0); +} + +#[test] +fn noncreature_is_not_a_crew_body() { + let f = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(face("Signet", CoreType::Artifact), 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.crew_body_count, 0); +} + +#[test] +fn vehicles_without_a_bench_collapse_commitment() { + let f = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(face("Filler", CoreType::Enchantment), 31), + ]); + assert_eq!(f.vehicle_count, 5); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn creatures_without_vehicles_collapse_commitment() { + let f = detect(&[ + entry(body("Bear", 2), 20), + entry(face("Filler", CoreType::Enchantment), 16), + ]); + assert_eq!(f.vehicle_count, 0); + assert_eq!(f.commitment, 0.0); +} + +// ─── calibration anchors: computed values, pinned ──────────────────────────── + +#[test] +fn dedicated_shell_saturates() { + let f = detect(&deck(8, 16)); + assert!( + (f.commitment - 1.000).abs() < 0.01, + "expected 1.000, got {}", + f.commitment + ); +} + +#[test] +fn realistic_build_hits_its_anchor() { + let f = detect(&deck(5, 10)); + assert!( + (f.commitment - 0.833).abs() < 0.01, + "expected ≈0.833, got {}", + f.commitment + ); + assert!(f.commitment >= VEHICLES_FLOOR); +} + +#[test] +fn light_build_hits_its_anchor() { + let f = detect(&deck(3, 6)); + assert!( + (f.commitment - 0.645).abs() < 0.01, + "expected ≈0.645, got {}", + f.commitment + ); + assert!(f.commitment >= VEHICLES_FLOOR); +} + +#[test] +fn two_vehicle_splash_still_clears_the_floor() { + let f = detect(&deck(2, 4)); + assert!( + (f.commitment - 0.527).abs() < 0.01, + "expected ≈0.527, got {}", + f.commitment + ); + assert!(f.commitment >= VEHICLES_FLOOR); +} + +#[test] +fn one_incidental_copter_stays_below_the_floor() { + // The case that drove the pillar scaling: a saturated bench must NOT + // compensate for near-zero Vehicle density. + let f = detect(&deck(1, 20)); + assert!( + (f.commitment - 0.373).abs() < 0.01, + "expected ≈0.373, got {}", + f.commitment + ); + assert!( + f.commitment < VEHICLES_FLOOR, + "one Copter in a creature deck must not switch the axis on, got {}", + f.commitment + ); +} + +#[test] +fn commitment_is_format_size_neutral() { + let sixty = detect(&deck(5, 10)); + let commander = detect(&[ + entry(vehicle("Copter", 1), 9), + entry(body("Bear", 2), 18), + entry(face("Filler", CoreType::Enchantment), 36), + ]); + assert!( + (sixty.commitment - commander.commitment).abs() < 0.05, + "{} vs {}", + sixty.commitment, + commander.commitment + ); +} + +#[test] +fn lands_are_excluded_from_the_denominator() { + let with_lands = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(body("Bear", 2), 10), + entry(face("Filler", CoreType::Enchantment), 21), + entry(face("Plains", CoreType::Land), 24), + ]); + assert_eq!(with_lands.commitment, detect(&deck(5, 10)).commitment); +} diff --git a/crates/phase-ai/src/features/vehicles.rs b/crates/phase-ai/src/features/vehicles.rs new file mode 100644 index 0000000000..4680a68883 --- /dev/null +++ b/crates/phase-ai/src/features/vehicles.rs @@ -0,0 +1,252 @@ +//! Vehicles feature — structural detection of a deck that wins through crewed +//! Vehicles (CR 702.122). +//! +//! Parser AST verification — VERIFIED against engine source: +//! - `Keyword::Crew { power, once_per_turn }` at +//! `crates/engine/src/types/keywords.rs:707` — the Vehicle payoff and, in +//! `power`, the exact total power CR 702.122a requires to crew it. +//! - `CardFace.keywords: Vec` at `crates/engine/src/types/card.rs:60`. +//! - `CardFace.power: Option` at `card.rs:171`; `PtValue::Fixed(i32)` +//! is the only deck-time-knowable form — a `*` power is `PtValue::Variable` +//! and is excluded rather than guessed. +//! - Vehicle subtype via the shared `reanimator::VEHICLE_SUBTYPE` constant, +//! promoted rather than redefined so the two features cannot disagree about +//! what a Vehicle is. The subtype widens ARCHETYPE membership only — CR 702.122a +//! makes Crew an activated ability, so it never stands in for a crew +//! requirement at the live seam. +//! +//! No parser remediation required — every axis is expressible over existing +//! typed AST. +//! +//! ## Why this axis exists +//! +//! CR 702.122a: "Crew N" means *tap any number of OTHER untapped creatures you +//! control with total power N or greater*. A Vehicle is therefore not a body the +//! deck pays mana for — it is a body the deck pays *board presence* for, and it +//! does nothing at all without creatures to tap. +//! +//! `CrewTimingPolicy` already decides whether a specific crew activation is +//! worth it, but it runs with `activation-constant Some(1.0)` because there is +//! no vehicles deck-feature for it to scale by. So it applies the same weight in +//! a dedicated Vehicles shell as in a deck with one incidental Smuggler's +//! Copter, and nothing anywhere values *casting* a Vehicle against whether the +//! board can actually crew it. +//! +//! ## Boundary with `equipment` +//! +//! Both axes attach value to a noncreature permanent that needs creatures. +//! Equipment moves stats onto an existing body (CR 702.6); a Vehicle BECOMES the +//! body and taps the creatures instead (CR 702.122a). The costs run opposite +//! ways — Equipment wants creatures on the battlefield afterwards, crewing takes +//! them out of combat — so the two never read the same card and a deck scoring +//! high on both is running two distinct plans. +//! +//! ## Boundary with `artifacts` +//! +//! `artifacts` counts artifact density and artifact-matters payoffs. Vehicles are +//! artifacts, so a Vehicles deck reads on that axis too — intentionally. This +//! axis is the narrower question of whether those artifacts are crewable bodies +//! with the creatures to crew them, which artifact density cannot answer. + +use engine::game::DeckEntry; +use engine::types::ability::PtValue; +use engine::types::card::CardFace; +use engine::types::card_type::CoreType; +use engine::types::keywords::Keyword; + +use crate::features::commitment; +use crate::features::reanimator::VEHICLE_SUBTYPE; + +/// Commitment at or above which Vehicles are a real plan rather than one +/// incidental Copter. Gates the vehicles-aware policies' `activation`. +pub const VEHICLES_FLOOR: f32 = 0.40; + +/// Vehicle density (per 60 nonland) at which the payoff pillar saturates. +const VEHICLE_SATURATION_PER_60: f32 = 12.0; + +/// CR 702.122a: crew taps *any number* of other creatures, so a Vehicle deck +/// needs a bench, not one big body. Two crew-capable creatures per Vehicle is +/// treated as full support. +const BODIES_PER_VEHICLE: f32 = 2.0; + +/// CR 702.122: per-deck Vehicles classification. +/// +/// Populated once per game from `DeckEntry` data. Detection is structural over +/// `CardFace.keywords` and the printed type line — never by card name. +#[derive(Debug, Clone, Default)] +pub struct VehiclesFeature { + /// Cards carrying `Keyword::Crew` — the Vehicles themselves. + pub vehicle_count: u32, + /// Summed `Crew N` requirement across those Vehicles. Consumed by policies + /// that need to know how much board presence the plan costs to switch on. + pub total_crew_cost: u32, + /// Creatures whose printed power can contribute to paying a crew cost + /// (CR 702.122a) — power must be a known, positive, fixed value. + pub crew_body_count: u32, + /// Summed printed power of those bodies. + pub total_crew_power: u32, + /// `0.0..=1.0` — how central crewed Vehicles are to this deck. + pub commitment: f32, +} + +/// Structural detection over each `DeckEntry`'s `CardFace` AST. +pub fn detect(deck: &[DeckEntry]) -> VehiclesFeature { + if deck.is_empty() { + return VehiclesFeature::default(); + } + + let mut vehicle_count = 0u32; + let mut total_crew_cost = 0u32; + let mut crew_body_count = 0u32; + let mut total_crew_power = 0u32; + let mut total_nonland = 0u32; + + for entry in deck { + let face = &entry.card; + if !face.card_type.core_types.contains(&CoreType::Land) { + total_nonland = total_nonland.saturating_add(entry.count); + } + + if vehicle_archetype_member(face) { + vehicle_count = vehicle_count.saturating_add(entry.count); + // Only a real `Keyword::Crew` contributes a cost — a subtype-only + // Vehicle joins the archetype but adds nothing to pay. + if let Some(crew) = crew_requirement(face) { + total_crew_cost = total_crew_cost.saturating_add(crew.saturating_mul(entry.count)); + } + } + + // CR 702.122a: a Vehicle taps OTHER creatures, so it can never crew + // itself — an uncrewed Vehicle is not a creature and contributes no + // power. Counting it as its own bench would let a pile of Vehicles look + // self-sufficient. + if let Some(power) = crew_capable_power(face) { + crew_body_count = crew_body_count.saturating_add(entry.count); + total_crew_power = total_crew_power.saturating_add(power.saturating_mul(entry.count)); + } + } + + let commitment = compute_commitment(vehicle_count, crew_body_count, total_nonland); + + VehiclesFeature { + vehicle_count, + total_crew_cost, + crew_body_count, + total_crew_power, + commitment, + } +} + +/// CR 702.122a: the total power required to crew this face, or `None` when it is +/// not a Vehicle. +/// +/// Keyed on `Keyword::Crew` rather than the Vehicle subtype: the keyword is what +/// actually grants the crew ability, and a card can carry it without the printed +/// subtype (or carry the subtype with its crew ability granted elsewhere). The +/// subtype is still accepted as a fallback so a Vehicle whose crew keyword the +/// parser has not attached is not silently dropped from the archetype — it just +/// contributes no crew cost. +pub(crate) fn crew_requirement(face: &CardFace) -> Option { + crew_requirement_parts(face.keywords.iter()) +} + +/// CR 702.122a: the total power required to crew, or `None` when this object has +/// no crew ability at all. +/// +/// Keyed STRICTLY on `Keyword::Crew`. CR 702.122a opens *"Crew is an activated +/// ability of Vehicle cards"* — the printed subtype does not grant it. A +/// subtype-only Vehicle therefore has no crew requirement to satisfy, and must +/// not be reported as one: returning `Some(0)` here made `available >= required` +/// true for an empty board (`0 >= 0`) and scored a live crew bonus for a +/// permanent that can never be crewed. +/// +/// Deck classification asks a different question and uses +/// [`vehicle_archetype_member`] instead — see that function for why the subtype +/// fallback is honest THERE and wrong here. +/// +/// Parts-based so it classifies a deck-time `CardFace.keywords` slice and a live +/// `GameObject.keywords` slice through one predicate. +pub(crate) fn crew_requirement_parts<'a>( + keywords: impl IntoIterator, +) -> Option { + keywords.into_iter().find_map(|keyword| match keyword { + Keyword::Crew { power, .. } => Some(*power), + _ => None, + }) +} + +/// Is this face part of the Vehicles ARCHETYPE? +/// +/// Deliberately broader than [`crew_requirement`]: a Vehicle whose crew keyword +/// the parser has not attached is still a Vehicle the deck is built around, and +/// dropping it would understate the archetype. That fallback is safe for deck +/// classification — it only decides how strongly the axis activates — and is NOT +/// safe at the live seam, where it would invent a crew cost of zero for a +/// permanent with no crew ability. +pub(crate) fn vehicle_archetype_member(face: &CardFace) -> bool { + crew_requirement(face).is_some() || face_has_vehicle_subtype(face) +} + +/// CR 205.3: the printed type line carries the Vehicle subtype. +pub(crate) fn face_has_vehicle_subtype(face: &CardFace) -> bool { + face.card_type + .subtypes + .iter() + .any(|subtype| subtype.eq_ignore_ascii_case(VEHICLE_SUBTYPE)) +} + +/// CR 702.122a: the printed power this face can contribute toward a crew cost, +/// or `None` when it cannot contribute. +/// +/// Requires a creature with a KNOWN, positive, fixed power: +/// * A Vehicle is excluded even when it is also a creature card, because crew +/// taps *other* creatures. +/// * `PtValue::Variable` ("*", "1+*") has no deck-time value, so it is excluded +/// rather than guessed — the axis under-counts instead of inventing a bench. +/// * Power 0 taps for nothing and never helps reach `N`. +pub(crate) fn crew_capable_power(face: &CardFace) -> Option { + if !face.card_type.core_types.contains(&CoreType::Creature) { + return None; + } + if face_has_vehicle_subtype(face) { + return None; + } + match face.power { + Some(PtValue::Fixed(power)) if power > 0 => u32::try_from(power).ok(), + _ => None, + } +} + +/// Calibration — every figure below is the value `compute_commitment` actually +/// returns, verified before being written down: +/// - Dedicated Vehicles shell — 8 Vehicles + 16 crew-capable creatures over 36 +/// nonland → **1.000**. +/// - Realistic build — 5 Vehicles + 10 bodies → **0.833**. +/// - Light build — 3 Vehicles + 6 bodies → **0.645**. +/// - Two-Vehicle splash — 2 Vehicles + 4 bodies → **0.527**, still a plan. +/// +/// Anti-calibration: +/// - One incidental Smuggler's Copter in a 20-creature deck → **0.373**, below +/// `VEHICLES_FLOOR`. This case drove the pillar scaling: a saturated bench must +/// NOT compensate for near-zero Vehicle density, or a single Copter would +/// switch the whole axis on. +/// - Vehicles with no bench (5 Vehicles + 0 creatures) → 0.0. +/// - Creatures with no Vehicles → 0.0. +/// +/// Geometric mean over (vehicle density, crew support): BOTH pillars are +/// mandatory. Vehicles with nothing to tap are artifacts that never become +/// creatures; creatures with no Vehicles are just a creature deck. +fn compute_commitment(vehicle_count: u32, crew_body_count: u32, total_nonland: u32) -> f32 { + let vehicle_density = (commitment::density_per_60(vehicle_count, total_nonland) + / VEHICLE_SATURATION_PER_60) + .min(1.0); + // Support is measured against THIS deck's own Vehicle count rather than a + // fixed density: a deck needs a bench proportional to the Vehicles it runs, + // and that ratio is already deck-size neutral. + let crew_support = if vehicle_count == 0 { + 0.0 + } else { + (crew_body_count as f32 / (vehicle_count as f32 * BODIES_PER_VEHICLE)).min(1.0) + }; + commitment::geometric_mean(&[vehicle_density, crew_support]) +} diff --git a/crates/phase-ai/src/policies/crew_timing.rs b/crates/phase-ai/src/policies/crew_timing.rs index 427e0da404..30e27ce19b 100644 --- a/crates/phase-ai/src/policies/crew_timing.rs +++ b/crates/phase-ai/src/policies/crew_timing.rs @@ -14,6 +14,14 @@ use super::context::PolicyContext; use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; use crate::features::DeckFeatures; +/// Floor under the vehicles-commitment scale. A crew activation the AI is +/// already looking at is worth judging even in a deck the axis rates low — and +/// even at commitment 0.0, because the deck-time bench detection is deliberately +/// conservative and cannot see tokens or variable-power creatures. The weight is +/// damped rather than zeroed, unlike the payoff policies which opt out entirely +/// below their floor. +const MIN_CREW_ACTIVATION: f32 = 0.25; + pub struct CrewTimingPolicy; impl TacticalPolicy for CrewTimingPolicy { @@ -27,11 +35,30 @@ impl TacticalPolicy for CrewTimingPolicy { fn activation( &self, - _features: &DeckFeatures, + features: &DeckFeatures, _state: &GameState, _player: PlayerId, ) -> Option { - Some(1.0) // activation-constant: exact initial-Crew action self-gates in verdict. + // CR 702.122: previously `activation-constant Some(1.0)` — the exact + // initial-Crew action self-gates in `verdict`, so a constant was safe, + // but it applied identical weight in a dedicated Vehicles shell and in a + // deck running one incidental Copter. `features::vehicles` now supplies + // that deck signal. + // + // This NEVER opts out, including at commitment 0.0, and that is + // deliberate rather than an oversight. `features::vehicles` is + // conservative by design: `crew_capable_power` excludes non-fixed + // printed power (`PtValue::Variable`), and a decklist cannot see tokens + // at all. So a zero commitment means "no bench I could prove at + // deck-build time", NOT "cannot crew". A Vehicles deck whose bench is + // tokens, or `*`-power creatures, scores 0.0 and can still reach a legal + // `CrewVehicle` action — and dropping the timing safeguard exactly there + // would be worst in the case that needs it most. + // + // The weight is therefore DAMPED, never zeroed: `MIN_CREW_ACTIVATION` + // floors it so the policy keeps judging a crew action the AI is already + // looking at, while a dedicated shell still outweighs an incidental one. + Some(features.vehicles.commitment.max(MIN_CREW_ACTIVATION)) } fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { @@ -142,6 +169,12 @@ mod tests { use engine::types::phase::Phase; use engine::types::zones::Zone; + use crate::features::vehicles::VehiclesFeature; + use crate::features::DeckFeatures; + use crate::policies::registry::{PolicyId, PolicyRegistry}; + use crate::session::AiSession; + use std::sync::Arc; + const AI: PlayerId = PlayerId(0); fn crew_fixture() -> (GameState, ObjectId, ObjectId) { @@ -294,4 +327,87 @@ mod tests { assert!(crew_has_exact_combat_use(&state, AI, vehicle, &activation)); } + + // ─── review #6790: zero cached commitment must NOT silence the safeguard ── + + #[test] + fn activation_never_opts_out_even_at_zero_commitment() { + // `features::vehicles` is conservative: it excludes variable printed + // power and cannot see tokens, so 0.0 means "no bench provable at + // deck-build time", not "cannot crew". + let zero = DeckFeatures { + vehicles: VehiclesFeature::default(), + ..Default::default() + }; + assert_eq!(zero.vehicles.commitment, 0.0); + assert_eq!( + CrewTimingPolicy.activation(&zero, &GameState::new_two_player(42), AI), + Some(MIN_CREW_ACTIVATION), + "a zero-commitment deck can still reach a legal crew action" + ); + } + + #[test] + fn activation_scales_above_the_floor() { + let committed = DeckFeatures { + vehicles: VehiclesFeature { + commitment: 0.8, + ..Default::default() + }, + ..Default::default() + }; + assert_eq!( + CrewTimingPolicy.activation(&committed, &GameState::new_two_player(42), AI), + Some(0.8), + "a dedicated shell must outweigh an incidental one" + ); + } + + #[test] + fn registry_emits_a_crew_verdict_at_zero_commitment() { + // The production seam: a live `CrewVehicle` candidate must still reach + // `CrewTimingPolicy` through the registry when cached commitment is 0.0. + // This is the regression the opt-out would have introduced — the deck + // whose bench is tokens scores 0.0 and needs the safeguard most. + let (state, vehicle, _) = crew_fixture(); + let candidate = CandidateAction { + action: GameAction::CrewVehicle { + vehicle_id: vehicle, + creature_ids: Vec::new(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Utility), + }; + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let config = AiConfig::default(); + let mut session = AiSession::empty(); + session.features.insert( + AI, + DeckFeatures { + vehicles: VehiclesFeature::default(), + ..Default::default() + }, + ); + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: AI, + config: &config, + context: &context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + let verdicts = PolicyRegistry::default().verdicts(&ctx); + assert!( + verdicts.iter().any(|(id, _)| *id == PolicyId::CrewTiming), + "CrewTimingPolicy must still be routed at commitment 0.0" + ); + } } diff --git a/crates/phase-ai/src/policies/mod.rs b/crates/phase-ai/src/policies/mod.rs index 63ed9ce62e..0deb97bb50 100644 --- a/crates/phase-ai/src/policies/mod.rs +++ b/crates/phase-ai/src/policies/mod.rs @@ -70,6 +70,7 @@ mod tempo_curve; mod tokens_wide; mod tribal_lord_priority; pub(crate) mod tutor; +mod vehicle_deployment; mod x_cast_gate; mod x_reference; mod x_value; diff --git a/crates/phase-ai/src/policies/registry.rs b/crates/phase-ai/src/policies/registry.rs index 375205ccd0..44d55c0243 100644 --- a/crates/phase-ai/src/policies/registry.rs +++ b/crates/phase-ai/src/policies/registry.rs @@ -159,6 +159,8 @@ pub enum PolicyId { /// CR 701.9: reward discarding into an on-battlefield "whenever you discard" /// engine — disjoint from `HandDisruption`, which scores OPPONENT discard. DiscardPayoff, + /// CR 702.122a: cast a Vehicle when the board can actually crew it. + VehicleDeployment, } /// Coarse routing kind for a candidate decision. Each policy declares which @@ -418,6 +420,7 @@ impl Default for PolicyRegistry { Box::new(super::cost_reduction::CostReductionPolicy), Box::new(super::draw_payoff::DrawPayoffPolicy), Box::new(super::discard_payoff::DiscardPayoffPolicy), + Box::new(super::vehicle_deployment::VehicleDeploymentPolicy), ]; let mut by_kind: HashMap> = HashMap::new(); for (idx, policy) in policies.iter().enumerate() { diff --git a/crates/phase-ai/src/policies/tests/mod.rs b/crates/phase-ai/src/policies/tests/mod.rs index 90515eceea..7e135a8aec 100644 --- a/crates/phase-ai/src/policies/tests/mod.rs +++ b/crates/phase-ai/src/policies/tests/mod.rs @@ -21,3 +21,4 @@ pub mod removal_lethality; pub mod sac_outlet_drain_repro; pub mod score_contract_lint; pub mod self_bounce_target; +pub mod vehicle_deployment; diff --git a/crates/phase-ai/src/policies/tests/vehicle_deployment.rs b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs new file mode 100644 index 0000000000..44793dbe7c --- /dev/null +++ b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs @@ -0,0 +1,536 @@ +//! Unit tests for `policies::vehicle_deployment` — CR 702.122a crewable-Vehicle +//! deployment. No `#[cfg(test)]` in SOURCE files; tests live here. + +use std::sync::Arc; + +use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; +use engine::game::zones::create_object; +use engine::types::ability::StaticDefinition; +use engine::types::ability::TargetFilter; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::format::FormatConfig; +use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::keywords::Keyword; +use engine::types::player::PlayerId; +use engine::types::statics::{CrewAction, CrewContributionKind, StaticMode}; +use engine::types::zones::Zone; + +use crate::config::AiConfig; +use crate::context::AiContext; +use crate::features::vehicles::{VehiclesFeature, VEHICLES_FLOOR}; +use crate::features::DeckFeatures; +use crate::policies::context::{PolicyContext, SearchDepth}; +use crate::policies::registry::{ + PolicyId, PolicyReason, PolicyRegistry, PolicyVerdict, TacticalPolicy, +}; +use crate::policies::vehicle_deployment::*; +use crate::session::AiSession; + +const AI: PlayerId = PlayerId(0); + +fn state() -> GameState { + GameState::new(FormatConfig::standard(), 2, 42) +} + +/// A Vehicle in hand with `Crew N`. +fn vehicle_in_hand(state: &mut GameState, crew: u32) -> (ObjectId, CardId) { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Copter".to_string(), Zone::Hand); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.card_types.subtypes.push("Vehicle".to_string()); + obj.keywords.push(Keyword::Crew { + power: crew, + once_per_turn: None, + }); + state.players[AI.0 as usize].hand.push_back(id); + (id, card_id) +} + +/// A non-Vehicle artifact in hand. +fn artifact_in_hand(state: &mut GameState) -> (ObjectId, CardId) { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Signet".to_string(), Zone::Hand); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + state.players[AI.0 as usize].hand.push_back(id); + (id, card_id) +} + +/// An untapped creature the AI controls, with `power`. +fn creature(state: &mut GameState, power: i32, controller: PlayerId) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + controller, + "Bear".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.power = Some(power); + obj.toughness = Some(power.max(1)); + id +} + +fn feature(commitment: f32) -> VehiclesFeature { + VehiclesFeature { + vehicle_count: 5, + total_crew_cost: 10, + crew_body_count: 10, + total_crew_power: 20, + commitment, + } +} + +fn session(commitment: f32) -> AiSession { + let features = DeckFeatures { + vehicles: feature(commitment), + ..Default::default() + }; + let mut session = AiSession::empty(); + session.features.insert(AI, features); + session +} + +fn context(config: &AiConfig, session: AiSession) -> AiContext { + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + context +} + +fn cast(object_id: ObjectId, card_id: CardId) -> CandidateAction { + CandidateAction { + action: GameAction::CastSpell { + object_id, + card_id, + targets: Vec::new(), + payment_mode: CastPaymentMode::default(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Spell), + } +} + +fn ctx<'a>( + state: &'a GameState, + candidate: &'a CandidateAction, + decision: &'a AiDecisionContext, + context: &'a AiContext, + config: &'a AiConfig, +) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate, + ai_player: AI, + config, + context, + cast_facts: None, + search_depth: SearchDepth::Root, + } +} + +fn priority_decision(candidate: &CandidateAction) -> AiDecisionContext { + AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + } +} + +fn score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { + match verdict { + PolicyVerdict::Score { delta, reason } => (delta, reason), + PolicyVerdict::Reject { reason } => panic!("unexpected Reject: {reason:?}"), + } +} + +fn verdict_for( + st: &GameState, + obj: ObjectId, + card: CardId, + commitment: f32, +) -> (f64, PolicyReason) { + let config = AiConfig::default(); + let context = context(&config, session(commitment)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + score_of(VehicleDeploymentPolicy.verdict(&ctx(st, &candidate, &decision, &context, &config))) +} + +// ─── activation ────────────────────────────────────────────────────────────── + +#[test] +fn activation_opts_out_below_floor() { + let features = DeckFeatures { + vehicles: feature(VEHICLES_FLOOR - 0.01), + ..Default::default() + }; + assert!(VehicleDeploymentPolicy + .activation(&features, &state(), AI) + .is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let features = DeckFeatures { + vehicles: feature(0.8), + ..Default::default() + }; + assert_eq!( + VehicleDeploymentPolicy.activation(&features, &state(), AI), + Some(0.8) + ); +} + +// ─── verdict ───────────────────────────────────────────────────────────────── + +#[test] +fn crewable_vehicle_scores_positive() { + let mut st = state(); + creature(&mut st, 3, AI); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); + assert!(delta > 0.0, "expected positive credit, got {delta}"); +} + +#[test] +fn uncrewable_vehicle_is_neutral_not_penalized() { + // No bodies: the Vehicle would enter as a blank. Withholding the bonus is the + // whole signal — this policy never vetoes a deployment. + let mut st = state(); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); + assert_eq!(delta, 0.0, "must never penalize, only withhold"); +} + +#[test] +fn crew_power_sums_across_multiple_bodies() { + // CR 702.122a: "any number of other untapped creatures with TOTAL power N". + let mut st = state(); + creature(&mut st, 1, AI); + creature(&mut st, 1, AI); + creature(&mut st, 1, AI); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); +} + +#[test] +fn insufficient_total_power_is_uncrewable() { + let mut st = state(); + creature(&mut st, 1, AI); + creature(&mut st, 1, AI); + let (_, reason) = { + let (obj, card) = vehicle_in_hand(&mut st, 5); + verdict_for(&st, obj, card, 0.8) + }; + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); +} + +#[test] +fn tapped_creatures_do_not_crew() { + // CR 702.122a requires UNTAPPED creatures. + let mut st = state(); + let body = creature(&mut st, 4, AI); + st.objects.get_mut(&body).unwrap().tapped = true; + let (obj, card) = vehicle_in_hand(&mut st, 2); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); +} + +#[test] +fn opponent_creatures_do_not_crew() { + // CR 702.122a: creatures YOU control. + let mut st = state(); + creature(&mut st, 5, PlayerId(1)); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); +} + +#[test] +fn non_vehicle_is_not_applicable() { + let mut st = state(); + creature(&mut st, 5, AI); + let (obj, card) = artifact_in_hand(&mut st); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn surplus_credit_is_bounded_by_the_cap() { + let mut st = state(); + for _ in 0..12 { + creature(&mut st, 5, AI); + } + let (obj, card) = vehicle_in_hand(&mut st, 1); + let (delta, _) = verdict_for(&st, obj, card, 0.8); + let config = AiConfig::default(); + let ceiling = config.policy_penalties.vehicle_deployment_bonus * 2.0; + assert!( + delta <= ceiling + f64::EPSILON, + "delta {delta} exceeded ceiling {ceiling}" + ); +} + +#[test] +fn exact_crew_requirement_is_crewable() { + // Boundary: total power exactly equals N (CR 702.122a says "N or greater"). + let mut st = state(); + creature(&mut st, 2, AI); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); +} + +// ─── production seam ───────────────────────────────────────────────────────── + +#[test] +fn registry_routes_cast_spell_to_this_policy() { + let mut st = state(); + creature(&mut st, 3, AI); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + let found = verdicts + .iter() + .find(|(id, _)| *id == PolicyId::VehicleDeployment) + .map(|(_, v)| v.clone()) + .expect("VehicleDeploymentPolicy must be registered and routed for CastSpell"); + let (delta, reason) = score_of(found); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); + assert!(delta > 0.0); +} + +#[test] +fn registry_stays_silent_below_the_activation_floor() { + let mut st = state(); + creature(&mut st, 3, AI); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let config = AiConfig::default(); + let context = context(&config, session(VEHICLES_FLOOR - 0.01)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + assert!( + !verdicts + .iter() + .any(|(id, _)| *id == PolicyId::VehicleDeployment), + "policy must not contribute below its activation floor" + ); +} + +// ─── review #6790 blocker: a subtype-only Vehicle has no crew ability ─────── + +/// A Vehicle by TYPE LINE only — no `Keyword::Crew`. CR 702.122a makes Crew an +/// activated ability, which a subtype alone does not grant. +fn subtype_only_vehicle_in_hand(state: &mut GameState) -> (ObjectId, CardId) { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Odd Vehicle".to_string(), Zone::Hand); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.card_types.subtypes.push("Vehicle".to_string()); + state.players[AI.0 as usize].hand.push_back(id); + (id, card_id) +} + +#[test] +fn subtype_only_vehicle_is_not_applicable_on_an_empty_board() { + // The exact regression: a synthesised `Crew 0` satisfied `0 >= 0` and scored + // a live crew bonus for a permanent that can never be crewed. + let mut st = state(); + let (obj, card) = subtype_only_vehicle_in_hand(&mut st); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn subtype_only_vehicle_is_not_applicable_with_a_full_board() { + // Same, with a board that WOULD satisfy any real crew cost — proving the + // rejection comes from the missing ability, not from an empty battlefield. + let mut st = state(); + creature(&mut st, 5, AI); + creature(&mut st, 5, AI); + let (obj, card) = subtype_only_vehicle_in_hand(&mut st); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn registry_stays_silent_for_a_subtype_only_vehicle() { + // At the production seam, per the review: registered + routed, still neutral. + let mut st = state(); + creature(&mut st, 5, AI); + let (obj, card) = subtype_only_vehicle_in_hand(&mut st); + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + // `.expect`, not `if let` — the invariant is "registered AND routed AND + // neutral". Skipping the assertions when the policy is absent would let a + // routing regression (activation regressing to `None`) pass silently, which + // is the opposite of what this test claims to enforce. + let found = verdicts + .iter() + .find(|(id, _)| *id == PolicyId::VehicleDeployment) + .map(|(_, v)| v.clone()) + .expect("VehicleDeploymentPolicy must be registered and routed for CastSpell"); + let (delta, reason) = score_of(found); + assert_eq!(reason.kind, "vehicle_deployment_na"); + assert_eq!(delta, 0.0); +} + +// ─── review #6790 NB: the engine crew authorities must actually be consulted ── + +/// Attach a static to a battlefield object. +fn attach_static(state: &mut GameState, id: ObjectId, mode: StaticMode) { + let obj = state.objects.get_mut(&id).unwrap(); + obj.static_definitions.push(StaticDefinition::new(mode)); +} + +#[test] +fn cant_crew_creature_does_not_contribute() { + // CR 702.122d, via `object_has_cant_crew`. Without that call the 4-power body + // would cover Crew 3 and this would score. + let mut st = state(); + let body = creature(&mut st, 4, AI); + attach_static(&mut st, body, StaticMode::CantCrew); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); + assert_eq!(delta, 0.0); +} + +#[test] +fn power_delta_contribution_is_honored() { + // CR 702.122a "as though its power were N greater", via + // `object_crew_power_contribution`. A raw `.power` sum would read 1 and call + // this uncrewable; the authority reads 3. + let mut st = state(); + let body = creature(&mut st, 1, AI); + attach_static( + &mut st, + body, + StaticMode::CrewContribution { + kind: CrewContributionKind::PowerDelta { delta: 2 }, + actions: vec![CrewAction::Crew], + }, + ); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!( + reason.kind, "vehicle_deployment_crewable", + "the engine's crew-power authority must be consulted, not raw power" + ); +} + +#[test] +fn toughness_instead_of_power_contribution_is_honored() { + // CR 702.122a "using its toughness rather than its power". + let mut st = state(); + let body = creature(&mut st, 1, AI); + st.objects.get_mut(&body).unwrap().toughness = Some(4); + attach_static( + &mut st, + body, + StaticMode::CrewContribution { + kind: CrewContributionKind::ToughnessInsteadOfPower, + actions: vec![CrewAction::Crew], + }, + ); + let (obj, card) = vehicle_in_hand(&mut st, 4); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); +} + +// ─── review #6790 blocker: CantTap creatures cannot pay Crew ───────────────── + +/// A creature the engine's crew path rejects: `StaticMode::CantTap`. +/// +/// Mirrors the engine's own fixture (`restrictions.rs::creature_with_cant_tap`) — +/// the static goes on BOTH `static_definitions` and `base_static_definitions`, +/// then `evaluate_layers` runs, because `object_cant_tap` has an O(1) fast path +/// gated on a static-kind presence index. Skipping the layer pass would leave +/// that index empty, the fast path would return `false`, and this test would pass +/// for the wrong reason. +fn cant_tap_creature(state: &mut GameState, power: i32) -> ObjectId { + let id = creature(state, power, AI); + { + let obj = state.objects.get_mut(&id).unwrap(); + let def = StaticDefinition::new(StaticMode::CantTap).affected(TargetFilter::SelfRef); + obj.static_definitions.push(def.clone()); + Arc::make_mut(&mut obj.base_static_definitions).push(def); + } + engine::game::layers::evaluate_layers(state); + id +} + +#[test] +fn cant_tap_creature_cannot_pay_crew() { + // CR 702.122a via the engine's `creature_can_pay_crew` authority: a CantTap + // 3/3 is untapped and controlled, but cannot legally be tapped, so it + // contributes nothing toward Crew 3. The previous hand-rolled filter omitted + // `object_cant_tap` and counted it, awarding the crewable bonus for a Vehicle + // that can never be crewed. + let mut st = state(); + let restricted = cant_tap_creature(&mut st, 3); + // Guard the fixture itself through the public authority: if the static did + // not take effect, this test would pass vacuously. Paired with a plain body + // of the same power so the assertion discriminates the restriction rather + // than merely observing a `false`. + let unrestricted = creature(&mut st, 3, AI); + assert!( + !engine::game::engine::creature_can_pay_crew(&st, restricted, AI), + "fixture must actually be under a CantTap restriction" + ); + assert!( + engine::game::engine::creature_can_pay_crew(&st, unrestricted, AI), + "an identical unrestricted body must be able to pay crew" + ); + // Remove the control body again so the crew total is the restricted one only. + st.battlefield.retain(|id| *id != unrestricted); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); + assert_eq!(delta, 0.0); +} + +#[test] +fn cant_tap_creature_alongside_a_legal_body_still_undercounts() { + // Discriminating: one legal 1-power body plus a CantTap 3/3 totals 1, not 4, + // so Crew 3 stays out of reach. + let mut st = state(); + cant_tap_creature(&mut st, 3); + creature(&mut st, 1, AI); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); +} + +#[test] +fn an_unrestricted_body_of_the_same_power_does_crew() { + // The positive control for the two tests above: identical power, no CantTap, + // so the difference is provably the restriction and not the power total. + let mut st = state(); + creature(&mut st, 3, AI); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); + assert!(delta > 0.0); +} diff --git a/crates/phase-ai/src/policies/vehicle_deployment.rs b/crates/phase-ai/src/policies/vehicle_deployment.rs new file mode 100644 index 0000000000..6d5635eb4f --- /dev/null +++ b/crates/phase-ai/src/policies/vehicle_deployment.rs @@ -0,0 +1,131 @@ +//! `VehicleDeploymentPolicy` — value casting a Vehicle against whether the board +//! can actually crew it. +//! +//! ## The gap this closes +//! +//! CR 702.122a: an uncrewed Vehicle is not a creature. It sits on the battlefield +//! doing nothing until its controller taps *other* untapped creatures with total +//! power N or greater. So the same card is a threat on a developed board and a +//! blank on an empty one — and nothing in the AI distinguishes those two casts. +//! +//! `CrewTimingPolicy` decides whether a specific crew activation is worth it, +//! which is the question *after* the Vehicle is already down. This policy asks +//! the earlier one: is deploying it now going to produce a body, or a brick? +//! +//! It is deliberately one-directional in the same way `DrawPayoffPolicy` is: it +//! ADDS value when the crew requirement is already met and withholds it +//! otherwise. It never vetoes a Vehicle cast — holding a Vehicle for a board that +//! may never arrive is its own mistake, and the mana-efficiency and board- +//! development policies already price deploying a permanent. +//! +//! ## Performance +//! +//! `verdict()` runs per candidate per search node. The card-local check — does +//! this candidate carry a crew requirement at all — reads one card's keywords and +//! rejects every non-Vehicle candidate. Only a confirmed Vehicle pays for the +//! battlefield walk, which is bounded by the AI's own permanents and delegates +//! per-creature power to the engine's `object_crew_power_contribution` authority. +//! No affordability sweep, no `find_legal_targets`. + +use engine::game::engine::creature_can_pay_crew; +use engine::game::static_abilities::object_crew_power_contribution; +use engine::types::actions::GameAction; +use engine::types::game_state::GameState; +use engine::types::player::PlayerId; +use engine::types::statics::CrewAction; + +use crate::features::vehicles::{crew_requirement_parts, VEHICLES_FLOOR}; +use crate::features::DeckFeatures; + +use super::context::PolicyContext; +use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; + +pub struct VehicleDeploymentPolicy; + +/// Cap on the surplus crew power rewarded, so a huge board cannot push one +/// Vehicle cast out of the preference band. +/// +/// `pub(crate)` so the bounded-score regression asserts against this constant +/// rather than a copied literal. +pub(crate) const MAX_REWARDED_SURPLUS: i32 = 3; + +impl TacticalPolicy for VehicleDeploymentPolicy { + fn id(&self) -> PolicyId { + PolicyId::VehicleDeployment + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::CastSpell] + } + + fn activation( + &self, + features: &DeckFeatures, + _state: &GameState, + _player: PlayerId, + ) -> Option { + if features.vehicles.commitment < VEHICLES_FLOOR { + None + } else { + Some(features.vehicles.commitment) + } + } + + fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { + // Card-local first: is this candidate even a Vehicle? + let Some(facts) = ctx.cast_facts() else { + return PolicyVerdict::neutral(PolicyReason::new("vehicle_deployment_na")); + }; + if !matches!(ctx.candidate.action, GameAction::CastSpell { .. }) { + return PolicyVerdict::neutral(PolicyReason::new("vehicle_deployment_na")); + } + // CR 702.122a: Crew is an ACTIVATED ABILITY. A Vehicle subtype without + // the keyword grants no crew ability, so it has no requirement to meet — + // routing through the strict authority keeps a `Crew 0` from being + // synthesised and trivially satisfied by an empty board. + let Some(crew_cost) = crew_requirement_parts(facts.object.keywords.iter()) else { + return PolicyVerdict::neutral(PolicyReason::new("vehicle_deployment_na")); + }; + + // Only now pay for the board walk. Eligibility is NOT re-derived here: + // `creature_can_pay_crew` is the engine's own composed authority, the + // same rule the crew payment path enforces (controlled, untapped, a + // creature, not `CantTap`, not `CantCrew`). Assembling that filter from + // parts is how this policy previously over-counted — it omitted the + // `object_cant_tap` term, so a `CantTap` 3/3 read as able to pay Crew 3. + // + // Per-creature power likewise goes through `object_crew_power_contribution`, + // so a body crewing "as though its power were N greater" or by toughness + // instead of power is counted exactly as the real payment would count it. + let available: i32 = ctx + .state + .battlefield + .iter() + .filter(|id| creature_can_pay_crew(ctx.state, **id, ctx.ai_player)) + .map(|id| object_crew_power_contribution(ctx.state, *id, CrewAction::Crew)) + .sum(); + + let required = i32::try_from(crew_cost).unwrap_or(i32::MAX); + if available < required { + // The Vehicle would enter as a blank. No penalty — see the module + // docs: withholding the bonus is the whole signal. + return PolicyVerdict::neutral( + PolicyReason::new("vehicle_deployment_uncrewable") + .with_fact("required", i64::from(required)) + .with_fact("available", i64::from(available)), + ); + } + + // The board can turn this into a creature the turn it lands. Scale mildly + // with surplus power, because spare bodies mean crewing costs less of the + // attack step. + let surplus = (available - required).min(MAX_REWARDED_SURPLUS); + let scaled = 1.0 + f64::from(surplus) / f64::from(MAX_REWARDED_SURPLUS); + PolicyVerdict::score( + ctx.config.policy_penalties.vehicle_deployment_bonus * scaled, + PolicyReason::new("vehicle_deployment_crewable") + .with_fact("required", i64::from(required)) + .with_fact("available", i64::from(available)), + ) + } +}