Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
8 changes: 8 additions & 0 deletions client/src/i18n/locales/de/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/locales/en/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
},
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/locales/es/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
},
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/locales/fr/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
},
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/locales/it/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
},
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/locales/pl/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
},
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/locales/pt/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
},
Expand Down
44 changes: 43 additions & 1 deletion client/src/pages/GamePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment on lines +3411 to +3425

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.

⚠️ Potential issue | 🟠 Major

Keep engine-provided counter_kind unchanged.

This still transforms the engine enum into an i18n key and passes the translated noun as kind. Localize only the surrounding template; pass cost.counter_kind through unchanged or expose a display-ready label from the engine/adapter.

Suggested fix
-    const kind = t(`gamePage.cost.playerCounterKind.${cost.counter_kind}`);
-    return t("gamePage.cost.playerCounters", { count: cost.count, kind });
+    return t("gamePage.cost.playerCounters", {
+      count: cost.count,
+      kind: cost.counter_kind,
+    });
🤖 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 `@client/src/pages/GamePage.tsx` around lines 3376 - 3378, Update the
counter-kind branch in the cost formatting logic to pass cost.counter_kind
unchanged as the kind interpolation value, removing the
gamePage.cost.playerCounterKind translation. Keep localization limited to the
surrounding gamePage.cost.playerCounters template.

Sources: Path instructions, Learnings

}
switch (cost.type) {
// Legacy `UnlessCost` JSON (pre-2026-05-09 fold) — preserved for
// saved-game compat.
Expand Down
7 changes: 6 additions & 1 deletion crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 { .. } => {}
Comment on lines +1572 to 1577

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

Preserve counter_kind in loop analysis.

GetPlayerCounters is parameterized, but this arm discards both counter_kind and count. The supplied parser tests create the same variant for Experience, while the comment only justifies ignoring poison counters; this can produce incorrect net-axis and loop conclusions for other player-counter kinds. Classify supported kinds explicitly, or make unsupported kinds conservatively handled and tested.

As per path instructions, parameterized variants must retain their semantic axis; the parser coverage demonstrates this is not a poison-only variant.

🤖 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/analysis/ability_graph.rs` around lines 1572 - 1577, The
AbilityCost::GetPlayerCounters arm currently discards counter_kind and count, so
update the loop-analysis handling to classify supported counter kinds explicitly
and preserve their semantic axis and quantity. Keep poison counters excluded
only where justified, and handle unsupported kinds conservatively rather than
treating every variant as a no-op; extend the relevant parser/loop tests to
cover Experience and unsupported kinds.

Source: Path instructions

}
}
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/src/game/cost_payability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
Expand Down
36 changes: 36 additions & 0 deletions crates/engine/src/game/costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,27 @@ 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.
AbilityCost::GetPlayerCounters {
counter_kind,
count,
} => {
if !super::effects::player_counter::add_player_counter_with_replacement(
state, player, player, *counter_kind, *count, events,
) {
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);
Expand Down Expand Up @@ -1713,6 +1734,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
Expand Down Expand Up @@ -1884,6 +1908,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
Expand Down Expand Up @@ -2064,6 +2092,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(),
},
Expand Down Expand Up @@ -2177,6 +2209,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(),
},
Expand Down
24 changes: 24 additions & 0 deletions crates/engine/src/game/engine_payment_choices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,30 @@ 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 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;
}
PaymentOutcome::Paused { .. } => {
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
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/printed_cards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,7 @@ fn walk_cost(cost: &AbilityCost, out: &mut Vec<String>) {
| AbilityCost::NinjutsuFamily { .. }
// CR 118.9: a borrowed keyword cost carries no nested effect/cost carrier.
| AbilityCost::KeywordCostOfCastSpell { .. }
| AbilityCost::GetPlayerCounters { .. }
| AbilityCost::Unimplemented { .. } => {}
}
}
Expand Down
12 changes: 12 additions & 0 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions crates/engine/src/game/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
}

Expand Down Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/parser/oracle_effect/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. } => {}
}
}
Expand Down
Loading
Loading