diff --git a/crates/engine/src/game/effects/draw.rs b/crates/engine/src/game/effects/draw.rs index bbcff0b84d..88f32e299d 100644 --- a/crates/engine/src/game/effects/draw.rs +++ b/crates/engine/src/game/effects/draw.rs @@ -215,6 +215,17 @@ pub(crate) fn start_draw_sequence_with_origin( origin: DrawSequenceOrigin, events: &mut Vec, ) -> replacement::ReplacementResult { + // CR 121.2a: A count-form draw replacement ("If [a player] would draw N or + // more cards, ...") modifies the draw INSTRUCTION "before considering any of + // the individual card draws". Consult those instruction-scoped shields here, + // against the whole `count`, before the instruction splits into individual + // draws below — the per-unit seam only ever sees `count == 1`, so it can + // never enforce a threshold of two or more. + let (count, applied) = + match replacement::replace_draw_instruction(state, player, count, applied, events) { + replacement::DrawInstructionOutcome::Proceed { count, applied } => (count, applied), + replacement::DrawInstructionOutcome::Replaced(result) => return result, + }; let frame_id = state.push_draw_sequence_with_origin(player, count, applied, origin); resume_draw_sequence(state, frame_id, events) } diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 7f53b1e119..5bd5b400ea 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -5,7 +5,7 @@ use std::sync::LazyLock; use crate::types::ability::{ AbilityCost, AbilityDefinition, CastingPermission, CombatDamageScope, ControllerRef, DamageModification, DamageRedirectTarget, DamageTargetFilter, DamageTargetPlayerScope, - Duration, Effect, EffectScope, ManaSpendPermission, PermissionGrantee, + DrawReplacementScope, Duration, Effect, EffectScope, ManaSpendPermission, PermissionGrantee, PostReplacementContinuation, PreventionAmount, QuantityExpr, QuantityModification, ReplacementCondition, ReplacementDefinition, ReplacementMode, ResolvedAbility, ShieldKind, TapStateChange, TargetFilter, TargetRef, @@ -5972,6 +5972,37 @@ fn object_replacement_candidate_applies( return false; } } + if let (Some(draw_scope), ProposedEvent::Draw { count, .. }) = (&repl_def.draw_scope, event) { + // CR 121.2a + CR 121.6b: a draw resolves in two seams and each shield is + // scoped to exactly one of them (`state.draw_consult_scope`). This is the + // seam that consumes the parsed threshold — sibling to `combat_scope` / + // `damage_target_filter` above. + match state.draw_consult_scope { + // Pre-split whole-instruction consult: ONLY a count-form + // ("would draw N or more cards") shield hooks the instruction, and + // only when it draws at least its printed threshold N. An + // IndividualDraw shield (Dredge, Notion Thief) must wait for its + // individual card below. + crate::types::ability::DrawConsultScope::Instruction => match draw_scope { + DrawReplacementScope::InstructionCount { min } if count >= min => {} + _ => return false, + }, + // Per-card / non-split consult: an IndividualDraw shield hooks each + // card; a count-form shield hooks a non-split whole-count draw (the + // turn-based draw step, connive, gift) at/above threshold. On the + // split path the count-form shield already fired at the instruction + // seam and is guarded here by the `applied` set (CR 614.5). + crate::types::ability::DrawConsultScope::Individual => { + if let DrawReplacementScope::InstructionCount { min } = draw_scope { + // CR 121.2a: a sub-threshold instruction (e.g. a single-card + // draw against Alms Collector's "two or more") is untouched. + if count < min { + return false; + } + } + } + } + } if let ProposedEvent::AddCounter { placement, .. } = event { // CR 614.1a: `valid_player` is a *relative* scope; the subject axis selects // whom it is relative to. Actor-scoped replacements (Vorinclex/Halving @@ -8311,6 +8342,142 @@ pub fn replace_event( result } +/// Result of consulting count-form replacements against a whole draw +/// instruction (CR 121.2a). See [`replace_draw_instruction`]. +pub(crate) enum DrawInstructionOutcome { + /// No instruction-scoped shield fired (or one only modified the count). The + /// instruction proceeds to its individual card draws with this surviving + /// count and applied set. + Proceed { + count: u32, + applied: HashSet, + }, + /// A count-form shield fully substituted or prevented the instruction (its + /// substitute, if any, has already been drained in this resolution step). + /// No individual draws follow; the carried result is the caller's return. + Replaced(ReplacementResult), +} + +/// True when any functioning permanent carries a count-form +/// (`InstructionCount`) draw replacement. A cheap early-out so ordinary draws — +/// the overwhelming majority, with no Alms-Collector-class shield anywhere — +/// keep their exact prior per-card path with zero extra pipeline work. +/// +/// Enumerates through the same `functioning_abilities::active_replacements` +/// iterator the authoritative matcher uses, so the gate respects functioning +/// status (CR 614.1) and can neither under-count (a false negative would silently +/// skip a live shield) nor spuriously fire on a non-functioning definition. +fn any_instruction_count_draw_shield(state: &GameState) -> bool { + super::functioning_abilities::active_replacements(state).any(|(_, _, def)| { + matches!(def.event, ReplacementEvent::Draw) + && matches!( + def.draw_scope, + Some(DrawReplacementScope::InstructionCount { .. }) + ) + }) +} + +/// CR 121.2a: Consult count-form ("If [a player] would draw N or more cards, +/// ...") draw replacements against a whole draw instruction BEFORE it splits +/// into individual card draws. A count-form antecedent modifies the instruction +/// "before considering any of the individual card draws", so the per-card seam +/// (which only ever sees `count == 1`) can never enforce a threshold of two or +/// more — this is the only seam that can. +/// +/// Only `InstructionCount`-scoped shields are eligible here (via +/// `state.draw_consult_scope`); an `IndividualDraw` shield still hooks each card +/// downstream. A single mandatory count-form shield resolves synchronously (no +/// CR 616 ordering choice, no optional yes/no); anything else — two competing +/// count-form shields on one instruction, or an optional one — has no printed +/// exemplar and is deferred to the per-card seam rather than mis-order a pause +/// whose resume this seam does not model. +pub(crate) fn replace_draw_instruction( + state: &mut GameState, + player: PlayerId, + count: u32, + applied: HashSet, + events: &mut Vec, +) -> DrawInstructionOutcome { + use crate::types::ability::DrawConsultScope; + + // Fast path: no count-form shield exists, so the instruction seam is inert. + if count == 0 || !any_instruction_count_draw_shield(state) { + return DrawInstructionOutcome::Proceed { count, applied }; + } + + let registry = replacement_registry(); + let instruction = ProposedEvent::Draw { + player_id: player, + count, + applied: applied.clone(), + }; + + // Enter the pre-split seam so only count-form shields match (CR 121.2a). + let prev_scope = state.draw_consult_scope; + state.draw_consult_scope = DrawConsultScope::Instruction; + + let candidates = find_applicable_replacements(state, &instruction, registry); + // CR 616.1 + CR 614.13: a single mandatory shield is the only synchronous + // shape. Look the definition up mirroring `apply_single_replacement`'s source + // resolution (object or liminal entry). + let single_mandatory = candidates.len() == 1 && { + let rid = candidates[0]; + state + .objects + .get(&rid.source) + .or_else(|| state.liminal_entries.get(&rid.source).map(|e| &e.object)) + .and_then(|obj| obj.replacement_definitions.get(rid.index)) + .is_some_and(|def| matches!(def.mode, ReplacementMode::Mandatory)) + }; + + if !single_mandatory { + // strict-failure: CR 121.2a competing/optional instruction-count draw + // replacements need a player-ordering or yes/no choice at the pre-split + // seam — no printed card exercises it. Proceed unreplaced; a `count == 0` + // candidate scan that matched nothing lands here too. + state.draw_consult_scope = prev_scope; + return DrawInstructionOutcome::Proceed { count, applied }; + } + + let result = replace_event(state, instruction, events); + // CR 614.6 + CR 121.6: a full substitution (Alms Collector: "instead you and + // that player each draw a card") is pre-zeroed by `apply_single_replacement` + // and its substitute stashed as a post-replacement continuation. Drain it in + // the same resolution step, mirroring `draw_through_replacement`'s Execute + // arm, so the substitute runs before the (now zero-count) instruction below. + if !matches!(result, ReplacementResult::NeedsChoice(_)) && state.has_post_replacement_drain() { + let _ = crate::game::engine_replacement::apply_pending_post_replacement_effect( + state, None, None, None, events, + ); + } + state.draw_consult_scope = prev_scope; + + match result { + // CR 614.11a: a count modifier (Alhammarret's Archive-class instruction + // shield) leaves a nonzero survivor — proceed with the modified count, + // carrying the fired shield in `applied` so the per-card seam does not + // re-offer it (CR 614.5). + ReplacementResult::Execute(ProposedEvent::Draw { + count: surviving, + applied: surviving_applied, + .. + }) => DrawInstructionOutcome::Proceed { + count: surviving, + applied: surviving_applied, + }, + ReplacementResult::Execute(other) => { + debug_assert!( + false, + "draw instruction consult produced a non-Draw survivor: {other:?}" + ); + DrawInstructionOutcome::Proceed { count, applied } + } + result @ (ReplacementResult::Prevented | ReplacementResult::NeedsChoice(_)) => { + DrawInstructionOutcome::Replaced(result) + } + } +} + /// CR 510.2 + CR 615.7 + CR 615.13: Run the replacement pipeline over a whole /// simultaneous combat-damage batch. /// @@ -11628,6 +11795,202 @@ mod tests { ); } + /// Alms Collector — "If an opponent would draw two or more cards, instead you + /// and that player each draw a card." A count-form antecedent with threshold + /// N=2 (`DrawReplacementScope::InstructionCount { min: 2 }`). + fn alms_collector_draw_replacement_def() -> ReplacementDefinition { + // CR 614.6 + CR 121.2a: the substitute draws a fixed card each — its + // shape is irrelevant to applicability, which turns on scope + threshold. + let substitute = AbilityDefinition::new( + crate::types::ability::AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + let mut repl = ReplacementDefinition::new(ReplacementEvent::Draw) + .draw_scope(DrawReplacementScope::InstructionCount { min: 2 }); + // CR 614.1a: "an opponent would draw" scopes the shield to opponents of + // Alms Collector's controller (PlayerId(0)). + repl.valid_player = Some(crate::types::ability::ReplacementPlayerScope::Opponent); + repl.execute = Some(Box::new(substitute)); + repl + } + + /// CR 121.2a: a count-form "two or more" antecedent modifies the draw + /// *instruction*, and only when it draws at least N=2 cards. The parsed + /// threshold must reach the matcher: a single-card opponent draw slips past + /// the shield, a two-card opponent draw is caught. This is the runtime proof + /// the reviewer required — the parser retaining `min` is inert unless the + /// applicability seam consumes it. + #[test] + fn alms_collector_threshold_gates_draw_replacement_by_count() { + let mut state = test_state_with_object( + ObjectId(20), + Zone::Battlefield, + vec![alms_collector_draw_replacement_def()], + ); + // Alms Collector is controlled by PlayerId(0); PlayerId(1) is the opponent. + state.objects.get_mut(&ObjectId(20)).unwrap().controller = PlayerId(0); + let registry = build_replacement_registry(); + + // Opponent draws ONE card: below the "two or more" threshold — untouched. + let opponent_draws_one = ProposedEvent::Draw { + player_id: PlayerId(1), + count: 1, + applied: HashSet::new(), + }; + assert!( + find_applicable_replacements(&state, &opponent_draws_one, ®istry).is_empty(), + "CR 121.2a: a one-card opponent draw is below Alms Collector's N=2 threshold \ + and must NOT be replaced" + ); + + // Opponent draws TWO cards: meets the threshold — the shield applies. + let opponent_draws_two = ProposedEvent::Draw { + player_id: PlayerId(1), + count: 2, + applied: HashSet::new(), + }; + assert_eq!( + find_applicable_replacements(&state, &opponent_draws_two, ®istry).len(), + 1, + "CR 121.2a: a two-card opponent draw meets the N=2 threshold and must be replaced" + ); + + // CR 614.1a: even a threshold-meeting draw by the controller is out of + // scope — the antecedent is opponent-only. + let controller_draws_two = ProposedEvent::Draw { + player_id: PlayerId(0), + count: 2, + applied: HashSet::new(), + }; + assert!( + find_applicable_replacements(&state, &controller_draws_two, ®istry).is_empty(), + "CR 614.1a: Alms Collector's opponent-scoped shield must not apply to its \ + controller's own draw" + ); + } + + /// An Alms-Collector-class count-form shield whose substitute is a clean + /// full replacement (here a life gain) so the observable outcome is + /// deterministic: when it fires, the original draw is pre-zeroed (CR 614.6) + /// and the substitute runs instead. Opponent-scoped, threshold N=2. + fn alms_class_full_substitution_shield() -> ReplacementDefinition { + let substitute = AbilityDefinition::new( + crate::types::ability::AbilityKind::Spell, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 3 }, + player: TargetFilter::Controller, + }, + ); + let mut repl = ReplacementDefinition::new(ReplacementEvent::Draw) + .draw_scope(DrawReplacementScope::InstructionCount { min: 2 }); + repl.valid_player = Some(crate::types::ability::ReplacementPlayerScope::Opponent); + repl.execute = Some(Box::new(substitute)); + repl + } + + fn seed_library(state: &mut GameState, player_index: usize, size: usize) { + let base = 200 + (player_index as u64) * 100; + let owner = state.players[player_index].id; + let lib = &mut state.players[player_index].library; + lib.clear(); + let mut ids = Vec::new(); + for i in 0..size as u64 { + let object_id = ObjectId(base + i); + lib.push_back(object_id); + ids.push((object_id, i)); + } + for (object_id, i) in ids { + state.objects.insert( + object_id, + GameObject::new( + object_id, + CardId(base + i), + owner, + format!("Library Card {i}"), + Zone::Library, + ), + ); + } + } + + /// CR 121.2a end-to-end: the count-form threshold is enforced at the + /// pre-split draw-instruction seam, driving the real production path + /// (`start_draw_sequence` → `replace_draw_instruction`). A one-card opponent + /// draw is below the "two or more" threshold and resolves normally; a + /// two-card opponent draw meets it and is fully replaced (opponent draws + /// nothing, the substitute runs) — the split into per-unit `count == 1` + /// draws can never see the threshold, so this proves the seam, not just the + /// isolated matcher. + #[test] + fn alms_class_replaces_instruction_at_pre_split_seam() { + use crate::game::effects::draw::start_draw_sequence; + + // --- Control: opponent draws ONE card → below threshold, not replaced. + let mut state = test_state_with_object( + ObjectId(20), + Zone::Battlefield, + vec![alms_class_full_substitution_shield()], + ); + state.objects.get_mut(&ObjectId(20)).unwrap().controller = PlayerId(0); + seed_library(&mut state, 1, 4); + let controller_life_before = state.players[0].life; + let opponent_hand_before = state.players[1].hand.len(); + let mut events = Vec::new(); + + let result = start_draw_sequence(&mut state, PlayerId(1), 1, &mut events); + assert!( + matches!(result, ReplacementResult::Execute(_)), + "a one-card draw resolves without pausing, got {result:?}" + ); + assert_eq!( + state.players[1].hand.len(), + opponent_hand_before + 1, + "CR 121.2a: a one-card opponent draw is below the N=2 threshold and must draw normally" + ); + assert_eq!( + state.players[0].life, controller_life_before, + "the sub-threshold draw must not fire the shield's substitute" + ); + + // --- Fire: opponent draws TWO cards → meets threshold, replaced whole. + let mut state = test_state_with_object( + ObjectId(20), + Zone::Battlefield, + vec![alms_class_full_substitution_shield()], + ); + state.objects.get_mut(&ObjectId(20)).unwrap().controller = PlayerId(0); + seed_library(&mut state, 1, 4); + let controller_life_before = state.players[0].life; + let opponent_hand_before = state.players[1].hand.len(); + let mut events = Vec::new(); + + let result = start_draw_sequence(&mut state, PlayerId(1), 2, &mut events); + assert!( + matches!(result, ReplacementResult::Execute(_)), + "the mandatory instruction-scoped shield resolves synchronously, got {result:?}" + ); + assert_eq!( + state.players[1].hand.len(), + opponent_hand_before, + "CR 121.2a + CR 614.6: the two-card draw is fully replaced — the opponent draws \ + nothing (the original instruction is pre-zeroed)" + ); + assert_eq!( + state.players[0].life, + controller_life_before + 3, + "the substitute (a life gain, standing in for Alms Collector's 'you and that player \ + each draw a card') runs in place of the replaced instruction" + ); + assert!( + state.draw_sequences.is_empty(), + "the replaced instruction leaves no draw frame parked, got {:?}", + state.draw_sequences + ); + } + // --------------------------------------------------------------------------- // CR 121.6b (GitHub Dredge/Bazaar-of-Baghdad report): a multi-card draw must // offer replacement independently per unit, not as one atomic batch. Drives diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 34c152300e..29bed90bca 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -446,20 +446,33 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option or more cards" threshold, not a "two or more" special case). A count + // form scopes the whole instruction (InstructionCount); "N >= 2" additionally + // carries a typed threshold (Alms Collector) wired below as an OnlyIfQuantity + // over the pending draw count. "one or more" (N == 1) is vacuously true, so it + // takes no threshold — its InstructionCount comes from the antecedent alone. + let draw_antecedent = nom_primitives::scan_at_word_boundaries(&lower, |i| { alt(( value( - DrawReplacementScope::IndividualDraw, + (DrawReplacementScope::IndividualDraw, None), tag::<_, _, OracleError<'_>>("would draw a card"), ), - value( - DrawReplacementScope::InstructionCount, - tag("would draw one or more cards"), - ), + ( + tag("would draw "), + nom_primitives::parse_number, + tag(" or more cards"), + ) + .map(|(_, n, _)| { + ( + DrawReplacementScope::InstructionCount, + if n >= 2 { Some(n) } else { None }, + ) + }), )) .parse(i) }); - if let Some(draw_scope) = draw_scope { + if let Some((draw_scope, threshold_n)) = draw_antecedent { // CR 614.1a: An "As long as , if you would draw a // card, ..." gate (Archmage Ascension) precedes the draw antecedent with // its own comma clause. Split it off so effect extraction anchors on the @@ -581,6 +594,29 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option {} } } + // CR 121.2a: a "draw N or more cards" antecedent (N >= 2) gates the + // replacement on the pending draw *instruction* being for at least N + // cards. Carry N as a typed `OnlyIfQuantity` over the event's draw count + // (`EventContextAmount`), evaluated at the instruction stage before the + // draw decomposes into individual card draws — composed (And) with any + // as-long-as / while / except-first gate already set. Alms Collector: + // "If an opponent would draw two or more cards, ...". + if let Some(n) = threshold_n { + let threshold = ReplacementCondition::OnlyIfQuantity { + lhs: QuantityExpr::Ref { + qty: QuantityRef::EventContextAmount, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: n as i32 }, + active_player_req: None, + }; + def.condition = Some(match def.condition.take() { + Some(existing) => ReplacementCondition::And { + conditions: vec![existing, threshold], + }, + None => threshold, + }); + } return Some(def); } @@ -18765,6 +18801,42 @@ mod tests { )); } + #[test] + fn alms_collector_count_form_threshold_gates_on_instruction_draw_count() { + // #5678 / CR 121.2a: "If an opponent would draw two or more cards, instead + // you and that player each draw a card." The count-form antecedent must + // (1) scope the whole instruction (InstructionCount), (2) carry N=2 as a + // typed OnlyIfQuantity over the pending draw count (EventContextAmount) so + // a one-card draw does not match, and (3) apply only to an opponent's draw. + let def = parse_replacement_line( + "If an opponent would draw two or more cards, instead you and that player each draw a card.", + "Alms Collector", + ) + .expect("Alms Collector's count-form antecedent must lower to a Draw replacement"); + assert_eq!(def.event, ReplacementEvent::Draw); + assert_eq!(def.draw_scope, Some(DrawReplacementScope::InstructionCount)); + assert_eq!(def.valid_player, Some(ReplacementPlayerScope::Opponent)); + assert_eq!( + def.condition, + Some(ReplacementCondition::OnlyIfQuantity { + lhs: QuantityExpr::Ref { + qty: QuantityRef::EventContextAmount, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + active_player_req: None, + }), + "N=2 must lower to OnlyIfQuantity(EventContextAmount >= 2)" + ); + // The substitute is a fixed per-player draw (you + the drawing opponent), + // not a count-modifier -- InstructionCount comes from the antecedent + // threshold, not the execute shape (the discipline the maintainer required). + assert!(matches!( + def.execute.as_deref().map(|a| &*a.effect), + Some(Effect::Draw { .. }) + )); + } + #[test] fn draw_replacement_leading_instead_prefix_blood_scrivener() { // CR 614.1a: "instead you draw two cards" — leading "instead" form with diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 64269f60d1..2f8bd2dba8 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -19947,6 +19947,30 @@ pub enum PlaneswalkReplacementScope { PlanarDieOnly, } +/// CR 121.2a + CR 121.6b: which [`DrawReplacementScope`] the in-progress draw +/// replacement consult is eligible to match. A draw instruction resolves in two +/// seams — the whole-instruction consult that runs *before* the instruction +/// splits into individual card draws, and the per-card consult that runs for +/// each individual draw — and a shield is scoped to exactly one of them. +/// +/// The default, [`Individual`](Self::Individual), is the per-card seam: an +/// `IndividualDraw` shield hooks each card, and a count-form +/// `InstructionCount` shield hooks a non-split whole-count draw (the turn-based +/// draw step, connive, gift) at or above its printed threshold. +/// [`Instruction`](Self::Instruction) is set only by +/// `game::replacement::replace_draw_instruction` for the pre-split consult, so +/// only `InstructionCount` shields see the whole instruction there — an +/// `IndividualDraw` shield must wait for its individual card. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum DrawConsultScope { + /// The per-card / non-split draw seam. See the type-level docs. + #[default] + Individual, + /// The pre-split whole-instruction seam (CR 121.2a). Only `InstructionCount` + /// count-form shields are eligible. + Instruction, +} + /// CR 614.1a: Which player(s) a replacement effect applies to, scoped relative /// to the replacement source player. For permanents/spells this is the source's /// controller; for cards outside the battlefield/stack, CR 109.4 + CR 108.4a diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index b0483203a3..ef877def8a 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -10980,6 +10980,13 @@ impl<'de> Deserialize<'de> for PendingLiminalEntryResume { } } +/// serde skip predicate for [`GameState::draw_consult_scope`] — the transient +/// consult scope is only ever `Instruction` mid-consult, which never spans a +/// serialization boundary, so the `Individual` default is elided. +fn draw_consult_scope_is_individual(scope: &crate::types::ability::DrawConsultScope) -> bool { + matches!(scope, crate::types::ability::DrawConsultScope::Individual) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GameState { pub turn_number: u32, @@ -11165,6 +11172,13 @@ pub struct GameState { #[serde(default, skip_serializing_if = "Option::is_none")] pub post_replacement_token_substitution_count: Option, + /// CR 121.2a: which draw-replacement scope the in-progress replacement + /// consult is eligible to match. See [`DrawConsultScope`]. Transient — set to + /// `Instruction` only for the duration of the pre-split whole-instruction + /// consult in `game::replacement::replace_draw_instruction`, and restored to + /// the `Individual` default (skipped by serde) immediately afterward. + #[serde(default, skip_serializing_if = "draw_consult_scope_is_individual")] + pub draw_consult_scope: crate::types::ability::DrawConsultScope, /// CR 614.12a + CR 707.9 + CR 603.2: `ZoneChanged`-to-battlefield events /// for an object whose entry is paused mid-resolution awaiting an /// interactive choice (e.g. `WaitingFor::CopyTargetChoice`). Per CR @@ -16071,6 +16085,7 @@ impl GameState { replacement_may_cost_paused: false, post_replacement_token_choice_applied: None, post_replacement_token_substitution_count: None, + draw_consult_scope: crate::types::ability::DrawConsultScope::Individual, deferred_entry_events: Vec::new(), layers_dirty: LayersDirty::full(), static_gate_truth: im::HashMap::new(), @@ -17300,6 +17315,7 @@ fn _gamestate_partition_is_total(s: &GameState) { pending_replacement: _, replacement_may_cost_paused: _, post_replacement_token_choice_applied: _, + draw_consult_scope: _, deferred_entry_events: _, layers_dirty: _, static_gate_truth: _, diff --git a/scripts/draw-replacement-corpus.tsv b/scripts/draw-replacement-corpus.tsv index c5fc38a719..29be33e047 100644 --- a/scripts/draw-replacement-corpus.tsv +++ b/scripts/draw-replacement-corpus.tsv @@ -25,6 +25,7 @@ # abundance Draw Optional none Choose - IndividualDraw alhammarret's archive Draw Mandatory none Draw nested-draw IndividualDraw +alms collector Draw Mandatory none Draw nested-draw InstructionCount archmage ascension Draw Optional none SearchLibrary - IndividualDraw asmodeus the archfiend Draw Mandatory none ExileTop - IndividualDraw bard, king of dale Draw Mandatory none Draw nested-draw IndividualDraw diff --git a/scripts/draw_replacement_census.py b/scripts/draw_replacement_census.py index bed75827af..c3abc9f37b 100755 --- a/scripts/draw_replacement_census.py +++ b/scripts/draw_replacement_census.py @@ -308,6 +308,15 @@ def classify_scope(card: str, repl: dict) -> str: f"(and to KNOWN_QUANTITY_MODIFICATIONS), then re-freeze." ) + # CR 121.2a: a typed antecedent threshold -- "draw N or more cards" lowered to + # an OnlyIfQuantity gating on the event's own draw count (EventContextAmount) -- + # modifies the whole instruction before any individual draw, so it is + # InstructionCount even when the substitute is a fixed draw (Alms Collector). + # The instruction-count signal lives in the condition subtree, not just the + # execute's count. + condition = repl.get("condition") + if condition is not None and reads_event_context_amount(condition): + return "InstructionCount" effect = ((repl.get("execute") or {}).get("effect")) or {} if effect.get("type") == "Draw" and reads_event_context_amount(effect.get("count")): return "InstructionCount"