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