diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index a2dc4eeda7..c92b0794c9 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 608.2d + CR 700.3: Controller chooses which opponent separates piles (multiplayer). + | { type: "SeparatePilesChooseOpponent"; data: { + player: PlayerId; + candidates: 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/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/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/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", 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/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 8ec1c8a610..59a37157f7 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 608.2d + CR 700.3: 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/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..72e9e1ee40 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: &crate::types::ability::AbilityDefinition, + 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, @@ -139,22 +158,104 @@ pub fn resolve( 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, }; Ok(()) } +/// 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)] +fn resolve_revealed_from_library_top( + state: &mut GameState, + ability: &ResolvedAbility, + events: &mut Vec, + count: u32, + chooser_id: PlayerId, + chosen_pile_effect: &crate::types::ability::AbilityDefinition, + unchosen_pile_effect: &Option>, +) -> Result<(), EffectError> { + let controller = ability.controller; + + // CR 609.3: If an effect attempts to do something impossible, it does only as much as possible. + 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 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 + .iter() + .filter(|p| p.id != controller && !p.is_eliminated) + .map(|p| p.id) + .collect(); + + let eligible: crate::im::Vector = revealed_ids.into_iter().collect(); + + 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(()) +} + /// 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 +271,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 +284,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 +339,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 +347,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 +376,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 +404,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 +436,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 +455,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 +463,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 +477,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 +495,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 +506,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 +541,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 +549,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.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 e89b38da5f..65db4f80f7 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 608.2d + 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 @@ -1682,6 +1714,7 @@ pub(super) fn handle_resolution_choice( mut completed, chooser, chosen_pile_effect, + unchosen_pile_effect, source_id, }, GameAction::SubmitPilePartition { pile_a }, @@ -1723,6 +1756,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 +1768,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 +1786,7 @@ pub(super) fn handle_resolution_choice( mut pending, current, chosen_pile_effect, + unchosen_pile_effect, source_id, }, GameAction::ChoosePile { pile }, @@ -1760,19 +1796,34 @@ 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, pile)], + &[(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 { + 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 { 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/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/parser/oracle_separate_piles.rs b/crates/engine/src/parser/oracle_separate_piles.rs index b5d050c489..3dc8f75e55 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(); @@ -86,6 +101,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, }, )) } @@ -166,6 +183,152 @@ fn rewrite_sub_effect_target_to_parent(effect: &mut Effect) { } } +/// 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.). +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.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()?; + // 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)?; + // 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)) +} + +/// 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() +} + +/// 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::*; @@ -199,6 +362,67 @@ mod tests { } } + /// 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. + #[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] diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index ef54f9adac..3db5a06d3c 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 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 /// 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/actions.rs b/crates/engine/src/types/actions.rs index 95fbde54b5..a36cf23e26 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 608.2d + 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 f258c952fb..4815d0b826 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 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`]. + 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 @@ -4584,6 +4604,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 +4626,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, }, @@ -5115,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", @@ -5272,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/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..f011635326 --- /dev/null +++ b/crates/engine/tests/integration/fact_or_fiction_pile_separation.rs @@ -0,0 +1,127 @@ +//! Integration test: Fact or Fiction pile-separation flow. +//! +//! 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::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 driven through the production Oracle parser. +#[test] +fn fact_or_fiction_full_flow() { + let mut scenario = GameScenario::new(); + 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) + + // 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}). + 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; 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/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/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 +} 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/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; 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() }) } 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 { .. }