-
-
Notifications
You must be signed in to change notification settings - Fork 149
fix(parser): preserve discard-this-way conditional outcomes #6855
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
174 changes: 174 additions & 0 deletions
174
crates/engine/tests/integration/chains_of_mephistopheles_discard_draw_or_mill.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| //! Runtime pipeline regression for issue #5653 — Chains of Mephistopheles / | ||
| //! Magus of the Chains. | ||
| //! | ||
| //! Oracle text: "If a player would draw a card except the first one they draw | ||
| //! in each of their draw steps, that player discards a card instead. If the | ||
| //! player discards a card this way, they draw a card. If the player doesn't | ||
| //! discard a card this way, they mill a card." | ||
| //! | ||
| //! The draw and mill branches are mutually exclusive on whether the discard | ||
| //! actually removed a card: a nonempty hand discards then draws (no mill); an | ||
| //! empty hand fails to discard, so the player mills instead of drawing (no | ||
| //! draw). Each scenario stocks the library with TWO cards so an unconditional | ||
| //! chain (both Draw and Mill firing regardless of the discard's outcome) is | ||
| //! distinguishable from the correct either/or behavior — with only one library | ||
| //! card, an unconditional Draw would exhaust the library before Mill could act, | ||
| //! making the bug invisible. | ||
|
|
||
| use engine::game::scenario::{GameScenario, P0, P1}; | ||
| use engine::game::scenario_db::GameScenarioDbExt; | ||
| use engine::types::actions::{DebugAction, GameAction}; | ||
| use engine::types::phase::Phase; | ||
| use engine::types::zones::Zone; | ||
|
|
||
| use crate::support::shared_card_db as load_db; | ||
|
|
||
| fn card_names( | ||
| state: &engine::types::game_state::GameState, | ||
| ids: impl Iterator<Item = engine::types::identifiers::ObjectId>, | ||
| ) -> Vec<String> { | ||
| ids.filter_map(|id| state.objects.get(&id).map(|o| o.name.clone())) | ||
| .collect() | ||
| } | ||
|
|
||
| /// CR 121.1 + CR 614.1a: with a card in hand, the replaced draw discards it, | ||
| /// then — because the discard succeeded — the player draws a replacement | ||
| /// card, and the mill branch must NOT also run. The library carries a second | ||
| /// card so an unconditional chain (Draw AND Mill both firing) would leave the | ||
| /// library empty and put a second card in the graveyard; only the correct | ||
| /// either/or behavior leaves "Sol Ring" untouched in the library. | ||
| #[test] | ||
| fn chains_nonempty_hand_discards_then_draws_no_mill() { | ||
| let db = load_db().expect("shared card database must be available for this integration test"); | ||
|
|
||
| let mut scenario = GameScenario::new(); | ||
| scenario.at_phase(Phase::PreCombatMain); | ||
| scenario.add_real_card(P0, "Chains of Mephistopheles", Zone::Battlefield, db); | ||
| scenario.add_real_card(P0, "Grizzly Bears", Zone::Hand, db); | ||
| // Library front-to-back: [Hill Giant, Sol Ring]. A correct single draw | ||
| // takes only Hill Giant, leaving Sol Ring in the library. | ||
| scenario.add_real_card(P0, "Hill Giant", Zone::Library, db); | ||
| scenario.add_real_card(P0, "Sol Ring", Zone::Library, db); | ||
| // P1 needs *some* library so SBAs don't fire. | ||
| for _ in 0..5 { | ||
| scenario.add_real_card(P1, "Plains", Zone::Library, db); | ||
| } | ||
| let mut runner = scenario.build(); | ||
| engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); | ||
| runner.state_mut().debug_mode = true; | ||
|
|
||
| runner | ||
| .act(GameAction::Debug(DebugAction::DrawCards { | ||
| player_id: P0, | ||
| count: 1, | ||
| })) | ||
| .expect("debug draw must succeed"); | ||
| runner.advance_until_stack_empty(); | ||
|
|
||
| let hand_names = card_names( | ||
| runner.state(), | ||
| runner.state().players[0].hand.iter().copied(), | ||
| ); | ||
| assert_eq!( | ||
| hand_names, | ||
| vec!["Hill Giant".to_string()], | ||
| "the discard-then-draw branch must swap Grizzly Bears for the drawn \ | ||
| Hill Giant (net hand size unchanged); got {hand_names:?}" | ||
| ); | ||
|
|
||
| let graveyard_names = card_names( | ||
| runner.state(), | ||
| runner.state().players[0].graveyard.iter().copied(), | ||
| ); | ||
| assert_eq!( | ||
| graveyard_names, | ||
| vec!["Grizzly Bears".to_string()], | ||
| "exactly the discarded card must be in the graveyard — a second card \ | ||
| here would mean the mill branch ran alongside the draw branch \ | ||
| instead of being mutually exclusive with it; got {graveyard_names:?}" | ||
| ); | ||
|
|
||
| let library_names = card_names( | ||
| runner.state(), | ||
| runner.state().players[0].library.iter().copied(), | ||
| ); | ||
| assert_eq!( | ||
| library_names, | ||
| vec!["Sol Ring".to_string()], | ||
| "only Hill Giant may leave the library (the single draw) — Sol Ring \ | ||
| must remain; a missing Sol Ring would mean mill also fired and \ | ||
| consumed it; got {library_names:?}" | ||
| ); | ||
| } | ||
|
|
||
| /// CR 121.1 + CR 614.1a: with an empty hand, the replaced draw has nothing to | ||
| /// discard, so the discard fails — and because it failed, the player mills a | ||
| /// card instead of drawing. The library carries a second card so an | ||
| /// unconditional chain (Draw AND Mill both firing) would draw one card into | ||
| /// hand AND mill the other; only the correct either/or behavior leaves the | ||
| /// hand empty and "Sol Ring" untouched in the library. | ||
| #[test] | ||
| fn chains_empty_hand_fails_discard_then_mills_no_draw() { | ||
| let db = load_db().expect("shared card database must be available for this integration test"); | ||
|
|
||
| let mut scenario = GameScenario::new(); | ||
| scenario.at_phase(Phase::PreCombatMain); | ||
| scenario.add_real_card(P0, "Chains of Mephistopheles", Zone::Battlefield, db); | ||
| // Library front-to-back: [Hill Giant, Sol Ring]. A correct mill takes only | ||
| // Hill Giant (the top card), leaving Sol Ring in the library. | ||
| scenario.add_real_card(P0, "Hill Giant", Zone::Library, db); | ||
| scenario.add_real_card(P0, "Sol Ring", Zone::Library, db); | ||
| // P1 needs *some* library so SBAs don't fire. | ||
| for _ in 0..5 { | ||
| scenario.add_real_card(P1, "Plains", Zone::Library, db); | ||
| } | ||
| let mut runner = scenario.build(); | ||
| engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); | ||
| runner.state_mut().debug_mode = true; | ||
|
|
||
| assert!( | ||
| runner.state().players[0].hand.is_empty(), | ||
| "test setup must start with an empty P0 hand" | ||
| ); | ||
|
|
||
| runner | ||
| .act(GameAction::Debug(DebugAction::DrawCards { | ||
| player_id: P0, | ||
| count: 1, | ||
| })) | ||
| .expect("debug draw must succeed"); | ||
| runner.advance_until_stack_empty(); | ||
|
|
||
| let hand_names = card_names( | ||
| runner.state(), | ||
| runner.state().players[0].hand.iter().copied(), | ||
| ); | ||
| assert!( | ||
| hand_names.is_empty(), | ||
| "an empty hand cannot discard, so the draw branch must NOT fire \ | ||
| either — the hand must stay empty; got {hand_names:?}" | ||
| ); | ||
|
|
||
| let graveyard_names = card_names( | ||
| runner.state(), | ||
| runner.state().players[0].graveyard.iter().copied(), | ||
| ); | ||
| assert_eq!( | ||
| graveyard_names, | ||
| vec!["Hill Giant".to_string()], | ||
| "the mill branch must move exactly the top library card to the \ | ||
| graveyard; got {graveyard_names:?}" | ||
| ); | ||
|
|
||
| let library_names = card_names( | ||
| runner.state(), | ||
| runner.state().players[0].library.iter().copied(), | ||
| ); | ||
| assert_eq!( | ||
| library_names, | ||
| vec!["Sol Ring".to_string()], | ||
| "only Hill Giant may leave the library (the single mill) — Sol Ring \ | ||
| must remain; a missing Sol Ring would mean the draw branch also \ | ||
| fired and consumed it; got {library_names:?}" | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Compose the subject dimension instead of repeating full tag() strings per subject.
parse_discard_this_way_affirmative_connectorandparse_discard_this_way_negated_connectoreach hard-code onetag()per subject ("you", "a player", "they", "that player", "the player"), duplicating the exact subject list already enumerated inparse_affirmative_reflexive_connectorandparse_negated_reflexive_connector. This repeats the full compound phrase ("if a player discards a card this way, ") instead of composing an existing subject-alternative with a shared verb-phrase suffix.If a new subject/anaphor form is added later, it must be updated in two independent places (the bare "does"/"doesn't" list and the "discard(s) a card this way" list), risking silent drift between them.
Compose the subject alternative (with its matching verb form) once, then append the fixed " discard(s)/discard(s)n't a card this way, " suffix, so both lists share one subject-to-verb-form source.
♻️ Suggested composition approach
Based on learnings, the referenced documentation for this file states: "The discard-this-way condition additions should therefore be generalized across supported subject/anaphor forms and wired into the existing condition dispatcher, not implemented as card-name or full-string special cases." As per coding guidelines,
crates/engine/src/parser/**/*.rsrequires composing nom combinators across independent dimensions "instead of enumerating full-string permutations."Also applies to: 9493-9523
🤖 Prompt for AI Agents
Sources: Coding guidelines, Path instructions