Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 12 additions & 0 deletions client/src/viewmodel/costLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,18 @@ function formatQuantityRef(ref: { type: string; [key: string]: unknown }): strin
case "ExiledFromHandThisResolution": return "cards exiled from hand";
case "Speed": return "your speed";
case "ChosenNumber": return "the chosen number";
// CR 101.4: the number a player secretly chose. The engine supplies the
// player scope (and, for the cross-player scopes, the fold); this only
// renders it — "the highest number" / "the lowest number".
case "PlayerChosenNumber": {
const aggregate =
ref.player != null && typeof ref.player === "object" && "aggregate" in ref.player
? (ref.player as { aggregate?: string }).aggregate
: undefined;
if (aggregate === "Max") return "the highest number";
if (aggregate === "Min") return "the lowest number";
return "the chosen number";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case "PreviousEffectAmount": return "the previous amount";
case "EventContextAmount": return "the amount";
case "EventContextSourcePower": return "the source's power";
Expand Down
7 changes: 7 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2095,6 +2095,7 @@ fn legacy_quantity_ref(x: &QuantityRef) -> bool {
| QuantityRef::TurnsTaken
| QuantityRef::CrimesCommittedThisTurn
| QuantityRef::ChosenNumber
| QuantityRef::PlayerChosenNumber { .. }
| QuantityRef::AttackedThisTurn { .. }
| QuantityRef::DescendedThisTurn
// CR 701.65b/701.66b/701.67c: controller-scoped per-turn bend accumulator
Expand Down Expand Up @@ -5903,6 +5904,12 @@ fn rw_quantity_ref(x: &QuantityRef) -> RwProfile {
| QuantityRef::TrackedSetSize
| QuantityRef::FilteredTrackedSetSize { .. }
| QuantityRef::ChosenNumber
// CR 101.4 + CR 608.2d: the player-axis chosen-number read. Its producer
// is a persisting `Effect::Choose`, whose own arm below already declares
// `reads_member_bound`; classifying the reader the same way keeps the
// CR 603.3b same-event ordering gate fail-closed for the producer/consumer
// pair, exactly as for the object-axis `ChosenNumber` sibling.
| QuantityRef::PlayerChosenNumber { .. }
| QuantityRef::CostXPaid
| QuantityRef::KickerCount
| QuantityRef::AdditionalCostPaymentCount
Expand Down
7 changes: 7 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2369,6 +2369,13 @@ fn scan_quantity_ref(x: &QuantityRef, mode: ScanMode) -> Axes {
acc
}
QuantityRef::ChosenNumber => Axes::NONE,
// CR 101.4 + CR 608.2d: the number a player chose this resolution. Like
// its object-axis sibling `ChosenNumber` this is a bounded one-shot
// answer, not an accumulating projected resource — a re-choose REPLACES
// the stored value rather than adding to it (`bind_named_choice`), so it
// cannot grow across loop iterations. The only axis it can contribute is
// whatever its player scope carries.
QuantityRef::PlayerChosenNumber { player } => scan_player_scope(player),
QuantityRef::AttackedThisTurn { scope, filter } => {
let mut acc = Axes::NONE;
acc = acc.or(scan_count_scope(scope));
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,9 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String {
}
QuantityRef::TurnsTaken => "turns taken".into(),
QuantityRef::ChosenNumber => "chosen number".into(),
QuantityRef::PlayerChosenNumber { player } => {
format!("secretly chosen number ({})", fmt_player_scope(player))
}
QuantityRef::AttackedThisTurn { .. } => "attacked this turn".into(),
QuantityRef::DescendedThisTurn => "descended this turn".into(),
QuantityRef::LoyaltyAbilitiesActivatedThisTurn { player } => {
Expand Down Expand Up @@ -8075,6 +8078,9 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) {
// strict-failure marker anywhere, so it is genuinely handled.
QuantityRef::TurnsTaken => ("TurnsTaken", Handled),
QuantityRef::ChosenNumber => ("ChosenNumber", Unhandled),
// CR 101.4 + CR 608.2d: resolved live in `quantity::resolve_quantity`
// over `Player::chosen_attributes` (per-candidate and aggregate scopes).
QuantityRef::PlayerChosenNumber { .. } => ("PlayerChosenNumber", Handled),
QuantityRef::AttackedThisTurn { .. } => ("AttackedThisTurn", Handled),
QuantityRef::DescendedThisTurn => ("DescendedThisTurn", Unhandled),
QuantityRef::LoyaltyAbilitiesActivatedThisTurn { .. } => {
Expand Down
79 changes: 73 additions & 6 deletions crates/engine/src/game/effects/choose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ pub(crate) fn resolve_random_in_chain(
) {
ability.update_trigger_source_context_in_resolution_segment(context);
}
// CR 101.4 + CR 608.2d: mirror the interactive answer handler so a
// game-selected number is readable per-player too (CR 608.2d override — the
// game makes the choice, but it is still THIS player's chosen number).
record_player_chosen_number(state, ability.controller, &choice_type, &chosen);

// CR 608.2c + CR 109.4: A `Choose(Player)`/`Choose(Opponent)` answer binds a
// resolution-scoped chosen player. Append it to the resolving ability's
Expand Down Expand Up @@ -251,16 +255,20 @@ pub(crate) fn bind_named_choice(
.filter(|source| source.is_exact_object_and_resolution())
.cloned();
if let Some(pid) = persist_player {
// CR 607.2d / CR 607.2m (by analogy): per-player anchor label. The
// `Player` axis only ever stores `ChosenAttribute::Label`, so no
// multi-keyword split is needed here. Replace-on-rechoose (retain-drop
// any existing `Label`, then push) mirrors the object-branch Keyword
// replace so "last chose" holds exactly one anchor per player.
// CR 607.2d / CR 607.2m (by analogy): per-player anchor. Unlike an
// object's `chosen_attributes` (which accumulates a history — The
// Toymaker's Trap reads every number it has committed), a PLAYER anchor
// answers "what did this player choose", so re-choosing REPLACES the
// prior answer of the same kind. Replace-on-rechoose is keyed on the
// attribute's own discriminant, which is byte-identical to the previous
// `Label`-only retain for the one kind routed here today and keeps any
// other kind a different effect recorded on the player untouched.
if let Some(attr) = ChosenAttribute::from_choice(choice_type.clone(), choice) {
if let Some(player) = state.players.iter_mut().find(|p| p.id == pid) {
let replaced = std::mem::discriminant(&attr);
player
.chosen_attributes
.retain(|a| !matches!(a, ChosenAttribute::Label(_)));
.retain(|a| std::mem::discriminant(a) != replaced);
player.chosen_attributes.push(attr);
}
// CR 613.1: per-player labels feed statics/filters — re-run layers.
Expand Down Expand Up @@ -335,6 +343,20 @@ pub(crate) fn named_choice_authority(
persist: bool,
choice_type: &ChoiceType,
) -> (Option<NamedChoiceSource>, Option<PlayerId>) {
// CR 607.2d / CR 607.2m (by analogy): a persisting `Labeled` answer chosen
// during a per-player iteration is the planar anchor (Two Streams Facility),
// recorded on the choosing player instead of the source object.
//
// NOTE for future axes: `scoped_player` is NOT a reliable "this is a
// per-player fan-out" marker — it is also set for a plain triggered ability
// resolving for its own controller (measured: The Toymaker's Trap's upkeep
// trigger arrives here with `scoped_player == controller == Some(P0)`,
// indistinguishable from the first iteration of a real fan-out). Adding a
// choice kind to this routing therefore MOVES the answer off the source for
// single-chooser cards too, which breaks any object-scoped reader. The
// per-player secret number (CR 101.4) is instead recorded ADDITIVELY by
// `record_player_chosen_number`, leaving every existing source binding
// intact.
let persist_player = (persist && matches!(choice_type, ChoiceType::Labeled { .. }))
.then_some(ability.scoped_player)
.flatten();
Expand All @@ -361,6 +383,51 @@ pub(crate) fn named_choice_authority(
)
}

/// CR 101.4 + CR 608.2d: Record the number a PLAYER chose onto that player, as
/// the per-resolution ledger [`crate::types::ability::QuantityRef::PlayerChosenNumber`]
/// folds into "the highest / lowest number" (Wheel of Misfortune, Menacing Ogre,
/// Life at Stake).
///
/// ADDITIVE, not a reroute: the source-object binding that `bind_named_choice`
/// performs is left exactly as it was, so a single-chooser card whose reader is
/// object-scoped (The Toymaker's Trap's committed number, read through
/// `QuantityRef::ChosenNumber`) is unaffected. The two axes answer different
/// questions — "what number is committed on this permanent" versus "what number
/// did this player choose" — and a card may legitimately want either.
///
/// Recording it for EVERY number choice rather than only for a detected
/// per-player fan-out is deliberate: `ResolvedAbility::scoped_player` is set for
/// a plain triggered ability resolving for its own controller as well as for a
/// real fan-out iteration, so there is no reliable runtime marker to gate on.
/// The write is harmless where nothing reads it — the ledger is cleared at every
/// top-level resolution entry (`effects::resolve_ability_chain`, depth 0), and
/// `game::visibility` keeps a player's number private to that player, so an
/// unread copy can neither leak nor survive into a later resolution.
///
/// Replace-on-rechoose: a player holds exactly one chosen number, so a second
/// choice in the same resolution supersedes the first.
pub(crate) fn record_player_chosen_number(
state: &mut GameState,
chooser: PlayerId,
choice_type: &ChoiceType,
choice: &str,
) {
if !matches!(choice_type, ChoiceType::NumberRange { .. }) {
return;
}
let Ok(value) = choice.parse::<u8>() else {
return;
};
if let Some(player) = state.players.iter_mut().find(|p| p.id == chooser) {
player
.chosen_attributes
.retain(|attribute| !matches!(attribute, ChosenAttribute::Number(_)));
player
.chosen_attributes
.push(ChosenAttribute::Number(value));
}
}

fn register_exact_named_choice_source(state: &mut GameState, source: Option<&NamedChoiceSource>) {
let Some(source) = source.filter(|source| source.is_exact_object_and_resolution()) else {
return;
Expand Down
73 changes: 69 additions & 4 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ use crate::game::conditions::{
use crate::game::filter;
use crate::game::speed::has_max_speed;
use crate::types::ability::{
AbilityCondition, AbilityCost, AbilityKind, CardPlayMode, CardTypeSetSource, ControllerRef,
CopyRetargetPermission, CostPaidObjectSnapshot, EachDamageRecipient, Effect, EffectError,
EffectKind, EffectOutcomeSignal, EffectScope, FilterProp, OpponentMayScope, PlayerFilter,
PlayerScope, QuantityExpr, QuantityRef, RepeatContinuation, ResolvedAbility,
AbilityCondition, AbilityCost, AbilityKind, CardPlayMode, CardTypeSetSource, ChosenAttribute,
ControllerRef, CopyRetargetPermission, CostPaidObjectSnapshot, EachDamageRecipient, Effect,
EffectError, EffectKind, EffectOutcomeSignal, EffectScope, FilterProp, OpponentMayScope,
PlayerFilter, PlayerScope, QuantityExpr, QuantityRef, RepeatContinuation, ResolvedAbility,
RevealUntilDisposition, SacrificeCost, SacrificeRequirement, SharedQuality,
SharedQualityRelation, SiblingCondition, SubAbilityLink, TapStateChange, TargetChoiceTiming,
TargetFilter, TargetRef, ThisWayCause,
Expand Down Expand Up @@ -644,6 +644,11 @@ pub(crate) fn candidate_player_scalar(p: &Player, attr: &QuantityRef) -> Option<
QuantityRef::CardsDrawnThisTurn { .. } => {
Some(u32_to_i32_saturating(p.cards_drawn_this_turn))
}
// CR 101.4 + CR 608.2d: the number this candidate secretly chose during
// the current resolution. `None` for a player who chose nothing, which
// fails the candidate predicate CLOSED — "each player who didn't choose
// the lowest number" must not sweep in a player who never chose at all.
QuantityRef::PlayerChosenNumber { .. } => p.chosen_number().map(i32::from),
_ => None,
}
}
Expand Down Expand Up @@ -8319,6 +8324,22 @@ pub fn resolve_ability_chain(
// alongside `last_zone_changed_ids` so cross-resolution leakage is
// impossible.
state.last_vote_ballots = crate::im::Vector::new();
// CR 101.4 + CR 608.2d: Per-resolution secret-number ledger. A per-player
// `Effect::Choose { NumberRange }` fan-out records each answer as
// `ChosenAttribute::Number` on the chooser (`bind_named_choice`), and
// `QuantityRef::PlayerChosenNumber` folds those into "the highest/lowest
// number". `Player::chosen_attributes` is otherwise DURABLE (players never
// change zones), so without this reset a later card whose choosers are a
// SUBSET of the table — Life at Stake's "you and target creature's
// controller" — would fold in bystanders' numbers left over from an
// earlier Wheel of Misfortune. Cleared alongside `last_vote_ballots`, the
// sibling per-player choice ledger, for the same reason. The player axis
// stores no other `Number`, so nothing else is disturbed.
for player in state.players.iter_mut() {
player
.chosen_attributes
.retain(|attribute| !matches!(attribute, ChosenAttribute::Number(_)));
}
state.last_effect_amount = None;
// CR 120.10: resolution-local excess channel resets with its total twin.
state.last_effect_excess_amount = None;
Expand Down Expand Up @@ -22184,6 +22205,50 @@ mod tests {
);
}

/// CR 101.4 + CR 608.2d: the per-player secret-number ledger is cleared at
/// chain depth 0 too, for the same reason as `last_vote_ballots`. Players
/// never change zones, so `Player::chosen_attributes` is otherwise durable:
/// without the reset, a card whose choosers are a SUBSET of the table (Life
/// at Stake's two choosers) would fold bystanders' numbers left over from an
/// earlier Wheel of Misfortune into its "highest number".
///
/// Fail-on-revert: drop the reset and the stale `Number(9)` below survives,
/// so the extremum reads 9 instead of 0.
#[test]
fn player_chosen_numbers_clear_at_chain_boundary() {
use crate::types::ability::{AggregateFunction, ChosenAttribute, PlayerScope};

let mut state = GameState::new_two_player(42);
// A number left behind by an earlier resolution.
state.players[1].chosen_attributes = vec![ChosenAttribute::Number(9)];

let ability = ResolvedAbility::new(Effect::NoOp, vec![], ObjectId(100), PlayerId(0));
let mut events = Vec::new();
resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap();

assert!(
state.players[1].chosen_attributes.is_empty(),
"a fresh top-level resolution must not inherit a prior one's secret numbers"
);
assert_eq!(
crate::game::quantity::resolve_quantity(
&state,
&QuantityExpr::Ref {
qty: QuantityRef::PlayerChosenNumber {
player: PlayerScope::AllPlayers {
aggregate: AggregateFunction::Max,
exclude: None,
},
},
},
PlayerId(0),
ObjectId(100),
),
0,
"with no numbers chosen this resolution the extremum is empty"
);
}

/// CR 608.2c + CR 109.5: "for each opponent who searched their library
/// this way" relies on `player_actions_this_way` accumulating across
/// player_scope iterations.
Expand Down
20 changes: 17 additions & 3 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16035,9 +16035,23 @@ mod stage2_injector_tests {
// shifts combine with #6958's paid-cast outcome exclusion and
// #6976's conditional-branch exclusions. None creates an
// `OptionalEffect` prompt. Re-pinned against the merged source.
"game/effects/mod.rs:6252".to_string(),
"game/effects/mod.rs:6329".to_string(),
"game/effects/mod.rs:9522".to_string(),
// Wheel of Misfortune round: `:6252/:6329/:9522 => :6257/:6334/:9543`,
// `+5/+5/+21`, and the asymmetry IS the measurement. `git diff -U0`
// on effects/mod.rs has exactly two non-test hunks:
// `@@ -646,0 +647,5 @@` — the `QuantityRef::PlayerChosenNumber` arm
// added to `candidate_player_scalar`. Above ALL THREE producers,
// and the whole `+5`.
// `@@ -8321,0 +8327,16 @@` — the depth-0 per-player secret-number
// ledger reset in `resolve_ability_chain`. Above the THIRD producer
// only (and below the first two), which is the further `+16`.
// Both hunks are a pure scalar READ and a `chosen_attributes` CLEAR
// respectively: neither raises a `WaitingFor`, so neither mints a
// prompt. All three producers remain byte-identical
// `WaitingFor::OptionalEffectChoice` assignments inside the functions
// this row names. The remaining hunks are an import line and `mod tests`.
"game/effects/mod.rs:6257".to_string(),
"game/effects/mod.rs:6334".to_string(),
"game/effects/mod.rs:9543".to_string(),
// UNMOVED across the rebase, and that is itself evidence the SET did not
// move: a census that had gained or lost a producer would not leave this
// entry both byte-identical AND at the same coordinate.
Expand Down
5 changes: 5 additions & 0 deletions crates/engine/src/game/engine_resolution_choices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6033,6 +6033,11 @@ pub(super) fn handle_resolution_choice(
source.as_mut(),
persist_player,
);
// CR 101.4 + CR 608.2d: additionally record a chosen NUMBER on the
// player who chose it, so a later clause can read every player's
// answer back ("the highest number", "each player who didn't choose
// the lowest number"). Additive to the source binding above.
effects::choose::record_player_chosen_number(state, player, &choice_type, &choice);
if let Some(context) = updated_context {
if let Some(frame) = state.active_ability_continuation_frame_mut() {
frame
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2829,6 +2829,7 @@ fn quantity_ref_reads_zone(qty: &QuantityRef, zone: Zone) -> bool {
// Per-turn bend-type tracking (Avatar Aang) — turn history, not a zone read.
| QuantityRef::BendTypesThisTurn
| QuantityRef::ChosenNumber
| QuantityRef::PlayerChosenNumber { .. }
| QuantityRef::ColorsInCommandersColorIdentity
| QuantityRef::CommanderCastFromCommandZoneCount
| QuantityRef::ConvokedCreatureCount
Expand Down Expand Up @@ -3150,6 +3151,7 @@ fn quantity_ref_reads_life(qty: &QuantityRef) -> bool {
| QuantityRef::LandsPlayedThisTurn { .. }
| QuantityRef::TurnsTaken
| QuantityRef::ChosenNumber
| QuantityRef::PlayerChosenNumber { .. }
| QuantityRef::DescendedThisTurn
| QuantityRef::LoyaltyAbilitiesActivatedThisTurn { .. }
| QuantityRef::SpellsCastLastTurn
Expand Down
Loading
Loading