Skip to content

fix(parser): UNSUPPORTED full-set one-off: Unleash the Flux#5682

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-99-unsupported-full-set-one
Jul 12, 2026
Merged

fix(parser): UNSUPPORTED full-set one-off: Unleash the Flux#5682
matthewevans merged 4 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-99-unsupported-full-set-one

Conversation

@ntindle

@ntindle ntindle commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

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

Root cause: UNSUPPORTED full-set one-off: Unleash the Flux

Cards corrected

  • Unleash the Flux

Fix

Implemented the approved plan for cluster 99 (Unleash the Flux, a Planechase Phenomenon) surgically. The card previously misparsed: "…then you flip a coin. If you lose the flip, repeat this process." routed "repeat this process" into FlipCoin.lose_effect as Unimplemented{"empty"}, silently dropping the loop.

Built one class-level primitive across all layers: a resolution-scoped coin-flip-result condition feeding the existing RepeatContinuation::WhileCondition loop, plus a nom parser reroute.

Changes:

  • types/game_state.rs: new Copy struct ResolutionCoinFlip { flipper: PlayerId, won: bool } + Option field on GameState (serde skip-if-None), constructor init, and manual field-equality arm.
  • types/ability.rs: new leaf variant AbilityCondition::CoinFlipOutcome { result: CoinFlipResult } (parameterized by the existing CoinFlipResult enum — no sibling split; distinct from EventOutcomeWon which reads the trigger event, not resolution state).
  • game/effects/flip_coin.rs: write state.resolution_coin_flip at both single-flip authorities (flip_through_replacement and the Krark resume_after_keep path).
  • game/effects/mod.rs: evaluate_condition arm (controller-relative read of resolution_coin_flip); reset the tracker at each WhileCondition iteration start; condition_depends_on_effect_performed exhaustive-union arm.
  • game/ability_rw.rs, game/ability_scan.rs, game/coverage.rs: exhaustive-match registration arms mirroring EventOutcomeWon.
  • parser/oracle_effect/conditions.rs: new nom stripper strip_coin_flip_conditional (tag/alt/value only) + ability_condition_to_static_condition union arm.
  • parser/oracle_effect/mod.rs: reroute in try_parse_repeat_process_directive to try the flip stripper first. It is body-gated: if the trailing body is not "repeat this process", the whole function returns None and the chunk falls through to the inline coin-flip fold, so Krark/Desperate Gambit/Ral Zarek win/lose branches are untouched.

Result: Unleash the Flux parses with 0 Unimplemented and repeat_until = WhileCondition{ CoinFlipOutcome{Lost} }, sub_ability FlipCoin{win:None,lose:None}. Runtime proof: while_condition_lost_flip_loops_until_win drives resolve_ability_chain through the real WhileCondition loop and asserts the loop repeats while lost and stops on a win, with resolution_coin_flip recording the winning flip. Added 9 tests total (3 stripper, 2 directive incl. non-repeat regression, 1 full-card, flip-records, controller-relativity, loop runtime proof).

Verification (cargo run directly — worktree not under Tilt): cargo fmt clean; cargo clippy -p engine --all-targets -D warnings PASS (exit 0); cargo test -p engine 2732 passed/0 failed; full gen-card-data.sh + semantic-audit confirm the card is clean corpus-wide. Krark/Sin/Claim Jumper regression tests pass.

Judgement calls: (1) check-parser-combinators.sh exits 1, but that is a PRE-EXISTING condition — its fixed diff baseline is PR #904 (far behind HEAD fa199f8), so it flags string-dispatch accumulated across untouched files (oracle.rs/sequence.rs/oracle_trigger.rs). Confirmed none of the flagged lines are mine and my added parser lines contain zero string dispatch. (2) Runtime proof strategy: I proved the NEW primitive (flip→condition→WhileCondition loop, controller-relativity, field recording) via focused cast/resolution-pipeline tests rather than building the full multi-player-sacrifice + phenomenon-encounter integration harness, because that composition's remaining surface (player-scope sacrifice fan-out, pending_continuation-before-pending_repeat_until drain ordering) is pre-existing, separately-tested machinery that my change does not modify; the parse test proves the full-card structure and the loop test proves the mechanism. (3) gen-card-data.sh incidentally regenerated two TRACKED data files (known-tokens.toml −9032 lines, oracle-subtypes.json +1) from my local mtgjson/token data differing from the committed baseline — unrelated to the fix. I restored both to HEAD (isolated upstream/main worktree, no other agent's uncommitted work here). card-data.json and semantic-audit outputs are gitignored.

No STOP-AND-RETURN, no scope expansion, no new subsystem — extension only.

Files changed

  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/game/effects/flip_coin.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/ability_rw.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/coverage.rs
  • crates/engine/src/parser/oracle_effect/conditions.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs

CR references

  • CR 705.2 — only the player who flips a coin wins or loses the flip; controller-relative gate (verified: grep -n "^705.2" docs/MagicCompRules.txt → "Only the player who flips the coin wins or loses the flip")
  • CR 705.1 — flip a coin (verified: grep -n "^705.1" docs/MagicCompRules.txt → "Some cards refer to flipping a coin")
  • CR 608.2c — follow instructions in order / read the whole text; home of the 'repeat this process' loop and the resolution-scoped read (verified: grep -n "^608.2c" docs/MagicCompRules.txt)
  • CR 104.4b — mandatory-action loop draw; cited in the GameState field-equality comment noting resolution_coin_flip is volatile and never masks a real repeat (verified: grep -n "^104.4b" docs/MagicCompRules.txt)

Verification

  • cargo fmt --all — pass
  • check-parser-combinators.sh (base=upstream/main merge-base fa7f157) — pass
  • cargo clippy -p engine --all-targets -- -D warnings — pass
  • cargo test -p engine — pass
  • oracle-gen data --filter 'unleash the flux' — pass
    Cards confirmed re-parsed correctly: unleash the flux

🤖 Generated with Claude Code

@ntindle
ntindle requested a review from matthewevans as a code owner July 12, 2026 15:07

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements support for tracking coin flip outcomes during resolution (CR 705.2) to enable repeating processes based on coin flips, introducing AbilityCondition::CoinFlipOutcome and ResolutionCoinFlip to store the most recent flip result. The review feedback correctly identifies a violation of Rule R2 of the repository style guide, which prohibits the use of bool fields in structs. It is recommended to refactor the won: bool field in ResolutionCoinFlip to use the existing CoinFlipResult enum, and update all associated instantiations, evaluations, and test assertions accordingly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1153 to +1157
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolutionCoinFlip {
pub flipper: PlayerId,
pub won: bool,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Avoid using bool fields in structs; use the existing CoinFlipResult enum instead.

Why it matters: This violates Rule R2 of the repository style guide, which prohibits bool fields in favor of strongly-typed enums to better express the design space.

Suggested fix: Replace pub won: bool with pub result: CoinFlipResult.

Suggested change
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolutionCoinFlip {
pub flipper: PlayerId,
pub won: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolutionCoinFlip {
pub flipper: PlayerId,
pub result: crate::types::ability::CoinFlipResult,
}
References
  1. Rule R2: No bool fields — parameterize with existing typed enums. A bool field never expresses the design space; the project uses typed enums like ControllerRef, Comparator, PlayerScope, Option, and dedicated discriminated unions instead. (link)

Comment on lines +87 to +90
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: player,
won,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Update ResolutionCoinFlip instantiation to use the result field with CoinFlipResult instead of the won boolean.

Why it matters: This aligns with the refactoring of ResolutionCoinFlip to adhere to Rule R2 of the repository style guide.

Suggested fix: Map the won boolean to CoinFlipResult.

Suggested change
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: player,
won,
});
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: player,
result: if won { crate::types::ability::CoinFlipResult::Won } else { crate::types::ability::CoinFlipResult::Lost },
});
References
  1. Rule R2: No bool fields — parameterize with existing typed enums. (link)

});
// CR 705.2: record the kept flip's result for the flipper (not the source's
// controller) so a `WhileCondition` loop gate reads the surviving flip.
state.resolution_coin_flip = Some(ResolutionCoinFlip { flipper, won });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Update ResolutionCoinFlip instantiation in resume_after_keep to use the result field with CoinFlipResult instead of the won boolean.

Why it matters: This aligns with the refactoring of ResolutionCoinFlip to adhere to Rule R2 of the repository style guide.

Suggested fix: Map the won boolean to CoinFlipResult.

    state.resolution_coin_flip = Some(ResolutionCoinFlip {
        flipper,
        result: if won { crate::types::ability::CoinFlipResult::Won } else { crate::types::ability::CoinFlipResult::Lost },
    });
References
  1. Rule R2: No bool fields — parameterize with existing typed enums. (link)

Comment on lines +1603 to +1607
assert_eq!(
Some(recorded.won),
event_won,
"recorded result must match the CoinFlipped event"
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Update the test assertion to check recorded.result against CoinFlipResult::Won instead of checking recorded.won.

Why it matters: This aligns with the refactoring of ResolutionCoinFlip to adhere to Rule R2 of the repository style guide.

Suggested fix: Compare recorded.result with CoinFlipResult::Won.

Suggested change
assert_eq!(
Some(recorded.won),
event_won,
"recorded result must match the CoinFlipped event"
);
assert_eq!(
Some(recorded.result == crate::types::ability::CoinFlipResult::Won),
event_won,
"recorded result must match the CoinFlipped event"
);
References
  1. Rule R2: No bool fields — parameterize with existing typed enums. (link)

Comment on lines +1662 to +1669
assert_eq!(
state.resolution_coin_flip,
Some(ResolutionCoinFlip {
flipper: PlayerId(0),
won: true,
}),
"final resolution flip is the winning flip for the controller"
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Update the test assertion to construct ResolutionCoinFlip with result: CoinFlipResult::Won instead of won: true.

Why it matters: This aligns with the refactoring of ResolutionCoinFlip to adhere to Rule R2 of the repository style guide.

Suggested fix: Use result: CoinFlipResult::Won.

Suggested change
assert_eq!(
state.resolution_coin_flip,
Some(ResolutionCoinFlip {
flipper: PlayerId(0),
won: true,
}),
"final resolution flip is the winning flip for the controller"
);
assert_eq!(
state.resolution_coin_flip,
Some(ResolutionCoinFlip {
flipper: PlayerId(0),
result: crate::types::ability::CoinFlipResult::Won,
}),
"final resolution flip is the winning flip for the controller"
);
References
  1. Rule R2: No bool fields — parameterize with existing typed enums. (link)

Comment thread crates/engine/src/game/effects/mod.rs Outdated
Comment on lines +8633 to +8637
AbilityCondition::CoinFlipOutcome { result } => {
state.resolution_coin_flip.is_some_and(|f| {
f.flipper == ability.controller && f.won == matches!(result, CoinFlipResult::Won)
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Simplify the CoinFlipOutcome condition evaluation by directly comparing the result field of ResolutionCoinFlip with the expected result.

Why it matters: This aligns with the refactoring of ResolutionCoinFlip to adhere to Rule R2 of the repository style guide and simplifies the logic by avoiding boolean matching.

Suggested fix: Directly compare f.result with *result.

Suggested change
AbilityCondition::CoinFlipOutcome { result } => {
state.resolution_coin_flip.is_some_and(|f| {
f.flipper == ability.controller && f.won == matches!(result, CoinFlipResult::Won)
})
}
AbilityCondition::CoinFlipOutcome { result } => {
state.resolution_coin_flip.is_some_and(|f| {
f.flipper == ability.controller && f.result == *result
})
}
References
  1. Rule R2: No bool fields — parameterize with existing typed enums. (link)

Comment on lines +19005 to +19008
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: PlayerId(0),
won: false,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Update the test setup to construct ResolutionCoinFlip with result: CoinFlipResult::Lost instead of won: false.

Why it matters: This aligns with the refactoring of ResolutionCoinFlip to adhere to Rule R2 of the repository style guide.

Suggested fix: Use result: CoinFlipResult::Lost.

Suggested change
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: PlayerId(0),
won: false,
});
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: PlayerId(0),
result: CoinFlipResult::Lost,
});
References
  1. Rule R2: No bool fields — parameterize with existing typed enums. (link)

Comment on lines +19025 to +19028
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: PlayerId(0),
won: true,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Update the test setup to construct ResolutionCoinFlip with result: CoinFlipResult::Won instead of won: true.

Why it matters: This aligns with the refactoring of ResolutionCoinFlip to adhere to Rule R2 of the repository style guide.

Suggested fix: Use result: CoinFlipResult::Won.

Suggested change
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: PlayerId(0),
won: true,
});
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: PlayerId(0),
result: CoinFlipResult::Won,
});
References
  1. Rule R2: No bool fields — parameterize with existing typed enums. (link)

Comment on lines +19038 to +19041
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: PlayerId(1),
won: false,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Update the test setup to construct ResolutionCoinFlip with result: CoinFlipResult::Lost instead of won: false.

Why it matters: This aligns with the refactoring of ResolutionCoinFlip to adhere to Rule R2 of the repository style guide.

Suggested fix: Use result: CoinFlipResult::Lost.

Suggested change
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: PlayerId(1),
won: false,
});
state.resolution_coin_flip = Some(ResolutionCoinFlip {
flipper: PlayerId(1),
result: CoinFlipResult::Lost,
});
References
  1. Rule R2: No bool fields — parameterize with existing typed enums. (link)

@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

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

1 card(s) · ability/FlipCoin · field lose: yes

Examples: Unleash the Flux

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

Examples: Unleash the Flux

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@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.

Verdict: blocked — the new coin-flip result state can outlive its resolution, and the affected card lacks a production-pipeline regression test; request changes before merge.

🔴 Blocker

crates/engine/src/game/effects/mod.rs:6062 clears resolution_coin_flip only at the start of a WhileCondition iteration, while crates/engine/src/game/effects/mod.rs:8633 reads it whenever CoinFlipOutcome is evaluated. The state is documented as resolution-scoped but can therefore survive a completed resolution and affect a later evaluation. CR 608.2c requires that the controller follows the instructions in their written order; the card text says, “each player sacrifices a nonland permanent, then you flip a coin. If you lose the flip, repeat this process.” Move the reset to the authoritative resolution lifetime, including continuation completion, rather than one repeat branch.

crates/engine/src/game/effects/flip_coin.rs:1617 proves only a bare flip/repeat, while crates/engine/src/parser/oracle_effect/tests.rs:41813 checks Unleash the Flux only as an AST. Neither test reaches the real each-player sacrifice choices, continuation resume, and repeat decision. Add a registered runtime scenario that drives those choices and demonstrates that an actual loss repeats the whole process and a win stops it.

🟡 Non-blocking

crates/engine/src/types/game_state.rs:1154 introduces ResolutionCoinFlip { won: bool }, even though crates/engine/src/types/ability.rs:17814 already defines CoinFlipResult. Reuse that typed domain vocabulary so the stored result and AbilityCondition::CoinFlipOutcome cannot drift.

✅ Clean

The current parse-diff is scoped to Unleash the Flux: it removes the previous Unimplemented lose branch and adds the intended coin-flip continuation. CR 705.2 was verified: only the player who flips the coin wins or loses it.

Recommendation: request changes — give the flip result a true resolution/continuation lifetime, add the interactive runtime regression, and store the existing CoinFlipResult type.

@matthewevans matthewevans added the bug Bug fix label Jul 12, 2026
ntindle and others added 2 commits July 12, 2026 14:12
…CoinFlipResult

Address @matthewevans's CHANGES_REQUESTED review on Unleash the Flux:

- Blocker: `resolution_coin_flip` could outlive its resolution — it was only
  cleared at each `WhileCondition` iteration start, so a stale flip from one
  resolution could wrongly satisfy a later `CoinFlipOutcome` gate. Clear it at
  the authoritative resolution-lifetime boundary (top-level `resolve_ability_chain`
  entry, depth == 0), which uniformly covers loop completion, bounded-cap exit,
  and the no-loop case (CR 608.2c + CR 705.2). The per-iteration clear remains as
  the intra-loop boundary.
- Reuse the existing `CoinFlipResult` typed vocabulary for the stored result
  (`ResolutionCoinFlip { flipper, result }` instead of `won: bool`) so the written
  value and `AbilityCondition::CoinFlipOutcome`'s read predicate can never drift;
  add `CoinFlipResult::from_won` as the single bool->enum authority.
- Add a registered runtime scenario (GameScenario + GameRunner) that drives the
  each-player nonland-permanent sacrifice choices, the flip continuation resume,
  and the repeat decision — proving an actual LOSS repeats the whole process and a
  WIN stops it — plus a focused unit test proving a stale flip does not leak into
  the next resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR
@matthewevans matthewevans self-assigned this Jul 12, 2026

@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.

Approve — the current head resolves the requested coin-flip lifetime, typed-state, and runtime-coverage gaps; ready for the merge queue.

🔴 Blocker

  • None. The prior requested changes are resolved on ae42ff638d82c2769f36149486d67d6b930207d9.

🟡 Non-blocking

  • None.

✅ Clean

  • crates/engine/src/game/effects/mod.rs:5983-6002 establishes the top-level resolution lifetime and :6078-6085 resets each WhileCondition iteration, preventing both cross-resolution and cross-iteration leakage.
  • crates/engine/src/types/game_state.rs:1157-1161 stores the existing CoinFlipResult, and crates/engine/src/game/effects/flip_coin.rs:87-90,464-467 records it at both normal and kept-flip authorities.
  • crates/engine/src/game/effects/flip_coin.rs:1741-1884 drives the parsed Unleash the Flux ability through real sacrifice selections, a losing flip that repeats, and a winning flip that stops. Current CI is green and the parse artifact is exactly one card with two expected signatures.

Recommendation: approve and enqueue with the existing bug label.

@matthewevans
matthewevans added this pull request to the merge queue Jul 12, 2026
@matthewevans matthewevans removed their assignment Jul 12, 2026
Merged via the queue into phase-rs:main with commit c8d4ea7 Jul 12, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants