-
-
Notifications
You must be signed in to change notification settings - Fork 148
fix(engine): model Ward paid with player counters (The Serpent Society, #6640) #6844
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/testsRepository: 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/srcRepository: 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/parserRepository: 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.rsRepository: 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/parserRepository: 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}")
PYRepository: phase-rs/phase Length of output: 9604 Reject out-of-range ward counter counts.
🤖 Prompt for AI AgentsSource: 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 { | ||
|
|
||
| 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" | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
fiRepository: 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 -260Repository: 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)}")
PYRepository: phase-rs/phase Length of output: 35149 🌐 Web query:
💡 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 -180Repository: phase-rs/phase Length of output: 50370 Include the payer’s current counter total in Ward severity.
🤖 Prompt for AI AgentsSource: 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; | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 AgentsSource: Path instructions |
||
| // 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) | ||
| } | ||
|
Comment on lines
+825
to
+827
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/srcRepository: 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/typesRepository: 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/gameRepository: 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/gameRepository: 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.rsRepository: 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/srcRepository: phase-rs/phase Length of output: 8593 Preserve all compound Ward-cost components at runtime.
📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Path instructions |
||
| WardCost::Compound(_) => 2.0, | ||
| }) | ||
| .sum::<f64>() | ||
|
|
||
There was a problem hiding this comment.
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:
Repository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 38512
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50370
Route player-counter Ward costs through the payment authority.
add_player_counter_with_replacementreturnstrueforReplacementResult::Prevented, andresolvereturnsOk(()). This leavespayment_failedfalse, so Ward’s primary effect resolves when the payer did not receive the required counters. ReturnPaid,Failed, orPausedthroughcosts::pay_ability_cost_for_resolution, including prevention and replacement-choice handling.🤖 Prompt for AI Agents
Sources: Coding guidelines, Path instructions