diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index ef10eb86d0..80ad099b9d 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1417,9 +1417,13 @@ export type KeywordAction = export type StackEntryKind = | { type: "Spell"; data: { card_id: CardId; ability?: ResolvedAbility; actual_mana_spent?: number } } | { type: "ActivatedAbility"; data: { source_id: ObjectId; ability: ResolvedAbility } } - | { type: "TriggeredAbility"; data: { source_id: ObjectId; ability: ResolvedAbility; description?: string; source_name?: string } } + | { type: "TriggeredAbility"; data: { source_id: ObjectId; ability: ResolvedAbility; description?: string; source_name?: string; provenance?: SyntheticTriggerProvenance } } | { type: "KeywordAction"; data: { action: KeywordAction } }; +/** Engine-authored identity for a synthesized triggered ability. */ +export type SyntheticTriggerProvenance = + | { type: "Storm"; data: { copy_count: number } }; + export interface StackEntry { id: ObjectId; source_id: ObjectId; @@ -1470,6 +1474,7 @@ export interface StackEntryDisplay { targets?: StackTargetDisplay[]; paid?: StackPaidFactView[]; trigger_context?: TriggerContextDisplay[]; + provenance?: SyntheticTriggerProvenance; } // ── Pending Cast (for target selection) ────────────────────────────────── @@ -2798,6 +2803,11 @@ export interface DerivedViews { * infer game logic from raw abilities. */ stack_entry_details?: Record; + /** + * CR 702.40a: prospective Storm copy counts for the viewing player's own + * hand, keyed by hand object id. The engine owns qualification and counting. + */ + prospective_storm_counts?: Record; /** * Engine-authored "Auras attached to player X" projection. Players have no * `attachments` back-link on the GameObject side because they aren't diff --git a/client/src/components/hand/MobileHandDrawer.tsx b/client/src/components/hand/MobileHandDrawer.tsx index b860901ee8..42af9c78f9 100644 --- a/client/src/components/hand/MobileHandDrawer.tsx +++ b/client/src/components/hand/MobileHandDrawer.tsx @@ -22,6 +22,7 @@ import { CardOrganizerToolbar } from "../modal/cardChoice/CardOrganizerToolbar.t // Stable empty lookup so an undefined `objects` (pre-game) never busts the // organizer's filter memo with a fresh `{}` each render. const EMPTY_OBJECTS: Record = {}; +const EMPTY_STORM_COUNTS: Record = {}; export function MobileHandDrawer() { const { t } = useTranslation("game"); @@ -30,6 +31,9 @@ export function MobileHandDrawer() { const playerId = usePerspectivePlayerId(); const player = useGameStore((s) => s.gameState?.players[playerId]); const objects = useGameStore((s) => s.gameState?.objects); + const prospectiveStormCounts = useGameStore( + (s) => s.gameState?.derived?.prospective_storm_counts ?? EMPTY_STORM_COUNTS, + ); const legalActionsByObject = useGameStore((s) => s.legalActionsByObject); const inspectObject = useUiStore((s) => s.inspectObject); const setPendingAbilityChoice = useUiStore((s) => s.setPendingAbilityChoice); @@ -200,6 +204,7 @@ export function MobileHandDrawer() { manaCost={obj.mana_cost} isPlayable={isPlayable} hasPriority={hasPriority} + stormCopyCount={prospectiveStormCounts[String(obj.id)]} onPlay={playCard} onDebugOpen={handleDebugOpen} /> @@ -219,6 +224,7 @@ interface DrawerCardProps { manaCost: ManaCost; isPlayable: boolean; hasPriority: boolean; + stormCopyCount?: number; onPlay: (objectId: number) => void; onDebugOpen: (objectId: number, x: number, y: number) => void; } @@ -229,9 +235,11 @@ const DrawerCard = memo(function DrawerCard({ manaCost, isPlayable, hasPriority, + stormCopyCount, onPlay, onDebugOpen, }: DrawerCardProps) { + const { t } = useTranslation("game"); const inspectObject = useUiStore((s) => s.inspectObject); const setPreviewSticky = useUiStore((s) => s.setPreviewSticky); const effectiveCost = useGameStore((s) => s.spellCosts[String(objectId)]); @@ -296,6 +304,14 @@ const DrawerCard = memo(function DrawerCard({
+ {stormCopyCount !== undefined && ( + + {stormCopyCount} + + )} ); }); diff --git a/client/src/components/hand/MobileHeldHandCard.tsx b/client/src/components/hand/MobileHeldHandCard.tsx index 934a22ff3b..75318e8c39 100644 --- a/client/src/components/hand/MobileHeldHandCard.tsx +++ b/client/src/components/hand/MobileHeldHandCard.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect } from "react"; import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; import { motion, useMotionValue, @@ -20,6 +21,7 @@ import { ManaCostPips } from "../mana/ManaCostPips.tsx"; interface MobileHeldHandCardProps { gesture: MobileHandGesture | null; object: GameObject | null; + stormCopyCount?: number; } /** @@ -29,7 +31,8 @@ interface MobileHeldHandCardProps { * fixed-position child into a container-relative element. The real HandCard * remains keyed in the fan but collapsed until the gesture ends. */ -export function MobileHeldHandCard({ gesture, object }: MobileHeldHandCardProps) { +export function MobileHeldHandCard({ gesture, object, stormCopyCount }: MobileHeldHandCardProps) { + const { t } = useTranslation("game"); const effectiveCost = useGameStore((s) => object ? s.spellCosts[String(object.id)] : undefined, ); @@ -130,6 +133,14 @@ export function MobileHeldHandCard({ gesture, object }: MobileHeldHandCardProps)
+ {stormCopyCount !== undefined && ( + + {stormCopyCount} + + )} , document.body, ); diff --git a/client/src/components/hand/PlayerHand.tsx b/client/src/components/hand/PlayerHand.tsx index f53e1811ad..b0e55152a5 100644 --- a/client/src/components/hand/PlayerHand.tsx +++ b/client/src/components/hand/PlayerHand.tsx @@ -51,6 +51,7 @@ import { MobileHeldHandCard } from "./MobileHeldHandCard.tsx"; // Stable empty lookup so an undefined `objects` (pre-game) never busts the // organizer's filter memo with a fresh `{}` each render. const EMPTY_OBJECTS: Record = {}; +const EMPTY_STORM_COUNTS: Record = {}; // The whole-row fan geometry — the overlap / tilt / arc that lays hand cards // (plus the castable exile / graveyard "wings") out as one held hand — now @@ -82,6 +83,9 @@ export function PlayerHand() { // otherwise rebuild the drag-end callback on every update. const hand = player?.hand; const objects = useGameStore((s) => s.gameState?.objects); + const prospectiveStormCounts = useGameStore( + (s) => s.gameState?.derived?.prospective_storm_counts ?? EMPTY_STORM_COUNTS, + ); const mobileHandGesture = useUiStore((s) => s.mobileHandGesture); // Use dispatchAction (animation pipeline) instead of store dispatch const inspectObject = useUiStore((s) => s.inspectObject); @@ -663,6 +667,7 @@ export function PlayerHand() { isSelected={selectedCardId === obj.id} hasPriority={hasPriority} isMobile={isMobile} + stormCopyCount={prospectiveStormCounts[String(obj.id)]} onDragEnd={handleDragEnd} onDrag={handleDrag} onClick={handleCardClick} @@ -784,6 +789,11 @@ export function PlayerHand() { ? objects[mobileHandGesture.objectId] : null } + stormCopyCount={ + mobileHandGesture + ? prospectiveStormCounts[String(mobileHandGesture.objectId)] + : undefined + } /> ); @@ -811,6 +821,7 @@ interface HandCardProps { isDragging: boolean; hasPriority: boolean; isMobile: boolean; + stormCopyCount?: number; onDragStart: (id: number) => void; onDragStop: () => void; onDragEnd: (objectId: number, event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => boolean; @@ -843,6 +854,7 @@ const HandCard = memo(function HandCard({ isDragging, hasPriority, isMobile, + stormCopyCount, onDragStart: onDragStartProp, onDragStop, onDragEnd, @@ -852,6 +864,7 @@ const HandCard = memo(function HandCard({ onMouseEnter, onMouseLeave, }: HandCardProps) { + const { t } = useTranslation("game"); const inspectObject = useUiStore((s) => s.inspectObject); const setDragging = useUiStore((s) => s.setDragging); const isMobileDragged = useUiStore( @@ -997,6 +1010,14 @@ const HandCard = memo(function HandCard({ unimplementedMechanics={unimplementedMechanics} className="!w-[var(--hand-card-w)] !h-[var(--hand-card-h)]" /> + {stormCopyCount !== undefined && ( + + {stormCopyCount} + + )} {/* Inner-edge drop highlights. Always rendered, normally invisible; their opacity is driven by MotionValues so the glow toggles without a re-render. They sit inside the displaced + rotated card, so they track diff --git a/client/src/components/stack/StackEntry.tsx b/client/src/components/stack/StackEntry.tsx index 6ef1bdfecf..03dd969c44 100644 --- a/client/src/components/stack/StackEntry.tsx +++ b/client/src/components/stack/StackEntry.tsx @@ -152,6 +152,9 @@ export function StackEntry({ entry, index, isTop, isPending, cardSize, style, on details?.paid?.filter((fact) => fact.type !== "XValue").map((fact) => formatPaidFact(fact, t)) ?? []; const contextLabels = details?.trigger_context?.map((context) => context.label) ?? []; + const stormCopyCount = details?.provenance?.type === "Storm" + ? details.provenance.data.copy_count + : undefined; const controllerLabel = entry.controller === playerId ? t("stack.controllerYou") : t("stack.controllerOpp"); const seatColor = useSeatColor(entry.controller); const controllerInitial = @@ -333,8 +336,16 @@ export function StackEntry({ entry, index, isTop, isPending, cardSize, style, on )} - {(targetLabels.length > 0 || paidLabels.length > 0 || contextLabels.length > 0) && ( + {(stormCopyCount !== undefined || targetLabels.length > 0 || paidLabels.length > 0 || contextLabels.length > 0) && (
+ {stormCopyCount !== undefined && ( + + {t("storm.copies", { count: stormCopyCount })} + + )} {targetLabels.slice(0, 2).map((label) => ( bool { @@ -98,6 +99,10 @@ pub struct StackEntryDisplay { pub paid: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub trigger_context: Vec, + /// Typed synthesized-trigger presentation provenance. This is the only + /// stack provenance surface consumed by the frontend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, } /// A single player-affecting condition the HUD surfaces as a status icon. @@ -282,6 +287,12 @@ pub struct DerivedViews { #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub stack_entry_details: HashMap, + /// CR 702.40a: copy counts for Storm spells in the viewing player's hand. + /// Keyed only by that viewer's hand object ids so hidden opponents' card + /// abilities and the table-wide spell ledger cannot leak through the view. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub prospective_storm_counts: HashMap, + /// CR 303.4 + CR 702.5: Auras attached to each player (Curse cycle, /// Faith's Fetters-class). Players have no `attachments` back-link /// because they aren't `GameObject`s — this projection is the engine's @@ -720,8 +731,79 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // answer. views.copied_permanents.sort_unstable(); - // CR 702.188a + 604.1: viewer-scoped web-slinging costs (own hand only → leak-proof). + // CR 702.40a: viewer-scoped prospective Storm copy counts (own hand only → leak-proof). if let Some(viewer) = viewer { + if let Some(player) = state.players.iter().find(|player| player.id == viewer) { + // `effective_spell_keywords` evaluates every keyword-grant source. Most + // snapshots have neither a Storm card nor a possible Storm grant, so only + // take that expensive path when one exists. The fallback remains necessary + // for CR 604.1 / CR 611.2c / CR 601.2f grants. + let may_have_granted_storm = + (crate::game::functioning_abilities::static_kind_present( + state, + StaticModeKind::CastWithKeyword, + ) && crate::game::functioning_abilities::game_active_statics(state).any( + |(_, definition)| { + matches!( + &definition.mode, + StaticMode::CastWithKeyword { + keyword: Keyword::Storm + } + ) + }, + )) || state.transient_continuous_effects.iter().any(|effect| { + matches!(&effect.affected, TargetFilter::SpecificPlayer { id } if *id == viewer) + && effect.modifications.iter().any(|modification| { + matches!( + modification, + ContinuousModification::GrantStaticAbility { definition } + if matches!( + &definition.mode, + StaticMode::CastWithKeyword { + keyword: Keyword::Storm + } + ) + ) + }) + }) || state.pending_next_spell_modifiers.iter().any(|modifier| { + matches!( + modifier, + crate::types::game_state::PendingNextSpellModifier { + player, + modifier: crate::types::game_state::NextSpellModifier::HasKeyword { + keyword: Keyword::Storm + }, + .. + } if *player == viewer + ) + }); + let mut copy_count = None; + for &hand_id in player.hand.iter() { + let has_printed_storm = state.objects.get(&hand_id).is_some_and(|object| { + object + .keywords + .iter() + .any(|keyword| matches!(keyword, Keyword::Storm)) + }); + if has_printed_storm + || (may_have_granted_storm + && crate::game::casting::effective_spell_keywords(state, viewer, hand_id) + .iter() + .any(|keyword| matches!(keyword, Keyword::Storm))) + { + let copy_count = *copy_count.get_or_insert_with(|| { + state + .spells_cast_this_turn_by_player + .values() + .map(|records| records.len()) + .sum::() as u32 + }); + views.prospective_storm_counts.insert(hand_id, copy_count); + } + } + } + + // CR 702.188a + 604.1: viewer-scoped web-slinging costs (own hand only → leak-proof). let has_web_slinging_static = crate::game::functioning_abilities::game_active_statics(state).any(|(_, def)| { matches!( @@ -1398,6 +1480,12 @@ fn stack_entry_detail(state: &GameState, entry: &StackEntry) -> StackEntryDispla targets: stack_entry_targets(state, entry), paid: stack_paid_facts(state.stack_paid_facts.get(&entry.id)), trigger_context: stack_trigger_context(state, entry), + provenance: match &entry.kind { + StackEntryKind::TriggeredAbility { provenance, .. } => provenance.clone(), + StackEntryKind::Spell { .. } + | StackEntryKind::ActivatedAbility { .. } + | StackEntryKind::KeywordAction { .. } => None, + }, } } @@ -2484,6 +2572,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -2504,6 +2593,124 @@ mod tests { ); } + #[test] + fn prospective_storm_counts_are_viewer_scoped() { + use crate::types::identifiers::CardId; + + let mut state = GameState::new_two_player(42); + let p0_storm = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Grapeshot".to_string(), + Zone::Hand, + ); + let p1_storm = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Empty the Warrens".to_string(), + Zone::Hand, + ); + state + .objects + .get_mut(&p0_storm) + .unwrap() + .keywords + .push(Keyword::Storm); + state + .objects + .get_mut(&p1_storm) + .unwrap() + .keywords + .push(Keyword::Storm); + state.players[0].hand.push_back(p0_storm); + state.players[1].hand.push_back(p1_storm); + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + im::Vector::from(vec![ + crate::types::game_state::SpellCastRecord::default(); + 2 + ]), + ); + + let views = derive_views(&state, Some(PlayerId(0))); + assert_eq!(views.prospective_storm_counts.get(&p0_storm), Some(&2)); + assert!(!views.prospective_storm_counts.contains_key(&p1_storm)); + } + + #[test] + fn prospective_storm_counts_include_effectively_granted_storm() { + use crate::types::ability::{ControllerRef, StaticDefinition, TypeFilter, TypedFilter}; + + let mut state = GameState::new_two_player(42); + let grantor = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Storm Grantor".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&grantor).unwrap().static_definitions = + vec![StaticDefinition::new(StaticMode::CastWithKeyword { + keyword: Keyword::Storm, + }) + .affected(TargetFilter::Typed( + TypedFilter::new(TypeFilter::Instant).controller(ControllerRef::You), + ))] + .into(); + let spell = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Granted Storm Spell".to_string(), + Zone::Hand, + ); + state + .objects + .get_mut(&spell) + .unwrap() + .card_types + .core_types + .push(CoreType::Instant); + state.players[0].hand.push_back(spell); + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + im::Vector::from(vec![crate::types::game_state::SpellCastRecord::default()]), + ); + + let views = derive_views(&state, Some(PlayerId(0))); + + assert_eq!(views.prospective_storm_counts.get(&spell), Some(&1)); + } + + #[test] + fn prospective_storm_counts_include_next_spell_granted_storm() { + let mut state = GameState::new_two_player(42); + let spell = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Next Storm Spell".to_string(), + Zone::Hand, + ); + state.players[0].hand.push_back(spell); + state.pending_next_spell_modifiers.push( + crate::types::game_state::PendingNextSpellModifier { + player: PlayerId(0), + modifier: crate::types::game_state::NextSpellModifier::HasKeyword { + keyword: Keyword::Storm, + }, + spell_filter: None, + source_id: None, + }, + ); + + let views = derive_views(&state, Some(PlayerId(0))); + + assert_eq!(views.prospective_storm_counts.get(&spell), Some(&0)); + } + /// SHAPE test (constructs `pending_cast`/pool directly, not via the cast /// pipeline): `pending_payment_remaining` is the locked cost reduced by ONLY /// the units the caster has pinned, so the payment UI's cost visibly shrinks @@ -2656,6 +2863,58 @@ mod tests { .any(|fact| matches!(fact, StackPaidFactView::ColorsSpent { distinct: 2 }))); } + #[test] + fn stack_entry_details_projects_storm_provenance_to_the_client_wire() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Grapeshot".to_string(), + Zone::Stack, + ); + let trigger = ObjectId(2); + state.stack.push_back(StackEntry { + id: trigger, + source_id: source, + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: source, + ability: Box::new(ResolvedAbility::new( + Effect::Unimplemented { + name: "storm".to_string(), + description: None, + }, + Vec::new(), + source, + PlayerId(0), + )), + condition: None, + trigger_event: None, + description: Some("Storm".to_string()), + source_name: "Grapeshot".to_string(), + subject_match_count: None, + die_result: None, + provenance: Some(SyntheticTriggerProvenance::Storm { copy_count: 2 }), + }, + }); + + let views = derive_views(&state, Some(PlayerId(0))); + assert_eq!( + views.stack_entry_details[&trigger].provenance, + Some(SyntheticTriggerProvenance::Storm { copy_count: 2 }), + "the stack detail projection carries typed Storm provenance" + ); + + let wire = serde_json::to_value(ClientGameStateRef::wrap(&state, Some(PlayerId(0)))) + .expect("serialize client game state"); + assert_eq!( + wire["derived"]["stack_entry_details"][trigger.0.to_string()]["provenance"], + serde_json::json!({"type": "Storm", "data": {"copy_count": 2}}), + "the frontend's derived stack-detail wire retains Storm provenance", + ); + } + #[test] fn pending_modal_spell_details_survive_filtering_and_client_wire_round_trip() { let mut state = GameState::new_two_player(42); @@ -2746,6 +3005,7 @@ mod tests { targets: Vec::new(), paid: Vec::new(), trigger_context: Vec::new(), + provenance: None, }; let empty_json = serde_json::to_string(&empty).expect("serialize empty display"); assert!( @@ -2845,6 +3105,7 @@ mod tests { source_name: "Watcher".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); @@ -2965,6 +3226,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }, provenance, ) diff --git a/crates/engine/src/game/effects/bounce.rs b/crates/engine/src/game/effects/bounce.rs index ec5bcf76f3..c4c951e027 100644 --- a/crates/engine/src/game/effects/bounce.rs +++ b/crates/engine/src/game/effects/bounce.rs @@ -807,6 +807,7 @@ mod tests { source_name: "Ability Source".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index bc362ef627..a17d70f0c9 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -4488,6 +4488,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); let mut events = Vec::new(); @@ -6099,6 +6100,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); @@ -6223,6 +6225,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); state.waiting_for = WaitingFor::Priority { diff --git a/crates/engine/src/game/effects/copy_spell.rs b/crates/engine/src/game/effects/copy_spell.rs index 40cdb2bfe6..e2d5a040ff 100644 --- a/crates/engine/src/game/effects/copy_spell.rs +++ b/crates/engine/src/game/effects/copy_spell.rs @@ -1814,6 +1814,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -2815,6 +2816,7 @@ mod tests { source_name: "Hope Estheim".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); state.stack.push_back(StackEntry { @@ -2838,6 +2840,7 @@ mod tests { source_name: "Opponent Source".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/src/game/effects/counter.rs b/crates/engine/src/game/effects/counter.rs index 3bc7a88cfa..d941750245 100644 --- a/crates/engine/src/game/effects/counter.rs +++ b/crates/engine/src/game/effects/counter.rs @@ -842,6 +842,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); @@ -948,6 +949,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); @@ -1097,6 +1099,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); @@ -1443,6 +1446,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); @@ -1627,6 +1631,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -1745,6 +1750,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index db9c9ed21a..33bbff6ef7 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2252,6 +2252,7 @@ fn try_begin_reflexive_target_selection_inner( // into the later fresh-`apply()` target-assign. subject_match_count: freeze_reflexive_event_count(state, controller, source_id), die_result: state.die_result_this_resolution, + provenance: None, }; let trigger_events = crate::game::triggers::take_pending_trigger_event_batch(state, &pending); @@ -2345,6 +2346,7 @@ fn try_begin_reflexive_target_selection_inner( // creating ability so the reflexive entry can re-stamp it when it // resolves as its own stack object. die_result: state.die_result_this_resolution, + provenance: None, }; let trigger_events = crate::game::triggers::take_pending_trigger_event_batch(state, &pending); let pending_for_state = pending.clone(); diff --git a/crates/engine/src/game/effects/transform_effect.rs b/crates/engine/src/game/effects/transform_effect.rs index e1dae444fc..edf52e0a9d 100644 --- a/crates/engine/src/game/effects/transform_effect.rs +++ b/crates/engine/src/game/effects/transform_effect.rs @@ -365,6 +365,7 @@ mod tests { source_name: "Front Face".to_string(), subject_match_count: None, die_result: None, + provenance: None, } } else { StackEntryKind::ActivatedAbility { @@ -460,6 +461,7 @@ mod tests { source_name: "Front Face".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }, &mut events, @@ -532,6 +534,7 @@ mod tests { source_name: "Front Face".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }, &mut events, diff --git a/crates/engine/src/game/effects/venture.rs b/crates/engine/src/game/effects/venture.rs index fe7f6635ad..76cc92f6e0 100644 --- a/crates/engine/src/game/effects/venture.rs +++ b/crates/engine/src/game/effects/venture.rs @@ -255,6 +255,7 @@ fn queue_room_trigger( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; // CR 603.2 + CR 309.4c: Dispatch through the standard diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 061656de56..1fc3a7cfc7 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1003,6 +1003,7 @@ fn do_eliminate( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }, events, ); diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 6a1dbfed76..f4b36b7cea 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -10091,6 +10091,7 @@ fn apply_action( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; super::triggers::push_pending_trigger_to_stack(state, trigger, &mut events); @@ -14609,6 +14610,7 @@ mod stage2_injector_tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, } }; @@ -14720,6 +14722,7 @@ mod stage2_injector_tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); state @@ -15185,6 +15188,7 @@ mod stage2_injector_tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, } } @@ -15532,9 +15536,9 @@ mod stage2_injector_tests { // because that is what makes a NEW mint a counted event; a function + // content-hash anchor would end the drift class while keeping that property, // and is offered as a follow-up rather than taken unannounced mid-review. - "game/effects/mod.rs:6175".to_string(), - "game/effects/mod.rs:6252".to_string(), - "game/effects/mod.rs:9456".to_string(), + "game/effects/mod.rs:6177".to_string(), + "game/effects/mod.rs:6254".to_string(), + "game/effects/mod.rs:9458".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. @@ -15647,11 +15651,10 @@ mod stage2_injector_tests { // failure this census exists to catch) cannot survive. // // Still inside `begin_pending_trigger_target_selection` (opens `:11547`). - // SET PRESERVATION: `git diff --stat upstream/main...HEAD` on - // `effects/mod.rs` and `effects/scoped_library_search.rs` is EMPTY, so - // `:6175/:6252/:9456/:452` could not have moved and stand re-read in place. - // Total still **5**. - "game/engine.rs:11696".to_string(), + // PR #7041's typed trigger-provenance initializers sit above the + // first three effects producers and this engine producer. CI + // re-derived the same five writes at these coordinates. + "game/engine.rs:11697".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ @@ -16161,6 +16164,7 @@ mod stage2_injector_tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); let prompt = cursor_live.waiting_for.clone(); assert_eq!( @@ -17291,6 +17295,7 @@ mod bounded_offer_conjunct_tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, } }) diff --git a/crates/engine/src/game/engine_exile_return_tests.rs b/crates/engine/src/game/engine_exile_return_tests.rs index bfa904955f..0f572d2136 100644 --- a/crates/engine/src/game/engine_exile_return_tests.rs +++ b/crates/engine/src/game/engine_exile_return_tests.rs @@ -836,6 +836,7 @@ fn white_auracite_real_oracle_text_returns_exiled_card() { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); @@ -988,6 +989,7 @@ fn haytham_kenway_per_opponent_exile_returns_when_source_leaves() { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); @@ -1108,6 +1110,7 @@ fn journey_to_nowhere_two_trigger_oracle_returns_exiled_creature() { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/src/game/engine_keyword_action_stack_tests.rs b/crates/engine/src/game/engine_keyword_action_stack_tests.rs index 0036978f87..eaa9391b88 100644 --- a/crates/engine/src/game/engine_keyword_action_stack_tests.rs +++ b/crates/engine/src/game/engine_keyword_action_stack_tests.rs @@ -904,6 +904,7 @@ fn issue_3660_finalize_copy_retarget_stashes_offers_on_deferred_pause() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }) } diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index 4433fde9d1..ad5ea695f6 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -199,6 +199,7 @@ fn pending_trigger_with_no_legal_target_at_choose_time_drops_not_errors() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let entry_id = ObjectId(state.next_object_id); state.next_object_id += 1; @@ -215,6 +216,7 @@ fn pending_trigger_with_no_legal_target_at_choose_time_drops_not_errors() { source_name: "Pinger".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); state.pending_trigger = Some(Box::new(pending)); @@ -2418,6 +2420,7 @@ fn push_token_trigger( source_name: "Token".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); entry_id diff --git a/crates/engine/src/game/engine_trigger_target_tests.rs b/crates/engine/src/game/engine_trigger_target_tests.rs index f81cbdd304..dd97a45350 100644 --- a/crates/engine/src/game/engine_trigger_target_tests.rs +++ b/crates/engine/src/game/engine_trigger_target_tests.rs @@ -119,6 +119,7 @@ fn trigger_target_selection_select_targets_pushes_to_stack() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -235,6 +236,7 @@ fn trigger_target_selection_rejects_illegal_target() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -325,6 +327,7 @@ fn triggered_modal_modes_with_targets_wait_for_target_selection() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -467,6 +470,7 @@ fn setup_vindictive_lich_pending_trigger(state: &mut GameState) { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -609,6 +613,7 @@ fn triggered_modal_modes_without_targets_consume_pending_trigger() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -734,6 +739,7 @@ fn triggered_commander_modal_cap_uses_controller_board_state() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -819,6 +825,7 @@ fn trigger_target_selection_enforces_different_player_constraint() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -976,6 +983,7 @@ fn choose_target_action_advances_trigger_selection_from_engine_state() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -1094,6 +1102,7 @@ fn triggered_modal_modes_reject_unsatisfiable_target_constraints() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -1207,6 +1216,7 @@ fn all_modes_exhausted_clears_pending_trigger() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let pending_for_state = pending.clone(); let stack_before = state.stack.len(); diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 573b12d8bd..2a255d5f1d 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -7319,6 +7319,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/src/game/planechase.rs b/crates/engine/src/game/planechase.rs index 61b8ceaaa9..ed000bbfb2 100644 --- a/crates/engine/src/game/planechase.rs +++ b/crates/engine/src/game/planechase.rs @@ -225,6 +225,7 @@ fn queue_planeswalk_trigger( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; crate::game::triggers::dispatch_synthetic_trigger(state, pending, events); } diff --git a/crates/engine/src/game/planechase_tests.rs b/crates/engine/src/game/planechase_tests.rs index 2b43c4b9ff..f2682a2cae 100644 --- a/crates/engine/src/game/planechase_tests.rs +++ b/crates/engine/src/game/planechase_tests.rs @@ -655,6 +655,7 @@ fn phenomenon_encounter_then_sba_planeswalk() { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/src/game/priority.rs b/crates/engine/src/game/priority.rs index a828d81264..ce253f7b23 100644 --- a/crates/engine/src/game/priority.rs +++ b/crates/engine/src/game/priority.rs @@ -732,6 +732,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index 343b418da3..6cff515227 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -4373,6 +4373,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 401f1cdc9d..d6be63c3e5 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -2961,6 +2961,7 @@ fn self_counter_run_key<'a>( source_name: _, subject_match_count: _, die_result: _, + provenance: None, } = &entry.kind else { return None; @@ -3167,6 +3168,7 @@ fn fixed_controller_gain_life_run_key<'a>( source_name: _, subject_match_count: _, die_result: _, + provenance: None, } = &entry.kind else { return None; @@ -3354,6 +3356,7 @@ fn fixed_opponent_lose_life_run_key<'a>( source_name: _, subject_match_count: _, die_result: _, + provenance: None, } = &entry.kind else { return None; @@ -4226,6 +4229,7 @@ fn batch_run_key<'a>(state: &'a GameState, entry: &'a StackEntry) -> Option, paid: Option, is_pending: bool, + provenance: Option, } /// Grouping signature for `stack_display_groups`. Two entries coalesce iff @@ -4438,6 +4443,12 @@ fn group_key(state: &GameState, entry: &StackEntry) -> StackGroupKey { .map(|ability| ability.selected_mode_labels.clone()) .unwrap_or_default(); let paid = state.stack_paid_facts.get(&entry.id).cloned(); + let provenance = match &entry.kind { + StackEntryKind::TriggeredAbility { provenance, .. } => provenance.clone(), + StackEntryKind::Spell { .. } + | StackEntryKind::ActivatedAbility { .. } + | StackEntryKind::KeywordAction { .. } => None, + }; StackGroupKey { source_name, tag, @@ -4446,6 +4457,7 @@ fn group_key(state: &GameState, entry: &StackEntry) -> StackGroupKey { targets, paid, is_pending: effective_ability.is_pending, + provenance, } } @@ -4556,8 +4568,9 @@ mod tests { use crate::game::triggers::{check_delayed_triggers, PendingTrigger}; use crate::game::zones::{self, create_object, move_to_zone}; use crate::types::ability::{ - CastingPermission, ControllerRef, CostPaidObjectSnapshot, Effect, ModalChoice, - QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, TypeFilter, TypedFilter, + CastingPermission, ControllerRef, CopyRetargetPermission, CostPaidObjectSnapshot, Effect, + ModalChoice, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, TypeFilter, + TypedFilter, }; use crate::types::card_type::CoreType; use crate::types::game_state::{ @@ -4899,6 +4912,7 @@ mod tests { source_name: "Trygon Predator".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); state.pending_trigger_entry = Some(entry_id); @@ -4922,6 +4936,7 @@ mod tests { may_trigger_origin: Some(MayTriggerOrigin::Printed { trigger_index: 0 }), subject_match_count: None, die_result: None, + provenance: None, })); state.waiting_for = WaitingFor::Priority { player: PlayerId(0), @@ -5320,6 +5335,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); @@ -6418,6 +6434,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -6432,6 +6449,53 @@ mod tests { assert_eq!(groups[0].member_ids.len(), 100); } + #[test] + fn stack_display_groups_keep_different_storm_copy_counts_separate() { + use crate::types::ability::{Effect, ResolvedAbility}; + use crate::types::game_state::SyntheticTriggerProvenance; + use crate::types::identifiers::{CardId, ObjectId}; + + let mut state = GameState::new_two_player(42); + let source = crate::game::zones::create_object( + &mut state, + CardId(1), + PlayerId(0), + "Grapeshot".to_string(), + Zone::Stack, + ); + for (id, copy_count) in [(ObjectId(10_001), 1), (ObjectId(10_002), 2)] { + state.stack.push_back(StackEntry { + id, + source_id: source, + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: source, + ability: Box::new(ResolvedAbility::new( + Effect::CopySpell { + target: TargetFilter::SelfRef, + retarget: CopyRetargetPermission::MayChooseNewTargets, + copier: None, + additional_modifications: Vec::new(), + starting_loyalty_from_casualty_sacrifice: false, + }, + vec![], + source, + PlayerId(0), + )), + condition: None, + trigger_event: None, + description: Some("Storm".to_string()), + source_name: "Grapeshot".to_string(), + subject_match_count: None, + die_result: None, + provenance: Some(SyntheticTriggerProvenance::Storm { copy_count }), + }, + }); + } + + assert_eq!(stack_display_groups(&state).len(), 2); + } + #[test] fn stack_display_groups_coalesce_identical_triggers_from_distinct_events() { use crate::types::ability::{Effect, ResolvedAbility}; @@ -6482,6 +6546,7 @@ mod tests { source_name: "Honored Dreyleader".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -6532,6 +6597,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }; state.stack.push_back(mk_entry(s1)); @@ -6585,6 +6651,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }; state @@ -6775,6 +6842,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, } }; @@ -7494,6 +7562,7 @@ mod tests { source_name: "Scute Swarm".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -7571,6 +7640,7 @@ mod tests { source_name: state.objects[&source].name.clone(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -7610,6 +7680,7 @@ mod tests { source_name: state.objects[&source].name.clone(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -7655,6 +7726,7 @@ mod tests { source_name: state.objects[&source].name.clone(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -8401,6 +8473,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -8699,6 +8772,7 @@ mod tests { source_name: "Scute Swarm".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); } @@ -12928,6 +13002,7 @@ mod tests { source_name: "Ancient Bronze Dragon".to_string(), subject_match_count: None, die_result: Some(11), + provenance: None, }, }); @@ -13184,6 +13259,7 @@ mod tests { source_name: "Conditional Trigger".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); let depth = state.stack.len(); diff --git a/crates/engine/src/game/static_abilities.rs b/crates/engine/src/game/static_abilities.rs index cac61afe3c..a1af2ff4eb 100644 --- a/crates/engine/src/game/static_abilities.rs +++ b/crates/engine/src/game/static_abilities.rs @@ -3409,6 +3409,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index e851ad5aba..ad0a20a41a 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -12267,6 +12267,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); (state, ability_id) @@ -13211,6 +13212,7 @@ mod tests { source_name: "Innkeeper's Talent".to_string(), subject_match_count: Some(0), die_result: None, + provenance: None, }, }); diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index e5a2a52461..3036c39941 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -20,8 +20,8 @@ use crate::types::game_state::{ AutoMayChoice, DamageRecord, DelayedTrigger, DistributionUnit, GameState, LatchedBatchedTrigger, LatchedSuppressTrigger, LogicalZoneChangeGroup, LogicalZoneChangeTerminalOutcome, MayTriggerAutoChoiceKey, MayTriggerOrigin, StackEntry, - StackEntryKind, TargetSelectionConstraint, TargetSelectionSlot, TriggerObservationTime, - TriggerSourceContext, WaitingFor, + StackEntryKind, SyntheticTriggerProvenance, TargetSelectionConstraint, TargetSelectionSlot, + TriggerObservationTime, TriggerSourceContext, WaitingFor, }; use crate::types::identifiers::{ DelayedInstallIdentity, DelayedTriggerInstanceId, DelayedTriggerOrigin, DelayedTriggerToken, @@ -111,6 +111,9 @@ pub struct PendingTrigger { /// resolution scope cleared) can re-stamp `die_result_this_resolution`. #[serde(default, skip_serializing_if = "Option::is_none")] pub die_result: Option, + /// Typed presentation provenance for a synthesized keyword trigger. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, } impl PendingTrigger { @@ -139,6 +142,7 @@ impl PendingTrigger { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, } } } @@ -2292,6 +2296,7 @@ fn collect_matching_triggers_inner( }, subject_match_count, die_result: None, + provenance: None, }, trigger_events, batched: trig_def.batched, @@ -3352,6 +3357,7 @@ fn collect_latched_batched_zone_triggers( }), subject_match_count, die_result: None, + provenance: None, }, trigger_events, batched: true, @@ -3615,6 +3621,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -3662,6 +3669,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -3705,6 +3713,7 @@ fn collect_pending_triggers_with_collection( }), subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -3750,6 +3759,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -3796,6 +3806,7 @@ fn collect_pending_triggers_with_collection( }), subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -3873,6 +3884,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -4209,16 +4221,20 @@ fn collect_pending_triggers_with_collection( .get(cast_obj_id) .map(|source| trigger_source_context_for_latch(state, source)); - // CR 702.102b: NOT-PRE-PAYMENT — this reacts to `GameEvent::SpellCast`, - // emitted after payment, so the `fused_split_spell` marker is already - // set and the non-fuse-aware collector's marker OR-gate yields the - // combined projection. Representative of every `SpellCast`-reactive - // `effective_spell_keywords` read in this module. - let storm_instances = - super::casting::effective_spell_keywords(state, *caster, *cast_obj_id) - .iter() - .filter(|keyword| matches!(keyword, Keyword::Storm)) - .count(); + // CR 702.40a/b: Storm is a spell ability, so its instances are + // frozen when the spell is cast. Do not re-evaluate live spell + // keywords after the cast event: a conditional grant may no longer + // match once the spell itself has entered the cast ledger. + let storm_instances = state + .objects + .get(cast_obj_id) + .map(|obj| { + obj.cast_spell_keywords + .iter() + .filter(|keyword| matches!(keyword, Keyword::Storm)) + .count() + }) + .unwrap_or_default(); if storm_instances > 0 { let copy_count = storm_copy_count_before_cast(state); for _ in 0..storm_instances { @@ -4262,6 +4278,9 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: Some(SyntheticTriggerProvenance::Storm { + copy_count: copy_count.max(0) as u32, + }), })); } } @@ -4324,6 +4343,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } @@ -4384,6 +4404,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } @@ -4505,6 +4526,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } @@ -4576,6 +4598,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } @@ -4648,6 +4671,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -4706,6 +4730,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -4738,6 +4763,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -4773,6 +4799,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -4862,6 +4889,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -4906,6 +4934,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -4951,6 +4980,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); session.mark_speed_trigger_used(state, trigger_controller); } @@ -4998,6 +5028,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); } } @@ -5241,6 +5272,7 @@ fn ring_pending_trigger( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }) } @@ -6765,6 +6797,7 @@ fn push_pending_trigger_to_stack_with_firing( may_trigger_origin, subject_match_count, die_result, + provenance, .. } = trigger; @@ -6807,6 +6840,7 @@ fn push_pending_trigger_to_stack_with_firing( source_name, subject_match_count, die_result, + provenance, }, }; stack::push_triggered_to_stack(state, entry, firing, events); @@ -8083,6 +8117,7 @@ pub fn check_state_triggers(state: &mut GameState) { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }); } } @@ -8260,6 +8295,7 @@ fn delayed_trigger_to_context( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }, trigger.provenance, ) @@ -11567,6 +11603,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let disposition = dispatch_pending_trigger_context_with_origin( @@ -11641,6 +11678,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let mut pending = vec![PendingTriggerContext::delayed( pending, @@ -12513,6 +12551,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); state.waiting_for = WaitingFor::OptionalEffectChoice { player: controller, @@ -15044,7 +15083,7 @@ pub mod tests { { let obj = state.objects.get_mut(&emblem).unwrap(); obj.is_emblem = true; - obj.static_definitions = vec![StaticDefinition::new(StaticMode::CastWithKeyword { + let storm_grant = StaticDefinition::new(StaticMode::CastWithKeyword { keyword: Keyword::Storm, }) .affected(TargetFilter::Typed( @@ -15053,8 +15092,8 @@ pub mod tests { TypeFilter::Sorcery, ])) .controller(ControllerRef::You), - ))] - .into(); + )); + obj.static_definitions = vec![storm_grant.clone(), storm_grant].into(); } let spell = create_object( @@ -15068,6 +15107,20 @@ pub mod tests { let obj = state.objects.get_mut(&spell).unwrap(); obj.card_types.core_types.push(CoreType::Instant); } + // The real cast pipeline latches effective keyword grants here. This + // fixture bypasses that pipeline, so seed the same finalized snapshot + // and prove both independently functioning Storm grants survive it. + let snapshot = + crate::game::casting::effective_spell_keyword_instances(&state, player, spell); + assert_eq!( + snapshot + .iter() + .filter(|keyword| matches!(keyword, Keyword::Storm)) + .count(), + 2, + "cast snapshot must retain duplicate Storm instances" + ); + state.objects.get_mut(&spell).unwrap().cast_spell_keywords = snapshot; state.stack.push_back(StackEntry { id: spell, source_id: spell, @@ -15133,12 +15186,24 @@ pub mod tests { }], ); - assert!(state.stack.iter().any(|entry| matches!( - &entry.kind, - StackEntryKind::TriggeredAbility { ability, .. } - if matches!(ability.effect, Effect::CopySpell { .. }) - && matches!(ability.repeat_for, Some(QuantityExpr::Fixed { value: 2 })) - ))); + assert_eq!( + state + .stack + .iter() + .filter(|entry| matches!( + &entry.kind, + StackEntryKind::TriggeredAbility { + ability, + provenance: Some(SyntheticTriggerProvenance::Storm { copy_count: 2 }), + .. + } + if matches!(ability.effect, Effect::CopySpell { .. }) + && matches!(ability.repeat_for, Some(QuantityExpr::Fixed { value: 2 })) + )) + .count(), + 2, + "each finalized Storm instance must produce its own provenance-carrying trigger" + ); } #[test] @@ -17178,6 +17243,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let mut events = Vec::new(); @@ -17257,6 +17323,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let draw_trig = parse_trigger_line(DRAW_ETB, "Curiosity Crafter"); @@ -17280,6 +17347,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; dispatch_collected_triggers( @@ -17889,6 +17957,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let context = PendingTriggerContext::single(pending); let mut events_out = Vec::new(); @@ -28617,6 +28686,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }) } @@ -28734,6 +28804,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }) } @@ -28952,6 +29023,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }) } @@ -31280,6 +31352,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }, &mut Vec::new(), ); @@ -31528,6 +31601,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }, &mut Vec::new(), ); @@ -31630,6 +31704,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }, &mut Vec::new(), ); @@ -31822,6 +31897,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }, &mut Vec::new(), ); @@ -31912,6 +31988,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }, &mut Vec::new(), ); diff --git a/crates/engine/src/game/triggers_dedup_regression_tests.rs b/crates/engine/src/game/triggers_dedup_regression_tests.rs index 8a94a46688..976fa32255 100644 --- a/crates/engine/src/game/triggers_dedup_regression_tests.rs +++ b/crates/engine/src/game/triggers_dedup_regression_tests.rs @@ -3326,6 +3326,7 @@ fn order_triggers_distinct_event_context_still_prompt() { may_trigger_origin: None, subject_match_count: Some(count), die_result: None, + provenance: None, }) }; let ctx_a = make_ctx(ObjectId(1), 1); @@ -3385,6 +3386,7 @@ fn order_triggers_event_context_ability_still_prompts_on_distinct_events() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }) }; let ctx_a = make_ctx(ObjectId(1), ObjectId(11)); @@ -3436,6 +3438,7 @@ fn archenemy_hero_team_orders_triggers_from_multiple_heroes_together() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }) }; diff --git a/crates/engine/src/game/triggers_ordering_parity_tests.rs b/crates/engine/src/game/triggers_ordering_parity_tests.rs index eace11120c..882a5b2cf6 100644 --- a/crates/engine/src/game/triggers_ordering_parity_tests.rs +++ b/crates/engine/src/game/triggers_ordering_parity_tests.rs @@ -948,6 +948,7 @@ fn ctx( may_trigger_origin: None, subject_match_count: None, die_result, + provenance: None, }) } @@ -1410,6 +1411,7 @@ fn ctx_c( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }) } diff --git a/crates/engine/src/game/triggers_pr7_order_template_tests.rs b/crates/engine/src/game/triggers_pr7_order_template_tests.rs index d3ef6c8187..632c943438 100644 --- a/crates/engine/src/game/triggers_pr7_order_template_tests.rs +++ b/crates/engine/src/game/triggers_pr7_order_template_tests.rs @@ -52,6 +52,7 @@ fn mk_ctx( may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }) } diff --git a/crates/engine/src/game/triggers_push_first_contract_tests.rs b/crates/engine/src/game/triggers_push_first_contract_tests.rs index 60f94276d3..c28e2fff52 100644 --- a/crates/engine/src/game/triggers_push_first_contract_tests.rs +++ b/crates/engine/src/game/triggers_push_first_contract_tests.rs @@ -420,6 +420,7 @@ fn push_first_no_legal_modes_modal_trigger_dropped_silently() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let stack_before = state.stack.len(); @@ -530,6 +531,7 @@ fn random_modal_trigger_resolves_without_prompting() { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let stack_before = state.stack.len(); diff --git a/crates/engine/src/parser/oracle_static/evasion.rs b/crates/engine/src/parser/oracle_static/evasion.rs index e17effb34c..ca97a0b549 100644 --- a/crates/engine/src/parser/oracle_static/evasion.rs +++ b/crates/engine/src/parser/oracle_static/evasion.rs @@ -110,13 +110,12 @@ fn strip_redundant_block_exception_by(filter_text: &str) -> Cow<'_, str> { /// Oracle text. Trigger doublers name the doubled ability's source as /// "a triggered ability of " — e.g. "a Ninja creature you control" /// (Splinter), "another creature you control of the chosen type" (Roaming -/// Throne), or the unrestricted "a permanent you control" (Panharmonicon-class). +/// Throne), or "a permanent you control" (Panharmonicon-class). /// -/// Returns `Some(filter)` only when `` narrows beyond a bare controlled -/// permanent (a subtype, a specific core type, or a property such as "another" -/// / "of the chosen type"). A bare "permanent you control" needs no filter — -/// `apply_trigger_doubling`'s controller match already enforces control — so -/// this returns `None`, leaving `affected` unset (Panharmonicon/Isshin/Drivnod). +/// Returns `Some(filter)` when `` supplies a source-domain constraint. +/// In particular, "permanent you control" must remain a `Permanent` filter: +/// the controller check alone would also admit spell-source triggers such as +/// Storm, which this phrase does not name. /// /// CR 603.2d: The source may itself be a flat disjunction of typed clauses /// sharing one trailing controller scope — "a Shaman or another Wizard you @@ -137,9 +136,9 @@ pub(crate) fn parse_doubler_source_filter(lower: &str) -> Option { .parse(i) })?; - // Parse the leading typed clause. A bare controlled permanent - // ("a permanent you control", Panharmonicon) adds nothing the controller - // match doesn't already enforce, so an unrestrictive clause yields `None`. + // Parse the leading typed clause. A bare controlled permanent remains a + // source-domain constraint: the controller match cannot distinguish a + // permanent's triggered ability from a spell's triggered ability. let (first, remainder) = parse_doubler_disjunct(source_phrase); if !doubler_source_is_restrictive(&first) { return None; @@ -161,12 +160,10 @@ pub(crate) fn parse_doubler_source_filter(lower: &str) -> Option { let mut branches = vec![first]; loop { let (filter, remainder) = parse_doubler_disjunct(rest); - // Each disjunct must independently narrow to a restrictive typed clause. + // Each disjunct must independently name a source-domain constraint. // If one does not — e.g. a stray "or" inside an unrelated suffix - // ("power 4 or greater") split the phrase mid-clause — bail so the - // doubler falls back to its conservative controller-only scope. This - // keeps the fallback strictly safe: a mis-parse can only widen back to - // "all your triggers", never narrow to a wrong subset. + // ("power 4 or greater") split the phrase mid-clause — abort the + // union extraction instead of constructing a partial source scope. if !doubler_source_is_restrictive(&filter) { return None; } @@ -198,20 +195,18 @@ fn doubler_disjunct_connector(input: &str) -> OracleResult<'_, ()> { .parse(input) } -/// CR 603.2d: A doubler `affected` filter must narrow beyond a bare controlled -/// permanent — `apply_trigger_doubling`'s controller match already enforces -/// control, so `Permanent`/`Card`/`Any` core types add nothing. A clause is -/// restrictive when it carries a concrete type/subtype restriction or any -/// property (subtype designations, "another", "of the chosen type", etc.). +/// CR 603.2d: A doubler `affected` filter must name a source-domain constraint. +/// `Permanent` is a real constraint because spells are not permanents; `Card` +/// and `Any` alone do not constrain the source. A clause is therefore valid +/// when it carries a permanent or concrete type/subtype restriction, or a +/// property such as "another" / "of the chosen type". fn doubler_source_is_restrictive(filter: &TargetFilter) -> bool { match filter { TargetFilter::Typed(tf) => { - tf.type_filters.iter().any(|t| { - !matches!( - t, - TypeFilter::Permanent | TypeFilter::Card | TypeFilter::Any - ) - }) || !tf.properties.is_empty() + tf.type_filters + .iter() + .any(|t| !matches!(t, TypeFilter::Card | TypeFilter::Any)) + || !tf.properties.is_empty() } TargetFilter::Or { filters } => filters.iter().all(doubler_source_is_restrictive), // CR 603.2d: "a triggered ability of ~" names the doubler's own source diff --git a/crates/engine/src/parser/oracle_static/snapshot_tests.rs b/crates/engine/src/parser/oracle_static/snapshot_tests.rs index 342f67bc44..510f36c4f3 100644 --- a/crates/engine/src/parser/oracle_static/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_static/snapshot_tests.rs @@ -620,10 +620,14 @@ fn parses_wayta_damage_caused_doubler() { cause: TriggerCause::ControlledCreatureDealtDamage } ); - assert!( - def.affected.is_none(), - "bare 'a permanent you control' must not add a redundant affected filter" - ); + let Some(TargetFilter::Typed(filter)) = def.affected.as_ref() else { + panic!( + "bare 'a permanent you control' must preserve its permanent source scope, got {:?}", + def.affected + ); + }; + assert_eq!(filter.type_filters, [TypeFilter::Permanent]); + assert_eq!(filter.controller, Some(ControllerRef::You)); } /// CR 603.2d + CR 601.2 + CR 707.10: Cast-or-copy-caused trigger doubler @@ -645,10 +649,14 @@ fn parses_veyran_cast_or_copy_caused_doubler() { } } ); - assert!( - def.affected.is_none(), - "bare 'a permanent you control' must not add a redundant affected filter" - ); + let Some(TargetFilter::Typed(filter)) = def.affected.as_ref() else { + panic!( + "Veyran must preserve its permanent source scope, got {:?}", + def.affected + ); + }; + assert_eq!(filter.type_filters, [TypeFilter::Permanent]); + assert_eq!(filter.controller, Some(ControllerRef::You)); } /// CR 603.2d: Source-restricted trigger doubler (Splinter, Radical Rat). @@ -770,12 +778,11 @@ fn harmonic_prodigy_disjunctive_source_doubles_shaman_or_wizard() { ); } -/// CR 603.6a: Panharmonicon's source is the unrestricted "a permanent you -/// control" — controller match alone suffices, so `affected` stays `None`. -/// Regression guard: the source-filter extraction must NOT populate -/// `affected` for a bare controlled-permanent source. +/// CR 603.6a: Panharmonicon's source is "a permanent you control". Preserve +/// that permanent-domain restriction so its controller check cannot admit a +/// spell-source trigger. #[test] -fn panharmonicon_doubler_has_no_source_filter() { +fn panharmonicon_doubler_preserves_permanent_source_filter() { let def = parse_static_line( "If an artifact or creature entering causes a triggered ability of a permanent you control to trigger, that ability triggers an additional time.", ) @@ -790,11 +797,14 @@ fn panharmonicon_doubler_has_no_source_filter() { "expected EntersBattlefield cause, got {:?}", def.mode ); - assert!( - def.affected.is_none(), - "bare 'permanent you control' source must leave affected None, got {:?}", - def.affected - ); + let Some(TargetFilter::Typed(filter)) = def.affected.as_ref() else { + panic!( + "bare 'permanent you control' source must preserve its scope, got {:?}", + def.affected + ); + }; + assert_eq!(filter.type_filters, [TypeFilter::Permanent]); + assert_eq!(filter.controller, Some(ControllerRef::You)); } /// CR 603.2d + CR 603.6a + CR 603.6c: Gandalf the White — legendary OR @@ -819,11 +829,14 @@ fn gandalf_the_white_doubler_static() { }, "Gandalf must parse as legendary-or-artifact battlefield transition doubling" ); - assert!( - def.affected.is_none(), - "bare 'permanent you control' source must leave affected None, got {:?}", - def.affected - ); + let Some(TargetFilter::Typed(filter)) = def.affected.as_ref() else { + panic!( + "Gandalf's permanent source must preserve its scope, got {:?}", + def.affected + ); + }; + assert_eq!(filter.type_filters, [TypeFilter::Permanent]); + assert_eq!(filter.controller, Some(ControllerRef::You)); } #[test] diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 26e43febd4..1e79c79272 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -12083,9 +12083,8 @@ fn parse_spells_quoted_duplicate_cascade_kept() { // The parser's duplicate gate consults `cast_merge_preserves_instances`, which is // deliberately NARROWER than the semantic `instances_function_separately`: Exalted -// (reachable in the quoted grammar) and Storm (not) both function separately by -// rule but their cast-grant counts are not consumed, so both are excluded and any -// duplicate grant that reaches the gate declines. +// remains excluded because its cast-grant count is not consumed, while Storm is +// preserved because its synthesized trigger consumes every cast-time instance. #[test] fn cast_merge_preserves_instances_is_narrower_than_functions_separately() { assert!(Keyword::Cascade.instances_function_separately()); @@ -12093,7 +12092,7 @@ fn cast_merge_preserves_instances_is_narrower_than_functions_separately() { assert!(Keyword::Exalted.instances_function_separately()); assert!(!Keyword::Exalted.cast_merge_preserves_instances()); assert!(Keyword::Storm.instances_function_separately()); - assert!(!Keyword::Storm.cast_merge_preserves_instances()); + assert!(Keyword::Storm.cast_merge_preserves_instances()); } #[test] diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 7d87c7646d..85f2cbd97d 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -12057,6 +12057,18 @@ pub struct StackEntry { pub kind: StackEntryKind, } +/// Engine-authored identity for a synthesized triggered ability whose display +/// needs a fact unavailable on ordinary Oracle-defined triggers. +/// +/// This is presentation provenance, not a second rules implementation: the +/// trigger's resolved ability remains the authority that actually resolves. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum SyntheticTriggerProvenance { + /// CR 702.40a: The Storm trigger will copy its source spell this many times. + Storm { copy_count: u32 }, +} + /// CR 400.7j: from→to record of a source object moved by its own resolving /// ability, so `source_is_current` can re-find it after the all-zone incarnation /// bump. `original_stamp` is the incarnation the resolving ability captured (fixed @@ -12633,6 +12645,10 @@ pub enum StackEntryKind { /// `die_result_this_resolution`. #[serde(default, skip_serializing_if = "Option::is_none")] die_result: Option, + /// Typed identity for synthesized keyword triggers. The frontend reads + /// this only through `StackEntryDisplay`, never from raw stack state. + #[serde(default, skip_serializing_if = "Option::is_none")] + provenance: Option, }, /// CR 113.3b: Activated keyword abilities (Equip / Crew / Saddle / Station) /// enter the stack after cost-payment + target selection and resolve with @@ -21982,6 +21998,7 @@ mod tests { source_name: "Normal trigger source".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }); state @@ -23166,6 +23183,7 @@ mod tests { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); a.lki_by_incarnation @@ -26131,6 +26149,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; let json = serde_json::to_string(&trigger).unwrap(); let deserialized: PendingTrigger = serde_json::from_str(&json).unwrap(); @@ -26197,6 +26216,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); state.pending_trigger_firing = Some(TriggerFiring::Ordinary); @@ -26681,6 +26701,7 @@ mod tests { source_name: "Token".to_string(), subject_match_count: None, die_result: None, + provenance: None, }, } } diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index d792ff332c..a5f9bca822 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -1794,16 +1794,16 @@ impl Keyword { /// the parser must not emit a duplicate `CastWithKeyword` grant the merge would /// silently drop. /// - /// - Cascade (CR 702.85c) and Ripple (CR 702.60b): each granted instance + /// - Cascade (CR 702.85c), Storm (CR 702.40b), and Ripple (CR 702.60b): each granted instance /// triggers separately, counted via `cast_spell_keywords` in /// `game/triggers.rs`. /// - Casualty (CR 702.153b) / Squad (CR 702.157b): each instance is paid and /// triggers separately. /// - /// Deliberately NARROWER than [`Self::instances_function_separately`]: Storm - /// (CR 702.40b), Myriad, Increment, Provoke, Exalted, and DoubleTeam function - /// separately by their own rules, but their cast-GRANT consumption still reads - /// the kind-deduped keyword list, so preserving duplicate grants would be inert. + /// Deliberately NARROWER than [`Self::instances_function_separately`]: Myriad, + /// Increment, Provoke, Exalted, and DoubleTeam function separately by their + /// own rules, but their cast-GRANT consumption still reads the kind-deduped + /// keyword list, so preserving duplicate grants would be inert. /// When such a keyword IS admitted by the quoted-list grammar (of these, only /// Exalted is in `parse_keyword_name`'s KEYWORDS today), the parser declines a /// duplicate of it rather than lower it to a single silently-deduped grant. @@ -1815,7 +1815,11 @@ impl Keyword { // CR 113.2c + CR 702.60b: multiple instances of Ripple function // independently, so a spell's cast-time snapshot must retain each // static grant for trigger synthesis. - Keyword::Cascade | Keyword::Ripple(_) | Keyword::Casualty(_) | Keyword::Squad(_) + Keyword::Cascade + | Keyword::Storm + | Keyword::Ripple(_) + | Keyword::Casualty(_) + | Keyword::Squad(_) ) } } diff --git a/crates/engine/tests/integration/game_state_boxed_ability_serde.rs b/crates/engine/tests/integration/game_state_boxed_ability_serde.rs index 2488983a77..389464b93f 100644 --- a/crates/engine/tests/integration/game_state_boxed_ability_serde.rs +++ b/crates/engine/tests/integration/game_state_boxed_ability_serde.rs @@ -105,6 +105,7 @@ fn populated_state() -> GameState { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); state.pending_trigger = Some(Box::new(PendingTrigger::ordinary( diff --git a/crates/engine/tests/integration/granted_storm_snapshot.rs b/crates/engine/tests/integration/granted_storm_snapshot.rs new file mode 100644 index 0000000000..300c8a150f --- /dev/null +++ b/crates/engine/tests/integration/granted_storm_snapshot.rs @@ -0,0 +1,88 @@ +//! Regression for a cast-time Storm grant whose condition becomes false when +//! the spell is recorded. The trigger must therefore use the cast-finalization +//! snapshot rather than re-evaluating the live static after `SpellCast`. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::{ + Comparator, ControllerRef, CountScope, QuantityExpr, QuantityRef, StaticCondition, + StaticDefinition, TargetFilter, TypeFilter, TypedFilter, +}; +use engine::types::game_state::{StackEntryKind, SyntheticTriggerProvenance}; +use engine::types::keywords::Keyword; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::statics::StaticMode; + +const OPT_ORACLE: &str = "Scry 1. (Look at the top card of your library. You may put that card on the bottom.)\nDraw a card."; + +/// CR 601.2f + CR 611.2f + CR 702.40a: a Storm grant that only applies before +/// the caster has cast a spell this turn is latched before the spell enters the +/// cast ledger, then produces its trigger from that snapshot. +#[test] +fn first_spell_storm_grant_is_snapshotted_before_cast_recording() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario + .add_creature(P0, "Storm Grantor", 1, 1) + .with_static_definition( + StaticDefinition::new(StaticMode::CastWithKeyword { + keyword: Keyword::Storm, + }) + .affected(TargetFilter::Typed( + TypedFilter::new(TypeFilter::Instant).controller(ControllerRef::You), + )) + .condition(StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::SpellsCastThisTurn { + scope: CountScope::Controller, + filter: None, + }, + }, + comparator: Comparator::EQ, + rhs: QuantityExpr::Fixed { value: 0 }, + }), + ); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Opt", true, OPT_ORACLE) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + let commit = runner.cast(spell).commit(); + let state = commit.state(); + + assert_eq!( + state + .spells_cast_this_turn_by_player + .get(&P0) + .map_or(0, |spells| spells.len()), + 1, + "the spell is recorded before its cast trigger is collected" + ); + assert_eq!( + state.objects[&spell].cast_spell_keywords, + [Keyword::Storm], + "cast finalization must preserve the pre-record Storm grant on the spell" + ); + let storm_copy_counts: Vec<_> = state + .stack + .iter() + .filter_map(|entry| match &entry.kind { + StackEntryKind::TriggeredAbility { + provenance: Some(SyntheticTriggerProvenance::Storm { copy_count }), + .. + } => Some(*copy_count), + StackEntryKind::TriggeredAbility { + provenance: None, .. + } => None, + StackEntryKind::Spell { .. } + | StackEntryKind::ActivatedAbility { .. } + | StackEntryKind::KeywordAction { .. } => None, + }) + .collect(); + assert_eq!( + storm_copy_counts, + [0], + "the finalized snapshot must create exactly one zero-copy Storm trigger for the first spell" + ); +} diff --git a/crates/engine/tests/integration/integration_bending.rs b/crates/engine/tests/integration/integration_bending.rs index f670fc858d..009f1c8f2d 100644 --- a/crates/engine/tests/integration/integration_bending.rs +++ b/crates/engine/tests/integration/integration_bending.rs @@ -919,6 +919,7 @@ fn test_search_changezone_shuffle_continuation_completes() { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }; stack::push_to_stack(&mut state, entry, &mut vec![]); @@ -1293,6 +1294,7 @@ fn test_earthbender_ascension_etb_completes_with_landfall() { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }; stack::push_to_stack(&mut state, entry, &mut vec![]); @@ -2100,6 +2102,7 @@ fn cast_synthetic_earthbend( source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }; stack::push_to_stack(state, entry, &mut vec![]); @@ -2413,6 +2416,7 @@ fn earthbended_land_returns_tapped_after_exile() { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }; stack::push_to_stack(runner.state_mut(), entry, &mut vec![]); diff --git a/crates/engine/tests/integration/issue_3282_consign_to_memory_counter.rs b/crates/engine/tests/integration/issue_3282_consign_to_memory_counter.rs index a846ffc919..c17fc5adf3 100644 --- a/crates/engine/tests/integration/issue_3282_consign_to_memory_counter.rs +++ b/crates/engine/tests/integration/issue_3282_consign_to_memory_counter.rs @@ -99,6 +99,7 @@ fn stack_with_counter_targets() -> (GameState, ObjectId, ObjectId, ObjectId, Obj source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/tests/integration/issue_5983_sothera_dies_edict.rs b/crates/engine/tests/integration/issue_5983_sothera_dies_edict.rs index 33d90fe3ad..543ed7e937 100644 --- a/crates/engine/tests/integration/issue_5983_sothera_dies_edict.rs +++ b/crates/engine/tests/integration/issue_5983_sothera_dies_edict.rs @@ -106,6 +106,7 @@ fn push_sothera_dies_trigger( source_name: "Sothera, the Supervoid".into(), subject_match_count: None, die_result: None, + provenance: None, }, }; stack::push_to_stack(runner.state_mut(), entry, &mut vec![]); diff --git a/crates/engine/tests/integration/kamigawa_flip_cards.rs b/crates/engine/tests/integration/kamigawa_flip_cards.rs index 95e75fbe3f..00c3340b18 100644 --- a/crates/engine/tests/integration/kamigawa_flip_cards.rs +++ b/crates/engine/tests/integration/kamigawa_flip_cards.rs @@ -205,6 +205,7 @@ fn resolve_trigger_body( source_name: source_name.to_string(), subject_match_count: None, die_result: None, + provenance: None, }, }, &mut vec![], diff --git a/crates/engine/tests/integration/louisoix_sacrifice_counter.rs b/crates/engine/tests/integration/louisoix_sacrifice_counter.rs index 08ca52269a..fda69bd3e0 100644 --- a/crates/engine/tests/integration/louisoix_sacrifice_counter.rs +++ b/crates/engine/tests/integration/louisoix_sacrifice_counter.rs @@ -166,6 +166,7 @@ fn stack_with_four_entries() -> (GameState, ObjectId, ObjectId, ObjectId, Object source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 9452c25231..355ebc8005 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -237,6 +237,7 @@ mod good_king_mog_xii_chapter_iv_588; mod gran_gran_integration; mod granted_alt_cost_hand_keyword; mod granted_bloodthirst_5802; +mod granted_storm_snapshot; mod granted_sunburst_5337; mod greater_good_activation; mod green_suns_zenith_regression; @@ -962,6 +963,7 @@ mod urzas_saga_chapter_two; mod urzas_tower_conditional_mana; mod vengeful_ancestor_goaded_attack_trigger; mod veteran_armorsmith_soldier_anthem; +mod veyran_storm_source_scope; mod vigor_regression; mod vincents_limit_break_tiered; mod virulent_emissary_trigger; diff --git a/crates/engine/tests/integration/veyran_storm_source_scope.rs b/crates/engine/tests/integration/veyran_storm_source_scope.rs new file mode 100644 index 0000000000..e217b8af2f --- /dev/null +++ b/crates/engine/tests/integration/veyran_storm_source_scope.rs @@ -0,0 +1,46 @@ +//! Veyran doubles triggered abilities of permanents, not spell abilities. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::game_state::{StackEntryKind, SyntheticTriggerProvenance}; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; + +const VEYRAN_DOUBLER_ORACLE: &str = "If you casting or copying an instant or sorcery spell causes a triggered ability of a permanent you control to trigger, that ability triggers an additional time."; +const CHATTERSTORM_ORACLE: &str = "Convoke\n\ +Create a 1/1 green Squirrel creature token.\n\ +Storm (When you cast this spell, copy it for each spell cast before it this turn. You may choose new targets for the copies.)"; + +/// CR 603.2d + CR 702.40a: Veyran's "ability of a permanent" scope excludes +/// Storm, a triggered ability of the spell on the stack. +#[test] +fn veyran_does_not_double_storm() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature_from_oracle(P0, "Veyran, Voice of Duality", 2, 2, VEYRAN_DOUBLER_ORACLE); + let chatterstorm = scenario + .add_spell_to_hand_from_oracle(P0, "Chatterstorm", false, CHATTERSTORM_ORACLE) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + let commit = runner.cast(chatterstorm).commit(); + let state = commit.state(); + let storm_triggers = state + .stack + .iter() + .filter(|entry| { + matches!( + &entry.kind, + StackEntryKind::TriggeredAbility { + provenance: Some(SyntheticTriggerProvenance::Storm { .. }), + .. + } + ) + }) + .count(); + + assert_eq!( + storm_triggers, 1, + "Veyran must not double Storm because Storm belongs to the spell, not a permanent" + ); +} diff --git a/crates/engine/tests/integration/vivien_invocation_reflexive_power.rs b/crates/engine/tests/integration/vivien_invocation_reflexive_power.rs index 2cae83968a..15b91d5790 100644 --- a/crates/engine/tests/integration/vivien_invocation_reflexive_power.rs +++ b/crates/engine/tests/integration/vivien_invocation_reflexive_power.rs @@ -125,6 +125,7 @@ fn vivien_invocation_reflexive_trigger_deals_entering_creatures_power() { source_name: String::new(), subject_match_count: None, die_result: None, + provenance: None, }, }); runner.state_mut().waiting_for = WaitingFor::Priority { player: P0 }; diff --git a/crates/phase-ai/src/policies/anti_self_harm.rs b/crates/phase-ai/src/policies/anti_self_harm.rs index 13886f8b7f..7a09e0caf3 100644 --- a/crates/phase-ai/src/policies/anti_self_harm.rs +++ b/crates/phase-ai/src/policies/anti_self_harm.rs @@ -3878,6 +3878,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); let config = AiConfig::default(); @@ -3991,6 +3992,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); let config = AiConfig::default(); diff --git a/crates/phase-ai/src/policies/evasion_removal_priority.rs b/crates/phase-ai/src/policies/evasion_removal_priority.rs index 1814f67268..e893c86267 100644 --- a/crates/phase-ai/src/policies/evasion_removal_priority.rs +++ b/crates/phase-ai/src/policies/evasion_removal_priority.rs @@ -1025,6 +1025,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, })); let config = AiConfig::default(); let slot = TargetSelectionSlot { diff --git a/crates/server-core/src/filter.rs b/crates/server-core/src/filter.rs index 7257077c08..b6349908aa 100644 --- a/crates/server-core/src/filter.rs +++ b/crates/server-core/src/filter.rs @@ -504,6 +504,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + provenance: None, }; PendingTriggerContext::single(pending) }