From 19cb8b890c223503ebe47d085573bed5b05ca353 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:01:15 -0700 Subject: [PATCH 01/13] fix(engine): escalate entry-incremental flush when an entrant's card types are rewritten CR 613.1 + CR 613.1d + CR 613.4a + CR 611.3a. Ashaya, Soul of the Wild's characteristic-defining ability counts "lands you control" (layer 7a, CR 613.4a) over a board its own layer-4 static feeds ("nontoken creatures you control are Forest lands", CR 613.1d). Layers apply in order (CR 613.1), so the type rewrite has already happened by the time the CDA's population read is evaluated -- but the `EnteredObjects` escalation gate probed the ENTERING object's membership in the counted population using its PRE-layer characteristics: a creature, not a land. The gate concluded the count was unperturbed, took the incremental arm, and left Ashaya's power and toughness stale at 1/1 where a full re-evaluation derives 2/2. The gate now escalates when a population READ is live AND some active effect reaching an entrant rewrites that entrant's card types. The read side spans both channels: an effect's dynamic magnitude or affected set, and a Continuous static's enabling condition (CR 611.3a -- a static's condition isn't locked in, so it re-evaluates as the board changes, and Axis 2b probes it per-entrant with the same pre-layer blindness). Precise on the write CLASSIFIER (`modification_writes_card_types` is a wildcard-free match over every `ContinuousModification` variant, so a future type-rewriting variant cannot be added without deciding this), conservative on the read side: a counted population is almost always keyed on card type, so narrowing it would buy nothing while adding a second 98-arm classifier. Projecting the entrant forward through layer 4 instead would need a speculative pass transitively closed over grants that unlock further grants. The write-side precision is load-bearing, not decorative. Escalating on "the entrant is a recipient of anything" instead regresses the deliberately-pinned `count_anthem_nonmatching_entry_does_not_escalate_and_matches_full` and `devotion_gate_colorless_entry_does_not_escalate_and_matches_full` fast paths, where the effect reaching the entrant writes only P/T and so cannot move a type-keyed or devotion-keyed count. `incremental_flush_must_escalate` (test-only) had drifted from the production gate -- it re-implemented the axes, omitted the recipient-sourced-effect check, and ran against a board without the recipient reset. It is now a thin wrapper delegating to `prepare_incremental_flush` on a scratch clone, so the test predicate and the production gate answer the same question. The per-object "back to base" reset is now a single authority, `reset_recipient_to_base`, used by both arms: the full pass applies it board-wide, the incremental arm applies it to recipients only. The full pass previously open-coded the same five steps, which is what let a second copy come into existence. Its one extra behavior -- collecting face-down permanents so their CR 708.2 profile is re-applied after layer 1a -- stays as a two-line tail; no signature change is needed because no reset step touches `face_down`. Known remaining gap, declared not closed: a population keyed on COLOR, KEYWORD, NAME or P/T whose entrant has that characteristic rewritten by another layer is still probed pre-layer, on the read side and on the write-side reach probe alike. Closing it needs the full characteristic-kind matrix (which kind each `FilterProp` reads by which kind each `ContinuousModification` writes). Its current behavior is pinned by the synthetic tripwire `known_gap_color_keyed_population_probes_entrant_pre_layer`, which is expected to flip when the matrix lands. Two further channels are named in the same doc block because neither falls under "other characteristics": CONTROLLER, since a layer-2 `ChangeController` (CR 613.1b) can move an entrant between "creatures you control" populations and CR 109.3 says controller is not a characteristic; and GRANT CHAINS, since an effect that grants a type-writing static rather than writing types itself is a second-order path the classifier cannot see. Found by differential verification against full re-evaluation during development. The regression test is discriminating: with the new gate disjunct short-circuited to `false`, `ashaya_power_counts_a_creature_that_its_own_static_turns_into_a_land` fails at the stale 1/1 where a full pass derives 2/2, and passes with the disjunct restored. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/layers.rs | 409 ++++++++++++++---- crates/engine/src/game/stack.rs | 275 ++++++++++++ .../integration/ashaya_nontoken_lands.rs | 38 ++ .../life_and_limb_sylvan_advocate.rs | 69 +++ crates/engine/tests/integration/main.rs | 1 + 5 files changed, 700 insertions(+), 92 deletions(-) create mode 100644 crates/engine/tests/integration/life_and_limb_sylvan_advocate.rs diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 185747d46a..7b8a66354f 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -1834,8 +1834,9 @@ fn rebuild_static_index_at_top() -> bool { /// stops re-deriving them, and a naturally-menace creature keeps its printed /// menace when it stops being suspected. /// -/// Called from the Step-1 reset of both the full (`evaluate_layers`) and -/// incremental (`apply_layers_incremental`) passes, immediately after the live +/// Called from `reset_recipient_to_base`, which both passes go through — the +/// full one (`evaluate_layers`) board-wide, the incremental one +/// (`prepare_incremental_flush`) over recipients — immediately after the live /// fields are reset to base, so the derived grant rides along with every reset. fn derive_suspected_abilities(obj: &mut crate::game::game_object::GameObject) { if !obj.is_suspected { @@ -1862,11 +1863,12 @@ fn derive_suspected_abilities(obj: &mut crate::game::game_object::GameObject) { /// CR 613.1 + CR 707.2 + CR 708.2: seed live characteristics from base_*; shared /// by Step-1 top-of-pass reset and the CR 613.2b Layer-1b face-down re-seed. /// -/// Assigns the live copiable-characteristic fields from their `base_*` baseline, -/// exactly mirroring the Step-1 reset block field-for-field. Does NOT call -/// `sync_missing_base_characteristics`, and does NOT touch controller, the -/// combat-assignment flags, or `derive_suspected_abilities` — those stay inline -/// in Step 1 (they are not part of the face-down CR 708.2a re-seed). +/// Assigns the live copiable-characteristic fields from their `base_*` baseline. +/// Does NOT call `sync_missing_base_characteristics`, and does NOT touch +/// controller, the combat-assignment flags, or `derive_suspected_abilities` — +/// those live in `reset_recipient_to_base`, which calls this as its second step +/// (they are not part of the face-down CR 708.2a re-seed, which is why this is +/// separable at all). fn seed_live_characteristics_from_base(obj: &mut crate::game::game_object::GameObject) { obj.name = obj.base_name.clone(); obj.power = obj.base_power; @@ -2039,23 +2041,12 @@ pub fn evaluate_layers(state: &mut GameState) { let mut face_down_ids: Vec = Vec::new(); for &id in &bf_ids { if let Some(obj) = state.objects.get_mut(&id) { - obj.sync_missing_base_characteristics(); - seed_live_characteristics_from_base(obj); + reset_recipient_to_base(obj); + // Reset does not touch `face_down`, so this reads the same value + // whichever side of the reset it sits on. if obj.face_down { face_down_ids.push(id); } - // CR 613.1b: Reset controller to the object's base controller; - // Layer 2 re-applies continuous control-changing effects. - obj.controller = obj.base_controller.unwrap_or(obj.owner); - // CR 613.11 + CR 510.1a: Reset combat-assignment rule flags; - // re-applied after object-characteristic layers are complete. - obj.assigns_damage_from_toughness = false; - obj.assigns_damage_as_though_unblocked = false; - obj.assigns_no_combat_damage = false; - // CR 701.60c: re-derive the suspected designation's menace + - // "can't block" onto the just-reset live fields (not base), so - // the grant lasts exactly as long as the designation. - derive_suspected_abilities(obj); } } // CR 702.94a + CR 400.3: Hand-zone continuous effects (Lorehold-style @@ -3429,6 +3420,27 @@ pub fn flush_layers(state: &mut GameState) { } } +/// CR 613.1: Single authority for the per-object "back to base" reset. Both +/// arms go through here — the full pass applies it board-wide over the +/// phased-in battlefield, the incremental arm applies it to recipients only — +/// so the two arms cannot drift in what "base" means. +fn reset_recipient_to_base(obj: &mut crate::game::game_object::GameObject) { + obj.sync_missing_base_characteristics(); + seed_live_characteristics_from_base(obj); + // CR 613.1b: layer 2 control-change effects are re-applied from the base + // controller, not accumulated on top of the previous pass's result. + obj.controller = obj.base_controller.unwrap_or(obj.owner); + // CR 613.11 + CR 510.1a: combat-assignment rule effects are re-derived every + // pass, so the previous pass's grants must not persist through the reset. + obj.assigns_damage_from_toughness = false; + obj.assigns_damage_as_though_unblocked = false; + obj.assigns_no_combat_damage = false; + // CR 701.60c: re-derive the suspected designation's menace + "can't block" + // onto the just-reset live fields (not base), so the grant lasts exactly as + // long as the designation. + derive_suspected_abilities(obj); +} + fn prepare_incremental_flush( state: &mut GameState, entered_ids: &BTreeSet, @@ -3448,13 +3460,7 @@ fn prepare_incremental_flush( // and collecting the prepared effect set so local CDAs are visible again. for &id in &recipient_ids { if let Some(obj) = state.objects.get_mut(&id) { - obj.sync_missing_base_characteristics(); - seed_live_characteristics_from_base(obj); - obj.controller = obj.base_controller.unwrap_or(obj.owner); - obj.assigns_damage_from_toughness = false; - obj.assigns_damage_as_though_unblocked = false; - obj.assigns_no_combat_damage = false; - derive_suspected_abilities(obj); + reset_recipient_to_base(obj); } } crate::types::game_state::StaticSourceIndex::rebuild_from_state(state); @@ -3467,6 +3473,11 @@ fn prepare_incremental_flush( return None; } if active_effects_force_incremental_escalation(state, entered_ids, &active_effects) + || population_probe_blinded_by_entrant_characteristic_change( + state, + entered_ids, + &active_effects, + ) || any_active_static_condition_perturbed_by_entry(state, entered_ids) { return None; @@ -3481,72 +3492,283 @@ fn prepare_incremental_flush( /// Decide whether an `EnteredObjects` flush must conservatively escalate to a /// full re-evaluation. /// -/// Two axes, both required-clean for the fast path: +/// SINGLE AUTHORITY: this delegates to `prepare_incremental_flush` — the exact +/// gate `flush_layers` consults — rather than re-deriving the decision. It +/// previously carried its own two-axis reimplementation, which had already +/// drifted from production in two ways: it omitted the recipient-sourced-effect +/// check, and it evaluated the active-effect axis against a board on which the +/// recipient reset had NOT yet run (production resets first, so a stale layer-6 +/// removal could hide a recipient's live static from the test predicate but not +/// from production). A test predicate that answers a different question than the +/// production gate cannot certify the production gate. +/// +/// The escalation disjuncts are enumerated at the `prepare_incremental_flush` +/// call site and documented on each disjunct's own function. This wrapper +/// deliberately restates neither: a second enumeration here is exactly what +/// drifted last time. /// -/// 1. Per-entered preconditions: the entered object must not itself be the -/// source of a continuous effect, carry a CDA static, or carry a -/// control-override / type-change / text-change / counter / attachment / -/// transient effect (the entry enqueued none for a plain token). +/// CR 611.3a: a static-ability continuous effect isn't locked in; it applies +/// at any moment to whatever its text indicates — so a board-population- +/// dependent magnitude, affected set, or enabling condition re-evaluates when +/// an object enters, changing PRE-EXISTING recipients. CR 613.7d: the entering +/// object receives its timestamp on zone entry. CR 613.8a: dependency/timestamp +/// ordering operates on the live set. /// -/// 2. Board-wide escalation: no ACTIVE continuous effect may have a magnitude, -/// affected set, or source-level enabling CONDITION that reads battlefield -/// object population. -/// CR 611.3a: a static-ability continuous effect isn't locked in; it applies -/// at any moment to whatever its text indicates — so a board-population- -/// dependent magnitude, affected set, or enabling condition re-evaluates when -/// an object enters, changing PRE-EXISTING recipients. CR 613.7d: the entering -/// object receives its timestamp on zone entry. CR 613.8a: dependency/timestamp -/// ordering operates on the live set. This scan is O(active-effect-count), NOT -/// O(battlefield). +/// Takes `&GameState` and works on a clone because `prepare_incremental_flush` +/// mutates: after its per-entrant precondition scan it resets every recipient +/// to base. The caller asks a question and must not have its board +/// half-flushed by the asking. #[cfg(test)] pub(crate) fn incremental_flush_must_escalate( state: &GameState, entered_ids: &BTreeSet, ) -> bool { - // Axis 1 — per-entered preconditions. - for &id in entered_ids { - let Some(obj) = state.objects.get(&id) else { - // The entered object already left (e.g. it was a token that died to - // an SBA before flush). A full pass is the safe handling. - return true; - }; - if entered_object_blocks_incremental(state, obj) { - return true; + let mut scratch = state.clone(); + prepare_incremental_flush(&mut scratch, entered_ids).is_none() +} + +/// Does this effect read battlefield object POPULATION at all — through its +/// dynamic magnitude or through its affected set? +/// +/// Single authority for the unnarrowed question, consumed by +/// `population_probe_blinded_by_entrant_characteristic_change`, whose whole +/// point is that per-entrant narrowing is unreliable. Axis 2a +/// (`active_effects_force_incremental_escalation`) asks the same question but +/// needs the two channels separately to pick a narrowing, so it consumes +/// `effect_population_reads` — the shared definition underneath — directly. +/// +/// Effects are not the only population readers: a Continuous static's enabling +/// `condition` reads populations too — that channel's twin is +/// `any_active_static_condition_reads_object_population`. +fn effect_reads_object_population(e: &ActiveContinuousEffect) -> bool { + let (magnitude_sensitive, affected_sensitive) = effect_population_reads(e); + magnitude_sensitive || affected_sensitive +} + +/// The two population-read channels of a single effect, computed once: +/// `(dynamic-magnitude sensitivity, affected-set sensitivity)`. Axis 2a needs +/// the split to pick which per-entrant narrowing applies without walking the +/// affected filter twice; everything else consumes the disjunction via +/// `effect_reads_object_population`. +fn effect_population_reads(e: &ActiveContinuousEffect) -> (bool, bool) { + ( + continuous_modification_dynamic_quantity(&e.modification) + .is_some_and(crate::game::quantity::quantity_expr_uses_object_count), + crate::game::filter::affected_filter_uses_object_population(&e.affected_filter), + ) +} + +/// Condition-channel twin of `effect_reads_object_population`: does any live +/// CONTINUOUS static definition carry an enabling `condition` that reads +/// battlefield object population (CR 611.3a — the condition re-evaluates as the +/// board changes)? Axis 2b (`any_active_static_condition_perturbed_by_entry`) +/// probes such conditions per-entrant with PRE-layer characteristics, so the +/// blindness disjunct below must treat them as population readers alongside +/// effect magnitudes and affected sets. +fn any_active_static_condition_reads_object_population(state: &GameState) -> bool { + let mut found = false; + for_each_static_effect_source(state, |_state, obj| { + if found { + return; } - } + if obj.static_definitions.iter_all().any(|def| { + def.mode == StaticMode::Continuous + && def + .condition + .as_ref() + .is_some_and(static_condition_uses_object_population) + }) { + found = true; + } + }); + found +} - // Axis 2a — magnitude + affected-set over the EXISTING active effect set, - // NARROWED to entries that actually perturb the population input. - // - // Two-stage test per effect: the committed exhaustive classifier - // (`quantity_expr_uses_object_count` / `affected_filter_uses_object_population`) - // is the OUTER conjunct (compile-time tripwire — a future population-reading - // variant forces a classification). Then the entry-aware narrowing layer asks - // whether any ENTERED object can flip THIS effect's population input. - // - // CR 109.5: the filter's "you control" must resolve against the EFFECT - // SOURCE's controller, not the entered object's — so `ctx` is built per-effect - // from `e.source_id` + `e.controller`. Escalation is `classifier(e) && - // any_entered_perturbs(e)`; both required. - let active_effects = collect_shared_active_continuous_effects(state); - if active_effects_force_incremental_escalation(state, entered_ids, &active_effects) { - return true; +/// CR 613.1 + CR 613.1d + CR 613.4a: escalate when the population probes below +/// are asking about an object whose characteristics the pass is about to change. +/// Layers apply in order (CR 613.1), so a layer-4 type rewrite (CR 613.1d) has +/// already happened by the time any later layer's population read — including a +/// layer-7a CDA's P/T definition (CR 613.4a) — is evaluated; the gate's probes +/// run BEFORE any layer. +/// +/// `active_effects_force_incremental_escalation` asks "does the ENTERING object +/// join this counted population?" against the entrant's characteristics as they +/// stand at gate time — which is BEFORE any layer has applied to it. If some +/// other active effect will change those characteristics later in the same pass, +/// the probe answered about the wrong object. The same blindness afflicts the +/// per-entrant probe in `any_active_static_condition_perturbed_by_entry`: a +/// static's enabling condition (CR 611.3a) that counts a population also reads +/// the entrant pre-layer. +/// +/// Found by differential verification against a full re-evaluation during +/// development, on Ashaya, Soul of the Wild: its CDA counts "lands you control" +/// (layer 7a) while its own second static makes nontoken creatures Forest LANDS +/// (layer 4). An entering Grizzly Bears is not a land at gate time, so the probe +/// reported no perturbation, the incremental arm ran, and Ashaya kept a stale +/// 1/1 where a full pass derives 2/2. Condition-channel twin of the same +/// blindness: Life and Limb (layer 4: all Saprolings are Forest lands) plus +/// Sylvan Advocate ("as long as you control six or more lands...") — an +/// entering Saproling flips the Advocate's condition only post-layer. The +/// discriminating fixture for that channel is the synthetic +/// `condition_gated_anthem_entry_escalates_when_entrant_types_rewritten`, which +/// is constructed to take the incremental path; the printed Life and Limb pair +/// is pinned end-to-end separately in +/// `tests/integration/life_and_limb_sylvan_advocate.rs`. +/// +/// Precise on the write CLASSIFIER, conservative on the READ side. Projecting +/// the entrant through layer 4 would need a speculative pass, transitively +/// closed over grants that unlock further grants; instead this escalates when a +/// population READ is live — an effect's dynamic magnitude or affected set +/// (`effect_reads_object_population`) or a Continuous static's enabling +/// condition (`any_active_static_condition_reads_object_population`) — AND some +/// active effect reaching an entrant rewrites that entrant's CARD TYPES. The +/// read side is not narrowed because a counted population is almost always +/// keyed on card type (`ObjectCount { filter: lands you control }`), so +/// narrowing it would buy nothing while adding a second 98-arm classifier. +/// Note the write-side REACH probe (`matches_target_filter` below) shares the +/// pre-layer blindness this disjunct exists to fix: a type-writer whose +/// affected filter keys on a characteristic another layer rewrites is still +/// missed — that is the write-side twin of the KNOWN REMAINING GAP. +/// +/// This precision is load-bearing, not decorative: escalating on "the entrant is +/// a recipient of anything" instead regresses the deliberately-pinned +/// `count_anthem_nonmatching_entry_does_not_escalate_and_matches_full` and +/// `devotion_gate_colorless_entry_does_not_escalate_and_matches_full` fast paths, +/// where the effect reaching the entrant writes only P/T and so cannot move a +/// type-keyed or devotion-keyed count. +/// +/// KNOWN REMAINING GAP, same shape, other characteristics: a population keyed on +/// COLOR, KEYWORD, NAME or P/T whose entrant has that characteristic rewritten by +/// another layer is still probed pre-layer — on the read side (the counted +/// filter) and on the write side (a type-writer's own affected filter keyed on +/// a rewritten characteristic) alike. Closing it needs the full +/// characteristic-kind matrix (which kind each `FilterProp` reads × which kind +/// each `ContinuousModification` writes) rather than the card-type projection of +/// it below. No printed pairing in the current corpus is known to exercise it +/// (claim not exhaustively verified — the tripwire below, not corpus absence, +/// is what holds the line); the gap's current behavior is pinned by +/// `known_gap_color_keyed_population_probes_entrant_pre_layer` (stack.rs +/// entry-flush escalation tests), which is expected to flip when the matrix +/// lands. +/// +/// Two further channels of the same blindness, named explicitly because neither +/// is covered by the sentence above: +/// +/// 1. CONTROLLER (CR 613.1b). A population keyed on controller — "creatures you +/// control" is the overwhelmingly common shape — is read here pre-layer, +/// while a layer-2 control-change effect (`ChangeController`, classified +/// `false` by `modification_writes_card_types` because it writes no card +/// type) can move the entrant between players' populations. This is NOT a +/// subset of the paragraph above: CR 109.3 states an object's controller is +/// not one of its characteristics, so "other characteristics" excludes it by +/// construction. Closing it is cheaper than the full matrix — battlefield +/// `ChangeController` is rare, so a controller-channel disjunct would cost +/// the fast path close to nothing. +/// 2. GRANT CHAINS. `GrantAbility` / `GrantStaticAbility` / `AddStaticMode` / +/// `RemoveAllAbilities` all classify `false`, which is correct for the +/// predicate "does this write card types" but leaves a second-order path the +/// classifier cannot see: an effect that GRANTS a type-writing static (or +/// strips a CDA) reaches the entrant without itself writing a type. The +/// residual risk is small because `entered_object_blocks_incremental` +/// already escalates for entrants that carry their own static or CDA, but it +/// is not zero and it is not closed here. +fn population_probe_blinded_by_entrant_characteristic_change( + state: &GameState, + entered_ids: &BTreeSet, + active_effects: &[ActiveContinuousEffect], +) -> bool { + // Write side first: `modification_writes_card_types` is a pure enum match, + // so the common board with no type-writer reaching an entrant pays neither + // the active-effect read scan nor the static-source condition walk (a + // traversal Axis 2b repeats immediately after this gate). + let writer_reaches_entrant = active_effects.iter().any(|e| { + if !modification_writes_card_types(&e.modification) { + return false; + } + let ctx = FilterContext::from_source_with_controller(e.source_id, e.controller); + entered_ids + .iter() + .any(|id| matches_target_filter(state, *id, &e.affected_filter, &ctx)) + }); + if !writer_reaches_entrant { + return false; } + active_effects.iter().any(effect_reads_object_population) + || any_active_static_condition_reads_object_population(state) +} - // Axis 2b — source-level enabling CONDITION over the EXISTING static-ability - // sources, NARROWED to entries that actually perturb the condition. Conditions - // remain attached to collected effects for application-time evaluation, while - // this source walk supplies the before/after truth comparison required for an - // incremental full-rebuild decision. - // - // CR 611.3a + CR 611.3b: when such a source-level enabling condition depends - // on board population, an object entering can flip the condition for the - // WHOLE recipient set, changing PRE-EXISTING recipients — so escalate to a - // full rebuild. The entry-aware narrowing (built per-source from the visited - // object, CR 109.5) skips escalation when no entered object can perturb the - // gate; the truth-delta refinement (below) skips escalation even when an - // entry perturbs the gate INPUT but does not flip its truth value. - any_active_static_condition_perturbed_by_entry(state, entered_ids) +/// CR 613.1d (layer 4): does this modification rewrite an object's card types, +/// subtypes or supertypes — the characteristics a counted population is keyed on? +/// +/// EXHAUSTIVE and wildcard-free over `ContinuousModification`, so a future +/// type-rewriting variant must be classified here at compile time rather than +/// silently reopening the Ashaya divergence. +fn modification_writes_card_types(m: &ContinuousModification) -> bool { + match m { + ContinuousModification::AddType { .. } + | ContinuousModification::RemoveType { .. } + | ContinuousModification::SetCardTypes { .. } + | ContinuousModification::AddSubtype { .. } + | ContinuousModification::RemoveSubtype { .. } + | ContinuousModification::RemoveAllSubtypes { .. } + | ContinuousModification::AddAllCreatureTypes + | ContinuousModification::AddAllBasicLandTypes + | ContinuousModification::AddAllLandTypes + | ContinuousModification::AddChosenSubtype { .. } + | ContinuousModification::SetBasicLandType { .. } + | ContinuousModification::SetChosenBasicLandType + | ContinuousModification::AddSupertype { .. } + | ContinuousModification::RemoveSupertype { .. } => true, + // CR 613.2a + CR 613.2c + CR 707.2: a copy effect (layer 1a) replaces + // the copiable values, + // card types among them, so it rewrites types just as surely as + // `SetCardTypes` does. + ContinuousModification::CopyValues { .. } | ContinuousModification::CopyChosen => true, + // Everything else writes a characteristic that is not a card type. + // Enumerated explicitly (no wildcard) so a future type-rewriting variant + // forces a decision here. + ContinuousModification::SetName { .. } + | ContinuousModification::SetTextName { .. } + | ContinuousModification::SetChosenName + | ContinuousModification::AddPower { .. } + | ContinuousModification::AddToughness { .. } + | ContinuousModification::SetPower { .. } + | ContinuousModification::SetToughness { .. } + | ContinuousModification::SetDynamicPower { .. } + | ContinuousModification::SetDynamicToughness { .. } + | ContinuousModification::SetPowerDynamic { .. } + | ContinuousModification::SetToughnessDynamic { .. } + | ContinuousModification::AddDynamicPower { .. } + | ContinuousModification::AddDynamicToughness { .. } + | ContinuousModification::SwitchPowerToughness + | ContinuousModification::SetStartingLoyalty { .. } + | ContinuousModification::AddCounterOnEnter { .. } + | ContinuousModification::AddKeyword { .. } + | ContinuousModification::AddDynamicKeyword { .. } + | ContinuousModification::AddKeywordWithDerivedCost { .. } + | ContinuousModification::RemoveKeyword { .. } + | ContinuousModification::AddChosenKeyword + | ContinuousModification::RemoveChosenKeyword + | ContinuousModification::GrantAbility { .. } + | ContinuousModification::GrantAllActivatedAbilitiesOf { .. } + | ContinuousModification::GrantAllTriggeredAbilitiesOf { .. } + | ContinuousModification::GrantTrigger { .. } + | ContinuousModification::GrantReplacement { .. } + | ContinuousModification::GrantStaticAbility { .. } + | ContinuousModification::AddStaticMode { .. } + | ContinuousModification::RemoveAllAbilities + | ContinuousModification::RetainPrintedTriggerFromSource { .. } + | ContinuousModification::RetainPrintedAbilityFromSource { .. } + | ContinuousModification::RetainAllOtherAbilitiesFromSource + | ContinuousModification::SetColor { .. } + | ContinuousModification::AddColor { .. } + | ContinuousModification::AddChosenColor { .. } + | ContinuousModification::AssignDamageFromToughness + | ContinuousModification::AssignDamageAsThoughUnblocked + | ContinuousModification::AssignNoCombatDamage + | ContinuousModification::ChangeController + | ContinuousModification::RemoveManaCost => false, + } } fn active_effects_force_incremental_escalation( @@ -3555,14 +3777,11 @@ fn active_effects_force_incremental_escalation( active_effects: &[ActiveContinuousEffect], ) -> bool { active_effects.iter().any(|e| { - let magnitude = continuous_modification_dynamic_quantity(&e.modification); - let magnitude_sensitive = - magnitude.is_some_and(crate::game::quantity::quantity_expr_uses_object_count); - let affected_sensitive = - crate::game::filter::affected_filter_uses_object_population(&e.affected_filter); + let (magnitude_sensitive, affected_sensitive) = effect_population_reads(e); if !magnitude_sensitive && !affected_sensitive { return false; } + let magnitude = continuous_modification_dynamic_quantity(&e.modification); let ctx = FilterContext::from_source_with_controller(e.source_id, e.controller); entered_ids.iter().any(|id| { let Some(entered) = state.objects.get(id) else { @@ -3821,10 +4040,16 @@ fn effect_is_restricted_to_incremental_recipients( /// visibility). It does NOT clear attribution globally or touch the rest of the /// battlefield: pre-existing objects keep their already-derived characteristics. /// -/// Caller (`flush_layers`) only reaches this path after -/// `incremental_flush_must_escalate` returned false, which guarantees no active -/// effect's magnitude or affected set reads board population — so re-deriving -/// just the entered objects yields a board identical to a full pass (CR 613.1). +/// Caller (`flush_layers`) only reaches this path when `prepare_incremental_flush` +/// returned `Some`, i.e. none of its escalation disjuncts fired. The disjuncts are +/// listed once, at that function; they are deliberately NOT restated here, because +/// a second enumeration is exactly what drifted last time. +/// +/// Within the limits those disjuncts detect, re-deriving just the entered objects +/// yields a board identical to a full pass (CR 613.1). Note what that does and +/// does not promise: the guarantee is "no DETECTED perturbation", not "no +/// population read is live". The residual blind spots are enumerated on +/// `population_probe_blinded_by_entrant_characteristic_change`. fn apply_layers_incremental(state: &mut GameState, prepared: PreparedIncrementalFlush) { let PreparedIncrementalFlush { recipient_ids, diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 94609c5a05..1f6b4a749d 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -10421,6 +10421,281 @@ mod tests { assert_pt_identical(&normal, &forced, "count-anthem matching escalation"); } + /// Build a board pairing a GREEN-keyed count magnitude with a color + /// wash: one enchantment carries "creatures get +X/+X, X = number of + /// green creatures" AND "creatures are green in addition to their other + /// colors" (layer 5). Two pre-existing 2/2 Bears — green via the wash, + /// so the count starts at 2 and both flush to 4/4. + fn green_count_anthem_with_color_wash_board() -> GameState { + use crate::types::ability::{ContinuousModification, StaticDefinition}; + use crate::types::statics::StaticMode; + let mut state = setup(); + for i in 0..2 { + let id = create_object( + &mut state, + CardId(280 + i), + PlayerId(0), + format!("WashBear{i}"), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&id).unwrap(); + o.base_power = Some(2); + o.base_toughness = Some(2); + o.power = Some(2); + o.toughness = Some(2); + o.base_card_types.core_types = vec![CoreType::Creature]; + o.card_types.core_types = vec![CoreType::Creature]; + o.base_color = vec![]; + o.color = vec![]; + } + let anthem = create_object( + &mut state, + CardId(290), + PlayerId(0), + "Color Wash Count Anthem".to_string(), + Zone::Battlefield, + ); + // "creatures are green in addition to their other colors" (layer 5). + let mut wash = StaticDefinition::new(StaticMode::Continuous); + wash.affected = Some(TargetFilter::Typed(TypedFilter::creature())); + wash.modifications = vec![ContinuousModification::AddColor { + color: ManaColor::Green, + }]; + // "creatures get +X/+X, X = number of green creatures" (layer 7c). + let green_creatures = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::HasColor { + color: ManaColor::Green, + }], + ..Default::default() + }); + let mut count = StaticDefinition::new(StaticMode::Continuous); + count.affected = Some(TargetFilter::Typed(TypedFilter::creature())); + count.modifications = vec![ + ContinuousModification::AddDynamicPower { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: green_creatures.clone(), + }, + }, + }, + ContinuousModification::AddDynamicToughness { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: green_creatures, + }, + }, + }, + ]; + { + let o = state.objects.get_mut(&anthem).unwrap(); + o.base_static_definitions = Arc::new(vec![wash.clone(), count.clone()]); + o.static_definitions = vec![wash, count].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + /// KNOWN GAP — deliberately pinned, expected to FLIP when the gap + /// closes. CR 613.1e + CR 613.1g: a population keyed on COLOR whose + /// entrant has that color rewritten by another layer is still probed + /// pre-layer. `population_probe_blinded_by_entrant_characteristic_change` + /// escalates only when an active effect rewrites an entrant's CARD + /// TYPES; a COLOR rewrite (`AddColor`, layer 5) is classified + /// non-perturbing, so the colorless entrant — green by the time the + /// count applies — is probed as colorless and the gate stays on the + /// incremental arm. No printed card in the current corpus is known to + /// pair a color-keyed count with a color wash (claim not exhaustively + /// verified — this tripwire, not corpus absence, is what holds the + /// line); this synthetic board pins the + /// resulting divergence so closing the gap (the full FilterProp-reads × + /// ContinuousModification-writes characteristic-kind matrix) turns this + /// red and the assertions below get rewritten to escalation + identity. + /// See the KNOWN REMAINING GAP note on + /// `population_probe_blinded_by_entrant_characteristic_change`. + #[test] + fn known_gap_color_keyed_population_probes_entrant_pre_layer() { + let (normal, escalated, forced) = + flush_entry_and_forced(green_count_anthem_with_color_wash_board, |s| { + add_colorless_creature_entry(s, 291) + }); + // Pin 1: the gate is blind to color rewrites — the incremental arm + // is (incorrectly, but knowingly) taken. + assert!( + !escalated, + "KNOWN GAP CLOSED? color-rewrite escalation now fires — rewrite \ + this test to assert escalation and board identity" + ); + // Pin 2: the divergence itself. Pre-existing Bears keep the stale + // count of 2 (4/4) where a full pass counts the now-green entrant + // and derives 5/5. + let bear_pts = |state: &GameState| { + let mut pts: Vec<(Option, Option)> = state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .filter(|o| o.name.starts_with("WashBear")) + .map(|o| (o.power, o.toughness)) + .collect(); + pts.sort(); + pts + }; + assert_eq!( + bear_pts(&normal), + vec![(Some(4), Some(4)); 2], + "incremental arm leaves pre-existing Bears at the stale count" + ); + assert_eq!( + bear_pts(&forced), + vec![(Some(5), Some(5)); 2], + "full pass counts the washed entrant — the correct CR 613 board" + ); + } + + /// Build a board pairing a PURE layer-4 type-writer with a + /// condition-gated fixed anthem — the CONDITION-channel analogue of the + /// Ashaya CDA regression. Source A: "creatures you control are lands in + /// addition to their other types" (no dynamic magnitude, no + /// population-sensitive affected set, no entry replacement). Source B: + /// "creatures you control get +2/+2 as long as you control four or more + /// lands" (`QuantityComparison` over `ObjectCount(Land)` — the ONLY + /// population read on the board, and it lives in a `condition`, not in + /// any effect's magnitude or affected set). One pre-existing GateBear, + /// two plain lands: pre-entry land count = 2 lands + 1 creature-as-land + /// = 3, gate OFF. + fn type_writer_with_condition_gated_anthem_board() -> GameState { + use crate::types::ability::{ + Comparator, ContinuousModification, StaticCondition, StaticDefinition, + TypeFilter as TF, TypedFilter as TFil, + }; + use crate::types::statics::StaticMode; + let mut state = setup(); + let bear = create_object( + &mut state, + CardId(300), + PlayerId(0), + "GateBear".to_string(), + Zone::Battlefield, + ); + { + let o = state.objects.get_mut(&bear).unwrap(); + o.base_power = Some(2); + o.base_toughness = Some(2); + o.power = Some(2); + o.toughness = Some(2); + o.base_card_types.core_types = vec![CoreType::Creature]; + o.card_types.core_types = vec![CoreType::Creature]; + } + for i in 0..2 { + let land = create_object( + &mut state, + CardId(301 + i), + PlayerId(0), + format!("QuietLand{i}"), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&land).unwrap(); + o.base_card_types.core_types = vec![CoreType::Land]; + o.card_types.core_types = vec![CoreType::Land]; + } + let type_writer = create_object( + &mut state, + CardId(310), + PlayerId(0), + "Creatures Are Lands".to_string(), + Zone::Battlefield, + ); + let mut writer_sd = StaticDefinition::new(StaticMode::Continuous); + writer_sd.affected = Some(TargetFilter::Typed(TFil::new(TF::Creature))); + writer_sd.modifications = vec![ContinuousModification::AddType { + core_type: CoreType::Land, + }]; + { + let o = state.objects.get_mut(&type_writer).unwrap(); + o.base_static_definitions = Arc::new(vec![writer_sd.clone()]); + o.static_definitions = vec![writer_sd].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + let anthem = create_object( + &mut state, + CardId(311), + PlayerId(0), + "Land Threshold Anthem".to_string(), + Zone::Battlefield, + ); + let mut anthem_sd = StaticDefinition::new(StaticMode::Continuous); + anthem_sd.affected = Some(TargetFilter::Typed(TFil::new(TF::Creature))); + anthem_sd.modifications = vec![ + ContinuousModification::AddPower { value: 2 }, + ContinuousModification::AddToughness { value: 2 }, + ]; + anthem_sd.condition = Some(StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(TFil::new(TF::Land)), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }); + { + let o = state.objects.get_mut(&anthem).unwrap(); + o.base_static_definitions = Arc::new(vec![anthem_sd.clone()]); + o.static_definitions = vec![anthem_sd].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + /// (3c) CONDITION-channel blindness — perturbing only POST-layer: + /// CR 611.3a + CR 613.1 + CR 613.1d. A plain creature entering is NOT a + /// land at gate time, so neither Axis 2a (no population-reading effect + /// magnitude or affected set is live) nor Axis 2b's pre-layer membership + /// probe fires; but the type-writer makes the entrant a land in layer 4 + /// and the count crosses the anthem's threshold, changing PRE-EXISTING + /// recipients. The blindness disjunct's condition channel + /// (`any_active_static_condition_reads_object_population`) MUST escalate + /// this entry, and the board must match a forced-Full pass (GateBear + /// 4/4, not stale 2/2). + #[test] + fn condition_gated_anthem_entry_escalates_when_entrant_types_rewritten() { + let (normal, escalated, forced) = + flush_entry_and_forced(type_writer_with_condition_gated_anthem_board, |s| { + add_colorless_creature_entry(s, 320) + }); + assert!( + escalated, + "entrant becomes a land in layer 4 and flips the threshold gate — must escalate" + ); + assert_pt_identical( + &normal, + &forced, + "condition-channel type-rewrite escalation", + ); + // Vacuity guard: `escalated` depends only on the classifier, so pin + // that the threshold gate actually flips — GateBear ends the pass + // at 4/4 (base 2/2 + the now-live +2/+2), not a stale 2/2. + let gatebear_pt = |state: &GameState| { + state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .find(|o| o.name == "GateBear") + .map(|o| (o.power, o.toughness)) + .unwrap() + }; + assert_eq!( + gatebear_pt(&forced), + (Some(4), Some(4)), + "the entrant-turned-land crosses the GE-4 land threshold" + ); + } + /// (4) MEDIUM-2 — whole-board TALLY affected filter /// (`MostPrevalentCreatureTypeIn`). The anthem affects "creatures of the /// most prevalent creature type on the battlefield". A creature token diff --git a/crates/engine/tests/integration/ashaya_nontoken_lands.rs b/crates/engine/tests/integration/ashaya_nontoken_lands.rs index a747e159e9..086b211e33 100644 --- a/crates/engine/tests/integration/ashaya_nontoken_lands.rs +++ b/crates/engine/tests/integration/ashaya_nontoken_lands.rs @@ -162,3 +162,41 @@ fn ashaya_creature_etb_triggers_landfall() { outcome.assert_hand_drawn(P0, 1); } + +/// CR 613.1 + CR 613.1d + CR 613.4a: Ashaya's own CDA counts a population its +/// own layer-4 effect feeds. Layers apply in order (CR 613.1), so "power and +/// toughness equal to the number of lands you control" (layer 7a, CR 613.4a) is +/// computed over a board on which "nontoken creatures you control are Forest +/// lands" (layer 4, CR 613.1d) has already run — an entering nontoken creature +/// raises Ashaya's P/T even though nothing that entered was printed as a land. +/// +/// Regression: found by differential verification against a full re-evaluation +/// during development. The entry- +/// incremental flush escalation gate probed the ENTERING object for membership +/// in "lands you control" using its pre-layer characteristics — a creature, not +/// a land — concluded the count was unperturbed, took the incremental arm, and +/// left Ashaya's P/T stale where a full re-evaluation raises it. +#[test] +fn ashaya_power_counts_a_creature_that_its_own_static_turns_into_a_land() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let ashaya_id = scenario + .add_creature_from_oracle(P0, "Ashaya, Soul of the Wild", 0, 0, ASHAYA) + .id(); + let entering_creature = scenario + .add_creature_to_hand(P0, "Grizzly Bears", 2, 2) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + + runner.cast(entering_creature).resolve(); + + let after = &runner.state().objects[&ashaya_id]; + assert_eq!( + (after.power, after.toughness), + (Some(2), Some(2)), + "both Ashaya and the entering Bears are Forest lands, so the CDA counts 2" + ); +} diff --git a/crates/engine/tests/integration/life_and_limb_sylvan_advocate.rs b/crates/engine/tests/integration/life_and_limb_sylvan_advocate.rs new file mode 100644 index 0000000000..35b81ef86e --- /dev/null +++ b/crates/engine/tests/integration/life_and_limb_sylvan_advocate.rs @@ -0,0 +1,69 @@ +//! Condition-channel twin of the Ashaya entry-flush regression +//! (`ashaya_nontoken_lands.rs`): a static's enabling CONDITION that counts a +//! population must be answered against the post-layer board when the entry +//! escalation gate decides whether an entering object perturbs it. +//! +//! Life and Limb makes all Saprolings Forest lands (layer 4, CR 613.1d); +//! Sylvan Advocate's +2/+2 is gated on "you control six or more lands" +//! (CR 611.3a enabling condition). An entering Saproling is a creature — not a +//! land — at gate time, so a pre-layer probe of "lands you control" would report +//! the count unperturbed even though the post-layer board crosses six. +//! +//! This test pins the CR-correct end-to-end outcome whichever flush arm the +//! board takes. It is deliberately NOT the discriminating test for the +//! escalation gate's condition channel — that is the synthetic fixture named in +//! the per-test comment below, which is constructed to take the incremental +//! path. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; + +const LIFE_AND_LIMB: &str = "All Forests and all Saprolings are 1/1 green \ +Saproling creatures and Forest lands in addition to their other types."; +const SYLVAN_ADVOCATE: &str = "Vigilance\nAs long as you control six or more \ +lands, this creature and land creatures you control get +2/+2."; + +/// CR 611.3a + CR 613.1 + CR 613.1d: with five lands, Life and Limb, and +/// Sylvan Advocate on the battlefield, a Saproling entering becomes the sixth +/// land during the same layer pass, so the Advocate's condition turns on and +/// the Advocate — a PRE-EXISTING recipient — must end the pass at 4/5, not a +/// stale 2/3. +/// +/// This asserts the CR-correct end-to-end outcome and nothing about WHICH flush +/// arm produced it: the assertion holds under either arm, and deliberately does +/// not encode a claim about the arm, because such a claim would be prose that no +/// assertion here can keep honest. The discriminating test for the escalation +/// gate's condition channel is the synthetic +/// `condition_gated_anthem_entry_escalates_when_entrant_types_rewritten` +/// fixture (stack.rs entry-flush escalation tests), which asserts escalation +/// directly. +#[test] +fn sylvan_advocate_condition_counts_a_saproling_that_life_and_limb_turns_into_a_land() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let advocate_id = scenario + .add_creature_from_oracle(P0, "Sylvan Advocate", 2, 3, SYLVAN_ADVOCATE) + .id(); + scenario.add_enchantment_from_oracle(P0, "Life and Limb", LIFE_AND_LIMB); + for i in 0..5 { + scenario.add_land_from_oracle(P0, &format!("Quiet Wastes {i}"), ""); + } + let entering = scenario + .add_creature_to_hand(P0, "Saproling Straggler", 1, 1) + .with_subtypes(vec!["Saproling"]) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + runner.cast(entering).resolve(); + + let advocate = &runner.state().objects[&advocate_id]; + assert_eq!( + (advocate.power, advocate.toughness), + (Some(4), Some(5)), + "the entering Saproling is a Forest land post-layer, lands reach six, \ + and the Advocate's own +2/+2 applies to itself" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 18d62e8b0f..cc54a83739 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -714,6 +714,7 @@ mod lathiel_end_step_counters_repro; mod leeching_sliver; mod leyline_taps_for_mana_repro; mod lictor_opponent_entered_this_turn; +mod life_and_limb_sylvan_advocate; mod lightning_dart_disjunctive_color_instead; mod liliana_dreadhorde_multi_dies; mod liliana_waker_cross_scope_decline; From 02a96f6cb4ead50e07a9f40f11f6a7d45661c7be Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:19:42 -0700 Subject: [PATCH 02/13] fix(engine): treat a control change as a population-key write in the entry-flush gate CR 613.1b + CR 109.3 + CR 613.1. Review follow-up on the CONTROLLER channel this commit's parent named as a known gap rather than closing. A population keyed on controller -- "creatures you control", the overwhelmingly common shape -- was probed with the entrant's PRE-layer controller, while a layer-2 `ChangeController` (CR 613.1b) moves the entrant between players' populations before any later layer counts it. The gate took the incremental arm and left pre-existing recipients stale. `modification_writes_card_types(&ContinuousModification) -> bool` becomes `modification_population_key_write(&ContinuousModification) -> Option`, still an exhaustive wildcard-free match over all 57 variants. Parameterizing rather than adding a second bool classifier is the CLAUDE.md sibling-cluster rule: two predicates differing only in which characteristic they name is the shape that compounds. The axis stays inside one CR section -- card types are written in layer 4 (CR 613.1d), controller in layer 2 (CR 613.1b), both within CR 613 -- and closing the remaining kinds (COLOR, KEYWORD, NAME, P/T) means adding variants here. Controller is deliberately NOT folded into the "other characteristics" gap paragraph: CR 109.3 states an object's controller is not one of its characteristics, so the two are separate claims. What unifies them for this gate is only that `TargetFilter` reads both when counting a population. Fast-path cost is nil in practice -- battlefield `ChangeController` is rare, so the extra classification almost never fires, and the two deliberately-pinned fast paths (`count_anthem_nonmatching_entry_does_not_escalate_and_matches_full`, `devotion_gate_colorless_entry_does_not_escalate_and_matches_full`) still take the incremental arm. The regression test is discriminating. With `ChangeController` classified back as no key write, `controller_change_entry_escalates_when_population_is_controller_keyed` fails on the escalation assertion; with the assertion bypassed it fails on the board comparison at a stale power of 4 where a full pass derives 5. GRANT CHAINS and the COLOR/KEYWORD/NAME/P/T matrix remain declared open, with `known_gap_color_keyed_population_probes_entrant_pre_layer` still pinning the latter's current behavior. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/layers.rs | 87 ++++++++++++------- crates/engine/src/game/stack.rs | 138 ++++++++++++++++++++++++++++++- 2 files changed, 192 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 7b8a66354f..07880199ac 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3621,10 +3621,12 @@ fn any_active_static_condition_reads_object_population(state: &GameState) -> boo /// population READ is live — an effect's dynamic magnitude or affected set /// (`effect_reads_object_population`) or a Continuous static's enabling /// condition (`any_active_static_condition_reads_object_population`) — AND some -/// active effect reaching an entrant rewrites that entrant's CARD TYPES. The -/// read side is not narrowed because a counted population is almost always -/// keyed on card type (`ObjectCount { filter: lands you control }`), so -/// narrowing it would buy nothing while adding a second 98-arm classifier. +/// active effect reaching an entrant rewrites one of that entrant's POPULATION +/// KEYS: its card types (layer 4, CR 613.1d) or its controller (layer 2, +/// CR 613.1b). The read side is not narrowed because a counted population is +/// almost always keyed on card type or controller (`ObjectCount { filter: lands +/// you control }` is keyed on both at once), so narrowing it would buy nothing +/// while adding a second 98-arm classifier. /// Note the write-side REACH probe (`matches_target_filter` below) shares the /// pre-layer blindness this disjunct exists to fix: a type-writer whose /// affected filter keys on a characteristic another layer rewrites is still @@ -3651,20 +3653,21 @@ fn any_active_static_condition_reads_object_population(state: &GameState) -> boo /// entry-flush escalation tests), which is expected to flip when the matrix /// lands. /// -/// Two further channels of the same blindness, named explicitly because neither -/// is covered by the sentence above: +/// CONTROLLER (CR 613.1b) was a channel of the same blindness and is closed +/// here, not merely declared: a population keyed on controller — "creatures you +/// control" is the overwhelmingly common shape — was probed pre-layer while a +/// layer-2 `ChangeController` moved the entrant between players' populations. +/// It is deliberately NOT a subset of the paragraph above, because CR 109.3 +/// states an object's controller is not one of its characteristics, so "other +/// characteristics" excludes it by construction. It is classified instead as a +/// population KEY alongside card types, by `modification_population_key_write`. +/// The cost to the fast path is nil in practice: battlefield `ChangeController` +/// is rare, so the extra disjunct almost never fires. /// -/// 1. CONTROLLER (CR 613.1b). A population keyed on controller — "creatures you -/// control" is the overwhelmingly common shape — is read here pre-layer, -/// while a layer-2 control-change effect (`ChangeController`, classified -/// `false` by `modification_writes_card_types` because it writes no card -/// type) can move the entrant between players' populations. This is NOT a -/// subset of the paragraph above: CR 109.3 states an object's controller is -/// not one of its characteristics, so "other characteristics" excludes it by -/// construction. Closing it is cheaper than the full matrix — battlefield -/// `ChangeController` is rare, so a controller-channel disjunct would cost -/// the fast path close to nothing. -/// 2. GRANT CHAINS. `GrantAbility` / `GrantStaticAbility` / `AddStaticMode` / +/// One further channel of the same blindness stays open, named explicitly +/// because neither the sentence above nor that classifier covers it: +/// +/// 1. GRANT CHAINS. `GrantAbility` / `GrantStaticAbility` / `AddStaticMode` / /// `RemoveAllAbilities` all classify `false`, which is correct for the /// predicate "does this write card types" but leaves a second-order path the /// classifier cannot see: an effect that GRANTS a type-writing static (or @@ -3677,12 +3680,12 @@ fn population_probe_blinded_by_entrant_characteristic_change( entered_ids: &BTreeSet, active_effects: &[ActiveContinuousEffect], ) -> bool { - // Write side first: `modification_writes_card_types` is a pure enum match, - // so the common board with no type-writer reaching an entrant pays neither - // the active-effect read scan nor the static-source condition walk (a - // traversal Axis 2b repeats immediately after this gate). + // Write side first: `modification_population_key_write` is a pure enum + // match, so the common board with no population-key writer reaching an + // entrant pays neither the active-effect read scan nor the static-source + // condition walk (a traversal Axis 2b repeats immediately after this gate). let writer_reaches_entrant = active_effects.iter().any(|e| { - if !modification_writes_card_types(&e.modification) { + if modification_population_key_write(&e.modification).is_none() { return false; } let ctx = FilterContext::from_source_with_controller(e.source_id, e.controller); @@ -3697,13 +3700,28 @@ fn population_probe_blinded_by_entrant_characteristic_change( || any_active_static_condition_reads_object_population(state) } -/// CR 613.1d (layer 4): does this modification rewrite an object's card types, -/// subtypes or supertypes — the characteristics a counted population is keyed on? +/// CR 613: the population-keying value a modification rewrites, if any. /// +/// Card types and controller are not the same kind of thing — CR 109.3 states +/// an object's controller is not one of its characteristics — and they are +/// written in different layers. What unifies them here is the single property +/// this gate cares about: both are read by `TargetFilter` when a population is +/// counted, so a pre-layer probe of either can be stale by the time the +/// counting effect applies. Both are written inside CR 613, so parameterizing +/// on this axis stays within one rule section, and closing the remaining kinds +/// (COLOR, KEYWORD, NAME, P/T) means adding variants here rather than growing a +/// sibling classifier. +enum PopulationKeyWrite { + /// CR 613.1d (layer 4): card types, subtypes or supertypes. + CardTypes, + /// CR 613.1b (layer 2): the object's controller. + Controller, +} + /// EXHAUSTIVE and wildcard-free over `ContinuousModification`, so a future -/// type-rewriting variant must be classified here at compile time rather than +/// key-rewriting variant must be classified here at compile time rather than /// silently reopening the Ashaya divergence. -fn modification_writes_card_types(m: &ContinuousModification) -> bool { +fn modification_population_key_write(m: &ContinuousModification) -> Option { match m { ContinuousModification::AddType { .. } | ContinuousModification::RemoveType { .. } @@ -3718,14 +3736,20 @@ fn modification_writes_card_types(m: &ContinuousModification) -> bool { | ContinuousModification::SetBasicLandType { .. } | ContinuousModification::SetChosenBasicLandType | ContinuousModification::AddSupertype { .. } - | ContinuousModification::RemoveSupertype { .. } => true, + | ContinuousModification::RemoveSupertype { .. } => Some(PopulationKeyWrite::CardTypes), // CR 613.2a + CR 613.2c + CR 707.2: a copy effect (layer 1a) replaces // the copiable values, // card types among them, so it rewrites types just as surely as // `SetCardTypes` does. - ContinuousModification::CopyValues { .. } | ContinuousModification::CopyChosen => true, - // Everything else writes a characteristic that is not a card type. - // Enumerated explicitly (no wildcard) so a future type-rewriting variant + ContinuousModification::CopyValues { .. } | ContinuousModification::CopyChosen => { + Some(PopulationKeyWrite::CardTypes) + } + // CR 613.1b (layer 2): a control-change effect writes no characteristic + // at all (CR 109.3), but it moves the object between controller-keyed + // populations — "creatures you control" — that a later layer counts. + ContinuousModification::ChangeController => Some(PopulationKeyWrite::Controller), + // Everything else writes a characteristic that is not a population key. + // Enumerated explicitly (no wildcard) so a future key-rewriting variant // forces a decision here. ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } @@ -3766,8 +3790,7 @@ fn modification_writes_card_types(m: &ContinuousModification) -> bool { | ContinuousModification::AssignDamageFromToughness | ContinuousModification::AssignDamageAsThoughUnblocked | ContinuousModification::AssignNoCombatDamage - | ContinuousModification::ChangeController - | ContinuousModification::RemoveManaCost => false, + | ContinuousModification::RemoveManaCost => None, } } diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 1f6b4a749d..4ee4ab5b3c 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -10135,10 +10135,20 @@ mod tests { /// mark layers entered. Flips no devotion / land-presence gate and matches /// no artifact/land filter. fn add_colorless_creature_entry(state: &mut GameState, card_id: u64) -> ObjectId { + add_colorless_creature_entry_under(state, card_id, PlayerId(0)) + } + + /// The same entrant under an explicit controller, for controller-keyed + /// population fixtures (CR 613.1b). + fn add_colorless_creature_entry_under( + state: &mut GameState, + card_id: u64, + controller: PlayerId, + ) -> ObjectId { let id = create_object( state, CardId(card_id), - PlayerId(0), + controller, "Insect".to_string(), Zone::Battlefield, ); @@ -10696,6 +10706,132 @@ mod tests { ); } + /// Board for the CONTROLLER channel (CR 613.1b). P0 controls two 2/2s + /// and an anthem whose dynamic magnitude counts "creatures you control", + /// plus a theft enchantment whose layer-2 `ChangeController` claims + /// every creature for the enchantment's controller. The theft's affected + /// filter is deliberately controller-FREE: a controller-keyed affected + /// filter would make layer 2 self-referential, and the point under test + /// is the counted population, not the affected set. + fn controller_keyed_count_anthem_with_control_theft_board() -> GameState { + use crate::types::ability::{ + ContinuousModification, ControllerRef, StaticDefinition, TypeFilter as TF, + TypedFilter as TFil, + }; + use crate::types::statics::StaticMode; + let mut state = setup(); + for i in 0..2 { + let id = create_object( + &mut state, + CardId(380 + i), + PlayerId(0), + format!("TheftBear{i}"), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&id).unwrap(); + o.base_power = Some(2); + o.base_toughness = Some(2); + o.power = Some(2); + o.toughness = Some(2); + o.base_card_types.core_types = vec![CoreType::Creature]; + o.card_types.core_types = vec![CoreType::Creature]; + } + let yours = TargetFilter::Typed(TFil { + type_filters: vec![TF::Creature], + controller: Some(ControllerRef::You), + ..Default::default() + }); + let anthem = create_object( + &mut state, + CardId(385), + PlayerId(0), + "Ally Count Anthem".to_string(), + Zone::Battlefield, + ); + let mut anthem_sd = StaticDefinition::new(StaticMode::Continuous); + anthem_sd.affected = Some(TargetFilter::Typed(TFil::new(TF::Creature))); + anthem_sd.modifications = vec![ + ContinuousModification::AddDynamicPower { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: yours.clone(), + }, + }, + }, + ContinuousModification::AddDynamicToughness { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { filter: yours }, + }, + }, + ]; + { + let o = state.objects.get_mut(&anthem).unwrap(); + o.base_static_definitions = Arc::new(vec![anthem_sd.clone()]); + o.static_definitions = vec![anthem_sd].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + let thief = create_object( + &mut state, + CardId(386), + PlayerId(0), + "Mass Mind Control".to_string(), + Zone::Battlefield, + ); + let mut theft_sd = StaticDefinition::new(StaticMode::Continuous); + theft_sd.affected = Some(TargetFilter::Typed(TFil::new(TF::Creature))); + theft_sd.modifications = vec![ContinuousModification::ChangeController]; + { + let o = state.objects.get_mut(&thief).unwrap(); + o.base_static_definitions = Arc::new(vec![theft_sd.clone()]); + o.static_definitions = vec![theft_sd].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + /// (3d) CONTROLLER-channel blindness — CR 613.1b + CR 613.1 + CR 109.3. + /// The entrant arrives under the OPPONENT, so a pre-layer probe of + /// "creatures you control" says the count is unperturbed; layer 2 then + /// hands the entrant to the anthem's controller and the count goes + /// 2 -> 3, which moves PRE-EXISTING recipients. CR 109.3 puts controller + /// outside an object's characteristics, so this is not reachable by the + /// card-type disjunct — `modification_population_key_write` has to + /// classify `ChangeController` as a population-key write in its own + /// right for this entry to escalate. + #[test] + fn controller_change_entry_escalates_when_population_is_controller_keyed() { + let (normal, escalated, forced) = flush_entry_and_forced( + controller_keyed_count_anthem_with_control_theft_board, + |s| add_colorless_creature_entry_under(s, 390, PlayerId(1)), + ); + assert!( + escalated, + "layer 2 moves the entrant into the counted population — must escalate" + ); + assert_pt_identical(&normal, &forced, "controller-channel escalation"); + // Vacuity guard: `escalated` depends only on the classifier, so pin + // that the stolen entrant really does move the count — TheftBears + // end the pass at 5/5 (base 2/2 + three creatures now controlled), + // not the pre-layer 4/4. + let theftbear_pt = |state: &GameState| { + state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .find(|o| o.name == "TheftBear0") + .map(|o| (o.power, o.toughness)) + .unwrap() + }; + assert_eq!( + theftbear_pt(&forced), + (Some(5), Some(5)), + "the stolen entrant is counted among \"creatures you control\"" + ); + } + /// (4) MEDIUM-2 — whole-board TALLY affected filter /// (`MostPrevalentCreatureTypeIn`). The anthem affects "creatures of the /// most prevalent creature type on the battlefield". A creature token From 4f7ce19b564ab796096f001e4dd0bde2e968e353 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:16:59 -0700 Subject: [PATCH 03/13] fix(engine): treat a color change as a population-key write in the entry-flush gate CR 613.1e. The entry-incremental flush escalates to a full re-evaluation when an entering permanent could move a counted population. It recognised two ways that happens -- a layer-4 card-type rewrite (CR 613.1d) and a layer-2 control change (CR 613.1b) -- and deliberately did not recognise a third. A layer-5 color-changing effect (CR 613.1e) rewrites the very characteristic a color-keyed population reads, and layer 5 runs before the layer-7 count, so the pre-layer probe saw the entrant's printed color rather than its derived one. That gap shipped as a knowingly-passing tripwire test asserting the stale board. It should not have. A test that passes while codifying stale derived state is an active layer-ordering error, not a baseline: the fixture's pre-existing Bears sat at 4/4 where a full pass correctly derives 5/5, and nothing in CI would have gone red if that divergence had widened. `modification_population_key_write` gains a `Color` variant and classifies `SetColor`, `AddColor` and `AddChosenColor` into it. The match stays exhaustive and wildcard-free over all `ContinuousModification` variants, so a future key-rewriting variant still cannot be added without deciding this question at compile time. Parameterizing here rather than adding a sibling classifier keeps the axis inside CR 613: all three keys are written by layers of the same rule section. `known_gap_color_keyed_population_probes_entrant_pre_layer` is replaced by `color_change_entry_escalates_when_population_is_color_keyed`, which requires escalation and full normal-vs-forced board identity instead of pinning the divergence. It discriminates in both directions: revert the classifier's `Color` arm and the escalation assertion fails at stack.rs:10292 with "a layer-5 color wash reaching the entrant moves a color-keyed count"; keep the arm but break the escalation plumbing and the identity assertion fails on the Bears' derived power/toughness. The fast path is unaffected. The gate still requires a classified writer to REACH an entrant AND a live population read to exist, so the two boards pinned as fast paths -- whose only effects write P/T -- keep taking the incremental arm: `count_anthem_nonmatching_entry_does_not_escalate_and_matches_full` and `devotion_gate_colorless_entry_does_not_escalate_and_matches_full` both still pass, along with the other 31 escalation fixtures. KEYWORD, NAME and P/T remain open as the same shape and are still documented as such; closing them needs the full FilterProp-reads x ContinuousModification-writes matrix rather than another per-key arm. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/layers.rs | 80 ++++++++++++++++++-------------- crates/engine/src/game/stack.rs | 57 +++++++++++------------ 2 files changed, 72 insertions(+), 65 deletions(-) diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 07880199ac..410bb2a5c9 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3622,11 +3622,12 @@ fn any_active_static_condition_reads_object_population(state: &GameState) -> boo /// (`effect_reads_object_population`) or a Continuous static's enabling /// condition (`any_active_static_condition_reads_object_population`) — AND some /// active effect reaching an entrant rewrites one of that entrant's POPULATION -/// KEYS: its card types (layer 4, CR 613.1d) or its controller (layer 2, -/// CR 613.1b). The read side is not narrowed because a counted population is -/// almost always keyed on card type or controller (`ObjectCount { filter: lands -/// you control }` is keyed on both at once), so narrowing it would buy nothing -/// while adding a second 98-arm classifier. +/// KEYS: its card types (layer 4, CR 613.1d), its controller (layer 2, +/// CR 613.1b) or its colors (layer 5, CR 613.1e). The read side is not narrowed +/// because a counted population is almost always keyed on card type, controller +/// or color (`ObjectCount { filter: lands you control }` is keyed on two of +/// them at once), so narrowing it would buy nothing while adding a second +/// 98-arm classifier. /// Note the write-side REACH probe (`matches_target_filter` below) shares the /// pre-layer blindness this disjunct exists to fix: a type-writer whose /// affected filter keys on a characteristic another layer rewrites is still @@ -3640,29 +3641,33 @@ fn any_active_static_condition_reads_object_population(state: &GameState) -> boo /// type-keyed or devotion-keyed count. /// /// KNOWN REMAINING GAP, same shape, other characteristics: a population keyed on -/// COLOR, KEYWORD, NAME or P/T whose entrant has that characteristic rewritten by +/// KEYWORD, NAME or P/T whose entrant has that characteristic rewritten by /// another layer is still probed pre-layer — on the read side (the counted /// filter) and on the write side (a type-writer's own affected filter keyed on /// a rewritten characteristic) alike. Closing it needs the full /// characteristic-kind matrix (which kind each `FilterProp` reads × which kind -/// each `ContinuousModification` writes) rather than the card-type projection of +/// each `ContinuousModification` writes) rather than the per-key projection of /// it below. No printed pairing in the current corpus is known to exercise it -/// (claim not exhaustively verified — the tripwire below, not corpus absence, -/// is what holds the line); the gap's current behavior is pinned by -/// `known_gap_color_keyed_population_probes_entrant_pre_layer` (stack.rs -/// entry-flush escalation tests), which is expected to flip when the matrix -/// lands. +/// (claim not exhaustively verified — corpus absence is not what holds the +/// line; the classifier's wildcard-free match is, because a future +/// key-rewriting variant cannot be added without deciding this question). /// -/// CONTROLLER (CR 613.1b) was a channel of the same blindness and is closed -/// here, not merely declared: a population keyed on controller — "creatures you -/// control" is the overwhelmingly common shape — was probed pre-layer while a -/// layer-2 `ChangeController` moved the entrant between players' populations. -/// It is deliberately NOT a subset of the paragraph above, because CR 109.3 -/// states an object's controller is not one of its characteristics, so "other -/// characteristics" excludes it by construction. It is classified instead as a -/// population KEY alongside card types, by `modification_population_key_write`. -/// The cost to the fast path is nil in practice: battlefield `ChangeController` -/// is rare, so the extra disjunct almost never fires. +/// CONTROLLER (CR 613.1b) and COLOR (CR 613.1e) were channels of the same +/// blindness and are closed here, not merely declared. A population keyed on +/// controller — "creatures you control" is the overwhelmingly common shape — +/// was probed pre-layer while a layer-2 `ChangeController` moved the entrant +/// between players' populations. A population keyed on color — "green +/// creatures" — was probed pre-layer while a layer-5 `AddColor` washed the +/// entrant into it, so a layer-7 count that runs after both still counted the +/// entrant's printed color. Controller is deliberately NOT a subset of the +/// paragraph above, because CR 109.3 states an object's controller is not one +/// of its characteristics, so "other characteristics" excludes it by +/// construction; color is a characteristic, but it is called out here because +/// it is now classified rather than deferred. All three are classified as +/// population KEYS by `modification_population_key_write`. The cost to the fast +/// path is nil in practice: the gate still requires the writer to REACH an +/// entrant AND a live population read to exist, so the boards pinned as fast +/// paths below — whose only effects write P/T — are untouched. /// /// One further channel of the same blindness stays open, named explicitly /// because neither the sentence above nor that classifier covers it: @@ -3702,20 +3707,22 @@ fn population_probe_blinded_by_entrant_characteristic_change( /// CR 613: the population-keying value a modification rewrites, if any. /// -/// Card types and controller are not the same kind of thing — CR 109.3 states -/// an object's controller is not one of its characteristics — and they are -/// written in different layers. What unifies them here is the single property -/// this gate cares about: both are read by `TargetFilter` when a population is -/// counted, so a pre-layer probe of either can be stale by the time the -/// counting effect applies. Both are written inside CR 613, so parameterizing -/// on this axis stays within one rule section, and closing the remaining kinds -/// (COLOR, KEYWORD, NAME, P/T) means adding variants here rather than growing a -/// sibling classifier. +/// Card types, controller and color are not the same kind of thing — CR 109.3 +/// states an object's controller is not one of its characteristics — and they +/// are written in three different layers. What unifies them here is the single +/// property this gate cares about: each is read by `TargetFilter` when a +/// population is counted, so a pre-layer probe of any of them can be stale by +/// the time the counting effect applies. All three are written inside CR 613, +/// so parameterizing on this axis stays within one rule section, and closing +/// the remaining kinds (KEYWORD, NAME, P/T) means adding variants here rather +/// than growing a sibling classifier. enum PopulationKeyWrite { /// CR 613.1d (layer 4): card types, subtypes or supertypes. CardTypes, /// CR 613.1b (layer 2): the object's controller. Controller, + /// CR 613.1e (layer 5): the object's colors. + Color, } /// EXHAUSTIVE and wildcard-free over `ContinuousModification`, so a future @@ -3748,6 +3755,14 @@ fn modification_population_key_write(m: &ContinuousModification) -> Option Some(PopulationKeyWrite::Controller), + // CR 613.1e (layer 5): a color-changing effect rewrites the very + // characteristic a color-keyed population reads ("green creatures", + // "each white permanent"), and layer 5 runs before the layer-7 count, + // so the pre-layer probe sees the entrant's printed color rather than + // its derived one. + ContinuousModification::SetColor { .. } + | ContinuousModification::AddColor { .. } + | ContinuousModification::AddChosenColor { .. } => Some(PopulationKeyWrite::Color), // Everything else writes a characteristic that is not a population key. // Enumerated explicitly (no wildcard) so a future key-rewriting variant // forces a decision here. @@ -3784,9 +3799,6 @@ fn modification_population_key_write(m: &ContinuousModification) -> Option, Option)> = state .battlefield @@ -10552,16 +10544,19 @@ mod tests { pts.sort(); pts }; - assert_eq!( - bear_pts(&normal), - vec![(Some(4), Some(4)); 2], - "incremental arm leaves pre-existing Bears at the stale count" - ); + // The washed entrant is green by the time the count applies, so the + // count is 3, not the pre-layer 2. assert_eq!( bear_pts(&forced), vec![(Some(5), Some(5)); 2], "full pass counts the washed entrant — the correct CR 613 board" ); + assert_eq!( + bear_pts(&normal), + bear_pts(&forced), + "escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "color-keyed population escalation"); } /// Build a board pairing a PURE layer-4 type-writer with a From ec60b681ba6b49ebb11765d8b298327133017740 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:42:26 -0700 Subject: [PATCH 04/13] fix(engine): gate the entry-incremental flush on a typed read/write-kind relation CR 613.1b / CR 613.1c / CR 613.1d / CR 613.1e / CR 613.1f / CR 613.1g / CR 613.6. The escalation gate had a one-sided writer list. `PopulationKeyWrite` enumerated the two characteristics an entering permanent could have rewritten -- card types and controller -- and any recognized write escalated whenever anything on the board read an object population at all. Keyword, name, colour and P/T rewrites were missing from that list, and the review is right that adding them to it would not have been the fix: the read side was untyped, so widening the writer list widens every board, and the two deliberately pinned fast paths (`count_anthem_nonmatching_entry_does_not_escalate_and_matches_full`, `devotion_gate_colorless_entry_does_not_escalate_and_matches_full`) would have started escalating. That is why the P/T channel could not be closed by extending the enum, and it is what made the sibling cluster the wrong shape to grow. Both sides are now typed and the gate is their intersection. `CharacteristicKinds` is a bitmask over seven kinds, one per CR 613 sublayer that can be written -- Controller (613.1b), NameText (613.1c, with 612.8 for name-setting as a text-changing effect), CardTypes (613.1d), Color (613.1e), Abilities (613.1f), PowerToughness (613.1g) -- plus ManaCost, which has no layer of its own but is copy-writable under 707.9b and is read by devotion, so a copy effect has to be able to move a devotion-keyed population. The parameterisation axis stays inside CR 613's own taxonomy; subtypes and supertypes fold into CardTypes because 613.1d is one sublayer. `modification_characteristic_writes` maps all 57 `ContinuousModification` variants to what they write; `target_filter_characteristic_reads` maps all 98 `FilterProp` variants, and sibling walkers cover `QuantityRef` and `StaticCondition`, to what they read. Every one is exhaustive and wildcard-free, so a new variant on either enum fails to compile until somebody classifies it -- the property the previous classifier had, kept on both surfaces instead of one. Uncertain forms map to the full set, so a classification error can only over-escalate. The read set is the union of three things and is built unconditionally: dynamic magnitudes, live conditions, and every live modification's affected filter. The affected-filter term is not optional. A board with no counting and no conditions at all -- a layer-3 rename plus a static whose affected set keys on names -- has an empty read set without it, so nothing intersects, and a pre-existing permanent whose membership the rename flips keeps stale P/T. That is the same blindness this gate exists to close, reached through a different door. CR 613.6 carves out one shape. It fixes a continuous effect's set of affected objects the first time the effect applies and retains it for the rest of the pass, so an effect cannot push an object out of the filter that admitted it. A static that reads a kind through its own affected filter and writes that kind through its own modification -- "artifacts that aren't creatures become creatures" reads and writes card types -- is therefore not a staleness risk against itself, and `incremental_entry_retains_multi_layer_effect_affected_set` is the board that proves it. The exclusion is keyed on the CR 613.6 retention group, not on the individual modification: one `StaticDefinition` spawns sibling effects that each carry a clone of the same affected filter, so excluding only the modification's own copy leaves the clones contributing the same kind. A kind read by two distinct groups survives any single exclusion, and an effect with no retention identity fails closed. The gate is cheapest-first and entrant-independent for as long as possible: the union of all write kinds is pure enum matches; the read set is computed once per flush with an early exit once it saturates; a global disjointness check exits before any affected filter is matched against any entrant. The scute-storm board leaves at that check -- keyword grants write abilities, the board reads card types and controller -- with zero `matches_target_filter` calls, which is stricter than the ordering it replaces rather than looser. Both fast paths still take the incremental arm, and they do it by typing rather than by exemption: their anthems write P/T while their boards read card types, controller and mana cost. The gate is now narrower in places as well as wider -- a control change against a purely P/T-keyed population no longer escalates -- and both directions are pinned. Eight fixtures cover the channels: keyword, P/T, name, the cross-group affected-filter channel, a condition reading through its own filter, and three negatives for disjoint reads, controller-vs-P/T, and the CR 613.6 self-exclusion. Each one was checked by reverting the specific classifier row or union term it depends on and confirming it fails with a concrete stale value, then restoring it. The P/T fixture puts the entrant under the opposing player so the toughness setter is the only writer that reaches it -- with P/T as one kind, a P/T-keyed count anthem that also reached the entrant would have satisfied the relation on its own and the fixture would have passed with the fix removed. The remaining-gap block is deleted because the matrix is what it asked for. What is left is stated conservatism, not blindness. One boundary is inherited unchanged and is now written down at the gate: the reach probe evaluates affected filters against the previous final state while full evaluation matches them at intermediate layer states, so a count-thresholded affected filter can diverge from the probe in either direction. Typing the relation neither narrows nor widens that. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/filter.rs | 494 ++++++++++++++- crates/engine/src/game/layers.rs | 930 +++++++++++++++++++++++------ crates/engine/src/game/quantity.rs | 264 +++++++- crates/engine/src/game/stack.rs | 751 ++++++++++++++++++++++- 4 files changed, 2251 insertions(+), 188 deletions(-) diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index ceb819efee..d4bd58c880 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -8,7 +8,8 @@ use std::collections::{HashMap, HashSet}; use crate::game::combat; use crate::game::game_object::GameObject; use crate::game::quantity::{ - counter_count_from_map, resolve_quantity, resolve_quantity_with_targets, + counter_count_from_map, quantity_expr_characteristic_reads_at, resolve_quantity, + resolve_quantity_with_targets, }; use crate::types::ability::{ ChoiceValue, ChosenAttribute, CombatRelation, CombatRelationSubject, ControllerRef, CountScope, @@ -31,6 +32,104 @@ use crate::types::proposed_event::{EtbTapState, ProposedEvent, TokenSpec}; use crate::types::statics::StaticMode; use crate::types::zones::Zone; +/// CR 613.1: The set of layer-writable characteristic kinds that a filter, +/// quantity expression or static condition READS — one bit per CR 613 sublayer +/// that can rewrite that kind, plus the copy-only mana cost. +/// +/// This is the shared currency of the entry-incremental flush gate's +/// read/write-kind relation (see `game::layers`): a live modification whose +/// write kinds are disjoint from every live read kind cannot flip any live +/// verdict, so the entry fast path stays sound. Both surfaces classify into +/// this one lattice, which is what makes the intersection meaningful. +/// +/// Granularity is exactly one CR 613 sublayer per kind, so the parameterization +/// axis stays inside CR 613's own taxonomy (no cross-section unification): +/// - [`Self::CONTROLLER`] — CR 613.1b (layer 2). CR 109.3 does not list +/// controller among an object's characteristics, but a control change moves +/// an object between controller-keyed populations, so the relation must track +/// it alongside the true characteristics. +/// - [`Self::NAME_TEXT`] — CR 613.1c (layer 3) + CR 612.8 (an effect that sets +/// an object's name is a text-changing effect). +/// - [`Self::CARD_TYPES`] — CR 613.1d (layer 4), which is card type, subtype +/// AND supertype; all three fold into one kind because CR 613.1d is one +/// sublayer. +/// - [`Self::COLOR`] — CR 613.1e (layer 5). +/// - [`Self::ABILITIES`] — CR 613.1f (layer 6). +/// - [`Self::POWER_TOUGHNESS`] — CR 613.1g (layer 7), sublayers CR 613.4a-d. +/// - [`Self::MANA_COST`] — no layer of its own; only copy effects rewrite it +/// (CR 707.9b). It is tracked because devotion (CR 700.5) and mana-value +/// populations key on it, so All-writers (copies) must intersect those reads. +/// +/// CRITICAL — this is NOT the question that +/// [`filter_prop_uses_object_population`] answers. That classifier answers +/// MEMBERSHIP-perturbation ("can another object entering the battlefield change +/// this verdict or this count"). This one answers CHARACTERISTIC-dependence +/// ("which layer-writable kinds does this verdict read"). They are siblings, +/// not duplicates: "the number of tapped permanents" USES the object population +/// but reads NO layer-writable kind, and this relation correctly ignores it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CharacteristicKinds(u8); + +impl CharacteristicKinds { + /// Reads (or writes) nothing that a continuous effect can rewrite. + pub(crate) const EMPTY: Self = Self(0); + /// CR 613.1b: layer 2, control-changing effects. + pub(crate) const CONTROLLER: Self = Self(1 << 0); + /// CR 613.1c + CR 612.8: layer 3, text-changing effects (names). + pub(crate) const NAME_TEXT: Self = Self(1 << 1); + /// CR 613.1d: layer 4, card type / subtype / supertype. + pub(crate) const CARD_TYPES: Self = Self(1 << 2); + /// CR 613.1e: layer 5, color-changing effects. + pub(crate) const COLOR: Self = Self(1 << 3); + /// CR 613.1f: layer 6, ability-adding and ability-removing effects. + pub(crate) const ABILITIES: Self = Self(1 << 4); + /// CR 613.1g + CR 613.4a-d: layer 7, power and/or toughness. + pub(crate) const POWER_TOUGHNESS: Self = Self(1 << 5); + /// CR 707.9b: copy-writable only; no layer of its own. + pub(crate) const MANA_COST: Self = Self(1 << 6); + /// Every kind — the conservative answer for any form whose reads or writes + /// cannot be determined structurally. Over-approximating here can only + /// over-escalate (a full re-evaluation is slower, never wrong). + pub(crate) const ALL: Self = Self(0b0111_1111); + + pub(crate) const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + pub(crate) const fn intersects(self, other: Self) -> bool { + self.0 & other.0 != 0 + } + + pub(crate) const fn intersection(self, other: Self) -> Self { + Self(self.0 & other.0) + } + + /// The kinds in `self` that are not in `other`. + pub(crate) const fn without(self, other: Self) -> Self { + Self(self.0 & !other.0) + } + + pub(crate) const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + pub(crate) const fn is_empty(self) -> bool { + self.0 == 0 + } + + pub(crate) const fn is_all(self) -> bool { + self.contains(Self::ALL) + } +} + +/// Recursion budget for the characteristic-read walkers. +/// +/// Filters, quantity expressions and conditions form a finite OWNED tree (no +/// cycles are representable), so the walk always terminates; this cap only +/// bounds stack depth on pathologically nested hand-authored data. Overflow +/// yields [`CharacteristicKinds::ALL`], the conservative answer. +pub(crate) const CHARACTERISTIC_READ_DEPTH: u32 = 8; + /// True when the filter's matched SET depends on the population of objects on /// the battlefield — i.e. another object entering or leaving the battlefield can /// change whether a PRE-EXISTING object satisfies this filter. @@ -258,6 +357,399 @@ fn filter_prop_uses_object_population(prop: &FilterProp) -> bool { } } +/// CR 613.1: Which layer-writable characteristic kinds does this filter's +/// verdict read? +/// +/// EXHAUSTIVE and wildcard-free over `TargetFilter`, deliberately living beside +/// [`affected_filter_uses_object_population`] so that adding a variant forces +/// BOTH the membership decision and the characteristic decision at one seam. +/// See [`CharacteristicKinds`] for why these are two questions, not one. +pub(crate) fn target_filter_characteristic_reads(filter: &TargetFilter) -> CharacteristicKinds { + target_filter_characteristic_reads_at(filter, CHARACTERISTIC_READ_DEPTH) +} + +pub(crate) fn target_filter_characteristic_reads_at( + filter: &TargetFilter, + depth: u32, +) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; + match filter { + TargetFilter::Not { filter: inner } => target_filter_characteristic_reads_at(inner, depth), + TargetFilter::Or { filters } | TargetFilter::And { filters } => { + filters.iter().fold(CharacteristicKinds::EMPTY, |acc, f| { + acc.union(target_filter_characteristic_reads_at(f, depth)) + }) + } + TargetFilter::Typed(TypedFilter { + type_filters, + controller, + properties, + }) => { + let mut kinds = CharacteristicKinds::EMPTY; + // CR 613.1d: every type constraint reads the layer-4 typeline. + for tf in type_filters { + kinds = kinds.union(type_filter_characteristic_reads(tf)); + } + // CR 613.1b: a controller-scoped filter gains and loses members when + // layer 2 rewrites control. + if controller.is_some() { + kinds = kinds.union(CharacteristicKinds::CONTROLLER); + } + for prop in properties { + if kinds.is_all() { + break; + } + kinds = kinds.union(filter_prop_characteristic_reads_at(prop, depth)); + } + kinds + } + // Payload-bearing object references: recurse into the embedded filter. + TargetFilter::TrackedSetFiltered { filter: inner, .. } => { + target_filter_characteristic_reads_at(inner, depth) + } + TargetFilter::ChosenDamageSource { filter: inner, .. } => { + inner.as_ref().map_or(CharacteristicKinds::EMPTY, |f| { + target_filter_characteristic_reads_at(f, depth) + }) + } + // CR 613.1b: stack-ability reference scoped by controller. + TargetFilter::StackAbility { .. } => CharacteristicKinds::CONTROLLER, + // CR 613.1b + CR 613.1d: parse-layer sugar for "that player and the + // permanents of this type they control"; lowered before object matching, + // but classified truthfully. + TargetFilter::ControllerAndControlledPermanents { .. } => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::CONTROLLER) + } + // CR 201.2: compares the live `name` field, which layer 3 writes. + TargetFilter::Named { .. } => CharacteristicKinds::NAME_TEXT, + // Fixed object / player references, zone anchors and ledger lookups: the + // referent is picked by identity, not by any layer-written + // characteristic. Enumerated explicitly (no wildcard) so a future variant + // is forced through this classification. CR 108.3: `Owner` is fixed at + // game start and is not layer-writable. + TargetFilter::None + | TargetFilter::Any + | TargetFilter::Player + | TargetFilter::Controller + | TargetFilter::Opponent + | TargetFilter::SelfRef + | TargetFilter::SourceOrPaired + | TargetFilter::StackSpell + | TargetFilter::SpecificObject { .. } + | TargetFilter::SpecificPlayer { .. } + | TargetFilter::PlayerWhoChoseLabel { .. } + | TargetFilter::Neighbor { .. } + | TargetFilter::ScopedPlayer + | TargetFilter::AttachedTo + | TargetFilter::LastCreated + | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged + | TargetFilter::CostPaidObject + | TargetFilter::ChosenCard + | TargetFilter::TrackedSet { .. } + | TargetFilter::ExiledBySource + | TargetFilter::ExiledCardByIndex { .. } + | TargetFilter::TriggeringSpellController + | TargetFilter::TriggeringSpellOwner + | TargetFilter::TriggeringSourceController + | TargetFilter::TriggeringPlayer + | TargetFilter::TriggeringSource + | TargetFilter::EventTarget + | TargetFilter::ParentTarget + | TargetFilter::ParentTargetSlot { .. } + | TargetFilter::ParentTargetController + | TargetFilter::ParentTargetOwner + | TargetFilter::SourceChosenPlayer + | TargetFilter::OriginalController + | TargetFilter::OriginalSource + | TargetFilter::PostReplacementSourceController + | TargetFilter::PostReplacementDamageSource + | TargetFilter::PostReplacementDamageTarget + | TargetFilter::PostReplacementDamageTargetOwner + | TargetFilter::DefendingPlayer + | TargetFilter::HasChosenName + | TargetFilter::Owner + | TargetFilter::GrantingObject + | TargetFilter::AllPlayers => CharacteristicKinds::EMPTY, + } +} + +/// CR 613.1d + CR 613.1f: a type constraint reads the layer-4 typeline; a +/// SUBTYPE constraint additionally reads layer-6 abilities, because Changeling +/// (CR 702.73a) makes an object every creature type and the live check reads the +/// keyword set (`subtype_matches_with_changeling`). +fn type_filter_characteristic_reads(tf: &TypeFilter) -> CharacteristicKinds { + match tf { + TypeFilter::Subtype(_) => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) + } + TypeFilter::Non(inner) => type_filter_characteristic_reads(inner), + TypeFilter::AnyOf(inners) => inners.iter().fold(CharacteristicKinds::EMPTY, |acc, t| { + acc.union(type_filter_characteristic_reads(t)) + }), + TypeFilter::Creature + | TypeFilter::Land + | TypeFilter::Artifact + | TypeFilter::Enchantment + | TypeFilter::Instant + | TypeFilter::Sorcery + | TypeFilter::Planeswalker + | TypeFilter::Battle + | TypeFilter::Kindred + | TypeFilter::Permanent + | TypeFilter::Card + | TypeFilter::Any => CharacteristicKinds::CARD_TYPES, + } +} + +/// CR 603.4: Which characteristic a "shares a quality with" comparison reads. +/// Shared by the `FilterProp::SharesQuality` arm and by +/// `QuantityRef::ObjectCountBySharedQuality` / `ObjectCountDistinct`, which +/// group objects on the same quality vocabulary. +pub(crate) fn shared_quality_characteristic_reads(quality: &SharedQuality) -> CharacteristicKinds { + match quality { + // CR 201.2: name comparison. + SharedQuality::Name => CharacteristicKinds::NAME_TEXT, + // CR 202.3: mana value is derived from the mana cost. + SharedQuality::ManaValue => CharacteristicKinds::MANA_COST, + // CR 208.1 / CR 209.1. + SharedQuality::Power | SharedQuality::Toughness | SharedQuality::TotalPowerToughness => { + CharacteristicKinds::POWER_TOUGHNESS + } + // CR 105.1. + SharedQuality::Color => CharacteristicKinds::COLOR, + // CR 205.3m + CR 702.73a: creature types see through Changeling, which + // is a layer-6 ability. + SharedQuality::CreatureType => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) + } + // CR 205.2 / CR 205.3. + SharedQuality::CardType | SharedQuality::LandType | SharedQuality::PermanentType => { + CharacteristicKinds::CARD_TYPES + } + } +} + +/// CR 613.1: EXHAUSTIVE, wildcard-free leaf classifier for +/// [`target_filter_characteristic_reads`] — the characteristic-dependence twin +/// of [`filter_prop_uses_object_population`]. Adding a `FilterProp` variant +/// forces a decision here. +/// +/// A prop that carries a nested `TargetFilter` or `QuantityExpr` unions the +/// payload's kinds ON TOP of its own intrinsic reads; a prop that carries a +/// `ControllerRef` unions [`CharacteristicKinds::CONTROLLER`], because layer 2 +/// can move objects across the scope the prop is asking about. +fn filter_prop_characteristic_reads_at(prop: &FilterProp, depth: u32) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; + match prop { + // ---- CR 613.1f (layer 6): keyword and ability reads. ---- + FilterProp::WithKeyword { .. } + | FilterProp::HasKeywordKind { .. } + | FilterProp::WithoutKeyword { .. } + | FilterProp::WithoutKeywordKind { .. } + // CR 605.1 + CR 113.1: both read the live ability set. + | FilterProp::HasManaAbility + | FilterProp::HasNoAbilities + // CR 602.1: activation costs live on the object's abilities. + | FilterProp::HasXInActivationCost => CharacteristicKinds::ABILITIES, + // CR 303.4 + CR 702.5: "could enchant" reads the source's own Enchant + // ability (layer 6) and the referenced object through the inner filter. + FilterProp::CanEnchant { target } => CharacteristicKinds::ABILITIES + .union(target_filter_characteristic_reads_at(target, depth)), + // CR 302.6 + CR 702.10: haste is a keyword read; the creature check is a + // typeline read. + FilterProp::HasHasteOrControlledSinceTurnBegan => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) + } + + // ---- CR 613.1d (layer 4): typeline reads. ---- + FilterProp::HasSupertype { .. } + | FilterProp::NotSupertype { .. } + | FilterProp::Historic + | FilterProp::NotHistoric + | FilterProp::IsChosenCardType + // CR 303.4 + CR 301.5: both read the attachment's subtype (Aura / + // Equipment) to decide the relationship. + | FilterProp::EnchantedBy + | FilterProp::EquippedBy => CharacteristicKinds::CARD_TYPES, + // CR 205.3m + CR 702.73a: creature-type reads see through Changeling, so + // they read layer 6 as well as layer 4. + FilterProp::IsChosenCreatureType | FilterProp::SharesCreatureTypeWithCommander => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) + } + // CR 205.3m + CR 701.23a: whole-zone creature-type tally, scoped to a + // player (CR 613.1b). + FilterProp::MostPrevalentCreatureTypeIn { .. } => CharacteristicKinds::CARD_TYPES + .union(CharacteristicKinds::ABILITIES) + .union(CharacteristicKinds::CONTROLLER), + // CR 310 + CR 613.1b: the Battle's protector is read by type and by + // controller scope. + FilterProp::ProtectorMatches { .. } + // CR 303.4 + CR 301.5: attachment subtype plus the attachment's + // controller scope. + | FilterProp::HasAttachment { .. } + | FilterProp::HasAnyAttachmentOf { .. } + // CR 700.9: "modified" reads counters, Equipment and Auras controlled by + // the permanent's controller. + | FilterProp::Modified => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::CONTROLLER) + } + + // ---- CR 613.1e (layer 5): color reads. ---- + FilterProp::HasColor { .. } + | FilterProp::NotColor { .. } + | FilterProp::IsChosenColor + | FilterProp::ColorCount { .. } => CharacteristicKinds::COLOR, + // CR 205.2 + CR 608.2c: the transient card predicate is matched on both + // the card types and the color of the candidate. + FilterProp::MatchesLastChosenCardPredicate => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::COLOR) + } + + // ---- CR 613.1c (layer 3): name reads. ---- + FilterProp::SameName | FilterProp::SameNameAsParentTarget => CharacteristicKinds::NAME_TEXT, + // CR 201.2 + CR 613.1f: `Named` also matches through the live + // `StaticMode::CountsAsNamed` aliases, which are layer-6 statics. + FilterProp::Named { .. } => { + CharacteristicKinds::NAME_TEXT.union(CharacteristicKinds::ABILITIES) + } + // CR 201.2a: whole-board name tally, optionally scoped by controller. + FilterProp::NameMatchesAnyPermanent { .. } => { + CharacteristicKinds::NAME_TEXT.union(CharacteristicKinds::CONTROLLER) + } + // CR 201.2: names of the permanents the evaluating controller controls + // that match the inner filter — name, controller scope, and the inner + // filter's own kinds. + FilterProp::DifferentNameFrom { filter } => CharacteristicKinds::NAME_TEXT + .union(CharacteristicKinds::CONTROLLER) + .union(target_filter_characteristic_reads_at(filter, depth)), + + // ---- CR 613.1g (layer 7): power/toughness reads. ---- + // CR 208 + CR 613.4b: `Base` scope reads base P/T, which layers 7a/7b + // still write, so both scopes read this kind. + FilterProp::PtComparison { value, .. } => CharacteristicKinds::POWER_TOUGHNESS + .union(quantity_expr_characteristic_reads_at(value, depth)), + FilterProp::PowerGTSource + | FilterProp::ToughnessGTPower + | FilterProp::PowerExceedsBase => CharacteristicKinds::POWER_TOUGHNESS, + + // ---- CR 707.9b: mana-cost reads (copy-writable only). ---- + FilterProp::Cmc { value, .. } => CharacteristicKinds::MANA_COST + .union(quantity_expr_characteristic_reads_at(value, depth)), + FilterProp::ManaValueParity { .. } + | FilterProp::ManaSymbolCount { .. } + | FilterProp::ManaCostIn { .. } + | FilterProp::HasXInManaCost => CharacteristicKinds::MANA_COST, + + // ---- Structural recursion. ---- + // CR 122.1: the counter count itself is not layer-written; only the + // threshold expression can read characteristics. + FilterProp::Counters { count, .. } => quantity_expr_characteristic_reads_at(count, depth), + FilterProp::AnyOf { props } => props.iter().fold(CharacteristicKinds::EMPTY, |acc, p| { + if acc.is_all() { + acc + } else { + acc.union(filter_prop_characteristic_reads_at(p, depth)) + } + }), + // CR 608.2c: negation does not change WHICH state the inner prop reads. + FilterProp::Not { prop } => filter_prop_characteristic_reads_at(prop, depth), + // CR 115.9b/c: the stack entry's targets are matched by the inner filter. + FilterProp::TargetsOnly { filter } | FilterProp::Targets { filter } => { + target_filter_characteristic_reads_at(filter, depth) + } + // CR 603.4: the shared quality names exactly which characteristic is + // compared; the reference set contributes its own filter's kinds. + FilterProp::SharesQuality { + quality, reference, .. + } => { + let quality_kinds = shared_quality_characteristic_reads(quality); + reference.as_ref().map_or(quality_kinds, |r| { + quality_kinds.union(target_filter_characteristic_reads_at(r, depth)) + }) + } + + // ---- CR 613.1b (layer 2): controller-scoped predicates. ---- + // Each reads the matched object's live controller, or scopes a ledger + // lookup by a `ControllerRef` that layer 2 can move objects across. + FilterProp::ControllerChoseLabel { .. } + | FilterProp::Attacking { .. } + | FilterProp::AttackedThisTurn { .. } + | FilterProp::CountersPutOnThisTurn { .. } => CharacteristicKinds::CONTROLLER, + + // ---- Undeterminable: conservatively every kind. ---- + // CR 109.4: an arbitrary player predicate over the object's controller + // can read anything about the boards those players control. + FilterProp::ControllerMatches { .. } + // CR 115.1 + CR 707.10: evaluates the triggering spell's OWN target + // filter, which is not reachable from this AST node. + | FilterProp::CouldBeTargetedByTriggeringSpell => CharacteristicKinds::ALL, + // CR 109.1: identity exclusion. Against the ability's own parent target + // this is pure object identity and reads nothing; against any other + // reference the excluded set is filter-derived and could be anything. + FilterProp::DistinctFrom { reference } => match reference.as_ref() { + TargetFilter::ParentTarget => CharacteristicKinds::EMPTY, + _ => CharacteristicKinds::ALL, + }, + + // ---- Reads no layer-writable characteristic. ---- + // Token identity, zone, ownership (CR 108.3), combat state, per-object + // designations, per-turn ledgers, stack shape, and the fail-closed + // unparsed leaf. Enumerated explicitly (no wildcard). + FilterProp::Token + | FilterProp::NonToken + | FilterProp::RepresentedByCard + | FilterProp::WasPlayed + | FilterProp::Blocking + | FilterProp::BlockingSource + | FilterProp::CombatRelation { .. } + | FilterProp::Unblocked + | FilterProp::AttackingAlone + | FilterProp::BlockingAlone + | FilterProp::Tapped + | FilterProp::Untapped + | FilterProp::IsSaddled + | FilterProp::SaddledSource + | FilterProp::ConvokedSource + | FilterProp::Foretold + | FilterProp::HasAdventure + | FilterProp::WasKicked + | FilterProp::InZone { .. } + // CR 108.3: owner is fixed at game start; no layer writes it. + | FilterProp::Owned { .. } + | FilterProp::AttachedToSource + | FilterProp::AttachedToRecipient + | FilterProp::Another + | FilterProp::Unpaired + | FilterProp::OtherThanTriggerObject + | FilterProp::InTrackedSet { .. } + | FilterProp::Suspected + | FilterProp::Renowned + | FilterProp::Goaded + | FilterProp::InAnyZone { .. } + | FilterProp::WasDealtDamageThisTurn + | FilterProp::DealtDamageThisTurn + | FilterProp::EnteredThisTurn + | FilterProp::ControlledContinuouslySinceTurnBegan + | FilterProp::ZoneChangedThisTurn { .. } + | FilterProp::BlockedThisTurn + | FilterProp::AttackedOrBlockedThisTurn + | FilterProp::HasSingleTarget + | FilterProp::Modal + | FilterProp::FaceDown + | FilterProp::Transformed + // CR 903.3: commander designation is set at deck construction. + | FilterProp::IsCommander + // Unparsed leaf: evaluates fail-closed `false` for every object, so its + // verdict can never be flipped by any layer. + | FilterProp::Other { .. } => CharacteristicKinds::EMPTY, + } +} + /// CR 611.3a: ENTRY-AWARE narrowing for a population-sensitive AFFECTED FILTER. /// `affected_filter_uses_object_population` proves an effect's affected set *can* /// read board population; this proves a SPECIFIC entering object can actually diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 410bb2a5c9..16dfa0c36f 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -11,15 +11,19 @@ use crate::game::conditions::{ eval_source_is_tapped_on_battlefield, }; use crate::game::devotion::count_devotion; -use crate::game::filter::{matches_target_filter, FilterContext}; +use crate::game::filter::{ + matches_target_filter, target_filter_characteristic_reads, + target_filter_characteristic_reads_at, CharacteristicKinds, FilterContext, + CHARACTERISTIC_READ_DEPTH, +}; use crate::game::game_object::DisplaySource; use crate::game::printed_cards::{ apply_copiable_values, ensure_keyword_triggers_for_copiable_values, intrinsic_copiable_values, is_runtime_host_lifetime_replacement, is_runtime_target_die_exile_replacement, }; use crate::game::quantity::{ - continuous_modification_dynamic_quantity, filter_uses_recipient, quantity_expr_uses_recipient, - QuantityContext, + continuous_modification_dynamic_quantity, filter_uses_recipient, + quantity_expr_characteristic_reads_at, quantity_expr_uses_recipient, QuantityContext, }; use crate::game::speed::{effective_speed, has_max_speed}; use crate::types::ability::{ @@ -1130,6 +1134,147 @@ fn static_condition_uses_object_population(condition: &StaticCondition) -> bool } } +/// CR 613.1: Which layer-writable characteristic kinds does this source-level +/// enabling condition read? +/// +/// Characteristic-dependence twin of `static_condition_uses_object_population`: +/// that predicate answers "can board MEMBERSHIP flip this gate", this one +/// answers "which layer-writable CHARACTERISTICS does the gate read". +/// +/// Two deliberate differences from the boolean twin: +/// - it RECURSES `SourceMatchesFilter` / `RecipientMatchesFilter` (both are +/// `false` there because a battlefield entry cannot change whether the source +/// or the recipient matches — but a layer write to that object very much can); +/// - `Unrecognized` maps to ALL, not to a kind. +/// +/// EXHAUSTIVE and wildcard-free. Conditions over zones, turn structure, player +/// totals, statuses (CR 110.5a) and counters (CR 122.1) read NO layer-writable +/// characteristic and MUST classify EMPTY — a blanket-ALL here would saturate +/// the read union on ordinary boards and turn the entry-flush gate into an +/// unconditional full re-evaluation. +fn static_condition_characteristic_reads(condition: &StaticCondition) -> CharacteristicKinds { + static_condition_characteristic_reads_at(condition, CHARACTERISTIC_READ_DEPTH) +} + +fn static_condition_characteristic_reads_at( + condition: &StaticCondition, + depth: u32, +) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; + match condition { + // Threshold gates: both operands are magnitudes. + StaticCondition::QuantityComparison { lhs, rhs, .. } => { + quantity_expr_characteristic_reads_at(lhs, depth) + .union(quantity_expr_characteristic_reads_at(rhs, depth)) + } + // CR 700.5: devotion sums mana symbols in the mana costs of the + // permanents the source's controller controls. + StaticCondition::DevotionGE { .. } => { + CharacteristicKinds::MANA_COST.union(CharacteristicKinds::CONTROLLER) + } + // CR 105.1: color histogram over every battlefield permanent. + StaticCondition::SharesColorWithMostCommonColorAmongPermanents => { + CharacteristicKinds::COLOR + } + // CR 109.4: "you control [filter]" — controller-scoped membership over a + // live filter. A filterless `IsPresent` still reads the controller scope. + StaticCondition::IsPresent { filter } => { + filter + .as_ref() + .map_or(CharacteristicKinds::CONTROLLER, |f| { + CharacteristicKinds::CONTROLLER + .union(target_filter_characteristic_reads_at(f, depth)) + }) + } + // CR 509.1a + CR 109.4: per-player board census over a live filter. + StaticCondition::DefendingPlayerControls { filter } => CharacteristicKinds::CONTROLLER + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 903.3 + CR 613.1b: commander designation is fixed at deck + // construction; only the control scope is layer-writable. + StaticCondition::ControlsCommander { .. } + | StaticCondition::SourceControllerEquals { .. } => CharacteristicKinds::CONTROLLER, + // Recurse combinators, early-exiting once saturated. + StaticCondition::And { conditions } | StaticCondition::Or { conditions } => conditions + .iter() + .fold(CharacteristicKinds::EMPTY, |acc, c| { + if acc.is_all() { + acc + } else { + acc.union(static_condition_characteristic_reads_at(c, depth)) + } + }), + StaticCondition::Not { condition } => { + static_condition_characteristic_reads_at(condition, depth) + } + // Parse fallback: text unknown, so every kind is conservatively assumed. + StaticCondition::Unrecognized { .. } => CharacteristicKinds::ALL, + // NET-NEW relative to the boolean twin: these match a live object + // against a filter, so every characteristic that filter reads is a live + // read of this condition. + StaticCondition::SourceMatchesFilter { filter } + | StaticCondition::RecipientMatchesFilter { filter } => { + target_filter_characteristic_reads_at(filter, depth) + } + // CR 301.5 + CR 303.4 + CR 306.1: attachment relationships are decided by + // the attachment's subtype (Equipment / Aura) and by the attached + // object's card type. + StaticCondition::SourceIsEquipped + | StaticCondition::SourceIsEnchanted + | StaticCondition::SourceAttachedToCreature => CharacteristicKinds::CARD_TYPES, + // Reads no layer-writable characteristic: zone contents, turn structure, + // combat state, player-scoped totals and designations, statuses + // (CR 110.5a), counters (CR 122.1), per-object chosen attributes, and + // cast history. Enumerated explicitly (no wildcard). + // + // CR 401.1: `TopOfLibraryMatches` reads the controller's LIBRARY top; no + // write to a battlefield object can change that card's characteristics. + StaticCondition::TopOfLibraryMatches { .. } + | StaticCondition::ChosenColorIs { .. } + | StaticCondition::ChosenLabelIs { .. } + | StaticCondition::HasMaxSpeed + | StaticCondition::SpeedGE { .. } + | StaticCondition::DayNightIs { .. } + | StaticCondition::HasCounters { .. } + | StaticCondition::CastVariantPaid { .. } + | StaticCondition::RecipientHasCounters { .. } + | StaticCondition::ClassLevelGE { .. } + | StaticCondition::SourceAttackingAlone + | StaticCondition::SourceIsAttacking + | StaticCondition::RecipientAttackingOwnerTarget { .. } + | StaticCondition::SourceIsBlocking + | StaticCondition::SourceIsBlocked + | StaticCondition::IsMonarch + | StaticCondition::IsInitiative + | StaticCondition::NoMonarch + | StaticCondition::HasCityBlessing + | StaticCondition::CompletedADungeon + | StaticCondition::WasStartingPlayer { .. } + | StaticCondition::SpellCastWithVariantThisTurn { .. } + | StaticCondition::OpponentPoisonAtLeast { .. } + | StaticCondition::UnlessPay { .. } + | StaticCondition::DuringYourTurn + | StaticCondition::SourceEnteredThisTurn + | StaticCondition::SourceHasDealtDamage + | StaticCondition::WasCast { .. } + | StaticCondition::IsRingBearer + | StaticCondition::RingLevelAtLeast { .. } + | StaticCondition::SourceIsTapped + | StaticCondition::IsTapped { .. } + | StaticCondition::SourceIsSaddled + | StaticCondition::SourceIsMonstrous + | StaticCondition::SourceIsHarnessed + | StaticCondition::SourceIsPaired + | StaticCondition::SourceInZone { .. } + | StaticCondition::EnchantedIsFaceDown + | StaticCondition::SourceIsFaceUp + | StaticCondition::AdditionalCostPaid + | StaticCondition::CastingAsVariant { .. } + | StaticCondition::None => CharacteristicKinds::EMPTY, + } +} + /// CR 611.3a: ENTRY-AWARE narrowing for a population-sensitive source-level /// enabling CONDITION. `static_condition_uses_object_population` proves a /// condition *can* gate on board population; this proves a SPECIFIC entering @@ -3527,29 +3672,13 @@ pub(crate) fn incremental_flush_must_escalate( prepare_incremental_flush(&mut scratch, entered_ids).is_none() } -/// Does this effect read battlefield object POPULATION at all — through its -/// dynamic magnitude or through its affected set? -/// -/// Single authority for the unnarrowed question, consumed by -/// `population_probe_blinded_by_entrant_characteristic_change`, whose whole -/// point is that per-entrant narrowing is unreliable. Axis 2a -/// (`active_effects_force_incremental_escalation`) asks the same question but -/// needs the two channels separately to pick a narrowing, so it consumes -/// `effect_population_reads` — the shared definition underneath — directly. -/// -/// Effects are not the only population readers: a Continuous static's enabling -/// `condition` reads populations too — that channel's twin is -/// `any_active_static_condition_reads_object_population`. -fn effect_reads_object_population(e: &ActiveContinuousEffect) -> bool { - let (magnitude_sensitive, affected_sensitive) = effect_population_reads(e); - magnitude_sensitive || affected_sensitive -} - /// The two population-read channels of a single effect, computed once: -/// `(dynamic-magnitude sensitivity, affected-set sensitivity)`. Axis 2a needs -/// the split to pick which per-entrant narrowing applies without walking the -/// affected filter twice; everything else consumes the disjunction via -/// `effect_reads_object_population`. +/// `(dynamic-magnitude sensitivity, affected-set sensitivity)`. Axis 2a +/// (`active_effects_force_incremental_escalation`) needs the split to pick which +/// per-entrant narrowing applies without walking the affected filter twice. +/// +/// MEMBERSHIP question, not a characteristic question — see +/// [`CharacteristicKinds`] for the deliberate split. fn effect_population_reads(e: &ActiveContinuousEffect) -> (bool, bool) { ( continuous_modification_dynamic_quantity(&e.modification) @@ -3558,30 +3687,177 @@ fn effect_population_reads(e: &ActiveContinuousEffect) -> (bool, bool) { ) } -/// Condition-channel twin of `effect_reads_object_population`: does any live -/// CONTINUOUS static definition carry an enabling `condition` that reads -/// battlefield object population (CR 611.3a — the condition re-evaluates as the -/// board changes)? Axis 2b (`any_active_static_condition_perturbed_by_entry`) -/// probes such conditions per-entrant with PRE-layer characteristics, so the -/// blindness disjunct below must treat them as population readers alongside -/// effect magnitudes and affected sets. -fn any_active_static_condition_reads_object_population(state: &GameState) -> bool { - let mut found = false; - for_each_static_effect_source(state, |_state, obj| { - if found { - return; +/// The live read union, split by whether a contribution is attributable to one +/// continuous effect. +struct LiveCharacteristicReads { + /// Read kinds that belong to no single effect, so no writer is ever exempt + /// from them: dynamic magnitudes and Continuous statics' enabling + /// conditions. + global: CharacteristicKinds, + /// `global` plus every live affected filter's kinds — the full ReadKinds + /// union that drives the entrant-independent global exit. + total: CharacteristicKinds, +} + +/// CR 613.1: the union of layer-writable characteristic kinds that the CURRENT +/// board actually READS, per the three live read channels. +/// +/// Entrant-independent, so the entry-flush gate computes it once per flush: +/// +/// ```text +/// global := ⋃ dynamic-magnitude kinds // every live modification's magnitude +/// ∪ ⋃ Continuous condition kinds // every live Continuous static's condition +/// ReadKinds := global ∪ ⋃ affected-filter kinds // every live modification's affected filter +/// ``` +/// +/// All three channels are unioned UNCONDITIONALLY. An earlier design gated the +/// affected-filter channel on the write set already intersecting, which is +/// unsound: a board whose only name-sensitive read lives in another static's +/// affected filter has an empty base union, so the gate would never notice a +/// layer-3 name rewrite reaching the entrant. +/// +/// The affected-filter channel is reported separately because it is the only +/// one attributable to a single effect, and CR 613.6 puts an effect's own +/// affected filter out of reach of its own writes — see +/// [`AffectedFilterReadTally`]. The other two channels are board-level and +/// admit no such exclusion. +/// +/// Walks early-exit the moment the union saturates to +/// [`CharacteristicKinds::ALL`]. +fn live_characteristic_reads( + state: &GameState, + active_effects: &[ActiveContinuousEffect], +) -> LiveCharacteristicReads { + let mut global = CharacteristicKinds::EMPTY; + let mut affected = CharacteristicKinds::EMPTY; + for e in active_effects { + if global.union(affected).is_all() { + break; + } + if let Some(q) = continuous_modification_dynamic_quantity(&e.modification) { + global = global.union(quantity_expr_characteristic_reads_at( + q, + CHARACTERISTIC_READ_DEPTH, + )); } - if obj.static_definitions.iter_all().any(|def| { - def.mode == StaticMode::Continuous - && def - .condition - .as_ref() - .is_some_and(static_condition_uses_object_population) - }) { - found = true; + affected = affected.union(target_filter_characteristic_reads(&e.affected_filter)); + } + if !global.union(affected).is_all() { + // CR 611.3a: a Continuous static's enabling condition re-evaluates as the + // board changes, so it is a live read channel in its own right. + let affected_so_far = affected; + for_each_static_effect_source(state, |_state, obj| { + if global.union(affected_so_far).is_all() { + return; + } + for def in obj.static_definitions.iter_all() { + if def.mode != StaticMode::Continuous { + continue; + } + if let Some(condition) = def.condition.as_ref() { + global = global.union(static_condition_characteristic_reads(condition)); + } + } + }); + } + LiveCharacteristicReads { + global, + total: global.union(affected), + } +} + +/// CR 613.6: a continuous effect whose modifications span several layers uses +/// ONE affected-object set. It is determined the first time the effect applies +/// and then retained for every other applicable layer — `started_effect_sets` +/// in [`apply_continuous_effect_filtered`] is exactly that retention, keyed by +/// [`ContinuousEffectGroupKey`]. The set is therefore fixed BEFORE any of that +/// effect's own modifications run, in the full pass and in the incremental pass +/// alike, so a modification can never move the affected set of the effect it +/// belongs to — not for the entrant and not for a pre-existing object. +/// +/// That makes the affected-filter read channel self-exclusive. When the gate +/// asks whether writer `M` can invalidate a live read, `M`'s own effect's +/// affected filter is not a read `M` can move; only OTHER effects' affected +/// filters are. `name_rewrite_entry_escalates_through_affected_filter_reads` +/// pins the cross-effect direction, where the rename is definition 0 and the +/// name-sensitive buff is definition 1, so the buff's set is determined AFTER +/// the rename applied. `incremental_entry_retains_multi_layer_effect_affected_set` +/// pins the self direction: one definition whose layer-4 `AddType` write is +/// read only by its own `Non(Creature)` affected filter, which CR 613.6 has +/// already locked in. +/// +/// Attribution is per retention group, never per modification: the sibling +/// modifications of one definition each carry a clone of the same affected +/// filter, so excluding one modification's copy would leave its siblings' +/// identical copies contributing the same kinds. +/// +/// `duplicated` keeps the exclusion O(effects) instead of O(effects²): a kind +/// read by two or more distinct groups survives excluding any single group. +struct AffectedFilterReadTally { + by_group: HashMap, + /// Kinds read by at least one attributed group's affected filter. + attributed: CharacteristicKinds, + /// Kinds read by at least two DISTINCT attributed groups. + duplicated: CharacteristicKinds, + /// Affected-filter reads from effects with no retention identity. Fail + /// closed — these are never excluded. + unattributed: CharacteristicKinds, +} + +impl AffectedFilterReadTally { + /// The affected-filter reads that survive excluding `effect`'s own CR 613.6 + /// retention group. + fn excluding_own_group( + &self, + state: &GameState, + effect: &ActiveContinuousEffect, + ) -> CharacteristicKinds { + let own = continuous_effect_group_key(state, effect) + .and_then(|key| self.by_group.get(&key).copied()) + .unwrap_or(CharacteristicKinds::EMPTY); + self.unattributed + .union(self.duplicated) + .union(self.attributed.without(own)) + } +} + +/// Attribute every live affected filter's characteristic reads to its CR 613.6 +/// retention group. Deliberately built only after the global disjointness exit +/// has already failed, so the boards that leave the gate at that exit never pay +/// for the grouping. +fn tally_affected_filter_reads( + state: &GameState, + active_effects: &[ActiveContinuousEffect], +) -> AffectedFilterReadTally { + let mut by_group: HashMap = HashMap::new(); + let mut unattributed = CharacteristicKinds::EMPTY; + for e in active_effects { + let reads = target_filter_characteristic_reads(&e.affected_filter); + if reads.is_empty() { + continue; } - }); - found + match continuous_effect_group_key(state, e) { + Some(key) => { + let slot = by_group.entry(key).or_insert(CharacteristicKinds::EMPTY); + *slot = slot.union(reads); + } + None => unattributed = unattributed.union(reads), + } + } + // Order-independent: `duplicated` ends up as the kinds present in two or + // more group entries however the map iterates. + let mut attributed = CharacteristicKinds::EMPTY; + let mut duplicated = CharacteristicKinds::EMPTY; + for reads in by_group.values() { + duplicated = duplicated.union(attributed.intersection(*reads)); + attributed = attributed.union(*reads); + } + AffectedFilterReadTally { + by_group, + attributed, + duplicated, + unattributed, + } } /// CR 613.1 + CR 613.1d + CR 613.4a: escalate when the population probes below @@ -3615,121 +3891,144 @@ fn any_active_static_condition_reads_object_population(state: &GameState) -> boo /// is pinned end-to-end separately in /// `tests/integration/life_and_limb_sylvan_advocate.rs`. /// -/// Precise on the write CLASSIFIER, conservative on the READ side. Projecting -/// the entrant through layer 4 would need a speculative pass, transitively -/// closed over grants that unlock further grants; instead this escalates when a -/// population READ is live — an effect's dynamic magnitude or affected set -/// (`effect_reads_object_population`) or a Continuous static's enabling -/// condition (`any_active_static_condition_reads_object_population`) — AND some -/// active effect reaching an entrant rewrites one of that entrant's POPULATION -/// KEYS: its card types (layer 4, CR 613.1d), its controller (layer 2, -/// CR 613.1b) or its colors (layer 5, CR 613.1e). The read side is not narrowed -/// because a counted population is almost always keyed on card type, controller -/// or color (`ObjectCount { filter: lands you control }` is keyed on two of -/// them at once), so narrowing it would buy nothing while adding a second -/// 98-arm classifier. -/// Note the write-side REACH probe (`matches_target_filter` below) shares the -/// pre-layer blindness this disjunct exists to fix: a type-writer whose -/// affected filter keys on a characteristic another layer rewrites is still -/// missed — that is the write-side twin of the KNOWN REMAINING GAP. +/// Closed by a typed READ/WRITE-KIND RELATION rather than by a one-sided list +/// of "population-keying" writers. Both sides are classified over the same +/// lattice ([`CharacteristicKinds`], one bit per layer-writable characteristic +/// kind), and the gate fires only when they INTERSECT: +/// +/// ```text +/// escalate ⇔ ∃ live M reaching an entrant with writes(M) ∩ reads_M_can_move ≠ ∅ +/// +/// reads_M_can_move := global reads (magnitudes, conditions) +/// ∪ affected-filter reads of every effect EXCEPT M's own +/// ``` +/// +/// The exclusion is CR 613.6, not an optimisation: M's own effect's affected +/// set is retained from the moment that effect first applies, so it is already +/// fixed before M runs and M cannot move it. See [`AffectedFilterReadTally`]. /// -/// This precision is load-bearing, not decorative: escalating on "the entrant is -/// a recipient of anything" instead regresses the deliberately-pinned -/// `count_anthem_nonmatching_entry_does_not_escalate_and_matches_full` and -/// `devotion_gate_colorless_entry_does_not_escalate_and_matches_full` fast paths, -/// where the effect reaching the entrant writes only P/T and so cannot move a -/// type-keyed or devotion-keyed count. +/// `writes(M)` comes from [`modification_characteristic_writes`], `ReadKinds` +/// from [`live_characteristic_reads`]. The predecessor of this gate recognized +/// only three write kinds (card types, controller, color) and did not classify +/// the read side at all, so it was simultaneously too narrow (a layer-3 name +/// rewrite or a layer-6 keyword grant reaching the entrant was invisible to a +/// name- or keyword-keyed population) and too wide (any recognized writer +/// escalated even against a board that reads no kind it writes). /// -/// KNOWN REMAINING GAP, same shape, other characteristics: a population keyed on -/// KEYWORD, NAME or P/T whose entrant has that characteristic rewritten by -/// another layer is still probed pre-layer — on the read side (the counted -/// filter) and on the write side (a type-writer's own affected filter keyed on -/// a rewritten characteristic) alike. Closing it needs the full -/// characteristic-kind matrix (which kind each `FilterProp` reads × which kind -/// each `ContinuousModification` writes) rather than the per-key projection of -/// it below. No printed pairing in the current corpus is known to exercise it -/// (claim not exhaustively verified — corpus absence is not what holds the -/// line; the classifier's wildcard-free match is, because a future -/// key-rewriting variant cannot be added without deciding this question). +/// SOUNDNESS: /// -/// CONTROLLER (CR 613.1b) and COLOR (CR 613.1e) were channels of the same -/// blindness and are closed here, not merely declared. A population keyed on -/// controller — "creatures you control" is the overwhelmingly common shape — -/// was probed pre-layer while a layer-2 `ChangeController` moved the entrant -/// between players' populations. A population keyed on color — "green -/// creatures" — was probed pre-layer while a layer-5 `AddColor` washed the -/// entrant into it, so a layer-7 count that runs after both still counted the -/// entrant's printed color. Controller is deliberately NOT a subset of the -/// paragraph above, because CR 109.3 states an object's controller is not one -/// of its characteristics, so "other characteristics" excludes it by -/// construction; color is a characteristic, but it is called out here because -/// it is now classified rather than deferred. All three are classified as -/// population KEYS by `modification_population_key_write`. The cost to the fast -/// path is nil in practice: the gate still requires the writer to REACH an -/// entrant AND a live population read to exist, so the boards pinned as fast -/// paths below — whose only effects write P/T — are untouched. +/// 1. If no live modification matches an entrant pre-layer, nothing applies to +/// it, so post-layer characteristics equal pre-layer ones and the pre-layer +/// population probe is exact. Chains cannot start without a pre-layer match. +/// 2. If M reaches the entrant and writes kind K, staleness requires some live +/// read to depend on K — through a counted magnitude, through a static's +/// enabling condition, or through another modification's affected filter. +/// `ReadKinds` unions all three unconditionally, so no live read of K can lie +/// outside it; kinds disjoint from every live read cannot flip any verdict. +/// The only read subtracted per modification is M's own effect's affected +/// filter, and CR 613.6 proves that one is not a read M can move: the +/// effect's affected set is determined when the effect first applies and +/// retained for its later layers, so it is fixed strictly before any of that +/// effect's own modifications run. Every other effect's affected filter stays +/// in, which is what the layer-3 rename channel rides on. +/// 3. Every uncertain form on either side maps to [`CharacteristicKinds::ALL`] +/// (unparsed conditions, arbitrary player predicates, recursion overflow, +/// `RemoveAllAbilities` stripping CDAs), so classification error can only +/// OVER-escalate: a full re-evaluation is slower, never wrong. +/// 4. STATED BOUNDARY, inherited unchanged. The reach probe below evaluates +/// affected filters against the previous FINAL state, while full evaluation +/// matches them at intermediate layer states; a count-thresholded affected +/// filter can therefore diverge from the probe in either direction. This +/// relation neither narrows nor widens that pre-existing limitation — it is a +/// property of the probe, not of the kind typing. Points 1-3 are a soundness +/// argument for the KIND RELATION, not a proof that the probe itself is +/// exact. /// -/// One further channel of the same blindness stays open, named explicitly -/// because neither the sentence above nor that classifier covers it: +/// Both classifiers are EXHAUSTIVE and wildcard-free, which is what actually +/// holds the line: a future `ContinuousModification` or `FilterProp` cannot be +/// added without deciding which kinds it writes and reads. /// -/// 1. GRANT CHAINS. `GrantAbility` / `GrantStaticAbility` / `AddStaticMode` / -/// `RemoveAllAbilities` all classify `false`, which is correct for the -/// predicate "does this write card types" but leaves a second-order path the -/// classifier cannot see: an effect that GRANTS a type-writing static (or -/// strips a CDA) reaches the entrant without itself writing a type. The -/// residual risk is small because `entered_object_blocks_incremental` -/// already escalates for entrants that carry their own static or CDA, but it -/// is not zero and it is not closed here. +/// Evaluation order is cheapest-first, and each stage can return "no escalation" +/// on its own: +/// +/// 1. `all_writes` — pure enum matches over the live modifications, no filter +/// work at all. +/// 2. `ReadKinds` — entrant-independent, computed once per flush, early-exiting +/// at ALL. +/// 3. their intersection — if empty, ZERO `matches_target_filter` calls happen. +/// This is the exit taken by the pinned fast paths, whose anthems write +/// `{PowerToughness}` while their boards read `{CardTypes, Controller}` or +/// `{ManaCost, Controller, CardTypes}`. +/// 4. only then, the CR 613.6 group attribution and the per-entrant +/// affected-filter reach probe, and only for the modifications that survived +/// stage 3. fn population_probe_blinded_by_entrant_characteristic_change( state: &GameState, entered_ids: &BTreeSet, active_effects: &[ActiveContinuousEffect], ) -> bool { - // Write side first: `modification_population_key_write` is a pure enum - // match, so the common board with no population-key writer reaching an - // entrant pays neither the active-effect read scan nor the static-source - // condition walk (a traversal Axis 2b repeats immediately after this gate). - let writer_reaches_entrant = active_effects.iter().any(|e| { - if modification_population_key_write(&e.modification).is_none() { + // Stage 1: pure enum matches. + let mut all_writes = CharacteristicKinds::EMPTY; + for e in active_effects { + all_writes = all_writes.union(modification_characteristic_writes(&e.modification)); + if all_writes.is_all() { + break; + } + } + if all_writes.is_empty() { + return false; + } + // Stage 2: entrant-independent read union, computed once. + let read_kinds = live_characteristic_reads(state, active_effects); + // Stage 3: global disjointness — no per-entrant filter matching at all. + if !all_writes.intersects(read_kinds.total) { + return false; + } + // Stage 3.5: only boards that survive stage 3 pay for attributing the + // affected-filter reads to their CR 613.6 retention groups. + let affected_reads = tally_affected_filter_reads(state, active_effects); + // Stage 4: per-entrant reach probe, restricted to modifications whose own + // write set intersects a live read that modification could actually move. + active_effects.iter().any(|e| { + let reads_e_can_move = read_kinds + .global + .union(affected_reads.excluding_own_group(state, e)); + if !modification_characteristic_writes(&e.modification).intersects(reads_e_can_move) { return false; } let ctx = FilterContext::from_source_with_controller(e.source_id, e.controller); entered_ids .iter() .any(|id| matches_target_filter(state, *id, &e.affected_filter, &ctx)) - }); - if !writer_reaches_entrant { - return false; - } - active_effects.iter().any(effect_reads_object_population) - || any_active_static_condition_reads_object_population(state) + }) } -/// CR 613: the population-keying value a modification rewrites, if any. +/// CR 613.1: which layer-writable characteristic kinds a modification WRITES. /// -/// Card types, controller and color are not the same kind of thing — CR 109.3 -/// states an object's controller is not one of its characteristics — and they -/// are written in three different layers. What unifies them here is the single -/// property this gate cares about: each is read by `TargetFilter` when a -/// population is counted, so a pre-layer probe of any of them can be stale by -/// the time the counting effect applies. All three are written inside CR 613, -/// so parameterizing on this axis stays within one rule section, and closing -/// the remaining kinds (KEYWORD, NAME, P/T) means adding variants here rather -/// than growing a sibling classifier. -enum PopulationKeyWrite { - /// CR 613.1d (layer 4): card types, subtypes or supertypes. - CardTypes, - /// CR 613.1b (layer 2): the object's controller. - Controller, - /// CR 613.1e (layer 5): the object's colors. - Color, +/// EXHAUSTIVE and wildcard-free over `ContinuousModification`, the write half of +/// the relation documented on +/// [`population_probe_blinded_by_entrant_characteristic_change`]. A variant that +/// writes several kinds returns their union; a variant whose effect cannot be +/// bounded returns [`CharacteristicKinds::ALL`]. +fn modification_characteristic_writes(m: &ContinuousModification) -> CharacteristicKinds { + modification_characteristic_writes_at(m, GRANTED_STATIC_WRITE_DEPTH) } -/// EXHAUSTIVE and wildcard-free over `ContinuousModification`, so a future -/// key-rewriting variant must be classified here at compile time rather than -/// silently reopening the Ashaya divergence. -fn modification_population_key_write(m: &ContinuousModification) -> Option { +/// Depth bound for `GrantStaticAbility` recursion: a granted static may itself +/// grant a static, and the granted `StaticDefinition` is owned data with no +/// structural bound, so the walk is capped and falls back to +/// [`CharacteristicKinds::ALL`] — conservative, per soundness point 3. +const GRANTED_STATIC_WRITE_DEPTH: u32 = 4; + +fn modification_characteristic_writes_at( + m: &ContinuousModification, + depth: u32, +) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; match m { + // ---- CR 613.1d (layer 4): typeline. ---- ContinuousModification::AddType { .. } | ContinuousModification::RemoveType { .. } | ContinuousModification::SetCardTypes { .. } @@ -3740,36 +4039,34 @@ fn modification_population_key_write(m: &ContinuousModification) -> Option Some(PopulationKeyWrite::CardTypes), - // CR 613.2a + CR 613.2c + CR 707.2: a copy effect (layer 1a) replaces - // the copiable values, - // card types among them, so it rewrites types just as surely as - // `SetCardTypes` does. - ContinuousModification::CopyValues { .. } | ContinuousModification::CopyChosen => { - Some(PopulationKeyWrite::CardTypes) - } - // CR 613.1b (layer 2): a control-change effect writes no characteristic - // at all (CR 109.3), but it moves the object between controller-keyed - // populations — "creatures you control" — that a later layer counts. - ContinuousModification::ChangeController => Some(PopulationKeyWrite::Controller), - // CR 613.1e (layer 5): a color-changing effect rewrites the very - // characteristic a color-keyed population reads ("green creatures", - // "each white permanent"), and layer 5 runs before the layer-7 count, - // so the pre-layer probe sees the entrant's printed color rather than - // its derived one. - ContinuousModification::SetColor { .. } - | ContinuousModification::AddColor { .. } - | ContinuousModification::AddChosenColor { .. } => Some(PopulationKeyWrite::Color), - // Everything else writes a characteristic that is not a population key. - // Enumerated explicitly (no wildcard) so a future key-rewriting variant - // forces a decision here. + | ContinuousModification::RemoveSupertype { .. } => CharacteristicKinds::CARD_TYPES, + // CR 305.7: setting a basic land type replaces the land's subtypes AND + // removes its abilities (the Song of the Dryads / Blood Moon class), so + // it is a genuine two-kind writer. + ContinuousModification::SetBasicLandType { .. } + | ContinuousModification::SetChosenBasicLandType => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) + } + + // ---- CR 613.1c (layer 3) + CR 612.8: name. ---- ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } - | ContinuousModification::SetChosenName - | ContinuousModification::AddPower { .. } + | ContinuousModification::SetChosenName => CharacteristicKinds::NAME_TEXT, + + // ---- CR 613.1e (layer 5): color. ---- + ContinuousModification::SetColor { .. } + | ContinuousModification::AddColor { .. } + | ContinuousModification::AddChosenColor { .. } => CharacteristicKinds::COLOR, + + // ---- CR 613.1b (layer 2): control. ---- + // CR 109.3: control is not a characteristic, but it moves the object + // between controller-keyed populations, which is the read this gate + // protects. + ContinuousModification::ChangeController => CharacteristicKinds::CONTROLLER, + + // ---- CR 613.1g + CR 613.4a-d (layer 7): power/toughness. ---- + ContinuousModification::AddPower { .. } | ContinuousModification::AddToughness { .. } | ContinuousModification::SetPower { .. } | ContinuousModification::SetToughness { .. } @@ -3779,30 +4076,73 @@ fn modification_population_key_write(m: &ContinuousModification) -> Option CharacteristicKinds::POWER_TOUGHNESS, + + // ---- CR 613.1f (layer 6): abilities. ---- + ContinuousModification::AddKeyword { .. } | ContinuousModification::RemoveKeyword { .. } | ContinuousModification::AddChosenKeyword | ContinuousModification::RemoveChosenKeyword + | ContinuousModification::AddDynamicKeyword { .. } | ContinuousModification::GrantAbility { .. } | ContinuousModification::GrantAllActivatedAbilitiesOf { .. } | ContinuousModification::GrantAllTriggeredAbilitiesOf { .. } | ContinuousModification::GrantTrigger { .. } | ContinuousModification::GrantReplacement { .. } - | ContinuousModification::GrantStaticAbility { .. } - | ContinuousModification::AddStaticMode { .. } - | ContinuousModification::RemoveAllAbilities + // CR 707.9a: retaining a printed ability through a copy still only + // rewrites the ability set, despite sitting in the copy layer. | ContinuousModification::RetainPrintedTriggerFromSource { .. } | ContinuousModification::RetainPrintedAbilityFromSource { .. } | ContinuousModification::RetainAllOtherAbilitiesFromSource - | ContinuousModification::AssignDamageFromToughness + // Battlefield application is a no-op (the real write happens on off-zone + // characteristics), but the truthful kind is still Abilities. + | ContinuousModification::AddKeywordWithDerivedCost { .. } + // Manufactures a `StaticDefinition` with a mode but no inner + // modifications, so there is nothing to recurse into. + | ContinuousModification::AddStaticMode { .. } => CharacteristicKinds::ABILITIES, + // CR 613.1f: grants a whole static, so it writes Abilities PLUS whatever + // the granted static's own modifications write. + ContinuousModification::GrantStaticAbility { definition } => definition + .modifications + .iter() + .fold(CharacteristicKinds::ABILITIES, |acc, inner| { + if acc.is_all() { + acc + } else { + acc.union(modification_characteristic_writes_at(inner, depth)) + } + }), + // CR 613.1f + CR 604.3: removing all abilities also strips + // characteristic-defining abilities, so it can second-order rewrite any + // kind the stripped CDA was defining. + ContinuousModification::RemoveAllAbilities => CharacteristicKinds::ALL, + + // ---- CR 613.1a + CR 707.9b (layer 1): copy effects. ---- + // A copy effect replaces the copiable values wholesale — name, mana + // cost, color, types, P/T and abilities — i.e. every kind except control + // (CR 109.3). `CopyChosen` applies as a no-op here because the real copy + // is installed as a latched `CopyValues`, but classifying it truthfully + // is free. + ContinuousModification::CopyValues { .. } | ContinuousModification::CopyChosen => { + CharacteristicKinds::ALL + } + // CR 202.1: the mana cost is only writable by copy effects; this variant + // is unreachable through the layer pipeline (its apply site asserts), but + // it is classified truthfully rather than as EMPTY. + ContinuousModification::RemoveManaCost => CharacteristicKinds::MANA_COST, + + // ---- Writes no characteristic. ---- + // CR 510.1a: combat damage ASSIGNMENT rules, not characteristics. + ContinuousModification::AssignDamageFromToughness | ContinuousModification::AssignDamageAsThoughUnblocked | ContinuousModification::AssignNoCombatDamage - | ContinuousModification::RemoveManaCost => None, + // CR 121.1 + CR 613.1: consumed as an entry replacement at resolution and + // never reached through the layer pipeline (its apply site asserts); + // counters are not characteristics in any case. + | ContinuousModification::AddCounterOnEnter { .. } + // CR 306.5b: loyalty is read from the copiable values, not written as a + // layer effect; the apply site asserts unreachable. + | ContinuousModification::SetStartingLoyalty { .. } => CharacteristicKinds::EMPTY, } } @@ -20620,4 +20960,238 @@ mod tests { "a live life-reading continuous static must force full escalation" ); } + + /// CR 613.1: the characteristic a layer exists to rewrite, or `None` for + /// layer 1 (CR 613.1a — copy effects rewrite the whole copiable-value set, + /// so no single kind is implied) and for the counter sublayer (CR 613.4c — + /// counters are not continuous modifications). + fn layer_implied_kind(layer: Layer) -> Option { + match layer { + // CR 613.1a: copy effects rewrite every copiable value at once. + Layer::Copy => None, + // CR 613.1b. + Layer::Control => Some(CharacteristicKinds::CONTROLLER), + // CR 613.1c. + Layer::Text => Some(CharacteristicKinds::NAME_TEXT), + // CR 613.1d. + Layer::Type => Some(CharacteristicKinds::CARD_TYPES), + // CR 613.1e. + Layer::Color => Some(CharacteristicKinds::COLOR), + // CR 613.1f. + Layer::Ability => Some(CharacteristicKinds::ABILITIES), + // CR 613.4a-d. + Layer::CharDef | Layer::SetPT | Layer::ModifyPT | Layer::SwitchPT => { + Some(CharacteristicKinds::POWER_TOUGHNESS) + } + // CR 613.4c: no `ContinuousModification` maps here. + Layer::CounterPT => None, + } + } + + /// Every `ContinuousModification` variant that reaches the layer pipeline. + /// + /// The six omitted variants are exactly the ones whose `layer()` arm is + /// `unreachable!()` — `RemoveManaCost`, `AddCounterOnEnter`, + /// `SetStartingLoyalty` (all consumed at copy resolution) and the three + /// combat-damage assignment rules (applied after layer evaluation). They + /// have no layer to be consistent with, so the check below cannot include + /// them; their kinds are still pinned by the exhaustive `match` in + /// `modification_characteristic_writes_at`. + fn every_layered_modification() -> Vec { + use crate::types::ability::{ + ColorChangeMode, CopiableValues, CostDerivation, ReplacementDefinition, + }; + use crate::types::card_type::{CardType, SubtypeSet}; + use crate::types::keywords::{CostBearingKeywordKind, DynamicKeywordKind}; + let dynamic = QuantityExpr::Fixed { value: 1 }; + vec![ + // ---- Layer 1 (CR 613.1a). ---- + ContinuousModification::CopyValues { + values: Box::new(CopiableValues { + name: "Copy".to_string(), + mana_cost: ManaCost::default(), + color: Vec::new(), + card_types: CardType::default(), + power: None, + toughness: None, + loyalty: None, + printed_loyalty: None, + keywords: Vec::new(), + abilities: Arc::new(Vec::new()), + trigger_definitions: Arc::new(Vec::new()), + replacement_definitions: Arc::new(Vec::new()), + static_definitions: Arc::new(Vec::new()), + }), + display_source: Default::default(), + printed_ref: None, + token_image_ref: None, + }, + ContinuousModification::CopyChosen, + ContinuousModification::SetName { + name: "N".to_string(), + }, + ContinuousModification::RetainPrintedTriggerFromSource { + source_trigger_index: 0, + }, + ContinuousModification::RetainPrintedAbilityFromSource { + source_ability_index: 0, + }, + ContinuousModification::RetainAllOtherAbilitiesFromSource, + // ---- Layer 2 (CR 613.1b). ---- + ContinuousModification::ChangeController, + // ---- Layer 3 (CR 613.1c). ---- + ContinuousModification::SetTextName { + name: "N".to_string(), + }, + ContinuousModification::SetChosenName, + // ---- Layer 4 (CR 613.1d). ---- + ContinuousModification::AddType { + core_type: CoreType::Creature, + }, + ContinuousModification::RemoveType { + core_type: CoreType::Creature, + }, + ContinuousModification::SetCardTypes { + core_types: vec![CoreType::Creature], + }, + ContinuousModification::AddSubtype { + subtype: "Bear".to_string(), + }, + ContinuousModification::RemoveSubtype { + subtype: "Bear".to_string(), + }, + ContinuousModification::RemoveAllSubtypes { + set: SubtypeSet::Creature, + }, + ContinuousModification::AddAllCreatureTypes, + ContinuousModification::AddAllBasicLandTypes, + ContinuousModification::AddAllLandTypes, + ContinuousModification::AddChosenSubtype { + kind: ChosenSubtypeKind::CreatureType, + }, + ContinuousModification::AddSupertype { + supertype: Supertype::Legendary, + }, + ContinuousModification::RemoveSupertype { + supertype: Supertype::Legendary, + }, + ContinuousModification::SetBasicLandType { + land_type: BasicLandType::Forest, + }, + ContinuousModification::SetChosenBasicLandType, + // ---- Layer 5 (CR 613.1e). ---- + ContinuousModification::SetColor { + colors: vec![ManaColor::Green], + }, + ContinuousModification::AddColor { + color: ManaColor::Green, + }, + ContinuousModification::AddChosenColor { + mode: ColorChangeMode::default(), + }, + // ---- Layer 6 (CR 613.1f). ---- + ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }, + ContinuousModification::RemoveKeyword { + keyword: Keyword::Flying, + }, + ContinuousModification::AddChosenKeyword, + ContinuousModification::RemoveChosenKeyword, + ContinuousModification::AddDynamicKeyword { + kind: DynamicKeywordKind::Annihilator, + value: dynamic.clone(), + }, + ContinuousModification::AddKeywordWithDerivedCost { + kind: CostBearingKeywordKind::Foretell, + derivation: CostDerivation::ManaCostReducedBy(ManaCost::default()), + }, + ContinuousModification::GrantAbility { + definition: Box::new(AbilityDefinition::new( + AbilityKind::Activated, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + )), + }, + ContinuousModification::GrantAllActivatedAbilitiesOf { + source: TargetFilter::SelfRef, + cap: None, + }, + ContinuousModification::GrantAllTriggeredAbilitiesOf { + source: TargetFilter::SelfRef, + }, + ContinuousModification::GrantTrigger { + trigger: Box::new(TriggerDefinition::new(TriggerMode::ChangesZone)), + }, + ContinuousModification::GrantReplacement { + replacement: Box::new(ReplacementDefinition::new(ReplacementEvent::DamageDone)), + }, + ContinuousModification::RemoveAllAbilities, + ContinuousModification::AddStaticMode { + mode: StaticMode::Continuous, + }, + ContinuousModification::GrantStaticAbility { + definition: Box::new(StaticDefinition::new(StaticMode::Continuous)), + }, + // ---- Layer 7 (CR 613.4a-d). ---- + ContinuousModification::SetDynamicPower { + value: dynamic.clone(), + }, + ContinuousModification::SetDynamicToughness { + value: dynamic.clone(), + }, + ContinuousModification::SetPower { value: 1 }, + ContinuousModification::SetToughness { value: 1 }, + ContinuousModification::SetPowerDynamic { + value: dynamic.clone(), + }, + ContinuousModification::SetToughnessDynamic { + value: dynamic.clone(), + }, + ContinuousModification::AddPower { value: 1 }, + ContinuousModification::AddToughness { value: 1 }, + ContinuousModification::AddDynamicPower { + value: dynamic.clone(), + }, + ContinuousModification::AddDynamicToughness { value: dynamic }, + ContinuousModification::SwitchPowerToughness, + ] + } + + /// CR 613.1: a modification's declared LAYER and its declared WRITE KIND + /// are two views of the same fact, so they must agree — an effect applied + /// in the type-changing layer must report that it writes card types, and so + /// on. This is the second tripwire on `modification_characteristic_writes`: + /// the exhaustive wildcard-free `match` catches a variant that was never + /// classified, and this catches one that was classified into the WRONG + /// kind (which the gate would silently under-escalate on). + /// + /// Both directions are only a containment, not an equality: a modification + /// may write MORE than its layer implies. CR 305.7's `SetBasicLandType` + /// sits in layer 4 but also strips abilities, and CR 613.1f's + /// `RemoveAllAbilities` sits in layer 6 but is classified `ALL` because it + /// turns off characteristic-defining abilities (CR 604.3). + #[test] + fn modification_write_kinds_agree_with_their_layer() { + for m in every_layered_modification() { + let writes = modification_characteristic_writes(&m); + let layer = m.layer(); + let Some(implied) = layer_implied_kind(layer) else { + // Layer 1: no single implied kind, but a copy effect always + // rewrites SOMETHING. + assert!( + !writes.is_empty(), + "{m:?} is a layer-1 copy effect but was classified as writing nothing" + ); + continue; + }; + assert!( + writes.contains(implied), + "{m:?} is applied in {layer:?} but its write kinds {writes:?} do not \ + include the characteristic that layer exists to rewrite ({implied:?})" + ); + } + } } diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 8bc7593492..d9ea4186dc 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -11,7 +11,8 @@ use crate::game::filter::{ matches_target_filter, matches_target_filter_on_attack_declaration_record, matches_target_filter_on_counter_added_record, matches_target_filter_on_damage_record_source, matches_target_filter_on_zone_change_record, player_matches_target_filter_in_state, - spell_record_matches_filter, type_filter_matches, FilterContext, + shared_quality_characteristic_reads, spell_record_matches_filter, + target_filter_characteristic_reads_at, type_filter_matches, CharacteristicKinds, FilterContext, }; use crate::game::speed::effective_speed; use crate::types::ability::{ @@ -999,6 +1000,267 @@ fn quantity_ref_uses_object_count(qty: &QuantityRef) -> bool { } } +/// CR 613.1: Which layer-writable characteristic kinds does this magnitude read? +/// +/// Structural twin of `quantity_expr_uses_object_count`: that predicate answers +/// "can board MEMBERSHIP change this magnitude"; this one answers "which +/// layer-writable CHARACTERISTICS does it read". Both are needed — a count of +/// tapped permanents is population-sensitive but reads no layer-written kind. +pub(crate) fn quantity_expr_characteristic_reads_at( + expr: &QuantityExpr, + depth: u32, +) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; + match expr { + QuantityExpr::Fixed { .. } => CharacteristicKinds::EMPTY, + QuantityExpr::Ref { qty } => quantity_ref_characteristic_reads(qty, depth), + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Offset { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Multiply { inner, .. } => { + quantity_expr_characteristic_reads_at(inner, depth) + } + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => { + exprs.iter().fold(CharacteristicKinds::EMPTY, |acc, e| { + if acc.is_all() { + acc + } else { + acc.union(quantity_expr_characteristic_reads_at(e, depth)) + } + }) + } + QuantityExpr::UpTo { max } => quantity_expr_characteristic_reads_at(max, depth), + QuantityExpr::Power { exponent, .. } => { + quantity_expr_characteristic_reads_at(exponent, depth) + } + QuantityExpr::Difference { left, right } => { + quantity_expr_characteristic_reads_at(left, depth) + .union(quantity_expr_characteristic_reads_at(right, depth)) + } + } +} + +/// CR 613.1: Leaf classification for [`quantity_expr_characteristic_reads_at`]. +/// EXHAUSTIVE and wildcard-free over `QuantityRef`, arm-for-arm with +/// `quantity_ref_uses_object_count`, so a new variant forces both decisions. +/// +/// Two payload rules apply on top of each arm's intrinsic reads: +/// 1. every arm whose count is taken over a LIVE object filter recurses into +/// that filter, because the filter's own predicates read characteristics; +/// 2. every arm carrying a `ControllerRef` unions +/// [`CharacteristicKinds::CONTROLLER`], because CR 613.1b can move objects +/// across the scope the reference is asking about. +/// +/// Arms that read a FROZEN per-turn history record (sacrificed / attacked / +/// zone-change / damage / token-creation journals) read no live characteristic: +/// those records store the object's characteristics as of the recorded event, so +/// no later layer write can change the tally. They classify EMPTY, and their +/// embedded filters are deliberately NOT recursed. +fn quantity_ref_characteristic_reads(qty: &QuantityRef, depth: u32) -> CharacteristicKinds { + match qty { + // ---- Live object censuses: recurse the counted filter. ---- + QuantityRef::ObjectCount { filter } + | QuantityRef::CountersOnObjects { filter, .. } + | QuantityRef::EnteredThisTurn { filter } + // CR 403.3: the tally is a per-turn journal, but its filter is matched + // against LIVE objects, so the filter's reads count. + | QuantityRef::BattlefieldEntriesThisTurn { filter, .. } + // CR 122.1: counter kinds are not layer-written; only the filter reads. + | QuantityRef::DistinctCounterKindsAmong { filter } => { + target_filter_characteristic_reads_at(filter, depth) + } + // CR 608.2c: tracked-set members are addressed by identity and matched live. + QuantityRef::FilteredTrackedSetSize { filter, .. } => { + target_filter_characteristic_reads_at(filter, depth) + } + // CR 201.2 + CR 603.4: dedupe key is a characteristic read of its own. + QuantityRef::ObjectCountDistinct { filter, qualities } => qualities + .iter() + .fold( + target_filter_characteristic_reads_at(filter, depth), + |acc, q| acc.union(shared_quality_characteristic_reads(q)), + ), + // CR 109.3 + CR 205.3m: grouping key is a characteristic read. + QuantityRef::ObjectCountBySharedQuality { + filter, quality, .. + } => target_filter_characteristic_reads_at(filter, depth) + .union(shared_quality_characteristic_reads(quality)), + // CR 202.3: aggregated object property plus the scanned filter. + QuantityRef::Aggregate { + property, filter, .. + } => object_property_characteristic_reads(property) + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 109.5 + CR 613.1b: per-player partition of a live census. + QuantityRef::ControlledByEachPlayer { filter, .. } => CharacteristicKinds::CONTROLLER + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 106.1 + CR 109.1: distinct colors over a live census. + QuantityRef::DistinctColorsAmongPermanents { filter } => CharacteristicKinds::COLOR + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 205.2a / CR 205.3: the object-filter source scans live objects; the + // zone / linked-exile / tracked-set sources do not read a live filter. + QuantityRef::DistinctCardTypes { source } + | QuantityRef::DistinctSubtypes { source, .. } => { + CharacteristicKinds::CARD_TYPES.union(match source { + CardTypeSetSource::Objects { filter, .. } => { + target_filter_characteristic_reads_at(filter, depth) + } + CardTypeSetSource::Zone { .. } + | CardTypeSetSource::ExiledBySource + | CardTypeSetSource::TrackedSet { .. } => CharacteristicKinds::EMPTY, + }) + } + // CR 604.3: a zone census, filtered by typeline and by an optional + // filter, scoped by controller. + QuantityRef::ZoneCardCount { + card_types, filter, .. + } => { + let mut kinds = CharacteristicKinds::CONTROLLER; + if !card_types.is_empty() { + kinds = kinds.union(CharacteristicKinds::CARD_TYPES); + } + filter.as_ref().map_or(kinds, |f| { + kinds.union(target_filter_characteristic_reads_at(f, depth)) + }) + } + // CR 700.8: party reads Cleric/Rogue/Warrior/Wizard creature types among + // the scoped player's creatures — CR 702.73a Changeling applies. + QuantityRef::PartySize { .. } => CharacteristicKinds::CARD_TYPES + .union(CharacteristicKinds::ABILITIES) + .union(CharacteristicKinds::CONTROLLER), + // CR 305.6: distinct basic land types among the referenced player's + // lands. Carries a `ControllerRef` (payload rule 2). + QuantityRef::BasicLandTypeCount { .. } => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::CONTROLLER) + } + // CR 700.5: devotion counts mana symbols in the mana costs of the + // permanents the scoped player controls. + QuantityRef::Devotion { .. } => { + CharacteristicKinds::MANA_COST.union(CharacteristicKinds::CONTROLLER) + } + // CR 903.3d: mana value of a commander, scoped by a `ControllerRef`. + QuantityRef::CommanderManaValue { .. } => { + CharacteristicKinds::MANA_COST.union(CharacteristicKinds::CONTROLLER) + } + + // ---- Single-object characteristic reads. ---- + // CR 208.1 / CR 209.1. + QuantityRef::Power { .. } | QuantityRef::Toughness { .. } => { + CharacteristicKinds::POWER_TOUGHNESS + } + // CR 607.2b: power of a card in exile, read the same way. + QuantityRef::ExiledCardPower { .. } => CharacteristicKinds::POWER_TOUGHNESS, + // CR 202.3 / CR 107.4a. + QuantityRef::ObjectManaValue { .. } + | QuantityRef::ManaSymbolsInManaCost { .. } + | QuantityRef::SelfManaValue => CharacteristicKinds::MANA_COST, + // CR 202.3 + CR 115.1: mana value of the object chosen for this ref's own + // target slot, whose candidates are `filter`. + QuantityRef::TargetObjectManaValue { filter } => CharacteristicKinds::MANA_COST + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 105.1 + CR 105.2. + QuantityRef::ObjectColorCount { .. } => CharacteristicKinds::COLOR, + // CR 201.1 + CR 201.2. + QuantityRef::ObjectNameWordCount { .. } => CharacteristicKinds::NAME_TEXT, + // CR 205.4a + CR 205.2a + CR 205.3: supertypes + card types + subtypes. + QuantityRef::ObjectTypelineComponentCount { .. } => CharacteristicKinds::CARD_TYPES, + // CR 608.2c: aggregates an object property over an identity-addressed set. + QuantityRef::TrackedSetAggregate { property, .. } => { + object_property_characteristic_reads(property) + } + // CR 122.1f + CR 109.4: reads the controller of the parent target. + QuantityRef::TargetControllerCounter { .. } => CharacteristicKinds::CONTROLLER, + // CR 400.7 + CR 613.1b: look-back attachment snapshot, optionally scoped + // by a `ControllerRef` (payload rule 2). + QuantityRef::AttachmentsOnLeavingObject { .. } => CharacteristicKinds::CONTROLLER, + + // ---- Reads no layer-writable characteristic. ---- + // Player-level totals, counters (CR 122.1 — counters are not + // characteristics), payments, choices, and the FROZEN per-turn / + // per-game history journals described in the doc comment. Enumerated + // explicitly (no wildcard). + QuantityRef::HandSize { .. } + | QuantityRef::LifeTotal { .. } + | QuantityRef::GraveyardSize { .. } + | QuantityRef::LifeAboveStarting + | QuantityRef::StartingLifeTotal + | QuantityRef::TriggeringDiscoverValue + | QuantityRef::TriggeringScryLookCount + | QuantityRef::TriggeringScryBottomCount + | QuantityRef::PlayerCount { .. } + | QuantityRef::EventContextPlayerCount { .. } + | QuantityRef::CountersOn { .. } + | QuantityRef::PlayerCounter { .. } + | QuantityRef::Variable { .. } + // Digital-only Alchemy counter-like value; no layer writes it. + | QuantityRef::Intensity { .. } + | QuantityRef::TargetZoneCardCount { .. } + | QuantityRef::CardsExiledBySource + | QuantityRef::TrackedSetSize + | QuantityRef::ExiledFromHandThisResolution + | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::LifeLostThisTurn { .. } + | QuantityRef::UnspentMana { .. } + | QuantityRef::Speed { .. } + | QuantityRef::EventContextAmount + | QuantityRef::EventContextSourceCostX + | QuantityRef::EventContextSourceModesChosen + // CR 117.1: spell-cast journals store each spell's cast-time + // characteristics. + | QuantityRef::SpellsCastThisTurn { .. } + | QuantityRef::SpellsCastThisGame { .. } + // CR 701.16a: sacrifice-time characteristics. + | QuantityRef::SacrificedThisTurn { .. } + | QuantityRef::CrimesCommittedThisTurn + | QuantityRef::BendTypesThisTurn + | QuantityRef::LifeGainedThisTurn { .. } + | QuantityRef::CardsDrawnThisTurn { .. } + | QuantityRef::LandsPlayedThisTurn { .. } + | QuantityRef::TurnsTaken + // CR 400.7 + CR 700.4: zone-change records store last-known information. + | QuantityRef::ZoneChangeCountThisTurn { .. } + | QuantityRef::ZoneChangeAggregateThisTurn { .. } + // CR 120.1: damage records store the amount actually dealt. + | QuantityRef::DamageDealtThisTurn { .. } + | QuantityRef::ChosenNumber + // CR 508.1: declaration-time attacker snapshots. + | QuantityRef::AttackedThisTurn { .. } + | QuantityRef::DescendedThisTurn + | QuantityRef::LoyaltyAbilitiesActivatedThisTurn { .. } + | QuantityRef::SpellsCastLastTurn + // CR 122.1: counter-addition journal. + | QuantityRef::CounterAddedThisTurn { .. } + | QuantityRef::CardsDiscardedThisTurn { .. } + // CR 111.2: creation-time token characteristics. + | QuantityRef::TokensCreatedThisTurn { .. } + | QuantityRef::PlayerActionsThisTurn { .. } + | QuantityRef::DungeonsCompleted + | QuantityRef::CostXPaid + | QuantityRef::KickerCount + | QuantityRef::AdditionalCostPaymentCount + | QuantityRef::AdditionalCostPaymentCountFor { .. } + | QuantityRef::ConvokedCreatureCount + | QuantityRef::TimesCostPaidThisResolution + | QuantityRef::ManaSpentToCast { .. } + // CR 903.4: color identity is fixed by the printed card. + | QuantityRef::ColorsInCommandersColorIdentity + | QuantityRef::CommanderCastFromCommandZoneCount + | QuantityRef::VoteCount { .. } => CharacteristicKinds::EMPTY, + } +} + +/// CR 208.1 + CR 209.1 + CR 202.3 + CR 107.4a: which characteristic an +/// aggregated object property reads. +fn object_property_characteristic_reads(property: &ObjectProperty) -> CharacteristicKinds { + match property { + ObjectProperty::Power | ObjectProperty::Toughness => CharacteristicKinds::POWER_TOUGHNESS, + ObjectProperty::ManaValue | ObjectProperty::ManaSymbolCount(_) => { + CharacteristicKinds::MANA_COST + } + } +} + /// CR 611.3a + CR 700.5: ENTRY-AWARE narrowing for a population-sensitive /// magnitude. `quantity_expr_uses_object_count` proves an effect's magnitude /// *can* read board population; this proves a SPECIFIC entering object can diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 951251a170..6d84f56812 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -10514,12 +10514,13 @@ mod tests { /// before the layer-7c count applies, so a pre-layer probe would see a /// colorless entrant, keep the incremental arm, and leave pre-existing /// Bears at a stale 4/4 where the correct CR 613 board is 5/5. - /// `modification_population_key_write` classifies the color writers as - /// `PopulationKeyWrite::Color`, so the gate escalates and the two boards + /// `modification_characteristic_writes` classifies the color writers as + /// `CharacteristicKinds::COLOR` and the counted filter reads the same + /// kind, so the sets intersect, the gate escalates and the two boards /// agree. /// /// This is the discriminating fixture for that channel: revert the - /// `Color` arm of the classifier and the escalation assertion fails; keep + /// `COLOR` arm of the classifier and the escalation assertion fails; keep /// the arm but break the escalation plumbing and the identity assertion /// fails on the Bears' derived power/toughness. #[test] @@ -10664,8 +10665,9 @@ mod tests { /// probe fires; but the type-writer makes the entrant a land in layer 4 /// and the count crosses the anthem's threshold, changing PRE-EXISTING /// recipients. The blindness disjunct's condition channel - /// (`any_active_static_condition_reads_object_population`) MUST escalate - /// this entry, and the board must match a forced-Full pass (GateBear + /// (`static_condition_characteristic_reads`, unioned into `ReadKinds` by + /// `live_characteristic_reads`) MUST escalate this entry, and the board + /// must match a forced-Full pass (GateBear /// 4/4, not stale 2/2). #[test] fn condition_gated_anthem_entry_escalates_when_entrant_types_rewritten() { @@ -10793,9 +10795,9 @@ mod tests { /// hands the entrant to the anthem's controller and the count goes /// 2 -> 3, which moves PRE-EXISTING recipients. CR 109.3 puts controller /// outside an object's characteristics, so this is not reachable by the - /// card-type disjunct — `modification_population_key_write` has to - /// classify `ChangeController` as a population-key write in its own - /// right for this entry to escalate. + /// card-type disjunct — `modification_characteristic_writes` has to + /// classify `ChangeController` as a `CharacteristicKinds::CONTROLLER` + /// write in its own right for this entry to escalate. #[test] fn controller_change_entry_escalates_when_population_is_controller_keyed() { let (normal, escalated, forced) = flush_entry_and_forced( @@ -11305,6 +11307,739 @@ mod tests { ); } + // ------------------------------------------------------------------ + // Read/write-kind relation fixtures (CR 613.1). + // + // Each board mirrors the color-wash fixture's shape: one enchantment + // carrying TWO Continuous static definitions, a vanilla entrant (so + // `entered_object_blocks_incremental` stays quiet), and a divergence + // that surfaces in power/toughness (all `assert_pt_identical` compares). + // + // Non-vacuity invariants, checked per fixture: the entrant must NOT + // satisfy the population-sensitive read PRE-layer (otherwise Axis 2a + // escalates and the kind relation goes untested), it must satisfy it + // POST-layer, and the writer's layer must run strictly before the + // reading layer. + // ------------------------------------------------------------------ + + /// Install a battlefield enchantment carrying `defs` as both its base + /// and its live static definitions, matching how the pre-existing + /// escalation boards install anthems. + fn install_static_enchantment( + state: &mut GameState, + card_id: u64, + name: &str, + defs: Vec, + ) -> ObjectId { + let id = create_object( + state, + CardId(card_id), + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&id).unwrap(); + o.base_static_definitions = Arc::new(defs.clone()); + o.static_definitions = defs.into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + id + } + + /// Create a vanilla 2/2 creature with an explicit name and color set. + fn add_relation_bear( + state: &mut GameState, + card_id: u64, + name: &str, + colors: Vec, + ) -> ObjectId { + let id = create_object( + state, + CardId(card_id), + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&id).unwrap(); + o.base_power = Some(2); + o.base_toughness = Some(2); + o.power = Some(2); + o.toughness = Some(2); + o.base_card_types.core_types = vec![CoreType::Creature]; + o.card_types.core_types = vec![CoreType::Creature]; + o.base_color.clone_from(&colors); + o.color = colors; + id + } + + /// Sorted `(power, toughness)` of every battlefield object whose name + /// starts with `prefix`. + fn pts_named(state: &GameState, prefix: &str) -> Vec<(Option, Option)> { + let mut pts: Vec<(Option, Option)> = state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .filter(|o| o.name.starts_with(prefix)) + .map(|o| (o.power, o.toughness)) + .collect(); + pts.sort(); + pts + } + + /// A `Continuous` static definition over `affected` applying `mods`. + fn continuous_static( + affected: TargetFilter, + mods: Vec, + ) -> crate::types::ability::StaticDefinition { + let mut def = crate::types::ability::StaticDefinition::new( + crate::types::statics::StaticMode::Continuous, + ); + def.affected = Some(affected); + def.modifications = mods; + def + } + + /// `AddDynamicPower` + `AddDynamicToughness` off one `ObjectCount`. + fn dynamic_pt_count( + counted: TargetFilter, + ) -> Vec { + use crate::types::ability::ContinuousModification; + let count = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { filter: counted }, + }; + vec![ + ContinuousModification::AddDynamicPower { + value: count.clone(), + }, + ContinuousModification::AddDynamicToughness { value: count }, + ] + } + + /// (2.1) KEYWORD channel (CR 613.1f). A layer-6 `AddKeyword` reaches the + /// entrant while the anthem's magnitude counts creatures WITH that + /// keyword. Pre-layer the entrant has no flying, so every membership + /// probe reports "no perturbation"; post-layer it does, so the count + /// moves 2 → 3 and the pre-existing FlyBears go 4/4 → 5/5. + /// + /// Discriminating for BOTH halves of the relation: revert the + /// `AddKeyword` family to EMPTY on the write side, or the `WithKeyword` + /// family to EMPTY on the read side, and the escalation assertion fails. + fn flying_count_anthem_with_keyword_grant_board() -> GameState { + use crate::types::ability::ContinuousModification; + use crate::types::Keyword; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 600 + i, &format!("FlyBear{i}"), vec![]); + } + let grant = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }], + ); + let flying_creatures = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::WithKeyword { + value: Keyword::Flying, + }], + ..Default::default() + }); + let count = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + dynamic_pt_count(flying_creatures), + ); + install_static_enchantment(&mut state, 610, "Flying Count Anthem", vec![grant, count]); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn keyword_grant_entry_escalates_when_population_is_keyword_keyed() { + let (normal, escalated, forced) = + flush_entry_and_forced(flying_count_anthem_with_keyword_grant_board, |s| { + add_colorless_creature_entry(s, 611) + }); + assert!( + escalated, + "a layer-6 keyword grant reaching the entrant moves a keyword-keyed \ + count — the entry must escalate to a full re-evaluation" + ); + assert_eq!( + pts_named(&forced, "FlyBear"), + vec![(Some(5), Some(5)); 2], + "full pass counts the entrant once it has flying — correct CR 613 board" + ); + assert_eq!( + pts_named(&normal, "FlyBear"), + pts_named(&forced, "FlyBear"), + "escalated entry must derive the same board as full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "keyword-keyed escalation"); + } + + /// (2.2) POWER/TOUGHNESS channel (CR 613.1g + CR 613.4b/c). A layer-7b + /// `SetToughness` reaches the entrant while the anthem's magnitude counts + /// creatures with toughness ≥ 4 at layer 7c. + /// + /// Because power/toughness is ONE kind, a P/T-keyed count anthem is + /// itself a P/T writer and would satisfy the relation on its own reach. + /// The count anthem's affected set is therefore "creatures you control" + /// while the entrant enters under the OPPONENT (mirroring + /// `controller_theft_count_anthem_board`), which makes `SetToughness` + /// the only entrant-reaching writer and gives the revert-check SetPT-arm + /// granularity rather than whole-kind granularity. + fn tough_count_anthem_with_set_toughness_board() -> GameState { + use crate::types::ability::{ContinuousModification, PtStat, PtValueScope}; + use crate::types::ControllerRef; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 620 + i, &format!("ToughBear{i}"), vec![]); + } + // Layer 7b, controller-agnostic: reaches the opponent's entrant too. + let setter = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::SetToughness { value: 4 }], + ); + let tough_creatures = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::PtComparison { + stat: PtStat::Toughness, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 4 }, + }], + ..Default::default() + }); + // Layer 7c, "creatures you control": deliberately EXCLUDES the + // opponent-controlled entrant. + let count = continuous_static( + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: Some(ControllerRef::You), + ..Default::default() + }), + vec![ContinuousModification::AddDynamicPower { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: tough_creatures, + }, + }, + }], + ); + install_static_enchantment( + &mut state, + 630, + "Toughness Count Anthem", + vec![setter, count], + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn pt_change_entry_escalates_when_population_is_pt_keyed() { + let (normal, escalated, forced) = + flush_entry_and_forced(tough_count_anthem_with_set_toughness_board, |s| { + add_colorless_creature_entry_under(s, 631, PlayerId(1)) + }); + assert!( + escalated, + "a layer-7b toughness set reaching the entrant moves a P/T-keyed \ + count — the entry must escalate to a full re-evaluation" + ); + assert_eq!( + pts_named(&forced, "ToughBear"), + vec![(Some(5), Some(4)); 2], + "full pass counts the entrant once its toughness is set to 4" + ); + assert_eq!( + pts_named(&normal, "ToughBear"), + pts_named(&forced, "ToughBear"), + "escalated entry must derive the same board as full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "P/T-keyed escalation"); + } + + /// (2.3) NAME channel (CR 613.1c + CR 612.8). A layer-3 `SetTextName` + /// reaches the entrant while the anthem's magnitude counts creatures by + /// name. Pre-layer the entrant is "Insect" and matches nothing; + /// post-layer it is a third "Doppelganger" and the pre-existing pair + /// goes 4/4 → 5/5. + fn named_count_anthem_with_name_rewrite_board() -> GameState { + use crate::types::ability::ContinuousModification; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 640 + i, "Doppelganger", vec![]); + } + let rename = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::SetTextName { + name: "Doppelganger".to_string(), + }], + ); + let doppelgangers = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::Named { + name: "Doppelganger".to_string(), + }], + ..Default::default() + }); + let count = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + dynamic_pt_count(doppelgangers), + ); + install_static_enchantment( + &mut state, + 650, + "Doppelganger Count Anthem", + vec![rename, count], + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn name_change_entry_escalates_when_population_is_name_keyed() { + let (normal, escalated, forced) = + flush_entry_and_forced(named_count_anthem_with_name_rewrite_board, |s| { + add_colorless_creature_entry(s, 651) + }); + assert!( + escalated, + "a layer-3 name rewrite reaching the entrant moves a name-keyed \ + count — the entry must escalate to a full re-evaluation" + ); + // The two pre-existing Doppelgangers are the only 2/2 printed bodies; + // the entrant is a 1/1, so it cannot be confused with them. + let bears = |s: &GameState| { + let mut pts: Vec<(Option, Option)> = s + .battlefield + .iter() + .filter_map(|id| s.objects.get(id)) + .filter(|o| o.base_power == Some(2) && o.base_toughness == Some(2)) + .map(|o| (o.power, o.toughness)) + .collect(); + pts.sort(); + pts + }; + assert_eq!( + bears(&forced), + vec![(Some(5), Some(5)); 2], + "full pass counts the renamed entrant — correct CR 613 board" + ); + assert_eq!( + bears(&normal), + bears(&forced), + "escalated entry must derive the same board as full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "name-keyed escalation"); + } + + /// (2.4) NARROWING NEGATIVE. A layer-6 keyword grant reaches the entrant, + /// but nothing live READS abilities: the only population read is an + /// artifact count (CR 613.1d) and both affected filters are plain + /// typelines. `{Abilities, PowerToughness} ∩ {CardTypes} = ∅`, so the + /// entry must stay on the incremental fast path — a board the previous + /// one-sided gate had no way to keep there, since it escalated on any + /// recognized writer plus any population read. + /// + /// Revert direction: make `AddKeyword` write `ALL` and the assertion + /// flips, which is what proves the narrowing is the classifier's doing. + fn artifact_count_anthem_with_keyword_grant_board() -> GameState { + use crate::types::ability::ContinuousModification; + use crate::types::Keyword; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 660 + i, &format!("DisjointBear{i}"), vec![]); + } + // One pre-existing artifact so the counted population is non-empty. + let relic = create_object( + &mut state, + CardId(662), + PlayerId(0), + "Relic".to_string(), + Zone::Battlefield, + ); + { + let o = state.objects.get_mut(&relic).unwrap(); + o.base_card_types.core_types = vec![CoreType::Artifact]; + o.card_types.core_types = vec![CoreType::Artifact]; + } + let grant = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }], + ); + let artifacts = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + ..Default::default() + }); + let count = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + dynamic_pt_count(artifacts), + ); + install_static_enchantment( + &mut state, + 670, + "Artifact Count With Grant", + vec![grant, count], + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn keyword_grant_entry_stays_incremental_when_population_reads_are_disjoint() { + use crate::types::Keyword; + let (normal, escalated, forced) = + flush_entry_and_forced(artifact_count_anthem_with_keyword_grant_board, |s| { + add_colorless_creature_entry(s, 671) + }); + assert!( + !escalated, + "a keyword grant cannot move an artifact-keyed count — \ + {{Abilities}} ∩ {{CardTypes}} = ∅, so the entry must stay incremental" + ); + // Non-vacuity: the grant really does reach the entrant, so the + // narrowing is the kind relation's doing and not a missed match. + let entrant = normal + .battlefield + .iter() + .filter_map(|id| normal.objects.get(id)) + .find(|o| o.name == "Insect") + .expect("entrant on battlefield"); + assert!( + entrant.keywords.contains(&Keyword::Flying), + "the entrant must actually be a recipient of the layer-6 grant" + ); + assert_pt_identical(&normal, &forced, "disjoint-kind non-escalation"); + } + + /// (2.5) AFFECTED-FILTER READ CHANNEL. This board has NO dynamic + /// magnitude and NO static condition — the ONLY name read on it lives in + /// another modification's AFFECTED FILTER. A layer-3 `SetTextName` + /// renames the entering artifact to "Doppelganger", which adds that name + /// to the reference set of the buff's `DifferentNameFrom` filter and + /// therefore REMOVES the pre-existing Doppelganger creature from the + /// buff's affected set (5/5 → 2/2). + /// + /// Pre-layer the entering Treasure does not match the reference filter + /// (which is keyed on the name it does not yet have), so Axis 2a's + /// per-entrant narrowing reports "no perturbation" and only the kind + /// relation can catch this. Revert direction: drop the affected-filter + /// channel from `live_characteristic_reads` and `ReadKinds` becomes + /// empty, so the gate exits at stage 2 and the board goes stale. + fn name_rewrite_with_affected_filter_read_board() -> GameState { + use crate::types::ability::ContinuousModification; + let mut state = setup(); + add_relation_bear(&mut state, 680, "Doppelganger", vec![]); + let rename = continuous_static( + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + ..Default::default() + }), + vec![ContinuousModification::SetTextName { + name: "Doppelganger".to_string(), + }], + ); + // "each artifact you control named Doppelganger" — the entrant only + // joins this set AFTER layer 3 renames it. + let named_artifacts = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + properties: vec![FilterProp::Named { + name: "Doppelganger".to_string(), + }], + ..Default::default() + }); + let buff = continuous_static( + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::DifferentNameFrom { + filter: Box::new(named_artifacts), + }], + ..Default::default() + }), + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + ); + install_static_enchantment(&mut state, 690, "Different Name Buff", vec![rename, buff]); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn name_rewrite_entry_escalates_through_affected_filter_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(name_rewrite_with_affected_filter_read_board, |s| { + add_artifact_entry(s, 691) + }); + assert!( + escalated, + "a layer-3 rename feeding another static's AFFECTED FILTER must \ + escalate — the affected-filter read channel is unconditional" + ); + let bear = |s: &GameState| { + s.battlefield + .iter() + .filter_map(|id| s.objects.get(id)) + .find(|o| o.base_power == Some(2)) + .map(|o| (o.power, o.toughness)) + .expect("pre-existing Doppelganger on battlefield") + }; + assert_eq!( + bear(&forced), + (Some(2), Some(2)), + "full pass drops the pre-existing Doppelganger out of the buff \ + once the renamed artifact joins the reference set" + ); + assert_eq!( + bear(&normal), + bear(&forced), + "escalated entry must derive the same board as full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "affected-filter read channel"); + } + + /// (2.6) CONDITION READ CHANNEL through the walker's NET-NEW recursion. + /// The buff is gated by `RecipientMatchesFilter` over a keyword filter — + /// a condition whose MEMBERSHIP twin + /// (`static_condition_uses_object_population`) answers `false`, so Axis + /// 2b never looks at it. Only `static_condition_characteristic_reads` + /// recursing into that filter puts `Abilities` into `ReadKinds`, which is + /// what makes the layer-6 grant reaching the entrant intersect. + /// + /// The DISCRIMINATING assertion here is the escalation bit: revert the + /// `SourceMatchesFilter` / `RecipientMatchesFilter` arms to EMPTY and it + /// flips. The identity assertion is the usual under-escalation tripwire, + /// not an independent proof of divergence. + fn condition_keyed_buff_with_keyword_grant_board() -> GameState { + use crate::types::ability::ContinuousModification; + use crate::types::{Keyword, StaticCondition}; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 700 + i, &format!("CondBear{i}"), vec![]); + } + let grant = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }], + ); + let mut buff = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + ); + buff.condition = Some(StaticCondition::RecipientMatchesFilter { + filter: TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::WithKeyword { + value: Keyword::Flying, + }], + ..Default::default() + }), + }); + install_static_enchantment(&mut state, 710, "Flying-Gated Buff", vec![grant, buff]); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn keyword_grant_entry_escalates_through_condition_filter_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(condition_keyed_buff_with_keyword_grant_board, |s| { + add_colorless_creature_entry(s, 711) + }); + assert!( + escalated, + "a live condition reading keywords through its own filter must put \ + Abilities in ReadKinds, so the layer-6 grant intersects and escalates" + ); + assert_eq!( + pts_named(&forced, "CondBear"), + vec![(Some(5), Some(5)); 2], + "the granted flying satisfies the recipient condition on the full pass" + ); + assert_pt_identical(&normal, &forced, "condition-filter read channel"); + } + + /// (2.7) CONDITION-ADJACENT NARROWING NEGATIVE. `ChangeController` is the + /// writer the PREVIOUS gate recognized (it was one of its three + /// population keys), and it genuinely reaches the entrant here. Every + /// live read on this board is keyed purely on power/toughness — the + /// counted filter and both affected filters use a bare `PtComparison` + /// with no type constraint and no controller scope — so + /// `{Controller} ∩ {PowerToughness} = ∅` and the control theft cannot + /// move anything. The old gate escalated this board; the relation keeps + /// it incremental. + fn pt_keyed_count_anthem_with_control_theft_board() -> GameState { + use crate::types::ability::{ContinuousModification, PtStat, PtValueScope}; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 720 + i, &format!("ScopeBear{i}"), vec![]); + } + // Layer 2: steals every object with power ≤ 1, i.e. the 1/1 entrant. + let thief = continuous_static( + TargetFilter::Typed(TypedFilter { + properties: vec![FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 1 }, + }], + ..Default::default() + }), + vec![ContinuousModification::ChangeController], + ); + let tough_objects = TargetFilter::Typed(TypedFilter { + properties: vec![FilterProp::PtComparison { + stat: PtStat::Toughness, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 2 }, + }], + ..Default::default() + }); + let count = continuous_static( + tough_objects.clone(), + vec![ContinuousModification::AddDynamicPower { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: tough_objects, + }, + }, + }], + ); + install_static_enchantment( + &mut state, + 730, + "Toughness Count With Theft", + vec![thief, count], + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn control_change_entry_stays_incremental_when_reads_are_pt_only() { + let (normal, escalated, forced) = + flush_entry_and_forced(pt_keyed_count_anthem_with_control_theft_board, |s| { + add_colorless_creature_entry_under(s, 731, PlayerId(1)) + }); + assert!( + !escalated, + "control theft cannot move a purely P/T-keyed population — \ + {{Controller}} ∩ {{PowerToughness}} = ∅, so the entry stays incremental" + ); + // Non-vacuity: the layer-2 writer really does reach the entrant, so + // the old one-sided gate would have escalated this exact board. + let entrant = normal + .battlefield + .iter() + .filter_map(|id| normal.objects.get(id)) + .find(|o| o.name == "Insect") + .expect("entrant on battlefield"); + assert_eq!( + entrant.controller, + PlayerId(0), + "the entrant must actually be a recipient of the layer-2 control change" + ); + assert_pt_identical(&normal, &forced, "controller-vs-P/T non-escalation"); + } + + /// (2.8) CR 613.6 SELF-EXCLUSION CARVE-OUT. One Continuous definition + /// whose modifications WRITE exactly the kind its OWN affected filter + /// READS, and nothing else on the board reads anything: the buff is + /// `AddPower`/`AddToughness` (writes `{PowerToughness}`) over "creatures + /// with power ≤ 1" (reads `{CardTypes, PowerToughness}`). There is no + /// dynamic magnitude and no static condition, so the affected filter is + /// the whole read union. + /// + /// CR 613.6 locks the effect's affected-object set the first time the + /// effect applies and retains it for the rest of the pass, so the buff + /// cannot push the entrant back out of the set it was just admitted to. + /// Its own write is therefore not a read it can move, and the entry must + /// stay incremental. + /// + /// Revert direction: drop the per-modification exclusion and test stage 4 + /// against the whole `ReadKinds` union again — `{PowerToughness}` then + /// intersects its own affected filter's reads and the escalation + /// assertion flips. + fn self_reading_pt_buff_board() -> GameState { + use crate::types::ability::{ContinuousModification, PtStat, PtValueScope}; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 740 + i, &format!("SelfBear{i}"), vec![]); + } + // Layer 7c. Pre-existing 2/2 bears are out of the set (power 2 > 1); + // the 1/1 entrant is in it, and stays in it after the +3/+3 that + // would otherwise disqualify it. + let buff = continuous_static( + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 1 }, + }], + ..Default::default() + }), + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + ); + install_static_enchantment(&mut state, 742, "Self-Reading Buff", vec![buff]); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn pt_writer_entry_stays_incremental_when_only_its_own_affected_filter_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(self_reading_pt_buff_board, |s| { + add_colorless_creature_entry(s, 743) + }); + assert!( + !escalated, + "CR 613.6 retains the effect's own affected set, so a modification \ + cannot move the filter that admitted it — the entry stays incremental" + ); + let entrant = |s: &GameState| { + s.battlefield + .iter() + .filter_map(|id| s.objects.get(id)) + .find(|o| o.name == "Insect") + .map(|o| (o.power, o.toughness)) + .expect("entrant on battlefield") + }; + // Non-vacuity: the P/T writer genuinely reaches the entrant, and the + // retained set keeps `AddToughness` applying even though `AddPower` + // already pushed current power past the filter's threshold. + assert_eq!( + entrant(&forced), + (Some(4), Some(4)), + "the entrant must actually be a recipient of the layer-7c buff" + ); + assert_eq!( + pts_named(&forced, "SelfBear"), + vec![(Some(2), Some(2)); 2], + "pre-existing 2/2 bears never satisfy the power ≤ 1 filter" + ); + assert_eq!( + entrant(&normal), + entrant(&forced), + "the incremental entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "CR 613.6 self-exclusion carve-out"); + } + /// Assert every battlefield object's computed power/toughness/loyalty and /// keyword set are identical across two states. fn assert_pt_identical(a: &GameState, b: &GameState, label: &str) { From 9be3efcd9def8f932a9f32ec15451cab61fad934 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:55:16 -0700 Subject: [PATCH 05/13] fix(engine): classify DuringOpponentsTurn in the condition read matrix CR 102.1 + CR 102.3 / CR 805.4a. `StaticCondition::DuringOpponentsTurn` landed on main after this branch was cut. `static_condition_characteristic_reads_at` is exhaustive and wildcard-free, so merging main in did not compile until the variant was classified. That is the tripwire behaving as designed rather than an incident -- the property the matrix exists to hold is precisely that a new condition cannot enter the tree unclassified. It joins the `DuringYourTurn` arm. Both consult the active player (CR 102.1), and neither consults a layer-writable characteristic of any object. The two are deliberately distinct conditions rather than one being `Not` of the other -- in a team game a teammate can be the active player while the controller's team still holds the turn (CR 102.3 + CR 805.4a) -- but that distinction is invisible to this classifier, which asks only which characteristics a condition reads. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/layers.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 16dfa0c36f..b97bc443d6 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -1255,6 +1255,7 @@ fn static_condition_characteristic_reads_at( | StaticCondition::OpponentPoisonAtLeast { .. } | StaticCondition::UnlessPay { .. } | StaticCondition::DuringYourTurn + | StaticCondition::DuringOpponentsTurn | StaticCondition::SourceEnteredThisTurn | StaticCondition::SourceHasDealtDamage | StaticCondition::WasCast { .. } From 552a70c67ac2ef09218cdda176fa4570bfc0b171 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:04:17 -0700 Subject: [PATCH 06/13] test(engine): query the keyword authority in the disjoint-kind fixture `check-parser-combinators.sh` flags `entrant.keywords.contains(..)` as a raw keyword query that bypasses the authorities in `game/keywords.rs`: a raw `contains` compares whole values, so it misses parameterised keywords whose payload differs, and it never consults off-zone grants. The non-vacuity assertion in `keyword_grant_entry_stays_incremental_when_population_reads_are_disjoint` now goes through `GameObject::has_keyword`, which matches on discriminant and is the form already used elsewhere in this file. The gate caught this in CI rather than locally because its base defaults to `git merge-base origin/main HEAD` (scripts/check-parser-combinators.sh:48) and this checkout names the upstream remote `upstream`, so the merge-base lookup failed and it fell back to `HEAD~1` -- one commit of the branch instead of the whole PR diff. Re-run against `upstream/main` explicitly it is `Gate A PASS head=a0b60dcf base=96e41b3ab`, and this was the only violation in the branch. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/stack.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 6d84f56812..126e2017a6 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -11710,7 +11710,7 @@ mod tests { .find(|o| o.name == "Insect") .expect("entrant on battlefield"); assert!( - entrant.keywords.contains(&Keyword::Flying), + entrant.has_keyword(&Keyword::Flying), "the entrant must actually be a recipient of the layer-6 grant" ); assert_pt_identical(&normal, &forced, "disjoint-kind non-escalation"); From 3f1a47412d35b407f6c201a6fa46106e3753504f Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:28:08 -0700 Subject: [PATCH 07/13] fix(engine): read the condition channel of resolution-created continuous effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `live_characteristic_reads` unioned characteristic kinds from printed and granted-inner static definitions' conditions, but skipped the `condition` a `TransientContinuousEffect` retains after resolution. CR 611.2c locks in a resolved effect's affected SET; it does not lock in the gate, so a retained condition is re-evaluated every pass exactly like the "isn't locked in" static-ability effect of CR 611.3a. With the condition invisible to the read set, an entry-incremental flush that rewrote an entrant's card types could not see that the rewrite perturbed the gate, and recipients kept a stale board. Union `e.condition` for every live `ActiveContinuousEffect` unconditionally, alongside the existing global and affected-filter channels: the gate decides WHETHER the effect applies at all, so it is a board-level read, not a per-modification one (CR 613.6). Also fixes four `characteristic_read_classification` misclassifications in `filter.rs`. `FilterProp::Owned` mapped to `CharacteristicKinds::EMPTY` on the reasoning that ownership is immutable (CR 108.3), but the prop is a two-operand comparison whose LEFT operand is the live controller reference (CR 109.5), which layer 2 can rewrite (CR 613.1b). Same shape for `Unpaired` (CR 702.95e), `ControlledContinuouslySinceTurnBegan`, and `HasHasteOrControlledSinceTurnBegan` (CR 302.6 + CR 702.10). Two CodeRabbit findings: `FilterProp::DistinctFrom` was matched by a `_ =>` wildcard inside a classifier documented as exhaustive and wildcard-free — it is now an explicit arm — and a stale fixture name in a `stack.rs` doc comment now points at `controller_keyed_count_anthem_with_control_theft_board`. Tests exercise the building blocks, not the cards: four classifier-invariant tests in `filter.rs` (including a roster guard that fails when a new `ControllerRef`-carrying prop is added without classifying it), and one entry-incremental fixture in `stack.rs` that installs a conditioned transient through the single construction authority `GameState::add_transient_continuous_effect`. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/filter.rs | 182 ++++++++++++++++++++++++++++--- crates/engine/src/game/layers.rs | 19 +++- crates/engine/src/game/stack.rs | 173 ++++++++++++++++++++++++++++- 3 files changed, 356 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index d4bd58c880..10b0ab47ad 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -561,10 +561,11 @@ fn filter_prop_characteristic_reads_at(prop: &FilterProp, depth: u32) -> Charact FilterProp::CanEnchant { target } => CharacteristicKinds::ABILITIES .union(target_filter_characteristic_reads_at(target, depth)), // CR 302.6 + CR 702.10: haste is a keyword read; the creature check is a - // typeline read. - FilterProp::HasHasteOrControlledSinceTurnBegan => { - CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) - } + // typeline read; and the `summoning_sick` continuity fallback is re-armed + // by every layer-2 control change (CR 613.1b). + FilterProp::HasHasteOrControlledSinceTurnBegan => CharacteristicKinds::CARD_TYPES + .union(CharacteristicKinds::ABILITIES) + .union(CharacteristicKinds::CONTROLLER), // ---- CR 613.1d (layer 4): typeline reads. ---- FilterProp::HasSupertype { .. } @@ -679,7 +680,23 @@ fn filter_prop_characteristic_reads_at(prop: &FilterProp, depth: u32) -> Charact FilterProp::ControllerChoseLabel { .. } | FilterProp::Attacking { .. } | FilterProp::AttackedThisTurn { .. } + // CR 108.3 fixes the RIGHT operand (the matched object's owner) at game + // start, but this prop is a two-operand relation: the `ControllerRef` + // LEFT operand resolves against the effect source's live controller + // (CR 109.5 — "you" on a static ability is the current controller of the + // object it is on), and layer 2 rewrites that (CR 613.1b). An immutable + // right operand does not make the relation immutable. + | FilterProp::Owned { .. } + // CR 302.6: reads the `summoning_sick` continuity flag, which layer 2 + // re-arms for every permanent whose controller changed (CR 613.1b). + | FilterProp::ControlledContinuouslySinceTurnBegan | FilterProp::CountersPutOnThisTurn { .. } => CharacteristicKinds::CONTROLLER, + // CR 702.95e: a pair breaks when either half changes controller (layer 2, + // CR 613.1b) or stops being a creature (layer 4, CR 613.1d), so the + // unpaired verdict reads both kinds. + FilterProp::Unpaired => { + CharacteristicKinds::CONTROLLER.union(CharacteristicKinds::CARD_TYPES) + } // ---- Undeterminable: conservatively every kind. ---- // CR 109.4: an arbitrary player predicate over the object's controller @@ -691,15 +708,18 @@ fn filter_prop_characteristic_reads_at(prop: &FilterProp, depth: u32) -> Charact // CR 109.1: identity exclusion. Against the ability's own parent target // this is pure object identity and reads nothing; against any other // reference the excluded set is filter-derived and could be anything. - FilterProp::DistinctFrom { reference } => match reference.as_ref() { - TargetFilter::ParentTarget => CharacteristicKinds::EMPTY, - _ => CharacteristicKinds::ALL, - }, + FilterProp::DistinctFrom { reference } => { + if matches!(reference.as_ref(), TargetFilter::ParentTarget) { + CharacteristicKinds::EMPTY + } else { + CharacteristicKinds::ALL + } + } // ---- Reads no layer-writable characteristic. ---- - // Token identity, zone, ownership (CR 108.3), combat state, per-object - // designations, per-turn ledgers, stack shape, and the fail-closed - // unparsed leaf. Enumerated explicitly (no wildcard). + // Token identity, zone, combat state, per-object designations, per-turn + // ledgers, stack shape, and the fail-closed unparsed leaf. Enumerated + // explicitly (no wildcard). FilterProp::Token | FilterProp::NonToken | FilterProp::RepresentedByCard @@ -719,12 +739,9 @@ fn filter_prop_characteristic_reads_at(prop: &FilterProp, depth: u32) -> Charact | FilterProp::HasAdventure | FilterProp::WasKicked | FilterProp::InZone { .. } - // CR 108.3: owner is fixed at game start; no layer writes it. - | FilterProp::Owned { .. } | FilterProp::AttachedToSource | FilterProp::AttachedToRecipient | FilterProp::Another - | FilterProp::Unpaired | FilterProp::OtherThanTriggerObject | FilterProp::InTrackedSet { .. } | FilterProp::Suspected @@ -734,7 +751,6 @@ fn filter_prop_characteristic_reads_at(prop: &FilterProp, depth: u32) -> Charact | FilterProp::WasDealtDamageThisTurn | FilterProp::DealtDamageThisTurn | FilterProp::EnteredThisTurn - | FilterProp::ControlledContinuouslySinceTurnBegan | FilterProp::ZoneChangedThisTurn { .. } | FilterProp::BlockedThisTurn | FilterProp::AttackedOrBlockedThisTurn @@ -13659,3 +13675,139 @@ mod tests { ); } } + +/// Building-block coverage for the characteristic-dependence classifier +/// (`filter_prop_characteristic_reads_at`). These pin the invariants the +/// classifier documents in prose, at the level of the primitive rather than of +/// any one card, so a future variant cannot drift into the wrong kind group. +#[cfg(test)] +mod characteristic_read_classification_tests { + use super::*; + use crate::types::ability::{AttachmentKind, SourceExclusion}; + + /// Wraps a single prop in the minimal `Typed` filter: no type filters and no + /// controller scope, so the reported kinds come from the prop alone. + fn only(prop: FilterProp) -> TargetFilter { + TargetFilter::Typed(TypedFilter { + properties: vec![prop], + ..TypedFilter::default() + }) + } + + /// CR 613.1b: layer 2 can move an object across any controller scope a + /// `ControllerRef` names, so every `FilterProp` that carries one reads the + /// CONTROLLER kind. The classifier states this invariant in prose; this test + /// is what makes the next `ControllerRef`-carrying variant fail loudly if it + /// is filed under a group that omits CONTROLLER. + #[test] + fn every_controller_ref_carrying_prop_reads_the_controller_kind() { + let props = [ + FilterProp::Attacking { + defender: Some(ControllerRef::You), + }, + FilterProp::ProtectorMatches { + controller: ControllerRef::You, + }, + FilterProp::Owned { + controller: ControllerRef::You, + }, + FilterProp::HasAttachment { + kind: AttachmentKind::Aura, + controller: Some(ControllerRef::You), + exclude_source: SourceExclusion::Include, + }, + FilterProp::HasAnyAttachmentOf { + kinds: vec![AttachmentKind::Aura], + controller: Some(ControllerRef::You), + }, + FilterProp::MostPrevalentCreatureTypeIn { + zone: Zone::Library, + scope: ControllerRef::You, + }, + FilterProp::AttackedThisTurn { + defender: Some(ControllerRef::You), + }, + FilterProp::NameMatchesAnyPermanent { + controller: Some(ControllerRef::You), + }, + ]; + assert_eq!( + props.len(), + 8, + "the ControllerRef-carrying FilterProp roster grew or shrank — extend \ + this list so the invariant stays fully pinned" + ); + for prop in props { + assert!( + target_filter_characteristic_reads(&only(prop.clone())) + .contains(CharacteristicKinds::CONTROLLER), + "{prop:?} scopes its verdict by a ControllerRef, which CR 613.1b \ + lets layer 2 rewrite — it must report a CONTROLLER read" + ); + } + } + + /// CR 108.3 fixes the owner, but `Owned` is a two-operand relation and only + /// its right operand is immutable; the left operand is the source's live + /// controller (CR 109.5), which layer 2 rewrites (CR 613.1b). + #[test] + fn owned_reads_the_controller_kind_despite_an_immutable_owner() { + for controller in [ + ControllerRef::You, + ControllerRef::Opponent, + ControllerRef::ScopedPlayer, + ] { + assert!( + target_filter_characteristic_reads(&only(FilterProp::Owned { controller })) + .contains(CharacteristicKinds::CONTROLLER), + "an immutable owner operand does not make the owner-vs-controller \ + relation immutable" + ); + } + } + + /// CR 702.95e: a soulbond pair breaks when either half changes controller + /// (layer 2, CR 613.1b) or stops being a creature (layer 4, CR 613.1d). + #[test] + fn unpaired_reads_the_controller_and_card_type_kinds() { + let kinds = target_filter_characteristic_reads(&only(FilterProp::Unpaired)); + assert!( + kinds.contains(CharacteristicKinds::CONTROLLER), + "CR 702.95e: gaining control of either half breaks the pair" + ); + assert!( + kinds.contains(CharacteristicKinds::CARD_TYPES), + "CR 702.95e: either half ceasing to be a creature breaks the pair" + ); + } + + /// CR 302.6: the summoning-sickness continuity flag is re-armed for every + /// permanent whose controller changed, so both props that read it depend on + /// layer 2 (CR 613.1b). + #[test] + fn continuity_props_read_the_controller_kind() { + assert!( + target_filter_characteristic_reads(&only( + FilterProp::ControlledContinuouslySinceTurnBegan + )) + .contains(CharacteristicKinds::CONTROLLER), + "a control change re-arms the continuity flag this prop reads" + ); + + let enlist = target_filter_characteristic_reads(&only( + FilterProp::HasHasteOrControlledSinceTurnBegan, + )); + assert!( + enlist.contains(CharacteristicKinds::CONTROLLER), + "the continuity fallback of the haste-or-continuity prop is layer-2 movable" + ); + assert!( + enlist.contains(CharacteristicKinds::ABILITIES), + "CR 702.10: the haste branch is a keyword read" + ); + assert!( + enlist.contains(CharacteristicKinds::CARD_TYPES), + "CR 302.6: the creature-typeline guard is a layer-4 read" + ); + } +} diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index b97bc443d6..71dd30b31d 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3707,11 +3707,12 @@ struct LiveCharacteristicReads { /// /// ```text /// global := ⋃ dynamic-magnitude kinds // every live modification's magnitude +/// ∪ ⋃ live condition kinds // every live effect's retained condition /// ∪ ⋃ Continuous condition kinds // every live Continuous static's condition /// ReadKinds := global ∪ ⋃ affected-filter kinds // every live modification's affected filter /// ``` /// -/// All three channels are unioned UNCONDITIONALLY. An earlier design gated the +/// All four channels are unioned UNCONDITIONALLY. An earlier design gated the /// affected-filter channel on the write set already intersecting, which is /// unsound: a board whose only name-sensitive read lives in another static's /// affected filter has an empty base union, so the gate would never notice a @@ -3720,7 +3721,7 @@ struct LiveCharacteristicReads { /// The affected-filter channel is reported separately because it is the only /// one attributable to a single effect, and CR 613.6 puts an effect's own /// affected filter out of reach of its own writes — see -/// [`AffectedFilterReadTally`]. The other two channels are board-level and +/// [`AffectedFilterReadTally`]. The other three channels are board-level and /// admit no such exclusion. /// /// Walks early-exit the moment the union saturates to @@ -3742,6 +3743,20 @@ fn live_characteristic_reads( )); } affected = affected.union(target_filter_characteristic_reads(&e.affected_filter)); + // CR 611.2c + CR 611.3a: CR 611.2c locks in the affected SET of a + // resolution-created continuous effect, but nothing locks in its + // enabling condition — a retained recipient-context condition is + // re-evaluated per recipient on every pass, exactly like the + // "isn't locked in" static-ability effect of CR 611.3a. The condition + // gates WHETHER the effect applies at all, so it is a board-level read + // and belongs in `global`, NOT in the per-effect `affected` channel + // that CR 613.6 lets an effect exclude from its own writes. This is the + // single authority for the condition channel: every + // `ActiveContinuousEffect` producer — printed statics, granted-inner + // statics and resolution-created transients — converges here. + if let Some(condition) = e.condition.as_ref() { + global = global.union(static_condition_characteristic_reads(condition)); + } } if !global.union(affected).is_all() { // CR 611.3a: a Continuous static's enabling condition re-evaluates as the diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 126e2017a6..0cca5dc1e8 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -11485,7 +11485,8 @@ mod tests { /// itself a P/T writer and would satisfy the relation on its own reach. /// The count anthem's affected set is therefore "creatures you control" /// while the entrant enters under the OPPONENT (mirroring - /// `controller_theft_count_anthem_board`), which makes `SetToughness` + /// `controller_keyed_count_anthem_with_control_theft_board`), which makes + /// `SetToughness` /// the only entrant-reaching writer and gives the revert-check SetPT-arm /// granularity rather than whole-kind granularity. fn tough_count_anthem_with_set_toughness_board() -> GameState { @@ -12040,6 +12041,176 @@ mod tests { assert_pt_identical(&normal, &forced, "CR 613.6 self-exclusion carve-out"); } + // ------------------------------------------------------------------ + // RESOLUTION-CREATED continuous effects (CR 611.2c + CR 611.3a). + // + // A `TransientContinuousEffect` is the third `ActiveContinuousEffect` + // producer, alongside printed statics and granted-inner statics. It is + // the only one whose affected set is FROZEN (CR 611.2c) while its + // enabling condition stays LIVE (CR 611.3a), so it is the only one + // where the affected-filter channel reports EMPTY and the condition is + // the sole live read. Both boards below build that effect through the + // single construction authority — see `install_gated_transient` for + // which production path produces this shape and which does NOT. + // ------------------------------------------------------------------ + + /// Install a resolution-created continuous effect that RETAINS its + /// enabling condition, through the single construction authority + /// (`GameState::add_transient_continuous_effect`), one effect per + /// recipient. + /// + /// CR 611.2c: the affected set is already frozen to `SpecificObject` — + /// a filter that reads NO layer-writable characteristic — which is what + /// every transient looks like once its effect has begun. CR 611.3a: the + /// condition rides alongside and stays live, re-evaluated on every + /// later pass. That asymmetry (frozen set, live gate) is what these two + /// boards exercise. + /// + /// REACHABILITY: `Effect::GenericEffect` is NOT the producer of this + /// shape. Per CR 608.2h + CR 611.2d its resolver determines an + /// in-effect "if " exactly once, at resolution, and installs + /// the transient with `condition: None` + /// (`effects/effect.rs::resolve`). A conditioned transient comes from + /// riders that hand a `StaticDefinition`'s condition straight to the + /// constructor — `effects/counter.rs::apply_source_static` (the + /// `CounterSourceRider::LosesAbilities` rider) is the live example. So + /// these fixtures call the constructor exactly the way that rider does. + fn install_gated_transient( + state: &mut GameState, + source: ObjectId, + recipients: &[ObjectId], + mods: Vec, + condition: crate::types::ability::StaticCondition, + ) { + for &id in recipients { + state.add_transient_continuous_effect( + source, + PlayerId(0), + Duration::UntilEndOfTurn, + TargetFilter::SpecificObject { id }, + mods.clone(), + Some(condition.clone()), + ); + } + } + + /// (3.1) TRANSIENT CONDITION READ CHANNEL. The only live read of card + /// types on this board lives in the CONDITION of a resolution-created + /// continuous effect whose affected set is already frozen to + /// `SpecificObject` (CR 611.2c), i.e. to an EMPTY-read filter. A + /// separate printed layer-4 `AddType{Land}` (CR 613.1d) reaches the + /// entrant, so the Land population that condition counts moves for + /// every pre-existing recipient. + /// + /// CR 611.2c freezes the affected SET and nothing else; CR 611.3a keeps + /// the gate re-evaluating on every pass, here per recipient + /// (`FilterProp::Another`). Pre-entry each 2/2 recipient sees exactly + /// 1 OTHER Land so `GE 2` is OFF; post-entry it sees 2 and turns ON + /// (3/3). + /// + /// DISCRIMINATING because the transient is the ONLY condition on the + /// board: drop the condition channel from `live_characteristic_reads` + /// and `ReadKinds` loses CardTypes entirely. Stage 4 then exempts the + /// layer-4 writer under CR 613.6 — the only other CardTypes read is its + /// OWN affected filter — the entry stays on the incremental path, and + /// the pre-existing recipients keep a stale 2/2. + fn transient_condition_read_board() -> GameState { + use crate::types::ability::{ContinuousModification, StaticCondition}; + let mut state = setup(); + let mut bears = Vec::new(); + for i in 0..2 { + bears.push(add_relation_bear( + &mut state, + 760 + i, + &format!("TransientBear{i}"), + vec![], + )); + } + // CR 613.1d: printed layer-4 type-changer, unconditional. Every + // creature is also a Land, so it reaches the entrant and MOVES the + // counted population. + let to_land = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::AddType { + core_type: CoreType::Land, + }], + ); + install_static_enchantment(&mut state, 762, "Land Conversion", vec![to_land]); + let source = install_static_enchantment(&mut state, 763, "Other-Lands Grant", vec![]); + install_gated_transient( + &mut state, + source, + &bears, + vec![ + ContinuousModification::AddPower { value: 1 }, + ContinuousModification::AddToughness { value: 1 }, + ], + // Recipient-relative: "two or more OTHER Lands". + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Land], + properties: vec![FilterProp::Another], + ..Default::default() + }), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + }, + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn type_rewrite_entry_escalates_through_transient_condition_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_condition_read_board, |s| { + add_colorless_creature_entry(s, 764) + }); + assert!( + escalated, + "a resolution-created effect's retained condition is a LIVE read \ + (CR 611.3a), so a layer-4 type rewrite reaching the entrant moves \ + the population it counts — the entry must escalate" + ); + // Non-vacuity: CR 611.2c really did freeze the affected set, so the + // affected-filter channel reports EMPTY and the condition channel is + // the only thing that can put CardTypes in ReadKinds. + assert!( + !forced.transient_continuous_effects.is_empty() + && forced.transient_continuous_effects.iter().all(|tce| { + matches!(tce.affected, TargetFilter::SpecificObject { .. }) + && tce.condition.is_some() + }), + "the grant must have resolved into SpecificObject-bound transients \ + that still carry their condition" + ); + // Non-vacuity: the gate is genuinely OFF before the entry, so the 3/3 + // below is the entrant's doing and not an already-buffed board. + let mut pre = transient_condition_read_board(); + flush_layers(&mut pre); + assert_eq!( + pts_named(&pre, "TransientBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry each recipient sees only 1 OTHER Land, so GE 2 is OFF" + ); + assert_eq!( + pts_named(&forced, "TransientBear"), + vec![(Some(3), Some(3)); 2], + "the full pass counts the entrant once layer 4 makes it a Land, so \ + each recipient sees 2 OTHER Lands and the gate turns ON" + ); + assert_eq!( + pts_named(&normal, "TransientBear"), + pts_named(&forced, "TransientBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient condition read channel"); + } + /// Assert every battlefield object's computed power/toughness/loyalty and /// keyword set are identical across two states. fn assert_pt_identical(a: &GameState, b: &GameState, label: &str) { From 035eca01ec607c8f4a23b7f97bdd1c87d5162e91 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:31:48 -0700 Subject: [PATCH 08/13] fix(engine): probe transient enabling conditions for entry perturbation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `any_active_static_condition_perturbed_by_entry` walked only `obj.static_definitions.iter_all()` — printed and granted-inner CONTINUOUS `StaticDefinition`s — so a source-level enabling condition riding on a `TransientContinuousEffect` was invisible to the entry-perturbation probe. CR 611.2c locks in a resolved effect's affected SET, not its gate. A retained condition therefore flips on entry exactly like a printed one (CR 611.3a), and every recipient frozen into that effect's set goes stale with it. Walk `state.transient_continuous_effects` as a second generator channel, reading `tce.condition` through the same population classifier and entry-narrowing probe as the printed walk. The transient walk deliberately has no truth-delta stage: `static_gate_truth` is keyed by `StaticGateKey { source, def_index }` over printed definitions, and a transient has no `def_index` in that key space (several transients can share one `source_id`). Escalating on perturbation alone is the direction the whole gate is built on, and matches the existing recipient-context arm, which also escalates with no cache consult. `FilterContext` is built with `tce.controller`, not the source object's current controller: CR 109.5 gives a resolved spell or ability its controller for the effect's whole existence, so "you" in the retained gate names the resolver — the rebind-to-current-controller reading is only correct for static abilities. The fixture installs a source-level `IsPresent{creature an opponent controls}` gate through `GameState::add_transient_continuous_effect`, the single construction authority, and reaches the probe on the disjunct that only this walk can satisfy: nothing on the board writes the kinds the gate reads, so the kind relation exits at stage 3 without it. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/layers.rs | 54 +++++++++++++++++--- crates/engine/src/game/stack.rs | 84 ++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 71dd30b31d..603ee9991d 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -4195,12 +4195,21 @@ fn active_effects_force_incremental_escalation( }) } -/// Scan every live static-ability source for a CONTINUOUS `StaticDefinition` -/// whose enabling `condition` is board-population-dependent AND that one of the -/// `entered_ids` actually perturbs. Walks the same source set as -/// `collect_shared_active_continuous_effects` (`for_each_static_effect_source`) -/// and reads the source definition's `condition` field. -/// O(active-source-count × entered-count); short-circuits on the first match. +/// Scan every live continuous-effect generator for an enabling `condition` that +/// is board-population-dependent AND that one of the `entered_ids` actually +/// perturbs. Two generator channels are walked, matching the two +/// `ActiveContinuousEffect` sources that carry a condition: +/// +/// * PRINTED (and granted-inner) CONTINUOUS `StaticDefinition`s, over the same +/// source set as `collect_shared_active_continuous_effects` +/// (`for_each_static_effect_source`), reading each definition's `condition`. +/// * RESOLUTION-CREATED `TransientContinuousEffect`s, reading `tce.condition`. +/// CR 611.2c locks in a resolved effect's affected SET, not its gate, so a +/// transient's condition stays live and flips on entry exactly like a printed +/// one — see the transient walk below for why it has no truth-delta stage. +/// +/// O((active-source-count + transient-count) × entered-count); short-circuits on +/// the first match. /// /// Three-stage test: /// 1. The committed exhaustive classifier @@ -4285,7 +4294,38 @@ fn any_active_static_condition_perturbed_by_entry( found = true; } }); - found + if found { + return true; + } + // CR 611.2c + CR 611.3a: the walk above sees only PRINTED (and granted-inner) + // static definitions. A continuous effect created by the resolution of a + // spell or ability keeps its enabling condition LIVE — CR 611.2c locks in the + // affected SET, and nothing else — so a source-level population gate riding + // on a transient flips on entry exactly like a printed one, and every + // recipient frozen into that effect's set goes stale with it. + // + // No truth-delta stage here: `static_gate_truth` is keyed by + // `(source, def_index)` over printed definitions, and a transient has no + // `def_index` in that key space (several transients can share one + // `source_id`). Escalating on perturbation alone is the direction the whole + // gate is built on — over-escalation costs a full pass, under-escalation + // ships a wrong board — and it matches the recipient-context arm above, + // which also escalates on perturbation with no cache consult. + state.transient_continuous_effects.iter().any(|tce| { + let Some(condition) = tce.condition.as_ref() else { + return false; + }; + if !static_condition_uses_object_population(condition) { + return false; + } + // CR 109.5: a resolved spell or ability RETAINS its controller, so "you" + // in the retained gate names `tce.controller` — not whoever controls the + // source object now (that reading is only correct for static abilities). + let ctx = FilterContext::from_source_with_controller(tce.source_id, tce.controller); + entered_ids + .iter() + .any(|id| entered_object_perturbs_static_condition(state, *id, &ctx, condition)) + }) } /// CR 611.3a + CR 611.3b: rewrite the source-level enabling-condition truth diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 0cca5dc1e8..2ac0829e96 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -12211,6 +12211,90 @@ mod tests { assert_pt_identical(&normal, &forced, "transient condition read channel"); } + /// (3.2) TRANSIENT SOURCE-LEVEL GATE. Same frozen-set/live-gate + /// asymmetry, but the gate is a plain SOURCE-LEVEL presence check + /// (CR 611.3a) instead of a recipient-context count, and NOTHING on the + /// board writes the kinds it reads. That makes the entry-perturbation + /// probe the only disjunct that can catch it: an opponent's creature + /// entering flips `IsPresent{creature an opponent controls}` from OFF + /// to ON, and every recipient frozen into the effect's set (CR 611.2c) + /// goes 2/2 → 5/5. + /// + /// DISCRIMINATING: drop the transient walk from + /// `any_active_static_condition_perturbed_by_entry` and the + /// printed-static walk sees no condition at all, the kind relation + /// exits at stage 3 (`{PowerToughness} ∩ {CardTypes, Controller} = ∅`), + /// and the recipients stay stale at 2/2. + fn transient_source_level_gate_board() -> GameState { + use crate::types::ability::{ContinuousModification, StaticCondition}; + use crate::types::ControllerRef; + let mut state = setup(); + let mut bears = Vec::new(); + for i in 0..2 { + bears.push(add_relation_bear( + &mut state, + 770 + i, + &format!("GatedBear{i}"), + vec![], + )); + } + let source = + install_static_enchantment(&mut state, 772, "Opponent-Gated Grant", vec![]); + install_gated_transient( + &mut state, + source, + &bears, + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + // CR 109.5: a resolved effect RETAINS its controller, so "an + // opponent" is read against P0. OFF on this board. + StaticCondition::IsPresent { + filter: Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: Some(ControllerRef::Opponent), + ..Default::default() + })), + }, + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn opponent_entry_escalates_through_transient_source_level_gate() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_source_level_gate_board, |s| { + add_colorless_creature_entry_under(s, 773, PlayerId(1)) + }); + assert!( + escalated, + "CR 611.2c freezes a resolved effect's affected SET, not its gate — \ + an entry that flips a transient's source-level presence gate must \ + escalate or every frozen recipient keeps a stale board" + ); + // Non-vacuity: the gate is genuinely OFF before the entry. + let mut pre = transient_source_level_gate_board(); + flush_layers(&mut pre); + assert_eq!( + pts_named(&pre, "GatedBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry no opponent controls a creature, so the gate is OFF" + ); + assert_eq!( + pts_named(&forced, "GatedBear"), + vec![(Some(5), Some(5)); 2], + "the opponent's entrant turns the gate ON for every frozen recipient" + ); + assert_eq!( + pts_named(&normal, "GatedBear"), + pts_named(&forced, "GatedBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient source-level gate"); + } + /// Assert every battlefield object's computed power/toughness/loyalty and /// keyword set are identical across two states. fn assert_pt_identical(a: &GameState, b: &GameState, label: &str) { From 59e8d153632c3c61a2ae6121cae0e1e1b7fc48fc Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:58:38 -0700 Subject: [PATCH 09/13] fix(engine): read both gates of a resolution-created continuous effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `live_characteristic_reads` unioned enabling conditions off `active_effects`, which is a projection that structurally drops the two gate shapes an entry can flip. `gather_transient_continuous_effects` skips a transient whose gate is currently OFF (and an OFF gate is exactly the one an entry turns ON), and it retains only a recipient-context condition — a source-level condition is stripped from the effect it pushes. A `Duration::ForAsLongAs` condition (CR 611.2b, Master Thief) reaches no `ActiveContinuousEffect` at all, because no gather copies a duration onto one. So a board whose only live read of a characteristic kind sat in one of those gates computed a `ReadKinds` missing that kind, the kind relation exited early, the entry stayed incremental, and every frozen recipient kept a stale board. Walk `state.transient_continuous_effects` directly as a fifth read channel, through a new `transient_gate_conditions` single authority that yields both gates `transient_effect_is_live` consults: the "for as long as" duration (CR 611.2b) and the retained enabling condition, which is the source definition's own CR 611.3a gate riding along (`effects/counter.rs::apply_source_static`). CR 611.2c freezes such an effect's affected SET and nothing else, so both gates stay live and can flip long after that set is fixed. `any_active_static_condition_perturbed_by_entry` had the same `ForAsLongAs` blind spot — 294ebcd6f gave it `tce.condition` only. It now iterates the same authority, so a second condition-bearing `Duration` variant is wired into the liveness evaluator, the read union, and the probe by editing one function. Its `FilterContext` is hoisted out of the per-condition loop; CR 109.5 keeps "you" in either retained gate bound to `tce.controller`, not to whoever controls the source object now. Docs: 27b8bf71e documented `e.condition` as "the single authority for the condition channel: every `ActiveContinuousEffect` producer converges here". That was false in both directions and is replaced by a WHAT CONVERGES WHERE block naming each producer and the channel that actually sees it, including the ring/emblem/sticker producers that carry no condition today, and the `active_combat_assignment_rule_effects_from_static_definitions` / `collect_transient_combat_assignment_rule_effects` pair, which duplicates the same retain/strip logic for CR 613.11 effects and would need patching if a condition channel is ever added there. Tests: two entry-incremental fixtures whose only live read of NameText is a transient gate — one source-level `tce.condition`, one `ForAsLongAs` duration — plus a probe-channel fixture whose `ForAsLongAs` gate is started by an opponent's entrant. Both read fixtures write with a layer-1 `SetName` (CR 707.9b) rather than a layer-4 `AddType`: `evaluate_layers` gathers between layer 1 and layer 2, so a gate can only observe base + layer-1 state, and a type-based fixture cannot discriminate this seam. Co-Authored-By: Claude Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/layers.rs | 143 ++++++++--- crates/engine/src/game/stack.rs | 414 ++++++++++++++++++++++++++++--- 2 files changed, 495 insertions(+), 62 deletions(-) diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 603ee9991d..3bce1eb2bb 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3701,7 +3701,7 @@ struct LiveCharacteristicReads { } /// CR 613.1: the union of layer-writable characteristic kinds that the CURRENT -/// board actually READS, per the three live read channels. +/// board actually READS, per the live read channels. /// /// Entrant-independent, so the entry-flush gate computes it once per flush: /// @@ -3709,10 +3709,11 @@ struct LiveCharacteristicReads { /// global := ⋃ dynamic-magnitude kinds // every live modification's magnitude /// ∪ ⋃ live condition kinds // every live effect's retained condition /// ∪ ⋃ Continuous condition kinds // every live Continuous static's condition +/// ∪ ⋃ transient gate kinds // every INSTALLED transient's duration + condition /// ReadKinds := global ∪ ⋃ affected-filter kinds // every live modification's affected filter /// ``` /// -/// All four channels are unioned UNCONDITIONALLY. An earlier design gated the +/// All five channels are unioned UNCONDITIONALLY. An earlier design gated the /// affected-filter channel on the write set already intersecting, which is /// unsound: a board whose only name-sensitive read lives in another static's /// affected filter has an empty base union, so the gate would never notice a @@ -3721,9 +3722,31 @@ struct LiveCharacteristicReads { /// The affected-filter channel is reported separately because it is the only /// one attributable to a single effect, and CR 613.6 puts an effect's own /// affected filter out of reach of its own writes — see -/// [`AffectedFilterReadTally`]. The other three channels are board-level and +/// [`AffectedFilterReadTally`]. The other four channels are board-level and /// admit no such exclusion. /// +/// WHAT CONVERGES WHERE. The `e.condition` channel below is NOT a single +/// authority over every gate on the board, and must not be documented as one: +/// +/// * printed and granted-inner Continuous statics reach it (gathered with +/// their condition intact), and are ALSO covered by the source walk below, +/// which is what sees a static whose gate is currently OFF; +/// * a transient reaches it only when its gate is currently ON *and* the +/// condition is recipient-context, because +/// [`gather_transient_continuous_effects`] skips a transient that is not +/// live and strips a source-level condition from the effect it pushes. Both +/// discarded shapes are exactly the ones an entry can flip, hence the +/// separate walk over `state.transient_continuous_effects`; +/// * the ring/emblem/sticker producers (`stickers.rs`, and the two `condition: +/// None` sites in this file) carry no condition at all today, so they +/// contribute nothing through any channel. +/// +/// `active_combat_assignment_rule_effects_from_static_definitions` / +/// `collect_transient_combat_assignment_rule_effects` duplicate the same +/// retain/strip logic for `ActiveCombatAssignmentRuleEffect`; those effects +/// change CR 613.11 rules, not characteristics, so they are outside this union +/// — but a future condition channel added here needs patching there too. +/// /// Walks early-exit the moment the union saturates to /// [`CharacteristicKinds::ALL`]. fn live_characteristic_reads( @@ -3750,14 +3773,33 @@ fn live_characteristic_reads( // "isn't locked in" static-ability effect of CR 611.3a. The condition // gates WHETHER the effect applies at all, so it is a board-level read // and belongs in `global`, NOT in the per-effect `affected` channel - // that CR 613.6 lets an effect exclude from its own writes. This is the - // single authority for the condition channel: every - // `ActiveContinuousEffect` producer — printed statics, granted-inner - // statics and resolution-created transients — converges here. + // that CR 613.6 lets an effect exclude from its own writes. Covers the + // gathered producers only — see the walks below for what this channel + // structurally cannot see. if let Some(condition) = e.condition.as_ref() { global = global.union(static_condition_characteristic_reads(condition)); } } + // CR 611.2b + CR 611.2c: both gates of a resolution-created effect — its + // "for as long as" duration and its retained enabling condition — decide + // WHETHER the effect applies at all, so both are board-level reads. + // + // Walked straight off `state.transient_continuous_effects` rather than off + // `active_effects`, because neither shape that matters survives that + // projection: `gather_transient_continuous_effects` skips a transient whose + // gate is currently OFF (an OFF gate is exactly the one an entry can turn + // ON), and it strips a source-level condition from the effect it pushes, + // leaving `e.condition == None` above. + if !global.union(affected).is_all() { + for tce in &state.transient_continuous_effects { + if global.union(affected).is_all() { + break; + } + for condition in transient_gate_conditions(tce) { + global = global.union(static_condition_characteristic_reads(condition)); + } + } + } if !global.union(affected).is_all() { // CR 611.3a: a Continuous static's enabling condition re-evaluates as the // board changes, so it is a live read channel in its own right. @@ -4197,16 +4239,18 @@ fn active_effects_force_incremental_escalation( /// Scan every live continuous-effect generator for an enabling `condition` that /// is board-population-dependent AND that one of the `entered_ids` actually -/// perturbs. Two generator channels are walked, matching the two -/// `ActiveContinuousEffect` sources that carry a condition: +/// perturbs. Two generator channels are walked, matching the two kinds of +/// generator that carry a condition: /// /// * PRINTED (and granted-inner) CONTINUOUS `StaticDefinition`s, over the same /// source set as `collect_shared_active_continuous_effects` /// (`for_each_static_effect_source`), reading each definition's `condition`. -/// * RESOLUTION-CREATED `TransientContinuousEffect`s, reading `tce.condition`. -/// CR 611.2c locks in a resolved effect's affected SET, not its gate, so a -/// transient's condition stays live and flips on entry exactly like a printed -/// one — see the transient walk below for why it has no truth-delta stage. +/// * RESOLUTION-CREATED `TransientContinuousEffect`s, reading BOTH gates that +/// [`transient_effect_is_live`] consults, via [`transient_gate_conditions`]: +/// the "for as long as" duration (CR 611.2b) and the retained enabling +/// condition. CR 611.2c locks in a resolved effect's affected SET, not +/// either gate, so both flip on entry while the frozen set goes stale with +/// them — see the transient walk below for why it has no truth-delta stage. /// /// O((active-source-count + transient-count) × entered-count); short-circuits on /// the first match. @@ -4297,12 +4341,15 @@ fn any_active_static_condition_perturbed_by_entry( if found { return true; } - // CR 611.2c + CR 611.3a: the walk above sees only PRINTED (and granted-inner) + // CR 611.2b + CR 611.2c: the walk above sees only PRINTED (and granted-inner) // static definitions. A continuous effect created by the resolution of a - // spell or ability keeps its enabling condition LIVE — CR 611.2c locks in the - // affected SET, and nothing else — so a source-level population gate riding - // on a transient flips on entry exactly like a printed one, and every - // recipient frozen into that effect's set goes stale with it. + // spell or ability keeps BOTH of its gates live — CR 611.2c locks in the + // affected SET, and nothing else — so a population-dependent gate riding on + // a transient flips on entry while every recipient frozen into that effect's + // set goes stale with it. Both gates are walked through + // `transient_gate_conditions`: the "for as long as" duration is CR 611.2b + // (Master Thief), and the retained condition is the source definition's own + // CR 611.3a gate (`effects/counter.rs::apply_source_static`). // // No truth-delta stage here: `static_gate_truth` is keyed by // `(source, def_index)` over printed definitions, and a transient has no @@ -4312,19 +4359,17 @@ fn any_active_static_condition_perturbed_by_entry( // ships a wrong board — and it matches the recipient-context arm above, // which also escalates on perturbation with no cache consult. state.transient_continuous_effects.iter().any(|tce| { - let Some(condition) = tce.condition.as_ref() else { - return false; - }; - if !static_condition_uses_object_population(condition) { - return false; - } // CR 109.5: a resolved spell or ability RETAINS its controller, so "you" - // in the retained gate names `tce.controller` — not whoever controls the - // source object now (that reading is only correct for static abilities). + // in either retained gate names `tce.controller` — not whoever controls + // the source object now (that reading is only correct for static + // abilities). let ctx = FilterContext::from_source_with_controller(tce.source_id, tce.controller); - entered_ids - .iter() - .any(|id| entered_object_perturbs_static_condition(state, *id, &ctx, condition)) + transient_gate_conditions(tce).any(|condition| { + static_condition_uses_object_population(condition) + && entered_ids + .iter() + .any(|id| entered_object_perturbs_static_condition(state, *id, &ctx, condition)) + }) }) } @@ -5563,8 +5608,46 @@ pub(crate) fn gather_transient_continuous_effects( } } -fn transient_duration_holds(state: &GameState, tce: &TransientContinuousEffect) -> bool { +/// CR 611.2b: the enabling condition a `"for as long as …"` DURATION carries. +/// +/// Single authority for "which condition does `tce.duration` gate on". The +/// liveness evaluator ([`transient_duration_holds`]), the read union +/// ([`live_characteristic_reads`]) and the entry-perturbation probe +/// ([`any_active_static_condition_perturbed_by_entry`]) all ask through here, +/// so a second condition-bearing `Duration` variant is wired into all three by +/// editing one function. +fn transient_duration_condition(tce: &TransientContinuousEffect) -> Option<&StaticCondition> { let Duration::ForAsLongAs { ref condition } = tce.duration else { + return None; + }; + Some(condition) +} + +/// CR 611.2b + CR 611.2c: every condition that gates whether a +/// resolution-created continuous effect is live on THIS pass. +/// +/// CR 611.2c freezes such an effect's affected SET when it begins and nothing +/// else, so both gates below stay live and can flip long after that set is +/// fixed: +/// +/// * the `"for as long as …"` DURATION (CR 611.2b — Master Thief's "gain +/// control of target artifact for as long as you control this creature"); +/// * the retained enabling CONDITION, which is the source `StaticDefinition`'s +/// own CR 611.3a gate riding along on the transient +/// (`effects/counter.rs::apply_source_static`). +/// +/// [`transient_effect_is_live`] consults exactly this pair, so any channel that +/// must see "what could turn this effect on or off" walks the same pair. +fn transient_gate_conditions( + tce: &TransientContinuousEffect, +) -> impl Iterator { + transient_duration_condition(tce) + .into_iter() + .chain(tce.condition.as_ref()) +} + +fn transient_duration_holds(state: &GameState, tce: &TransientContinuousEffect) -> bool { + let Some(condition) = transient_duration_condition(tce) else { return true; }; diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 2ac0829e96..db7e291f3d 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -11386,6 +11386,21 @@ mod tests { pts } + /// `pts_named`'s twin for boards where a layer-1 `SetName` override + /// (CR 707.9b) rewrites the live name: selects on the PRINTED name, + /// which `reset_recipient_to_base` restores at the top of every pass. + fn pts_base_named(state: &GameState, prefix: &str) -> Vec<(Option, Option)> { + let mut pts: Vec<(Option, Option)> = state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .filter(|o| o.base_name.starts_with(prefix)) + .map(|o| (o.power, o.toughness)) + .collect(); + pts.sort(); + pts + } + /// A `Continuous` static definition over `affected` applying `mods`. fn continuous_static( affected: TargetFilter, @@ -12042,54 +12057,66 @@ mod tests { } // ------------------------------------------------------------------ - // RESOLUTION-CREATED continuous effects (CR 611.2c + CR 611.3a). + // RESOLUTION-CREATED continuous effects (CR 611.2b + CR 611.2c). // // A `TransientContinuousEffect` is the third `ActiveContinuousEffect` // producer, alongside printed statics and granted-inner statics. It is - // the only one whose affected set is FROZEN (CR 611.2c) while its - // enabling condition stays LIVE (CR 611.3a), so it is the only one - // where the affected-filter channel reports EMPTY and the condition is - // the sole live read. Both boards below build that effect through the - // single construction authority — see `install_gated_transient` for - // which production path produces this shape and which does NOT. + // the only one whose affected set is FROZEN (CR 611.2c) while its two + // gates stay LIVE, so it is the only one where the affected-filter + // channel reports EMPTY and a gate is the sole live read. Those gates + // are the "for as long as" DURATION (CR 611.2b) and the retained + // CONDITION, which is the source definition's own CR 611.3a gate + // carried along; `transient_effect_is_live` consults exactly that pair. + // Every board below builds its effect through the single construction + // authority — see `install_gated_transient` for which production path + // produces this shape and which does NOT. // ------------------------------------------------------------------ - /// Install a resolution-created continuous effect that RETAINS its - /// enabling condition, through the single construction authority + /// Install a resolution-created continuous effect that RETAINS a gate, + /// through the single construction authority /// (`GameState::add_transient_continuous_effect`), one effect per /// recipient. /// /// CR 611.2c: the affected set is already frozen to `SpecificObject` — /// a filter that reads NO layer-writable characteristic — which is what - /// every transient looks like once its effect has begun. CR 611.3a: the - /// condition rides alongside and stays live, re-evaluated on every - /// later pass. That asymmetry (frozen set, live gate) is what these two - /// boards exercise. + /// every transient looks like once its effect has begun. The gate rides + /// alongside and stays live: a `Duration::ForAsLongAs` re-evaluates per + /// pass because CR 611.2b makes the effect last exactly as long as its + /// stated condition holds, and a retained `condition` re-evaluates + /// because it is the source `StaticDefinition`'s own CR 611.3a gate. + /// That asymmetry (frozen set, live gate) is what these boards + /// exercise. /// - /// REACHABILITY: `Effect::GenericEffect` is NOT the producer of this - /// shape. Per CR 608.2h + CR 611.2d its resolver determines an - /// in-effect "if " exactly once, at resolution, and installs - /// the transient with `condition: None` + /// REACHABILITY: `Effect::GenericEffect` is NOT the producer of the + /// retained-`condition` shape. Per CR 608.2h + CR 611.2d its resolver + /// determines an in-effect "if " exactly once, at + /// resolution, and installs the transient with `condition: None` /// (`effects/effect.rs::resolve`). A conditioned transient comes from /// riders that hand a `StaticDefinition`'s condition straight to the /// constructor — `effects/counter.rs::apply_source_static` (the - /// `CounterSourceRider::LosesAbilities` rider) is the live example. So - /// these fixtures call the constructor exactly the way that rider does. + /// `CounterSourceRider::LosesAbilities` rider) is the live example, and + /// no shipped card gives that rider a condition yet, so the + /// `condition`-gated boards below are preventive. The + /// `Duration::ForAsLongAs` shape needs no such caveat: it is what the + /// parser emits for any "for as long as …" clause it can read + /// (`parser/oracle_nom/duration.rs`), and gain-control, phasing and + /// copy effects install it today. fn install_gated_transient( state: &mut GameState, source: ObjectId, recipients: &[ObjectId], mods: Vec, - condition: crate::types::ability::StaticCondition, + duration: Duration, + condition: Option, ) { for &id in recipients { state.add_transient_continuous_effect( source, PlayerId(0), - Duration::UntilEndOfTurn, + duration.clone(), TargetFilter::SpecificObject { id }, mods.clone(), - Some(condition.clone()), + condition.clone(), ); } } @@ -12102,18 +12129,24 @@ mod tests { /// entrant, so the Land population that condition counts moves for /// every pre-existing recipient. /// - /// CR 611.2c freezes the affected SET and nothing else; CR 611.3a keeps - /// the gate re-evaluating on every pass, here per recipient + /// CR 611.2c freezes the affected SET and nothing else; the retained + /// gate is the source definition's own CR 611.3a condition, so it keeps + /// re-evaluating on every pass, here per recipient /// (`FilterProp::Another`). Pre-entry each 2/2 recipient sees exactly /// 1 OTHER Land so `GE 2` is OFF; post-entry it sees 2 and turns ON /// (3/3). /// /// DISCRIMINATING because the transient is the ONLY condition on the - /// board: drop the condition channel from `live_characteristic_reads` + /// board: drop the `e.condition` channel from `live_characteristic_reads` /// and `ReadKinds` loses CardTypes entirely. Stage 4 then exempts the /// layer-4 writer under CR 613.6 — the only other CardTypes read is its /// OWN affected filter — the entry stays on the incremental path, and - /// the pre-existing recipients keep a stale 2/2. + /// the pre-existing recipients keep a stale 2/2. The entry-perturbation + /// probe cannot rescue it: the entrant is not a Land until layer 4 has + /// run, so `entered_object_perturbs_static_condition` sees no + /// perturbation. The recipient-context gate is what keeps this board on + /// the `e.condition` channel — `transient_source_level_condition_read_board` + /// covers the source-level twin, which that channel never sees. fn transient_condition_read_board() -> GameState { use crate::types::ability::{ContinuousModification, StaticCondition}; let mut state = setup(); @@ -12145,8 +12178,9 @@ mod tests { ContinuousModification::AddPower { value: 1 }, ContinuousModification::AddToughness { value: 1 }, ], + Duration::UntilEndOfTurn, // Recipient-relative: "two or more OTHER Lands". - StaticCondition::QuantityComparison { + Some(StaticCondition::QuantityComparison { lhs: QuantityExpr::Ref { qty: QuantityRef::ObjectCount { filter: TargetFilter::Typed(TypedFilter { @@ -12158,7 +12192,7 @@ mod tests { }, comparator: Comparator::GE, rhs: QuantityExpr::Fixed { value: 2 }, - }, + }), ); state.layers_dirty = crate::types::game_state::LayersDirty::Full; state @@ -12212,8 +12246,9 @@ mod tests { } /// (3.2) TRANSIENT SOURCE-LEVEL GATE. Same frozen-set/live-gate - /// asymmetry, but the gate is a plain SOURCE-LEVEL presence check - /// (CR 611.3a) instead of a recipient-context count, and NOTHING on the + /// asymmetry, but the gate is a plain SOURCE-LEVEL presence check (the + /// source definition's own CR 611.3a condition, carried onto the + /// transient) instead of a recipient-context count, and NOTHING on the /// board writes the kinds it reads. That makes the entry-perturbation /// probe the only disjunct that can catch it: an opponent's creature /// entering flips `IsPresent{creature an opponent controls}` from OFF @@ -12248,15 +12283,16 @@ mod tests { ContinuousModification::AddPower { value: 3 }, ContinuousModification::AddToughness { value: 3 }, ], + Duration::UntilEndOfTurn, // CR 109.5: a resolved effect RETAINS its controller, so "an // opponent" is read against P0. OFF on this board. - StaticCondition::IsPresent { + Some(StaticCondition::IsPresent { filter: Some(TargetFilter::Typed(TypedFilter { type_filters: vec![TypeFilter::Creature], controller: Some(ControllerRef::Opponent), ..Default::default() })), - }, + }), ); state.layers_dirty = crate::types::game_state::LayersDirty::Full; state @@ -12295,6 +12331,320 @@ mod tests { assert_pt_identical(&normal, &forced, "transient source-level gate"); } + /// The name a layer-1 override (CR 707.9b) stamps onto every creature on + /// the (3.3)/(3.4) boards. + const OVERRIDDEN_NAME: &str = "Cloned Bear"; + + /// "Three or more permanents named `OVERRIDDEN_NAME`", counted + /// board-wide. Same shape as (3.1)'s gate minus the recipient context: + /// no `FilterProp::Another`, so `condition_uses_recipient_context` is + /// false and every gather strips it off the effect it pushes. + fn overridden_name_count_at_least(count: i32) -> crate::types::ability::StaticCondition { + crate::types::ability::StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Named { + name: OVERRIDDEN_NAME.to_string(), + }, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: count }, + } + } + + /// Two 2/2 recipients, a printed LAYER-1 `SetName` override (CR 707.9b) + /// over creatures, and one +1/+1 transient per recipient gated on + /// `install_gate`. + /// + /// WHY LAYER 1 and not the layer-4 rewrite (3.1) uses: a source-level + /// condition and a `ForAsLongAs` duration are both evaluated inside + /// `gather_transient_continuous_effects`, and `evaluate_layers` gathers + /// at Step 3 — after layer 1 has been applied and before layers 2-7. + /// Layer 1 is therefore the ONLY layer whose writes such a gate can see + /// within one pass. (A retained recipient-context condition is instead + /// re-checked at APPLY time, which is why (3.1) can use layer 4.) + /// `prepare_incremental_flush` gathers with NO layer applied at all, so + /// the entrant is still printed-named there — that divergence is exactly + /// the staleness these boards catch. + /// + /// Nothing else on the board is a creature, so the overridden-name + /// population is exactly the creature count: 2 before the entry, 3 + /// after, which moves a `GE 3` gate from OFF to ON. + fn transient_name_count_gate_board( + install_gate: impl Fn(&mut GameState, ObjectId, &[ObjectId]), + ) -> GameState { + use crate::types::ability::ContinuousModification; + let mut state = setup(); + let mut bears = Vec::new(); + for i in 0..2 { + bears.push(add_relation_bear( + &mut state, + 780 + i, + &format!("NameBear{i}"), + vec![], + )); + } + // CR 707.9b: a layer-1 copiable-value name override, which MOVES the + // counted population by reaching the entrant. + let rename = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::SetName { + name: OVERRIDDEN_NAME.to_string(), + }], + ); + install_static_enchantment(&mut state, 782, "Mass Renaming", vec![rename]); + let source = install_static_enchantment(&mut state, 783, "Name-Gated Grant", vec![]); + install_gate(&mut state, source, &bears); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + /// (3.3) TRANSIENT SOURCE-LEVEL CONDITION, READ CHANNEL. The twin of + /// (3.1) with the recipient context removed. A source-level condition + /// never reaches `ActiveContinuousEffect::condition`: + /// `gather_transient_continuous_effects` strips it (only a + /// recipient-context condition is retained), and while the gate is OFF + /// the effect is not gathered at all. So the ONLY way NameText enters + /// `ReadKinds` is the walk over `state.transient_continuous_effects`. + /// + /// DISCRIMINATING: drop that walk and `ReadKinds` holds only the + /// CardTypes its affected filters read, which is disjoint from the + /// layer-1 `SetName` writer's `{NameText}` — the relation exits at + /// stage 3, the entry stays incremental, and the recipients keep a + /// stale 2/2 while a full pass says 3/3. The perturbation probe cannot + /// rescue it either: the entrant still carries its printed name when + /// the probe runs, so it does not perturb the overridden-name count. + fn transient_source_level_condition_read_board() -> GameState { + use crate::types::ability::ContinuousModification; + transient_name_count_gate_board(|state, source, bears| { + install_gated_transient( + state, + source, + bears, + vec![ + ContinuousModification::AddPower { value: 1 }, + ContinuousModification::AddToughness { value: 1 }, + ], + Duration::UntilEndOfTurn, + Some(overridden_name_count_at_least(3)), + ) + }) + } + + #[test] + fn name_rewrite_entry_escalates_through_transient_source_level_condition_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_source_level_condition_read_board, |s| { + add_colorless_creature_entry(s, 784) + }); + assert!( + escalated, + "a source-level gate on a resolution-created effect is stripped from \ + the gathered effect, so only the transient walk can put NameText in \ + ReadKinds — a layer-1 name override reaching the entrant must escalate" + ); + // Non-vacuity: the gate really is the board-wide count — no + // `FilterProp::Another`, nothing else recipient-relative — so it is + // source-level and every gather strips it before the `e.condition` + // channel could ever see it. + assert!( + !forced.transient_continuous_effects.is_empty() + && forced.transient_continuous_effects.iter().all(|tce| { + matches!(tce.affected, TargetFilter::SpecificObject { .. }) + && tce.condition.as_ref() == Some(&overridden_name_count_at_least(3)) + }), + "the fixture must install SpecificObject-bound transients whose gate is \ + source-level, or the `e.condition` channel would cover this board" + ); + let mut pre = transient_source_level_condition_read_board(); + flush_layers(&mut pre); + assert_eq!( + pts_base_named(&pre, "NameBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry only 2 permanents carry the overridden name, so GE 3 is OFF" + ); + assert_eq!( + pts_base_named(&forced, "NameBear"), + vec![(Some(3), Some(3)); 2], + "layer 1 renames the entrant too, making it the third — the gate turns ON" + ); + assert_eq!( + pts_base_named(&normal, "NameBear"), + pts_base_named(&forced, "NameBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient source-level condition reads"); + } + + /// (3.4) `ForAsLongAs` DURATION, READ CHANNEL. Identical board to (3.3) + /// with the gate moved from `tce.condition` into + /// `Duration::ForAsLongAs` (CR 611.2b — the effect lasts exactly as + /// long as its stated condition holds). `transient_effect_is_live` + /// evaluates it in the same gather, and no gather ever copies a + /// duration's condition onto an `ActiveContinuousEffect`, so this gate + /// is invisible to every channel except the transient walk. + /// + /// DISCRIMINATING: drop `transient_duration_condition` from + /// `transient_gate_conditions` and `ReadKinds` loses NameText exactly + /// as in (3.3) — recipients keep a stale 2/2. + fn transient_duration_gate_read_board() -> GameState { + use crate::types::ability::ContinuousModification; + transient_name_count_gate_board(|state, source, bears| { + install_gated_transient( + state, + source, + bears, + vec![ + ContinuousModification::AddPower { value: 1 }, + ContinuousModification::AddToughness { value: 1 }, + ], + Duration::ForAsLongAs { + condition: overridden_name_count_at_least(3), + }, + None, + ) + }) + } + + #[test] + fn name_rewrite_entry_escalates_through_transient_duration_gate_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_duration_gate_read_board, |s| { + add_colorless_creature_entry(s, 785) + }); + assert!( + escalated, + "CR 611.2b makes a `for as long as` duration a live gate, so the kinds \ + it reads are live reads — a layer-1 name override reaching the entrant \ + must escalate" + ); + // Non-vacuity: the gate lives in the DURATION, not in `condition`, + // so no `tce.condition` channel could have covered this board. + assert!( + !forced.transient_continuous_effects.is_empty() + && forced.transient_continuous_effects.iter().all(|tce| { + tce.condition.is_none() + && matches!(tce.duration, Duration::ForAsLongAs { .. }) + }), + "the fixture must gate purely through `Duration::ForAsLongAs`" + ); + let mut pre = transient_duration_gate_read_board(); + flush_layers(&mut pre); + assert_eq!( + pts_base_named(&pre, "NameBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry only 2 permanents carry the overridden name, so the \ + duration has not started" + ); + assert_eq!( + pts_base_named(&forced, "NameBear"), + vec![(Some(3), Some(3)); 2], + "layer 1 renames the entrant too, making it the third — the duration holds" + ); + assert_eq!( + pts_base_named(&normal, "NameBear"), + pts_base_named(&forced, "NameBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient duration gate reads"); + } + + /// (3.5) `ForAsLongAs` DURATION, PERTURBATION-PROBE CHANNEL. The twin + /// of (3.2) with the gate moved into the duration: Master Thief's "for + /// as long as you control this creature" shape, inverted to an + /// opponent-presence check so an entry can start it. NOTHING on this + /// board writes the kinds the gate reads, so the read union cannot see + /// the flip — while the duration is unmet the effect is not gathered at + /// all and `all_writes` is empty, which exits the kind relation at + /// stage 1. + /// + /// DISCRIMINATING: drop `transient_duration_condition` from + /// `transient_gate_conditions` and the probe's transient arm sees only + /// `tce.condition`, which is `None` here — no disjunct fires, the entry + /// stays incremental, and the frozen recipients keep a stale 2/2 while + /// a full pass says 5/5. + fn transient_duration_gate_probe_board() -> GameState { + use crate::types::ability::{ContinuousModification, StaticCondition}; + use crate::types::ControllerRef; + let mut state = setup(); + let mut bears = Vec::new(); + for i in 0..2 { + bears.push(add_relation_bear( + &mut state, + 790 + i, + &format!("DurationBear{i}"), + vec![], + )); + } + let source = + install_static_enchantment(&mut state, 792, "Opponent-Gated Duration", vec![]); + install_gated_transient( + &mut state, + source, + &bears, + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + // CR 611.2b + CR 109.5: the duration is re-read every pass and + // "an opponent" stays bound to the resolver, P0. + Duration::ForAsLongAs { + condition: StaticCondition::IsPresent { + filter: Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: Some(ControllerRef::Opponent), + ..Default::default() + })), + }, + }, + None, + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn opponent_entry_escalates_through_transient_duration_gate() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_duration_gate_probe_board, |s| { + add_colorless_creature_entry_under(s, 793, PlayerId(1)) + }); + assert!( + escalated, + "CR 611.2c freezes a resolved effect's affected SET, not its duration — \ + an entry that starts a `for as long as` duration must escalate or every \ + frozen recipient keeps a stale board" + ); + // Non-vacuity: the gate lives in the DURATION only. + assert!( + !forced.transient_continuous_effects.is_empty() + && forced.transient_continuous_effects.iter().all(|tce| { + tce.condition.is_none() + && matches!(tce.duration, Duration::ForAsLongAs { .. }) + }), + "the fixture must gate purely through `Duration::ForAsLongAs`" + ); + let mut pre = transient_duration_gate_probe_board(); + flush_layers(&mut pre); + assert_eq!( + pts_named(&pre, "DurationBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry no opponent controls a creature, so the duration never started" + ); + assert_eq!( + pts_named(&forced, "DurationBear"), + vec![(Some(5), Some(5)); 2], + "the opponent's entrant starts the duration for every frozen recipient" + ); + assert_eq!( + pts_named(&normal, "DurationBear"), + pts_named(&forced, "DurationBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient duration gate probe"); + } + /// Assert every battlefield object's computed power/toughness/loyalty and /// keyword set are identical across two states. fn assert_pt_identical(a: &GameState, b: &GameState, label: &str) { From 8fe3914d9c0f6ec9bd6377c878c3e4dc2080f567 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:58:39 -0700 Subject: [PATCH 10/13] test(engine): pin the ControllerRef roster to the FilterProp declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roster guard added in 27b8bf71e asserted `props.len() == 8` against the literal array standing two lines above it, so it could only ever restate itself — adding a `ControllerRef`-carrying variant to `FilterProp` left it green, which is the one drift it claimed to catch. Replace it with two authorities the test cannot edit. `carries_controller_ref` is an exhaustive wildcard-free classifier, so a new `FilterProp` variant fails to compile until it is classified; `declared_controller_ref_carriers` source-scans the `FilterProp` declaration in `types/ability.rs` for the variants that actually carry the field, so the sampled roster must equal the enum. Dropping one sample now fails with the two variant-name lists diffed against each other, and a new carrier turns the same assertion red until it is sampled, classified, and shown to report a CONTROLLER read (CR 613.1b). Also withdraws a claim in 27b8bf71e's own body: it said `FilterProp::DistinctFrom` had been "matched by a `_ =>` wildcard inside a classifier documented as exhaustive and wildcard-free — now an explicit arm". `git show e7f856cff:crates/engine/src/game/filter.rs` shows the parent already had an explicit `match reference.as_ref()` arm; 27b8bf71e rewrote that arm into an `if matches!(...)`, changing no behavior and no exhaustiveness. This restores the `match` form as a readability change only, and the claim should not be read as evidence of a fixed wildcard. Co-Authored-By: Claude Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/filter.rs | 210 +++++++++++++++++++++++++++++-- 1 file changed, 199 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 10b0ab47ad..31c25c1412 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -708,13 +708,10 @@ fn filter_prop_characteristic_reads_at(prop: &FilterProp, depth: u32) -> Charact // CR 109.1: identity exclusion. Against the ability's own parent target // this is pure object identity and reads nothing; against any other // reference the excluded set is filter-derived and could be anything. - FilterProp::DistinctFrom { reference } => { - if matches!(reference.as_ref(), TargetFilter::ParentTarget) { - CharacteristicKinds::EMPTY - } else { - CharacteristicKinds::ALL - } - } + FilterProp::DistinctFrom { reference } => match reference.as_ref() { + TargetFilter::ParentTarget => CharacteristicKinds::EMPTY, + _ => CharacteristicKinds::ALL, + }, // ---- Reads no layer-writable characteristic. ---- // Token identity, zone, combat state, per-object designations, per-turn @@ -13694,6 +13691,186 @@ mod characteristic_read_classification_tests { }) } + /// CR 613.1b: does this `FilterProp` scope its verdict by a `ControllerRef`? + /// + /// EXHAUSTIVE and wildcard-free, which is the whole point: adding a + /// `FilterProp` variant fails to compile here until it is classified, and a + /// variant classified as a carrier must also get a sample in + /// `every_controller_ref_carrying_prop_reads_the_controller_kind` below — + /// which then proves the classifier reports a CONTROLLER read for it. The + /// compiler pins the classification; the roster/`carries_controller_ref` + /// cross-check below pins that the roster does not drift the other way. + fn carries_controller_ref(prop: &FilterProp) -> bool { + match prop { + // The `ControllerRef`-carrying roster. Layer 2 can move an object + // across any scope these name (CR 613.1b), so + // `filter_prop_characteristic_reads_at` must report CONTROLLER for + // every one of them. + FilterProp::Attacking { .. } + | FilterProp::ProtectorMatches { .. } + | FilterProp::Owned { .. } + | FilterProp::HasAttachment { .. } + | FilterProp::HasAnyAttachmentOf { .. } + | FilterProp::MostPrevalentCreatureTypeIn { .. } + | FilterProp::AttackedThisTurn { .. } + | FilterProp::NameMatchesAnyPermanent { .. } => true, + // Everything else carries no `ControllerRef` of its own. Several + // still read CONTROLLER for other reasons (`Unpaired` via CR + // 702.95e, the CR 302.6 continuity props, the nested-filter + // recursers); this classifier answers only "does the variant carry + // the field", never "does it read the kind". + FilterProp::Token + | FilterProp::NonToken + | FilterProp::RepresentedByCard + | FilterProp::ControllerChoseLabel { .. } + | FilterProp::ControllerMatches { .. } + | FilterProp::WasPlayed + | FilterProp::Blocking + | FilterProp::BlockingSource + | FilterProp::CombatRelation { .. } + | FilterProp::Unblocked + | FilterProp::AttackingAlone + | FilterProp::BlockingAlone + | FilterProp::Tapped + | FilterProp::Untapped + | FilterProp::IsSaddled + | FilterProp::SaddledSource + | FilterProp::ConvokedSource + | FilterProp::HasHasteOrControlledSinceTurnBegan + | FilterProp::WithKeyword { .. } + | FilterProp::HasKeywordKind { .. } + | FilterProp::WithoutKeyword { .. } + | FilterProp::WithoutKeywordKind { .. } + | FilterProp::CanEnchant { .. } + | FilterProp::Counters { .. } + | FilterProp::Cmc { .. } + | FilterProp::ManaValueParity { .. } + | FilterProp::ManaCostIn { .. } + | FilterProp::InZone { .. } + | FilterProp::Foretold + | FilterProp::HasAdventure + | FilterProp::EnchantedBy + | FilterProp::EquippedBy + | FilterProp::AttachedToSource + | FilterProp::AttachedToRecipient + | FilterProp::Another + | FilterProp::Unpaired + | FilterProp::OtherThanTriggerObject + | FilterProp::HasColor { .. } + | FilterProp::PtComparison { .. } + | FilterProp::PowerGTSource + | FilterProp::ColorCount { .. } + | FilterProp::ManaSymbolCount { .. } + | FilterProp::HasSupertype { .. } + | FilterProp::IsChosenCreatureType + | FilterProp::IsChosenColor + | FilterProp::IsChosenCardType + | FilterProp::MatchesLastChosenCardPredicate + | FilterProp::HasSingleTarget + | FilterProp::Modal + | FilterProp::NotColor { .. } + | FilterProp::NotSupertype { .. } + | FilterProp::Suspected + | FilterProp::Renowned + | FilterProp::Goaded + | FilterProp::ToughnessGTPower + | FilterProp::PowerExceedsBase + | FilterProp::AnyOf { .. } + | FilterProp::Not { .. } + | FilterProp::InTrackedSet { .. } + | FilterProp::Modified + | FilterProp::Historic + | FilterProp::NotHistoric + | FilterProp::DifferentNameFrom { .. } + | FilterProp::DistinctFrom { .. } + | FilterProp::InAnyZone { .. } + | FilterProp::SharesQuality { .. } + | FilterProp::WasDealtDamageThisTurn + | FilterProp::DealtDamageThisTurn + | FilterProp::EnteredThisTurn + | FilterProp::ControlledContinuouslySinceTurnBegan + | FilterProp::ZoneChangedThisTurn { .. } + | FilterProp::BlockedThisTurn + | FilterProp::AttackedOrBlockedThisTurn + | FilterProp::CountersPutOnThisTurn { .. } + | FilterProp::FaceDown + | FilterProp::Transformed + | FilterProp::TargetsOnly { .. } + | FilterProp::Targets { .. } + | FilterProp::CouldBeTargetedByTriggeringSpell + | FilterProp::HasXInManaCost + | FilterProp::HasXInActivationCost + | FilterProp::WasKicked + | FilterProp::HasManaAbility + | FilterProp::HasNoAbilities + | FilterProp::Named { .. } + | FilterProp::SameName + | FilterProp::SameNameAsParentTarget + | FilterProp::IsCommander + | FilterProp::SharesCreatureTypeWithCommander + | FilterProp::Other { .. } => false, + } + } + + /// The `ControllerRef`-carrying roster read out of the `FilterProp` + /// DECLARATION, so the roster below cannot silently fall behind the enum. + /// + /// Source-scanned rather than hand-counted on purpose: a hand-maintained + /// count can only ever restate the list standing next to it, which is what + /// the previous `assert_eq!(props.len(), 8)` did. Scanning `ability.rs` + /// gives an authority the test cannot edit, so adding a variant with a + /// `ControllerRef` field turns the roster assertion RED until a sample for + /// it is added — and `carries_controller_ref` then forces the sample to be + /// classified, and the CONTROLLER assertion forces the classifier to be + /// right about it. + fn declared_controller_ref_carriers() -> Vec { + let src = include_str!("../types/ability.rs"); + let decl = "pub enum FilterProp {"; + let start = src.find(decl).expect("FilterProp declaration"); + let body = &src[start + decl.len()..]; + let body = &body[..body.find("\n}").expect("end of FilterProp")]; + + let mut carriers = Vec::new(); + let mut current: Option<&str> = None; + for line in body.lines() { + let trimmed = line.trim_start(); + // Doc comments name `ControllerRef` in prose; they declare nothing. + if trimmed.starts_with("//") { + continue; + } + // A variant header is the only thing at one indent level that opens + // with an uppercase letter; its fields sit one level deeper. + if let Some(header) = line + .strip_prefix(" ") + .filter(|l| l.starts_with(char::is_uppercase)) + { + current = Some(header.trim_end_matches([' ', '{', ',', '('])); + } + if let (true, Some(name)) = (trimmed.contains("ControllerRef"), current) { + carriers.push(name.to_string()); + } + } + carriers.sort_unstable(); + carriers.dedup(); + assert!( + !carriers.is_empty(), + "scanned zero carriers — the FilterProp declaration moved or its \ + formatting changed, so this gate is no longer scanning anything" + ); + carriers + } + + /// The variant name of a `FilterProp` sample, which is what `Debug` prints + /// first and is the only handle a value gives onto its own variant. + fn variant_name(prop: &FilterProp) -> String { + let debug = format!("{prop:?}"); + debug + .split(|c: char| !c.is_alphanumeric() && c != '_') + .next() + .expect("Debug output opens with the variant name") + .to_string() + } + /// CR 613.1b: layer 2 can move an object across any controller scope a /// `ControllerRef` names, so every `FilterProp` that carries one reads the /// CONTROLLER kind. The classifier states this invariant in prose; this test @@ -13731,12 +13908,23 @@ mod characteristic_read_classification_tests { controller: Some(ControllerRef::You), }, ]; + let mut sampled: Vec = props.iter().map(variant_name).collect(); + sampled.sort_unstable(); + sampled.dedup(); assert_eq!( - props.len(), - 8, - "the ControllerRef-carrying FilterProp roster grew or shrank — extend \ - this list so the invariant stays fully pinned" + sampled, + declared_controller_ref_carriers(), + "the sampled roster and the `ControllerRef`-carrying variants declared \ + in `types/ability.rs` have diverged — add a sample for every new \ + carrier so the CR 613.1b invariant below stays fully covered" ); + for prop in &props { + assert!( + carries_controller_ref(prop), + "{prop:?} is in the ControllerRef roster but `carries_controller_ref` \ + classifies it as a non-carrier — one of the two is wrong" + ); + } for prop in props { assert!( target_filter_characteristic_reads(&only(prop.clone())) From 5526fdfee8c7bfe268ad6bab823aa146ee4e8a51 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:09:11 -0700 Subject: [PATCH 11/13] refactor(engine): route every transient gate read through one authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 review of this branch found the "single authority" claim in 27b8bf71e still only half true, and three comments claiming more than the code proves. Six sites re-implemented the dual-gate check inline (`casting.rs`, four in `static_abilities.rs`, `turns.rs`) instead of asking `transient_gate_conditions`. Each would have silently kept the old two-gate shape if a third condition-bearing `Duration` variant were added. They now call the iterator, which is `pub(crate)` for it. No behavior change: the iterator yields exactly the pair each site open-coded. `casting.rs` also led with CR 603.4 + CR 608.2h, neither of which describes "both gates must hold" — it now cites CR 611.2b + CR 611.3a like the other five. Docs corrected where they claimed more than the code delivers: * WHAT CONVERGES WHERE is an enumerated producer table for `ActiveContinuousEffect::condition` rather than prose. It names all four `condition: None` producers, and states the consequence the prose had backwards: a GRANTED-INNER static's condition lives in `inner.condition`, which the source walk (over OUTER `obj.static_definitions.iter_all()`) cannot see until a prior pass has materialized it — so on a never-yet-evaluated state `e.condition` is the only channel that sees it. That is what makes the union load-bearing rather than redundant. * `transient_gate_conditions` no longer claims `transient_effect_is_live` consults exactly this pair: it also applies a CR 400.7 recipient-incarnation check and an `UntilHostLeavesPlay` source-zone check. Both read no layer-writable characteristic, which is why they sit outside the iterator and outside `live_characteristic_reads`. The lapsed-attachment sweep is named as the one deliberate non-consumer — it destructures a specific shape (CR 301.5) to decide expiry, not liveness, so routing it here would lose what it matches on. * `declared_controller_ref_carriers` records its ceiling: the scan is textual, so a `ControllerRef` reached through a nested type stays invisible and the roster tripwire only catches total scan failure. Tests: the source-level-condition fixture asserted the gather strips the condition by inference from what it installed. It now runs `gather_transient_continuous_effects` and asserts every produced effect has `condition: None` — the strip is the whole premise of the test, so it is asserted rather than assumed. Co-Authored-By: Claude Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/casting.rs | 18 ++--- crates/engine/src/game/filter.rs | 10 +++ crates/engine/src/game/layers.rs | 65 ++++++++++++++---- crates/engine/src/game/stack.rs | 19 +++-- crates/engine/src/game/static_abilities.rs | 80 ++++++++++------------ crates/engine/src/game/turns.rs | 18 +++-- 6 files changed, 125 insertions(+), 85 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 35eb229199..7aa242ac0d 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -2241,17 +2241,13 @@ fn transient_granted_spell_keywords_for( if id != caster { continue; } - // CR 603.4 + CR 608.2h: mirror `transient_grants_static_mode_to_player`'s - // dual-condition gating exactly. - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !super::layers::transient_gate_conditions(tce).all(|condition| { + super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) + }) { + continue; } for modification in &tce.modifications { let ContinuousModification::GrantStaticAbility { definition } = modification else { diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 31c25c1412..49a00a4768 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -13823,6 +13823,16 @@ mod characteristic_read_classification_tests { /// it is added — and `carries_controller_ref` then forces the sample to be /// classified, and the CONTROLLER assertion forces the classifier to be /// right about it. + /// + /// CEILING: the scan is TEXTUAL, so it only sees `ControllerRef` named + /// directly in a variant's own field list. A future `NewProp { spec: + /// Box }` whose `AttackSpec` holds a `ControllerRef` stays + /// invisible here — the roster does not grow, no sample is forced, and this + /// test stays green while the classifier goes unverified for it. The + /// `!carriers.is_empty()` tripwire below only catches total scan failure + /// (declaration moved or reformatted), not an indirect carrier. Reaching + /// through a nested type needs a real type walk, which is not available + /// without a reflection dependency; classify such a variant by hand. fn declared_controller_ref_carriers() -> Vec { let src = include_str!("../types/ability.rs"); let decl = "pub enum FilterProp {"; diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 3bce1eb2bb..9688275ae7 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3726,20 +3726,36 @@ struct LiveCharacteristicReads { /// admit no such exclusion. /// /// WHAT CONVERGES WHERE. The `e.condition` channel below is NOT a single -/// authority over every gate on the board, and must not be documented as one: +/// authority over every gate on the board, and must not be documented as one. +/// The full producer census of `ActiveContinuousEffect::condition`, and which +/// channel can see each one: /// -/// * printed and granted-inner Continuous statics reach it (gathered with -/// their condition intact), and are ALSO covered by the source walk below, -/// which is what sees a static whose gate is currently OFF; -/// * a transient reaches it only when its gate is currently ON *and* the -/// condition is recipient-context, because -/// [`gather_transient_continuous_effects`] skips a transient that is not -/// live and strips a source-level condition from the effect it pushes. Both +/// | producer | `condition` it writes | seen by | +/// |---|---|---| +/// | The Ring emblem (CR 701.54c) | `None` | nothing to see | +/// | [`collect_shared_active_continuous_effects`] (printed statics) | `def.condition` | `e.condition` AND the source walk | +/// | [`expand_granted_static_effects`] | `inner.condition` | `e.condition` ONLY | +/// | `expand_granted_activated_abilities` | `None` | nothing to see | +/// | `expand_granted_triggered_abilities` | `None` | nothing to see | +/// | [`gather_transient_continuous_effects`] | recipient-context `tce.condition` only | `e.condition`, plus the transient walk for what it drops | +/// | `stickers.rs` (two P/T sites) | `None` | nothing to see | +/// +/// Two consequences a reader must not get backwards: +/// +/// * a GRANTED-INNER static's condition lives in `inner.condition`, nested in +/// `ContinuousModification::GrantStaticAbility`. The source walk below reads +/// `obj.static_definitions.iter_all()`, i.e. OUTER definitions, so it sees +/// that condition only once a previous pass has materialized the granted +/// definition onto the recipient. On a never-yet-evaluated state — hand-built +/// or freshly deserialized — `e.condition` is the ONLY channel that sees it. +/// That is what the `e.condition` union is load-bearing for; it is not +/// redundant with the source walk. +/// * a TRANSIENT reaches `e.condition` only when its gate is currently ON *and* +/// the condition is recipient-context, because +/// [`gather_transient_continuous_effects`] skips a transient that is not live +/// and strips a source-level condition from the effect it pushes. Both /// discarded shapes are exactly the ones an entry can flip, hence the -/// separate walk over `state.transient_continuous_effects`; -/// * the ring/emblem/sticker producers (`stickers.rs`, and the two `condition: -/// None` sites in this file) carry no condition at all today, so they -/// contribute nothing through any channel. +/// separate walk over `state.transient_continuous_effects`. /// /// `active_combat_assignment_rule_effects_from_static_definitions` / /// `collect_transient_combat_assignment_rule_effects` duplicate the same @@ -5636,9 +5652,28 @@ fn transient_duration_condition(tce: &TransientContinuousEffect) -> Option<&Stat /// own CR 611.3a gate riding along on the transient /// (`effects/counter.rs::apply_source_static`). /// -/// [`transient_effect_is_live`] consults exactly this pair, so any channel that -/// must see "what could turn this effect on or off" walks the same pair. -fn transient_gate_conditions( +/// [`transient_effect_is_live`] consults this pair plus two gates that read no +/// layer-writable characteristic at all — the CR 400.7 recipient-incarnation +/// check and the `UntilHostLeavesPlay` source-zone check — which is why they +/// are outside this iterator and outside [`live_characteristic_reads`]: a zone +/// or identity change is not something layers 1-7 can write. +/// +/// Every consumer that asks "what could turn this effect on or off" walks this +/// pair through here: [`transient_effect_is_live`] (via +/// [`transient_duration_holds`] and its own `tce.condition` arm), +/// [`live_characteristic_reads`], [`any_active_static_condition_perturbed_by_entry`], +/// `casting::transient_granted_spell_keywords_for` and +/// `static_abilities::transient_grants_static_mode_to_player`. The last two +/// evaluate with `evaluate_condition` rather than `source_condition_gate_passes` +/// — the authority is over WHICH conditions gate the effect, not over how a +/// given caller evaluates them. +/// +/// One deliberate non-consumer: the lapsed-attachment sweep in +/// [`evaluate_layers`] destructures the exact `ForAsLongAs { +/// RecipientMatchesFilter { AttachedTo } }` shape (CR 301.5) to decide +/// permanent EXPIRY, not liveness. It needs the structural match, not the +/// condition list, so routing it through here would lose the thing it matches on. +pub(crate) fn transient_gate_conditions( tce: &TransientContinuousEffect, ) -> impl Iterator { transient_duration_condition(tce) diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index db7e291f3d..29402e7b38 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -12444,10 +12444,9 @@ mod tests { the gathered effect, so only the transient walk can put NameText in \ ReadKinds — a layer-1 name override reaching the entrant must escalate" ); - // Non-vacuity: the gate really is the board-wide count — no - // `FilterProp::Another`, nothing else recipient-relative — so it is - // source-level and every gather strips it before the `e.condition` - // channel could ever see it. + // Non-vacuity, fixture side: the installed gate really is the + // board-wide count — no `FilterProp::Another`, nothing else + // recipient-relative — so it is source-level. assert!( !forced.transient_continuous_effects.is_empty() && forced.transient_continuous_effects.iter().all(|tce| { @@ -12457,6 +12456,18 @@ mod tests { "the fixture must install SpecificObject-bound transients whose gate is \ source-level, or the `e.condition` channel would cover this board" ); + // Non-vacuity, GATHERED side: asserting the fixture only proves what + // was installed. Run the real gather and confirm the condition is + // gone from every effect it produces — that strip is the whole + // premise of this test, so it is asserted, not inferred. + let mut gathered = Vec::new(); + crate::game::layers::gather_transient_continuous_effects(&forced, &mut gathered); + assert!( + !gathered.is_empty() && gathered.iter().all(|e| e.condition.is_none()), + "the gather must strip this source-level condition; if it retained it, \ + `e.condition` would cover the board and the transient walk would be \ + untested here" + ); let mut pre = transient_source_level_condition_read_board(); flush_layers(&mut pre); assert_eq!( diff --git a/crates/engine/src/game/static_abilities.rs b/crates/engine/src/game/static_abilities.rs index 7edbffa73b..727116223c 100644 --- a/crates/engine/src/game/static_abilities.rs +++ b/crates/engine/src/game/static_abilities.rs @@ -846,15 +846,13 @@ pub(crate) fn transient_grants_static_mode_to_player( if affected_id != player_id { continue; } - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } let grants_mode = tce.modifications.iter().any(|m| { matches!(m, ContinuousModification::AddStaticMode { mode: m_mode } if m_mode == mode) @@ -894,15 +892,13 @@ pub(crate) fn transient_grants_static_mode_to_object( ) { continue; } - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } let grants_mode = tce.modifications.iter().any(|m| { matches!(m, ContinuousModification::AddStaticMode { mode: m_mode } if m_mode == mode) @@ -1479,15 +1475,13 @@ pub fn player_has_protection_from_everything(state: &GameState, player_id: Playe continue; } // CR 611.2b: ForAsLongAs durations re-evaluate their condition each cycle. - if let crate::types::ability::Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } let grants_everything = tce.modifications.iter().any(|m| { matches!( @@ -1805,15 +1799,13 @@ fn transient_grants_other_static_to_context( continue; } // CR 611.2b: ForAsLongAs durations re-evaluate their condition each cycle. - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } let grants_named_other = tce.modifications.iter().any(|m| { matches!( @@ -2099,15 +2091,13 @@ fn transient_additional_land_drops(state: &GameState, player: PlayerId) -> u8 { continue; } // CR 611.2b: ForAsLongAs durations re-evaluate their condition each cycle. - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } for m in &tce.modifications { if let ContinuousModification::AddStaticMode { mode } = m { diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 6f53ec78aa..20b4e70f5d 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -716,7 +716,7 @@ fn scan_step_end_mana_handlers( state: &GameState, player_id: PlayerId, ) -> Vec { - use crate::types::ability::{ContinuousModification, Duration, TargetFilter}; + use crate::types::ability::{ContinuousModification, TargetFilter}; use crate::types::game_state::StepEndManaScanEntry; let context = super::static_abilities::StaticCheckContext { @@ -763,15 +763,13 @@ fn scan_step_end_mana_handlers( if affected_id != player_id { continue; } - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !super::layers::transient_gate_conditions(tce).all(|condition| { + super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) + }) { + continue; } for modification in &tce.modifications { if let ContinuousModification::AddStaticMode { From 51af30ea4b3a5ae3d9fa2ed9571f7ccf7c3ce5b5 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:26:17 -0700 Subject: [PATCH 12/13] refactor(engine): route the last two hand-rolled transient gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit claimed `transient_gate_conditions` is the authority every consumer walks, and enumerated them. The enumeration was incomplete — the same false-completeness defect it was written to fix. Two sites still destructured the pair by hand: * `static_abilities::object_has_active_cant_phase_in` wrapped it in a local `condition_holds` closure taking duration and condition as separate parameters, so it read as a helper rather than a duplicate. * `visibility::viewer_may_look_at_face_down` inlined it under a comment saying it honors "the same duration/condition gates the static-mode TCE queries in `static_abilities.rs` apply" — naming the shared rule while keeping a private copy of it. Both now call the iterator. No behavior change: it yields exactly the pair each site open-coded, in the same order. The doc no longer enumerates consumers, because an enumeration is what went stale. It states the invariant and how to check it instead: outside `layers.rs`, `Duration::ForAsLongAs` appears only in constructors, in the `ability_rw` / `ability_scan` / `coverage` walkers that classify a duration without evaluating it, and in the lapsed-attachment sweep documented as a deliberate non-consumer. A new site that destructures the pair by hand is the regression to look for. Co-Authored-By: Claude Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/layers.rs | 30 ++++++++++++++++------ crates/engine/src/game/static_abilities.rs | 26 ++++++------------- crates/engine/src/game/visibility.rs | 22 +++++++--------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 9688275ae7..363f5859d9 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3728,12 +3728,15 @@ struct LiveCharacteristicReads { /// WHAT CONVERGES WHERE. The `e.condition` channel below is NOT a single /// authority over every gate on the board, and must not be documented as one. /// The full producer census of `ActiveContinuousEffect::condition`, and which -/// channel can see each one: +/// channel can see each one. One row per construction site — the eight +/// `ActiveContinuousEffect { .. }` literals in the engine, six here and two in +/// `stickers.rs`, all reached through +/// [`collect_shared_active_continuous_effects`]: /// /// | producer | `condition` it writes | seen by | /// |---|---|---| /// | The Ring emblem (CR 701.54c) | `None` | nothing to see | -/// | [`collect_shared_active_continuous_effects`] (printed statics) | `def.condition` | `e.condition` AND the source walk | +/// | [`active_continuous_effects_from_static_definitions`] (printed statics) | `def.condition` | `e.condition` AND the source walk | /// | [`expand_granted_static_effects`] | `inner.condition` | `e.condition` ONLY | /// | `expand_granted_activated_abilities` | `None` | nothing to see | /// | `expand_granted_triggered_abilities` | `None` | nothing to see | @@ -5661,12 +5664,23 @@ fn transient_duration_condition(tce: &TransientContinuousEffect) -> Option<&Stat /// Every consumer that asks "what could turn this effect on or off" walks this /// pair through here: [`transient_effect_is_live`] (via /// [`transient_duration_holds`] and its own `tce.condition` arm), -/// [`live_characteristic_reads`], [`any_active_static_condition_perturbed_by_entry`], -/// `casting::transient_granted_spell_keywords_for` and -/// `static_abilities::transient_grants_static_mode_to_player`. The last two -/// evaluate with `evaluate_condition` rather than `source_condition_gate_passes` -/// — the authority is over WHICH conditions gate the effect, not over how a -/// given caller evaluates them. +/// [`live_characteristic_reads`] and +/// [`any_active_static_condition_perturbed_by_entry`] in this module, six +/// static-mode/protection queries in `static_abilities`, plus +/// `casting::transient_granted_spell_keywords_for`, +/// `turns::scan_step_end_mana_handlers` and +/// `visibility::viewer_may_look_at_face_down`. All but the first evaluate with +/// `evaluate_condition` rather than `source_condition_gate_passes` — the +/// authority is over WHICH conditions gate the effect, not over how a given +/// caller evaluates them. +/// +/// That claim is grep-checkable rather than enumerated, because an enumeration +/// silently goes stale: outside this file, `Duration::ForAsLongAs` appears only +/// in constructors (`add_transient_continuous_effect` call sites), in the +/// `ability_rw` / `ability_scan` / `coverage` walkers that classify a duration +/// without evaluating it, and in the non-consumer below. A new site that +/// destructures the pair by hand instead of calling this is the regression to +/// look for. /// /// One deliberate non-consumer: the lapsed-attachment sweep in /// [`evaluate_layers`] destructures the exact `ForAsLongAs { diff --git a/crates/engine/src/game/static_abilities.rs b/crates/engine/src/game/static_abilities.rs index 727116223c..cac61afe3c 100644 --- a/crates/engine/src/game/static_abilities.rs +++ b/crates/engine/src/game/static_abilities.rs @@ -9,7 +9,7 @@ use crate::game::functioning_abilities::{ use crate::game::game_object::GameObject; use crate::game::layers::{evaluate_condition, evaluate_condition_with_recipient}; use crate::types::ability::{ - ContinuousModification, ControllerRef, Duration, StaticDefinition, TargetFilter, TypedFilter, + ContinuousModification, ControllerRef, StaticDefinition, TargetFilter, TypedFilter, }; use crate::types::game_state::GameState; use crate::types::identifiers::ObjectId; @@ -928,22 +928,12 @@ pub(crate) fn transient_grants_static_mode_to_object( /// deliberately skips); (2) any filter-scoped transient grant; and (3) a printed /// static (parity with the `CantUntap` intrinsic path, future-proofing). pub(crate) fn object_has_active_cant_phase_in(state: &GameState, object_id: ObjectId) -> bool { - let condition_holds = |duration: &Duration, - condition: &Option, - controller: PlayerId, - source_id: ObjectId| - -> bool { - if let Duration::ForAsLongAs { condition } = duration { - if !evaluate_condition(state, condition, controller, source_id) { - return false; - } - } - if let Some(condition) = condition { - if !evaluate_condition(state, condition, controller, source_id) { - return false; - } - } - true + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + let condition_holds = |tce: &crate::types::game_state::TransientContinuousEffect| { + crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) }; // (1) SpecificObject-pinned transient grant — the Pandorica lock. @@ -957,7 +947,7 @@ pub(crate) fn object_has_active_cant_phase_in(state: &GameState, object_id: Obje } ) }) - && condition_holds(&tce.duration, &tce.condition, tce.controller, tce.source_id) + && condition_holds(tce) }); if pinned { return true; diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index faed8a498e..453acfd137 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -1698,7 +1698,7 @@ fn viewer_may_look_at_face_down( obj_id: ObjectId, can_view_private_for_player: &impl Fn(PlayerId) -> bool, ) -> bool { - use crate::types::ability::{ContinuousModification, Duration}; + use crate::types::ability::ContinuousModification; use crate::types::statics::{StaticMode, StaticModeKind}; // CR 708.5: O(1) presence gate covers ONLY the battlefield-static authority. The // duration-bound `transient_continuous_effects` scan below is a separate authority @@ -1741,18 +1741,14 @@ fn viewer_may_look_at_face_down( if !grants_look { continue; } - // Honor the same duration/condition gates the static-mode TCE queries in - // `static_abilities.rs` apply (a `ForAsLongAs` duration or explicit - // `condition` must still hold this look). - if let Duration::ForAsLongAs { condition } = &tce.duration { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(condition) = &tce.condition { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are, shared with the static-mode TCE queries in + // `static_abilities.rs`. + if !super::layers::transient_gate_conditions(tce).all(|condition| { + super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) + }) { + continue; } // CR 608.2c: "you" is latched to the player who controlled the ability at // resolution (the stored `tce.controller`), NOT the source's current From 9ec4a136c3adf1849bd55ba0bb81d614cb2612a0 Mon Sep 17 00:00:00 2001 From: mcbradd <35860549+mcbradd@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:26:51 -0700 Subject: [PATCH 13/13] docs(engine): scope the transient-gate authority claim to liveness evaluators The consumer enumeration above `transient_gate_conditions` claimed every consumer of the duration/condition pair routes through it, and offered `Duration::ForAsLongAs` as the grep that would catch a new offender. Both were wrong in the same direction: too broad a claim, too narrow a grep. The claim now covers consumers that EVALUATE liveness. Two classes are named as out of scope instead of silently contradicting it: * Walkers that CLASSIFY a duration without evaluating it -- `analysis::resource` (two hand-destructuring sibling-mutability scans) plus `ability_rw` / `ability_scan` / `coverage`. They stay variant-safe through `ability_scan`'s exhaustive matches, not through this authority. * Gate-blind consumers, disclosed as a tracked pre-existing gap: `casting::apply_static_activated_ability_cost_reduction` and `effects::attach::protection_blocks_attachment` apply a transient effect without consulting either gate. Routing them here changes behavior and needs its own CR analysis and tests. The grep recipe becomes the iteration site `for tce in &state.transient_continuous_effects`. `Duration::ForAsLongAs` only matches sites that already destructure the duration, so it structurally cannot see the gate-blind pair. Also scopes the producer census: `active_continuous_effects_from_base_static_source` is an alternate entry into an already-censused row, not a ninth producer, and never feeds `evaluate_layers`. Documentation only -- no production or test behavior changes. Co-Authored-By: Claude Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/layers.rs | 40 ++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 363f5859d9..3a131a3fb0 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3743,6 +3743,12 @@ struct LiveCharacteristicReads { /// | [`gather_transient_continuous_effects`] | recipient-context `tce.condition` only | `e.condition`, plus the transient walk for what it drops | /// | `stickers.rs` (two P/T sites) | `None` | nothing to see | /// +/// One alternate ENTRY into row 2 is out of this census by scope: +/// [`active_continuous_effects_from_base_static_source`] re-enters +/// [`active_continuous_effects_from_static_definitions`] for off-zone keyword +/// queries (sole caller `off_zone_characteristics.rs`) and never feeds +/// [`evaluate_layers`], so no flush channel needs to see its conditions. +/// /// Two consequences a reader must not get backwards: /// /// * a GRANTED-INNER static's condition lives in `inner.condition`, nested in @@ -5661,8 +5667,8 @@ fn transient_duration_condition(tce: &TransientContinuousEffect) -> Option<&Stat /// are outside this iterator and outside [`live_characteristic_reads`]: a zone /// or identity change is not something layers 1-7 can write. /// -/// Every consumer that asks "what could turn this effect on or off" walks this -/// pair through here: [`transient_effect_is_live`] (via +/// Every consumer that EVALUATES whether this effect is live walks the pair +/// through here: [`transient_effect_is_live`] (via /// [`transient_duration_holds`] and its own `tce.condition` arm), /// [`live_characteristic_reads`] and /// [`any_active_static_condition_perturbed_by_entry`] in this module, six @@ -5674,15 +5680,29 @@ fn transient_duration_condition(tce: &TransientContinuousEffect) -> Option<&Stat /// authority is over WHICH conditions gate the effect, not over how a given /// caller evaluates them. /// -/// That claim is grep-checkable rather than enumerated, because an enumeration -/// silently goes stale: outside this file, `Duration::ForAsLongAs` appears only -/// in constructors (`add_transient_continuous_effect` call sites), in the -/// `ability_rw` / `ability_scan` / `coverage` walkers that classify a duration -/// without evaluating it, and in the non-consumer below. A new site that -/// destructures the pair by hand instead of calling this is the regression to -/// look for. +/// Two classes sit deliberately outside that claim. +/// +/// Walkers that CLASSIFY a duration without evaluating it are not consumers: +/// `analysis::resource` (two hand-destructuring sibling-mutability scans), +/// plus the `ability_rw` / `ability_scan` / `coverage` walkers. They ask what a +/// duration reads, never whether it holds, and they stay variant-safe through +/// `ability_scan`'s exhaustive matches rather than through this authority. +/// +/// Gate-blind consumers are a tracked pre-existing gap, not an exemption: +/// `casting::apply_static_activated_ability_cost_reduction` and +/// `effects::attach::protection_blocks_attachment` apply a transient effect +/// without consulting either gate, so a lapsed condition still reduces a cost +/// or blocks an attachment. Routing them through here changes behavior and +/// needs its own CR analysis and tests, so it is out of scope here. +/// +/// The grep that surfaces a new offender is `for tce in +/// &state.transient_continuous_effects` — the iteration site, not +/// `Duration::ForAsLongAs`. The latter only matches sites that already +/// destructure the duration, so it cannot see the gate-blind pair above. A new +/// iteration site that decides liveness by hand instead of calling this is the +/// regression to look for. /// -/// One deliberate non-consumer: the lapsed-attachment sweep in +/// One deliberate non-consumer inside this file: the lapsed-attachment sweep in /// [`evaluate_layers`] destructures the exact `ForAsLongAs { /// RecipientMatchesFilter { AttachedTo } }` shape (CR 301.5) to decide /// permanent EXPIRY, not liveness. It needs the structural match, not the