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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crates/engine/src/game/engine_payment_choices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kind> 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()));
}
}
Comment on lines +1183 to +1205

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -t f '^player_counter\.rs$' crates/engine | while IFS= read -r file; do
  ast-grep outline "$file" --items all
  rg -n -C 10 'GivePlayerCounter|fn resolve|ReplacementChoice|counter_kind|controller|targets' "$file"
done

rg -n -C 8 'pay_ability_cost_for_resolution|PaymentOutcome|EffectCost' \
  crates/engine/src/game crates/engine/src/types

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- payment branch ---'
sed -n '1128,1220p' crates/engine/src/game/engine_payment_choices.rs

printf '%s\n' '--- resolution cost authority ---'
fd -t f '^costs\.rs$' crates/engine | while IFS= read -r file; do
  ast-grep outline "$file" --items all
  rg -n -C 12 'enum PaymentOutcome|pay_ability_cost_for_resolution|EffectCost|GivePlayerCounter|supported_at_resolution|resolve_effect' "$file"
done

printf '%s\n' '--- counter event and replacement semantics ---'
rg -n -C 12 'enum CounterPlacement|AddCounter|actor:|ReplacementResult::Prevented|add_player_counter_with_replacement' \
  crates/engine/src/types crates/engine/src/game/effects crates/engine/src/game/replacement.rs

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- payment branch ---'
sed -n '1140,1215p' crates/engine/src/game/engine_payment_choices.rs

printf '%s\n' '--- cost files ---'
fd -t f 'costs\.rs$' crates/engine

printf '%s\n' '--- resolution authority symbols ---'
rg -n -C 8 'enum PaymentOutcome|fn pay_ability_cost_for_resolution|EffectCost|GivePlayerCounter|supported_at_resolution' \
  crates/engine/src/game/costs.rs

printf '%s\n' '--- counter placement type ---'
rg -n -C 8 'enum CounterPlacement|AddCounter' crates/engine/src/types/proposed_event.rs

printf '%s\n' '--- focused player-counter tests ---'
sed -n '780,875p' crates/engine/src/game/effects/proliferate.rs
sed -n '1,190p' crates/engine/src/game/effects/player_counter.rs

Repository: phase-rs/phase

Length of output: 38512


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- replacement actor usage ---'
rg -n -C 10 'CounterPlacement::Player|placement\.actor|actor.*CounterPlacement|counter.*actor' \
  crates/engine/src/game/replacement.rs crates/engine/src/game/engine_replacement.rs \
  crates/engine/src/types

printf '%s\n' '--- unless-payment entry and completion ---'
rg -n -C 14 'surface_unless_payment|WaitingFor::UnlessPayment|cost_payment_failed_flag|payment_failed' \
  crates/engine/src/game/engine_payment_choices.rs crates/engine/src/game/effects

printf '%s\n' '--- resolution cost match around EffectCost ---'
sed -n '1260,1375p' crates/engine/src/game/costs.rs
sed -n '1745,1822p' crates/engine/src/game/costs.rs

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- replacement matching for AddCounter ---'
rg -n -C 6 'AddCounter|CounterPlacement::Player|actor' \
  crates/engine/src/game/replacement.rs

printf '%s\n' '--- exact resolution cost contract ---'
sed -n '644,725p' crates/engine/src/game/costs.rs
sed -n '1300,1365p' crates/engine/src/game/costs.rs
sed -n '1750,1820p' crates/engine/src/game/costs.rs

printf '%s\n' '--- exact unless-payment failure mapping ---'
sed -n '620,675p' crates/engine/src/game/engine_payment_choices.rs
sed -n '1210,1245p' crates/engine/src/game/engine_payment_choices.rs

Repository: phase-rs/phase

Length of output: 50370


Route player-counter Ward costs through the payment authority.

add_player_counter_with_replacement returns true for ReplacementResult::Prevented, and resolve returns Ok(()). This leaves payment_failed false, so Ward’s primary effect resolves when the payer did not receive the required counters. Return Paid, Failed, or Paused through costs::pay_ability_cost_for_resolution, including prevention and replacement-choice handling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/engine_payment_choices.rs` around lines 1183 - 1205,
Update the Effect::GivePlayerCounter Ward-cost branch to resolve through
costs::pay_ability_cost_for_resolution instead of directly calling
effects::player_counter::resolve. Propagate its Paid, Failed, and Paused
outcomes, ensuring prevented counters mark payment as failed while
replacement-choice waits return the paused action result.

Sources: Coding guidelines, Path instructions

_ => payment_failed = true,
},
AbilityCost::Unimplemented { .. } => {
Expand Down
16 changes: 16 additions & 0 deletions crates/engine/src/game/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kind> 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,
Comment on lines +223 to +229

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 'GetPlayerCounters|value:\s*\*count as i32' crates/engine/src crates/engine/tests

Repository: phase-rs/phase

Length of output: 7227


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- WardCost and quantity definitions ---'
rg -n -C 8 'enum WardCost|GetPlayerCounters|enum QuantityExpr|struct QuantityExpr|parse_number' crates/engine/src/types crates/engine/src/parser crates/engine/src/game

printf '%s\n' '--- Relevant conversion and quantity consumers ---'
rg -n -C 8 'QuantityExpr::Fixed|value:\s*.*as i32|\.value\b' crates/engine/src | head -n 500

printf '%s\n' '--- Parser number implementation and tests ---'
rg -n -C 12 'fn parse_number|parse_number\s*=|parse_number\.' crates/engine/src/parser crates/engine/src

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- WardCost definition ---'
rg -n -A 35 -B 8 'enum WardCost' crates/engine/src/types/keywords.rs

printf '%s\n' '--- QuantityExpr definition ---'
rg -n -A 45 -B 8 'enum QuantityExpr' crates/engine/src/types/ability.rs crates/engine/src/types

printf '%s\n' '--- Ward parser ---'
sed -n '720,785p' crates/engine/src/parser/oracle_keyword.rs

printf '%s\n' '--- Ward conversion ---'
sed -n '190,240p' crates/engine/src/game/triggers.rs

printf '%s\n' '--- Number parser definition ---'
rg -n -A 35 -B 10 'parse_number' crates/engine/src/parser/nom_primitives.rs crates/engine/src/parser

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Candidate type files ---'
git ls-files 'crates/engine/src/types/*' | sed -n '1,120p'

printf '%s\n' '--- WardCost definition ---'
rg -n -A 18 -B 5 'enum WardCost' crates/engine/src/types

printf '%s\n' '--- QuantityExpr definition ---'
rg -l 'enum QuantityExpr' crates/engine/src/types | while read -r f; do
  printf '\nFILE: %s\n' "$f"
  rg -n -A 35 -B 5 'enum QuantityExpr' "$f"
done

printf '%s\n' '--- Ward parser ---'
sed -n '740,775p' crates/engine/src/parser/oracle_keyword.rs

printf '%s\n' '--- Ward conversion ---'
sed -n '205,235p' crates/engine/src/game/triggers.rs

printf '%s\n' '--- Number parser declarations only ---'
rg -n -m 20 -A 18 -B 8 'parse_number' crates/engine/src/parser/nom_primitives.rs

Repository: phase-rs/phase

Length of output: 8996


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Files defining or exporting parse_number ---'
rg -l 'parse_number' crates/engine/src/parser | sort

printf '%s\n' '--- parse_number definitions and nearby code ---'
for f in $(rg -l 'parse_number' crates/engine/src/parser | sort); do
  matches=$(rg -n 'parse_number' "$f" | head -n 12)
  if [ -n "$matches" ]; then
    printf '\nFILE: %s\n%s\n' "$f" "$matches"
  fi
done

printf '%s\n' '--- Numeric parser tests or explicit bounds ---'
rg -n -C 5 'u32::MAX|i32::MAX|try_from\(.*parse_number|parse_number.*u32|parse_number.*overflow|number word' crates/engine/src/parser

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Exact digit parser ---'
sed -n '20,70p' crates/engine/src/parser/oracle_nom/primitives.rs

printf '%s\n' '--- Exact English-number parser bounds ---'
sed -n '67,130p' crates/engine/src/parser/oracle_nom/primitives.rs

printf '%s\n' '--- Wrapper remainder handling ---'
sed -n '341,365p' crates/engine/src/parser/oracle_util.rs

printf '%s\n' '--- Ward parser call site and tests ---'
rg -n -A 12 -B 8 'GetPlayerCounters|ward.*get|get [^"]* counters' crates/engine/src/parser/oracle_keyword.rs

printf '%s\n' '--- Standalone conversion probe ---'
python3 - <<'PY'
values = [0, 5, 2_147_483_647, 2_147_483_648, 4_294_967_295]
for value in values:
    signed = value if value <= 2_147_483_647 else value - 2**32
    print(f"{value} -> {signed}")
PY

Repository: phase-rs/phase

Length of output: 9604


Reject out-of-range ward counter counts.

parse_number accepts u32, but line 227 casts it to i32. Counts above i32::MAX wrap to negative quantities. Use a checked conversion and reject invalid WardCost input before constructing QuantityExpr::Fixed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/triggers.rs` around lines 223 - 229, Update the
WardCost::GetPlayerCounters conversion to perform a checked u32-to-i32
conversion before constructing QuantityExpr::Fixed; reject the WardCost input
when the count exceeds i32::MAX instead of allowing a wrapping negative value,
while preserving valid counter handling.

Source: Path instructions

}),
},
// 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 {
Expand Down
49 changes: 48 additions & 1 deletion crates/engine/src/parser/oracle_keyword.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,11 @@ fn try_parse_multi_type_enchant(line: &str) -> Option<Keyword> {
/// 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<Keyword> {
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 {}.
Expand Down Expand Up @@ -744,6 +748,25 @@ fn parse_ward_cost_single(lower: &str) -> Option<WardCost> {
return Some(WardCost::Sacrifice { count, filter });
}

// CR 702.21a + CR 122.1: "get N <kind> 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());
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions crates/engine/src/types/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
141 changes: 141 additions & 0 deletions crates/engine/tests/integration/serpent_society_ward_poison_6640.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
23 changes: 22 additions & 1 deletion crates/phase-ai/src/policies/anti_self_harm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kind> 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,
}
}
Comment on lines +51 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- candidate files ---'
fd -i 'anti_self_harm|phase-ai|MagicCompRules|main.rs' . | head -80
printf '%s\n' '--- symbol references ---'
rg -n -C 4 'ward_counter_severity|GetPlayerCounters|PlayerCounterKind|poison' crates/phase-ai crates/engine/tests/integration docs/MagicCompRules.txt 2>/dev/null | head -240
printf '%s\n' '--- source outline ---'
if [ -f crates/phase-ai/src/policies/anti_self_harm.rs ]; then
  ast-grep outline crates/phase-ai/src/policies/anti_self_harm.rs
fi

Repository: phase-rs/phase

Length of output: 30555


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- anti_self_harm.rs: helper and scoring ---'
cat -n crates/phase-ai/src/policies/anti_self_harm.rs | sed -n '1,135p;560,735p;1035,1245p'
printf '%s\n' '--- rules document candidates ---'
find . -type f -iname '*MagicCompRules*' -o -type f -iname '*CLAUDE.md' | sort
printf '%s\n' '--- CR references in tracked files ---'
rg -n -C 2 '104\.3d|704\.5c|ten or more poison|ten poison|poison counters' --glob '*.txt' --glob '*.md' --glob '*.rs' . | head -220
printf '%s\n' '--- Ward-related symbols and tests ---'
rg -n -C 5 'Ward|ward|GetPlayerCounters|counter.*severity|target selection' crates/phase-ai/src crates/phase-ai/tests crates/engine/tests 2>/dev/null | head -260

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- exact helper references ---'
rg -n -C 12 'ward_counter_severity|GetPlayerCounters|WardCost' crates/phase-ai/src/policies/anti_self_harm.rs crates/phase-ai/src/policies crates/phase-ai/src/strategy_helpers.rs crates/engine/src/types/keywords.rs crates/engine/src/types/ability.rs 2>/dev/null
printf '%s\n' '--- Ward payment helpers ---'
rg -n -C 10 'can_pay_ward_cost|WardCounter|PlayerCounter' crates/phase-ai/src crates/engine/src | head -260
printf '%s\n' '--- focused tests mentioning counter effects ---'
rg -n -C 8 'plus_counter_is_beneficial|minus_counter_is_harmful|player_counter|Poison|ward' crates/phase-ai/src/policies/anti_self_harm.rs
printf '%s\n' '--- CLAUDE.md relevant rules ---'
rg -n -C 3 'counter|Ward|CR|typed|exhaustive|phase-ai' CLAUDE.md
printf '%s\n' '--- repository tracking and rule-file status ---'
git ls-files | rg '(^|/)(MagicCompRules\.txt|CLAUDE\.md)$|phase-ai/src/policies/anti_self_harm.rs'

Repository: phase-rs/phase

Length of output: 48199


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Ward scoring context ---'
cat -n crates/phase-ai/src/policies/anti_self_harm.rs | sed -n '740,842p'
printf '%s\n' '--- player counter accessor ---'
cat -n crates/engine/src/types/player.rs | sed -n '45,75p;245,272p'
printf '%s\n' '--- anti-self-harm counter tests ---'
rg -n -C 18 'plus_counter_is_beneficial|minus_counter_is_harmful|ward_counter|GetPlayerCounters|PlayerCounterKind::Poison' crates/phase-ai/src/policies/anti_self_harm.rs
printf '%s\n' '--- target/cast candidate routing ---'
rg -n -C 8 'AntiSelfHarm|ChooseTarget|SelectTargets|CastSpell|score_pre_cast' crates/phase-ai/src/policies/registry.rs crates/phase-ai/src/policies/context.rs crates/phase-ai/src/policies/anti_self_harm.rs | head -240
printf '%s\n' '--- deterministic threshold probe ---'
python3 - <<'PY'
def current(count):
    return min(count / 2.0, 3.0)
for existing, incoming in ((0, 1), (9, 1), (5, 5)):
    print(f"existing={existing}, incoming={incoming}, total={existing+incoming}, current_severity={current(incoming)}")
PY

Repository: phase-rs/phase

Length of output: 35149


🌐 Web query:

Magic Comprehensive Rules poison counters ten or more player loses CR 704.5c 104.3d

💡 Result:

According to the Magic: The Gathering Comprehensive Rules, a player who has ten or more poison counters loses the game [1][2]. This is enforced as a state-based action, which means the player loses the game the next time a player would receive priority [1][2]. This rule is explicitly stated in two sections of the Comprehensive Rules: 1. Rule 104.3d: "If a player has ten or more poison counters, that player loses the game the next time a player would receive priority. (This is a state-based action. See rule 704.)" [1][2]. 2. Rule 704.5c: This rule defines the state-based action regarding poison counters [3][4]. While the specific text of 704.5c may vary slightly in presentation across editions, it functions in conjunction with 104.3d to trigger the loss of the game upon reaching the threshold [3][4]. Note that for specific variants like Two-Headed Giant, this rule is superseded by alternative rules (such as 704.6b, which sets the threshold at fifteen poison counters for a team) [3][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- policy score bands and Ward penalty ---'
rg -n -C 5 'CRITICAL_MAX|ward_cost_penalty_base|default_ward_cost_penalty_base' crates/phase-ai/src/policies crates/phase-ai/src/config.rs
printf '%s\n' '--- target scoring test helpers and nearby tests ---'
rg -n -C 12 'fn make_target_selection_ctx|fn make_mutate_target_selection_ctx|score_target_object|target.*ward|ward.*target' crates/phase-ai/src/policies/anti_self_harm.rs
printf '%s\n' '--- Ward counter engine behavior ---'
rg -n -C 8 'GetPlayerCounters|add_player_counters|player_counter\(' crates/engine/src | head -180

Repository: phase-rs/phase

Length of output: 50370


Include the payer’s current counter total in Ward severity.

ward_counter_severity scores only the incoming count. Under CR 104.3d, 9 existing poison counters plus 1 incoming counter causes a loss and must receive the strongest severity (3.0). The 5+5 case must receive the same severity instead of the current 2.5. Pass player_counter(kind) into the helper and cover both cases through target selection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/phase-ai/src/policies/anti_self_harm.rs` around lines 51 - 60, Update
ward_counter_severity to accept the payer’s current counter total and calculate
poison/rad severity from current total plus incoming count, capping it at 3.0 so
9+1 and 5+5 receive maximum severity. Update its callers to pass
player_counter(kind), and add target-selection coverage for both cases.

Source: Learnings


// `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;
Expand Down Expand Up @@ -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)
}
Comment on lines +804 to +810

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Evaluate player-counter Ward costs for noncreature targets.

The new arm executes only inside the creature-only branch that starts at Line 733. An artifact, enchantment, or planeswalker with Keyword::Ward therefore bypasses both can_pay_ward_cost and the counter severity penalty. The noncreature path treats that Ward cost as free.

Move shared Ward-cost evaluation before the creature/noncreature split, or invoke it from both branches. Add a noncreature Ward target-selection regression.

As per path instructions, evaluate generalized Ward behavior instead of only the current creature case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/phase-ai/src/policies/anti_self_harm.rs` around lines 804 - 810, Make
Ward-cost evaluation apply to every target type, not only creatures: move the
shared handling around the creature/noncreature split in the Ward resolution
flow, or invoke it from both branches, so noncreature targets also run
can_pay_ward_cost and ward_counter_severity for WardCost::GetPlayerCounters. Add
a regression covering a noncreature Ward target.

Source: Path instructions

// CR 702.21a: Compound costs sum severity of components.
WardCost::Compound(costs) => costs
.iter()
Expand All @@ -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)
}
Comment on lines +825 to +827

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail
rg -n -C 8 'WardCost::Compound|GetPlayerCounters|ward_cost_to_ability_cost|can_pay_ward_cost' \
  crates/engine/src crates/phase-ai/src

Repository: phase-rs/phase

Length of output: 34248


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- WardCost and AbilityCost definitions ---'
rg -n -C 12 'enum WardCost|Compound\(|enum AbilityCost|unless_pay|EffectCost' \
  crates/engine/src/types crates/engine/src/game

printf '%s\n' '--- Unless-payment resolution and payment dispatch ---'
rg -n -C 16 'unless_pay|AbilityCost::Compound|resolve.*cost|pay.*cost|EffectCost' \
  crates/engine/src/game crates/engine/src/types

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- AbilityCost definition ---'
rg -n -A 120 -B 8 'pub enum AbilityCost|enum AbilityCost' crates/engine/src/types

printf '%s\n' '--- WardCost definition ---'
rg -n -A 45 -B 8 'pub enum WardCost|enum WardCost' crates/engine/src/types/keywords.rs

printf '%s\n' '--- Ward conversion and unless-pay construction ---'
sed -n '185,255p' crates/engine/src/game/triggers.rs
sed -n '3625,3685p' crates/engine/src/game/triggers.rs

printf '%s\n' '--- Runtime unless-pay interceptor ---'
rg -n -C 20 'unless_pay' crates/engine/src/game/effects/mod.rs crates/engine/src/game/effects.rs crates/engine/src/game

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- Definition files ---'
rg -l 'pub enum AbilityCost|enum AbilityCost' crates/engine/src
rg -l 'pub enum WardCost|enum WardCost' crates/engine/src

printf '%s\n' '--- Exact definition locations ---'
rg -n -m 5 'pub enum AbilityCost|enum AbilityCost|pub enum WardCost|enum WardCost' crates/engine/src

printf '%s\n' '--- Conversion and runtime locations ---'
rg -n -m 30 'fn ward_cost_to_ability_cost|WardCost::Compound|AbilityCost::Composite|unless_pay' \
  crates/engine/src/game/triggers.rs crates/engine/src/game/effects/mod.rs crates/engine/src/game/effects.rs crates/engine/src/game

Repository: phase-rs/phase

Length of output: 45286


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- AbilityCost variants ---'
sed -n '8380,8475p' crates/engine/src/types/ability.rs

printf '%s\n' '--- WardCost variants ---'
sed -n '520,565p' crates/engine/src/types/keywords.rs

printf '%s\n' '--- Unless-payment setup ---'
sed -n '8935,9110p' crates/engine/src/game/effects/mod.rs

printf '%s\n' '--- Unless-cost normalization ---'
sed -n '11805,11880p' crates/engine/src/game/effects/mod.rs

printf '%s\n' '--- AI compound handling ---'
sed -n '785,840p' crates/phase-ai/src/policies/anti_self_harm.rs
sed -n '736,798p' crates/phase-ai/src/policies/strategy_helpers.rs

Repository: phase-rs/phase

Length of output: 26014


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

triggers = Path("crates/engine/src/game/triggers.rs").read_text()
anti = Path("crates/phase-ai/src/policies/anti_self_harm.rs").read_text()
helpers = Path("crates/phase-ai/src/policies/strategy_helpers.rs").read_text()
keywords = Path("crates/engine/src/types/keywords.rs").read_text()

checks = {
    "engine compound conversion uses only first component":
        "WardCost::Compound(costs)" in triggers
        and "costs.first()" in triggers
        and "ward_cost_to_ability_cost(first)" in triggers,
    "AI affordability requires every compound component":
        "WardCost::Compound(costs) => costs" in helpers
        and ".all(|cost| can_pay_ward_cost(ctx, cost, warded))" in helpers,
    "AI severity uses fixed nested-compound fallback":
        "WardCost::Compound(_) => 2.0" in anti,
    "WardCost documents compound costs as conjoined":
        "multiple costs that must all be paid" in keywords,
}

for name, result in checks.items():
    print(f"{name}: {result}")
    assert result, name
PY

printf '%s\n' '--- Exact engine conversion ---'
sed -n '232,250p' crates/engine/src/game/triggers.rs

printf '%s\n' '--- Composite unless-payment branch ---'
sed -n '9080,9135p' crates/engine/src/game/effects/mod.rs

printf '%s\n' '--- WardCost compound construction sites ---'
rg -n -C 4 'WardCost::Compound' crates/engine/src crates/phase-ai/src

Repository: phase-rs/phase

Length of output: 8593


Preserve all compound Ward-cost components at runtime.

crates/engine/src/game/triggers.rs converts WardCost::Compound with costs.first(), so the runtime prompts and pays only the first sub-cost. This conflicts with strategy_helpers.rs, which requires every component, and with anti_self_harm.rs, which scores every component.

  • Convert compound costs recursively into AbilityCost::Composite.
  • Replace WardCost::Compound(_) => 2.0 with recursive severity scoring.
📍 Affects 2 files
  • crates/phase-ai/src/policies/anti_self_harm.rs#L825-L827 (this comment)
  • crates/phase-ai/src/policies/strategy_helpers.rs#L783-L786
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/phase-ai/src/policies/anti_self_harm.rs` around lines 825 - 827, The
WardCost handling must preserve every component of compound costs. In
crates/phase-ai/src/policies/anti_self_harm.rs at lines 825-827, update the
WardCost severity logic to recursively score each component instead of assigning
WardCost::Compound a fixed value; in
crates/phase-ai/src/policies/strategy_helpers.rs at lines 783-786, ensure
compound costs are recursively converted into AbilityCost::Composite rather than
using only the first cost, and update the corresponding runtime conversion in
the triggers logic so prompts and payments include all components.

Source: Path instructions

WardCost::Compound(_) => 2.0,
})
.sum::<f64>()
Expand Down
4 changes: 4 additions & 0 deletions crates/phase-ai/src/policies/strategy_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading