diff --git a/client/src/components/hand/MobileHandDrawer.tsx b/client/src/components/hand/MobileHandDrawer.tsx
index 42af9c78f9..dd1689b732 100644
--- a/client/src/components/hand/MobileHandDrawer.tsx
+++ b/client/src/components/hand/MobileHandDrawer.tsx
@@ -18,6 +18,7 @@ import {
} from "../../viewmodel/cardActionChoice.ts";
import { useCardOrganizer } from "../modal/cardChoice/useCardOrganizer.ts";
import { CardOrganizerToolbar } from "../modal/cardChoice/CardOrganizerToolbar.tsx";
+import { StormCopyBadge } from "./StormCopyBadge.tsx";
// Stable empty lookup so an undefined `objects` (pre-game) never busts the
// organizer's filter memo with a fresh `{}` each render.
@@ -239,7 +240,6 @@ const DrawerCard = memo(function DrawerCard({
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)]);
@@ -305,12 +305,7 @@ 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 75318e8c39..88e465d2b3 100644
--- a/client/src/components/hand/MobileHeldHandCard.tsx
+++ b/client/src/components/hand/MobileHeldHandCard.tsx
@@ -1,6 +1,5 @@
import { useLayoutEffect } from "react";
import { createPortal } from "react-dom";
-import { useTranslation } from "react-i18next";
import {
motion,
useMotionValue,
@@ -17,6 +16,7 @@ import type { MobileHandGesture } from "../../stores/uiStore.ts";
import { spellCostDisplay } from "../../viewmodel/costLabel.ts";
import { CardImage } from "../card/CardImage.tsx";
import { ManaCostPips } from "../mana/ManaCostPips.tsx";
+import { StormCopyBadge } from "./StormCopyBadge.tsx";
interface MobileHeldHandCardProps {
gesture: MobileHandGesture | null;
@@ -32,7 +32,6 @@ interface MobileHeldHandCardProps {
* remains keyed in the fan but collapsed until the gesture ends.
*/
export function MobileHeldHandCard({ gesture, object, stormCopyCount }: MobileHeldHandCardProps) {
- const { t } = useTranslation("game");
const effectiveCost = useGameStore((s) =>
object ? s.spellCosts[String(object.id)] : undefined,
);
@@ -134,12 +133,7 @@ export function MobileHeldHandCard({ gesture, object, stormCopyCount }: MobileHe
{stormCopyCount !== undefined && (
-
- {stormCopyCount}
-
+
)}
,
document.body,
diff --git a/client/src/components/hand/PlayerHand.tsx b/client/src/components/hand/PlayerHand.tsx
index b0e55152a5..1eb99bf52e 100644
--- a/client/src/components/hand/PlayerHand.tsx
+++ b/client/src/components/hand/PlayerHand.tsx
@@ -47,6 +47,7 @@ import {
} from "./handFanPresentation.ts";
import { useHandScrubPreview } from "./useHandScrubPreview.ts";
import { MobileHeldHandCard } from "./MobileHeldHandCard.tsx";
+import { StormCopyBadge } from "./StormCopyBadge.tsx";
// Stable empty lookup so an undefined `objects` (pre-game) never busts the
// organizer's filter memo with a fresh `{}` each render.
@@ -864,7 +865,6 @@ 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(
@@ -1011,12 +1011,7 @@ const HandCard = memo(function HandCard({
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
diff --git a/client/src/components/hand/StormCopyBadge.tsx b/client/src/components/hand/StormCopyBadge.tsx
new file mode 100644
index 0000000000..7f8dcabe05
--- /dev/null
+++ b/client/src/components/hand/StormCopyBadge.tsx
@@ -0,0 +1,28 @@
+import { useTranslation } from "react-i18next";
+
+type StormCopyBadgeVariant = "drawer" | "held" | "fan";
+
+const BADGE_CLASS_BY_VARIANT: Record = {
+ drawer:
+ "pointer-events-none absolute right-1 top-1 rounded-full bg-violet-700 px-1.5 py-0.5 text-[11px] font-bold leading-none text-white shadow-md",
+ held:
+ "absolute right-1 top-1 rounded-full bg-violet-700 px-1.5 py-0.5 text-[11px] font-bold leading-none text-white shadow-md",
+ fan:
+ "pointer-events-none absolute -right-1 -top-2 rounded-full bg-violet-700 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white shadow-md",
+};
+
+export function StormCopyBadge({
+ count,
+ variant,
+}: {
+ count: number;
+ variant: StormCopyBadgeVariant;
+}) {
+ const { t } = useTranslation("game");
+
+ return (
+
+ {count}
+
+ );
+}
diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs
index 3036c39941..1b030177b7 100644
--- a/crates/engine/src/game/triggers.rs
+++ b/crates/engine/src/game/triggers.rs
@@ -49,7 +49,7 @@ use super::conditions::{
};
use super::filter::{
matches_target_filter, matches_target_filter_on_damage_record_source,
- spell_record_matches_filter, FilterContext,
+ matches_target_filter_on_lki_snapshot, spell_record_matches_filter, FilterContext,
};
use super::game_object::GameObject;
use super::speed::{
@@ -4221,10 +4221,11 @@ fn collect_pending_triggers_with_collection(
.get(cast_obj_id)
.map(|source| trigger_source_context_for_latch(state, source));
- // 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.
+ // CR 702.40a/b: Storm is a triggered ability that functions on the
+ // stack, and its instances are fixed 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)
@@ -7783,6 +7784,70 @@ fn filter_references_self(filter: &TargetFilter) -> bool {
}
}
+/// CR 403.3: A doubler's "ability of a permanent" scope refers to a source
+/// that was a battlefield permanent when it triggered. The `Permanent` type
+/// filter remains available for "permanent card" queries in other zones, so
+/// this trigger-source restriction lives at the doubler's CR 603.2d boundary.
+fn doubler_filter_requires_battlefield_permanent(filter: &TargetFilter) -> bool {
+ match filter {
+ TargetFilter::Typed(typed) => typed.type_filters.contains(&TypeFilter::Permanent),
+ TargetFilter::And { filters } => filters
+ .iter()
+ .any(doubler_filter_requires_battlefield_permanent),
+ TargetFilter::Or { filters } => filters
+ .iter()
+ .all(doubler_filter_requires_battlefield_permanent),
+ TargetFilter::Not { .. } => false,
+ _ => false,
+ }
+}
+
+/// CR 403.3 + CR 608.2h: Match a trigger source against its doubler's scope.
+/// A source that has left the battlefield is checked from its captured source
+/// context, while a permanent spell observed on the stack cannot satisfy an
+/// "ability of a permanent" filter.
+fn trigger_source_matches_doubler_filter(
+ state: &GameState,
+ trigger: &PendingTrigger,
+ filter: &TargetFilter,
+ doubler_id: ObjectId,
+) -> bool {
+ let filter_context = FilterContext::from_source(state, doubler_id);
+ if !doubler_filter_requires_battlefield_permanent(filter) {
+ return matches_target_filter(state, trigger.source_id, filter, &filter_context);
+ }
+
+ let Some(source_context) = trigger.ability.trigger_source.as_ref() else {
+ // Built-in keyword triggers are collected only from battlefield
+ // candidates and do not capture a source context. Their source remains
+ // live during this collection pass, so evaluate its current object.
+ return state
+ .objects
+ .get(&trigger.source_id)
+ .is_some_and(|obj| obj.zone == Zone::Battlefield)
+ && matches_target_filter(state, trigger.source_id, filter, &filter_context);
+ };
+ if source_context.identity.expected_zone != Zone::Battlefield {
+ return false;
+ }
+
+ let source_is_still_on_battlefield = state.objects.get(&trigger.source_id).is_some_and(|obj| {
+ obj.zone == Zone::Battlefield
+ && ObjectIncarnationRef::from_object(obj) == source_context.identity.reference
+ });
+ if source_is_still_on_battlefield {
+ matches_target_filter(state, trigger.source_id, filter, &filter_context)
+ } else {
+ matches_target_filter_on_lki_snapshot(
+ state,
+ trigger.source_id,
+ &source_context.lki,
+ filter,
+ &filter_context,
+ )
+ }
+}
+
fn apply_trigger_doubling(state: &GameState, pending: &mut Vec) {
// CR 702.26b + CR 604.1: `active_static_definitions` owns the gating so a
// phased-out doubler no longer doubles triggers.
@@ -7843,12 +7908,7 @@ fn apply_trigger_doubling(state: &GameState, pending: &mut Vec