Skip to content

Commit b902333

Browse files
JacobWoodsonclaudematthewevans
authored
feat(engine): implement Jetfire, Ingenious Scientist // Jetfire, Air Guardian (phase-rs#6056)
* feat(engine): implement Jetfire, Ingenious Scientist // Jetfire, Air Guardian Transformers DFC (set BOT). Almost every mechanic already existed (More Than Meets the Eye, Living metal, Flying, convert->transform, adapt, the "can't be spent to cast nonartifact spells" restriction and its cross-sentence attachment, and the counter-removal cost -> chosen_x -> "that much" wiring). Four surgical changes complete the two activated abilities: - parser: `strip_mana_subject_prefix` recognizes "target player adds" as a genuine chosen recipient (`TargetFilter::Player`), distinct from the "active/that player" anaphors (CR 115.1 + CR 106.4). - engine: `mana_effect_recipient` deposits into the targeted player for a `Player` recipient, provenance-gated by a new `mana_count_reads_targets` so a count-source `Player` target ("Add {U} for each card in target player's hand") still pays the controller (no Jeska's Will regression). - parser: "that much {C}" now parses as a count-prefixed colorless production (was falling to Unimplemented). - parser: "adapt" added to the clause-start verb table so "Convert Jetfire, then adapt 3" chains the adapt sub-ability. No new enum variants. Adds 6 tests (both faces + the recipient clause + the multi-authority recipient/count-source gate + spend-restriction deposit). Full engine suite green; fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(engine): model mana recipient vs count-source as explicit target roles Addresses the review blocker on PR phase-rs#6056. The previous commit resolved a targeted-player mana recipient by inferring the role from the production count's shape, which conflated two distinct CR 601.2c "target" instances that share one Effect::Mana.target field. CR 601.2c requires an independent choice per instance of the word "target". A mana sentence can name two: the RECIPIENT ("Target player adds that much {C}" -- Jetfire) and the COUNT SOURCE ("Add {R} for each card in target opponent's hand" -- Jeska's Will; Carpet of Flowers). One field could not carry both, so a sentence naming both silently dropped the recipient and deposited the mana to the controller. - types: new ManaTargetRole (Recipient / CountSource / Both) replaces Effect::Mana.target's bare Option<TargetFilter>. Roles are stamped at parse time from GRAMMAR, never inferred from quantity shape. - parser: both subject-stamping routes now COMBINE into Both instead of declining on is_none() (the clobber) -- oracle_effect/mana.rs and the subject-predicate route in oracle_effect/mod.rs. Count-source producers stamp CountSource at the point of grammatical knowledge. - resolution: each role resolves against ITS OWN slot via ability_scoped_to_slot, leaving shared quantity resolution untouched. mana_effect_recipient returns Option<PlayerId> so an illegal recipient deposits nowhere rather than falling back to the controller (CR 608.2b). - targeting: multi-role mana surfaces one slot per role (recipient first) in collect_target_slots / collect_target_slot_specs, with matching arms in both assign fns, chain_has_target_sink, minimum_targets_in_chain, and a position-stable validate_targets_in_chain arm. retarget_slot_violation enforces per-slot CR 115.7a legality. - deletes mana_count_reads_targets, mana_production_count, quantity_expr_reads_targets and quantity_ref_reads_targets -- removing the inference and, by construction, the two wildcard match arms flagged in review. Effect::target_filter() still returns the sole declared filter, so all 13 shipping mana-target cards keep byte-identical routing; only the two-role shape enters the new arms. Fixture roles were migrated by card name, because the role is not recoverable from filter shape -- Carpet of Flowers and Spectral Searchlight are both Typed with opposite roles. One mechanical or-pattern repair in mtgish-import; ability_rw's D5 scan was split rather than dropped, so its verdicts are unchanged for all 11 fixture mana cards. 18 new tests, including the maintainer-requested end-to-end fixture for the recipient + target-derived-count class (two players with different hand sizes, so a slot mix-up fails). Full engine suite 16743 passed / 0 failed; integration 3164 passed / 0 failed; clippy --workspace --all-targets clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(engine): an illegal mana count source must fail to determine, not count the recipient Addresses the second review round on PR phase-rs#6056. count_scoped_ability fell back to the UNSCOPED ability whenever ability_scoped_to_slot(.., CountSource) returned None. That conflated "no count-source role declared" with "declared count source is no longer legal": in the latter case the unscoped ability still holds the LEGAL recipient, and QuantityRef::TargetZoneCardCount scans for the first player target, so a Both role whose recipient stayed legal but whose count source became illegal produced mana from the RECIPIENT's hand instead of failing to determine the illegal target's information (CR 608.2b). count_scoped_ability now distinguishes four cases: 1. no count-source role -- unscoped, exactly today's behavior for every single-role mana; 2. context-ref count source -- resolved through context, mirroring mana_effect_recipient. Previously this fell into case 4 and silently resolved to 0 under a CR 608.2b citation that does not apply to it, since nothing there is an illegal target; 3. declared and still legal -- narrowed to that one player; 4. declared but illegal -- no player exposed, so the count resolves to 0. Scoping is confined to the PLAYER axis. retain_only_player_at drops only TargetRef::Player entries and leaves non-player targets at their original positions, because ManaProduction::AnyCombinationOfObjectColors { scope: Target } reads an OBJECT target from the same vec and requires nothing about the count source -- clearing the whole vec would make that half of the production silently yield no colors, which CR 608.2b does not license. The same defect existed at a second site the review did not cite: handle_choose_mana_effect -> chosen_mana_types_for_prompt also derives the count (its SingleColor arm multiplies the chosen color by it) and was passing the unscoped ability. Both sites now route through count_scoped_ability. New cast-pipeline regression: 3 players, recipient P1 with 2 cards, count source P2 with 5, P2 eliminated between SelectTargets and resolution. Reverting the fix fails it with left: 2, right: 2 -- the count read the recipient's hand, exactly the reported defect. Guards assert the recipient stayed legal and that EffectResolved{Mana} actually fired, so a whole-spell fizzle cannot satisfy the zero-mana assertions vacuously. Engine suite 16743 passed / 0 failed; integration 3165 passed / 0 failed; clippy --workspace --all-targets clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(scripts): add mana target-role fixture migration for the maintainer port The generated fixture crates/engine/tests/fixtures/integration_cards.json conflicts with main and cannot be resolved by taking either side: this branch carries the ManaTargetRole schema change while main independently regenerated the fixture (2,945 keys vs 2,807; 139 cards only on main; 1,278 shared entries differ). Producing the merged artifact requires the project card-data path, which is not available in this worktree, so the port belongs to a Tilt-attached maintainer. This script makes the schema half of that port mechanical. After the fixture is regenerated normally, run: node scripts/migrate-mana-target-roles.mjs It rewrites Effect::Mana.target from the legacy bare TargetFilter encoding to the ManaTargetRole encoding for the 11 affected cards. Keyed by CARD NAME, deliberately. The role is not recoverable from the serialized filter: carpet of flowers (Typed{controller:"Opponent"}) is a CountSource while spectral searchlight (Typed{controller:ChosenPlayer(0)}) is a Recipient -- identical outer shape, opposite roles. Inferring the role from the filter or from the production's quantity shape is precisely the defect this PR removes and was rejected in review, so the script FAILS LOUDLY on any mana-target card missing from the table rather than guessing, and tells the reader how to decide the role from Oracle text. The migration set was verified stable: main's independent regeneration contains the same 11 mana-target entries, name-for-name, despite adding 139 cards. Verified end to end against real data: - applying it to main's regenerated fixture migrates exactly 11 entries and yields encodings IDENTICAL to this branch's for all 11; - idempotent (re-running reports 11 already in role form, writes nothing); - --check reports without writing; - an injected unmapped mana-target card exits 1 with guidance; - output stays a single minified line, guarded in-script. Node rather than Python to match gen-test-fixture.py's sibling only in spirit: python3 is unavailable in this environment, and shipping a script I could not execute would defeat the purpose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(merge): resolve semantic conflicts with main Two conflicts the textual merge could not see: - main added a 'chooser' field to TargetSelectionSlot; the multi-role Mana slot arm now constructs it with chooser: None like every sibling arm (the SlotAccumulator stamps the actual chooser). - main added a new direct reader of Effect::Mana.target in ability_scan's loop-firewall arm (a ninth reader, added after this branch's reader audit). It now walks role.declared_filters() so BOTH role filters are scanned, mirroring the D5 legacy scan and the AI POISON scan; a partial view would let the loop firewall miss a target-derived axis. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(merge): migrate persisted game-state dumps to the mana role encoding Main added gzipped GameState dumps (kilo_live_offer_from_real_dump, combo_infinite_pile, sprout_inalla_realistic_offer) whose objects carry parsed Effect::Mana abilities in the pre-role encoding. A state deserializes through typed serde BEFORE any restore-time migration hook can run, so the dumps themselves must carry the role encoding; without this, 6 lib + 20 integration tests fail with missing-field 'role' (plus one collateral failure via a poisoned shared fixture cache). migrate-mana-target-roles.mjs gains a --state mode: gunzip, wrap each Mana target by the OWNING OBJECT's card name through the same fail-loud name-keyed table, regzip. Three cards appear in dumps but not the curated card fixture; each role was decided from Scryfall Oracle text, never inferred from shape: wolfwillow haven -> Recipient ('its controller adds an additional {G}') priest of forgotten gods -> Recipient ('You add {B}{B} and draw a card') rousing refrain -> CountSource ('Add {R} for each card in target opponent's hand' - the Jeska's Will clause verbatim) These are excluded from fixture mode's drop-detection count via STATE_ONLY_CARDS so the curated-11 guard keeps its teeth. All four dumps migrated (24 entries), idempotency verified via --state --check. NOTE for maintainers: this migrates TEST dumps only. If production persisted games can carry pre-role Mana targets across an engine upgrade, the restore path needs a JSON-level migration before typed deserialization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(engine): tolerate parallel-schedule noise in the bulk-Treasure O(N) bound bulk_treasure_activation_is_linear_not_factorial reads MANA_READINESS_CALLS, a bare process-global AtomicUsize with no test serialization, so any concurrently scheduled test that exercises mana readiness increments it between this test's store(0) and load. The branch's ~18 added lib tests shifted the parallel schedule enough to tip the 4*N bound by exactly one (got 25, bound 24); the full suite passes single-threaded (17513/0), proving pollution rather than a complexity regression. Widen the bound to 8*N = 48 with a comment. Discrimination is preserved: the O(N!) regression this test guards against produces >= 720 readiness calls at N = 6, 15x over the widened bound, while schedule pollution is single-digit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(engine): preserve untouched target slots in forced retargeting of multi-role mana Addresses the forced-retarget blocker on PR phase-rs#6056. The forced path in change_targets.rs assigned stack_ability.targets = vec![new_target], which for a ManaTargetRole::Both ability (recipient + count-source slots) dropped the other slot and skipped the per-slot legality check the interactive path uses. CR 115.7b: 'change a target' changes exactly ONE target to another legal target; every other target stays unchanged (never deleted). New helper forced_retarget_targets identifies the slot the new target is legal for by zipping mana_multi_role(effect).surfaced_filters() through validate_targets_for_ability (the same call shape as the interactive retarget_slot_violation seam), replaces only that index, and preserves the rest. Single-target / non-mana nodes take mana_multi_role == None -> slot 0, byte-identical to the prior vec![new_target]. End-to-end regression forced_retarget_multi_role_mana_6056: a Both mana ability with recipient=P1, count_source=P2 force-retargeted to P3 must resolve (via real resolve_top) to targets [P3, P2]; the old code collapses it to [P3] (len 1). A positive reach-guard asserts the recipient actually became P3 so the preservation assertion is non-vacuous. Also in this change (review follow-ups): - migrate-mana-target-roles.mjs: envelopeField(role) throws for Both (a single legacy filter cannot reconstruct two), replacing the bare FIELD_BY_ROLE[role] lookup that would emit { role: 'Both', undefined: ... }. - ability_rw.rs: corrected a stale CR 603.10a citation to CR 601.2c on the Effect::Mana legacy-scan arm. - parser test parse_add_fixed_count_colorless_no_target covers the Fixed count path of the colorless count-prefix arm ('Add three {C}.'). - hoisted the duplicated mana_fixture_roles() test helper into game::test_fixtures, imported by ability_rw.rs and mana_abilities.rs. Engine lib 17585/0, integration 3834/0, clippy --workspace --all-targets clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(engine): CR 115.7a forced-retarget must change to *another* target Resolve the two HIGH review blockers on PR phase-rs#6056 against the current head. - ability_utils: the multi-role mana target-slot arm returned EngineError::ActionNotAllowed, which no longer typechecks after main switched the slot builder to TargetSlotBuildError. Return no_legal_target_slots() like every sibling arm. - change_targets: forced_retarget_targets now selects the first surfaced slot whose filter legally accepts the candidate AND whose current target actually differs from it. CR 115.7a requires changing a target to *another* legal target, so a slot already holding the candidate is not a change and must be skipped in favor of a slot that can genuinely change. Previously the first-legal-slot predicate could no-op on a recipient slot already holding the candidate while an available change on the count-source slot went unmade. - Regression: overlapping-role Both filters with targets [P1, P2], forcing P1, must yield [P1, P1] by changing the count-source slot (revert-verified: dropping the "changes" guard leaves [P1, P2] and fails the assertion). - phase-ai/zone_eval: adapt the targeted-mana test to the ManaTargetRole model (Recipient { recipient: TargetFilter::Player }). Verified: cargo fmt; clippy --workspace --all-targets clean; phase-engine integration forced_retarget tests pass; phase-ai targeted-mana test passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(test): re-migrate integration fixture to ManaTargetRole (bigint-safe) The main-merge (e14eb2c) pulled main's regenerated integration_cards.json without re-running the mana-target-role migration, so the committed fixture still carried the legacy `Effect::Mana.target: Option<TargetFilter>` shape. Every test that loads the fixture (phase-ai search scenarios, engine analysis::corpus_tests / ai_support::candidates / analysis::ability_graph) then panicked with `missing field 'role'`, failing both Rust test shards. Re-migrating exposed a second defect in the migration script itself: the fixture now carries u64 sentinels (a `SpecificObject` id of u64::MAX, 18446744073709551615) introduced on main by the Jailbreak fix (phase-rs#6691). The script's JSON.parse/JSON.stringify round-trip silently rewrote those to lossy floats (1.8446744073709552e+19), which serde rejects as `invalid type: floating point, expected u64`. Make the fixture-mode round-trip bigint-lossless: a string-aware scanner quotes 16+ digit integer *values* behind an ASCII sentinel before parse and unquotes them after stringify. The scanner tracks JSON string state (with escape handling) so it never touches `[`/`,`+digits sequences inside card text — the fragility a naive regex would have. The sentinel is JSON- and regex-safe, and unwrapping fires only when the entire string value is the sentinel followed by digits, so it cannot collide with real content. Verified: `node scripts/migrate-mana-target-roles.mjs` migrates 12 entries fail-loud clean; u64 sentinel survives as an integer, no float leak; the previously-failing phase-ai subtlety test and engine corpus_tests (46), candidates (43), and ability_graph (42) all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(test): re-migrate 4p GameState dumps to ManaTargetRole (bigint-safe) The card fixture wasn't the only stale artifact the main-merge left behind: two committed persisted-GameState dumps still carried the legacy `Effect::Mana.target: Option<TargetFilter>` shape, so every test that restores them panicked with `missing field 'role'` while deserializing the 4p state: - dina_conqueror_4p.json.gz (8 mana targets) analysis::resource::tests::dina_untargeted_drain_4p_cover_is_not_vetoed_by_a_library_cost_static - witherbloom_sprout_lumaret_simple_4p.json.gz (4 mana targets) loop_shortcut::{ai_collapse_candidate_is_clamped_to_the_accepted_bound, scheduled_collapse_renders_no_unbounded_badge, unregistered_axis_still_renders_its_infinity_badge, accepted_fixed_count_bounds_the_boundary_collapse_prompt, two_accepts_in_one_phase_bound_the_collapse_to_the_smallest_accepted_count} A GameState deserializes through typed serde before any restore-time migration hook runs, so the dump itself must carry the role encoding. Re-migrated both via the script's `--state` mode; all roles resolved to Recipient (Priest of Forgotten Gods, The Warring Triad), fail-loud clean. Also apply the bigint-lossless round-trip to `--state` mode (it previously used raw JSON.parse/JSON.stringify): GameState dumps carry u64 object-id sentinels that a naive round-trip mangles into `expected u64`, the same defect just fixed in fixture mode. Verified: both dumps re-scan with zero stale mana targets, no float/sentinel leak; the dina resource test and all 71 loop_shortcut integration tests (including the 6 that were failing) pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
1 parent c521e86 commit b902333

33 files changed

Lines changed: 4134 additions & 150 deletions

crates/engine/src/ai_support/filter.rs

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ use crate::game::engine::SimulationProbeGuard;
3838
use crate::game::functioning_abilities::game_functioning_statics;
3939
use crate::game::{casting, keywords, turn_control};
4040
use crate::types::ability::{
41-
AbilityCost, AbilityDefinition, AbilityKind, ActivationRestriction, FilterProp, ParitySource,
42-
ParsedCondition, QuantityExpr, ReplacementDefinition, ResolvedAbility, StaticDefinition,
43-
TargetFilter, TargetRef, TriggerDefinition,
41+
AbilityCost, AbilityDefinition, AbilityKind, ActivationRestriction, Effect, FilterProp,
42+
ParitySource, ParsedCondition, QuantityExpr, ReplacementDefinition, ResolvedAbility,
43+
StaticDefinition, TargetFilter, TargetRef, TriggerDefinition,
4444
};
4545
use crate::types::actions::GameAction;
4646
use crate::types::card_type::CardType;
@@ -1001,6 +1001,25 @@ fn resolved_ability_target_filters_safe(ability: &ResolvedAbility) -> bool {
10011001
return false;
10021002
}
10031003
}
1004+
// CR 601.2c: A mana effect declares role-scoped target filters;
1005+
// `target_filter()` returns only the first. Scan them ALL here, or a POISON
1006+
// FilterProp in a non-first mana role filter would be silently treated as
1007+
// SAFE and its ChooseTarget candidates wrongly memoized. Memoization
1008+
// soundness is a conservative gate — over-scanning only costs a memo,
1009+
// under-scanning is a bug. `declared_filters()` (not `surfaced_filters()`):
1010+
// context-ref filters are conservatively POISON under
1011+
// `target_filter_all_props_safe`, the correct conservative direction here.
1012+
if let Effect::Mana {
1013+
target: Some(role), ..
1014+
} = &ability.effect
1015+
{
1016+
if !role
1017+
.declared_filters()
1018+
.all(|(_, f)| target_filter_all_props_safe(f))
1019+
{
1020+
return false;
1021+
}
1022+
}
10041023
if let Some(sub) = &ability.sub_ability {
10051024
if !resolved_ability_target_filters_safe(sub) {
10061025
return false;
@@ -1280,6 +1299,82 @@ fn legality_equivalence_key(
12801299
mod tests {
12811300
use super::*;
12821301
use crate::ai_support::candidate_actions;
1302+
1303+
/// Matrix rows 9a + 9b — the mana-role POISON scan is a PURE EXTENSION.
1304+
///
1305+
/// `resolved_ability_target_filters_safe` reads `Effect::target_filter()`,
1306+
/// which returns only the FIRST declared role filter. For a `Both` role the
1307+
/// second filter would go unscanned and a POISON `FilterProp` inside it
1308+
/// would be wrongly treated as SAFE, wrongly memoizing `ChooseTarget`
1309+
/// candidates. Memoization soundness is a conservative gate: over-scanning
1310+
/// costs a memo, under-scanning is a bug.
1311+
///
1312+
/// 9b is the paired over-application negative — no SINGLE-role verdict may
1313+
/// change. Fails if the arm is written with `any()`, with
1314+
/// `surfaced_filters()`, or so that it SHADOWS rather than supplements the
1315+
/// generic scan.
1316+
#[test]
1317+
fn mana_role_poison_scan_extends_without_tightening() {
1318+
use crate::types::ability::{
1319+
ManaProduction, ManaTargetRole, QuantityExpr, TargetFilter, TypedFilter,
1320+
};
1321+
use crate::types::identifiers::ObjectId;
1322+
use crate::types::player::PlayerId;
1323+
1324+
let poison = TargetFilter::Typed(
1325+
TypedFilter::default().properties(vec![FilterProp::AttackedOrBlockedThisTurn]),
1326+
);
1327+
let safe = TargetFilter::Player;
1328+
// Reach guard for the fixture itself: these must actually differ under
1329+
// the predicate, or every assertion below is vacuous.
1330+
assert!(!target_filter_all_props_safe(&poison));
1331+
assert!(target_filter_all_props_safe(&safe));
1332+
1333+
let ability = |role: Option<ManaTargetRole>| {
1334+
ResolvedAbility::new(
1335+
Effect::Mana {
1336+
produced: ManaProduction::Colorless {
1337+
count: QuantityExpr::Fixed { value: 1 },
1338+
},
1339+
restrictions: vec![],
1340+
grants: vec![],
1341+
expiry: None,
1342+
target: role,
1343+
},
1344+
vec![],
1345+
ObjectId(1),
1346+
PlayerId(0),
1347+
)
1348+
};
1349+
1350+
// 9a: POISON hiding in the NON-FIRST role is still caught.
1351+
assert!(
1352+
!resolved_ability_target_filters_safe(&ability(Some(ManaTargetRole::Both {
1353+
recipient: safe.clone(),
1354+
count_source: poison.clone(),
1355+
}))),
1356+
"a POISON filter in the second mana role must not be memoized as SAFE"
1357+
);
1358+
1359+
// 9b: single-role verdicts are untouched in BOTH directions.
1360+
assert!(resolved_ability_target_filters_safe(&ability(Some(
1361+
ManaTargetRole::Recipient {
1362+
recipient: safe.clone()
1363+
}
1364+
))));
1365+
assert!(resolved_ability_target_filters_safe(&ability(Some(
1366+
ManaTargetRole::CountSource {
1367+
count_source: safe.clone()
1368+
}
1369+
))));
1370+
assert!(!resolved_ability_target_filters_safe(&ability(Some(
1371+
ManaTargetRole::Recipient {
1372+
recipient: poison.clone()
1373+
}
1374+
))));
1375+
assert!(resolved_ability_target_filters_safe(&ability(None)));
1376+
}
1377+
12831378
use crate::game::engine::apply_as_current_for_simulation;
12841379
use crate::types::game_state::{
12851380
ActiveSearchDecisionAuthority, ActiveSearchDecisionControl, CastPaymentMode,

crates/engine/src/game/ability_rw.rs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3159,9 +3159,21 @@ fn legacy_effect(x: &Effect) -> bool {
31593159
}
31603160

31613161
// ---- Options-only carriers ----
3162-
Effect::Mana { target, .. }
3163-
| Effect::LoseTheGame { target }
3164-
| Effect::WinTheGame { target } => otf(target),
3162+
// CR 601.2c (D5): `Effect::Mana`'s target is a ROLE (`ManaTargetRole`),
3163+
// not a bare `Option<TargetFilter>`, so it can no longer share the
3164+
// or-pattern below. It must still be scanned: several shipping mana
3165+
// cards carry exactly the frozen event-context tags this visitor exists
3166+
// to find — `TriggeringPlayer` (Bubbling Muck, High Tide, Mana Flare)
3167+
// and `ParentTargetController` (Fertile Ground, Utopia Sprawl, Wild
3168+
// Growth, Shimmerwilds Growth). Dropping Mana from the visitor would
3169+
// silently change `legacy_*` verdicts for all of them. Scan EVERY
3170+
// declared role filter (`declared_filters`, not `surfaced_filters`) —
3171+
// the tags live on context-ref filters, which `surfaced_filters`
3172+
// excludes by definition.
3173+
Effect::Mana { target, .. } => target
3174+
.as_ref()
3175+
.is_some_and(|role| role.declared_filters().any(|(_, f)| legacy_target_filter(f))),
3176+
Effect::LoseTheGame { target } | Effect::WinTheGame { target } => otf(target),
31653177
Effect::ChooseFromZone { filter, .. } => otf(filter),
31663178
Effect::ReduceNextSpellCost { spell_filter, .. }
31673179
| Effect::GrantNextSpellAbility { spell_filter, .. } => otf(spell_filter),
@@ -6555,6 +6567,60 @@ mod tests {
65556567
use crate::types::ability::{
65566568
AbilityKind, ChoiceType, Comparator, CountScope, PtValue, TargetSelectionMode,
65576569
};
6570+
6571+
use crate::game::test_fixtures::mana_fixture_roles;
6572+
6573+
/// Matrix rows 15b + 17 — zero delta for the D5 frozen-event-tag visitor,
6574+
/// which reads `Effect::Mana`'s target DIRECTLY and bypasses
6575+
/// `Effect::target_filter()` entirely.
6576+
///
6577+
/// CR 603.10a: this visitor exists to find frozen event-context tags, and
6578+
/// seven of the eleven fixture mana entries carry exactly those tags
6579+
/// (`TriggeringPlayer` on Bubbling Muck / High Tide / Mana Flare,
6580+
/// `ParentTargetController` on the four Auras). Deleting Mana from the
6581+
/// or-pattern to silence the compiler — the path of least resistance — would
6582+
/// silently flip all seven; writing the split arm with `surfaced_filters()`
6583+
/// would too, since the tags live on context-ref filters that
6584+
/// `surfaced_filters` excludes by definition. Both mistakes fail here.
6585+
#[test]
6586+
fn legacy_effect_verdict_unchanged_for_every_fixture_mana_role() {
6587+
use crate::types::ability::{ManaProduction, QuantityExpr};
6588+
6589+
for (name, role) in mana_fixture_roles() {
6590+
let sole = role
6591+
.declared_filters()
6592+
.next()
6593+
.map(|(_, f)| f)
6594+
.expect("every shipping role declares exactly one filter");
6595+
// The pre-change verdict: `otf(target)` over the bare filter.
6596+
let expected = legacy_target_filter(sole);
6597+
let effect = Effect::Mana {
6598+
produced: ManaProduction::Colorless {
6599+
count: QuantityExpr::Fixed { value: 1 },
6600+
},
6601+
restrictions: vec![],
6602+
grants: vec![],
6603+
expiry: None,
6604+
target: Some(role.clone()),
6605+
};
6606+
assert_eq!(
6607+
legacy_effect(&effect),
6608+
expected,
6609+
"{name}: D5 frozen-tag verdict must be identical to the pre-role reading"
6610+
);
6611+
}
6612+
6613+
// Reach guard: at least one fixture role IS tagged, so the loop above is
6614+
// not vacuously comparing `false == false` everywhere.
6615+
assert!(
6616+
mana_fixture_roles().iter().any(|(_, role)| {
6617+
role.declared_filters()
6618+
.any(|(_, f)| legacy_target_filter(f))
6619+
}),
6620+
"the fixture set must contain at least one frozen-tag-bearing role, or this test proves nothing"
6621+
);
6622+
}
6623+
65586624
use crate::types::counter::CounterType;
65596625
use crate::types::identifiers::{ObjectId, TrackedSetId};
65606626
use crate::types::player::PlayerId;

crates/engine/src/game/ability_scan.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,12 +1018,19 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes {
10181018
ScanMode::Conservative => Axes::CONSERVATIVE,
10191019
ScanMode::LoopFirewall => {
10201020
let mut acc = scan_mana_production(produced, mode);
1021-
if let Some(t) = target {
1022-
acc = acc.or(scan_target_filter(
1023-
t,
1024-
FilterReadContext::SnapshotOrEvent,
1025-
mode,
1026-
));
1021+
// CR 601.2c: a mana target is role-tagged (recipient / count
1022+
// source). Scan EVERY declared role filter, mirroring the D5
1023+
// legacy scan (`ability_rw`) and the AI POISON scan
1024+
// (`ai_support::filter`) — a partial view here would let the
1025+
// loop firewall miss a target-derived axis.
1026+
if let Some(role) = target {
1027+
for (_, filter) in role.declared_filters() {
1028+
acc = acc.or(scan_target_filter(
1029+
filter,
1030+
FilterReadContext::SnapshotOrEvent,
1031+
mode,
1032+
));
1033+
}
10271034
}
10281035
acc
10291036
}

0 commit comments

Comments
 (0)