From 3adb70c2e3ca97e026a1166433026379d10865da Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 5 Aug 2026 14:11:31 -0700 Subject: [PATCH 1/7] chore(ai): refresh decision cost baseline --- crates/phase-ai/baselines/perf-baseline.json | 42 ++++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/phase-ai/baselines/perf-baseline.json b/crates/phase-ai/baselines/perf-baseline.json index 0297886158..ab6df72241 100644 --- a/crates/phase-ai/baselines/perf-baseline.json +++ b/crates/phase-ai/baselines/perf-baseline.json @@ -1,7 +1,7 @@ { "schema_version": 4, - "git_sha": "64b65e58e249", - "card_data_hash": "d11502ce500bf1ed0392287cc0376248f37f993d", + "git_sha": "1e7943098bad", + "card_data_hash": "670a4a14a501f5ae5df43f832676c4556104234d", "base_seed": 2654435769, "action_cap": 3000, "sample_count": 5, @@ -11,26 +11,26 @@ "enchantress-mirror" ], "counters": { - "attackable_player_sweeps": 830, - "auto_tap_source_cache_builds": 31, + "attackable_player_sweeps": 2640, + "auto_tap_source_cache_builds": 10, "cached_auto_tap_source_rejects": 0, - "cached_auto_tap_source_reuses": 0, + "cached_auto_tap_source_reuses": 37, "combat_shadow_block_scans": 0, - "crew_eligibility_scans": 7337, + "crew_eligibility_scans": 12027, "granted_ability_provider_scans": 0, - "layers_escalated": 93, - "layers_full_eval": 3495, - "layers_incremental": 491, - "legal_actions_spell_cost_sweeps": 31, - "legend_rule_mode_gate_scans": 10274, - "mana_aura_trigger_scans": 14286, - "mana_display_sweeps": 270, - "mana_display_swept_objects": 2718, - "priority_cast_probe_builds": 31, + "layers_escalated": 73, + "layers_full_eval": 15877, + "layers_incremental": 395, + "legal_actions_spell_cost_sweeps": 10, + "legend_rule_mode_gate_scans": 27535, + "mana_aura_trigger_scans": 53208, + "mana_display_sweeps": 247, + "mana_display_swept_objects": 2455, + "priority_cast_probe_builds": 10, "restriction_static_exact_scans": 0, - "restriction_static_mode_gate_scans": 46421, - "sba_battlefield_snapshot_builds": 10205, - "sba_empty_battlefield_short_circuits": 57, + "restriction_static_mode_gate_scans": 155747, + "sba_battlefield_snapshot_builds": 27462, + "sba_empty_battlefield_short_circuits": 35, "spell_keyword_grant_scans": 0, "stack_batch_candidates": 0, "stack_batch_observer_refusals": 0, @@ -38,8 +38,8 @@ "stack_batched_entries": 0, "stack_inert_noop_batches": 0, "stack_inert_noop_entries": 0, - "state_clone_for_legality": 6489, - "static_full_scans": 15 + "state_clone_for_legality": 19342, + "static_full_scans": 0 }, - "wall_clock_ms": 196164 + "wall_clock_ms": 25331 } \ No newline at end of file From 12563d52921fb64c80bb787828c51f65a1ebdc1d Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 5 Aug 2026 14:19:57 -0700 Subject: [PATCH 2/7] fix(storm): address review and perf gate findings --- .../src/components/hand/MobileHandDrawer.tsx | 8 +- .../components/hand/MobileHeldHandCard.tsx | 10 +-- client/src/components/hand/PlayerHand.tsx | 8 +- client/src/components/hand/StormCopyBadge.tsx | 28 +++++++ crates/engine/src/game/triggers.rs | 75 ++++++++++++++++--- .../engine/src/parser/oracle_static/tests.rs | 9 ++- .../integration/granted_storm_snapshot.rs | 2 +- .../integration/veyran_storm_source_scope.rs | 47 ++++++++++++ 8 files changed, 151 insertions(+), 36 deletions(-) create mode 100644 client/src/components/hand/StormCopyBadge.tsx diff --git a/client/src/components/hand/MobileHandDrawer.tsx b/client/src/components/hand/MobileHandDrawer.tsx index 42af9c78f9..fe5579eed1 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. @@ -305,12 +306,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..637e48bd10 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. @@ -1011,12 +1012,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..0e4f0af292 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,63 @@ 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 { + return false; + }; + 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 +7901,7 @@ fn apply_trigger_doubling(state: &GameState, pending: &mut Vec Date: Wed, 5 Aug 2026 14:22:09 -0700 Subject: [PATCH 3/7] fix(ui): remove unused drawer translation hook --- client/src/components/hand/MobileHandDrawer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/components/hand/MobileHandDrawer.tsx b/client/src/components/hand/MobileHandDrawer.tsx index fe5579eed1..dd1689b732 100644 --- a/client/src/components/hand/MobileHandDrawer.tsx +++ b/client/src/components/hand/MobileHandDrawer.tsx @@ -240,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)]); From ad31f498eb6baa86023d10f8e11a19c604edfd42 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 5 Aug 2026 14:24:30 -0700 Subject: [PATCH 4/7] fix(ui): remove unused hand translation hook --- client/src/components/hand/PlayerHand.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/components/hand/PlayerHand.tsx b/client/src/components/hand/PlayerHand.tsx index 637e48bd10..1eb99bf52e 100644 --- a/client/src/components/hand/PlayerHand.tsx +++ b/client/src/components/hand/PlayerHand.tsx @@ -865,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( From 2a4663ce9785d7491f6f10b049c370e654a886bf Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 5 Aug 2026 14:48:35 -0700 Subject: [PATCH 5/7] test(storm): isolate veyran positive guard --- .../integration/veyran_storm_source_scope.rs | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/crates/engine/tests/integration/veyran_storm_source_scope.rs b/crates/engine/tests/integration/veyran_storm_source_scope.rs index a868811d8b..29e910aec3 100644 --- a/crates/engine/tests/integration/veyran_storm_source_scope.rs +++ b/crates/engine/tests/integration/veyran_storm_source_scope.rs @@ -6,8 +6,9 @@ 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, draw a card."; +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 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.)"; @@ -19,9 +20,6 @@ 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 witness = scenario - .add_creature_from_oracle(P0, "Cast Witness", 1, 1, CAST_WITNESS_ORACLE) - .id(); let chatterstorm = scenario .add_spell_to_hand_from_oracle(P0, "Chatterstorm", false, CHATTERSTORM_ORACLE) .with_mana_cost(ManaCost::zero()) @@ -43,7 +41,32 @@ fn veyran_does_not_double_storm() { ) }) .count(); - let witness_triggers = state + + assert_eq!( + storm_triggers, 1, + "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| { @@ -56,10 +79,6 @@ fn veyran_does_not_double_storm() { witness_triggers, 2, "Veyran must double a cast-triggered ability from a controlled permanent" ); - assert_eq!( - storm_triggers, 1, - "Veyran must not double Storm because Storm belongs to the spell, not a permanent" - ); } /// CR 403.3 + CR 603.2d: A creature spell is not a permanent while it is on From 2e05f413efa8adcdb7d9a8e7f31c2f9ae9858029 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 5 Aug 2026 14:53:07 -0700 Subject: [PATCH 6/7] fix(triggers): preserve keyword doubler coverage --- crates/engine/src/game/triggers.rs | 5 ++- .../integration/veyran_storm_source_scope.rs | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 0e4f0af292..777aae07fd 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -7818,7 +7818,10 @@ fn trigger_source_matches_doubler_filter( } let Some(source_context) = trigger.ability.trigger_source.as_ref() else { - return false; + // 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 matches_target_filter(state, trigger.source_id, filter, &filter_context); }; if source_context.identity.expected_zone != Zone::Battlefield { return false; diff --git a/crates/engine/tests/integration/veyran_storm_source_scope.rs b/crates/engine/tests/integration/veyran_storm_source_scope.rs index 29e910aec3..44ee713eed 100644 --- a/crates/engine/tests/integration/veyran_storm_source_scope.rs +++ b/crates/engine/tests/integration/veyran_storm_source_scope.rs @@ -8,6 +8,7 @@ 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\ @@ -81,6 +82,40 @@ fn veyran_doubles_cast_trigger_of_battlefield_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] From a350494e510e8c12a2be850378cbb71a8b782de1 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 5 Aug 2026 14:54:57 -0700 Subject: [PATCH 7/7] fix(triggers): require live keyword source --- crates/engine/src/game/triggers.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 777aae07fd..1b030177b7 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -7821,7 +7821,11 @@ fn trigger_source_matches_doubler_filter( // 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 matches_target_filter(state, trigger.source_id, filter, &filter_context); + 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;