Skip to content

fix(parser): UNSUPPORTED seam: Play historic from graveyard once/turn + granted leave-battlefield re#5762

Open
ntindle wants to merge 2 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-113-unsupported-seam-play-historic
Open

fix(parser): UNSUPPORTED seam: Play historic from graveyard once/turn + granted leave-battlefield re#5762
ntindle wants to merge 2 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-113-unsupported-seam-play-historic

Conversation

@ntindle

@ntindle ntindle commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a parser misparse affecting 1 card(s) in the Doctor Who Commander precons.

Root cause: UNSUPPORTED seam: Play historic from graveyard once/turn + granted leave-battlefield replacement rider on the played object

Cards corrected

  • The Eighth Doctor

Fix

Implemented the approved plan for Cluster-113 (The Eighth Doctor): a play/cast-from-graveyard permission that GRANTS a per-object leave-the-battlefield → exile replacement to the permanent played this way. Built the general carrier seam, not a Doctor-special-case. No new enum variant — two struct-field additions plus a shared install helper and a shared parser def-builder.

WHAT CHANGED (by subsystem):
Types: added granted_replacement: Option<Box<ReplacementDefinition>> as a fourth leaf rider on StaticMode::GraveyardCastPermission (serde-only, Display/FromStr default None; Hash/Display already destructure with .. so no change there — ReplacementDefinition lacks Hash, confirmed). Added transient GameState.pending_etb_replacements: Vec<(ObjectId, Box<ReplacementDefinition>)> mirroring pending_etb_counters at all four lifecycle sites (field/Default/destructure/PartialEq).
Parser: extended parse_leave_battlefield_rider_ref with a ~ self-ref alt arm; extracted shared parse_leave_battlefield_exile_replacement_def (byte-identical effect-path output preserved via a thin wrapper); replaced the honest-gap return None bail in try_parse_disjunctive_graveyard_cast_permission with a nom split_once_on(". if you do, it gains ") + delimited(char('"'), take_until, char('"')) quote-strip that feeds the shared builder (rider present-but-unparsable still aborts — honest gap preserved).
Runtime: new single-authority GameObject::install_replacement(def, to_base) (add_target_replacement.rs refactored to call it); readers permission_granted_replacement / selected_static_permission_granted_replacement (spell, CR 607.1 self-grant fallback) / graveyard_permission_granted_replacement (land) in casting.rs; spell seam in finalize_cast (casting_costs.rs) and land seam in handle_play_land (engine.rs) both push onto pending_etb_replacements; the drain+base-install lives at the top of apply_zone_delivery_tail gated on to == Battlefield — the single convergence point of the immediate deliver fall-through AND the counter-pause resume, so it fires exactly once; turn-cleanup backstop clear in turns.rs.

VERIFICATION (worktree off-Tilt, cargo run directly): cargo fmt clean; precise parser diff-gate on my added lines = CLEAN (no string dispatch); cargo clippy -p engine -p phase-ai -p engine-wasm -- -D warnings (no --all-targets per disk ceiling) = 0 warnings (covers phase-ai/engine-wasm lib exhaustiveness); cargo test -p engine = 16517 lib + 2958 integration, 0 failed; cargo test -p phase-ai = 0 failed. oracle-gen for The Eighth Doctor now emits the GraveyardCastPermission static with granted_replacement (Moved/SelfRef → Exile), the mill-3 trigger, NO Unimplemented, and parse_warnings: [].

RUNTIME PROOF (new card-test, GameScenario+GameRunner, 3 tests all green): (1) cast a historic permanent from graveyard via the permission → enters battlefield with the redirect on base_replacement_definitions, then Destroy → lands in EXILE (proves SelfRef Moved install fires and binds to the entrant, not the Doctor); (2) CR 611.2c persistence — remove the Doctor, then the grantee's exit still redirects to exile; (3) net-new land path — play a historic land from graveyard via PlayLand → rider installed on the land's base, exit → exile. Building-block parser tests: honest-gap test flipped to assert Some(rider) with correct shape; def-builder tested on both ~ and pronoun forms.

NEIGHBOR REGRESSIONS re-run green: l02_bb3_cast_permission_finality (all), finality_counter_death_to_exile (all 4 death paths), ark_of_hunger land-play, mill_rest_in_peace_redirect, living_death_replacement, statics serde/Display round-trip, casting rules integration.

JUDGEMENT CALLS: (a) followed orchestrator disk-ceiling override (NO --all-targets) rather than the plan's literal --all-targets; lib clippy still catches the non-exhaustive-match class in phase-ai/engine-wasm, which is the whole exhaustiveness risk. (b) install_replacement doc cites CR 613.1 + CR 614.1a (accurate to base-restore + replacement semantics) rather than the plan's tentative CR 611.2b, since 611.2b is specifically 'for as long as' durations; the preserved 611.2b in the add_target_replacement invariant comment remains correct for the ControllerControlsSource ('for as long as you control') case.

STOP-AND-RETURN: none — no CR ambiguity, no multi-PR seam. The adjacent reanimation-rider class (Gruesome Encore etc.) uses a different carrier (effect rider) and is explicitly out of cluster-113 scope; the extracted def-builder + install helper are the seams that unlock it later.

VERIFICATION NOT RUN (disk preservation, ~6Gi free): cargo semantic-audit, cargo coverage, and frontend pnpm type-check/lint. The targeted oracle-gen already proved this specific card parses clean (no Unimplemented, no swallow warnings); no frontend code changed and the new field is an optional serde field (backward-compatible, tsify auto-regenerated). Also note: the full ./scripts/check-parser-combinators.sh reports FAIL, but it diffs against an ancient fixed base commit (ff799f8, PR #904) and every flagged line is pre-existing code in oracle.rs/other files — the precise per-diff gate on my added lines is clean.

Files changed

  • crates/engine/src/types/statics.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_static/restriction.rs
  • crates/engine/src/parser/oracle_static/tests.rs
  • crates/engine/src/parser/swallow_check.rs
  • crates/engine/src/game/game_object.rs
  • crates/engine/src/game/effects/add_target_replacement.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/zone_pipeline.rs
  • crates/engine/src/game/turns.rs
  • crates/engine/src/game/engine_tests.rs
  • crates/engine/src/game/effects/create_emblem.rs
  • crates/engine/tests/integration/rules/casting.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/eighth_doctor_granted_leave_battlefield_exile.rs

CR references

  • CR 305.1
  • CR 601.2a
  • CR 603.6c
  • CR 607.1
  • CR 608.2c
  • CR 611.2c
  • CR 613.1
  • CR 614.1a
  • CR 614.6
  • CR 614.12
  • CR 700.6

Verification

  • cargo fmt --all — clean (exit 0, no changes; nothing to commit)
  • check-parser-combinators.sh (scoped to upstream/main merge-base 7c63d29fe) — clean (Gate A PASS, exit 0)
  • cargo clippy -p engine -p phase-ai -p engine-wasm -- -D warnings — clean (exit 0)
  • cargo test -p engine -p phase-ai — clean (exit 0, 22 suites ok, 0 failed)
  • oracle-gen data --filter "the eighth doctor" — clean (exit 0, AST matches oracle text, no Unimplemented/Unknown)
    Cards confirmed re-parsed correctly: The Eighth Doctor

🤖 Generated with Claude Code

@ntindle ntindle requested a review from matthewevans as a code owner July 13, 2026 22:21
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR · 1 card(s), 2 signature(s) (baseline: main 6d7821dced96)

1 card(s) · static/GraveyardCastPermission(Play,once_per_turn) · added: GraveyardCastPermission(Play,once_per_turn) (affects=historic land or historic permanent)

Examples: The Eighth Doctor

1 card(s) · ability/static_structure · removed: static_structure

Examples: The Eighth Doctor

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — the new leave-battlefield rider is recovered from its source rather than from the permission that actually authorized this play/cast.

🔴 Blocker

  • crates/engine/src/game/casting.rs:2255-2273 re-scans every graveyard permission on the source and takes the first one with a rider. It does not retain the selected permission definition (or rider) whose affected filter admitted this particular spell. A source with two overlapping GraveyardCastPermission definitions can therefore attach the wrong rider. Carry the selected permission provenance/rider through GraveyardPermissionSource and CastingVariant::GraveyardPermission, rather than looking it up after the cast. Add a collision regression proving that a spell admitted by a no-rider permission does not receive a different permission's rider.
  • crates/engine/src/game/casting.rs:2282-2287 repeats the same source-wide lookup for lands; cover that path in the collision regression too.
  • CI currently fails with E0063 at crates/mtgish-import/src/convert/player_effect.rs:243: the new StaticMode::GraveyardCastPermission field is not initialized. Thread granted_replacement: None (or the appropriate mapping) through every external constructor.

🟡 Non-blocking

  • crates/engine/src/game/casting.rs:3173-3178 also changes emblem/static-definition behavior as an unrelated side effect. Please split it out or add focused evidence that it is required for this permission path.

✅ Clean

  • The parser uses a typed static mode and the runtime tests cover the core permanent, persistence-after-source-leaves, and land-play scenarios. The verified Oracle text for The Eighth Doctor does require the granted leave-battlefield replacement.

Recommendation: retain the selected authorizing permission at choice time, use it for both spell and land finalization, add the conflicting-permission regressions, and repair the initializer before requesting re-review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants