Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions crates/phase-ai/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 6 additions & 0 deletions crates/phase-ai/src/features/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}
Expand Down
5 changes: 4 additions & 1 deletion crates/phase-ai/src/features/reanimator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
1 change: 1 addition & 0 deletions crates/phase-ai/src/features/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ pub mod mill;
pub mod no_name_matching;
pub mod poison;
pub mod reanimator;
pub mod vehicles;
275 changes: 275 additions & 0 deletions crates/phase-ai/src/features/tests/vehicles.rs
Original file line number Diff line number Diff line change
@@ -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<DeckEntry> {
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);
}
Loading
Loading