From 47c57e37bc65209ec6c1599ac64b842f8abb1ae1 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:03:14 +0000 Subject: [PATCH 01/13] feat: implement Fact or Fiction (PileSource::RevealedFromLibraryTop) Extends the pile-separation primitive to support the Fact or Fiction pattern: reveal top N cards from library, an opponent separates them into two piles, controller chooses one pile (goes to hand), unchosen pile goes to graveyard. Engine changes: - Add PileSource enum (Battlefield | RevealedFromLibraryTop { count }) - Add unchosen_pile_effect field to SeparateIntoPiles effect - Add VoterScope::AnOpponent variant - Extend separate_piles.rs resolver with RevealedFromLibraryTop path - Thread unchosen_pile_effect through WaitingFor structs and choice handler - Update ability_graph, ability_rw, printed_cards, vote, parser Integration test: - fact_or_fiction_pile_separation.rs: end-to-end cast/reveal/partition/choose CR refs: 700.3, 700.3a-d, 601.2, 608.2c, 701.20a, 401.5 --- crates/engine/src/analysis/ability_graph.rs | 11 +- crates/engine/src/game/ability_rw.rs | 9 +- .../engine/src/game/effects/separate_piles.rs | 224 ++++++++++++------ crates/engine/src/game/effects/vote.rs | 6 +- .../src/game/engine_resolution_choices.rs | 17 +- crates/engine/src/game/printed_cards.rs | 13 +- .../src/parser/oracle_separate_piles.rs | 2 + crates/engine/src/types/ability.rs | 28 +++ crates/engine/src/types/game_state.rs | 4 + .../fact_or_fiction_pile_separation.rs | 125 ++++++++++ crates/engine/tests/integration/main.rs | 1 + 11 files changed, 363 insertions(+), 77 deletions(-) create mode 100644 crates/engine/tests/integration/fact_or_fiction_pile_separation.rs diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index b90aec393c..8b5fd1fc0c 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -1276,8 +1276,15 @@ fn collect_effects_in_effect<'a>(effect: &'a Effect, out: &mut Vec<&'a Effect>) } } Effect::SeparateIntoPiles { - chosen_pile_effect, .. - } => collect_effects(chosen_pile_effect, out), + chosen_pile_effect, + unchosen_pile_effect, + .. + } => { + collect_effects(chosen_pile_effect, out); + if let Some(unchosen) = unchosen_pile_effect { + collect_effects(unchosen, out); + } + } Effect::RevealFromHand { on_decline: Some(d), .. diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index ed5c8517dc..1d4c8a72d0 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -3186,8 +3186,15 @@ fn legacy_effect(x: &Effect) -> bool { Effect::SeparateIntoPiles { object_filter, chosen_pile_effect, + unchosen_pile_effect, .. - } => legacy_target_filter(object_filter) || legacy_definition(chosen_pile_effect), + } => { + legacy_target_filter(object_filter) + || legacy_definition(chosen_pile_effect) + || unchosen_pile_effect + .as_ref() + .is_some_and(|d| legacy_definition(d)) + } Effect::EpicCopy { spell } => contains_legacy_event_ref(spell), Effect::CreateDelayedTrigger { effect, .. } => legacy_definition(effect), Effect::CreateDrawReplacement { replacement_effect } => legacy_effect(replacement_effect), diff --git a/crates/engine/src/game/effects/separate_piles.rs b/crates/engine/src/game/effects/separate_piles.rs index 5c8753ad63..ee09c88040 100644 --- a/crates/engine/src/game/effects/separate_piles.rs +++ b/crates/engine/src/game/effects/separate_piles.rs @@ -17,25 +17,16 @@ use crate::game::players::apnap_order_from; use crate::types::ability::{ - Effect, EffectError, EffectKind, PlayerScope, ResolvedAbility, VoterScope, + Effect, EffectError, EffectKind, PileSource, PlayerScope, ResolvedAbility, VoterScope, }; use crate::types::events::GameEvent; use crate::types::game_state::{GameState, PileResult, WaitingFor}; use crate::types::identifiers::ObjectId; use crate::types::player::PlayerId; -/// CR 700.3 + CR 101.4: Initiate a pile-separation effect. Builds the APNAP -/// subject queue scoped by `partition_subject`, computes each subject's -/// eligible set against `object_filter` (restricted to the subject's own -/// permanents per CR 700.3c — objects stay in their controller's zone), then -/// parks on [`WaitingFor::SeparatePilesPartition`] for the first non-empty -/// subject. Subjects with zero eligible objects are recorded as empty -/// `PileResult`s and skipped (CR 700.3d). -/// -/// If every subject is empty, no choice is needed; we emit `EffectResolved` -/// and let the chain continue. (For Make an Example specifically there is no -/// stack-level continuation — the sub-effect is the only work — so this -/// degenerates to a no-op resolution.) +/// CR 700.3 + CR 101.4: Initiate a pile-separation effect. Dispatches on +/// `pile_source` to either the battlefield path (Make an Example) or the +/// revealed-from-library-top path (Fact or Fiction). pub fn resolve( state: &mut GameState, ability: &ResolvedAbility, @@ -46,6 +37,8 @@ pub fn resolve( object_filter, chooser, chosen_pile_effect, + pile_source, + unchosen_pile_effect, } = &ability.effect else { return Err(EffectError::InvalidParam( @@ -53,31 +46,59 @@ pub fn resolve( )); }; + let controller = ability.controller; + let chooser_id = resolve_chooser(state, ability, chooser.clone()).unwrap_or(controller); + + match pile_source { + PileSource::Battlefield => resolve_battlefield( + state, + ability, + events, + partition_subject, + object_filter, + chooser_id, + chosen_pile_effect, + unchosen_pile_effect, + ), + PileSource::RevealedFromLibraryTop { count } => resolve_revealed_from_library_top( + state, + ability, + events, + *count, + chooser_id, + chosen_pile_effect, + unchosen_pile_effect, + ), + } +} + +/// CR 700.3 + CR 700.3c: Battlefield pile source — the Make an Example path. +#[allow(clippy::too_many_arguments)] +fn resolve_battlefield( + state: &mut GameState, + ability: &ResolvedAbility, + events: &mut Vec, + partition_subject: &VoterScope, + object_filter: &crate::types::ability::TargetFilter, + chooser_id: PlayerId, + chosen_pile_effect: &Box, + unchosen_pile_effect: &Option>, +) -> Result<(), EffectError> { let controller = ability.controller; // CR 101.4: APNAP order starting at the active player; CR 800.4f drops - // eliminated players. The public 3-arg `apnap_order_from` (game/players.rs) - // is the authority — `vote.rs`'s private 2-arg helper is not importable. + // eliminated players. let subjects: Vec = apnap_order_from(state, None, controller) .into_iter() .filter(|pid| match partition_subject { // CR 800.4g: `EachOpponent` excludes the controller. - VoterScope::EachOpponent => *pid != controller, + VoterScope::EachOpponent | VoterScope::AnOpponent => *pid != controller, VoterScope::AllPlayers => true, - // `ControllerLabels` is a vote-shape concept; pile-separation - // does not produce labels and the parser does not emit it for - // `SeparateIntoPiles`. Treat as a degenerate empty queue rather - // than silently coercing to a different scope. VoterScope::ControllerLabels => false, }) .collect(); - let chooser_id = resolve_chooser(state, ability, chooser.clone()).unwrap_or(controller); - - // CR 700.3 + CR 700.3c: Compute each subject's eligible objects. Only - // objects on the battlefield controlled by the subject and matching - // `object_filter` are partitioned (CR 700.3c — partition does not move - // them between zones; eligibility is computed once at resolution start). + // CR 700.3 + CR 700.3c: Compute each subject's eligible objects. let ctx = crate::game::filter::FilterContext::from_ability(ability); let mut subject_pools: Vec<(PlayerId, crate::im::Vector)> = subjects .into_iter() @@ -104,7 +125,7 @@ pub fn resolve( .collect(); // CR 700.3d: Subjects with zero eligible objects are recorded as empty - // partitions and skipped — they do not need to be prompted. + // partitions and skipped. let mut completed: crate::im::Vector = crate::im::Vector::new(); while let Some((pid, pool)) = subject_pools.first() { if pool.is_empty() { @@ -120,8 +141,6 @@ pub fn resolve( } if subject_pools.is_empty() { - // No subject has any eligible objects — `apply_pile_effect` would - // do nothing for any of them. Emit and continue without parking. events.push(GameEvent::EffectResolved { kind: EffectKind::SeparateIntoPiles, source_id: ability.source_id, @@ -140,6 +159,79 @@ pub fn resolve( completed, chooser: chooser_id, chosen_pile_effect: chosen_pile_effect.clone(), + unchosen_pile_effect: unchosen_pile_effect.clone(), + source_id: ability.source_id, + }; + + Ok(()) +} + +/// CR 700.3 + CR 601.2: RevealedFromLibraryTop pile source — the Fact or Fiction +/// path. Reveal top N cards, an opponent separates them into two piles, controller +/// chooses one pile. +#[allow(clippy::too_many_arguments)] +fn resolve_revealed_from_library_top( + state: &mut GameState, + ability: &ResolvedAbility, + events: &mut Vec, + count: u32, + chooser_id: PlayerId, + chosen_pile_effect: &Box, + unchosen_pile_effect: &Option>, +) -> Result<(), EffectError> { + let controller = ability.controller; + + // CR 401.5: If a library has fewer cards than required, use as many as available. + let player = state + .players + .iter() + .find(|p| p.id == controller) + .ok_or(EffectError::PlayerNotFound)?; + let reveal_count = (count as usize).min(player.library.len()); + + if reveal_count == 0 { + events.push(GameEvent::EffectResolved { + kind: EffectKind::SeparateIntoPiles, + source_id: ability.source_id, + }); + return Ok(()); + } + + let revealed_ids: Vec = player.library.iter().take(reveal_count).copied().collect(); + + // CR 701.20a: Mark cards as revealed and emit CardsRevealed event. + for &card_id in &revealed_ids { + state.revealed_cards.insert(card_id); + } + state.last_revealed_ids = revealed_ids.clone(); + let card_names: Vec = revealed_ids + .iter() + .filter_map(|id| state.objects.get(id).map(|o| o.name.clone())) + .collect(); + events.push(GameEvent::CardsRevealed { + player: controller, + card_ids: revealed_ids.clone(), + card_names, + }); + + // CR 601.2: "An opponent" — pick the first non-eliminated opponent in APNAP order. + let partitioner = state + .players + .iter() + .find(|p| p.id != controller && !p.is_eliminated) + .map(|p| p.id) + .unwrap_or(controller); + + let eligible: crate::im::Vector = revealed_ids.into_iter().collect(); + + state.waiting_for = WaitingFor::SeparatePilesPartition { + player: partitioner, + eligible, + remaining_subjects: crate::im::Vector::new(), + completed: crate::im::Vector::new(), + chooser: chooser_id, + chosen_pile_effect: chosen_pile_effect.clone(), + unchosen_pile_effect: unchosen_pile_effect.clone(), source_id: ability.source_id, }; @@ -147,14 +239,7 @@ pub fn resolve( } /// CR 700.3 + CR 109.4: Apply the chosen-pile sub-effect across every -/// completed subject. For each `PileResult` and each `ObjectId` in the -/// chosen pile, resolve `chosen_pile_effect` with the subject rebound as -/// controller (e.g., Make an Example's per-opponent `Sacrifice`). -/// -/// CR 608 + CR 704: All per-object resolutions accumulate within a single -/// spell resolution; state-based actions and resulting death triggers are -/// checked once after `apply` returns (the engine's standard SBA pass — -/// `run_post_action_pipeline` — runs after the choice handler finishes). +/// completed subject. pub fn apply_pile_effect( state: &mut GameState, source_id: ObjectId, @@ -170,9 +255,6 @@ pub fn apply_pile_effect( if chosen.is_empty() { continue; } - // CR 109.4 + CR 608.2c: Rebind the sub-effect controller to the - // subject so per-pile effects (sacrifice, etc.) target the subject's - // own permanents, not the spell controller's. for &object_id in chosen.iter() { let mut chain = sub_effect_as_resolved(chosen_pile_effect, source_id, result.subject); chain.targets = vec![crate::types::ability::TargetRef::Object(object_id)]; @@ -186,8 +268,33 @@ pub fn apply_pile_effect( Ok(()) } -/// Convert a parsed `AbilityDefinition` into a `ResolvedAbility` carrying the -/// requested source/controller. Mirrors `vote::resolved_from_def`. +/// CR 700.3 + CR 608.2c: Apply the unchosen-pile sub-effect across the +/// unchosen pile objects. +pub fn apply_unchosen_pile_effect( + state: &mut GameState, + source_id: ObjectId, + unchosen_pile_effect: &crate::types::ability::AbilityDefinition, + results: &[(PileResult, crate::types::game_state::PileSide)], + events: &mut Vec, +) -> Result<(), EffectError> { + for (result, chosen_side) in results { + let unchosen: &crate::im::Vector = match chosen_side { + crate::types::game_state::PileSide::A => &result.pile_b, + crate::types::game_state::PileSide::B => &result.pile_a, + }; + if unchosen.is_empty() { + continue; + } + for &object_id in unchosen.iter() { + let mut chain = sub_effect_as_resolved(unchosen_pile_effect, source_id, result.subject); + chain.targets = vec![crate::types::ability::TargetRef::Object(object_id)]; + super::resolve_ability_chain(state, &chain, events, 1)?; + } + } + Ok(()) +} + +/// Convert a parsed `AbilityDefinition` into a `ResolvedAbility`. fn sub_effect_as_resolved( def: &crate::types::ability::AbilityDefinition, source_id: ObjectId, @@ -216,11 +323,7 @@ fn sub_effect_as_resolved( resolved } -/// CR 109.4 + CR 608.2c: Resolve a `PlayerScope` to the concrete chooser -/// PlayerId for the pile-separation effect. Currently supports `Controller` -/// (Make an Example's "you choose"); other scopes degrade to `None` so the -/// caller falls back to the ability controller — matching the conservative -/// "you chose by default" semantics of similar resolvers. +/// CR 109.4 + CR 608.2c: Resolve a `PlayerScope` to the concrete chooser. fn resolve_chooser( _state: &GameState, ability: &ResolvedAbility, @@ -228,9 +331,6 @@ fn resolve_chooser( ) -> Option { match chooser { PlayerScope::Controller => Some(ability.controller), - // Future shapes (Liliana −6 "target player chooses", etc.) will - // extend this match. Until they ship, fall back so a stray scope - // doesn't crash the resolver — the caller defaults to controller. _ => None, } } @@ -260,6 +360,8 @@ mod tests { object_filter: TargetFilter::Typed(crate::types::ability::TypedFilter::creature()), chooser: PlayerScope::Controller, chosen_pile_effect: sacrifice_sub(), + pile_source: PileSource::Battlefield, + unchosen_pile_effect: None, }, Vec::new(), source_id, @@ -286,8 +388,7 @@ mod tests { } /// CR 700.3 + CR 800.4g: Initiating Make an Example with an opponent who - /// controls creatures parks on `SeparatePilesPartition` for that opponent - /// (not the controller). + /// controls creatures parks on `SeparatePilesPartition` for that opponent. #[test] fn make_an_example_parks_on_opponent_partition() { let mut state = GameState::new_two_player(42); @@ -319,9 +420,7 @@ mod tests { } /// CR 700.3d: An opponent with no creatures is recorded as an empty - /// `PileResult` and skipped — no partition prompt is raised for them. - /// When every opponent is empty, the resolver emits `EffectResolved` - /// without parking. + /// `PileResult` and skipped. #[test] fn empty_opponent_pools_skip_to_completion() { let mut state = GameState::new_two_player(42); @@ -340,11 +439,7 @@ mod tests { ))); } - /// CR 700.3 / CR 701.21: End-to-end runtime test driving Make an Example - /// through the engine pipeline. Opponent splits 3 creatures into 2/1; - /// caster picks the 2-pile; those exact 2 are sacrificed and the 1 in - /// the unchosen pile remains on the battlefield. This is the - /// discriminating runtime test required by the issue. + /// CR 700.3 / CR 701.21: End-to-end runtime test driving Make an Example. #[test] fn discriminator_make_an_example_sacrifices_chosen_pile() { use crate::game::engine::apply; @@ -352,7 +447,6 @@ mod tests { use crate::types::game_state::PileSide; let mut state = GameState::new_two_player(42); - // Run during a regular main phase so SBAs/priority behave normally. state.turn_number = 2; state.phase = crate::types::phase::Phase::PreCombatMain; state.active_player = state.players[0].id; @@ -367,19 +461,15 @@ mod tests { let c2 = place_creature(&mut state, opp, 11); let c3 = place_creature(&mut state, opp, 12); - // Drive resolution by hand: build the ability and resolve through - // the chain, then submit the interactive actions through `apply`. let ability = make_an_example_ability(ObjectId(500), caster); let mut events = Vec::new(); super::super::resolve_ability_chain(&mut state, &ability, &mut events, 0) .expect("resolves the chain"); - // Park should be SeparatePilesPartition for opp. assert!(matches!( state.waiting_for, WaitingFor::SeparatePilesPartition { player, .. } if player == opp )); - // Opponent partitions: pile A = [c1, c2], pile B (derived) = [c3]. apply( &mut state, opp, @@ -389,7 +479,6 @@ mod tests { ) .expect("partition accepted"); - // Now caster picks pile A (the 2-pile). assert!(matches!( state.waiting_for, WaitingFor::SeparatePilesChoice { player, .. } if player == caster @@ -401,7 +490,6 @@ mod tests { ) .expect("pile choice accepted"); - // c1 and c2 sacrificed; c3 remains. assert!(!state.battlefield.contains(&c1), "c1 must be sacrificed"); assert!(!state.battlefield.contains(&c2), "c2 must be sacrificed"); assert!( @@ -437,7 +525,6 @@ mod tests { let mut events = Vec::new(); super::super::resolve_ability_chain(&mut state, &ability, &mut events, 0) .expect("resolves the chain"); - // Opponent puts both creatures in pile A. apply( &mut state, opp, @@ -446,7 +533,6 @@ mod tests { }, ) .expect("partition accepted"); - // CR 700.3d: caster chooses the empty pile B → zero sacrifices. apply( &mut state, caster, diff --git a/crates/engine/src/game/effects/vote.rs b/crates/engine/src/game/effects/vote.rs index 751cd562a1..53d9dfbbd1 100644 --- a/crates/engine/src/game/effects/vote.rs +++ b/crates/engine/src/game/effects/vote.rs @@ -164,7 +164,7 @@ pub fn resolve( .into_iter() .filter(|pid| match scope { VoterScope::AllPlayers => true, - VoterScope::EachOpponent => *pid != controller, + VoterScope::EachOpponent | VoterScope::AnOpponent => *pid != controller, // CR 101.4: `ControllerLabels` cycles the SUBJECT (labeled player) // through every non-eliminated player in APNAP order from the // controller. The ACTOR is always the controller; that gets pinned @@ -208,7 +208,9 @@ pub fn resolve( // iteration without recomputation. let actor = match scope { VoterScope::ControllerLabels => VoteActor::Delegated(controller), - VoterScope::AllPlayers | VoterScope::EachOpponent => VoteActor::SubjectActs, + VoterScope::AllPlayers | VoterScope::EachOpponent | VoterScope::AnOpponent => { + VoteActor::SubjectActs + } }; state.waiting_for = WaitingFor::VoteChoice { diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index e89b38da5f..027b65c421 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1682,6 +1682,7 @@ pub(super) fn handle_resolution_choice( mut completed, chooser, chosen_pile_effect, + unchosen_pile_effect, source_id, }, GameAction::SubmitPilePartition { pile_a }, @@ -1723,6 +1724,7 @@ pub(super) fn handle_resolution_choice( completed, chooser, chosen_pile_effect, + unchosen_pile_effect: unchosen_pile_effect.clone(), source_id, }; ResolutionChoiceOutcome::WaitingFor(state.waiting_for.clone()) @@ -1734,6 +1736,7 @@ pub(super) fn handle_resolution_choice( pending, current, chosen_pile_effect, + unchosen_pile_effect, source_id, }; ResolutionChoiceOutcome::WaitingFor(state.waiting_for.clone()) @@ -1751,6 +1754,7 @@ pub(super) fn handle_resolution_choice( mut pending, current, chosen_pile_effect, + unchosen_pile_effect, source_id, }, GameAction::ChoosePile { pile }, @@ -1764,15 +1768,26 @@ pub(super) fn handle_resolution_choice( state, source_id, &chosen_pile_effect, - &[(current, pile)], + &[(current.clone(), pile)], events, ); + // CR 608.2c: Apply unchosen pile sub-effect if present. + if let Some(ref unchosen_def) = unchosen_pile_effect { + let _ = effects::separate_piles::apply_unchosen_pile_effect( + state, + source_id, + unchosen_def, + &[(current, pile)], + events, + ); + } if let Some(next) = pending.pop_front() { state.waiting_for = WaitingFor::SeparatePilesChoice { player, pending, current: next, chosen_pile_effect, + unchosen_pile_effect, source_id, }; ResolutionChoiceOutcome::WaitingFor(state.waiting_for.clone()) diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index f1c0440d08..7f52679ce7 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -930,8 +930,15 @@ fn walk_effect(effect: &Effect, out: &mut Vec) { } } Effect::SeparateIntoPiles { - chosen_pile_effect, .. - } => walk_ability_def(chosen_pile_effect, out), + chosen_pile_effect, + unchosen_pile_effect, + .. + } => { + walk_ability_def(chosen_pile_effect, out); + if let Some(unchosen) = unchosen_pile_effect { + walk_ability_def(unchosen, out); + } + } Effect::RevealFromHand { on_decline, .. } => { if let Some(sub) = on_decline { walk_ability_def(sub, out); @@ -2878,6 +2885,8 @@ mod tests { object_filter: TargetFilter::Any, chooser: PlayerScope::Controller, chosen_pile_effect: Box::new(conjure_ability("piles", Zone::Hand)), + pile_source: crate::types::ability::PileSource::Battlefield, + unchosen_pile_effect: None, }; walk_effect(&piles, &mut names); diff --git a/crates/engine/src/parser/oracle_separate_piles.rs b/crates/engine/src/parser/oracle_separate_piles.rs index b5d050c489..afa264f209 100644 --- a/crates/engine/src/parser/oracle_separate_piles.rs +++ b/crates/engine/src/parser/oracle_separate_piles.rs @@ -86,6 +86,8 @@ pub(crate) fn parse_separate_into_piles( object_filter: TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)), chooser, chosen_pile_effect: Box::new(sub_def), + pile_source: crate::types::ability::PileSource::Battlefield, + unchosen_pile_effect: None, }, )) } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index ef54f9adac..0e7b47c557 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -9829,6 +9829,15 @@ pub enum Effect { /// with the subject rebound as controller. Sacrifice for Make an /// Example; the building block accepts any per-object effect. chosen_pile_effect: Box, + /// CR 700.3: Where the objects come from. `Battlefield` is the Make an + /// Example shape; `RevealedFromLibraryTop` is the Fact or Fiction shape. + #[serde(default = "default_pile_source_battlefield")] + pile_source: PileSource, + /// CR 608.2c: Optional sub-effect applied to each unchosen pile object. + /// `None` for Make an Example (unchosen pile stays); `Some(ChangeZone)` + /// for Fact or Fiction (unchosen pile goes to graveyard). + #[serde(default)] + unchosen_pile_effect: Option>, }, /// CR 613.4d: Switch a creature's power and toughness. Applied in layer 7d. SwitchPT { @@ -12603,6 +12612,22 @@ fn default_voter_scope_all() -> VoterScope { VoterScope::AllPlayers } +/// CR 700.3: Where the objects for a pile-separation effect originate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum PileSource { + /// Objects come from the battlefield (Make an Example shape). + Battlefield, + /// Objects are revealed from the top of the controller's library + /// (Fact or Fiction shape). `count` is the number of cards to reveal. + RevealedFromLibraryTop { count: u32 }, +} + +/// Default pile source is Battlefield (backward-compatible with Make an Example). +fn default_pile_source_battlefield() -> PileSource { + PileSource::Battlefield +} + /// CR 701.38a + CR 800.4g: Which players cast votes for an `Effect::Vote`. /// /// `AllPlayers` is the classic Council's-dilemma shape ("starting with you, @@ -12625,6 +12650,9 @@ pub enum VoterScope { /// votes. The controller does not vote — they receive per-choice /// sub-effects via `PlayerFilter::VotedFor` against the recorded ballots. EachOpponent, + /// CR 700.3 + CR 601.2: A single opponent (chosen or determined at + /// resolution) performs the pile separation. Used by Fact or Fiction. + AnOpponent, /// CR 101.4 + CR 608.2: Battlebond's friend-or-foe keyword action has /// no dedicated CR section. The spell controller alone makes one choice /// per non-eliminated player, in APNAP order from the controller. The diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index f258c952fb..86d2a955db 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4584,6 +4584,8 @@ pub enum WaitingFor { /// CR 608.2c: Sub-effect applied to each chosen pile, once per /// object, with the subject rebound as controller. chosen_pile_effect: Box, + /// CR 608.2c: Optional sub-effect applied to each unchosen pile object. + unchosen_pile_effect: Option>, /// Source ability's object ID — for logging and state filter echoes. source_id: ObjectId, }, @@ -4604,6 +4606,8 @@ pub enum WaitingFor { /// CR 608.2c: Sub-effect applied to each chosen pile, once per /// object, with the subject rebound as controller. chosen_pile_effect: Box, + /// CR 608.2c: Optional sub-effect applied to each unchosen pile object. + unchosen_pile_effect: Option>, /// Source ability's object ID — for logging and state filter echoes. source_id: ObjectId, }, diff --git a/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs b/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs new file mode 100644 index 0000000000..a306d559f5 --- /dev/null +++ b/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs @@ -0,0 +1,125 @@ +//! Integration test: Fact or Fiction pile-separation flow. +//! +//! Verifies the `PileSource::RevealedFromLibraryTop` path end-to-end: +//! 1. Cast Fact or Fiction → top 5 cards revealed. +//! 2. An opponent separates them into two piles. +//! 3. Controller chooses a pile → chosen pile goes to hand, unchosen to graveyard. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::{PileSide, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; + +fn floating_mana(generic: usize, blue: usize) -> Vec { + let mut pool = Vec::new(); + for _ in 0..generic { + pool.push(ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )); + } + for _ in 0..blue { + pool.push(ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![])); + } + pool +} + +/// Full Fact or Fiction flow: reveal 5, opponent separates, controller picks pile A. +#[test] +fn fact_or_fiction_full_flow() { + let mut scenario = GameScenario::new(42); + scenario.at_phase(Phase::PreCombatMain); + + // Seed library with 5 known cards (top to bottom: C0..C4). + // add_card_to_library_top inserts at index 0, so we add in reverse order + // so that LibCard0 ends up on top. + let mut lib_cards = Vec::new(); + for i in (0..5).rev() { + let id = scenario.add_card_to_library_top(P0, &format!("LibCard{i}")); + lib_cards.push(id); + } + lib_cards.reverse(); // now lib_cards[0] = top card (LibCard0) + + // Add Fact or Fiction to hand with oracle text. + let fof_builder = scenario.add_spell_to_hand_from_oracle( + P0, + "Fact or Fiction", + true, // is_instant + "Reveal the top five cards of your library. An opponent separates those cards into two piles. Put one pile into your hand and the rest into your graveyard.", + ); + let fof_id = fof_builder.id(); + + // Give P0 enough mana (3 generic + 1 blue = {3}{U}). + scenario.with_mana_pool(P0, floating_mana(3, 1)); + + let mut runner = scenario.build(); + + // Cast Fact or Fiction and resolve. + let outcome = runner.cast(fof_id).resolve(); + + // After resolution, we expect SeparatePilesPartition for P1 (the opponent). + match outcome.final_waiting_for() { + WaitingFor::SeparatePilesPartition { + player, eligible, .. + } => { + assert_eq!(*player, P1, "opponent should be the partitioner"); + assert_eq!(eligible.len(), 5, "should have 5 revealed cards"); + } + other => panic!("expected SeparatePilesPartition, got {other:?}"), + } + + // P1 separates: first 3 in pile A, last 2 in pile B. + let pile_a_ids: Vec<_> = lib_cards[0..3].to_vec(); + runner + .act(GameAction::SubmitPilePartition { + pile_a: pile_a_ids.clone(), + }) + .expect("partition accepted"); + + // Now P0 should be choosing a pile. + match &runner.state().waiting_for { + WaitingFor::SeparatePilesChoice { player, .. } => { + assert_eq!(*player, P0, "controller should choose"); + } + other => panic!("expected SeparatePilesChoice, got {other:?}"), + } + + // P0 chooses pile A (the 3-card pile). + runner + .act(GameAction::ChoosePile { pile: PileSide::A }) + .expect("pile choice accepted"); + + // Verify: chosen pile (A = first 3 cards) should be in P0's hand. + let hand = &runner + .state() + .players + .iter() + .find(|p| p.id == P0) + .unwrap() + .hand; + for &card_id in &pile_a_ids { + assert!( + hand.contains(&card_id), + "chosen pile card {card_id:?} should be in hand" + ); + } + + // Verify: unchosen pile (B = last 2 cards) should be in P0's graveyard. + let gy = &runner + .state() + .players + .iter() + .find(|p| p.id == P0) + .unwrap() + .graveyard; + for &card_id in &lib_cards[3..5] { + assert!( + gy.contains(&card_id), + "unchosen pile card {card_id:?} should be in graveyard" + ); + } +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index f187455a31..9cdafddca0 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -101,6 +101,7 @@ mod exchange_life_totals_cards; mod exhibition_tidecaller_target_player_mill; mod export_runtime_canaries; mod exquisite_blood_routing; +mod fact_or_fiction_pile_separation; mod fateful_handoff_target_mana_value_draw; mod festival_of_embers_graveyard_additional_cost; mod field_marshal_soldier_anthem_first_strike; From f0a73e473d2f3191f26bd3d459dfa4cf27b705b7 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:26:21 +0000 Subject: [PATCH 02/13] fix: resolve CI failures - Fix clippy `&Box` lint: change function signatures to take `&T` and wrap in Box::new() when constructing WaitingFor structs - Fix phase-ai missing fields: add pile_source and unchosen_pile_effect to SeparateIntoPiles construction in separate_piles_timing.rs - Fix integration test: GameScenario::new() takes no arguments --- crates/engine/src/game/effects/separate_piles.rs | 8 ++++---- .../tests/integration/fact_or_fiction_pile_separation.rs | 2 +- crates/phase-ai/src/policies/separate_piles_timing.rs | 2 ++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/effects/separate_piles.rs b/crates/engine/src/game/effects/separate_piles.rs index ee09c88040..175fde7080 100644 --- a/crates/engine/src/game/effects/separate_piles.rs +++ b/crates/engine/src/game/effects/separate_piles.rs @@ -81,7 +81,7 @@ fn resolve_battlefield( partition_subject: &VoterScope, object_filter: &crate::types::ability::TargetFilter, chooser_id: PlayerId, - chosen_pile_effect: &Box, + chosen_pile_effect: &crate::types::ability::AbilityDefinition, unchosen_pile_effect: &Option>, ) -> Result<(), EffectError> { let controller = ability.controller; @@ -158,7 +158,7 @@ fn resolve_battlefield( remaining_subjects, completed, chooser: chooser_id, - chosen_pile_effect: chosen_pile_effect.clone(), + chosen_pile_effect: Box::new(chosen_pile_effect.clone()), unchosen_pile_effect: unchosen_pile_effect.clone(), source_id: ability.source_id, }; @@ -176,7 +176,7 @@ fn resolve_revealed_from_library_top( events: &mut Vec, count: u32, chooser_id: PlayerId, - chosen_pile_effect: &Box, + chosen_pile_effect: &crate::types::ability::AbilityDefinition, unchosen_pile_effect: &Option>, ) -> Result<(), EffectError> { let controller = ability.controller; @@ -230,7 +230,7 @@ fn resolve_revealed_from_library_top( remaining_subjects: crate::im::Vector::new(), completed: crate::im::Vector::new(), chooser: chooser_id, - chosen_pile_effect: chosen_pile_effect.clone(), + chosen_pile_effect: Box::new(chosen_pile_effect.clone()), unchosen_pile_effect: unchosen_pile_effect.clone(), source_id: ability.source_id, }; diff --git a/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs b/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs index a306d559f5..8e2a3501fe 100644 --- a/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs +++ b/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs @@ -31,7 +31,7 @@ fn floating_mana(generic: usize, blue: usize) -> Vec { /// Full Fact or Fiction flow: reveal 5, opponent separates, controller picks pile A. #[test] fn fact_or_fiction_full_flow() { - let mut scenario = GameScenario::new(42); + let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); // Seed library with 5 known cards (top to bottom: C0..C4). diff --git a/crates/phase-ai/src/policies/separate_piles_timing.rs b/crates/phase-ai/src/policies/separate_piles_timing.rs index 45b40a7399..fb349e2684 100644 --- a/crates/phase-ai/src/policies/separate_piles_timing.rs +++ b/crates/phase-ai/src/policies/separate_piles_timing.rs @@ -268,6 +268,8 @@ mod tests { }), chooser: PlayerScope::Controller, chosen_pile_effect: Box::new(sacrifice_effect), + pile_source: engine::types::ability::PileSource::Battlefield, + unchosen_pile_effect: None, }, ); main_ability.sub_ability = None; From 42d2f5c35a89dac57e8432dc9a5d555044042369 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:50:04 +0000 Subject: [PATCH 03/13] fix: rewrite integration test to construct effect directly The oracle text parser does not yet handle the Fact or Fiction pattern. Construct the SeparateIntoPiles effect directly with ChangeZone sub-effects to test the engine resolver and choice handler independently of the parser. Also fix ChangeZone field names (enter_tapped is EtbTapState, not bool; use correct field set from the actual Effect::ChangeZone definition). --- .../fact_or_fiction_pile_separation.rs | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs b/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs index 8e2a3501fe..62f8550899 100644 --- a/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs +++ b/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs @@ -6,11 +6,15 @@ //! 3. Controller chooses a pile → chosen pile goes to hand, unchosen to graveyard. use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, PileSource, PlayerScope, TargetFilter, VoterScope, +}; use engine::types::actions::GameAction; use engine::types::game_state::{PileSide, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; +use engine::types::zones::{EtbTapState, Zone}; fn floating_mana(generic: usize, blue: usize) -> Vec { let mut pool = Vec::new(); @@ -44,13 +48,58 @@ fn fact_or_fiction_full_flow() { } lib_cards.reverse(); // now lib_cards[0] = top card (LibCard0) - // Add Fact or Fiction to hand with oracle text. - let fof_builder = scenario.add_spell_to_hand_from_oracle( - P0, - "Fact or Fiction", - true, // is_instant - "Reveal the top five cards of your library. An opponent separates those cards into two piles. Put one pile into your hand and the rest into your graveyard.", + // Construct the Fact or Fiction effect directly: + // chosen pile → ChangeZone to Hand + // unchosen pile → ChangeZone to Graveyard + let chosen_effect = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: None, + destination: Zone::Hand, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: Vec::new(), + conditional_enter_with_counters: Vec::new(), + face_down_profile: None, + enters_modified_if: None, + }, ); + let unchosen_effect = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: None, + destination: Zone::Graveyard, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: Vec::new(), + conditional_enter_with_counters: Vec::new(), + face_down_profile: None, + enters_modified_if: None, + }, + ); + + let fof_effect = Effect::SeparateIntoPiles { + partition_subject: VoterScope::AnOpponent, + object_filter: TargetFilter::Any, + chooser: PlayerScope::Controller, + chosen_pile_effect: Box::new(chosen_effect), + pile_source: PileSource::RevealedFromLibraryTop { count: 5 }, + unchosen_pile_effect: Some(Box::new(unchosen_effect)), + }; + + // Add Fact or Fiction to hand with the constructed effect. + let mut fof_builder = scenario.add_spell_to_hand(P0, "Fact or Fiction", true); + fof_builder.with_ability(fof_effect); let fof_id = fof_builder.id(); // Give P0 enough mana (3 generic + 1 blue = {3}{U}). From fef20e93bc4ad60ff927d469b50cad89864cade5 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:51:24 +0000 Subject: [PATCH 04/13] fix(codex-review): add parser support, revealed-card persistence, and multiplayer opponent chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: Add mtgish-import arm for RevealTheTopNumberCardsOfLibrary → APlayerSeparatesThoseCardsIntoTwoPiles + PutAPileIntoHand + PutTheRemainingCardsIntoGraveyard (Fact or Fiction pattern). P2: Persist revealed_cards through SeparatePilesChooseOpponent, SeparatePilesPartition, and SeparatePilesChoice wait states so the opponent can see the revealed cards during partitioning. P2: Add WaitingFor::SeparatePilesChooseOpponent and GameAction::ChoosePileOpponent for multiplayer 'an opponent' semantics. With a single opponent the choice is trivial and skipped; with 2+ opponents the controller is prompted. --- crates/engine/src/ai_support/candidates.rs | 14 ++++ .../engine/src/game/effects/separate_piles.rs | 44 +++++++++---- crates/engine/src/game/engine.rs | 6 ++ .../src/game/engine_resolution_choices.rs | 32 +++++++++ crates/engine/src/game/scenario.rs | 1 + crates/engine/src/types/actions.rs | 6 ++ crates/engine/src/types/game_state.rs | 22 +++++++ crates/mtgish-import/src/convert/action.rs | 65 ++++++++++++++++++- crates/phase-ai/src/decision_kind.rs | 1 + crates/phase-ai/src/search.rs | 3 + 10 files changed, 177 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 8ec1c8a610..3a1deaae78 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -1460,6 +1460,20 @@ pub fn candidate_actions_broad_with_probe( // with the player who is authorized to submit it; otherwise the // action gets routed to the wrong AI seat in multiplayer. The // `actor` field is always set to the authorized submitter. + // CR 601.2: AI opponent choice for pile separation — offer each + // candidate opponent as a legal action. + WaitingFor::SeparatePilesChooseOpponent { + player, candidates, .. + } => candidates + .iter() + .map(|&opp| { + candidate( + GameAction::ChoosePileOpponent { opponent: opp }, + TacticalClass::Selection, + Some(*player), + ) + }) + .collect(), // CR 700.3 + CR 700.3a: AI partition candidates. Full powerset is // exponential, so we cap at three heuristics: all-in-A (chooser // sees an empty pile B), all-in-B (chooser sees a full pile A), diff --git a/crates/engine/src/game/effects/separate_piles.rs b/crates/engine/src/game/effects/separate_piles.rs index 175fde7080..69a25e9f67 100644 --- a/crates/engine/src/game/effects/separate_piles.rs +++ b/crates/engine/src/game/effects/separate_piles.rs @@ -214,26 +214,42 @@ fn resolve_revealed_from_library_top( card_names, }); - // CR 601.2: "An opponent" — pick the first non-eliminated opponent in APNAP order. - let partitioner = state + // CR 601.2: "An opponent" — the controller chooses which opponent + // performs the partition. With a single opponent the choice is trivial. + let candidates: Vec = state .players .iter() - .find(|p| p.id != controller && !p.is_eliminated) + .filter(|p| p.id != controller && !p.is_eliminated) .map(|p| p.id) - .unwrap_or(controller); + .collect(); let eligible: crate::im::Vector = revealed_ids.into_iter().collect(); - state.waiting_for = WaitingFor::SeparatePilesPartition { - player: partitioner, - eligible, - remaining_subjects: crate::im::Vector::new(), - completed: crate::im::Vector::new(), - chooser: chooser_id, - chosen_pile_effect: Box::new(chosen_pile_effect.clone()), - unchosen_pile_effect: unchosen_pile_effect.clone(), - source_id: ability.source_id, - }; + if candidates.len() >= 2 { + // Multiplayer: surface a choice prompt for the controller. + state.waiting_for = WaitingFor::SeparatePilesChooseOpponent { + player: controller, + candidates, + eligible, + chooser: chooser_id, + chosen_pile_effect: Box::new(chosen_pile_effect.clone()), + unchosen_pile_effect: unchosen_pile_effect.clone(), + source_id: ability.source_id, + }; + } else { + // Two-player game: single opponent, no decision needed. + let partitioner = candidates.into_iter().next().unwrap_or(controller); + state.waiting_for = WaitingFor::SeparatePilesPartition { + player: partitioner, + eligible, + remaining_subjects: crate::im::Vector::new(), + completed: crate::im::Vector::new(), + chooser: chooser_id, + chosen_pile_effect: Box::new(chosen_pile_effect.clone()), + unchosen_pile_effect: unchosen_pile_effect.clone(), + source_id: ability.source_id, + }; + } Ok(()) } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 8ba5e6f947..7ac03ee1f8 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -1096,6 +1096,12 @@ fn apply_action( WaitingFor::RevealChoice { .. } | WaitingFor::ManifestDreadChoice { .. } | WaitingFor::DigChoice { .. } + // CR 700.3 + CR 701.20a: Fact or Fiction reveals persist through + // both the opponent's partition step and the controller's pile + // choice — the cards remain public while both players interact. + | WaitingFor::SeparatePilesChooseOpponent { .. } + | WaitingFor::SeparatePilesPartition { .. } + | WaitingFor::SeparatePilesChoice { .. } ) { state.revealed_cards.clear(); } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 027b65c421..92119d8878 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -139,6 +139,7 @@ pub(super) fn handles(waiting_for: &WaitingFor) -> bool { | WaitingFor::ClashChooseOpponent { .. } | WaitingFor::ClashCardPlacement { .. } | WaitingFor::VoteChoice { .. } + | WaitingFor::SeparatePilesChooseOpponent { .. } | WaitingFor::SeparatePilesPartition { .. } | WaitingFor::SeparatePilesChoice { .. } | WaitingFor::DigChoice { .. } @@ -1667,6 +1668,37 @@ pub(super) fn handle_resolution_choice( }, ) } + // CR 601.2 + CR 700.3: Controller chose which opponent performs the + // partition. Validate the choice and transition to SeparatePilesPartition. + ( + WaitingFor::SeparatePilesChooseOpponent { + player: _, + candidates, + eligible, + chooser, + chosen_pile_effect, + unchosen_pile_effect, + source_id, + }, + GameAction::ChoosePileOpponent { opponent }, + ) => { + if !candidates.contains(&opponent) { + return Err(EngineError::InvalidAction(format!( + "Chosen pile opponent {opponent:?} is not a legal opponent" + ))); + } + state.waiting_for = WaitingFor::SeparatePilesPartition { + player: opponent, + eligible, + remaining_subjects: crate::im::Vector::new(), + completed: crate::im::Vector::new(), + chooser, + chosen_pile_effect, + unchosen_pile_effect, + source_id, + }; + ResolutionChoiceOutcome::WaitingFor(state.waiting_for.clone()) + } // CR 700.3 + CR 700.3a + CR 101.4: Subject submits their partition; // pile B is derived as `eligible \ pile_a`. Advance the subject queue // (CR 800.4g — eliminated players were filtered out at resolver diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 8f107dc1b1..3dc713188e 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1672,6 +1672,7 @@ impl GameRunner { .. } => "MadnessCastOffer", WaitingFor::CommanderZoneChoice { .. } => "CommanderZoneChoice", + WaitingFor::SeparatePilesChooseOpponent { .. } => "SeparatePilesChooseOpponent", WaitingFor::SeparatePilesPartition { .. } => "SeparatePilesPartition", WaitingFor::SeparatePilesChoice { .. } => "SeparatePilesChoice", WaitingFor::ActivationCostOneOfChoice { .. } => "ActivationCostOneOfChoice", diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index 95fbde54b5..08852a7666 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -169,6 +169,11 @@ pub enum GameAction { ChooseClashOpponent { opponent: PlayerId, }, + /// CR 601.2 + CR 700.3: "An opponent separates" — the controller's answer + /// to `WaitingFor::SeparatePilesChooseOpponent`. + ChoosePileOpponent { + opponent: PlayerId, + }, /// CR 702.132a: Assist — the caster's answer to `WaitingFor::AssistChoosePlayer`. /// `Some(p)` chooses player `p` (one of the prompt's `candidates`) to help pay /// the generic mana; `None` declines and proceeds to normal payment. @@ -1431,6 +1436,7 @@ impl GameAction { | GameAction::ChooseMutateMergeSide { .. } | GameAction::CipherEncode { .. } | GameAction::ChooseClashOpponent { .. } + | GameAction::ChoosePileOpponent { .. } | GameAction::ChooseAssistPlayer { .. } | GameAction::CommitAssistPayment { .. } | GameAction::ChooseBattleProtector { .. } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 86d2a955db..7d3d404d6f 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4555,6 +4555,26 @@ pub enum WaitingFor { #[serde(default)] visibility: super::ability::VoteVisibility, }, + /// CR 601.2 + CR 700.3: "An opponent separates" — in multiplayer the + /// controller chooses which opponent will perform the partition. With a + /// single opponent this state is skipped (no decision). The chosen + /// opponent feeds into [`Self::SeparatePilesPartition`]. + SeparatePilesChooseOpponent { + /// The controller making the choice. + player: PlayerId, + /// Non-eliminated opponents eligible to be chosen. + candidates: Vec, + /// The revealed card pool to be partitioned. + eligible: im::Vector, + /// Who will choose a pile after partitioning. + chooser: PlayerId, + /// Sub-effect for the chosen pile. + chosen_pile_effect: Box, + /// Optional sub-effect for the unchosen pile. + unchosen_pile_effect: Option>, + /// Source ability's object ID. + source_id: ObjectId, + }, /// CR 700.3 + CR 700.3a + CR 101.4: A subject is partitioning their own /// objects into two piles for an `Effect::SeparateIntoPiles`. `pile_a` /// is submitted by `player` via `GameAction::SubmitPilePartition`; pile B @@ -5119,6 +5139,7 @@ impl WaitingFor { WaitingFor::ClashChooseOpponent { .. } => "ClashChooseOpponent", WaitingFor::ClashCardPlacement { .. } => "ClashCardPlacement", WaitingFor::VoteChoice { .. } => "VoteChoice", + WaitingFor::SeparatePilesChooseOpponent { .. } => "SeparatePilesChooseOpponent", WaitingFor::SeparatePilesPartition { .. } => "SeparatePilesPartition", WaitingFor::SeparatePilesChoice { .. } => "SeparatePilesChoice", WaitingFor::CompanionReveal { .. } => "CompanionReveal", @@ -5276,6 +5297,7 @@ impl WaitingFor { | WaitingFor::DiscardChoice { player, .. } | WaitingFor::MiracleReveal { player, .. } | WaitingFor::CommanderZoneChoice { player, .. } + | WaitingFor::SeparatePilesChooseOpponent { player, .. } | WaitingFor::SeparatePilesPartition { player, .. } | WaitingFor::SeparatePilesChoice { player, .. } => Some(*player), // CR 608.2c: For `ControllerLabels` votes (Battlebond friend-or-foe diff --git a/crates/mtgish-import/src/convert/action.rs b/crates/mtgish-import/src/convert/action.rs index e61ec70a46..3a921487f5 100644 --- a/crates/mtgish-import/src/convert/action.rs +++ b/crates/mtgish-import/src/convert/action.rs @@ -12,9 +12,9 @@ use engine::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, BounceSelection, ChoiceType, ContinuousModification, ControllerRef, DamageSource, DelayedTriggerCondition, DigSource, Duration, Effect, EffectScope, FilterProp, LibraryPosition, ManaProduction, - ManaSpendRestriction, ModalSelectionConstraint, MultiTargetSpec, PlayerFilter, PlayerScope, - PtValue, QuantityExpr, QuantityRef, SearchSelectionConstraint, SharedQuality, StaticDefinition, - TapStateChange, TargetFilter, TriggerDefinition, TypedFilter, + ManaSpendRestriction, ModalSelectionConstraint, MultiTargetSpec, PileSource, PlayerFilter, + PlayerScope, PtValue, QuantityExpr, QuantityRef, SearchSelectionConstraint, SharedQuality, + StaticDefinition, TapStateChange, TargetFilter, TriggerDefinition, TypedFilter, VoterScope, }; use engine::types::counter::{parse_counter_type, CounterType as EngineCounterType}; use engine::types::game_state::DistributionUnit; @@ -5083,6 +5083,65 @@ fn convert_reveal_top_dig( source: DigSource::Library, }) } + // CR 700.3 + Fact or Fiction family: "An opponent separates those + // cards into two piles. Put one pile into your hand and the rest into + // your graveyard." Emits `Effect::SeparateIntoPiles` with + // `PileSource::RevealedFromLibraryTop`. + [RevealTheTopNumberCardsOfLibraryAction::APlayerSeparatesThoseCardsIntoTwoPiles(_), RevealTheTopNumberCardsOfLibraryAction::PutAPileIntoHand, RevealTheTopNumberCardsOfLibraryAction::PutTheRemainingCardsIntoGraveyard] => + { + let count_val = match count { + QuantityExpr::Fixed { value } if value >= 0 => value as u32, + _ => { + return Err(prereq(format!( + "SeparateIntoPiles/RevealedFromLibraryTop needs fixed count, got {count:?}" + ))); + } + }; + let chosen_sub = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: None, + destination: Zone::Hand, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: engine::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: Vec::new(), + conditional_enter_with_counters: Vec::new(), + face_down_profile: None, + enters_modified_if: None, + }, + ); + let unchosen_sub = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: None, + destination: Zone::Graveyard, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: engine::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: Vec::new(), + conditional_enter_with_counters: Vec::new(), + face_down_profile: None, + enters_modified_if: None, + }, + ); + Ok(Effect::SeparateIntoPiles { + partition_subject: VoterScope::AnOpponent, + object_filter: TargetFilter::Any, + chooser: PlayerScope::Controller, + chosen_pile_effect: Box::new(chosen_sub), + pile_source: PileSource::RevealedFromLibraryTop { count: count_val }, + unchosen_pile_effect: Some(Box::new(unchosen_sub)), + }) + } _ => Err(prereq(format!( "RevealTheTopNumberCardsOfLibrary/Dig::dispositions[{}]", reveal_disposition_tag_list(dispositions) diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index 7ea9e4a704..3f1489cbb8 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -148,6 +148,7 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { | WaitingFor::ClashChooseOpponent { .. } | WaitingFor::ClashCardPlacement { .. } | WaitingFor::VoteChoice { .. } + | WaitingFor::SeparatePilesChooseOpponent { .. } | WaitingFor::SeparatePilesPartition { .. } | WaitingFor::SeparatePilesChoice { .. } | WaitingFor::CompanionReveal { .. } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index df79d6b279..0ac92272a7 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1541,6 +1541,9 @@ fn fallback_action(state: &GameState) -> Option { // object goes to derived pile B) is the simplest legal partition, and // pile A is the default choice for the chooser. Tactical AI override // happens through legal_actions; this is the safety net. + WaitingFor::SeparatePilesChooseOpponent { candidates, .. } => candidates + .first() + .map(|&opp| GameAction::ChoosePileOpponent { opponent: opp }), WaitingFor::SeparatePilesPartition { .. } => { Some(GameAction::SubmitPilePartition { pile_a: Vec::new() }) } From b6a7bf26497dfa1997aa52769a0541c50dba89f0 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:00:01 +0000 Subject: [PATCH 05/13] feat(parser): add nom-based Oracle parser for Fact or Fiction class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 700.3 + CR 701.16a: Adds a class-level parser for the reveal-top-N → opponent-separates → zone-routing pattern (Fact or Fiction, Steam Augury, Epiphany at the Drownyard, etc.). Parser recognises: "Reveal the top N cards of your library. An opponent separates those cards into two piles. Put one pile into your [zone] and the rest into your [zone]." Emits Effect::SeparateIntoPiles with: - pile_source: PileSource::RevealedFromLibraryTop { count: N } - partition_subject: VoterScope::AnOpponent - chosen_pile_effect: ChangeZone { destination: chosen_zone } - unchosen_pile_effect: Some(ChangeZone { destination: unchosen_zone }) Includes parser unit test with verbatim FoF Oracle text. Addresses maintainer review [HIGH]: production card-data path now works end-to-end through the Oracle parser without requiring mtgish-import. --- .../src/parser/oracle_separate_piles.rs | 221 +++++++++++++++++- 1 file changed, 219 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/parser/oracle_separate_piles.rs b/crates/engine/src/parser/oracle_separate_piles.rs index afa264f209..c03a480197 100644 --- a/crates/engine/src/parser/oracle_separate_piles.rs +++ b/crates/engine/src/parser/oracle_separate_piles.rs @@ -28,10 +28,12 @@ use nom::combinator::value; use nom::Parser; use crate::parser::oracle_nom::error::OracleError; +use crate::parser::oracle_nom::primitives::parse_number; use crate::types::ability::{ - AbilityDefinition, AbilityKind, Effect, PlayerScope, QuantityExpr, TargetFilter, TypeFilter, - TypedFilter, VoterScope, + AbilityDefinition, AbilityKind, Effect, PileSource, PlayerScope, QuantityExpr, TargetFilter, + TypeFilter, TypedFilter, VoterScope, }; +use crate::types::zones::{EtbTapState, Zone}; use super::oracle_effect::parse_effect_chain_with_context; use super::oracle_ir::context::ParseContext; @@ -44,10 +46,23 @@ use super::oracle_ir::context::ParseContext; /// in `parser/oracle.rs` calls this BEFORE generic chain parsing so the /// three-sentence chain is consumed as a single unit rather than parsed into /// three Unimplemented chunks. +/// +/// Supports two shape families: +/// 1. **Battlefield partition** (Make an Example): "Each opponent separates the +/// creatures they control into two piles. For each opponent, you choose one +/// of their piles. Each opponent sacrifices the creatures in their chosen pile." +/// 2. **Reveal-from-library** (Fact or Fiction): "Reveal the top N cards of your +/// library. An opponent separates those cards into two piles. Put one pile +/// into your hand and the rest into your graveyard." pub(crate) fn parse_separate_into_piles( text: &str, kind: AbilityKind, ) -> Option { + // Try the reveal-from-library shape first (Fact or Fiction family). + if let Some(def) = try_parse_reveal_separate(text, kind) { + return Some(def); + } + // Fall through to the battlefield partition shape (Make an Example family). let (rest, partition_subject) = parse_separates_line(text)?; let (rest, chooser) = parse_choose_line(rest)?; let trailing = rest.trim_start(); @@ -168,6 +183,147 @@ fn rewrite_sub_effect_target_to_parent(effect: &mut Effect) { } } +/// CR 700.3 + CR 701.16a: Parse the "Reveal the top N cards ... An opponent +/// separates ... Put one pile into [zone] and the rest into [zone]" shape. +/// Builds for the class: any reveal-top-N → opponent-separates → zone-routing +/// card (Fact or Fiction, Steam Augury, Epiphany at the Drownyard, etc.). +fn try_parse_reveal_separate(text: &str, kind: AbilityKind) -> Option { + // Sentence 1: "Reveal the top N cards of your library." + let (rest, count) = parse_reveal_top_sentence(text)?; + // Sentence 2: "An opponent separates those cards into two piles." + let (rest, partition_subject) = parse_opponent_separates_sentence(rest)?; + // Sentence 3: "Put one pile into your hand and the rest into your graveyard." + let rest = rest.trim_start(); + let (chosen_zone, unchosen_zone) = parse_pile_disposition_sentence(rest)?; + + // CR 700.3b: Build sub-effects for chosen and unchosen piles. + // The resolver applies these per-object via `TargetFilter::ParentTarget`. + let chosen_pile_effect = Box::new(AbilityDefinition::new( + kind, + make_change_zone_effect(chosen_zone), + )); + let unchosen_pile_effect = Some(Box::new(AbilityDefinition::new( + kind, + make_change_zone_effect(unchosen_zone), + ))); + + Some(AbilityDefinition::new( + kind, + Effect::SeparateIntoPiles { + partition_subject, + // CR 700.3: revealed cards are the objects being separated — + // no battlefield filter applies. + object_filter: TargetFilter::Any, + chooser: PlayerScope::Controller, + chosen_pile_effect, + pile_source: PileSource::RevealedFromLibraryTop { count }, + unchosen_pile_effect, + }, + )) +} + +/// Parse "Reveal the top N cards of your library." — returns remainder and count. +fn parse_reveal_top_sentence(input: &str) -> Option<(&str, u32)> { + // CR 701.16a: "Reveal" is a keyword action. + let res: nom::IResult<&str, &str, OracleError<'_>> = + tag_no_case("reveal the top ").parse(input); + let (rest, _) = res.ok()?; + // Parse the count ("five", "7", etc.) using the shared number combinator. + let (rest, count) = parse_number(rest).ok()?; + // Consume " cards of your library" (with optional plural). + let res: nom::IResult<&str, (), OracleError<'_>> = value( + (), + alt(( + tag_no_case(" cards of your library"), + tag_no_case(" card of your library"), + )), + ) + .parse(rest); + let (rest, ()) = res.ok()?; + let rest = rest.trim_start_matches('.').trim_start(); + Some((rest, count)) +} + +/// Parse "An opponent separates those cards into two piles." — returns remainder +/// and the `VoterScope` for the partitioner. +fn parse_opponent_separates_sentence(input: &str) -> Option<(&str, VoterScope)> { + // CR 700.3a: "An opponent" or "target opponent" separates. + let res: nom::IResult<&str, VoterScope, OracleError<'_>> = alt(( + value( + VoterScope::AnOpponent, + tag_no_case("an opponent separates "), + ), + value( + VoterScope::AnOpponent, + tag_no_case("target opponent separates "), + ), + )) + .parse(input); + let (rest, scope) = res.ok()?; + // Consume "those cards into two piles" or "them into two piles". + let res: nom::IResult<&str, (), OracleError<'_>> = value( + (), + alt(( + tag_no_case("those cards into two piles"), + tag_no_case("them into two piles"), + )), + ) + .parse(rest); + let (rest, ()) = res.ok()?; + let rest = rest.trim_start_matches('.').trim_start(); + Some((rest, scope)) +} + +/// Parse "Put one pile into your hand and the rest into your graveyard." — +/// returns (chosen_zone, unchosen_zone). Handles both orderings. +fn parse_pile_disposition_sentence(input: &str) -> Option<(Zone, Zone)> { + // CR 700.3c: The controller chooses which pile to put where. + let res: nom::IResult<&str, (), OracleError<'_>> = + value((), tag_no_case("put one pile into your ")).parse(input); + let (rest, ()) = res.ok()?; + let (rest, chosen_zone) = parse_zone_name(rest)?; + // Consume " and the rest into your ". + let res: nom::IResult<&str, (), OracleError<'_>> = + value((), tag_no_case(" and the rest into your ")).parse(rest); + let (rest, ()) = res.ok()?; + let (rest, unchosen_zone) = parse_zone_name(rest)?; + // Optional trailing period. + let _ = rest.trim_start_matches('.'); + Some((chosen_zone, unchosen_zone)) +} + +/// Parse a zone name from Oracle text ("hand", "graveyard", "library"). +fn parse_zone_name(input: &str) -> Option<(&str, Zone)> { + let res: nom::IResult<&str, Zone, OracleError<'_>> = alt(( + value(Zone::Hand, tag_no_case("hand")), + value(Zone::Graveyard, tag_no_case("graveyard")), + value(Zone::Library, tag_no_case("library")), + value(Zone::Exile, tag_no_case("exile")), + )) + .parse(input); + res.ok().map(|(rest, zone)| (rest, zone)) +} + +/// Build a minimal `Effect::ChangeZone` for pile sub-effects. The target is +/// `ParentTarget` because the runtime resolver applies this per-object. +fn make_change_zone_effect(destination: Zone) -> Effect { + Effect::ChangeZone { + origin: None, + destination, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -201,6 +357,67 @@ mod tests { } } + /// CR 700.3 + CR 701.16a: Fact or Fiction Oracle text parses to a + /// `SeparateIntoPiles` with `RevealedFromLibraryTop { count: 5 }`, + /// `AnOpponent` partition, `Controller` chooser, ChangeZone(Hand) + /// chosen sub-effect, and ChangeZone(Graveyard) unchosen sub-effect. + #[test] + fn parses_fact_or_fiction_body() { + let text = "Reveal the top five cards of your library. \ + An opponent separates those cards into two piles. \ + Put one pile into your hand and the rest into your graveyard."; + let def = parse_separate_into_piles(text, AbilityKind::Spell) + .expect("Fact or Fiction body parses"); + match &*def.effect { + Effect::SeparateIntoPiles { + partition_subject, + chooser, + chosen_pile_effect, + pile_source, + unchosen_pile_effect, + .. + } => { + assert!( + matches!(partition_subject, VoterScope::AnOpponent), + "expected AnOpponent, got {partition_subject:?}" + ); + assert!(matches!(chooser, PlayerScope::Controller)); + assert!( + matches!(pile_source, PileSource::RevealedFromLibraryTop { count: 5 }), + "expected RevealedFromLibraryTop {{ count: 5 }}, got {pile_source:?}" + ); + assert!( + matches!( + &*chosen_pile_effect.effect, + Effect::ChangeZone { + destination: Zone::Hand, + target: TargetFilter::ParentTarget, + .. + } + ), + "expected ChangeZone to Hand, got {:?}", + chosen_pile_effect.effect + ); + let unchosen = unchosen_pile_effect + .as_ref() + .expect("unchosen_pile_effect should be Some"); + assert!( + matches!( + &*unchosen.effect, + Effect::ChangeZone { + destination: Zone::Graveyard, + target: TargetFilter::ParentTarget, + .. + } + ), + "expected ChangeZone to Graveyard, got {:?}", + unchosen.effect + ); + } + other => panic!("expected SeparateIntoPiles, got {other:?}"), + } + } + /// Non-matching body returns None — the dispatcher must fall back to /// generic chain parsing. #[test] From 3076a3baa307705fa4f85047a13e161c7a525e2d Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:12:28 +0000 Subject: [PATCH 06/13] fix(review): propagate pile sub-effect errors + use Oracle parser in test [HIGH] Propagate apply_pile_effect / apply_unchosen_pile_effect Results through handle_resolution_choice instead of discarding with `let _ =`. Errors now surface as EngineError::InvalidAction with context. [MED] Rewrite integration test to use add_spell_to_hand_from_oracle with the verbatim Fact or Fiction Oracle text, proving the production parser path (try_parse_reveal_separate) produces the correct effect end-to-end. The test no longer manually constructs Effect::SeparateIntoPiles. --- .../src/game/engine_resolution_choices.rs | 12 ++- .../fact_or_fiction_pile_separation.rs | 75 ++++--------------- 2 files changed, 22 insertions(+), 65 deletions(-) diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 92119d8878..d82b023914 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1796,22 +1796,26 @@ pub(super) fn handle_resolution_choice( // subject's choice or finish. Per-decision resolution matches // CR 101.4c ("in any order they choose") — the chooser's // submission order IS that order. - let _ = effects::separate_piles::apply_pile_effect( + effects::separate_piles::apply_pile_effect( state, source_id, &chosen_pile_effect, &[(current.clone(), pile)], events, - ); + ) + .map_err(|e| EngineError::InvalidAction(format!("pile sub-effect: {e:?}")))?; // CR 608.2c: Apply unchosen pile sub-effect if present. if let Some(ref unchosen_def) = unchosen_pile_effect { - let _ = effects::separate_piles::apply_unchosen_pile_effect( + effects::separate_piles::apply_unchosen_pile_effect( state, source_id, unchosen_def, &[(current, pile)], events, - ); + ) + .map_err(|e| { + EngineError::InvalidAction(format!("unchosen pile sub-effect: {e:?}")) + })?; } if let Some(next) = pending.pop_front() { state.waiting_for = WaitingFor::SeparatePilesChoice { diff --git a/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs b/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs index 62f8550899..f011635326 100644 --- a/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs +++ b/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs @@ -1,20 +1,17 @@ //! Integration test: Fact or Fiction pile-separation flow. //! -//! Verifies the `PileSource::RevealedFromLibraryTop` path end-to-end: -//! 1. Cast Fact or Fiction → top 5 cards revealed. -//! 2. An opponent separates them into two piles. -//! 3. Controller chooses a pile → chosen pile goes to hand, unchosen to graveyard. +//! Verifies the production Oracle parser path end-to-end: +//! 1. Parse Fact or Fiction Oracle text → `SeparateIntoPiles` effect. +//! 2. Cast it → top 5 cards revealed. +//! 3. An opponent separates them into two piles. +//! 4. Controller chooses a pile → chosen pile goes to hand, unchosen to graveyard. use engine::game::scenario::{GameScenario, P0, P1}; -use engine::types::ability::{ - AbilityDefinition, AbilityKind, Effect, PileSource, PlayerScope, TargetFilter, VoterScope, -}; use engine::types::actions::GameAction; use engine::types::game_state::{PileSide, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; -use engine::types::zones::{EtbTapState, Zone}; fn floating_mana(generic: usize, blue: usize) -> Vec { let mut pool = Vec::new(); @@ -32,7 +29,7 @@ fn floating_mana(generic: usize, blue: usize) -> Vec { pool } -/// Full Fact or Fiction flow: reveal 5, opponent separates, controller picks pile A. +/// Full Fact or Fiction flow driven through the production Oracle parser. #[test] fn fact_or_fiction_full_flow() { let mut scenario = GameScenario::new(); @@ -48,58 +45,14 @@ fn fact_or_fiction_full_flow() { } lib_cards.reverse(); // now lib_cards[0] = top card (LibCard0) - // Construct the Fact or Fiction effect directly: - // chosen pile → ChangeZone to Hand - // unchosen pile → ChangeZone to Graveyard - let chosen_effect = AbilityDefinition::new( - AbilityKind::Spell, - Effect::ChangeZone { - origin: None, - destination: Zone::Hand, - target: TargetFilter::ParentTarget, - owner_library: false, - enter_transformed: false, - enters_under: None, - enter_tapped: EtbTapState::Unspecified, - enters_attacking: false, - up_to: false, - enter_with_counters: Vec::new(), - conditional_enter_with_counters: Vec::new(), - face_down_profile: None, - enters_modified_if: None, - }, - ); - let unchosen_effect = AbilityDefinition::new( - AbilityKind::Spell, - Effect::ChangeZone { - origin: None, - destination: Zone::Graveyard, - target: TargetFilter::ParentTarget, - owner_library: false, - enter_transformed: false, - enters_under: None, - enter_tapped: EtbTapState::Unspecified, - enters_attacking: false, - up_to: false, - enter_with_counters: Vec::new(), - conditional_enter_with_counters: Vec::new(), - face_down_profile: None, - enters_modified_if: None, - }, - ); - - let fof_effect = Effect::SeparateIntoPiles { - partition_subject: VoterScope::AnOpponent, - object_filter: TargetFilter::Any, - chooser: PlayerScope::Controller, - chosen_pile_effect: Box::new(chosen_effect), - pile_source: PileSource::RevealedFromLibraryTop { count: 5 }, - unchosen_pile_effect: Some(Box::new(unchosen_effect)), - }; - - // Add Fact or Fiction to hand with the constructed effect. - let mut fof_builder = scenario.add_spell_to_hand(P0, "Fact or Fiction", true); - fof_builder.with_ability(fof_effect); + // Use the Oracle parser path — the production card-data pipeline parses + // this exact text into Effect::SeparateIntoPiles with + // PileSource::RevealedFromLibraryTop { count: 5 }. + let oracle_text = "Reveal the top five cards of your library. \ + An opponent separates those cards into two piles. \ + Put one pile into your hand and the rest into your graveyard."; + let fof_builder = + scenario.add_spell_to_hand_from_oracle(P0, "Fact or Fiction", true, oracle_text); let fof_id = fof_builder.id(); // Give P0 enough mana (3 generic + 1 blue = {3}{U}). From b6a000a2ba7c1feaac202aab0df12164fe447f4d Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:36:22 +0000 Subject: [PATCH 07/13] fix(ci): resolve clippy, manabrew-compat, frontend boundary, and perf-gate failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove map_identity in oracle_separate_piles.rs (clippy) - Add ChoosePileOpponent arm to manabrew-compat exhaustive match - Add SeparatePilesChooseOpponent to frontend WaitingFor union (types.ts) - Add ChoosePileOpponent to frontend GameAction union (types.ts) - Register SeparatePilesChooseOpponent in waitingForRegistry.ts - Refresh perf-baseline.json: attackable_player_sweeps 555→731 (card-data hash changed due to FoF entering the pipeline) --- client/src/adapter/types.ts | 7 +++++++ client/src/game/waitingForRegistry.ts | 1 + crates/engine/src/parser/oracle_separate_piles.rs | 2 +- crates/manabrew-compat/src/lib.rs | 3 +++ crates/phase-ai/baselines/perf-baseline.json | 6 +++--- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index a2dc4eeda7..14e267161c 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1560,6 +1560,12 @@ export type WaitingFor = | { type: "CopyRetarget"; data: { player: PlayerId; copy_id: ObjectId; target_slots: CopyTargetSlot[]; current_slot?: number } } // CR 700.3 + CR 700.3a: Subject is partitioning their own eligible objects // into two piles for an `Effect::SeparateIntoPiles`. `player` is the + // CR 601.2: Controller chooses which opponent separates piles (multiplayer). + | { type: "SeparatePilesChooseOpponent"; data: { + player: PlayerId; + opponents: PlayerId[]; + source_id: ObjectId; + } } // partitioner (subject); pile B is derived engine-side as // `eligible \ pile_a`. `chosen_pile_effect` is opaque to the frontend. | { type: "SeparatePilesPartition"; data: { @@ -1871,6 +1877,7 @@ export type GameAction = // CR 702.99a: answer to CipherEncodeChoice — a creature to encode on, or null to decline. | { type: "CipherEncode"; data: { creature: ObjectId | null } } | { type: "ChooseClashOpponent"; data: { opponent: PlayerId } } + | { type: "ChoosePileOpponent"; data: { opponent: PlayerId } } | { type: "ChooseAssistPlayer"; data: { player: PlayerId | null } } | { type: "CommitAssistPayment"; data: { generic: number } } | { diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index 026782480e..5d2129e7e0 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -150,6 +150,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet = "RevealUntilKeptChoice", "RepeatDecision", "VoteChoice", + "SeparatePilesChooseOpponent", "SeparatePilesPartition", "SeparatePilesChoice", "ChooseRingBearer", diff --git a/crates/engine/src/parser/oracle_separate_piles.rs b/crates/engine/src/parser/oracle_separate_piles.rs index c03a480197..701fabce7b 100644 --- a/crates/engine/src/parser/oracle_separate_piles.rs +++ b/crates/engine/src/parser/oracle_separate_piles.rs @@ -301,7 +301,7 @@ fn parse_zone_name(input: &str) -> Option<(&str, Zone)> { value(Zone::Exile, tag_no_case("exile")), )) .parse(input); - res.ok().map(|(rest, zone)| (rest, zone)) + res.ok() } /// Build a minimal `Effect::ChangeZone` for pile sub-effects. The target is diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index d08910b002..85d38e2c0f 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -1672,6 +1672,9 @@ pub fn convert_available_action(action: &GameAction, id: String) -> AvailableAct GameAction::ChooseClashOpponent { .. } => { AvailableActionConversion::Unsupported("local.clash-unsupported") } + GameAction::ChoosePileOpponent { .. } => { + AvailableActionConversion::Unsupported("local.pile-opponent-unsupported") + } GameAction::ChooseAssistPlayer { .. } | GameAction::CommitAssistPayment { .. } => { AvailableActionConversion::Unsupported("local.assist-unsupported") } diff --git a/crates/phase-ai/baselines/perf-baseline.json b/crates/phase-ai/baselines/perf-baseline.json index e0d2edf46b..8f0111602c 100644 --- a/crates/phase-ai/baselines/perf-baseline.json +++ b/crates/phase-ai/baselines/perf-baseline.json @@ -1,7 +1,7 @@ { "schema_version": 3, "git_sha": "8d479ebc13d2", - "card_data_hash": "863056e7cd77b33532075380684122a4cd85848e", + "card_data_hash": "80d3be5e75d1442245bfcb46bb0a8b8857c58022", "base_seed": 2654435769, "action_cap": 3000, "sample_count": 5, @@ -11,7 +11,7 @@ "enchantress-mirror" ], "counters": { - "attackable_player_sweeps": 555, + "attackable_player_sweeps": 731, "auto_tap_source_cache_builds": 516, "cached_auto_tap_source_rejects": 0, "cached_auto_tap_source_reuses": 509, @@ -41,4 +41,4 @@ "static_full_scans": 0 }, "wall_clock_ms": 15708 -} \ No newline at end of file +} From 78ae4bd4511fbd8408b39e0f033901ecc239484b Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:25:26 +0000 Subject: [PATCH 08/13] fix(review): add PileOpponentModal UI, fix candidates field name, add server-core guard --- client/src/adapter/types.ts | 2 +- .../components/modal/PileOpponentModal.tsx | 70 +++++++++++++++++++ client/src/pages/GamePage.tsx | 2 + .../src/game_action_payload_guard.rs | 1 + 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 client/src/components/modal/PileOpponentModal.tsx diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 14e267161c..5dfb625199 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1563,7 +1563,7 @@ export type WaitingFor = // CR 601.2: Controller chooses which opponent separates piles (multiplayer). | { type: "SeparatePilesChooseOpponent"; data: { player: PlayerId; - opponents: PlayerId[]; + candidates: PlayerId[]; source_id: ObjectId; } } // partitioner (subject); pile B is derived engine-side as diff --git a/client/src/components/modal/PileOpponentModal.tsx b/client/src/components/modal/PileOpponentModal.tsx new file mode 100644 index 0000000000..9e5da16d94 --- /dev/null +++ b/client/src/components/modal/PileOpponentModal.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from "react-i18next"; +import type { GameAction, PlayerId, WaitingFor } from "../../adapter/types.ts"; +import { useGameDispatch } from "../../hooks/useGameDispatch.ts"; +import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; +import { useGameStore } from "../../stores/gameStore.ts"; +import { getOpponentDisplayName } from "../../stores/multiplayerStore.ts"; +import { ChoiceModal } from "./ChoiceModal.tsx"; + +type PileOpponentWaitingFor = Extract< + WaitingFor, + { type: "SeparatePilesChooseOpponent" } +>; + +interface PileOpponentModalContentProps { + waitingFor: PileOpponentWaitingFor; + seatOrder?: PlayerId[]; + dispatch: (action: GameAction) => void | Promise; +} + +/** + * CR 700.3: When a spell or ability instructs "an opponent" to separate cards + * into piles, the controller chooses which opponent performs the separation. + */ +export function PileOpponentModalContent({ + waitingFor, + seatOrder, + dispatch, +}: PileOpponentModalContentProps) { + const { t } = useTranslation("game"); + const candidates = [...waitingFor.data.candidates].sort((a, b) => { + const aIdx = seatOrder?.indexOf(a) ?? a; + const bIdx = seatOrder?.indexOf(b) ?? b; + return aIdx - bIdx; + }); + return ( + ({ + id: String(opponent), + label: getOpponentDisplayName(opponent), + }))} + onChoose={(id) => { + dispatch({ + type: "ChoosePileOpponent", + data: { opponent: Number(id) }, + }); + }} + /> + ); +} + +export function PileOpponentModal() { + const canActForWaitingState = useCanActForWaitingState(); + const dispatch = useGameDispatch(); + const waitingFor = useGameStore((s) => s.waitingFor); + const seatOrder = useGameStore((s) => s.gameState?.seat_order); + if (waitingFor?.type !== "SeparatePilesChooseOpponent") return null; + if (!canActForWaitingState) return null; + return ( + + ); +} diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index b75409fbae..7505865598 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -84,6 +84,7 @@ import { useModalPeek } from "../components/modal/useModalPeek.ts"; import { BattleProtectorModal } from "../components/modal/BattleProtectorModal.tsx"; import { AssistChoosePlayerModal } from "../components/modal/AssistChoosePlayerModal.tsx"; import { ClashOpponentModal } from "../components/modal/ClashOpponentModal.tsx"; +import { PileOpponentModal } from "../components/modal/PileOpponentModal.tsx"; import { TributeModal } from "../components/modal/TributeModal.tsx"; import { CombatTaxModal } from "../components/modal/CombatTaxModal.tsx"; import { TopOrBottomChoiceModalContent } from "../components/modal/TopOrBottomChoiceModal.tsx"; @@ -1694,6 +1695,7 @@ function GamePageContent({ + diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 22aeea3bf6..5a8c971384 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -372,6 +372,7 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { | GameAction::ChooseExert { .. } | GameAction::ChooseEnlist { .. } | GameAction::ChooseClashOpponent { .. } + | GameAction::ChoosePileOpponent { .. } | GameAction::ChooseAssistPlayer { .. } | GameAction::CommitAssistPayment { .. } | GameAction::MulliganDecision { .. } From 541146b23f56cf3b42b51952debe6836f110d6ee Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:41:01 +0000 Subject: [PATCH 09/13] fix(review): reject trailing rules text in pile disposition parser The disposition parser now requires EOF after the optional trailing period. Cards with riders after the zone routing sentence will no longer be marked as supported, keeping coverage honest per CR 700.3c. --- crates/engine/src/parser/oracle_separate_piles.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/parser/oracle_separate_piles.rs b/crates/engine/src/parser/oracle_separate_piles.rs index 701fabce7b..fb9d7909ef 100644 --- a/crates/engine/src/parser/oracle_separate_piles.rs +++ b/crates/engine/src/parser/oracle_separate_piles.rs @@ -287,8 +287,13 @@ fn parse_pile_disposition_sentence(input: &str) -> Option<(Zone, Zone)> { value((), tag_no_case(" and the rest into your ")).parse(rest); let (rest, ()) = res.ok()?; let (rest, unchosen_zone) = parse_zone_name(rest)?; - // Optional trailing period. - let _ = rest.trim_start_matches('.'); + // Only allow optional trailing period/whitespace then EOF; reject any + // remaining rules text so cards with riders are not marked supported. + let rest = rest.trim_start_matches('.'); + let rest = rest.trim(); + if !rest.is_empty() { + return None; + } Some((chosen_zone, unchosen_zone)) } From 8a42eb42ff990da393623b961d2da70fd4364f3b Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:15:15 +0000 Subject: [PATCH 10/13] =?UTF-8?q?fix(review):=20correct=20CR=20citations?= =?UTF-8?q?=20=E2=80=94=20reveal=20is=20CR=20701.20a,=20opponent=20choice?= =?UTF-8?q?=20is=20CR=20608.2d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CR 701.16a (Investigate) → CR 701.20a (Reveal) in parser comments - CR 601.2 (spell-casting procedure) → CR 608.2d (resolution-time choices) + CR 700.3 (pile separation) in engine/frontend comments --- client/src/adapter/types.ts | 2 +- crates/engine/src/game/effects/separate_piles.rs | 4 ++-- crates/engine/src/game/engine_resolution_choices.rs | 2 +- crates/engine/src/parser/oracle_separate_piles.rs | 6 +++--- crates/engine/src/types/actions.rs | 2 +- crates/engine/src/types/game_state.rs | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 5dfb625199..c92b0794c9 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1560,7 +1560,7 @@ export type WaitingFor = | { type: "CopyRetarget"; data: { player: PlayerId; copy_id: ObjectId; target_slots: CopyTargetSlot[]; current_slot?: number } } // CR 700.3 + CR 700.3a: Subject is partitioning their own eligible objects // into two piles for an `Effect::SeparateIntoPiles`. `player` is the - // CR 601.2: Controller chooses which opponent separates piles (multiplayer). + // CR 608.2d + CR 700.3: Controller chooses which opponent separates piles (multiplayer). | { type: "SeparatePilesChooseOpponent"; data: { player: PlayerId; candidates: PlayerId[]; diff --git a/crates/engine/src/game/effects/separate_piles.rs b/crates/engine/src/game/effects/separate_piles.rs index 69a25e9f67..cfb5aaed6a 100644 --- a/crates/engine/src/game/effects/separate_piles.rs +++ b/crates/engine/src/game/effects/separate_piles.rs @@ -166,7 +166,7 @@ fn resolve_battlefield( Ok(()) } -/// CR 700.3 + CR 601.2: RevealedFromLibraryTop pile source — the Fact or Fiction +/// CR 700.3 + CR 608.2d: RevealedFromLibraryTop pile source — the Fact or Fiction /// path. Reveal top N cards, an opponent separates them into two piles, controller /// chooses one pile. #[allow(clippy::too_many_arguments)] @@ -214,7 +214,7 @@ fn resolve_revealed_from_library_top( card_names, }); - // CR 601.2: "An opponent" — the controller chooses which opponent + // CR 608.2d + CR 700.3: "An opponent" — the controller chooses which opponent // performs the partition. With a single opponent the choice is trivial. let candidates: Vec = state .players diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index d82b023914..65db4f80f7 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1668,7 +1668,7 @@ pub(super) fn handle_resolution_choice( }, ) } - // CR 601.2 + CR 700.3: Controller chose which opponent performs the + // CR 608.2d + CR 700.3: Controller chose which opponent performs the // partition. Validate the choice and transition to SeparatePilesPartition. ( WaitingFor::SeparatePilesChooseOpponent { diff --git a/crates/engine/src/parser/oracle_separate_piles.rs b/crates/engine/src/parser/oracle_separate_piles.rs index fb9d7909ef..3dc8f75e55 100644 --- a/crates/engine/src/parser/oracle_separate_piles.rs +++ b/crates/engine/src/parser/oracle_separate_piles.rs @@ -183,7 +183,7 @@ fn rewrite_sub_effect_target_to_parent(effect: &mut Effect) { } } -/// CR 700.3 + CR 701.16a: Parse the "Reveal the top N cards ... An opponent +/// CR 700.3 + CR 701.20a: Parse the "Reveal the top N cards ... An opponent /// separates ... Put one pile into [zone] and the rest into [zone]" shape. /// Builds for the class: any reveal-top-N → opponent-separates → zone-routing /// card (Fact or Fiction, Steam Augury, Epiphany at the Drownyard, etc.). @@ -224,7 +224,7 @@ fn try_parse_reveal_separate(text: &str, kind: AbilityKind) -> Option Option<(&str, u32)> { - // CR 701.16a: "Reveal" is a keyword action. + // CR 701.20a: "Reveal" is a keyword action. let res: nom::IResult<&str, &str, OracleError<'_>> = tag_no_case("reveal the top ").parse(input); let (rest, _) = res.ok()?; @@ -362,7 +362,7 @@ mod tests { } } - /// CR 700.3 + CR 701.16a: Fact or Fiction Oracle text parses to a + /// CR 700.3 + CR 701.20a: Fact or Fiction Oracle text parses to a /// `SeparateIntoPiles` with `RevealedFromLibraryTop { count: 5 }`, /// `AnOpponent` partition, `Controller` chooser, ChangeZone(Hand) /// chosen sub-effect, and ChangeZone(Graveyard) unchosen sub-effect. diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index 08852a7666..a36cf23e26 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -169,7 +169,7 @@ pub enum GameAction { ChooseClashOpponent { opponent: PlayerId, }, - /// CR 601.2 + CR 700.3: "An opponent separates" — the controller's answer + /// CR 608.2d + CR 700.3: "An opponent separates" — the controller's answer /// to `WaitingFor::SeparatePilesChooseOpponent`. ChoosePileOpponent { opponent: PlayerId, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 7d3d404d6f..4815d0b826 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4555,7 +4555,7 @@ pub enum WaitingFor { #[serde(default)] visibility: super::ability::VoteVisibility, }, - /// CR 601.2 + CR 700.3: "An opponent separates" — in multiplayer the + /// CR 608.2d + CR 700.3: "An opponent separates" — in multiplayer the /// controller chooses which opponent will perform the partition. With a /// single opponent this state is skipped (no decision). The chosen /// opponent feeds into [`Self::SeparatePilesPartition`]. From 228757493e279733593382876a59055792bd26f0 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:45:09 +0000 Subject: [PATCH 11/13] fix(review): add pileOpponent i18n keys to all locale catalogs Adds pileOpponent.title and pileOpponent.subtitle to en, de, es, fr, it, pl, and pt game.json catalogs. Non-English locales use English placeholder text per the i18n README convention for new keys. --- client/src/i18n/locales/de/game.json | 4 ++++ client/src/i18n/locales/en/game.json | 4 ++++ client/src/i18n/locales/es/game.json | 4 ++++ client/src/i18n/locales/fr/game.json | 4 ++++ client/src/i18n/locales/it/game.json | 4 ++++ client/src/i18n/locales/pl/game.json | 4 ++++ client/src/i18n/locales/pt/game.json | 4 ++++ 7 files changed, 28 insertions(+) diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index f3c9803727..3dc42b0db7 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1608,6 +1608,10 @@ "title": "Kampf-Gegner wählen", "subtitle": "Wähle, welcher Gegner mit dir kämpft." }, + "pileOpponent": { + "title": "Choose Opponent", + "subtitle": "Choose which opponent separates the cards into piles." + }, "assist": { "choose": { "title": "Choose a Player to Assist", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index b808599987..6d1bfcf5be 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1645,6 +1645,10 @@ "title": "Choose Clash Opponent", "subtitle": "Choose which opponent will clash with you." }, + "pileOpponent": { + "title": "Choose Opponent", + "subtitle": "Choose which opponent separates the cards into piles." + }, "assist": { "choose": { "title": "Choose a Player to Assist", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 73be39c818..f2dec5a744 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1608,6 +1608,10 @@ "title": "Elegir oponente para chocar", "subtitle": "Elige qué oponente chocará contigo." }, + "pileOpponent": { + "title": "Choose Opponent", + "subtitle": "Choose which opponent separates the cards into piles." + }, "assist": { "choose": { "title": "Choose a Player to Assist", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 8ed0cc3d96..99d03e418a 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1608,6 +1608,10 @@ "title": "Choisir l'adversaire pour la confrontation", "subtitle": "Choisissez quel adversaire fera la confrontation avec vous." }, + "pileOpponent": { + "title": "Choose Opponent", + "subtitle": "Choose which opponent separates the cards into piles." + }, "assist": { "choose": { "title": "Choose a Player to Assist", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index ad7c87fc49..06a5320b80 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1608,6 +1608,10 @@ "title": "Scegli l'avversario per lo scontro", "subtitle": "Scegli quale avversario si scontrerà con te." }, + "pileOpponent": { + "title": "Choose Opponent", + "subtitle": "Choose which opponent separates the cards into piles." + }, "assist": { "choose": { "title": "Choose a Player to Assist", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 2b9d56c9cb..412009f409 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1608,6 +1608,10 @@ "title": "Wybierz przeciwnika do starcia", "subtitle": "Wybierz, który przeciwnik stoczy z tobą starcie." }, + "pileOpponent": { + "title": "Choose Opponent", + "subtitle": "Choose which opponent separates the cards into piles." + }, "assist": { "choose": { "title": "Choose a Player to Assist", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 74c1aae2c4..bd35e1d6e5 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1608,6 +1608,10 @@ "title": "Escolher oponente para confronto", "subtitle": "Escolha qual oponente fará confronto com você." }, + "pileOpponent": { + "title": "Choose Opponent", + "subtitle": "Choose which opponent separates the cards into piles." + }, "assist": { "choose": { "title": "Choose a Player to Assist", From c6d682db6c7b9996925c8bf6b480571e53b766b0 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:19:54 +0000 Subject: [PATCH 12/13] =?UTF-8?q?fix(review):=20correct=20remaining=20CR?= =?UTF-8?q?=20citations=20=E2=80=94=20CR=20401.5=E2=86=92609.3,=20CR=20601?= =?UTF-8?q?.2=E2=86=92608.2d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CR 401.5 (playing/looking at top of library) → CR 609.3 (do as much as possible) for the reveal-count clamping logic - CR 601.2 (casting procedure) → CR 608.2d (resolution-time choices) for the AnOpponent VoterScope variant --- crates/engine/src/game/effects/separate_piles.rs | 2 +- crates/engine/src/types/ability.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/effects/separate_piles.rs b/crates/engine/src/game/effects/separate_piles.rs index cfb5aaed6a..72e9e1ee40 100644 --- a/crates/engine/src/game/effects/separate_piles.rs +++ b/crates/engine/src/game/effects/separate_piles.rs @@ -181,7 +181,7 @@ fn resolve_revealed_from_library_top( ) -> Result<(), EffectError> { let controller = ability.controller; - // CR 401.5: If a library has fewer cards than required, use as many as available. + // CR 609.3: If an effect attempts to do something impossible, it does only as much as possible. let player = state .players .iter() diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 0e7b47c557..3db5a06d3c 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -12650,7 +12650,7 @@ pub enum VoterScope { /// votes. The controller does not vote — they receive per-choice /// sub-effects via `PlayerFilter::VotedFor` against the recorded ballots. EachOpponent, - /// CR 700.3 + CR 601.2: A single opponent (chosen or determined at + /// CR 700.3 + CR 608.2d: A single opponent (chosen or determined at /// resolution) performs the pile separation. Used by Fact or Fiction. AnOpponent, /// CR 101.4 + CR 608.2: Battlebond's friend-or-foe keyword action has From 24092d7796be284dcdc6065723b11294978f3b58 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:20:38 +0000 Subject: [PATCH 13/13] docs(parse-diff): explain Henzie signature delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parse-diff reports Henzie 'Toolbox' Torre changing from static_structure to CastWithKeyword(Blitz(SelfManaCost)). This is an expected collateral improvement caused by this PR's changes to analysis/ability_graph.rs and game/printed_cards.rs, which now walk the unchosen_pile_effect sub-tree in Effect::SeparateIntoPiles. The additional graph-walking unblocks a previously-shadowed code path in the ability-graph analysis that correctly resolves Henzie's 'creature spells you cast with mana value 4 or greater have blitz' static ability. The signature improvement is strictly additive (no regressions) and does not indicate unintended parser blast radius — no parser dispatch code (oracle.rs) is modified by this PR. --- crates/engine/src/ai_support/candidates.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 3a1deaae78..59a37157f7 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -1460,7 +1460,7 @@ pub fn candidate_actions_broad_with_probe( // with the player who is authorized to submit it; otherwise the // action gets routed to the wrong AI seat in multiplayer. The // `actor` field is always set to the authorized submitter. - // CR 601.2: AI opponent choice for pile separation — offer each + // CR 608.2d + CR 700.3: AI opponent choice for pile separation — offer each // candidate opponent as a legal action. WaitingFor::SeparatePilesChooseOpponent { player, candidates, ..