From 5bfca06e5c5162ba24f552e1b206b641c2688c05 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Fri, 31 Jul 2026 14:24:06 -0300 Subject: [PATCH] fix(engine): model Ward paid with player counters (#6640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Serpent Society's "Ward—Get five poison counters" had no representation: WardCost lacked a player-counter form, so the oracle parser fell through to the mana fallback and lowered it to WardCost::Mana(generic 0). An opponent targeting the creature paid nothing and the spell resolved for free. Add WardCost::GetPlayerCounters { kind, count }, parameterized over PlayerCounterKind so it covers the whole class (poison/rad/experience/ticket), not one card: - Parser (oracle_keyword): parse "get N counters" as a ward cost, reusing the same parse_number + parse_player_counter_kind combinators the imperative "get N poison counters" effect uses, and strip the parenthetical reminder text first (CR 702.21a + CR 122.1). - ward_cost_to_ability_cost (triggers): map it to an EffectCost wrapping GivePlayerCounter, mirroring the existing "unless you take N damage" / "unless its controller draws" punisher shape (CR 118.12). - Unless-payment (engine_payment_choices): add the GivePlayerCounter arm to the EffectCost payer path so paying re-targets the effect to the payer and adds the counters (CR 122.1 + CR 104.3d). - AI: can_pay_ward_cost treats it as always payable (a player can always receive counters); anti_self_harm scores poison/rad as self-harm scaled by count and experience/ticket as harmless. Tests: parser unit test (word/digit count, reminder stripping, whole class) plus runtime regressions in serpent_society_ward_poison_6640.rs — paying gives exactly five poison counters and leaves the spell on the stack; declining counters it. Closes #6640 --- .../engine/src/game/engine_payment_choices.rs | 23 +++ crates/engine/src/game/triggers.rs | 16 ++ crates/engine/src/parser/oracle_keyword.rs | 49 +++++- crates/engine/src/types/keywords.rs | 10 ++ crates/engine/tests/integration/main.rs | 1 + .../serpent_society_ward_poison_6640.rs | 141 ++++++++++++++++++ .../phase-ai/src/policies/anti_self_harm.rs | 23 ++- .../phase-ai/src/policies/strategy_helpers.rs | 4 + 8 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/integration/serpent_society_ward_poison_6640.rs diff --git a/crates/engine/src/game/engine_payment_choices.rs b/crates/engine/src/game/engine_payment_choices.rs index 149ba172cd..831c938826 100644 --- a/crates/engine/src/game/engine_payment_choices.rs +++ b/crates/engine/src/game/engine_payment_choices.rs @@ -1180,6 +1180,29 @@ pub(super) fn handle_unless_payment( return Ok(action_result(events, state.waiting_for.clone())); } } + // CR 702.21a + CR 122.1 + CR 118.12: "Ward—Get N counters" + // (The Serpent Society). The payer pays by receiving N player + // counters. Re-target the effect to the payer (a declared Player + // target) and resolve it through the player-counter handler, the + // same punisher shape as the DealDamage/Draw arms above. + Effect::GivePlayerCounter { .. } => { + let mut counter_ability = pending_effect.as_ref().clone(); + counter_ability.effect = *effect.clone(); + counter_ability.targets = vec![TargetRef::Player(player)]; + counter_ability.unless_pay = None; + counter_ability.sub_ability = None; + if let Err(e) = + effects::player_counter::resolve(state, &counter_ability, events) + { + return Err(EngineError::InvalidAction(format!("{e:?}"))); + } + if matches!( + state.waiting_for, + WaitingFor::ReplacementChoice { .. } + ) { + return Ok(action_result(events, state.waiting_for.clone())); + } + } _ => payment_failed = true, }, AbilityCost::Unimplemented { .. } => { diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 44d1f58fd9..c3f4274246 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -213,6 +213,22 @@ fn ward_cost_to_ability_cost(ward_cost: &WardCost) -> AbilityCost { WardCost::Sacrifice { count, filter } => { AbilityCost::Sacrifice(SacrificeCost::count(filter.clone(), *count)) } + // CR 702.21a + CR 122.1 + CR 118.12: "get N counters" — the + // targeting player pays by receiving N player counters. Modeled as an + // effect-as-cost that gives the payer the counters (the `EffectCost` + // unless-payment arm re-targets it to the payer), mirroring the + // "unless you take N damage" / "unless its controller draws" punisher + // shape. `TargetFilter::Player` (a declared, non-context-ref target) so + // the resolver reads the payer from the ability's chosen targets. + WardCost::GetPlayerCounters { kind, count } => AbilityCost::EffectCost { + effect: Box::new(crate::types::ability::Effect::GivePlayerCounter { + counter_kind: *kind, + count: QuantityExpr::Fixed { + value: *count as i32, + }, + target: TargetFilter::Player, + }), + }, // CR 702.21a + CR 701.67: Waterbend ward cost maps to mana payment. // Full tap-to-help semantics deferred to waterbend cost integration. WardCost::Waterbend(mana_cost) => AbilityCost::Mana { diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index e6bd07bf32..505aa8c5da 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -677,7 +677,11 @@ fn try_parse_multi_type_enchant(line: &str) -> Option { /// Handles "pay N life", "discard a card", "sacrifice a permanent/creature/etc." /// Also handles compound costs like "{2}, Pay 2 life" → Compound([Mana, PayLife]). fn parse_ward_cost(cost_text: &str) -> Option { - let lower = cost_text.trim().trim_end_matches('.').to_lowercase(); + // CR 702.21a: drop the parenthetical reminder some ward costs carry (e.g. + // The Serpent Society's "(A player with ten or more poison counters loses + // the game.)") so the counter-noun suffix match below sees a clean cost. + let no_reminder = strip_reminder_text(cost_text); + let lower = no_reminder.trim().trim_end_matches('.').to_lowercase(); // CR 702.21a: Detect compound costs — comma-separated sub-costs. // Only split on ", " that is NOT inside mana braces {}. @@ -744,6 +748,25 @@ fn parse_ward_cost_single(lower: &str) -> Option { return Some(WardCost::Sacrifice { count, filter }); } + // CR 702.21a + CR 122.1: "get N counters" — the targeting player + // receives N player counters (poison/rad/experience/ticket) as the ward + // cost (The Serpent Society: "Ward—Get five poison counters"). Built for the + // whole class via `parse_player_counter_kind`, not a poison-only special + // case, and composed from the same `parse_number` + counter-kind combinators + // the imperative "get N poison counters" effect uses. + if let Ok((after_get, _)) = tag::<_, _, OracleError<'_>>("get ").parse(lower) { + if let Ok((after_count, count)) = nom_primitives::parse_number.parse(after_get) { + if let Ok((after_kind, kind)) = + nom_primitives::parse_player_counter_kind.parse(after_count.trim_start()) + { + let tail = after_kind.trim_start(); + if tail == "counter" || tail == "counters" { + return Some(WardCost::GetPlayerCounters { kind, count }); + } + } + } + } + // CR 702.21a + CR 701.67: "waterbend {N}" — ward cost paid via waterbend mechanic. if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("waterbend").parse(lower) { let cost = crate::database::mtgjson::parse_mtgjson_mana_cost(rest.trim()); @@ -3326,6 +3349,30 @@ mod tests { ); } + #[test] + fn parse_granted_keyword_fragment_ward_get_poison_counters() { + use crate::types::player::PlayerCounterKind; + // CR 702.21a + CR 122.1 (issue #6640): The Serpent Society's ward cost — + // word-number count, reminder text stripped, whole-class counter kind. + assert_eq!( + parse_granted_keyword_fragment( + "ward—get five poison counters. (a player with ten or more poison counters loses the game.)" + ), + Some(Keyword::Ward(WardCost::GetPlayerCounters { + kind: PlayerCounterKind::Poison, + count: 5, + })) + ); + // Built for the class, not one card: a digit count and a different kind. + assert_eq!( + parse_granted_keyword_fragment("ward—get 2 rad counters"), + Some(Keyword::Ward(WardCost::GetPlayerCounters { + kind: PlayerCounterKind::Rad, + count: 2, + })) + ); + } + #[test] fn parse_granted_keyword_fragment_protection_from_color() { use crate::types::keywords::ProtectionTarget; diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index d792ff332c..68c86ce834 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -537,6 +537,16 @@ pub enum WardCost { count: u32, filter: crate::types::ability::TargetFilter, }, + /// CR 702.21a + CR 122.1: Ward cost paid by the targeting player *receiving* + /// N player counters (e.g. The Serpent Society's "Ward—Get five poison + /// counters"). Parameterized over `PlayerCounterKind` so it covers the whole + /// class (poison/rad/experience/ticket), not one card. The payer can always + /// choose to pay — getting counters is always possible — so this is a genuine + /// pay/decline choice, not an affordability gate. + GetPlayerCounters { + kind: crate::types::player::PlayerCounterKind, + count: u32, + }, /// CR 702.21a: Ward cost paid via waterbend — tap artifacts/creatures to help pay. /// Distinct from Mana because waterbend has unique payment semantics (CR 701.67). Waterbend(ManaCost), diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 3de438afaf..571e385afa 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -840,6 +840,7 @@ mod selenia_vigilance_grant; mod self_destruct_target_power; mod sensei_golden_tail_5950; mod sentinel_sliver_vigilance_grant; +mod serpent_society_ward_poison_6640; 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_6640.rs b/crates/engine/tests/integration/serpent_society_ward_poison_6640.rs new file mode 100644 index 0000000000..2f73d50d59 --- /dev/null +++ b/crates/engine/tests/integration/serpent_society_ward_poison_6640.rs @@ -0,0 +1,141 @@ +//! Issue #6640: The Serpent Society's "Ward—Get five poison counters" was lowered +//! to a zero-mana ward (`WardCost::Mana` generic 0) because `WardCost` had no +//! poison-counter form, so an opponent targeting it paid nothing and the spell +//! resolved for free. +//! +//! Oracle text (verified from card data, per the issue): +//! Deathtouch +//! Ward—Get five poison counters. (A player with ten or more poison counters +//! loses the game.) +//! Whenever another creature you control with deathtouch dies, each opponent +//! sacrifices a nontoken creature of their choice. +//! +//! These runtime regressions drive the real cast pipeline: an opponent targets +//! the warded creature, Ward triggers, and the opponent either takes the five +//! poison counters (spell survives) or declines (spell is countered). +//! +//! https://github.com/phase-rs/phase/issues/6640 + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::phase::Phase; +use engine::types::player::PlayerCounterKind; +use engine::types::zones::Zone; + +const SERPENT_SOCIETY: &str = "Deathtouch\nWard—Get five poison counters. (A player with ten or more poison counters loses the game.)\nWhenever another creature you control with deathtouch dies, each opponent sacrifices a nontoken creature of their choice."; + +/// CR 702.21a + CR 122.1: targeting The Serpent Society triggers Ward, prompting +/// the targeting opponent to pay by getting five poison counters. Paying leaves +/// the targeted spell on the stack and adds exactly five poison counters — not +/// zero, which was the pre-fix behavior of the mislowered `Mana(0)` cost. +#[test] +fn serpent_society_ward_charges_five_poison_counters_when_paid() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let serpent = scenario + .add_creature_from_oracle(P0, "The Serpent Society", 3, 3, SERPENT_SOCIETY) + .id(); + let murder = scenario + .add_spell_to_hand_from_oracle(P1, "Murder", 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(murder).target_objects(&[serpent]).commit(); + runner.advance_until_stack_empty(); + + // CR 702.21a: Ward must prompt the targeting opponent (P1) to pay. + let WaitingFor::UnlessPayment { player, .. } = &runner.state().waiting_for else { + panic!( + "The Serpent Society's Ward must prompt the opponent, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!(*player, P1, "the targeting player pays Ward (CR 702.21a)"); + assert_eq!( + runner.state().players[P1.0 as usize].poison_counters, + 0, + "no poison counters before payment" + ); + + runner + .act(GameAction::PayUnlessCost { pay: true }) + .expect("the opponent chooses to pay the poison-counter Ward cost"); + + // CR 122.1 + CR 104.3d: paying adds exactly five poison counters (routed to + // the dedicated poison field), not zero. + assert_eq!( + runner.state().players[P1.0 as usize].poison_counters, + 5, + "paying Ward must give the opponent five poison counters" + ); + assert_eq!( + runner.state().players[P1.0 as usize].player_counter(&PlayerCounterKind::Poison), + 5, + "poison accessor mirrors the dedicated field" + ); + // CR 702.21a: paying Ward leaves the targeted spell on the stack to resolve. + assert!( + runner.state().stack.iter().any(|entry| entry.id == murder), + "paying Ward keeps the targeting spell on the stack" + ); +} + +/// CR 702.21a: declining the poison-counter Ward cost counters the targeting +/// spell (it never resolves), and the opponent gains no poison counters. +#[test] +fn serpent_society_ward_counters_the_spell_when_declined() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let serpent = scenario + .add_creature_from_oracle(P0, "The Serpent Society", 3, 3, SERPENT_SOCIETY) + .id(); + let murder = scenario + .add_spell_to_hand_from_oracle(P1, "Murder", 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(murder).target_objects(&[serpent]).commit(); + runner.advance_until_stack_empty(); + + assert!( + matches!(runner.state().waiting_for, WaitingFor::UnlessPayment { .. }), + "Ward must prompt before the spell resolves, got {:?}", + runner.state().waiting_for + ); + + runner + .act(GameAction::PayUnlessCost { pay: false }) + .expect("the opponent declines the poison-counter Ward cost"); + + // CR 702.21a + CR 701.6a: declining counters the spell to its owner's graveyard. + assert_eq!( + runner.state().objects[&murder].zone, + Zone::Graveyard, + "declining Ward counters the targeting spell to the graveyard" + ); + // CR 122.1: no poison counters are gained when the cost is declined. + assert_eq!( + runner.state().players[P1.0 as usize].poison_counters, + 0, + "declining Ward gives no poison counters" + ); + // The warded creature survives — the destroy spell never resolved. + assert_eq!( + runner.state().objects[&serpent].zone, + Zone::Battlefield, + "the countered spell never destroys the warded creature" + ); +} diff --git a/crates/phase-ai/src/policies/anti_self_harm.rs b/crates/phase-ai/src/policies/anti_self_harm.rs index dad9d1ed09..3bd0319aef 100644 --- a/crates/phase-ai/src/policies/anti_self_harm.rs +++ b/crates/phase-ai/src/policies/anti_self_harm.rs @@ -44,10 +44,21 @@ use crate::features::DeckFeatures; #[cfg(test)] use engine::types::game_state::CastPaymentMode; use engine::types::game_state::GameState; -use engine::types::player::PlayerId; +use engine::types::player::{PlayerCounterKind, PlayerId}; pub struct AntiSelfHarmPolicy; +/// CR 702.21a + CR 104.3d: Self-harm severity of a "Ward—Get N counters" +/// cost. Poison and rad counters are harmful (poison at ten loses the game), so +/// severity scales with the count; experience and ticket counters are +/// beneficial, so paying them is not self-harm. +fn ward_counter_severity(kind: PlayerCounterKind, count: u32) -> f64 { + match kind { + PlayerCounterKind::Poison | PlayerCounterKind::Rad => (count as f64 / 2.0).min(3.0), + PlayerCounterKind::Experience | PlayerCounterKind::Ticket => 0.0, + } +} + // `turn_only` can scale early-game verdicts by 1.3; cap the raw verdict so // registry-scaled anti-self-harm penalties stay within the critical band. const ANTI_SELF_HARM_RAW_CRITICAL_CEILING: f64 = CRITICAL_MAX / 1.3; @@ -790,6 +801,13 @@ fn score_target_object(ctx: &PolicyContext<'_>, object_id: ObjectId, beneficial: 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 104.3d: receiving poison/rad counters is + // real self-harm scaled by count (ten poison loses the + // game); experience/ticket counters are beneficial, so no + // penalty for getting them. + WardCost::GetPlayerCounters { kind, count } => { + ward_counter_severity(*kind, *count) + } // CR 702.21a: Compound costs sum severity of components. WardCost::Compound(costs) => costs .iter() @@ -804,6 +822,9 @@ fn score_target_object(ctx: &PolicyContext<'_>, object_id: ObjectId, beneficial: WardCost::Waterbend(cost) => { (cost.mana_value() as f64 / 2.0).min(2.0) } + WardCost::GetPlayerCounters { kind, count } => { + ward_counter_severity(*kind, *count) + } WardCost::Compound(_) => 2.0, }) .sum::() diff --git a/crates/phase-ai/src/policies/strategy_helpers.rs b/crates/phase-ai/src/policies/strategy_helpers.rs index d73c3e8faa..d010c0081f 100644 --- a/crates/phase-ai/src/policies/strategy_helpers.rs +++ b/crates/phase-ai/src/policies/strategy_helpers.rs @@ -780,6 +780,10 @@ pub(crate) fn can_pay_ward_cost( .count(); matching as u32 >= *count } + // CR 702.21a + CR 122.1: receiving player counters is always possible, + // so this ward cost can always be paid — the AI's real decision (whether + // it's worth the counters) is scored in `anti_self_harm`, not gated here. + WardCost::GetPlayerCounters { .. } => true, // 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.