Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions client/src/components/hand/MobileHandDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)]);
Expand Down Expand Up @@ -305,12 +305,7 @@ const DrawerCard = memo(function DrawerCard({
<ManaCostPips cost={displayCost} isReduced={isReduced} size="fluid" />
</div>
{stormCopyCount !== undefined && (
<span
className="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"
title={t("storm.copies", { count: stormCopyCount })}
>
{stormCopyCount}
</span>
<StormCopyBadge count={stormCopyCount} variant="drawer" />
)}
</button>
);
Expand Down
10 changes: 2 additions & 8 deletions client/src/components/hand/MobileHeldHandCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
motion,
useMotionValue,
Expand All @@ -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;
Expand All @@ -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,
);
Expand Down Expand Up @@ -134,12 +133,7 @@ export function MobileHeldHandCard({ gesture, object, stormCopyCount }: MobileHe
<ManaCostPips cost={displayCost} isReduced={isReduced} size="fluid" />
</div>
{stormCopyCount !== undefined && (
<span
className="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"
title={t("storm.copies", { count: stormCopyCount })}
>
{stormCopyCount}
</span>
<StormCopyBadge count={stormCopyCount} variant="held" />
)}
</motion.div>,
document.body,
Expand Down
9 changes: 2 additions & 7 deletions client/src/components/hand/PlayerHand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1011,12 +1011,7 @@ const HandCard = memo(function HandCard({
className="!w-[var(--hand-card-w)] !h-[var(--hand-card-h)]"
/>
{stormCopyCount !== undefined && (
<span
className="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"
title={t("storm.copies", { count: stormCopyCount })}
>
{stormCopyCount}
</span>
<StormCopyBadge count={stormCopyCount} variant="fan" />
)}
{/* Inner-edge drop highlights. Always rendered, normally invisible; their
opacity is driven by MotionValues so the glow toggles without a
Expand Down
28 changes: 28 additions & 0 deletions client/src/components/hand/StormCopyBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";

type StormCopyBadgeVariant = "drawer" | "held" | "fan";

const BADGE_CLASS_BY_VARIANT: Record<StormCopyBadgeVariant, string> = {
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 (
<span className={BADGE_CLASS_BY_VARIANT[variant]} title={t("storm.copies", { count })}>
{count}
</span>
);
}
82 changes: 71 additions & 11 deletions crates/engine/src/game/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
&source_context.lki,
filter,
&filter_context,
)
}
}

fn apply_trigger_doubling(state: &GameState, pending: &mut Vec<PendingTriggerContext>) {
// CR 702.26b + CR 604.1: `active_static_definitions` owns the gating so a
// phased-out doubler no longer doubles triggers.
Expand Down Expand Up @@ -7843,12 +7908,7 @@ fn apply_trigger_doubling(state: &GameState, pending: &mut Vec<PendingTriggerCon
// CR 603.2d: If the doubler specifies an affected filter (e.g. "creature you
// control of the chosen type"), only double triggers from matching sources.
if let Some(filter) = affected {
if !matches_target_filter(
state,
trigger.source_id,
filter,
&FilterContext::from_source(state, *doubler_id),
) {
if !trigger_source_matches_doubler_filter(state, trigger, filter, *doubler_id) {
continue;
}
}
Expand Down
9 changes: 5 additions & 4 deletions crates/engine/src/parser/oracle_static/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12081,10 +12081,11 @@ 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
// remains excluded because its cast-grant count is not consumed, while Storm is
// preserved because its synthesized trigger consumes every cast-time instance.
// CR 702.40b: Each Storm instance triggers separately. The parser's duplicate gate
// consults `cast_merge_preserves_instances`, which is deliberately NARROWER than the
// semantic `instances_function_separately`: Exalted 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ 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
/// CR 601.2a + 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]
Expand Down
101 changes: 101 additions & 0 deletions crates/engine/tests/integration/veyran_storm_source_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ 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 CAST_WITNESS_ORACLE: &str = "Whenever you cast an instant or sorcery spell, add {R}.";
const CAST_THIS_SPELL_ORACLE: &str = "When you cast this spell, draw a card.";
const PROWESS_WITNESS_ORACLE: &str = "Prowess";
const SIMPLE_SPELL_ORACLE: &str = "Draw a card.";
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.)";
Expand Down Expand Up @@ -44,3 +48,100 @@ fn veyran_does_not_double_storm() {
"Veyran must not double Storm because Storm belongs to the spell, not a permanent"
);
}

/// CR 603.2d: Veyran doubles a cast-triggered ability of a controlled
/// battlefield permanent.
#[test]
fn veyran_doubles_cast_trigger_of_battlefield_permanent() {
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 witness = scenario
.add_creature_from_oracle(P0, "Cast Witness", 1, 1, CAST_WITNESS_ORACLE)
.id();
let spell = scenario
.add_spell_to_hand_from_oracle(P0, "Simple Spell", false, SIMPLE_SPELL_ORACLE)
.with_mana_cost(ManaCost::zero())
.id();

let mut runner = scenario.build();
let commit = runner.cast(spell).commit();
let witness_triggers = commit
.state()
.stack
.iter()
.filter(|entry| {
entry.source_id == witness
&& matches!(&entry.kind, StackEntryKind::TriggeredAbility { .. })
})
.count();

assert_eq!(
witness_triggers, 2,
"Veyran must double a cast-triggered ability from a controlled permanent"
);
}

/// CR 702.108a + CR 603.2d: Keyword-synthesized triggers do not capture a
/// source context, but their live battlefield source still qualifies for
/// Veyran's permanent scope.
#[test]
fn veyran_doubles_prowess_without_captured_source_context() {
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 prowess_witness = scenario
.add_creature_from_oracle(P0, "Prowess Witness", 1, 1, PROWESS_WITNESS_ORACLE)
.id();
let spell = scenario
.add_spell_to_hand_from_oracle(P0, "Simple Spell", false, SIMPLE_SPELL_ORACLE)
.with_mana_cost(ManaCost::zero())
.id();

let mut runner = scenario.build();
let commit = runner.cast(spell).commit();
let prowess_triggers = commit
.state()
.stack
.iter()
.filter(|entry| {
entry.source_id == prowess_witness
&& matches!(&entry.kind, StackEntryKind::TriggeredAbility { .. })
})
.count();

assert_eq!(
prowess_triggers, 2,
"Veyran must double Prowess while its source remains on the battlefield"
);
}

/// CR 403.3 + CR 603.2d: A creature spell is not a permanent while it is on
/// the stack, so Veyran must not double its "when you cast this spell" trigger.
#[test]
fn veyran_does_not_double_cast_trigger_of_permanent_spell() {
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 creature_spell = scenario
.add_creature_to_hand_from_oracle(P0, "Stack-Born Witness", 1, 1, CAST_THIS_SPELL_ORACLE)
.with_mana_cost(ManaCost::zero())
.id();

let mut runner = scenario.build();
let commit = runner.cast(creature_spell).commit();
let creature_spell_triggers = commit
.state()
.stack
.iter()
.filter(|entry| {
entry.source_id == creature_spell
&& matches!(&entry.kind, StackEntryKind::TriggeredAbility { .. })
})
.count();

assert_eq!(
creature_spell_triggers, 1,
"Veyran must not double a trigger whose source is a creature spell on the stack"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading