Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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>
);
}
75 changes: 64 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,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,
)
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 +7901,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
47 changes: 47 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,8 @@ 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_THIS_SPELL_ORACLE: &str = "When you cast this spell, 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 All @@ -17,6 +19,9 @@ 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())
Expand All @@ -38,9 +43,51 @@ fn veyran_does_not_double_storm() {
)
})
.count();
let witness_triggers = 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"
);
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
/// 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.
42 changes: 21 additions & 21 deletions crates/phase-ai/baselines/perf-baseline.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,35 +11,35 @@
"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,
"stack_batch_plans": 0,
"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
}
Loading