From 23ce1f2fda0880d017d33926904f6f9617ac4157 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Wed, 29 Jul 2026 08:57:44 -0700 Subject: [PATCH 1/5] feat(phase-ai): add vehicles deck-feature axis + VehicleDeploymentPolicy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CrewTimingPolicy` ran with `activation-constant Some(1.0)` — it applied identical weight in a dedicated Vehicles shell and in a deck holding one incidental Smuggler's Copter, because no vehicles deck-feature existed for it to scale by. This adds that axis and wires it in, so the constant is gone. CR 702.122a: "Crew N" taps any number of OTHER untapped creatures you control with total power N or greater. A Vehicle is therefore not a body the deck buys with mana — it is one it buys with board presence, and an uncrewed Vehicle is not a creature at all. Feature (`features/vehicles.rs`), structural over `CardFace` AST: - `vehicle_count` / `total_crew_cost` from `Keyword::Crew { power }`, with the Vehicle subtype as a fallback so a Vehicle whose crew keyword the parser has not attached still joins the archetype (contributing no crew cost). - `crew_body_count` / `total_crew_power` — creatures that can actually pay a crew cost. A Vehicle is excluded even when also printed as a creature, because crew taps OTHER creatures; `PtValue::Variable` ("*") is excluded rather than guessed; power 0 taps for nothing. - `commitment` — geometric mean over (Vehicle density, crew support). Both pillars mandatory: Vehicles with no bench never become creatures, and a bench with no Vehicles is just a creature deck. Policy (`policies/vehicle_deployment.rs`, `CastSpell`) asks the question that comes BEFORE `CrewTimingPolicy`'s: will deploying this Vehicle now produce a body or a brick? It adds value only when the board already meets the crew requirement and withholds it otherwise — never a veto, since holding a Vehicle for a board that may never arrive is its own mistake. Shared authorities rather than reimplementation: - `VEHICLE_SUBTYPE` is promoted from `features::reanimator` instead of being redefined, so two features cannot disagree about what a Vehicle is. - Per-creature crew power goes through the engine's `object_crew_power_contribution` + `object_has_cant_crew`, so a creature that crews by toughness or "as though its power were N greater" (CR 702.122a) and one under a `CantCrew` static (CR 702.122d) are counted exactly as the real crew payment would count them. Summing `.power` here would have been silently wrong for both. Calibration is computed, not asserted from intuition, and one anchor exposed a real defect: the first pillar scaling let a single incidental Copter in a 20-creature deck reach 0.456 — above the floor — because a saturated bench compensated for near-zero Vehicle density. Retuned so it lands at 0.373 and is correctly rejected. Anchors pinned by test: 8+16 -> 1.000, 5+10 -> 0.833, 3+6 -> 0.645, 2+4 -> 0.527, 1+20 -> 0.373 (below the 0.40 floor). `vehicle_deployment_bonus` lands in `UNTUNED_POLICY_PENALTY_FIELDS` pending a paired-seed calibration. 34 tests. Four invariants were mutated independently and each killed exactly its own guard, with no cross-masking: the self-crew exclusion, the untapped requirement, the controller requirement, and the positive-power requirement. Verified: `cargo test -p phase-ai` 1844 passed, `cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings` exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) --- crates/phase-ai/src/config.rs | 13 + crates/phase-ai/src/features/mod.rs | 6 + crates/phase-ai/src/features/reanimator.rs | 5 +- crates/phase-ai/src/features/tests/mod.rs | 1 + .../phase-ai/src/features/tests/vehicles.rs | 260 ++++++++++++++ crates/phase-ai/src/features/vehicles.rs | 234 +++++++++++++ crates/phase-ai/src/policies/crew_timing.rs | 18 +- crates/phase-ai/src/policies/mod.rs | 1 + crates/phase-ai/src/policies/registry.rs | 3 + crates/phase-ai/src/policies/tests/mod.rs | 1 + .../src/policies/tests/vehicle_deployment.rs | 330 ++++++++++++++++++ .../src/policies/vehicle_deployment.rs | 142 ++++++++ 12 files changed, 1011 insertions(+), 3 deletions(-) create mode 100644 crates/phase-ai/src/features/tests/vehicles.rs create mode 100644 crates/phase-ai/src/features/vehicles.rs create mode 100644 crates/phase-ai/src/policies/tests/vehicle_deployment.rs create mode 100644 crates/phase-ai/src/policies/vehicle_deployment.rs 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..0323f3f5f2 --- /dev/null +++ b/crates/phase-ai/src/features/tests/vehicles.rs @@ -0,0 +1,260 @@ +//! 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 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..4d25bbc0b7 --- /dev/null +++ b/crates/phase-ai/src/features/vehicles.rs @@ -0,0 +1,234 @@ +//! 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. +//! +//! 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 let Some(crew) = crew_requirement(face) { + vehicle_count = vehicle_count.saturating_add(entry.count); + 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(), &face.card_type.subtypes) +} + +/// Parts-based twin of [`crew_requirement`], for callers holding a live +/// `GameObject` rather than a `CardFace`. +/// +/// Detection runs against the deck list (`CardFace.keywords`, +/// `CardFace.card_type.subtypes`); the policy runs against the battlefield +/// (`GameObject.keywords`, `GameObject.card_types.subtypes`). Same shapes, +/// different field paths — passing the slices keeps ONE classifier instead of a +/// deck-time and a live copy that could disagree about what a Vehicle is. +pub(crate) fn crew_requirement_parts<'a>( + keywords: impl IntoIterator, + subtypes: &[String], +) -> Option { + let keyword_crew = keywords.into_iter().find_map(|keyword| match keyword { + Keyword::Crew { power, .. } => Some(*power), + _ => None, + }); + if keyword_crew.is_some() { + return keyword_crew; + } + subtypes + .iter() + .any(|subtype| subtype.eq_ignore_ascii_case(VEHICLE_SUBTYPE)) + .then_some(0) +} + +/// 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..df59109f87 100644 --- a/crates/phase-ai/src/policies/crew_timing.rs +++ b/crates/phase-ai/src/policies/crew_timing.rs @@ -14,6 +14,12 @@ 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, so 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 +33,19 @@ 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. The floor is deliberately NOT applied: crewing is + // still a real decision in a low-commitment deck, so the policy keeps + // firing at a reduced weight rather than switching off, and only a deck + // with no Vehicles at all (commitment 0.0) goes silent. + Some(features.vehicles.commitment.max(MIN_CREW_ACTIVATION)) } fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { 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..7a52b8132d --- /dev/null +++ b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs @@ -0,0 +1,330 @@ +//! 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::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::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" + ); +} 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..9dda839894 --- /dev/null +++ b/crates/phase-ai/src/policies/vehicle_deployment.rs @@ -0,0 +1,142 @@ +//! `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::static_abilities::{object_crew_power_contribution, object_has_cant_crew}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +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")); + } + let Some(crew_cost) = crew_requirement_parts( + facts.object.keywords.iter(), + &facts.object.card_types.subtypes, + ) else { + return PolicyVerdict::neutral(PolicyReason::new("vehicle_deployment_na")); + }; + + // Only now pay for the board walk. CR 702.122a: crew taps OTHER untapped + // creatures the controller owns, so an already-tapped body, a creature + // under a `CantCrew` static (CR 702.122d), and the Vehicle itself all + // contribute nothing. Per-creature power goes through the engine's + // authority so a Vehicle crewed "as though its power were N greater", or + // by toughness instead of power, is counted the way the crew payment + // itself would count it. + let available: i32 = ctx + .state + .battlefield + .iter() + .filter_map(|id| { + let obj = ctx.state.objects.get(id)?; + if obj.controller != ctx.ai_player + || obj.tapped + || !obj.card_types.core_types.contains(&CoreType::Creature) + { + return None; + } + if object_has_cant_crew(ctx.state, *id) { + return None; + } + Some(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)), + ) + } +} From bbadc7401e4810bdc56c13a2280c45fce0b770fc Mon Sep 17 00:00:00 2001 From: minion1227 Date: Wed, 29 Jul 2026 09:25:41 -0700 Subject: [PATCH 2/5] fix(phase-ai): a Vehicle subtype is not a crew ability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the blocker and both non-blocking findings on #6790. [BLOCKER] `crew_requirement` returned `Some(0)` for any Vehicle subtype without `Keyword::Crew`, and `VehicleDeploymentPolicy` consumed that as a live crew cost. `available >= required` then read `0 >= 0` — true on an EMPTY board — so a permanent with no crew ability scored `vehicle_deployment_crewable` and a positive bonus. CR 702.122a opens "Crew is an activated ability of Vehicle cards"; a type line does not grant it. The defect was conflating two different questions behind one predicate: - **Archetype membership** (deck classification): is this card part of the Vehicles plan? A Vehicle whose crew keyword the parser missed still is, so the subtype fallback is honest here — it only scales how strongly the axis activates. Now `vehicle_archetype_member`. - **Crew requirement** (live scoring): what does crewing this cost right now? Only `Keyword::Crew` answers that. Now `crew_requirement` / `crew_requirement_parts`, strict, and what the policy routes through. `detect` uses membership for `vehicle_count` and the strict authority for `total_crew_cost`, so a subtype-only Vehicle joins the archetype while adding nothing to pay. [NB] `crew_timing`'s comment claimed commitment 0.0 "goes silent" while `max(MIN_CREW_ACTIVATION)` always returned `Some(0.25)`. Implemented the stated opt-out rather than softening the comment: a deck with no Vehicles has no crew decision to weigh, so the policy returns `None`; above zero the weight is damped, not floored out. [NB] The suite never exercised the engine crew authorities the module docs claim. Added direct fixtures for both live branches: a creature under `StaticMode:: CantCrew` (CR 702.122d) contributing nothing, and `CrewContribution` in both kinds — `PowerDelta` ("as though its power were N greater") and `ToughnessInsteadOfPower`. Tests 34 -> 41. Each fix is proven by mutation: - restoring the subtype fallback in the policy reds all three subtype-only tests, including the registry-seam one the review asked for; - deleting the `object_has_cant_crew` call reds `cant_crew_creature_does_not_contribute`; - replacing `object_crew_power_contribution` with a raw `.power` sum reds both contribution tests — proving the policy consults the authority rather than merely importing it. Verified: `cargo test -p phase-ai` 1894 passed, `cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings` exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) --- .../phase-ai/src/features/tests/vehicles.rs | 15 +++ crates/phase-ai/src/features/vehicles.rs | 60 ++++++--- crates/phase-ai/src/policies/crew_timing.rs | 14 +- .../src/policies/tests/vehicle_deployment.rs | 126 ++++++++++++++++++ .../src/policies/vehicle_deployment.rs | 9 +- 5 files changed, 195 insertions(+), 29 deletions(-) diff --git a/crates/phase-ai/src/features/tests/vehicles.rs b/crates/phase-ai/src/features/tests/vehicles.rs index 0323f3f5f2..7411ba5d9c 100644 --- a/crates/phase-ai/src/features/tests/vehicles.rs +++ b/crates/phase-ai/src/features/tests/vehicles.rs @@ -125,6 +125,21 @@ fn variable_power_creature_is_not_counted() { 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 diff --git a/crates/phase-ai/src/features/vehicles.rs b/crates/phase-ai/src/features/vehicles.rs index 4d25bbc0b7..4680a68883 100644 --- a/crates/phase-ai/src/features/vehicles.rs +++ b/crates/phase-ai/src/features/vehicles.rs @@ -11,7 +11,9 @@ //! 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. +//! 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. @@ -105,9 +107,13 @@ pub fn detect(deck: &[DeckEntry]) -> VehiclesFeature { total_nonland = total_nonland.saturating_add(entry.count); } - if let Some(crew) = crew_requirement(face) { + if vehicle_archetype_member(face) { vehicle_count = vehicle_count.saturating_add(entry.count); - total_crew_cost = total_crew_cost.saturating_add(crew.saturating_mul(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 @@ -141,32 +147,44 @@ pub fn detect(deck: &[DeckEntry]) -> VehiclesFeature { /// 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(), &face.card_type.subtypes) + crew_requirement_parts(face.keywords.iter()) } -/// Parts-based twin of [`crew_requirement`], for callers holding a live -/// `GameObject` rather than a `CardFace`. +/// CR 702.122a: the total power required to crew, or `None` when this object has +/// no crew ability at all. /// -/// Detection runs against the deck list (`CardFace.keywords`, -/// `CardFace.card_type.subtypes`); the policy runs against the battlefield -/// (`GameObject.keywords`, `GameObject.card_types.subtypes`). Same shapes, -/// different field paths — passing the slices keeps ONE classifier instead of a -/// deck-time and a live copy that could disagree about what a Vehicle is. +/// 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, - subtypes: &[String], ) -> Option { - let keyword_crew = keywords.into_iter().find_map(|keyword| match keyword { + keywords.into_iter().find_map(|keyword| match keyword { Keyword::Crew { power, .. } => Some(*power), _ => None, - }); - if keyword_crew.is_some() { - return keyword_crew; - } - subtypes - .iter() - .any(|subtype| subtype.eq_ignore_ascii_case(VEHICLE_SUBTYPE)) - .then_some(0) + }) +} + +/// 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. diff --git a/crates/phase-ai/src/policies/crew_timing.rs b/crates/phase-ai/src/policies/crew_timing.rs index df59109f87..75595aff53 100644 --- a/crates/phase-ai/src/policies/crew_timing.rs +++ b/crates/phase-ai/src/policies/crew_timing.rs @@ -41,10 +41,16 @@ impl TacticalPolicy for CrewTimingPolicy { // 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. The floor is deliberately NOT applied: crewing is - // still a real decision in a low-commitment deck, so the policy keeps - // firing at a reduced weight rather than switching off, and only a deck - // with no Vehicles at all (commitment 0.0) goes silent. + // that deck signal. + // + // A deck the axis scores at zero has no Vehicles at all, so there is no + // crew decision to weigh and the policy opts out. Above that, the weight + // is DAMPED rather than floored-out: crewing is still a real decision in + // a low-commitment deck, unlike the payoff policies which opt out below + // their floor entirely. + if features.vehicles.commitment <= 0.0 { + return None; + } Some(features.vehicles.commitment.max(MIN_CREW_ACTIVATION)) } diff --git a/crates/phase-ai/src/policies/tests/vehicle_deployment.rs b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs index 7a52b8132d..94cb70b7de 100644 --- a/crates/phase-ai/src/policies/tests/vehicle_deployment.rs +++ b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs @@ -5,6 +5,7 @@ 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::actions::GameAction; use engine::types::card_type::CoreType; use engine::types::format::FormatConfig; @@ -12,6 +13,7 @@ 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; @@ -328,3 +330,127 @@ fn registry_stays_silent_below_the_activation_floor() { "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)); + let found = verdicts + .iter() + .find(|(id, _)| *id == PolicyId::VehicleDeployment) + .map(|(_, v)| v.clone()); + if let Some(verdict) = found { + let (delta, reason) = score_of(verdict); + 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"); +} diff --git a/crates/phase-ai/src/policies/vehicle_deployment.rs b/crates/phase-ai/src/policies/vehicle_deployment.rs index 9dda839894..525bb873cd 100644 --- a/crates/phase-ai/src/policies/vehicle_deployment.rs +++ b/crates/phase-ai/src/policies/vehicle_deployment.rs @@ -79,10 +79,11 @@ impl TacticalPolicy for VehicleDeploymentPolicy { if !matches!(ctx.candidate.action, GameAction::CastSpell { .. }) { return PolicyVerdict::neutral(PolicyReason::new("vehicle_deployment_na")); } - let Some(crew_cost) = crew_requirement_parts( - facts.object.keywords.iter(), - &facts.object.card_types.subtypes, - ) else { + // 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")); }; From ec7601a4084ce859f4cbaf1c91a214dbd404f30d Mon Sep 17 00:00:00 2001 From: minion1227 Date: Wed, 29 Jul 2026 09:43:18 -0700 Subject: [PATCH 3/5] test(phase-ai): enforce the routed-and-neutral invariant instead of skipping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the CodeRabbit finding on `722795f67`. `registry_stays_silent_for_a_subtype_only_vehicle` used `if let Some(verdict) = found { .. }`, so if `VehicleDeploymentPolicy` were ever dropped from routing for this candidate — activation regressing to `None`, say — `found` would be `None`, the body would be skipped, and the test would pass having verified nothing. That is the vacuous-negative shape, and it was undermining the exact "registered AND routed AND neutral" invariant the test exists to pin. Replaced with `.expect(..)`, matching the sibling test at the same production seam. Proven non-vacuous: forcing `activation` to return `None` now reds this test, where previously it would have passed. Separately, the CodeRabbit note about `CrewTimingPolicy` staying active at commitment 0.0 targets `c763008b8` — the commit BEFORE the fix. The opt-out landed in `722795f67` and is present at `crew_timing.rs:51`; no further change is needed there. Verified: `cargo test -p phase-ai --lib vehicle` 41 passed. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/policies/tests/vehicle_deployment.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/phase-ai/src/policies/tests/vehicle_deployment.rs b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs index 94cb70b7de..4515da986e 100644 --- a/crates/phase-ai/src/policies/tests/vehicle_deployment.rs +++ b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs @@ -381,15 +381,18 @@ fn registry_stays_silent_for_a_subtype_only_vehicle() { 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()); - if let Some(verdict) = found { - let (delta, reason) = score_of(verdict); - assert_eq!(reason.kind, "vehicle_deployment_na"); - assert_eq!(delta, 0.0); - } + .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 ── From 9a6409fb881687b1c6dbdb58e24e0c9a9a9a909e Mon Sep 17 00:00:00 2001 From: minion1227 Date: Wed, 29 Jul 2026 10:11:59 -0700 Subject: [PATCH 4/5] fix(phase-ai): a conservative deck-time bench is not a live-action gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the zero-commitment opt-out added in the previous round, per review. The earlier note offered a choice — correct the comment, or implement the stated opt-out — and I picked the opt-out. That was the wrong branch, for a reason that lives in this PR's own code: `features::vehicles::crew_capable_power` deliberately excludes non-fixed printed power (`PtValue::Variable`), and a decklist cannot see tokens at all. So `commitment == 0.0` means "no bench provable at deck-build time", NOT "cannot crew" — `features/tests/vehicles.rs` pins exactly that case. A Vehicles deck whose bench is tokens or `*`-power creatures scores zero and can still reach a legal `CrewVehicle` action, so gating a LIVE action on a conservative STATIC classification removed the timing safeguard precisely where it is most needed. `activation` therefore never opts out. `MIN_CREW_ACTIVATION` damps the weight instead of zeroing it, so a crew action the AI is already evaluating still gets judged while a dedicated shell continues to outweigh an incidental Copter. The comment now explains that rather than describing behavior the code does not have — the defect the first note was really pointing at. Two regressions, both proven RED by reintroducing the early return: - `activation_never_opts_out_even_at_zero_commitment` — activation level. - `registry_emits_a_crew_verdict_at_zero_commitment` — the production seam, with a legal `CrewVehicle` candidate and cached commitment 0.0. - `activation_scales_above_the_floor` pins the other direction, so damping cannot silently become a flat constant again. Verified: `cargo test -p phase-ai` 1897 passed, `cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings` exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) --- crates/phase-ai/src/policies/crew_timing.rs | 118 ++++++++++++++++++-- 1 file changed, 107 insertions(+), 11 deletions(-) diff --git a/crates/phase-ai/src/policies/crew_timing.rs b/crates/phase-ai/src/policies/crew_timing.rs index 75595aff53..30e27ce19b 100644 --- a/crates/phase-ai/src/policies/crew_timing.rs +++ b/crates/phase-ai/src/policies/crew_timing.rs @@ -15,9 +15,11 @@ use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, Tacti 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, so the -/// weight is damped rather than zeroed — unlike the payoff policies, which opt -/// out entirely below their floor. +/// 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; @@ -43,14 +45,19 @@ impl TacticalPolicy for CrewTimingPolicy { // deck running one incidental Copter. `features::vehicles` now supplies // that deck signal. // - // A deck the axis scores at zero has no Vehicles at all, so there is no - // crew decision to weigh and the policy opts out. Above that, the weight - // is DAMPED rather than floored-out: crewing is still a real decision in - // a low-commitment deck, unlike the payoff policies which opt out below - // their floor entirely. - if features.vehicles.commitment <= 0.0 { - return None; - } + // 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)) } @@ -162,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) { @@ -314,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" + ); + } } From b325be5599ec1254f636428fb76ea904255500fc Mon Sep 17 00:00:00 2001 From: minion1227 Date: Wed, 29 Jul 2026 16:52:17 -0700 Subject: [PATCH 5/5] fix(phase-ai): crew eligibility comes from the engine, not a partial copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the blocker on #6790. `VehicleDeploymentPolicy` assembled its own crew-eligibility filter — controlled, untapped, a creature, not `CantCrew` — and omitted the engine's `object_cant_tap` term. The authoritative path requires all five: `is_tappable_creature_for_cost` (engine.rs) gates on `!object_cant_tap`, and the payment revalidates it and rejects the crew outright. So a `CantTap` 3/3 counted toward Crew 3 and the policy awarded `vehicle_deployment_crewable` for a Vehicle that can never be crewed. Rather than add the missing term to my own filter — leaving a fourth predicate to drift — the duplicate is deleted. `engine::game::engine::creature_can_pay_crew` is a new `pub` authority composing the two halves the crew payment enforces (tappability per CR 702.122a, plus the `CantCrew` static per CR 702.122d), and the policy makes one call to it. Per-creature power already went through `object_crew_power_contribution`; now eligibility does too. Three regressions, and two fixture traps worth recording: - `object_cant_tap` has an O(1) fast path gated on a static-kind presence index, so a fixture that only pushes the static leaves the index empty, the fast path returns `false`, and the test passes for the WRONG reason. The fixture mirrors the engine's own (`restrictions.rs::creature_with_cant_tap`): static on both `static_definitions` and `base_static_definitions`, then `evaluate_layers`. - The fixture is reach-guarded through the public authority — the restricted body must NOT be able to pay crew while an identical unrestricted body MUST, so the assertion discriminates the restriction rather than observing an incidental `false`. `cant_tap_creature_cannot_pay_crew` and `cant_tap_creature_alongside_a_legal_body_still_undercounts` both go RED when the partial filter is restored; `an_unrestricted_body_of_the_same_power_does_crew` is the positive control at identical power. Tests 41 -> 44. Verified: `cargo test -p phase-ai --lib` 1900 passed, `cargo clippy -p phase-engine --lib --features proptest -- -D warnings` and `cargo clippy -p phase-ai --all-targets -- -D warnings` both exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) --- crates/engine/src/game/engine.rs | 18 +++++ .../src/policies/tests/vehicle_deployment.rs | 77 +++++++++++++++++++ .../src/policies/vehicle_deployment.rs | 40 ++++------ 3 files changed, 109 insertions(+), 26 deletions(-) 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/policies/tests/vehicle_deployment.rs b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs index 4515da986e..44793dbe7c 100644 --- a/crates/phase-ai/src/policies/tests/vehicle_deployment.rs +++ b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs @@ -6,6 +6,7 @@ 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; @@ -457,3 +458,79 @@ fn toughness_instead_of_power_contribution_is_honored() { 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 index 525bb873cd..6d5635eb4f 100644 --- a/crates/phase-ai/src/policies/vehicle_deployment.rs +++ b/crates/phase-ai/src/policies/vehicle_deployment.rs @@ -27,9 +27,9 @@ //! per-creature power to the engine's `object_crew_power_contribution` authority. //! No affordability sweep, no `find_legal_targets`. -use engine::game::static_abilities::{object_crew_power_contribution, object_has_cant_crew}; +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::card_type::CoreType; use engine::types::game_state::GameState; use engine::types::player::PlayerId; use engine::types::statics::CrewAction; @@ -87,34 +87,22 @@ impl TacticalPolicy for VehicleDeploymentPolicy { return PolicyVerdict::neutral(PolicyReason::new("vehicle_deployment_na")); }; - // Only now pay for the board walk. CR 702.122a: crew taps OTHER untapped - // creatures the controller owns, so an already-tapped body, a creature - // under a `CantCrew` static (CR 702.122d), and the Vehicle itself all - // contribute nothing. Per-creature power goes through the engine's - // authority so a Vehicle crewed "as though its power were N greater", or - // by toughness instead of power, is counted the way the crew payment - // itself would count it. + // 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_map(|id| { - let obj = ctx.state.objects.get(id)?; - if obj.controller != ctx.ai_player - || obj.tapped - || !obj.card_types.core_types.contains(&CoreType::Creature) - { - return None; - } - if object_has_cant_crew(ctx.state, *id) { - return None; - } - Some(object_crew_power_contribution( - ctx.state, - *id, - CrewAction::Crew, - )) - }) + .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);