Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
82de74c
fix(engine): Metamorphic Alteration as-enters choose + host copy (#6013)
claytonlin1110 Jul 23, 2026
c9d49fa
fix(engine): cover EffectKind::ChoosePermanent in trigger index
claytonlin1110 Jul 23, 2026
a6cf3ce
fix(engine): stop using GameScenario::state_mut in Metamorphic tests
claytonlin1110 Jul 23, 2026
c93e959
fix(engine): attach Aura before Metamorphic copy choice applies
claytonlin1110 Jul 23, 2026
0efb9a3
fix(engine): route Metamorphic as-enters choose through replacement p…
claytonlin1110 Jul 23, 2026
9173e16
fix(engine): gate Metamorphic ChoosePermanent on CopyChosen link
claytonlin1110 Jul 23, 2026
b503d99
fix(engine): compile CopyChosenHost apply against Box execute
claytonlin1110 Jul 23, 2026
ce5cf1d
fix(engine): narrow Metamorphic choose to CopyChosenHost only
claytonlin1110 Jul 23, 2026
3a0e901
fix(engine): drop unused as-enters permanent phrase helper
claytonlin1110 Jul 23, 2026
c7d81ea
fix(engine): use Definitions::iter_all in Metamorphic fixture assert
claytonlin1110 Jul 23, 2026
e42fc83
fix(engine): use public iter_unchecked in Metamorphic fixture
claytonlin1110 Jul 23, 2026
90f4179
fix(engine): honor mid-entry choice from spell CallerEpilogue drain
claytonlin1110 Jul 23, 2026
e5daa59
fix(engine): apply CallerEpilogue WaitingFor without burying continua…
claytonlin1110 Jul 23, 2026
942cbcb
fix(engine): drain CallerEpilogue post-effects after Aura attach
claytonlin1110 Jul 23, 2026
7c9276e
fix(engine): recover Aura host attach for Metamorphic spell path
claytonlin1110 Jul 23, 2026
5fb5498
fix(engine): carry Aura spell target through Metamorphic CopyTargetCh…
claytonlin1110 Jul 23, 2026
4d35c00
fix(engine): resolve cast-link locals in CopyTargetChoice stash
claytonlin1110 Jul 23, 2026
1b988ba
Merge branch 'main' into fix/6013-metamorphic-alteration
claytonlin1110 Jul 23, 2026
46f4cd5
fix(engine): clear Metamorphic CopyTargetChoice before next cast
claytonlin1110 Jul 23, 2026
b71d2c7
fix(engine): stop echoing answered Metamorphic CopyTargetChoice
claytonlin1110 Jul 23, 2026
a08ffb0
refactor(engine): Metamorphic review fixups — drop persist, share spe…
claytonlin1110 Jul 23, 2026
cc83741
Merge branch 'main' into fix/6013-metamorphic-alteration
matthewevans Jul 24, 2026
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
2 changes: 1 addition & 1 deletion client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1639,7 +1639,7 @@ export type WaitingFor =
| { type: "GameOver"; data: { winner: PlayerId | null } }
| { type: "ReplacementChoice"; data: { player: PlayerId; candidate_count: number; candidates?: ReplacementCandidateSummary[] } }
| { type: "OrderTriggers"; data: { player: PlayerId; triggers: PendingTriggerSummary[] } }
| { type: "CopyTargetChoice"; data: { player: PlayerId; source_id: ObjectId; valid_targets: ObjectId[]; max_mana_value?: number | null } }
| { type: "CopyTargetChoice"; data: { player: PlayerId; source_id: ObjectId; valid_targets: ObjectId[]; max_mana_value?: number | null; purpose?: { type: "BecomeCopy" | "PersistChosenAttribute" } } }
| { type: "ExploreChoice"; data: { player: PlayerId; source_id: ObjectId; choosable: ObjectId[]; remaining: ObjectId[]; pending_effect: unknown } }
| { type: "ReturnAsAuraTarget"; data: { player: PlayerId; source_id: ObjectId; returned_id: ObjectId; legal_targets: TargetRef[]; pending_effect: unknown } }
| { type: "EquipTarget"; data: { player: PlayerId; equipment_id: ObjectId; valid_targets: ObjectId[] } }
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,9 @@ fn effect_projection(effect: &Effect) -> Projection {
| Effect::HideawayConceal { .. }
| Effect::CopyTokenBlockingAttacker { .. }
| Effect::BecomeCopy { .. }
// CR 707.2c (Metamorphic Alteration): as-enters copy choice — no repeatable
// modeled axis at the candidate stage.
| Effect::ChoosePermanent { .. }
| Effect::GainActivatedAbilitiesOfTarget { .. }
| Effect::ChooseCard { .. }
| Effect::DoublePT { .. }
Expand Down
11 changes: 11 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2710,6 +2710,9 @@ fn legacy_continuous_modification(m: &ContinuousModification) -> bool {
legacy_quantity_expr(value)
}
ContinuousModification::CopyValues { .. }
// CR 707.2c (Metamorphic Alteration): inert parse-time copy marker — no
// frozen event-context tag.
| ContinuousModification::CopyChosen
| ContinuousModification::SetName { .. }
| ContinuousModification::SetTextName { .. }
| ContinuousModification::AddPower { .. }
Expand Down Expand Up @@ -3159,6 +3162,9 @@ fn legacy_effect(x: &Effect) -> bool {
| Effect::CastFromZone {
target, duration, ..
} => legacy_target_filter(target) || odur(duration),
// CR 707.2c (Metamorphic Alteration): the copy-source choice pool is a
// target filter; walk it for legacy event-refs like every other filter.
Effect::ChoosePermanent { filter } => legacy_target_filter(filter),
Effect::GenericEffect {
duration,
target,
Expand Down Expand Up @@ -5482,6 +5488,11 @@ fn rw_effect(
// and may add counters from a live property read. Fail closed until the
// copy/counter sub-steps have a precise profile.
| Effect::EachPlayerCopyChosen { .. }
// CR 707.2c (Metamorphic Alteration): raised only from the Aura-ETB
// replacement path; on answer it installs a Layer-1 copy on the Aura's
// host. Never resolves through the intra-ability chain — fail-closed
// conservative keeps the exhaustive match honest.
| Effect::ChoosePermanent { .. }
| Effect::Myriad
| Effect::Encore
| Effect::CombineHost { .. }
Expand Down
37 changes: 31 additions & 6 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,13 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes {
acc
}
Effect::BecomeCopy { .. } => Axes::CONSERVATIVE,
// CR 707.2c: the chosen creature's copiable values are latched onto the
// Aura's host at the answer — a copy-family continuous effect, same
// conservative classification as `BecomeCopy`. `filter` scans no
// per-source projected resource (it just bounds the choice pool).
Effect::ChoosePermanent { filter } => {
scan_target_filter(filter, target_ctx, mode).or(Axes::CONSERVATIVE)
}
Effect::GainActivatedAbilitiesOfTarget {
target,
recipient,
Expand Down Expand Up @@ -5002,6 +5009,10 @@ fn scan_continuous_modification(m: &ContinuousModification, mode: ScanMode) -> A
// documented fail-safe (over-veto = missed offer). A full `TriggerDefinition`
// walker is a follow-up.
ContinuousModification::CopyValues { .. }
// CR 707.2c (Metamorphic Alteration): the copy-marker stands in for a
// copy that grants the donor's whole ability set — fail-closed alongside
// its `CopyValues` sibling (the real grant is the installed TCE).
| ContinuousModification::CopyChosen
| ContinuousModification::GrantTrigger { .. }
| ContinuousModification::GrantAllActivatedAbilitiesOf { .. }
| ContinuousModification::GrantAllTriggeredAbilitiesOf { .. }
Expand Down Expand Up @@ -5174,6 +5185,10 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext {
// recipient set ("Shards you control", CR 707.2).
| Effect::GainActivatedAbilitiesOfTarget { .. }
| Effect::BecomeCopy { .. }
// CR 707.2c (Metamorphic Alteration): the copy target is chosen from a
// battlefield-reading filter pool; defense-in-depth parity with
// BecomeCopy (fail-closed census — over-vetoes the single-host shortcut).
| Effect::ChoosePermanent { .. }
// CR 708.2 / CR 708.2a (face-down permanents): `resolved_battlefield_object_
// ids` (effects/mod.rs) falls through to a battlefield mass scan for a
// non-targeted "turn each matching creature face up/down" (Illithid
Expand Down Expand Up @@ -5485,7 +5500,7 @@ enum CensusRole {
#[cfg(test)]
fn effect_census_role(e: &Effect) -> CensusRole {
match e {
// -- CENSUS (28): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus
// -- CENSUS (29): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus
// arm - mass battlefield population reads that scale with growth.
Effect::EachSourceDealsDamage { .. }
| Effect::EachDealsDamageEqualToPower { .. }
Expand Down Expand Up @@ -5527,6 +5542,9 @@ fn effect_census_role(e: &Effect) -> CensusRole {
| Effect::PhaseIn { .. }
| Effect::GainActivatedAbilitiesOfTarget { .. }
| Effect::BecomeCopy { .. }
// CR 707.2c (Metamorphic Alteration): parity with the `effect_target_ctx`
// LiveBoardCensus member added above.
| Effect::ChoosePermanent { .. }
| Effect::TurnFaceUp { .. }
| Effect::TurnFaceDown { .. }
| Effect::MultiplyCounter { .. }
Expand Down Expand Up @@ -5920,6 +5938,10 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom {
| Effect::HideawayConceal { .. }
| Effect::CopyTokenBlockingAttacker { .. }
| Effect::BecomeCopy { .. }
// CR 707.2c: raises `WaitingFor::CopyTargetChoice` — prompts, fail-closed
// MayPrompt (never resolved through the normal chain, but classified here
// to keep the match exhaustive).
| Effect::ChoosePermanent { .. }
| Effect::GainActivatedAbilitiesOfTarget { .. }
| Effect::ChooseCard { .. }
| Effect::PutCounter { .. }
Expand Down Expand Up @@ -6187,6 +6209,8 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool {
| Effect::HideawayConceal { .. }
| Effect::CopyTokenBlockingAttacker { .. }
| Effect::BecomeCopy { .. }
// CR 707.2c: choosing a permanent draws on no game randomness.
| Effect::ChoosePermanent { .. }
| Effect::GainActivatedAbilitiesOfTarget { .. }
| Effect::ChooseCard { .. }
| Effect::PutCounter { .. }
Expand Down Expand Up @@ -6874,7 +6898,7 @@ mod tests {
}

/// guard#3 (mitigation #3): the `LiveBoardCensus` tag set of `effect_target_ctx`
/// == EXACTLY the enumeration-derived MASS-POPULATION set (28). Source-scanned, not
/// == EXACTLY the enumeration-derived MASS-POPULATION set (29). Source-scanned, not
/// hand-counted (the hand-count is what produced the earlier "relax=4" miss). Under
/// B's SnapshotOrEvent default this is the primary false-certificate gate: only a
/// census tag vetoes a mass read that ESCALATES over inert token growth (which
Expand Down Expand Up @@ -6910,6 +6934,7 @@ mod tests {
"ChangeZoneAll",
"ChooseAndSacrificeRest",
"ChooseObjectsIntoTrackedSet",
"ChoosePermanent",
"CounterAll",
"DamageAll",
"DamageEachPlayer",
Expand Down Expand Up @@ -6949,7 +6974,7 @@ mod tests {
got, want,
"census tag set drifted from the enumeration-derived mass-population set"
);
assert_eq!(got.len(), 28, "exactly 28 mass-population census tags");
assert_eq!(got.len(), 29, "exactly 29 mass-population census tags");
}

/// A7' (mitigation #4, replaces the void census-default A7): with SnapshotOrEvent the
Expand Down Expand Up @@ -7060,7 +7085,7 @@ mod tests {
/// with `effect_target_ctx` on the Census/Relax boundary, closing the F1 gap where a
/// census-ROLE slot silently in the generic relax `|`-chain (exactly R1's Suspect{All})
/// is invisible to the census-arm-only guards. Structural: both functions' `Census`
/// name-sets are source-scanned and asserted IDENTICAL (== the 28). Behavioral: the
/// name-sets are source-scanned and asserted IDENTICAL (== the 29). Behavioral: the
/// two oracles agree on every discriminator, incl. BOTH Suspect/Unsuspect scopes.
///
/// REVERT-PROBE (discrimination proof): moving `Suspect{All}` out of the census arm of
Expand All @@ -7075,7 +7100,7 @@ mod tests {
use crate::types::ability::{EffectScope, TapStateChange};
use ScanMode::LoopFirewall;

// -- Structural: the two census name-sets are byte-identical (and == 28).
// -- Structural: the two census name-sets are byte-identical (and == 29).
fn census_names(fnsrc: &str, terminator: &str) -> Vec<String> {
let end = fnsrc.find(terminator).expect("census terminator");
let block = &fnsrc[..end];
Expand Down Expand Up @@ -7104,7 +7129,7 @@ mod tests {
etc_census, ecr_census,
"effect_census_role Census set diverged from effect_target_ctx"
);
assert_eq!(ecr_census.len(), 28, "exactly 28 census members");
assert_eq!(ecr_census.len(), 29, "exactly 29 census members");

// -- Behavioral: the two oracles agree on the Census/Relax boundary for every
// discriminator. `census(e, true)` requires BOTH `effect_census_role == Census`
Expand Down
7 changes: 7 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2333,6 +2333,10 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> {
} => {
d.push(("target".into(), fmt_target(target)));
}
// CR 707.2c (Metamorphic Alteration): report the copy-source choice pool.
Effect::ChoosePermanent { filter } => {
d.push(("choose".into(), fmt_target(filter)));
}
Effect::Destroy { target, .. }
| Effect::Sacrifice { target, .. }
| Effect::GainControl { target }
Expand Down Expand Up @@ -4165,6 +4169,9 @@ fn fmt_modification(m: &crate::types::ability::ContinuousModification) -> String
use crate::types::ability::ContinuousModification;
match m {
ContinuousModification::CopyValues { .. } => "copy values".into(),
// CR 707.2c (Metamorphic Alteration): parse-time marker for the enchanted
// host's copy — the runtime copy is the latched `CopyValues` TCE.
ContinuousModification::CopyChosen => "copy chosen".into(),
ContinuousModification::SetName { name } => format!("set name {name}"),
ContinuousModification::SetTextName { name } => format!("set text name {name}"),
ContinuousModification::AddPower { value } => format!("power {:+}", value),
Expand Down
14 changes: 14 additions & 0 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3886,6 +3886,20 @@ pub fn resolve_effect(
copy_token_blocking::resolve(state, ability, events)
}
Effect::BecomeCopy { .. } => become_copy::resolve(state, ability, events),
// CR 614.12a + CR 707.2c (Metamorphic Alteration): `ChoosePermanent` is an
// as-enters replacement-effect choice, NOT a stack-resolution effect. It is
// raised by `apply_post_replacement_effect` and answered by
// `handle_copy_target_choice` (PersistChosenAttribute). It never enters the
// normal resolution chain, so reaching this dispatch arm is a bug.
Effect::ChoosePermanent { .. } => {
debug_assert!(
false,
"Effect::ChoosePermanent must be handled via the Aura-ETB \
replacement path (apply_post_replacement_effect), never resolved \
as a stack effect"
);
Ok(())
}
Effect::GainActivatedAbilitiesOfTarget { .. } => {
gain_activated_abilities::resolve(state, ability, events)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3448,6 +3448,7 @@ fn copy_target_choice_resolves_become_copy() {
source_id: clone_id,
valid_targets: vec![target_id],
max_mana_value: None,
purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy,
};

// Player chooses to copy Grizzly Bears
Expand Down Expand Up @@ -3542,6 +3543,7 @@ fn copy_target_choice_applies_copied_enter_with_counters_replacement_before_sba(
source_id: assassin,
valid_targets: vec![ghave],
max_mana_value: None,
purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy,
};

apply_as_current(
Expand Down Expand Up @@ -3615,6 +3617,7 @@ fn echoing_deeps_copying_sunken_citadel_prompts_for_the_copied_color_choice() {
source_id: deeps,
valid_targets: vec![citadel],
max_mana_value: None,
purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy,
};

let result = apply_as_current(
Expand Down Expand Up @@ -3922,6 +3925,7 @@ fn copy_target_choice_fires_granted_etb_trigger_against_deferred_entry_event() {
source_id: assassin,
valid_targets: vec![bear],
max_mana_value: None,
purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy,
};

apply_as_current(
Expand Down Expand Up @@ -4103,6 +4107,7 @@ fn copy_target_choice_surfaces_interactive_trigger_prompt_for_deferred_entry() {
source_id: assassin,
valid_targets: vec![bear],
max_mana_value: None,
purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy,
};

let _waiting = apply_as_current(
Expand Down Expand Up @@ -4182,6 +4187,7 @@ fn copy_target_choice_rejects_invalid_target() {
source_id: clone_id,
valid_targets: vec![valid_id], // Bird is NOT in valid targets
max_mana_value: None,
purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy,
};

// Try to choose invalid target
Expand Down Expand Up @@ -4332,6 +4338,7 @@ fn superior_spider_man_full_copy_flow_copies_graveyard_card_and_exiles_it() {
source_id: spidey,
valid_targets: vec![elesh],
max_mana_value: None,
purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy,
};

let result = apply_as_current(
Expand Down Expand Up @@ -4508,6 +4515,7 @@ fn reflexive_when_you_do_fires_after_become_copy_replacement() {
source_id: cloner,
valid_targets: vec![source_card],
max_mana_value: None,
purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy,
};

// Accumulate events across the full resolution so we can count
Expand Down
Loading
Loading