diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 3ebce1e171..dd0b5ceed1 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1081,6 +1081,14 @@ "returnToHand_one": "eine bleibende Karte auf die Hand zurückgeben", "returnToHand_other": "{{count}} bleibende Karten auf die Hand zurückgeben", "energy": "{{amount}} Energie", + "playerCounters_one": "{{count}} {{kind}}marke erhalten", + "playerCounters_other": "{{count}} {{kind}}marken erhalten", + "playerCounterKind": { + "Poison": "Gift", + "Experience": "Erfahrung", + "Rad": "Strahlung", + "Ticket": "Ticket" + }, "generic": "Kosten", "pay": "{{cost}} bezahlen" }, diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 14f70977c0..e7c1258fbc 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1113,6 +1113,14 @@ "returnToHand_one": "return a permanent to hand", "returnToHand_other": "return {{count}} permanents to hand", "energy": "{{amount}} energy", + "playerCounters_one": "get {{count}} {{kind}} counter", + "playerCounters_other": "get {{count}} {{kind}} counters", + "playerCounterKind": { + "Poison": "poison", + "Experience": "experience", + "Rad": "rad", + "Ticket": "ticket" + }, "generic": "a cost", "pay": "Pay {{cost}}" }, diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 9f6a998304..8f6e28189c 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1081,6 +1081,14 @@ "returnToHand_one": "devuelve un permanente a la mano", "returnToHand_other": "devuelve {{count}} permanentes a la mano", "energy": "{{amount}} de energía", + "playerCounters_one": "consigue {{count}} contador de {{kind}}", + "playerCounters_other": "consigue {{count}} contadores de {{kind}}", + "playerCounterKind": { + "Poison": "veneno", + "Experience": "experiencia", + "Rad": "rad", + "Ticket": "ticket" + }, "generic": "un coste", "pay": "Pagar {{cost}}" }, diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index a5a83e7346..03602ab233 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1081,6 +1081,14 @@ "returnToHand_one": "renvoyer un permanent en main", "returnToHand_other": "renvoyer {{count}} permanents en main", "energy": "{{amount}} énergie", + "playerCounters_one": "obtenir {{count}} marqueur {{kind}}", + "playerCounters_other": "obtenir {{count}} marqueurs {{kind}}", + "playerCounterKind": { + "Poison": "poison", + "Experience": "expérience", + "Rad": "rad", + "Ticket": "ticket" + }, "generic": "un coût", "pay": "Payer {{cost}}" }, diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 1f44c5addf..1c934afbb5 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1081,6 +1081,14 @@ "returnToHand_one": "restituisci un permanente alla mano", "returnToHand_other": "restituisci {{count}} permanenti alla mano", "energy": "{{amount}} energia", + "playerCounters_one": "ottieni {{count}} segnalino {{kind}}", + "playerCounters_other": "ottieni {{count}} segnalini {{kind}}", + "playerCounterKind": { + "Poison": "veleno", + "Experience": "esperienza", + "Rad": "radiazione", + "Ticket": "ticket" + }, "generic": "un costo", "pay": "Paga {{cost}}" }, diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index ed80f18a8a..0668068a42 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1081,6 +1081,14 @@ "returnToHand_one": "zwróć trwałego do ręki", "returnToHand_other": "zwróć {{count}} trwałych do ręki", "energy": "{{amount}} energii", + "playerCounters_one": "zdobądź {{count}} znacznik {{kind}}", + "playerCounters_other": "zdobądź {{count}} znaczników {{kind}}", + "playerCounterKind": { + "Poison": "trucizny", + "Experience": "doświadczenia", + "Rad": "promieniowania", + "Ticket": "biletu" + }, "generic": "koszt", "pay": "Zapłać {{cost}}" }, diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 048b81c120..5d8b1522cb 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1081,6 +1081,14 @@ "returnToHand_one": "devolva um permanente à mão", "returnToHand_other": "devolva {{count}} permanentes à mão", "energy": "{{amount}} de energia", + "playerCounters_one": "obtenha {{count}} marcador de {{kind}}", + "playerCounters_other": "obtenha {{count}} marcadores de {{kind}}", + "playerCounterKind": { + "Poison": "veneno", + "Experience": "experiência", + "Rad": "radiação", + "Ticket": "ticket" + }, "generic": "um custo", "pay": "Pagar {{cost}}" }, diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 51b930697a..fbc308eccd 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -3379,9 +3379,51 @@ function formatManaCost(cost: { type: string; shards?: string[]; generic?: numbe } function formatUnlessCost( - cost: { type: string; cost?: { type: string; shards?: string[]; generic?: number }; amount?: number; count?: number }, + cost: + // CR 702.21a + CR 122.1 + CR 104.3d: Ward's player-counter cost is a real + // discriminated variant with required fields (the engine's + // `AbilityCost::GetPlayerCounters` always sends both) — rendered + // unchanged, not reinterpreted (no lowercasing, no fallback defaults). + | { + type: "GetPlayerCounters"; + count: number; + counter_kind: "Poison" | "Experience" | "Rad" | "Ticket"; + } + | { + type: string; + cost?: { type: string; shards?: string[]; generic?: number }; + amount?: number; + count?: number; + }, t: TFunction<"game">, ): string { + // `"counter_kind" in cost` narrows via property presence rather than a + // `cost.type` literal comparison — the sibling union member's `type: string` + // is too wide for a `switch (cost.type)`/`cost.type === "GetPlayerCounters"` + // check to exclude it, so `cost.counter_kind` would otherwise fail to + // type-check inside that branch. The exhaustive switch below maps each + // engine value to its i18n key explicitly rather than interpolating + // `cost.counter_kind` directly into the key template — that would make the + // display layer depend on the engine's serde string matching the locale + // JSON's key names, an implicit coupling the compiler can't check. This way + // a future `PlayerCounterKind` variant fails to compile here instead of + // silently rendering a missing translation. + if ("counter_kind" in cost) { + const kindKey: "Poison" | "Experience" | "Rad" | "Ticket" = (() => { + switch (cost.counter_kind) { + case "Poison": + return "Poison"; + case "Experience": + return "Experience"; + case "Rad": + return "Rad"; + case "Ticket": + return "Ticket"; + } + })(); + const kind = t(`gamePage.cost.playerCounterKind.${kindKey}`); + return t("gamePage.cost.playerCounters", { count: cost.count, kind }); + } switch (cost.type) { // Legacy `UnlessCost` JSON (pre-2026-05-09 fold) — preserved for // saved-game compat. diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index bbf63ae952..30eb9b8fb1 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -1455,7 +1455,7 @@ fn sink_mana_cost(acc: &mut NodeAcc, cost: &ManaCost) { } /// CR 118 cost fold: the fourth compile-time drift gate — an exhaustive -/// **no-wildcard** match over all 29 [`AbilityCost`] variants. Polarity/sign +/// **no-wildcard** match over all 30 [`AbilityCost`] variants. Polarity/sign /// aware: a cost consumes a resource (negative `net`, ⇒ `requires`) or, in cost /// position, *produces* one (positive `net`, ⇒ `produces`). Field-less axes /// (`Tap`, `AnyCounter`) are injected directly. @@ -1569,6 +1569,11 @@ fn fold_cost(acc: &mut NodeAcc, cost: &AbilityCost) { // cast (the spell being cast), never an activation cost of this ability, // so it carries no modeled axis for the loop detector. | AbilityCost::KeywordCostOfCastSpell { .. } + // CR 702.21a: a Ward player-counter cost, like the effect-side + // `Effect::GivePlayerCounter` above, carries no modeled axis here — + // poison accumulation is a loss condition, not a combo resource this + // loop detector tracks. + | AbilityCost::GetPlayerCounters { .. } | AbilityCost::Unimplemented { .. } => {} } } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 614bee56e2..067de50b6e 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -4429,6 +4429,7 @@ fn scan_ability_cost(cost: &AbilityCost, mode: ScanMode) -> Axes { | AbilityCost::Waterbend { .. } | AbilityCost::NinjutsuFamily { .. } | AbilityCost::KeywordCostOfCastSpell { .. } + | AbilityCost::GetPlayerCounters { .. } | AbilityCost::Unimplemented { .. } => Axes::NONE, } } diff --git a/crates/engine/src/game/cost_payability.rs b/crates/engine/src/game/cost_payability.rs index e41c23c1e6..2acfe89580 100644 --- a/crates/engine/src/game/cost_payability.rs +++ b/crates/engine/src/game/cost_payability.rs @@ -724,6 +724,10 @@ impl AbilityCost { // affordability is decided by the separate mana-payment step, not this // choice-of-object gate. AbilityCost::KeywordCostOfCastSpell { .. } => true, + // CR 702.21a + CR 122.1: Ward's player-counter cost is never paid + // as an activation cost (only at resolution, via the unless-pay + // round trip), and it has no affordability limit — always payable. + AbilityCost::GetPlayerCounters { .. } => true, } } } diff --git a/crates/engine/src/game/costs.rs b/crates/engine/src/game/costs.rs index 1f723d056a..77ac5e23a0 100644 --- a/crates/engine/src/game/costs.rs +++ b/crates/engine/src/game/costs.rs @@ -1323,6 +1323,41 @@ fn pay_ability_cost_inner( delta: -(amount as i32), }); } + // CR 702.21a + CR 122.1 + CR 104.3d: Ward cost paid by giving the + // paying player counters of a kind (The Serpent Society). No + // affordability check (see `can_pay_resolution`) — a player may + // always choose to accept more counters. Routes through + // `add_player_counter_with_replacement` — not a raw + // `resolve_and_apply_player_edit` call — so "players can't get + // counters" replacement effects still apply, mirroring the + // `EffectCost`/`PutCounter` arm's use of the sibling + // `effects::counters::add_counter_with_replacement` above. A + // replacement that PREVENTS the addition (Solemnity) is a genuinely + // FAILED payment here, not a paused one: unlike effect resolution + // (where "prevented" and "applied" both just mean the pending item is + // resolved), a cost that silently gives zero counters must not be + // mistaken for having actually been paid, or Ward's deterrent is + // bypassed for free. + AbilityCost::GetPlayerCounters { + counter_kind, + count, + } => { + match super::effects::player_counter::add_player_counter_with_replacement( + state, player, player, *counter_kind, *count, events, + ) { + super::effects::player_counter::PlayerCounterAdditionOutcome::Applied => {} + super::effects::player_counter::PlayerCounterAdditionOutcome::Prevented => { + return Ok(payment_failed( + "Player-counter cost prevented by a replacement effect", + )); + } + super::effects::player_counter::PlayerCounterAdditionOutcome::NeedsChoice => { + return Ok(PaymentOutcome::Paused { + remaining_cost: None, + }); + } + } + } AbilityCost::PaySpeed { amount } => { let amount = resolve_cost_quantity(state, amount, player, source_id, scope); let amount = u8::try_from(amount.max(0)).unwrap_or(u8::MAX); @@ -1713,6 +1748,9 @@ pub(crate) fn supported_at_resolution(cost: &AbilityCost) -> bool { | AbilityCost::PaySpeed { .. } | AbilityCost::TapCreatures { .. } | AbilityCost::Composite { .. } + // CR 702.21a + CR 122.1: Ward's unless-pay always resolves at + // resolution time (never activation), so this must be true here. + | AbilityCost::GetPlayerCounters { .. } | AbilityCost::OneOf { .. } => true, // Only the chosen-from-hand discard has a resolution arm (the // `WaitingFor::DiscardChoice` / forced-choice fast path). The source-card @@ -1884,6 +1922,10 @@ fn can_pay_resolution( AbilityCost::OneOf { costs } => costs .iter() .any(|cost| can_pay_resolution(state, payer, cost, ability)), + // CR 702.21a + CR 122.1: Always payable — no resource/eligibility + // limit on giving yourself more counters (poison's ten-or-more loss + // condition is a separate SBA, not a payment-time affordability gate). + AbilityCost::GetPlayerCounters { .. } => true, // Variants below have no resolution-time payment arm // (`supported_at_resolution` is the shared membership authority). // Refusing here is the conservative affordability answer (treat as @@ -2064,6 +2106,10 @@ mod tests { AbilityCost::KeywordCostOfCastSpell { .. } => AbilityCost::KeywordCostOfCastSpell { keyword: crate::types::keywords::KeywordKind::Suspend, }, + AbilityCost::GetPlayerCounters { .. } => AbilityCost::GetPlayerCounters { + counter_kind: crate::types::player::PlayerCounterKind::Poison, + count: 1, + }, AbilityCost::Unimplemented { .. } => AbilityCost::Unimplemented { description: "test".to_string(), }, @@ -2177,6 +2223,10 @@ mod tests { AbilityCost::KeywordCostOfCastSpell { keyword: crate::types::keywords::KeywordKind::Suspend, }, + AbilityCost::GetPlayerCounters { + counter_kind: crate::types::player::PlayerCounterKind::Poison, + count: 1, + }, AbilityCost::Unimplemented { description: String::new(), }, diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index 61eb335bf0..3797ccfab4 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -436,14 +436,16 @@ pub(crate) fn drain_pending_counter_additions(state: &mut GameState, events: &mu player_id, counter_kind, count, - } => super::player_counter::add_player_counter_with_replacement( - state, - actor, - player_id, - counter_kind, - count, - events, - ), + } => { + super::player_counter::add_player_counter_with_replacement( + state, + actor, + player_id, + counter_kind, + count, + events, + ) != super::player_counter::PlayerCounterAdditionOutcome::NeedsChoice + } PendingCounterAddition::Energy { actor, player_id, diff --git a/crates/engine/src/game/effects/deal_damage.rs b/crates/engine/src/game/effects/deal_damage.rs index 14938677a7..d2f7d88a1e 100644 --- a/crates/engine/src/game/effects/deal_damage.rs +++ b/crates/engine/src/game/effects/deal_damage.rs @@ -559,14 +559,15 @@ pub(crate) fn apply_damage_after_replacement( // counters. Route through the player-counter replacement pipeline // so "players can't get poison counters" / poison-doublers apply; // the actor is the source's controller. - if !player_counter::add_player_counter_with_replacement( + if player_counter::add_player_counter_with_replacement( state, ctx.controller, *player_id, PlayerCounterKind::Poison, actual_amount, events, - ) { + ) == player_counter::PlayerCounterAdditionOutcome::NeedsChoice + { return DamageResult::NeedsChoice; } } else { @@ -587,14 +588,15 @@ pub(crate) fn apply_damage_after_replacement( // when a creature deals combat damage to a player. Route through // the player-counter replacement pipeline (prevention/doublers); // the actor is the source's controller. - if !player_counter::add_player_counter_with_replacement( + if player_counter::add_player_counter_with_replacement( state, ctx.controller, *player_id, PlayerCounterKind::Poison, ctx.combat_damage_poison, events, - ) { + ) == player_counter::PlayerCounterAdditionOutcome::NeedsChoice + { return DamageResult::NeedsChoice; } } diff --git a/crates/engine/src/game/effects/player_counter.rs b/crates/engine/src/game/effects/player_counter.rs index bd7130cacc..1c947c4715 100644 --- a/crates/engine/src/game/effects/player_counter.rs +++ b/crates/engine/src/game/effects/player_counter.rs @@ -8,6 +8,30 @@ use crate::types::player::{PlayerCounterKind, PlayerId}; use crate::types::proposed_event::{CounterPlacement, ProposedEvent}; use crate::types::resolved_commands::ResolvedPlayerEdit; +/// The replacement-aware outcome of attempting to add player counters. +/// +/// Distinguished from a plain `bool` because callers fall into two families +/// that need `Prevented` handled differently: +/// - Effect resolution (`resolve` below, `deal_damage`'s infect/toxic poison, +/// `proliferate`, the pending-counter-addition drain in `counters.rs`) +/// treats `Applied` and `Prevented` identically — the pending item is fully +/// resolved either way, whether or not any counters actually landed. +/// - Cost payment (`costs.rs`'s `GetPlayerCounters` ability-cost arm) must +/// treat `Prevented` as a FAILED payment: a "players can't get counters" +/// replacement (Solemnity) silently zeroing out a Ward's player-counter +/// cost must not be mistaken for having actually paid it, or Ward's whole +/// deterrent is bypassed for free (CR 702.21a). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayerCounterAdditionOutcome { + /// The counters were added (possibly replacement-adjusted in count). + Applied, + /// A replacement effect prevented the counter addition outright. + Prevented, + /// Replacement ordering or an optional replacement needs this player's + /// choice; `state.waiting_for` has already been set. + NeedsChoice, +} + pub fn add_player_counter_with_replacement( state: &mut GameState, actor: PlayerId, @@ -15,9 +39,9 @@ pub fn add_player_counter_with_replacement( counter_kind: PlayerCounterKind, count: u32, events: &mut Vec, -) -> bool { +) -> PlayerCounterAdditionOutcome { if count == 0 { - return true; + return PlayerCounterAdditionOutcome::Applied; } // CR 122.1 + CR 614.17: Player-counter additions pass through the @@ -48,12 +72,100 @@ pub fn add_player_counter_with_replacement( { apply_player_counter_addition(state, player_id, counter_kind, count, events); } - true + PlayerCounterAdditionOutcome::Applied } - replacement::ReplacementResult::Prevented => true, + replacement::ReplacementResult::Prevented => PlayerCounterAdditionOutcome::Prevented, replacement::ReplacementResult::NeedsChoice(player) => { state.waiting_for = replacement::replacement_choice_waiting_for(player, state); - false + PlayerCounterAdditionOutcome::NeedsChoice + } + } +} + +/// The replacement-aware result of previewing a player-counter addition. +/// +/// Mirrors `counters::CounterAdditionPreview` (the object-counter sibling), +/// parameterized for players instead of an object incarnation — players have +/// no incarnation-staleness concept, so there is no `None` "target no longer +/// matches" case here. +/// +/// This is intentionally an engine-internal decision fact rather than wire +/// state: callers use it while evaluating a currently-bound action, so it +/// must not be serialized or retained across turns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayerCounterAdditionPreview { + /// The proposed count reaches the player unchanged. + Applied { count: u32 }, + /// A replacement effect prevents the counter addition. + Prevented, + /// Replacement ordering or an optional replacement needs this player's choice. + ChoiceRequired { player: PlayerId }, + /// Replacement effects change the proposed counter count (e.g. a doubler). + Transformed { count: u32 }, + /// A replacement rewrites the counter event into a different event class. + /// + /// The preview cannot claim that the requested counter was added, so + /// consumers must handle this explicitly rather than treating it as an + /// absent preview. + Unsupported, +} + +/// Preview a player-counter addition through the real replacement pipeline, +/// without mutating live game state. +/// +/// Runs on an isolated clone of `state`, so a tactical caller cannot add +/// pending choices, events, or counters to the live game. Used by +/// `phase-ai`'s Ward-lethality check (`can_pay_ward_cost`) to project the +/// REPLACEMENT-ADJUSTED poison total a payment would actually give — a +/// doubler or +N effect can make a printed count understate the real result, +/// and trusting the printed count alone can let the AI accept a payment that +/// is actually lethal (CR 104.3d). +/// +/// CR 122.1 + CR 614.1: Counter placement is subject to applicable +/// replacement effects before the event happens. +pub fn preview_player_counter_addition( + state: &GameState, + actor: PlayerId, + player_id: PlayerId, + counter_kind: PlayerCounterKind, + count: u32, +) -> PlayerCounterAdditionPreview { + if count == 0 { + return PlayerCounterAdditionPreview::Applied { count }; + } + + let proposed = ProposedEvent::AddCounter { + placement: CounterPlacement::Player { + actor, + player_id, + counter_kind, + }, + count, + applied: HashSet::new(), + }; + let mut preview_state = state.clone(); + let mut events = Vec::new(); + + match replacement::replace_event(&mut preview_state, proposed, &mut events) { + replacement::ReplacementResult::Execute(ProposedEvent::AddCounter { + count: resulting_count, + .. + }) if resulting_count == count => PlayerCounterAdditionPreview::Applied { + count: resulting_count, + }, + replacement::ReplacementResult::Execute(ProposedEvent::AddCounter { + count: resulting_count, + .. + }) => PlayerCounterAdditionPreview::Transformed { + count: resulting_count, + }, + // A replacement may redirect the event into a different event class. + // The counter-placement fact is explicitly unsupported rather than + // absent, so conservative callers cannot mistake it for "no counters". + replacement::ReplacementResult::Execute(_) => PlayerCounterAdditionPreview::Unsupported, + replacement::ReplacementResult::Prevented => PlayerCounterAdditionPreview::Prevented, + replacement::ReplacementResult::NeedsChoice(player) => { + PlayerCounterAdditionPreview::ChoiceRequired { player } } } } @@ -158,14 +270,9 @@ pub fn resolve( else { continue; }; - if !add_player_counter_with_replacement( - state, - actor, - player_id, - counter_kind, - count, - events, - ) { + if add_player_counter_with_replacement(state, actor, player_id, counter_kind, count, events) + == PlayerCounterAdditionOutcome::NeedsChoice + { super::counters::stash_pending_counter_additions( state, additions[index + 1..].to_vec(), diff --git a/crates/engine/src/game/effects/proliferate.rs b/crates/engine/src/game/effects/proliferate.rs index a5503083d7..275a484d91 100644 --- a/crates/engine/src/game/effects/proliferate.rs +++ b/crates/engine/src/game/effects/proliferate.rs @@ -370,14 +370,16 @@ fn apply_counter_addition_plan_item( player_id, counter_kind, count, - } => super::player_counter::add_player_counter_with_replacement( - state, - actor, - player_id, - counter_kind, - count, - events, - ), + } => { + super::player_counter::add_player_counter_with_replacement( + state, + actor, + player_id, + counter_kind, + count, + events, + ) != super::player_counter::PlayerCounterAdditionOutcome::NeedsChoice + } PendingCounterAddition::Energy { actor, player_id, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 5cd704f147..ee5e253e26 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -3166,12 +3166,15 @@ pub(crate) fn drain_pending_cost_move_resume( | PendingCostMoveResume::DelveManaPayment { .. } | PendingCostMoveResume::ManaAbilityPayment { .. } | PendingCostMoveResume::LoyaltyActivation { .. } + | PendingCostMoveResume::GetPlayerCountersUnlessPayment { .. } ) ), // CR 606.4 + CR 616.1: a fully-prevented loyalty counter add (e.g. an // opponent's Solemnity would prevent the counters) must still complete the // parked activation instead of wedging, so `LoyaltyActivation` is eligible - // at the Prevented boundary as well. + // at the Prevented boundary as well. `GetPlayerCountersUnlessPayment` is + // eligible here too: a prevented Ward player-counter payment is a FAILED + // cost (CR 702.21a) that must counter the guarded ability, not wedge. CostMoveDrainBoundary::ReplacementPrevented { .. } => matches!( state.pending_cost_move_resume, Some( @@ -3185,6 +3188,7 @@ pub(crate) fn drain_pending_cost_move_resume( | PendingCostMoveResume::DelveManaPayment { .. } | PendingCostMoveResume::ManaAbilityPayment { .. } | PendingCostMoveResume::LoyaltyActivation { .. } + | PendingCostMoveResume::GetPlayerCountersUnlessPayment { .. } ) ), CostMoveDrainBoundary::PriorityBoundary => matches!( @@ -3251,6 +3255,15 @@ pub(crate) fn drain_pending_cost_move_resume( Some(PendingCostMoveResume::LoyaltyActivation { .. }) ) { super::planeswalker::resume_loyalty_activation(state, events)? + } else if matches!( + state.pending_cost_move_resume, + Some(PendingCostMoveResume::GetPlayerCountersUnlessPayment { .. }) + ) { + engine_payment_choices::resume_get_player_counters_unless_payment( + state, + events, + matches!(boundary, CostMoveDrainBoundary::ReplacementDelivered { .. }), + )? } else { unreachable!("eligible cost-move root must remain parked") }; diff --git a/crates/engine/src/game/engine_payment_choices.rs b/crates/engine/src/game/engine_payment_choices.rs index 73a37915ba..b586c1daaa 100644 --- a/crates/engine/src/game/engine_payment_choices.rs +++ b/crates/engine/src/game/engine_payment_choices.rs @@ -730,6 +730,49 @@ pub(super) fn handle_unless_payment( } } } + // CR 702.21a + CR 122.1 + CR 104.3d: Unless-cost of giving + // yourself N counters (Ward's player-counter form). No + // affordability gate exists — route through the single payment + // authority exactly like PayLife/PayEnergy above. Unlike those + // two, a real `Paused` path exists here (a "can't get counters" + // replacement effect, or a CR 616.1 replacement-ordering choice, + // may need a live choice), so it is preserved rather than lumped + // with `Failed`. + AbilityCost::GetPlayerCounters { .. } => { + match costs::pay_ability_cost_for_resolution( + state, + player, + &cost, + pending_effect.as_ref(), + events, + )? { + PaymentOutcome::Paid => {} + PaymentOutcome::Failed { .. } => { + payment_failed = true; + } + // CR 702.21a + CR 122.1 + CR 616.1: the counter-placement + // replacement needs the player's choice. Stash the FULL + // unless-payment continuation here — nothing else records + // `pending_effect`/`trigger_event`/`effect_description`/ + // `remaining` once `add_player_counter_with_replacement` + // overwrites `state.waiting_for` with the ReplacementChoice + // prompt below — so the choice's resolution can settle + // this exact Ward payment (via `finish_unless_payment`, + // resumed by `resume_get_player_counters_unless_payment`) + // instead of leaving it orphaned at bare Priority. + PaymentOutcome::Paused { .. } => { + state.pending_cost_move_resume = + Some(PendingCostMoveResume::GetPlayerCountersUnlessPayment { + cost: poll_cost.clone(), + pending_effect: pending_effect.clone(), + trigger_event: trigger_event.clone(), + effect_description: effect_description.clone(), + remaining: remaining.clone(), + }); + return Ok(action_result(events, state.waiting_for.clone())); + } + } + } // CR 118.12a + CR 701.9 + CR 702.24a: Unless-discard. Resolve the // per-counter-scaled count, gate on eligible hand size, and seed the // `remaining` re-prompt loop (one card per round-trip). Defers to the @@ -1291,6 +1334,42 @@ pub(super) fn handle_unless_payment( } } + finish_unless_payment( + state, + pay, + payment_failed, + poll_cost, + pending_effect, + trigger_event, + effect_description, + remaining, + post_action_event_start, + events, + ) +} + +/// CR 118.12 + CR 118.12a: The shared paid/failed epilogue for every +/// unless-cost shape — poll re-emit for "unless any player pays", resolve the +/// guarded ability's chain when the cost is unpaid/failed, and settle +/// priority/continuations either way. Extracted from `handle_unless_payment`'s +/// own tail so a cost shape that pauses on a nested replacement choice mid-payment +/// (`AbilityCost::GetPlayerCounters`, via +/// `PendingCostMoveResume::GetPlayerCountersUnlessPayment`) can resume through +/// EXACTLY this same logic once the choice resolves, instead of duplicating it +/// and risking drift between the immediate and deferred paths. +#[allow(clippy::too_many_arguments)] +fn finish_unless_payment( + state: &mut GameState, + pay: bool, + payment_failed: bool, + poll_cost: AbilityCost, + pending_effect: Box, + trigger_event: Option, + effect_description: Option, + remaining: Vec, + mut post_action_event_start: Option, + events: &mut Vec, +) -> Result { if !pay || payment_failed { // CR 118.12a: "[Effect] unless any player pays ..." poll — when the // current player declines (or cannot pay) and more players remain, @@ -1727,6 +1806,46 @@ pub(super) fn resume_ward_sacrifice_payment( } } +/// CR 702.21a + CR 122.1 + CR 616.1: Resume a Ward player-counter unless-payment +/// after its `AddCounter` replacement choice settled. `payment_succeeded` comes +/// from the exact boundary the replacement pipeline resolved to +/// (`CostMoveDrainBoundary::ReplacementDelivered` = the counters were actually +/// added = paid; `ReplacementPrevented` = a replacement fully suppressed the +/// placement = failed — mirroring `PlayerCounterAdditionOutcome`'s established +/// Applied/Prevented ↔ Paid/Failed mapping for the immediate-payment case). +/// Delegates to the same `finish_unless_payment` tail every other unless-cost +/// shape uses, so the Ward-guarded ability is settled exactly once, either way, +/// instead of the game resetting to bare priority with its fate undetermined. +pub(super) fn resume_get_player_counters_unless_payment( + state: &mut GameState, + events: &mut Vec, + payment_succeeded: bool, +) -> Result { + let Some(PendingCostMoveResume::GetPlayerCountersUnlessPayment { + cost, + pending_effect, + trigger_event, + effect_description, + remaining, + }) = state.pending_cost_move_resume.take() + else { + unreachable!("GetPlayerCounters unless-payment resume requires its typed continuation") + }; + finish_unless_payment( + state, + true, + !payment_succeeded, + cost, + pending_effect, + trigger_event, + effect_description, + remaining, + None, + events, + )?; + Ok(state.waiting_for.clone()) +} + pub(super) fn handle_ward_sacrifice_choice( state: &mut GameState, waiting_for: WaitingFor, diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index 5062fb0afa..29d9230e0e 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -1004,6 +1004,7 @@ fn walk_cost(cost: &AbilityCost, out: &mut Vec) { | AbilityCost::NinjutsuFamily { .. } // CR 118.9: a borrowed keyword cost carries no nested effect/cost carrier. | AbilityCost::KeywordCostOfCastSpell { .. } + | AbilityCost::GetPlayerCounters { .. } | AbilityCost::Unimplemented { .. } => {} } } diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 5e63f39104..8b709f50e9 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1170,6 +1170,18 @@ fn replacement_cost_description(cost: &AbilityCost) -> String { // multiplier itself doesn't change the *kind* of cost the prompt // describes; the resolved scaled amount is decided in Task 6. AbilityCost::PerCounter { base, .. } => replacement_cost_description(base), + // CR 702.21a + CR 122.1 + CR 104.3d: Ward's player-counter cost. + AbilityCost::GetPlayerCounters { + counter_kind, + count, + } => { + let kind = format!("{counter_kind:?}").to_lowercase(); + if *count == 1 { + format!("Get a {kind} counter") + } else { + format!("Get {count} {kind} counters") + } + } AbilityCost::ManaDynamic { .. } | AbilityCost::Tap | AbilityCost::Untap diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index f23b57f5b7..de43fae29e 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -230,6 +230,13 @@ fn ward_cost_to_ability_cost(ward_cost: &WardCost) -> AbilityCost { } } } + WardCost::GetPlayerCounters { + counter_kind, + count, + } => AbilityCost::GetPlayerCounters { + counter_kind: *counter_kind, + count: *count, + }, } } @@ -19011,6 +19018,20 @@ pub mod tests { let waterbend = WardCost::Waterbend(ManaCost::generic(4)); let result = ward_cost_to_ability_cost(&waterbend); assert!(matches!(result, AbilityCost::Mana { cost } if cost == ManaCost::generic(4))); + + // Get player counters (The Serpent Society: "Ward—Get five poison counters.") + let poison = WardCost::GetPlayerCounters { + counter_kind: crate::types::player::PlayerCounterKind::Poison, + count: 5, + }; + let result = ward_cost_to_ability_cost(&poison); + assert!(matches!( + result, + AbilityCost::GetPlayerCounters { + counter_kind: crate::types::player::PlayerCounterKind::Poison, + count: 5, + } + )); } #[test] diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index d509076380..49a01e8f5c 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -9676,6 +9676,9 @@ fn apply_where_x_to_ability_cost( // CR 118.9: the borrowed keyword cost is read at runtime from the cast // spell's keyword — it carries no where-X `QuantityExpr` amount to bind. | AbilityCost::KeywordCostOfCastSpell { .. } + // CR 702.21a: `count` is a fixed `u32`, not a `QuantityExpr` — no + // where-X amount to bind. + | AbilityCost::GetPlayerCounters { .. } | AbilityCost::Unimplemented { .. } => {} } } diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index e6bd07bf32..cc73c8b806 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use crate::parser::oracle_nom::error::OracleError; +use crate::parser::oracle_nom::error::{OracleError, OracleResult}; use nom::branch::alt; use nom::bytes::complete::{tag, take_until}; use nom::character::complete::{alpha1, space0, space1}; @@ -700,6 +700,34 @@ fn parse_ward_cost(cost_text: &str) -> Option { Some(Keyword::Ward(cost)) } +/// CR 702.21a + CR 122.1 + CR 104.3d: "get N counter(s)" / "get a/an +/// counter" — the single grammatical authority for this ward-cost +/// family. Composes the count/article, kind, and singular/plural axes as +/// independent nom combinators (this repo's mandated style) rather than +/// enumerating their product as ad-hoc string dispatch. +fn parse_get_player_counters_ward_cost(input: &str) -> OracleResult<'_, WardCost> { + all_consuming(|i| { + let (rest, _) = tag::<_, _, OracleError<'_>>("get ").parse(i)?; + let (rest, count) = alt(( + nom_primitives::parse_number, + value(1u32, nom_primitives::parse_article), + )) + .parse(rest)?; + let (rest, _) = space0.parse(rest)?; + let (rest, counter_kind) = nom_primitives::parse_player_counter_kind.parse(rest)?; + let (rest, _) = tag(" counter").parse(rest)?; + let (rest, _) = opt(tag("s")).parse(rest)?; + Ok(( + rest, + WardCost::GetPlayerCounters { + counter_kind, + count, + }, + )) + }) + .parse(input) +} + /// Parse a single ward cost component (not compound). fn parse_ward_cost_single(lower: &str) -> Option { // CR 702.21a + CR 608.2h + CR 113.7a: Ward's life cost reads the source's @@ -750,6 +778,30 @@ fn parse_ward_cost_single(lower: &str) -> Option { return Some(WardCost::Waterbend(cost)); } + // CR 702.21a + CR 122.1 + CR 104.3d: "get N counter(s)" — a + // player-counter ward cost (The Serpent Society: "Ward—Get five poison + // counters."). MUST run before the mana-cost fallback below, which + // otherwise silently parses unrecognized cost text with no mana + // symbols/braces as a free, always-paid Ward (phase-rs/phase#6640). + // + // One grammatical authority over the count/article, kind, and + // singular/plural axes — composed nom combinators, not string-suffix + // dispatch — so this parser family has a single production to extend + // rather than ad-hoc per-branch string handling. + if tag::<_, _, OracleError<'_>>("get ").parse(lower).is_ok() { + return match parse_get_player_counters_ward_cost(lower) { + Ok((_, cost)) => Some(cost), + // CR 702.21a: recognized as counter-shaped ("get ...") but the + // count, kind, or "counter(s)" tail didn't parse in full — fail + // closed rather than falling through to the mana-cost fallback + // below, which would otherwise silently produce a free, + // always-paid Ward for unsupported/malformed counter text + // (phase-rs/phase#6640's exact bug class, for different + // malformed input). + Err(_) => None, + }; + } + // Fall back to mana cost parsing let cost = crate::database::mtgjson::parse_mtgjson_mana_cost(lower.trim()); Some(WardCost::Mana(cost)) @@ -2829,6 +2881,62 @@ mod tests { use super::*; use crate::types::ability::{AbilityCost, SacrificeCost}; use crate::types::mana::ManaCost; + use crate::types::player::PlayerCounterKind; + + #[test] + fn ward_get_poison_counters_parses_as_player_counter_cost() { + // Issue #6640 (The Serpent Society): "Ward—Get five poison counters." + // must not silently fall through to the mana-cost fallback. + let result = parse_ward_cost("Get five poison counters."); + assert_eq!( + result, + Some(Keyword::Ward(WardCost::GetPlayerCounters { + counter_kind: PlayerCounterKind::Poison, + count: 5, + })) + ); + } + + #[test] + fn ward_get_player_counters_accepts_digit_count_and_other_kinds() { + // Class-level coverage: digit form, and a non-poison counter kind. + let result = parse_ward_cost("Get 3 experience counters."); + assert_eq!( + result, + Some(Keyword::Ward(WardCost::GetPlayerCounters { + counter_kind: PlayerCounterKind::Experience, + count: 3, + })) + ); + } + + #[test] + fn ward_get_a_poison_counter_singular_defaults_to_count_one() { + let result = parse_ward_cost("Get a poison counter."); + assert_eq!( + result, + Some(Keyword::Ward(WardCost::GetPlayerCounters { + counter_kind: PlayerCounterKind::Poison, + count: 1, + })) + ); + } + + // Issue #6640 follow-up: counter-shaped "get ... counter(s)" text that + // fails to parse (malformed count, unknown kind) must fail closed + // (`None`) rather than silently falling through to the mana-cost + // fallback and becoming a free, always-paid Ward. + #[test] + fn ward_get_counters_with_unparseable_count_fails_closed() { + let result = parse_ward_cost("Get many poison counters."); + assert_eq!(result, None); + } + + #[test] + fn ward_get_counters_with_unknown_kind_fails_closed() { + let result = parse_ward_cost("Get five sprocket counters."); + assert_eq!(result, None); + } #[test] fn parse_granted_keyword_fragment_cascade() { diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 0d75048a26..6cc4da278d 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -8524,6 +8524,16 @@ pub enum AbilityCost { KeywordCostOfCastSpell { keyword: crate::types::keywords::KeywordKind, }, + /// CR 702.21a + CR 122.1 + CR 104.3d: Give the paying player counters of + /// a kind, as a Ward unless-cost (The Serpent Society's "Ward—Get five + /// poison counters."). Unlike `PayLife`/`PayEnergy`, there is no + /// affordability gate — a player may always choose to accept more + /// counters; poison's ten-or-more loss condition is a separate, + /// already-implemented SBA, not a payment-time limit. + GetPlayerCounters { + counter_kind: PlayerCounterKind, + count: u32, + }, Unimplemented { description: String, }, @@ -8621,6 +8631,7 @@ impl AbilityCost { | AbilityCost::Waterbend { .. } | AbilityCost::NinjutsuFamily { .. } | AbilityCost::KeywordCostOfCastSpell { .. } + | AbilityCost::GetPlayerCounters { .. } | AbilityCost::Unimplemented { .. } => {} } } @@ -8677,6 +8688,7 @@ impl AbilityCost { | AbilityCost::EffectCost { .. } | AbilityCost::PerCounter { .. } | AbilityCost::KeywordCostOfCastSpell { .. } + | AbilityCost::GetPlayerCounters { .. } | AbilityCost::Unimplemented { .. } => false, } } @@ -8754,6 +8766,9 @@ impl AbilityCost { AbilityCost::Mill { .. } => vec![CostCategory::Mills], AbilityCost::Exert => vec![CostCategory::Exerts], AbilityCost::Blight { .. } => vec![CostCategory::PutsCounters], + // CR 702.21a + CR 122.1: Ward's player-counter cost puts counters + // on the payer, same category as Blight's self-counter cost. + AbilityCost::GetPlayerCounters { .. } => vec![CostCategory::PutsCounters], AbilityCost::Reveal { .. } => vec![CostCategory::Reveals], AbilityCost::Behold { action, .. } => { if *action == BeholdCostAction::ExileChosen { @@ -8866,6 +8881,8 @@ impl AbilityCost { | AbilityCost::EffectCost { .. } // CR 118.9: borrowed mana cost — pays mana, never destroys the source. | AbilityCost::KeywordCostOfCastSpell { .. } + // CR 702.21a: gives the payer counters — never destroys the source. + | AbilityCost::GetPlayerCounters { .. } | AbilityCost::Unimplemented { .. } => false, } } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 5b9e7c2508..84e0894599 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -5797,6 +5797,28 @@ pub enum PendingCostMoveResume { resolved: Box, ability_index: usize, }, + /// CR 702.21a + CR 122.1 + CR 616.1: Ward's player-counter unless-cost + /// (`AbilityCost::GetPlayerCounters`) paused on a replacement choice for + /// the `AddCounter` event it attempted (e.g. an optional "you may + /// prevent a player from getting counters" replacement, or a CR 616.1 + /// ordering choice among several applicable replacements). Retains the + /// full `WaitingFor::UnlessPayment` payload so the choice's resolution — + /// Applied (paid) via the `ReplacementDelivered` boundary, or Prevented + /// (failed) via the `ReplacementPrevented` boundary — can drive the same + /// paid/failed tail `handle_unless_payment` uses for every other cost + /// shape, instead of resetting to bare priority with the Ward-guarded + /// ability's fate undetermined. + GetPlayerCountersUnlessPayment { + #[serde(deserialize_with = "crate::types::ability::deserialize_ability_cost_compat")] + cost: AbilityCost, + pending_effect: Box, + #[serde(default, skip_serializing_if = "Option::is_none")] + trigger_event: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + effect_description: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + remaining: Vec, + }, } /// CR 601.2h + CR 616.1: Resume paying a sequential cost after a replacement diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index d792ff332c..bb7ebd068f 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -543,6 +543,15 @@ pub enum WardCost { /// CR 702.21a: Compound ward cost — multiple costs that must all be paid. /// Used for "Ward—{2}, Pay 2 life" where comma-separated sub-costs are conjoined. Compound(Vec), + /// CR 702.21a + CR 122.1 + CR 104.3d: Ward cost paid by giving the paying + /// player counters of a kind (The Serpent Society: "Ward—Get five poison + /// counters."). Parameterized over `PlayerCounterKind` rather than a + /// poison-only variant so a future Ward cost of a different + /// player-counter kind reuses this shape instead of adding a sibling. + GetPlayerCounters { + counter_kind: crate::types::player::PlayerCounterKind, + count: u32, + }, } /// CR 702.54a + CR 702.54b: Bloodthirst has fixed-N and X-count forms. diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 23368ab476..9b8c10a589 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -814,6 +814,7 @@ mod seasoned_dungeoneer_initiative_room_trigger; mod selenia_vigilance_grant; mod sensei_golden_tail_5950; mod sentinel_sliver_vigilance_grant; +mod serpent_society_ward_poison_cost; mod serras_emissary_chosen_card_type_protection; mod sin_spiras_punishment_repeat; mod skullwinder_chosen_opponent; diff --git a/crates/engine/tests/integration/serpent_society_ward_poison_cost.rs b/crates/engine/tests/integration/serpent_society_ward_poison_cost.rs new file mode 100644 index 0000000000..fd4f11e83f --- /dev/null +++ b/crates/engine/tests/integration/serpent_society_ward_poison_cost.rs @@ -0,0 +1,457 @@ +//! Regression for issue #6640: The Serpent Society's Ward—Get five poison +//! counters never gave the targeting opponent poison counters, because the +//! Oracle parser had no `WardCost` variant for "give yourself N counters" and +//! silently fell back to `WardCost::Mana(generic: 0)` — a free, always-paid +//! Ward that does nothing. +//! +//! https://github.com/phase-rs/phase/issues/6640 +//! +//! CR references: +//! - CR 702.21a: Ward — counter the targeting spell/ability unless the +//! targeting player pays the stated cost. +//! - CR 122.1 + CR 104.3d: giving a player poison counters; a player with +//! ten or more poison counters loses the game (a separate SBA, not +//! exercised by this test). + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::game::zones::create_object; +use engine::types::ability::{ + QuantityModification, ReplacementDefinition, ReplacementMode, ReplacementPlayerScope, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::CardId; +use engine::types::phase::Phase; +use engine::types::player::PlayerCounterKind; +use engine::types::replacements::ReplacementEvent; +use engine::types::zones::Zone; +use std::sync::Arc; + +const SERPENT_SOCIETY: &str = "Deathtouch\n\ +Ward—Get five poison counters. (A player with ten or more poison counters loses the game.)\n\ +Whenever another creature you control with deathtouch dies, each opponent sacrifices a nontoken creature of their choice."; + +#[test] +fn serpent_society_ward_prompts_the_targeting_opponent_for_poison_counters() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let serpent_society = scenario + .add_creature_from_oracle(P0, "The Serpent Society", 3, 4, SERPENT_SOCIETY) + .id(); + let destroy = scenario + .add_spell_to_hand_from_oracle(P1, "Destroy Spell", true, "Destroy target creature.") + .id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + } + + runner + .cast(destroy) + .target_objects(&[serpent_society]) + .commit(); + runner.advance_until_stack_empty(); + + let WaitingFor::UnlessPayment { player, cost, .. } = &runner.state().waiting_for else { + panic!( + "Ward must prompt the targeting opponent to pay the poison-counter cost, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!(*player, P1); + assert!(matches!( + cost, + engine::types::ability::AbilityCost::GetPlayerCounters { + counter_kind: PlayerCounterKind::Poison, + count: 5, + } + )); +} + +#[test] +fn serpent_society_ward_declined_counters_the_spell_and_gives_no_poison() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let serpent_society = scenario + .add_creature_from_oracle(P0, "The Serpent Society", 3, 4, SERPENT_SOCIETY) + .id(); + let destroy = scenario + .add_spell_to_hand_from_oracle(P1, "Destroy Spell", true, "Destroy target creature.") + .id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + } + + runner + .cast(destroy) + .target_objects(&[serpent_society]) + .commit(); + runner.advance_until_stack_empty(); + + runner + .act(GameAction::PayUnlessCost { pay: false }) + .expect("declining Ward must be a legal action"); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().players[P1.0 as usize].poison_counters, + 0, + "declining Ward's cost must not give the opponent any poison counters" + ); + assert!( + runner + .state() + .objects + .get(&serpent_society) + .is_some_and(|obj| obj.zone == engine::types::zones::Zone::Battlefield), + "declining Ward's cost must counter the targeting spell, leaving Serpent Society alive" + ); + assert!( + !runner.state().stack.iter().any(|entry| entry.id == destroy), + "the countered spell must be removed from the stack" + ); +} + +#[test] +fn serpent_society_ward_paid_gives_five_poison_and_the_spell_resolves() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let serpent_society = scenario + .add_creature_from_oracle(P0, "The Serpent Society", 3, 4, SERPENT_SOCIETY) + .id(); + let destroy = scenario + .add_spell_to_hand_from_oracle(P1, "Destroy Spell", true, "Destroy target creature.") + .id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + } + + runner + .cast(destroy) + .target_objects(&[serpent_society]) + .commit(); + runner.advance_until_stack_empty(); + + runner + .act(GameAction::PayUnlessCost { pay: true }) + .expect("the opponent pays Ward's poison-counter cost"); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().players[P1.0 as usize].poison_counters, + 5, + "paying Ward's cost must give the targeting opponent five poison counters" + ); + assert!( + runner + .state() + .objects + .get(&serpent_society) + .is_none_or(|obj| obj.zone != engine::types::zones::Zone::Battlefield), + "paying Ward's cost must let the targeted destroy spell resolve, removing Serpent Society from the battlefield" + ); +} + +/// CR 104.3d + CR 704.5c: a payment that pushes the payer to ten or more +/// poison counters must trigger the loss state-based action immediately — +/// before the targeted destroy spell gets a chance to continue resolving. +/// Mirrors `crates/engine/src/game/sba.rs`'s own `sba_poison_10_player_loses` +/// unit test's expected shape. +#[test] +fn serpent_society_ward_payment_that_reaches_ten_poison_loses_the_game() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let serpent_society = scenario + .add_creature_from_oracle(P0, "The Serpent Society", 3, 4, SERPENT_SOCIETY) + .id(); + let destroy = scenario + .add_spell_to_hand_from_oracle(P1, "Destroy Spell", true, "Destroy target creature.") + .id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + state.players[P1.0 as usize].poison_counters = 5; + } + + runner + .cast(destroy) + .target_objects(&[serpent_society]) + .commit(); + runner.advance_until_stack_empty(); + + runner + .act(GameAction::PayUnlessCost { pay: true }) + .expect("the opponent pays Ward's poison-counter cost"); + + assert_eq!( + runner.state().players[P1.0 as usize].poison_counters, + 10, + "5 existing + 5 from Ward's cost must reach the ten-poison threshold" + ); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::GameOver { winner: Some(p) } if p == P0 + ), + "reaching ten poison must trigger the CR 104.3d loss SBA immediately, got {:?}", + runner.state().waiting_for + ); + assert!( + runner + .state() + .objects + .get(&serpent_society) + .is_some_and(|obj| obj.zone == engine::types::zones::Zone::Battlefield), + "the game must end (P1 loses) before the destroy spell gets a chance to resolve, so Serpent Society must still be on the battlefield" + ); +} + +/// CR 122.1 + CR 614.17 + CR 702.21a: Solemnity's "Players can't get +/// counters" replacement must make Ward's poison-counter cost a FAILED +/// payment, not a free bypass. Before this fix, `add_player_counter_with_ +/// replacement` reported `Prevented` as if it were a paid cost, so the +/// targeting opponent's spell would incorrectly continue resolving even +/// though no poison was actually given — nullifying Ward's entire deterrent +/// for free. Solemnity's real Oracle text is "Players can't get counters. +/// Prevent all damage that would be dealt to permanents by sources with +/// counters on them." — only the first (relevant) sentence is used here. +#[test] +fn serpent_society_ward_payment_prevented_by_solemnity_counters_the_spell() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario + .add_creature_from_oracle(P0, "Solemnity", 0, 0, "Players can't get counters.") + .as_enchantment(); + let serpent_society = scenario + .add_creature_from_oracle(P0, "The Serpent Society", 3, 4, SERPENT_SOCIETY) + .id(); + let destroy = scenario + .add_spell_to_hand_from_oracle(P1, "Destroy Spell", true, "Destroy target creature.") + .id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + } + + runner + .cast(destroy) + .target_objects(&[serpent_society]) + .commit(); + runner.advance_until_stack_empty(); + + runner + .act(GameAction::PayUnlessCost { pay: true }) + .expect("attempting to pay Ward's poison-counter cost must be a legal action even when Solemnity prevents the actual counter gain"); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().players[P1.0 as usize].poison_counters, + 0, + "Solemnity must prevent the poison counters from actually being given" + ); + assert!( + runner + .state() + .objects + .get(&serpent_society) + .is_some_and(|obj| obj.zone == engine::types::zones::Zone::Battlefield), + "a prevented player-counter payment must be treated as a FAILED cost, countering the targeting spell exactly like a declined payment — Serpent Society must survive" + ); + assert!( + !runner.state().stack.iter().any(|entry| entry.id == destroy), + "the countered spell must be removed from the stack" + ); +} + +/// Installs a synthetic OPTIONAL "you may prevent a player from getting +/// counters" replacement on a fresh P0 permanent. No real card has exactly +/// this wording, so — mirroring this file's own Solemnity test (which uses a +/// real, if partial, MANDATORY prevention) and the engine's established +/// pattern for exercising an optional replacement choice with no real-card +/// precedent — the definition is installed directly, after `scenario.build()`, +/// so the real Ward -> `GetPlayerCounters` -> `add_player_counter_with_ +/// replacement` -> `replace_event` path discovers it naturally (a production +/// setup, not a hand-constructed `WaitingFor`). +fn install_optional_player_counter_prevention(state: &mut engine::types::game_state::GameState) { + let source = create_object( + state, + CardId(9101), + P0, + "Optional Poison Warden".to_string(), + Zone::Battlefield, + ); + let mut def = ReplacementDefinition::new(ReplacementEvent::AddCounter); + def.mode = ReplacementMode::Optional { decline: None }; + def.quantity_modification = Some(QuantityModification::Prevent); + def.valid_player = Some(ReplacementPlayerScope::AnyPlayer); + let reps = vec![def]; + let obj = state.objects.get_mut(&source).unwrap(); + obj.replacement_definitions = reps.clone().into(); + obj.base_replacement_definitions = Arc::new(reps); +} + +/// Regression for reviewer matthewevans's finding on PR #6662: a Ward +/// player-counter cost whose `AddCounter` event needs a CR 616.1 replacement +/// choice (as opposed to Solemnity's unconditional, mandatory prevention +/// above) must not orphan the unless-payment continuation. Before this fix, +/// `add_player_counter_with_replacement`'s `NeedsChoice` arm replaced +/// `waiting_for` with the bare `ReplacementChoice` prompt and nothing +/// preserved `pending_effect`/`trigger_event` — once the player answered the +/// prompt, `handle_replacement_choice` applied (or failed to apply) the +/// counters and reset straight to `WaitingFor::Priority`, leaving Ward's +/// guarded "counter the spell" outcome permanently undetermined: the +/// targeting spell was neither countered nor allowed to resolve. +/// +/// Accept branch: the optional replacement prevents the counter placement +/// (`PlayerCounterAdditionOutcome::Prevented`) — a FAILED Ward payment, so the +/// targeting spell must be countered, exactly like the Solemnity test above. +#[test] +fn serpent_society_ward_optional_counter_prevention_accepted_counters_the_spell() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let serpent_society = scenario + .add_creature_from_oracle(P0, "The Serpent Society", 3, 4, SERPENT_SOCIETY) + .id(); + let destroy = scenario + .add_spell_to_hand_from_oracle(P1, "Destroy Spell", true, "Destroy target creature.") + .id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + install_optional_player_counter_prevention(state); + } + + runner + .cast(destroy) + .target_objects(&[serpent_society]) + .commit(); + runner.advance_until_stack_empty(); + + runner + .act(GameAction::PayUnlessCost { pay: true }) + .expect("attempting to pay Ward's poison-counter cost must be legal even when an optional replacement can prevent it"); + + // Reaching a REPLACEMENT CHOICE (not an orphaned bare Priority) is the + // regression's core assertion. + let WaitingFor::ReplacementChoice { + player, + candidate_count, + .. + } = runner.state().waiting_for + else { + panic!( + "optional player-counter prevention must surface a real replacement choice, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!( + player, P1, + "the payer (Ward's targeting opponent) makes the replacement choice" + ); + assert_eq!( + candidate_count, 2, + "an Optional replacement offers accept (0) and decline (1)" + ); + + runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("accepting the optional prevention must be a legal replacement choice"); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().players[P1.0 as usize].poison_counters, + 0, + "the accepted prevention must stop the poison counters from being given" + ); + assert!( + runner + .state() + .objects + .get(&serpent_society) + .is_some_and(|obj| obj.zone == Zone::Battlefield), + "a prevented player-counter payment must be treated as a FAILED cost, countering the targeting spell — Serpent Society must survive" + ); + assert!( + !runner.state().stack.iter().any(|entry| entry.id == destroy), + "the countered spell must be removed from the stack, not left stranded" + ); +} + +/// Decline branch: the optional replacement does not apply, so the original +/// `AddCounter` proceeds unmodified (`PlayerCounterAdditionOutcome::Applied`) +/// — a PAID Ward payment, so the targeting spell must resolve normally. +#[test] +fn serpent_society_ward_optional_counter_prevention_declined_pays_the_cost_and_resolves_the_spell() +{ + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let serpent_society = scenario + .add_creature_from_oracle(P0, "The Serpent Society", 3, 4, SERPENT_SOCIETY) + .id(); + let destroy = scenario + .add_spell_to_hand_from_oracle(P1, "Destroy Spell", true, "Destroy target creature.") + .id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + install_optional_player_counter_prevention(state); + } + + runner + .cast(destroy) + .target_objects(&[serpent_society]) + .commit(); + runner.advance_until_stack_empty(); + + runner + .act(GameAction::PayUnlessCost { pay: true }) + .expect("attempting to pay must be legal"); + let WaitingFor::ReplacementChoice { .. } = runner.state().waiting_for else { + panic!( + "expected a replacement choice, got {:?}", + runner.state().waiting_for + ); + }; + + runner + .act(GameAction::ChooseReplacement { index: 1 }) + .expect("declining the optional prevention must be a legal replacement choice"); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().players[P1.0 as usize].poison_counters, + 5, + "declining the optional prevention must let Ward's cost actually give five poison counters" + ); + assert!( + runner + .state() + .objects + .get(&serpent_society) + .is_none_or(|obj| obj.zone != Zone::Battlefield), + "a successfully paid Ward cost must let the targeted destroy spell resolve" + ); +} diff --git a/crates/mtgish-import/src/convert/action.rs b/crates/mtgish-import/src/convert/action.rs index 4cf37add20..39170b017e 100644 --- a/crates/mtgish-import/src/convert/action.rs +++ b/crates/mtgish-import/src/convert/action.rs @@ -403,6 +403,9 @@ fn rewrite_bound_x_in_ability_cost(cost: &mut AbilityCost, binding: &QuantityExp // CR 118.9: the borrowed keyword cost is read at runtime from the cast // spell's keyword — it carries no X-bound `QuantityExpr` to rewrite. | AbilityCost::KeywordCostOfCastSpell { .. } + // CR 702.21a: `count` is a fixed `u32`, not a `QuantityExpr` — no + // X-bound amount to rewrite. + | AbilityCost::GetPlayerCounters { .. } | AbilityCost::Unimplemented { .. } => 0, } } diff --git a/crates/phase-ai/src/policies/anti_self_harm.rs b/crates/phase-ai/src/policies/anti_self_harm.rs index 15b8cc17e0..6e93778751 100644 --- a/crates/phase-ai/src/policies/anti_self_harm.rs +++ b/crates/phase-ai/src/policies/anti_self_harm.rs @@ -772,48 +772,6 @@ fn score_target_object(ctx: &PolicyContext<'_>, object_id: ObjectId, beneficial: } } - // Price the cost of an *affordable* ward (must pay an extra cost). - // An unaffordable ward is hard-rejected upstream by `tactical_gate` - // (CR 702.21a — the spell would just be countered), so this judgment - // layer never double-scores that case. - for keyword in &object.keywords { - if let Keyword::Ward(ward_cost) = keyword { - if !can_pay_ward_cost(ctx, ward_cost, object) { - break; - } - let severity = match ward_cost { - WardCost::Mana(cost) => (cost.mana_value() as f64 / 2.0).min(2.0), - WardCost::PayLife(amount) => (*amount as f64 / 3.0).min(2.0), - WardCost::PayLifeEqualToPower => { - (object.power.unwrap_or(0).max(0) as f64 / 3.0).min(2.0) - } - WardCost::DiscardCard => 1.5, - WardCost::Sacrifice { count, .. } => *count as f64 * 2.0, - WardCost::Waterbend(cost) => (cost.mana_value() as f64 / 2.0).min(2.0), - // CR 702.21a: Compound costs sum severity of components. - WardCost::Compound(costs) => costs - .iter() - .map(|c| match c { - WardCost::Mana(cost) => (cost.mana_value() as f64 / 2.0).min(2.0), - WardCost::PayLife(amount) => (*amount as f64 / 3.0).min(2.0), - WardCost::PayLifeEqualToPower => { - (object.power.unwrap_or(0).max(0) as f64 / 3.0).min(2.0) - } - WardCost::DiscardCard => 1.5, - WardCost::Sacrifice { count, .. } => *count as f64 * 2.0, - WardCost::Waterbend(cost) => { - (cost.mana_value() as f64 / 2.0).min(2.0) - } - WardCost::Compound(_) => 2.0, - }) - .sum::() - .min(4.0), - }; - score += ctx.penalties().ward_cost_penalty_base * severity; - break; - } - } - // Removal quality mismatch: penalize premium removal on cheap targets if let Some(source) = ctx.source_object() { let spell_mv = source.mana_cost.mana_value(); @@ -884,6 +842,102 @@ fn score_target_object(ctx: &PolicyContext<'_>, object_id: ObjectId, beneficial: score += controller_delta * noncreature_value; } + // Price the cost of an *affordable* ward (must pay an extra cost). Applies to every + // permanent type, not just creatures — a Ward-bearing artifact/enchantment/planeswalker + // is exactly as real a target-choice cost as a Ward-bearing creature. An unaffordable ward + // is hard-rejected upstream by `tactical_gate` (CR 702.21a — the spell would just be + // countered), so this judgment layer never double-scores that case. + if !beneficial { + for keyword in &object.keywords { + if let Keyword::Ward(ward_cost) = keyword { + if !can_pay_ward_cost(ctx, ward_cost, object) { + break; + } + let severity = match ward_cost { + WardCost::Mana(cost) => (cost.mana_value() as f64 / 2.0).min(2.0), + WardCost::PayLife(amount) => (*amount as f64 / 3.0).min(2.0), + WardCost::PayLifeEqualToPower => { + (object.power.unwrap_or(0).max(0) as f64 / 3.0).min(2.0) + } + WardCost::DiscardCard => 1.5, + WardCost::Sacrifice { count, .. } => *count as f64 * 2.0, + WardCost::Waterbend(cost) => (cost.mana_value() as f64 / 2.0).min(2.0), + // CR 702.21a + CR 122.1 + CR 728.1: giving yourself + // player counters has an explicit, kind-specific + // valuation — no wildcard fallback, so a future + // supported counter kind forces a deliberate + // decision here. A lethal poison payment never + // reaches this scoring at all — `can_pay_ward_cost` + // above already rejects it (reframed as "can't + // rationally pay"), so the Poison arm only ever sees + // sub-lethal, ordinary-severity payments. + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count, + } => *count as f64 * 3.0, + // CR 728.1: each rad counter mills a card and, if that + // card is nonland, costs 1 life. Real cost, not + // harmless — approximated as PayLife's per-life + // severity (amount/3.0) scaled by ~0.6 (typical + // nonland fraction of a deck), i.e. ~0.2 per counter. + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Rad, + count, + } => (*count as f64 * 0.2).min(2.0), + // Experience/ticket counters carry no loss-condition + // or resource-drain risk — purely beneficial or + // neutral to receive. + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Experience, + .. + } => 0.0, + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Ticket, + .. + } => 0.0, + // CR 702.21a: Compound costs sum severity of components. + // A Compound containing a lethal poison sub-cost is + // also already rejected upstream by `can_pay_ward_cost` + // (which requires every sub-cost payable), so this + // fold only ever sees payable compounds. + WardCost::Compound(costs) => costs + .iter() + .map(|c| match c { + WardCost::Mana(cost) => (cost.mana_value() as f64 / 2.0).min(2.0), + WardCost::PayLife(amount) => (*amount as f64 / 3.0).min(2.0), + WardCost::PayLifeEqualToPower => { + (object.power.unwrap_or(0).max(0) as f64 / 3.0).min(2.0) + } + WardCost::DiscardCard => 1.5, + WardCost::Sacrifice { count, .. } => *count as f64 * 2.0, + WardCost::Waterbend(cost) => (cost.mana_value() as f64 / 2.0).min(2.0), + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count, + } => *count as f64 * 3.0, + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Rad, + count, + } => (*count as f64 * 0.2).min(2.0), + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Experience, + .. + } => 0.0, + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Ticket, + .. + } => 0.0, + WardCost::Compound(_) => 2.0, + }) + .sum::() + .min(4.0), + }; + score += ctx.penalties().ward_cost_penalty_base * severity; + break; + } + } + } + score } @@ -3390,6 +3444,156 @@ mod tests { ); } + #[test] + fn noncreature_ward_target_scores_lower_than_unwarded_equivalent() { + let mut state = make_state(); + + // Two identical artifacts (non-creature): one bare, one with a small, payable, + // nonlethal poison-counter Ward. Both owned by the opponent (PlayerId(1)) so removal + // targeting them is non-beneficial from the AI's (PlayerId(0)) perspective. + let bare_card_id = CardId(state.next_object_id); + let bare_artifact = create_object( + &mut state, + bare_card_id, + PlayerId(1), + "Prophetic Prism".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&bare_artifact) + .unwrap() + .card_types + .core_types + .push(engine::types::card_type::CoreType::Artifact); + + let warded_card_id = CardId(state.next_object_id); + let warded_artifact = create_object( + &mut state, + warded_card_id, + PlayerId(1), + "Warded Relic".to_string(), + Zone::Battlefield, + ); + let warded_obj = state.objects.get_mut(&warded_artifact).unwrap(); + warded_obj + .card_types + .core_types + .push(engine::types::card_type::CoreType::Artifact); + warded_obj + .keywords + .push(Keyword::Ward(WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count: 2, + })); + + // Set up pending trigger with a removal (exile) effect, matching + // `trigger_target_prefers_creature_over_token` above. + state.pending_trigger = Some(Box::new(engine::game::triggers::PendingTrigger { + source_id: ObjectId(200), + controller: PlayerId(0), + condition: None, + ability: Box::new(ResolvedAbility::new( + Effect::ChangeZone { + origin: None, + destination: Zone::Exile, + target: TargetFilter::Any, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: engine::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + Vec::new(), + ObjectId(200), + PlayerId(0), + )), + timestamp: 1, + target_constraints: Vec::new(), + distribute: None, + trigger_event: None, + modal: None, + mode_abilities: vec![], + description: None, + may_trigger_origin: None, + subject_match_count: None, + die_result: None, + })); + + let config = AiConfig::default(); + let legal_targets = vec![ + TargetRef::Object(bare_artifact), + TargetRef::Object(warded_artifact), + ]; + let decision = AiDecisionContext { + waiting_for: WaitingFor::TriggerTargetSelection { + player: PlayerId(0), + trigger_controller: None, + trigger_event: None, + trigger_events: Vec::new(), + target_slots: vec![TargetSelectionSlot { + legal_targets: legal_targets.clone(), + optional: false, + chooser: None, + }], + mode_labels: Vec::new(), + target_constraints: Vec::new(), + selection: Default::default(), + source_id: Some(ObjectId(200)), + description: None, + }, + candidates: Vec::new(), + }; + + // Score targeting the bare artifact + let bare_candidate = CandidateAction { + action: GameAction::ChooseTarget { + target: Some(TargetRef::Object(bare_artifact)), + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Target), + }; + let bare_ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &bare_candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + let bare_score = AntiSelfHarmPolicy.score(&bare_ctx); + + // Score targeting the warded artifact + let warded_candidate = CandidateAction { + action: GameAction::ChooseTarget { + target: Some(TargetRef::Object(warded_artifact)), + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Target), + }; + let warded_ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &warded_candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + let warded_score = AntiSelfHarmPolicy.score(&warded_ctx); + + assert!( + bare_score > warded_score, + "Should prefer targeting the unwarded artifact ({bare_score}) over the poison-Ward artifact ({warded_score})" + ); + } + #[test] fn trigger_target_effects_are_extracted() { let mut state = make_state(); diff --git a/crates/phase-ai/src/policies/strategy_helpers.rs b/crates/phase-ai/src/policies/strategy_helpers.rs index a8140ab4a4..c53b5cefdd 100644 --- a/crates/phase-ai/src/policies/strategy_helpers.rs +++ b/crates/phase-ai/src/policies/strategy_helpers.rs @@ -287,6 +287,62 @@ pub(crate) fn available_mana_after_spell(ctx: &PolicyContext<'_>) -> u32 { sources.saturating_sub(spell_cost) } +/// CR 104.3d: total poison counters `ward` would ACTUALLY give the payer, +/// summed across every `GetPlayerCounters { Poison, .. }` sub-cost in the +/// whole tree — a `Compound` cost's sub-costs are all paid together (CR +/// 702.21a: "every conjoined sub-cost must be payable"), not independently, +/// so two individually-nonlethal poison sub-costs can be jointly lethal and +/// must be checked against their COMBINED total, not each against the same +/// unchanged starting count. +/// +/// Projects each sub-cost through `preview_player_counter_addition` — the +/// real replacement pipeline, side-effect-free — rather than trusting the +/// printed count: a doubler or +N effect on the payer can make the printed +/// count understate what actually happens, letting a lethal payment look +/// safe. Returns `None` when any sub-cost's replacement outcome can't be +/// cleanly projected (`ChoiceRequired`/`Unsupported`) — callers must treat +/// `None` as "can't prove this is safe", never as zero poison. +fn total_poison_from_ward_cost(ctx: &PolicyContext<'_>, ward: &WardCost) -> Option { + match ward { + WardCost::GetPlayerCounters { + counter_kind: kind @ engine::types::player::PlayerCounterKind::Poison, + count, + } => { + match engine::game::effects::player_counter::preview_player_counter_addition( + ctx.state, + ctx.ai_player, + ctx.ai_player, + *kind, + *count, + ) { + engine::game::effects::player_counter::PlayerCounterAdditionPreview::Applied { + count, + } + | engine::game::effects::player_counter::PlayerCounterAdditionPreview::Transformed { + count, + } => Some(count), + // For poison-quantity projection only, prevention contributes zero counters. + // This does not mean the Ward cost is payable: `can_pay_ward_cost` separately + // rejects Prevented, ChoiceRequired, and Unsupported previews because the + // engine cannot successfully complete or safely project that payment. + engine::game::effects::player_counter::PlayerCounterAdditionPreview::Prevented => { + Some(0) + } + engine::game::effects::player_counter::PlayerCounterAdditionPreview::ChoiceRequired { + .. + } + | engine::game::effects::player_counter::PlayerCounterAdditionPreview::Unsupported => { + None + } + } + } + WardCost::Compound(costs) => costs.iter().try_fold(0u32, |total, cost| { + Some(total.saturating_add(total_poison_from_ward_cost(ctx, cost)?)) + }), + _ => Some(0), + } +} + /// CR 702.21a: Whether the AI can pay `ward` after committing to the spell it is /// casting. Mana / Waterbend costs use the post-spell mana estimate; non-mana /// costs check the corresponding resource (life, a spare card, sacrificeable @@ -297,6 +353,25 @@ pub(crate) fn can_pay_ward_cost( ward: &WardCost, warded: &GameObject, ) -> bool { + // CR 104.3d: reject up front if the AGGREGATE poison this cost would + // ACTUALLY give (direct or across every Compound sub-cost, replacement- + // adjusted) reaches or crosses `LETHAL_POISON` — checked once, against + // the combined total, before any per-variant mechanical-affordability + // logic below. `None` means the replacement outcome couldn't be cleanly + // projected (a live choice or an unmodeled event rewrite) — conservatively + // decline rather than assume it's safe. The AI must never treat ending + // its own game as an ordinary payable cost, for direct or compound Ward + // alike. + match total_poison_from_ward_cost(ctx, ward) { + None => return false, + Some(total_poison) if total_poison > 0 => { + let current = ctx.state.players[ctx.ai_player.0 as usize].poison_counters; + if current.saturating_add(total_poison) >= crate::features::poison::LETHAL_POISON { + return false; + } + } + Some(_) => {} + } match ward { WardCost::Mana(cost) | WardCost::Waterbend(cost) => { available_mana_after_spell(ctx) >= cost.mana_value() @@ -334,6 +409,28 @@ pub(crate) fn can_pay_ward_cost( .count(); matching as u32 >= *count } + // CR 702.21a + CR 122.1 + CR 104.3d: mechanically payable in the ordinary + // case — no resource limit on giving yourself more counters — UNLESS a + // replacement effect actually prevents the addition (Solemnity) or its + // outcome can't be cleanly projected (a live choice or an unmodeled event + // rewrite). `costs.rs`'s `AbilityCost::GetPlayerCounters` payment path + // treats `Prevented` as a genuinely FAILED payment, not a paused or + // zero-cost one — so the AI must decline here too, or it will target into + // Solemnity, believe the Ward is safely payable, and have its spell + // countered when payment actually fails. Lethal (but payable) poison is + // already rejected by the aggregate check above, for direct and compound + // costs alike. + WardCost::GetPlayerCounters { counter_kind, count } => matches!( + engine::game::effects::player_counter::preview_player_counter_addition( + ctx.state, + ctx.ai_player, + ctx.ai_player, + *counter_kind, + *count, + ), + engine::game::effects::player_counter::PlayerCounterAdditionPreview::Applied { .. } + | engine::game::effects::player_counter::PlayerCounterAdditionPreview::Transformed { .. } + ), // CR 702.21a: every conjoined sub-cost must be payable. Mana contention // between multiple mana sub-costs is approximated (each checked against // the full post-spell pool) — rare enough not to warrant exact tracking. diff --git a/crates/phase-ai/src/tactical_gate.rs b/crates/phase-ai/src/tactical_gate.rs index 1771825f7e..a0a0fedae5 100644 --- a/crates/phase-ai/src/tactical_gate.rs +++ b/crates/phase-ai/src/tactical_gate.rs @@ -684,6 +684,9 @@ mod tests { use engine::game::combat::{AttackerInfo, CombatState}; use engine::game::scenario::{GameScenario, P0, P1}; use engine::types::ability::{BounceSelection, ResolvedAbility, TargetFilter}; + use engine::types::ability::{ + QuantityModification, ReplacementDefinition, ReplacementPlayerScope, + }; use engine::types::game_state::{ PendingCast, StackEntry, StackEntryKind, TargetSelectionProgress, TargetSelectionSlot, WaitingFor, @@ -691,6 +694,7 @@ mod tests { use engine::types::identifiers::CardId; use engine::types::keywords::WardCost; use engine::types::mana::ManaCost; + use engine::types::replacements::ReplacementEvent; #[test] fn rejects_pump_after_combat_without_live_threat() { @@ -1130,4 +1134,281 @@ mod tests { assert_ne!(gate_for(8, 7), GateDecision::Reject); assert_ne!(gate_for(4, 3), GateDecision::Reject); } + + /// CR 702.21a + CR 104.3d: a Ward payment that would push the AI to ten + /// or more poison counters must reject the target — the AI must never + /// treat ending its own game as an ordinary payable ward cost. + #[test] + fn rejects_targeting_ward_that_would_be_lethal_poison() { + let mut scenario = GameScenario::new(); + let creature = scenario + .add_creature(P1, "Warded", 2, 2) + .with_keyword(Keyword::Ward(WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count: 5, + })) + .id(); + let mut runner = scenario.build(); + let state = runner.state_mut(); + state.players[P0.0 as usize].poison_counters = 5; + let decision = damage_target_decision(creature, 3); + let candidate = choose_target_candidate(creature); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &candidate, + ai_player: P0, + config: &config, + context: &AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&ctx), GateDecision::Reject); + } + + /// A poison Ward payment that stays below the ten-poison threshold does + /// not gate the target out — mirrors `allows_targeting_payable_ward`. + #[test] + fn allows_targeting_ward_with_nonlethal_poison_payment() { + let mut scenario = GameScenario::new(); + let creature = scenario + .add_creature(P1, "Warded", 2, 2) + .with_keyword(Keyword::Ward(WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count: 5, + })) + .id(); + let mut runner = scenario.build(); + let state = runner.state_mut(); + // P0 starts at 0 poison — 0 + 5 = 5, well below the 10-poison SBA. + let decision = damage_target_decision(creature, 3); + let candidate = choose_target_candidate(creature); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &candidate, + ai_player: P0, + config: &config, + context: &AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_ne!(assess_candidate(&ctx), GateDecision::Reject); + } + + /// CR 702.21a + CR 104.3d: two individually-nonlethal poison sub-costs in + /// a `Compound` Ward can be jointly lethal — the aggregate across every + /// sub-cost must be checked, not each sub-cost against the same + /// unchanged starting total. + #[test] + fn rejects_targeting_ward_with_jointly_lethal_compound_poison() { + let mut scenario = GameScenario::new(); + let creature = scenario + .add_creature(P1, "Warded", 2, 2) + .with_keyword(Keyword::Ward(WardCost::Compound(vec![ + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count: 3, + }, + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count: 3, + }, + ]))) + .id(); + let mut runner = scenario.build(); + let state = runner.state_mut(); + // 4 existing + 3 + 3 = 10 (lethal), but 4 + 3 = 7 alone is not — a + // per-sub-cost check against the same starting total would wrongly + // allow this. + state.players[P0.0 as usize].poison_counters = 4; + let decision = damage_target_decision(creature, 3); + let candidate = choose_target_candidate(creature); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &candidate, + ai_player: P0, + config: &config, + context: &AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&ctx), GateDecision::Reject); + } + + /// CR 702.21a: the ward-affordability gate applies to any targetable + /// permanent, not just creatures — a lethal poison Ward on a noncreature + /// permanent must be rejected identically. + #[test] + fn rejects_targeting_noncreature_ward_that_would_be_lethal_poison() { + let mut scenario = GameScenario::new(); + let artifact = scenario + .add_creature(P1, "Warded Artifact", 0, 0) + .as_artifact() + .with_keyword(Keyword::Ward(WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count: 5, + })) + .id(); + let mut runner = scenario.build(); + let state = runner.state_mut(); + state.players[P0.0 as usize].poison_counters = 5; + let decision = damage_target_decision(artifact, 3); + let candidate = choose_target_candidate(artifact); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &candidate, + ai_player: P0, + config: &config, + context: &AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&ctx), GateDecision::Reject); + } + + /// CR 104.3d + CR 614.1a: a doubler on the poison counters the AI itself + /// would receive can make an individually-nonlethal PRINTED count + /// actually lethal once replacement-adjusted. The AI must project the + /// real, replacement-adjusted result (`preview_player_counter_addition`) + /// rather than trusting the printed count — the naive printed-count math + /// (4 existing + 3 printed = 7) would wrongly call this safe, but the + /// doubled result (4 + 6 = 10) is lethal. + #[test] + fn rejects_targeting_ward_with_lethal_poison_after_doubling_replacement() { + let mut scenario = GameScenario::new(); + // A permanent the AI (P0) controls that doubles poison counters P0 + // would receive. `valid_player: Some(You)` + the default recipient + // scope means this applies whenever P0 is the one gaining counters, + // mirroring how `player_counter.rs`'s own Solemnity test constructs a + // global player-counter replacement, parameterized to double instead + // of prevent. + let doubler_id = scenario.add_creature(P0, "Poison Doubler", 0, 0).id(); + let mut doubler_def = ReplacementDefinition::new(ReplacementEvent::AddCounter) + .quantity_modification(QuantityModification::DOUBLE); + doubler_def.valid_player = Some(ReplacementPlayerScope::You); + let creature = scenario + .add_creature(P1, "Warded", 2, 2) + .with_keyword(Keyword::Ward(WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count: 3, + })) + .id(); + let mut runner = scenario.build(); + let state = runner.state_mut(); + state + .objects + .get_mut(&doubler_id) + .unwrap() + .replacement_definitions = vec![doubler_def].into(); + state.players[P0.0 as usize].poison_counters = 4; + let decision = damage_target_decision(creature, 3); + let candidate = choose_target_candidate(creature); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &candidate, + ai_player: P0, + config: &config, + context: &AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&ctx), GateDecision::Reject); + } + + /// CR 702.21a: a "players can't get counters" replacement (Solemnity) means + /// the AI's Ward payment will actually FAIL — `costs.rs`'s + /// `AbilityCost::GetPlayerCounters` treats `Prevented` as a failed payment, + /// not a zero-cost one — so the AI must not target into this believing the + /// Ward is safely (and freely) payable. + #[test] + fn rejects_targeting_ward_with_prevented_player_counter_payment() { + let mut scenario = GameScenario::new(); + let solemnity_id = scenario.add_creature(P0, "Solemnity", 0, 0).id(); + let mut prevent_def = ReplacementDefinition::new(ReplacementEvent::AddCounter) + .quantity_modification(QuantityModification::Prevent); + prevent_def.valid_player = Some(ReplacementPlayerScope::AnyPlayer); + let creature = scenario + .add_creature(P1, "Warded", 2, 2) + .with_keyword(Keyword::Ward(WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count: 3, + })) + .id(); + let mut runner = scenario.build(); + let state = runner.state_mut(); + state + .objects + .get_mut(&solemnity_id) + .unwrap() + .replacement_definitions = vec![prevent_def].into(); + let decision = damage_target_decision(creature, 3); + let candidate = choose_target_candidate(creature); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &candidate, + ai_player: P0, + config: &config, + context: &AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&ctx), GateDecision::Reject); + } + + /// CR 702.21a: a `Compound` Ward's sub-costs are conjoined — ALL must be + /// payable, so a prevented `GetPlayerCounters` sub-cost must reject the + /// whole cost even when its sibling sub-cost (here, a small life payment) + /// is perfectly payable on its own. Proves the recursion through + /// `Compound`'s `.all(|cost| can_pay_ward_cost(...))`, not just the direct + /// leaf case covered by `rejects_targeting_ward_with_prevented_player_counter_payment`. + #[test] + fn rejects_compound_ward_with_prevented_player_counter_leaf() { + let mut scenario = GameScenario::new(); + let solemnity_id = scenario.add_creature(P0, "Solemnity", 0, 0).id(); + let mut prevent_def = ReplacementDefinition::new(ReplacementEvent::AddCounter) + .quantity_modification(QuantityModification::Prevent); + prevent_def.valid_player = Some(ReplacementPlayerScope::AnyPlayer); + let creature = scenario + .add_creature(P1, "Warded", 2, 2) + .with_keyword(Keyword::Ward(WardCost::Compound(vec![ + WardCost::PayLife(2), // trivially payable on its own (P0 starts at 20 life) + WardCost::GetPlayerCounters { + counter_kind: engine::types::player::PlayerCounterKind::Poison, + count: 3, + }, + ]))) + .id(); + let mut runner = scenario.build(); + let state = runner.state_mut(); + state + .objects + .get_mut(&solemnity_id) + .unwrap() + .replacement_definitions = vec![prevent_def].into(); + let decision = damage_target_decision(creature, 3); + let candidate = choose_target_candidate(creature); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &candidate, + ai_player: P0, + config: &config, + context: &AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&ctx), GateDecision::Reject); + } }