diff --git a/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts b/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts index ea7a042232..3421074930 100644 --- a/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts +++ b/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts @@ -581,17 +581,34 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => { ).toThrow("P2P supports 2-6 players"); }); - it("enables multiplayer-mode enforcement on the engine at init time", async () => { - // P2PHostAdapter owns an authoritative WASM engine locally; flipping - // the engine's multiplayer flag during initialize() ensures any stray - // restore_game_state call is refused in the Rust layer. + it("enables multiplayer-mode enforcement at game start, not at lobby open", async () => { + // The engine's multiplayer flag is process-wide and nothing ever clears it, + // so an open host lobby must not set it — it is claimed only when the host + // actually takes the engine, on the line before `initializeGame`, which is + // where `initialize_debug_permissions` reads it. const { adapter } = makeHost(2); expect(mockSetMultiplayerMode).not.toHaveBeenCalled(); await adapter.initialize(); + expect(mockSetMultiplayerMode).not.toHaveBeenCalled(); + + await adapter.applySeatMutation({ + type: "SetKind", + data: { + seatIndex: 1, + kind: { + type: "Ai", + data: { difficulty: "Medium", deck: { type: "Random" } }, + }, + }, + }); + await adapter.initializeGame(); + expect(mockSetMultiplayerMode).toHaveBeenCalledTimes(1); expect(mockSetMultiplayerMode).toHaveBeenCalledWith(true); + expect(mockSetMultiplayerMode.mock.invocationCallOrder[0]) + .toBeLessThan(mockInitializeGame.mock.invocationCallOrder[0]); }); it("does not reinitialize the host during the lobby-to-game handoff", async () => { @@ -601,7 +618,7 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => { await adapter.initialize(); expect(mockInitialize).toHaveBeenCalledTimes(1); - expect(mockSetMultiplayerMode).toHaveBeenCalledTimes(1); + expect(mockSetMultiplayerMode).not.toHaveBeenCalled(); }); it("fences a stale host when a same-session resume claims a new incarnation", async () => { diff --git a/client/src/adapter/__tests__/wasm-adapter.test.ts b/client/src/adapter/__tests__/wasm-adapter.test.ts index 111282c316..599c29ccc3 100644 --- a/client/src/adapter/__tests__/wasm-adapter.test.ts +++ b/client/src/adapter/__tests__/wasm-adapter.test.ts @@ -57,6 +57,7 @@ const mockWorkerClient = { exportState: vi.fn().mockResolvedValue("{}"), restoreState: vi.fn().mockResolvedValue(undefined), resumeMultiplayerHostState: vi.fn().mockResolvedValue(undefined), + applySeatMutation: vi.fn().mockResolvedValue({ state: {}, delta: {} }), ping: vi.fn().mockResolvedValue("phase-rs engine ready"), takeLastPanic: vi.fn().mockResolvedValue(null), dispose: vi.fn(), @@ -647,6 +648,23 @@ describe("WasmAdapter", () => { }); }); + describe("applySeatMutation", () => { + it("does not load the card database", async () => { + await adapter.initialize(); + + const mutation = JSON.stringify({ type: "AddAiSeat", difficulty: "Medium" }); + await adapter.applySeatMutation("{}", mutation); + + expect(mockWorkerClient.applySeatMutation).toHaveBeenCalledWith("{}", mutation); + // Seat mutations are a pure reducer over the passed-in seat state plus the + // static starter-deck table; the engine re-resolves against CARD_DB at + // `initializeGame`. Warming it here would put a second full card database + // in memory for every lobby seat change. + expect(mockWorkerClient.loadCardDbFromUrl).not.toHaveBeenCalled(); + expect(adapter.cardDbLoaded).toBe(false); + }); + }); + describe("initializeGame", () => { it("delegates to worker client with seed", async () => { await adapter.initialize(); diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index 1d77fdb7b3..6a616cf2fa 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -57,7 +57,7 @@ export type SelectionConstraint = { "type": "count", "data": { min: number, max: export type ConfirmSemantics = "immediate" | "explicit"; -export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseGiftRecipient" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "activateManaSource" | "backToManaPayment" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "endContinuousEffect" | "debug"; +export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseGiftRecipient" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "activateManaSource" | "backToManaPayment" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "chooseEntryController" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "endContinuousEffect" | "debug"; export type InteractionRoleCode = "source" | "candidate" | "partner" | "attackTarget" | "target" | "paymentMode" | "abilityIndex" | "attacker" | "bandCount" | "blocker" | "blocked" | "untap" | "exert" | "enlistTarget" | "enlist" | "opponent" | "assistPlayer" | "assist" | "genericMana" | "mulligan" | "serumPowder" | "handCard" | "selected" | "counterSource" | "counterType" | "amount" | "coinFlipIndex" | "sideboardIndex" | "faceUpExile" | "optionIndex" | "triggerIndex" | "crewMember" | "stationCrew" | "x" | "mainCard" | "sideboardCard" | "playFirst" | "option" | "candidateIndex" | "cardName" | "pileA" | "pile" | "modeIndex" | "pay" | "face" | "castCost" | "permanentType" | "returnCreature" | "permissionSource" | "accept" | "spliceCard" | "splice" | "choice" | "costBranch" | "costBranchIndex" | "pair" | "dungeon" | "roomIndex" | "door" | "operation" | "convokeMana" | "harmonizeCreature" | "harmonize" | "companion" | "castChoice" | "castCard" | "placement" | "mergeSide" | "encodeCreature" | "encode" | "defender" | "protector" | "assignmentMode" | "damageTarget" | "damageAmount" | "trampleDamage" | "controllerDamage" | "destination" | "discardCard" | "learn" | "category" | "kept" | "phyrexianPayment" | "manaChoice" | "count" | "manaPayment" | "producedMana" | "color" | "player" | "castingVariant" | "mode" | "modeCost" | "castingCost" | "voteOption" | "voteCandidate"; diff --git a/client/src/adapter/p2p-adapter.ts b/client/src/adapter/p2p-adapter.ts index 07b69804a5..fb1aab2ed4 100644 --- a/client/src/adapter/p2p-adapter.ts +++ b/client/src/adapter/p2p-adapter.ts @@ -1313,9 +1313,10 @@ export class P2PHostAdapter implements EngineAdapter { } // Resume path: load the persisted GameState with a fresh RNG seed // and atomic multiplayer-flag flip. `resumeMultiplayerHostState` - // mirrors server-core's `from_persisted` pattern. Fresh-host path: - // just flip the flag; engine state is populated by the guests - // joining + `initializeGame`. + // mirrors server-core's `from_persisted` pattern. A fresh host lobby + // sets no engine flag at all — `setMultiplayerMode(true)` is deferred to + // `startPregameGameInner`, immediately before `initializeGame` claims + // the engine, so an open lobby leaves zero engine footprint. if (this.isResume && this.resumeGameState) { await this.wasm.resumeMultiplayerHostState(this.resumeGameState); this.resumeGameState = null; @@ -1323,8 +1324,6 @@ export class P2PHostAdapter implements EngineAdapter { tokens: this.playerTokens.size, gameStarted: this.gameStarted, }); - } else { - await this.wasm.setMultiplayerMode(true); } this.resolvePregameReady(); } catch (err) { @@ -1645,6 +1644,12 @@ export class P2PHostAdapter implements EngineAdapter { const playerCount = allowPartialStart ? orderedOpponents.length + 1 : this.pregameSeatState.seats.length; + // Claim the engine for multiplayer here, not at lobby open: the only + // in-creation reader of the flag is `initialize_debug_permissions`, + // evaluated inside `initialize_game`, so setting it on the line before is + // equivalent. The resume path is untouched — `resume_multiplayer_host_state` + // refuses when the flag is already set and sets it itself. + await this.wasm.setMultiplayerMode(true); const result = await this.wasm.initializeGame( deckPayload, this.formatConfig, diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index e70368da07..1eb37bf59d 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1698,6 +1698,7 @@ export type WaitingFor = | { type: "DeclareBlockers"; data: { player: PlayerId; valid_blocker_ids: ObjectId[]; valid_block_targets: Record; block_requirements?: Record; blocker_constraints?: Record } } | { type: "GameOver"; data: { winner: PlayerId | null } } | { type: "ReplacementChoice"; data: { player: PlayerId; candidate_count: number; candidates?: ReplacementCandidateSummary[] } } + | { type: "EntryControllerChoice"; data: { player: PlayerId; candidates: PlayerId[] } } | { type: "OrderTriggers"; data: { player: PlayerId; triggers: PendingTriggerSummary[] } } | { type: "CopyTargetChoice"; data: { player: PlayerId; source_id: ObjectId; valid_targets: ObjectId[]; max_mana_value?: number | null; purpose?: { type: "BecomeCopy" | "PersistChosenAttribute" } } } | { type: "ExploreChoice"; data: { player: PlayerId; source_id: ObjectId; choosable: ObjectId[]; remaining: ObjectId[]; pending_effect: unknown } } @@ -2243,6 +2244,7 @@ export type GameAction = | { type: "ChooseTarget"; data: { target: TargetRef | null } } | { type: "ChoosePair"; data: { partner: ObjectId | null } } | { type: "ChooseReplacement"; data: { index: number } } + | { type: "ChooseEntryController"; data: { opponent: PlayerId } } | { type: "OrderTriggers"; data: { order: number[] } } | { type: "CancelCast" } | { type: "Equip"; data: { equipment_id: ObjectId; target_id: ObjectId } } diff --git a/client/src/adapter/wasm-adapter.ts b/client/src/adapter/wasm-adapter.ts index 8f70b0b6f8..5fa86a849e 100644 --- a/client/src/adapter/wasm-adapter.ts +++ b/client/src/adapter/wasm-adapter.ts @@ -721,7 +721,9 @@ export class WasmAdapter implements EngineAdapter, AiDecisionDiagnosticsCapabili * Toggle the engine's multiplayer enforcement flag. When enabled, the * Rust side refuses `restore_game_state` with a descriptive error — * defense against any caller trying to rewind a multiplayer game. - * Called by multiplayer adapters (P2P host/guest) after WASM init. + * Called by the P2P host immediately before `initializeGame` — not at + * lobby open. A pregame lobby owns no game state, so enabling enforcement + * there only leaves the flag set on the worker for the lobby's lifetime. */ async setMultiplayerMode(enabled: boolean): Promise { this.assertInitialized(); @@ -735,7 +737,15 @@ export class WasmAdapter implements EngineAdapter, AiDecisionDiagnosticsCapabili async applySeatMutation(stateJson: string, mutationJson: string): Promise { this.assertInitialized(); - await this.ensureCardDb(); + // No `ensureCardDb()` here: `apply_seat_mutation` never reads CARD_DB. Its + // `WasmDeckResolver` resolves only against the static `STARTER_DECKS` table + // (crates/engine/src/starter_decks.rs) and otherwise clones the passed-in + // name list, staying at the name-only layer — `initialize_game` re-resolves + // against CARD_DB when the game actually starts. (The Rust doc comment on + // `apply_seat_mutation` claiming it uses "the TLS card database" is stale; + // read the resolver, not the doc comment.) Warming a ~100 MB DB for every + // lobby seat change is pure cost, and on a host lobby it is a second + // resident copy alongside the shared worker's. if (this.engine) { const result = await this.engine.applySeatMutation(stateJson, mutationJson); this.invalidateAiDecisionDiagnostics(); diff --git a/client/src/components/modal/EntryControllerModal.tsx b/client/src/components/modal/EntryControllerModal.tsx new file mode 100644 index 0000000000..aa2c409f9c --- /dev/null +++ b/client/src/components/modal/EntryControllerModal.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; + +import type { GameAction, 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 EntryControllerWaitingFor = Extract< + WaitingFor, + { type: "EntryControllerChoice" } +>; + +interface EntryControllerModalContentProps { + waitingFor: EntryControllerWaitingFor; + dispatch: (action: GameAction) => void | Promise; +} + +/** CR 614.12a: choose an opponent before the permanent enters the battlefield. */ +export function EntryControllerModalContent({ + waitingFor, + dispatch, +}: EntryControllerModalContentProps) { + const { t } = useTranslation("game"); + + return ( + ({ + id: String(opponent), + label: getOpponentDisplayName(opponent), + }))} + onChoose={(id) => { + dispatch({ + type: "ChooseEntryController", + data: { opponent: Number(id) }, + }); + }} + /> + ); +} + +export function EntryControllerModal() { + const canActForWaitingState = useCanActForWaitingState(); + const dispatch = useGameDispatch(); + const waitingFor = useGameStore((s) => s.waitingFor); + + if (waitingFor?.type !== "EntryControllerChoice") return null; + if (!canActForWaitingState) return null; + + return ; +} diff --git a/client/src/components/modal/__tests__/EntryControllerModal.test.tsx b/client/src/components/modal/__tests__/EntryControllerModal.test.tsx new file mode 100644 index 0000000000..6b6f036099 --- /dev/null +++ b/client/src/components/modal/__tests__/EntryControllerModal.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { GameAction, WaitingFor } from "../../../adapter/types.ts"; +import { isWaitingForHandled } from "../../../game/waitingForRegistry.ts"; +import { useMultiplayerStore } from "../../../stores/multiplayerStore.ts"; +import { EntryControllerModalContent } from "../EntryControllerModal.tsx"; + +type EntryControllerWaitingFor = Extract; + +function entryControllerWaitingFor(): EntryControllerWaitingFor { + return { + type: "EntryControllerChoice", + data: { player: 0, candidates: [2, 1] }, + }; +} + +afterEach(() => { + cleanup(); + useMultiplayerStore.setState({ playerNames: new Map() }); +}); + +describe("EntryControllerModalContent", () => { + it("registers the waiting state as handled", () => { + expect(isWaitingForHandled(entryControllerWaitingFor())).toBe(true); + }); + + it("dispatches the selected entry controller", () => { + useMultiplayerStore.setState({ + playerNames: new Map([ + [1, "Alice"], + [2, "Bob"], + ]), + }); + const dispatch = vi.fn<(action: GameAction) => void>(); + render( + , + ); + + expect(screen.getByRole("heading", { name: "Choose Entry Controller" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Bob" })); + + expect(dispatch).toHaveBeenCalledWith({ + type: "ChooseEntryController", + data: { opponent: 2 }, + }); + }); +}); diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index 7a1bd329ff..5b3c74094d 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -92,6 +92,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet = "PrecastCopyShortcutOffer", "RespondToPrecastCopyShortcut", "ReplacementChoice", + "EntryControllerChoice", "CopyTargetChoice", "CopyRetarget", "ExploreChoice", diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 75bf6f851f..f229ed6115 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1809,6 +1809,10 @@ "title": "Geschenk-Empfänger wählen", "subtitle": "Wähle, welcher Gegner das versprochene Geschenk erhält." }, + "entryController": { + "title": "Kontrolleur beim Eintreten wählen", + "subtitle": "Wähle, welcher Gegner diese bleibende Karte beim Eintreten kontrolliert." + }, "optionalCost": { "gift": { "title": "Ein Geschenk versprechen?", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 6ac2b8897a..7d956367c5 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1853,6 +1853,10 @@ "title": "Choose Gift Recipient", "subtitle": "Choose which opponent receives the promised gift." }, + "entryController": { + "title": "Choose Entry Controller", + "subtitle": "Choose which opponent controls this permanent as it enters." + }, "optionalCost": { "gift": { "title": "Promise a gift?", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 40f56d06d8..d4cffd20d0 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1809,6 +1809,10 @@ "title": "Elegir destinatario del regalo", "subtitle": "Elige qué oponente recibe el regalo prometido." }, + "entryController": { + "title": "Elegir controlador al entrar", + "subtitle": "Elige qué oponente controla este permanente al entrar." + }, "optionalCost": { "gift": { "title": "¿Prometer un regalo?", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index f755581635..04613d4886 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1809,6 +1809,10 @@ "title": "Choisir le destinataire du cadeau", "subtitle": "Choisissez quel adversaire reçoit le cadeau promis." }, + "entryController": { + "title": "Choisir le contrôleur à l'arrivée", + "subtitle": "Choisissez quel adversaire contrôle ce permanent à son arrivée." + }, "optionalCost": { "gift": { "title": "Promettre un cadeau ?", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 505b456aa5..5b5a940a57 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1809,6 +1809,10 @@ "title": "Scegli il destinatario del dono", "subtitle": "Scegli quale avversario riceve il dono promesso." }, + "entryController": { + "title": "Scegli il controllore all'entrata", + "subtitle": "Scegli quale avversario controlla questo permanente mentre entra." + }, "optionalCost": { "gift": { "title": "Promettere un dono?", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index d5a07fd315..78993022ba 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1809,6 +1809,10 @@ "title": "Wybierz odbiorcę prezentu", "subtitle": "Wybierz przeciwnika, który otrzyma obiecany prezent." }, + "entryController": { + "title": "Wybierz kontrolującego przy wejściu", + "subtitle": "Wybierz przeciwnika, który kontroluje ten permanent, gdy wchodzi na pole bitwy." + }, "optionalCost": { "gift": { "title": "Obiecać prezent?", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index fe9a72f37d..8bf35ad432 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1809,6 +1809,10 @@ "title": "Escolher destinatário do presente", "subtitle": "Escolha qual oponente recebe o presente prometido." }, + "entryController": { + "title": "Escolher controlador ao entrar", + "subtitle": "Escolha qual oponente controla esta permanente ao entrar." + }, "optionalCost": { "gift": { "title": "Prometer um presente?", diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 2310d6c8fa..576d2086a3 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -111,6 +111,7 @@ import { ZoneOpponentChooserModal } from "../components/modal/ZoneOpponentChoose import { PileOpponentModal } from "../components/modal/PileOpponentModal.tsx"; import { AnnouncingOpponentModal } from "../components/modal/AnnouncingOpponentModal.tsx"; import { GiftRecipientModal } from "../components/modal/GiftRecipientModal.tsx"; +import { EntryControllerModal } from "../components/modal/EntryControllerModal.tsx"; import { TributeModal } from "../components/modal/TributeModal.tsx"; import { CombatTaxModal } from "../components/modal/CombatTaxModal.tsx"; import { TopOrBottomChoiceModalContent } from "../components/modal/TopOrBottomChoiceModal.tsx"; @@ -1877,6 +1878,7 @@ function GamePageContent({ + diff --git a/client/src/wasm/draft_wasm.d.ts b/client/src/wasm/draft_wasm.d.ts index 3c7db9a9bc..ba9f2c0fb5 100644 --- a/client/src/wasm/draft_wasm.d.ts +++ b/client/src/wasm/draft_wasm.d.ts @@ -36,7 +36,7 @@ export function auto_pick(): any; * - `pool_input_json`: serialized `PoolInput` discriminated union * (`{ "type": "Set" | "Cube", "data": { ... } }`) * - `seats_json`: JSON array of SeatDescriptors - * - `kind`: 0=Quick, 1=Premier, 2=Traditional, 3=Sealed. The user-selected DraftKind + * - `kind`: 0=Quick, 1=Premier, 2=Traditional, 3=Sealed. * flows through to `DraftConfig.kind` unchanged. Tournament match format * (Bo1 for Premier and Sealed, Bo3 for Traditional) is identical to set drafts. * - `seed`: RNG seed for deterministic pack generation @@ -139,7 +139,7 @@ export function start_quick_draft(set_pool_json: string, difficulty: number, see /** * Start a local Sealed event: one human and seven bots each open six packs, - * then deckbuilding begins immediately. + * then the human proceeds directly to deckbuilding. */ export function start_sealed_draft(set_pool_json: string, difficulty: number, seed: number): any; diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index 5c8231d95b..56b5d28fab 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -206,7 +206,7 @@ export function get_legal_actions_for_viewer_js(player_id: number): any; /** * Get the legal actions, auto-pass recommendation, and spell costs for the current game state. - * Returns `{ actions: GameAction[], autoPassRecommended: boolean, spellCosts: Record }`. + * Returns `{ actions: GameAction[], autoPassRecommended: boolean, spellCosts: Record }`. */ export function get_legal_actions_js(): any; @@ -509,7 +509,6 @@ export interface InitOutput { readonly evaluate_deck_compatibility_js: (a: any) => [number, number, number]; readonly export_game_state_json: () => [number, number, number, number]; readonly export_replay_log: () => [number, number, number, number]; - readonly getFormatRegistry: () => any; readonly get_ai_action_proposal: (a: number, b: number, c: number) => [number, number, number]; readonly get_ai_action_proposal_from_scores: (a: number, b: number, c: number, d: number, e: number, f: bigint) => [number, number, number]; readonly get_ai_action_proposal_from_scores_with_diagnostics: (a: number, b: number, c: number, d: number, e: number, f: bigint) => [number, number, number]; @@ -556,6 +555,7 @@ export interface InitOutput { readonly replay_header_js: () => any; readonly list_token_presets_js: () => any; readonly create_initial_state: () => any; + readonly getFormatRegistry: () => any; readonly clear_replay_playback: () => void; readonly replay_length_js: () => number; readonly __wbindgen_malloc: (a: number, b: number) => number; diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index b46c93e240..7af74e9c8f 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -425,6 +425,17 @@ pub fn candidate_actions_exact(state: &GameState) -> Vec { ) }) .collect(), + WaitingFor::EntryControllerChoice { player, candidates } => candidates + .iter() + .copied() + .map(|opponent| { + candidate( + GameAction::ChooseEntryController { opponent }, + TacticalClass::Replacement, + Some(*player), + ) + }) + .collect(), WaitingFor::MoveCountersDistribution { player, available, @@ -877,6 +888,18 @@ pub fn candidate_actions_broad_with_probe( ) }) .collect(), + WaitingFor::EntryControllerChoice { player, candidates } => candidates + .iter() + .map(|opponent| { + candidate( + GameAction::ChooseEntryController { + opponent: *opponent, + }, + TacticalClass::Replacement, + Some(*player), + ) + }) + .collect(), WaitingFor::ManaPayment { player, convoke_mode, diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 0b3fdc4ec1..f8cd1fea9a 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -1221,6 +1221,7 @@ fn classify_flat_priority_action(action: &GameAction) -> FlatPriorityActionClass | GameAction::SelectTargets { .. } | GameAction::ChooseTarget { .. } | GameAction::ChooseReplacement { .. } + | GameAction::ChooseEntryController { .. } | GameAction::OrderTriggers { .. } | GameAction::CancelCast | GameAction::Equip { .. } diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 20e03d1221..4c33dff067 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -1339,6 +1339,7 @@ fn scope_of(target: &TargetFilter, chain_root: Option) -> WriteScope | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::Opponent | TargetFilter::Typed(..) | TargetFilter::Not { .. } @@ -2265,6 +2266,7 @@ fn legacy_target_filter(f: &TargetFilter) -> bool { | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::Opponent | TargetFilter::SelfRef | TargetFilter::SourceOrPaired @@ -2476,6 +2478,7 @@ fn member_bound_target_filter(f: &TargetFilter) -> bool { | TargetFilter::AttachedTo | TargetFilter::Neighbor { .. } | TargetFilter::OriginalController + | TargetFilter::SourceController | TargetFilter::EventTarget | TargetFilter::TriggeringSourceController | TargetFilter::PostReplacementSourceController @@ -6466,6 +6469,9 @@ fn rw_target_filter(x: &TargetFilter) -> RwProfile { } // CR 607.2d / CR 607.2m (by analogy): durable per-player anchor-label reads. TargetFilter::PlayerWhoChoseLabel { label: _ } => reads_player_of(StateKind::Other), + // CR 608.2h + CR 113.7a: source-controller resolution follows the + // source's exact live-or-LKI incarnation. + TargetFilter::SourceController => reads_src_of(StateKind::Other), TargetFilter::Typed(tf) => { if tf .properties diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index fbff5eccad..912d6b2e72 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -2913,6 +2913,7 @@ fn scan_target_filter(x: &TargetFilter, ctx: FilterReadContext, mode: ScanMode) TargetFilter::Any => Axes::NONE, TargetFilter::Player => Axes::NONE, TargetFilter::Controller => Axes::NONE, + TargetFilter::SourceController => Axes::NONE, TargetFilter::Opponent => Axes::NONE, TargetFilter::SelfRef => Axes::NONE, // CR 201.5a: a source-relative object ref (the granting object), like diff --git a/crates/engine/src/game/cost_payability.rs b/crates/engine/src/game/cost_payability.rs index 884c75ef89..63ae33c87c 100644 --- a/crates/engine/src/game/cost_payability.rs +++ b/crates/engine/src/game/cost_payability.rs @@ -61,6 +61,7 @@ pub(crate) fn target_filter_has_x_mana_value_constraint(filter: &TargetFilter) - | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::Opponent | TargetFilter::SelfRef | TargetFilter::SourceOrPaired @@ -143,6 +144,7 @@ pub(crate) fn relax_x_mana_value_constraint(filter: &TargetFilter) -> TargetFilt | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::Opponent | TargetFilter::SelfRef | TargetFilter::SourceOrPaired diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 02ded2c088..2b4248e04c 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -549,6 +549,7 @@ fn fmt_target(filter: &TargetFilter) -> String { TargetFilter::Player => "player".into(), TargetFilter::AllPlayers => "any player".into(), TargetFilter::Controller => "controller".into(), + TargetFilter::SourceController => "source's controller".into(), TargetFilter::Opponent => "opponent".into(), TargetFilter::OriginalController => "original controller".into(), TargetFilter::ScopedPlayer => "scoped player".into(), diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 68a4e58efb..0939e69ec8 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -9782,9 +9782,8 @@ mod tests { ); } - // Enter the battlefield with NO imperative controller override (default - // would be the owner's control, player 0). The self-replacement must - // flip control to the opponent, player 1. + // Seed player 0 explicitly as a cast path does. The self-replacement + // must still flip control to the sole opponent, player 1. let ability = ResolvedAbility::new( Effect::ChangeZone { origin: Some(Zone::Hand), @@ -9792,7 +9791,7 @@ mod tests { target: TargetFilter::Any, owner_library: false, enter_transformed: false, - enters_under: None, + enters_under: Some(ControllerRef::You), enter_tapped: crate::types::zones::EtbTapState::Unspecified, enters_attacking: false, up_to: false, @@ -9819,4 +9818,76 @@ mod tests { "CR 110.2a: enters under the opponent's control, not its owner's" ); } + + /// CR 614.12a: with multiple eligible opponents, a self-entry controller + /// replacement pauses before the physical move and delivers directly under + /// the chosen opponent's control. + #[test] + fn self_enters_under_opponent_choice_is_pre_entry_and_honors_selection() { + use crate::types::ability::{ControllerRef, ReplacementDefinition}; + use crate::types::card_type::CoreType; + use crate::types::format::FormatConfig; + use crate::types::replacements::ReplacementEvent; + + let mut state = GameState::new(FormatConfig::standard(), 3, 7); + let obj = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Xantcha, Sleeper Agent".to_string(), + Zone::Hand, + ); + { + let object = state.objects.get_mut(&obj).unwrap(); + object.card_types.core_types.push(CoreType::Creature); + object.replacement_definitions.push( + ReplacementDefinition::new(ReplacementEvent::Moved) + .valid_card(TargetFilter::SelfRef) + .destination_zone(Zone::Battlefield) + .enters_under(ControllerRef::Opponent), + ); + } + let ability = ResolvedAbility::new( + Effect::ChangeZone { + origin: Some(Zone::Hand), + destination: Zone::Battlefield, + target: TargetFilter::Any, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::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, + }, + vec![TargetRef::Object(obj)], + ObjectId(999), + PlayerId(0), + ); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + + assert_eq!(state.objects[&obj].zone, Zone::Hand); + assert!(matches!( + &state.waiting_for, + crate::types::game_state::WaitingFor::EntryControllerChoice { + player: PlayerId(0), + candidates, + } if candidates == &vec![PlayerId(1), PlayerId(2)] + )); + + apply_as_current( + &mut state, + GameAction::ChooseEntryController { + opponent: PlayerId(2), + }, + ) + .unwrap(); + + assert_eq!(state.objects[&obj].zone, Zone::Battlefield); + assert_eq!(state.objects[&obj].controller, PlayerId(2)); + } } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index dcb8c5a032..7da525dd54 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -7069,6 +7069,13 @@ pub(crate) fn resolve_player_for_context_ref( return player; } } + // CR 608.2h + CR 113.7a: "~'s controller" reads the source's exact + // live-or-LKI incarnation. Do not fall through to `ability.controller`, + // which is the activating player for an activated ability. + if matches!(target_filter, TargetFilter::SourceController) { + return crate::game::targeting::resolve_effect_player_ref(state, ability, target_filter) + .unwrap_or(ability.controller); + } if let Some(target_ref) = crate::game::targeting::resolve_event_context_target( state, target_filter, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 056adb4ca2..9bdcc6ddb5 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -9865,6 +9865,10 @@ fn apply_action( (WaitingFor::ReplacementChoice { .. }, GameAction::ChooseReplacement { index }) => { engine_replacement::handle_replacement_choice(state, index, &mut events)? } + ( + WaitingFor::EntryControllerChoice { .. }, + GameAction::ChooseEntryController { opponent }, + ) => engine_replacement::handle_entry_controller_choice(state, opponent, &mut events)?, // CR 603.3b: Player submits the chosen order for their pending triggers. // `actor` is already authorized as the prompted player by // `check_actor_authorization` (via `WaitingFor::acting_player`). @@ -16113,9 +16117,11 @@ mod stage2_injector_tests { // Current-main port: #7221's typed player-action completion seam and the // contemporaneous upstream changes moved these three producers. Re-derived // in the merged source, still in their named production functions. + // #7382's optional-player routing and pre-entry controller prompt move only + // the third and fifth coordinates; both named mints were re-read in place. "game/effects/mod.rs:6640".to_string(), "game/effects/mod.rs:6717".to_string(), - "game/effects/mod.rs:9932".to_string(), + "game/effects/mod.rs:9939".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. @@ -16453,7 +16459,7 @@ mod stage2_injector_tests { // #4155 adds seven lines above this producer for abandoned-cast // finalization, while its deferred-resume cleanup removes two; // the net +5 moves this coordinate to `:12008`. - "game/engine.rs:12008".to_string(), + "game/engine.rs:12012".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 7319472ce9..c4fdc4a685 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1430,6 +1430,64 @@ pub(super) fn handle_replacement_choice( } } +/// CR 614.12a: Commit a pre-entry controller choice onto the exact parked +/// ZoneChange, then resume through the ordinary replacement-choice handler so +/// delivery, trigger collection, and any later replacement ordering retain +/// their existing single authorities. +pub(super) fn handle_entry_controller_choice( + state: &mut GameState, + opponent: PlayerId, + events: &mut Vec, +) -> Result { + let (player, candidates) = match &state.waiting_for { + WaitingFor::EntryControllerChoice { player, candidates } => (*player, candidates), + _ => { + return Err(EngineError::InvalidAction( + "entry controller choice is not pending".to_string(), + )); + } + }; + if !candidates.contains(&opponent) + || !crate::game::players::choosable_opponents(state, player).contains(&opponent) + { + return Err(EngineError::InvalidAction( + "chosen entry controller is not eligible".to_string(), + )); + } + let Some(pending) = state.pending_replacement.as_mut() else { + return Err(EngineError::InvalidAction( + "entry controller choice has no pending replacement".to_string(), + )); + }; + if pending.candidates.len() != 1 || pending.is_optional { + return Err(EngineError::InvalidAction( + "entry controller choice has an invalid replacement resume".to_string(), + )); + } + let ProposedEvent::ZoneChange { + controller_override, + to: Zone::Battlefield, + .. + } = &mut pending.proposed + else { + return Err(EngineError::InvalidAction( + "entry controller choice does not own a battlefield entry".to_string(), + )); + }; + *controller_override = Some(opponent); + pending.proposed.applied_set_mut().insert( + crate::types::proposed_event::AppliedReplacementKey::EntryControllerChoice { + source: pending.candidates[0].source, + index: pending.candidates[0].index, + controller: opponent, + }, + ); + // Mark before re-entering the ordinary handler so the pre-entry chooser + // is not offered again while that handler applies this same replacement. + pending.proposed.mark_applied(pending.candidates[0]); + handle_replacement_choice(state, 0, events) +} + /// CR 707.2c + CR 614.12a + CR 613.1a: Answer path for Metamorphic Alteration's /// "As this Aura enters, choose a creature." Latches the chosen creature's /// copiable values (fixed here, per CR 707.2c, as the copy effect first starts diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 7e06ab73dd..ff13948e27 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -163,6 +163,7 @@ pub(crate) fn affected_filter_uses_object_population(filter: &TargetFilter) -> b | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::Opponent | TargetFilter::SelfRef | TargetFilter::SourceOrPaired @@ -435,6 +436,7 @@ pub(crate) fn target_filter_characteristic_reads_at( | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::Opponent | TargetFilter::SelfRef | TargetFilter::SourceOrPaired @@ -808,6 +810,7 @@ pub(crate) fn entered_object_perturbs_affected_filter( | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::Opponent | TargetFilter::SelfRef | TargetFilter::SourceOrPaired @@ -1541,6 +1544,7 @@ pub(crate) fn filter_contains(filter: &TargetFilter, leaf: &dyn Fn(&TargetFilter | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::ControllerAndControlledPermanents { .. } | TargetFilter::Opponent | TargetFilter::SelfRef @@ -2890,6 +2894,7 @@ fn filter_inner_for_object( // CR 118.12a: unless-payer population — never matches an object. TargetFilter::AllPlayers => false, TargetFilter::Controller => false, // Controller is a player, not an object + TargetFilter::SourceController => false, // SourceController is a player, not an object // CR 102.3: Opponent is a player reference (used only as a slot announcer), // never an object. TargetFilter::Opponent => false, @@ -3537,6 +3542,7 @@ fn zone_change_filter_inner( // CR 118.12a: unless-payer population — never matches an object. TargetFilter::AllPlayers => false, TargetFilter::Controller => false, + TargetFilter::SourceController => false, // CR 102.3: Opponent is a player reference, never an object. TargetFilter::Opponent => false, // CR 109.5: OriginalController is a player reference, not an object. @@ -4030,6 +4036,7 @@ pub fn spell_record_matches_filter( // CR 118.12a: unless-payer population, never an object filter. | TargetFilter::AllPlayers | TargetFilter::Controller + | TargetFilter::SourceController // CR 102.3: Opponent is a player reference, never a spell-record filter. | TargetFilter::Opponent | TargetFilter::OriginalController @@ -4347,6 +4354,7 @@ fn spell_object_matches_filter_inner( // CR 118.12a: unless-payer population, never an object filter. | TargetFilter::AllPlayers | TargetFilter::Controller + | TargetFilter::SourceController // CR 102.3: Opponent is a player reference, never a spell-record filter. | TargetFilter::Opponent | TargetFilter::OriginalController diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 2209606c5b..30ebeadbc1 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -261,6 +261,7 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H | WaitingFor::ExertChoice { .. } | WaitingFor::EnlistChoice { .. } | WaitingFor::ReplacementChoice { .. } + | WaitingFor::EntryControllerChoice { .. } | WaitingFor::CopyTargetChoice { .. } | WaitingFor::ExploreChoice { .. } | WaitingFor::ReturnAsAuraTarget { .. } @@ -488,6 +489,7 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { | WaitingFor::ExertChoice { .. } | WaitingFor::EnlistChoice { .. } | WaitingFor::ReplacementChoice { .. } + | WaitingFor::EntryControllerChoice { .. } | WaitingFor::CopyTargetChoice { .. } | WaitingFor::ExploreChoice { .. } | WaitingFor::ReturnAsAuraTarget { .. } @@ -3438,6 +3440,7 @@ fn selection_projection( | WaitingFor::EnlistChoice { .. } | WaitingFor::GameOver { .. } | WaitingFor::ReplacementChoice { .. } + | WaitingFor::EntryControllerChoice { .. } | WaitingFor::OrderTriggers { .. } | WaitingFor::CopyTargetChoice { .. } | WaitingFor::ExploreChoice { .. } @@ -4381,7 +4384,8 @@ fn project_action_payload( | GameAction::ChooseZoneOpponentChooser { opponent } | GameAction::ChoosePileOpponent { opponent } | GameAction::ChooseAnnouncingOpponent { opponent } - | GameAction::ChooseGiftRecipient { opponent } => { + | GameAction::ChooseGiftRecipient { opponent } + | GameAction::ChooseEntryController { opponent } => { push_player_surface(surfaces, *opponent, InteractionRoleCode::Opponent) } GameAction::ChooseAssistPlayer { player } => { @@ -5106,6 +5110,7 @@ fn action_code(action: &GameAction) -> InteractionActionCode { GameAction::SelectTargets { .. } => InteractionActionCode::SelectTargets, GameAction::ChooseTarget { .. } => InteractionActionCode::ChooseTarget, GameAction::ChooseReplacement { .. } => InteractionActionCode::ChooseReplacement, + GameAction::ChooseEntryController { .. } => InteractionActionCode::ChooseEntryController, GameAction::OrderTriggers { .. } => InteractionActionCode::OrderTriggers, GameAction::CancelCast => InteractionActionCode::CancelCast, GameAction::Equip { .. } => InteractionActionCode::Equip, diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 0c5530d980..e47d22b9c1 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3372,6 +3372,7 @@ fn target_filter_reads_life_total(filter: &TargetFilter) -> bool { | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::ControllerAndControlledPermanents { .. } | TargetFilter::Opponent | TargetFilter::SelfRef diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 4d34234e9f..64b0dddb91 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -855,6 +855,13 @@ pub(crate) fn pending_replacement_option_count( /// because step-end mana handlers are not attached to a single object — they /// are scanned per-player per-phase-transition. pub fn replacement_choice_waiting_for(player: PlayerId, state: &GameState) -> WaitingFor { + // CR 614.12a: This prompt is raised while applying a replacement, but it + // is not an ordering choice. Callers throughout the zone pipeline use this + // common helper after `ReplacementResult::NeedsChoice`; preserve the + // already-surfaced pre-entry controller prompt instead of overwriting it. + if let WaitingFor::EntryControllerChoice { .. } = &state.waiting_for { + return state.waiting_for.clone(); + } // CR 616.1 / CR 614: each option carries its source object so the frontend // can show which object (or rule-based virtual replacement) creates it, // mirroring the `PendingTriggerSummary` payload for CR 603.3b trigger @@ -1005,7 +1012,9 @@ pub fn replacement_choice_waiting_for(player: PlayerId, state: &GameState) -> Wa pub fn park_waiting_for(state: &mut GameState, player: PlayerId) { if matches!( state.waiting_for, - WaitingFor::CopyTargetChoice { .. } | WaitingFor::ReturnAsAuraTarget { .. } + WaitingFor::CopyTargetChoice { .. } + | WaitingFor::ReturnAsAuraTarget { .. } + | WaitingFor::EntryControllerChoice { .. } ) || super::engine_resolution_choices::handles(&state.waiting_for) { return; @@ -7892,10 +7901,8 @@ fn event_modifiers_for_ability( /// `ControllerRef::Opponent` ("enters under the control of an opponent of your /// choice") is resolved here rather than via the canonical `controller_ref_player`, /// which returns `None` for `Opponent` (ambiguous when more than one opponent -/// exists). In a two-player game this is the sole opponent — fully correct. In -/// multiplayer it picks the first opponent in seat order; a full controller choice -/// is a follow-up. Either way the permanent enters under an opponent's control -/// rather than its owner's, satisfying CR 110.2a. +/// exists). The multi-opponent case pauses in `entry_controller_choice`; this +/// fallback is therefore only the no-choice (zero/one eligible opponent) path. fn resolve_self_enters_under_controller( state: &GameState, object_id: ObjectId, @@ -7903,9 +7910,11 @@ fn resolve_self_enters_under_controller( ) -> Option { let entering_controller = state.objects.get(&object_id)?.controller; match cref { - ControllerRef::Opponent => crate::game::players::opponents(state, entering_controller) - .into_iter() - .next(), + ControllerRef::Opponent => { + crate::game::players::choosable_opponents(state, entering_controller) + .into_iter() + .next() + } other => crate::game::filter::controller_ref_player( state, object_id, @@ -8483,6 +8492,15 @@ fn apply_single_replacement( // enters under its owner's control first). Resolve the carried // `ControllerRef` against the entering object's own controller. if let Some(cref) = modifiers.controller_override.as_ref() { + let selected_entry_controller = + new_event.applied_set().iter().find_map(|key| match key { + AppliedReplacementKey::EntryControllerChoice { + source, + index, + controller, + } if *source == rid.source && *index == rid.index => Some(*controller), + _ => None, + }); if let ProposedEvent::ZoneChange { object_id, to: Zone::Battlefield, @@ -8490,7 +8508,9 @@ fn apply_single_replacement( .. } = &mut new_event { - if let Some(pid) = + if let Some(selected_controller) = selected_entry_controller { + *controller_override = Some(selected_controller); + } else if let Some(pid) = resolve_self_enters_under_controller(state, *object_id, cref) { *controller_override = Some(pid); @@ -9204,6 +9224,69 @@ fn replacement_definition_for_id( }) } +/// CR 614.12a: determine whether a mandatory self-entry controller replacement +/// needs a pre-entry opponent choice. The candidate set is captured once, before +/// any physical zone move; the answer is written onto the same `ZoneChange` and +/// resumed through the ordinary replacement pipeline. +fn entry_controller_choice( + state: &GameState, + proposed: &ProposedEvent, + rid: ReplacementId, +) -> Option<(PlayerId, Vec)> { + if proposed.already_applied(&rid) { + return None; + } + let ProposedEvent::ZoneChange { + object_id, + to: Zone::Battlefield, + .. + } = proposed + else { + return None; + }; + let replacement = replacement_definition_for_id(state, rid)?; + if replacement_mode_is_optional(&replacement.mode) + || !matches!( + replacement.enters_under.as_ref(), + Some(ControllerRef::Opponent) + ) + { + return None; + } + let chooser = state.objects.get(object_id)?.controller; + let candidates = crate::game::players::choosable_opponents(state, chooser); + (candidates.len() >= 2).then_some((chooser, candidates)) +} + +/// CR 614.12a: park an as-enters controller choice without applying its +/// replacement yet. Keeping the selected `ReplacementId` and exact proposed +/// event in the normal pending record means the answer resumes the established +/// CR 616.1 loop rather than reconstructing a zone move. +fn park_entry_controller_choice( + state: &mut GameState, + proposed: ProposedEvent, + depth: u16, + rid: ReplacementId, + player: PlayerId, + candidates: Vec, +) -> ReplacementResult { + state.pending_replacement = Some(PendingReplacement { + proposed, + sacrifice_provenance: None, + candidates: vec![rid], + search_found_candidates: Vec::new(), + depth, + is_optional: false, + library_placement: None, + excess_recipient: None, + lifelink_bonus: 0, + may_cost_paid: false, + may_cost_remaining: None, + }); + state.waiting_for = WaitingFor::EntryControllerChoice { player, candidates }; + ReplacementResult::NeedsChoice(player) +} + fn pipeline_loop( state: &mut GameState, mut proposed: ProposedEvent, @@ -9270,6 +9353,18 @@ fn pipeline_loop( return ReplacementResult::NeedsChoice(affected); } + if let Some((player, entry_candidates)) = entry_controller_choice(state, &proposed, rid) + { + return park_entry_controller_choice( + state, + proposed, + depth, + rid, + player, + entry_candidates, + ); + } + proposed.mark_applied(rid); match apply_single_replacement_and_dirty( state, @@ -9553,7 +9648,23 @@ fn continue_replacement_impl( let reparked_depth = pending.depth; let reparked_library_placement = pending.library_placement.clone(); let reparked_sacrifice_provenance = pending.sacrifice_provenance; - let mut proposed = pending.proposed; + let mut proposed = pending.proposed.clone(); + if chosen_index == 0 { + if let Some((player, entry_candidates)) = entry_controller_choice(state, &proposed, rid) + { + // The optional accept decision is already made. Re-park the + // same replacement as mandatory so the entry-controller answer + // applies it exactly once without re-offering accept/decline. + pending.candidates = vec![rid]; + pending.is_optional = false; + state.pending_replacement = Some(pending); + state.waiting_for = WaitingFor::EntryControllerChoice { + player, + candidates: entry_candidates, + }; + return ReplacementResult::NeedsChoice(player); + } + } proposed.mark_applied(rid); // CR 614.1a: the "first time you would create … each turn" window is // per-player; it is consumed by `record_token_created` when the resulting @@ -9825,7 +9936,17 @@ fn continue_replacement_impl( return ReplacementResult::NeedsChoice(affected); } - let mut proposed = pending.proposed; + let mut proposed = pending.proposed.clone(); + if let Some((player, entry_candidates)) = entry_controller_choice(state, &proposed, rid) { + pending.candidates = vec![rid]; + pending.is_optional = false; + state.pending_replacement = Some(pending); + state.waiting_for = WaitingFor::EntryControllerChoice { + player, + candidates: entry_candidates, + }; + return ReplacementResult::NeedsChoice(player); + } proposed.mark_applied(rid); // CR 614.1a: per-player "first time each turn" window is consumed by // `record_token_created` on the created tokens; no per-source bookkeeping here. diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index c2b40851ad..ddb67e3acf 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1757,6 +1757,7 @@ impl GameRunner { WaitingFor::EnlistChoice { .. } => "EnlistChoice", WaitingFor::GameOver { .. } => "GameOver", WaitingFor::ReplacementChoice { .. } => "ReplacementChoice", + WaitingFor::EntryControllerChoice { .. } => "EntryControllerChoice", WaitingFor::OrderTriggers { .. } => "OrderTriggers", WaitingFor::CopyTargetChoice { .. } => "CopyTargetChoice", WaitingFor::ExploreChoice { .. } => "ExploreChoice", diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 2cb2a5136a..008eeaef1f 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -107,17 +107,21 @@ fn push_to_stack_with_firing( .get(&entry.source_id) .filter(|object| object.back_face.is_some()); let count = source.map(|object| object.transformation_count); - let incarnation = source.map(|object| object.incarnation); if let Some(ability) = entry.ability_mut() { + // CR 608.2h + CR 113.7a: Every activated/triggered ability needs + // its source incarnation, not only a transforming source. Effects + // such as "~'s controller loses life" use it to read the source's + // current controller while it remains in its expected zone and its + // LKI controller after it leaves, without rebinding a re-entered + // object that reuses this storage id. + if ability.source_incarnation.is_none() { + ability + .set_source_incarnation_recursive(source_ref.map(|source| source.incarnation)); + } // CR 701.27f: delayed triggered abilities already carry their // creation-time generation and must not be restamped when fired. if ability.context.source_transformation_count.is_none() { ability.set_source_transformation_count_recursive(count); - // CR 400.7: a re-entered source can share the same storage ID - // and transformation generation, so retain its incarnation too. - if ability.source_incarnation.is_none() { - ability.set_source_incarnation_recursive(incarnation); - } } } } diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index 235ffe83ad..47ec684d6c 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -1449,6 +1449,30 @@ pub fn resolve_effect_player_ref( // `effects::resolve_player_for_context_ref`, which resolves `Controller` // straight to `ability.controller`. TargetFilter::Controller => Some(ability.controller), + // CR 608.2h + CR 113.7a: "~'s controller" follows the source's exact + // incarnation. A triggered ability owns the richer TriggerSourceContext + // authority; an activated ability carries its source incarnation from + // the shared stack-push seam. Neither path may fall back to the latest + // object with the same storage id. + TargetFilter::SourceController => ability + .trigger_source + .as_ref() + .map(|source| source.source_read(state).controller()) + .or_else(|| { + let incarnation = ability.source_incarnation?; + state + .objects + .get(&ability.source_id) + .filter(|source| source.incarnation == incarnation) + .map(|source| source.controller) + .or_else(|| { + state + .lki_by_incarnation + .get(&ability.source_id) + .and_then(|by_incarnation| by_incarnation.get(&incarnation)) + .map(|lki| lki.controller) + }) + }), // CR 109.5: The ability's original controller — fixed even when // `player_scope` iteration has rebound `ability.controller`. TargetFilter::OriginalController => { @@ -5081,6 +5105,48 @@ mod tests { ) } + /// CR 608.2h + CR 113.7a: A source-controller predicate on a triggered + /// ability reads the observed incarnation while it remains in its observed + /// zone, then uses that incarnation's LKI rather than a same-id return. + #[test] + fn source_controller_trigger_context_uses_live_then_lki_provenance() { + let mut state = GameState::new_two_player(7); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "trigger source".to_string(), + Zone::Battlefield, + ); + let source_context = crate::game::triggers::trigger_source_context_for_latch( + &state, + state.objects.get(&source).expect("test source exists"), + ); + let mut ability = make_resolved_with_targets(vec![], source); + ability.trigger_source = Some(source_context); + + state + .objects + .get_mut(&source) + .expect("test source exists") + .controller = PlayerId(1); + assert_eq!( + resolve_effect_player_ref(&state, &ability, &TargetFilter::SourceController), + Some(PlayerId(1)), + "the exact live incarnation observes a control change" + ); + + let returned = state.objects.get_mut(&source).expect("test source exists"); + returned.zone = Zone::Battlefield; + returned.incarnation += 1; + returned.controller = PlayerId(1); + assert_eq!( + resolve_effect_player_ref(&state, &ability, &TargetFilter::SourceController), + Some(PlayerId(0)), + "a same-id re-entry must use the triggering incarnation's LKI" + ); + } + /// CR 109.5 + CR 701.55a: A villainous-choice "you …" branch is resolved /// with `controller = source controller` and `scoped_player = the chooser` /// (an opponent). "you"/`Controller` must resolve to the controller, not to diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index 65aaf1535a..7b0a2b72ae 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -812,6 +812,7 @@ pub(super) fn target_filter_matches_object( // CR 118.12a: unless-payer population — never matches an object. TargetFilter::AllPlayers => false, TargetFilter::Controller => false, + TargetFilter::SourceController => false, // CR 102.3: Opponent is a player reference, never an object. TargetFilter::Opponent => false, // CR 109.5: OriginalController is a player reference, not an object. diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 64e7d74251..e5fdc56f36 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -3871,6 +3871,7 @@ pub(crate) fn deliver_replaced_zone_change( fn replacement_pause_delivery_result(state: &GameState) -> ZoneDeliveryResult { match &state.waiting_for { WaitingFor::ReplacementChoice { player, .. } + | WaitingFor::EntryControllerChoice { player, .. } // CR 614.12a: a Devour as-enters sacrifice surfaced its own // `EffectZoneChoice`; carry its chooser so the caller's `park_waiting_for` // doesn't clobber the already-surfaced prompt. diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index dcb6ae3958..70137309d8 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -2764,6 +2764,7 @@ fn ability_reads_last_created(def: &AbilityDefinition) -> bool { | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::ControllerAndControlledPermanents { .. } | TargetFilter::Opponent | TargetFilter::SelfRef diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 094c63c33a..10c58150fa 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -8340,6 +8340,7 @@ fn rebind_controller_scope(filter: &mut TargetFilter, from: ControllerRef, to: C | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::ControllerAndControlledPermanents { .. } | TargetFilter::Opponent | TargetFilter::SelfRef diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 4afd96c86c..97880a37b0 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -1092,7 +1092,16 @@ pub(super) fn split_clause_sequence(text: &str) -> Vec { // attach tail is the sole exception: split at " and attach an // Equipment that was attached …" even inside quote mode. let allow_single_quote_attach_split = in_single_quote - && starts_attach_equipment_was_attached_clause(remainder_trimmed); + && (starts_attach_equipment_was_attached_clause(remainder_trimmed) + // CR 608.2c + CR 113.7a: `~'s controller` is a + // complete player predicate, so its chained action + // must reach the regular clause lowerer. Restrict + // this escape hatch to the exact typed source-owner + // subject; other possessives remain one clause. + || source_controller_predicate_has_action_tail( + ¤t, + remainder_trimmed, + )); if !in_single_quote || allow_single_quote_attach_split { // Suppress split when "and put" follows "from among" — the // "put into hand / onto battlefield" is part of the same @@ -2428,6 +2437,23 @@ pub(crate) fn starts_bare_and_clause(text: &str) -> bool { starts_bare_and_clause_lower(&lower) } +/// CR 608.2c + CR 113.7a: `~'s controller loses 2 life and you draw a card` +/// contains two actions. Card-name possessives otherwise keep quote mode to +/// protect object predicates such as "~'s controller sacrifices it and draws a +/// card"; admit only the self-source player predicate followed by a normal +/// imperative clause start. +fn source_controller_predicate_has_action_tail(current: &str, tail: &str) -> bool { + let lower = current.to_ascii_lowercase(); + let parsed = preceded( + take_until::<_, _, OracleError<'_>>("~'s controller "), + tag::<_, _, OracleError<'_>>("~'s controller "), + ) + .parse(lower.as_str()); + parsed.is_ok_and(|(rest, _)| { + tag::<_, _, OracleError<'_>>("loses ").parse(rest).is_ok() && starts_bare_and_clause(tail) + }) +} + fn starts_they_continuous_clause_lower(input: &str) -> OracleResult<'_, ()> { let (input, _) = tag("they ").parse(input)?; alt(( diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index 57cf3a51b6..3d1f847764 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -2878,6 +2878,36 @@ pub(super) fn parse_subject_application( is_optional: false, }); } + // CR 608.2c + CR 113.7a: "~'s controller" names the controller of the + // ability's source object, not the controller of the resolving ability. + // This matters when another player activates the source's ability (Xantcha, + // Sleeper Agent class). Keep it distinct from the anaphoric "its controller" + // branch below, which refers to a parent target. + if let Ok((after_head, _)) = + tag::<_, _, OracleError<'_>>("~'s controller may").parse(lower.as_str()) + { + if after_head.trim().is_empty() { + return Some(SubjectApplication { + affected: TargetFilter::SourceController, + target: None, + multi_target: None, + inherits_parent: false, + is_optional: true, + }); + } + } + if tag::<_, _, OracleError<'_>>("~'s controller") + .parse(lower.as_str()) + .is_ok_and(|(rest, _)| rest.trim().is_empty()) + { + return Some(SubjectApplication { + affected: TargetFilter::SourceController, + target: None, + multi_target: None, + inherits_parent: false, + is_optional: false, + }); + } // CR 608.2c + CR 608.2d: "its controller" / "their controller" as anaphoric // subject, optionally carrying a "may" modal ("its controller may search // their library" — Assassin's Trophy, Path to Exile, Oblation, etc.). When @@ -6518,6 +6548,10 @@ pub(crate) fn starts_with_subject_prefix(lower: &str) -> bool { alt(( value((), tag::<_, _, OracleError<'_>>("its owner ")), value((), tag("~'s owner ")), + // CR 608.2c + CR 113.7a: The source object's controller is a + // player subject, so it must enter the subject-predicate path + // before the following action is lowered. + value((), tag("~'s controller ")), // CR 115.1 + CR 109.1: "another target X" declares a target, and // the downstream Another property identifies an object distinct from // the source. Without this arm, an imperative predicate on an diff --git a/crates/engine/src/parser/oracle_static/restriction.rs b/crates/engine/src/parser/oracle_static/restriction.rs index 5d673aaa58..c87e4a7e1a 100644 --- a/crates/engine/src/parser/oracle_static/restriction.rs +++ b/crates/engine/src/parser/oracle_static/restriction.rs @@ -2321,6 +2321,7 @@ fn usable_disjunctive_permission_filter(filter: &TargetFilter) -> bool { | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::Opponent | TargetFilter::SelfRef | TargetFilter::GrantingObject diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index 75bf3826ec..6229134fe7 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -638,6 +638,48 @@ fn ability_word_labeled_activated_ability_parses_cost_effect_restriction() { ); } +/// CR 113.7a + CR 608.2h: "~'s controller" is the source object's +/// controller, not the controller of the resolving activated ability. +#[test] +fn source_controller_predicate_chains_with_ordinary_controller_effect() { + use crate::types::ability::QuantityExpr; + + let parsed = parse( + "{3}: ~'s controller loses 2 life and you draw a card. Any player may activate this ability.", + "Xantcha, Sleeper Agent", + &[], + &["Legendary", "Creature"], + &["Minion"], + ); + assert_eq!(parsed.abilities.len(), 1, "got {parsed:#?}"); + let ability = &parsed.abilities[0]; + assert!(matches!( + ability.effect.as_ref(), + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 2 }, + target: Some(TargetFilter::SourceController), + } + )); + assert!(matches!( + ability + .sub_ability + .as_deref() + .map(|next| next.effect.as_ref()), + Some(Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }) + )); + assert!( + !matches!(ability.effect.as_ref(), Effect::Unimplemented { .. }) + && ability + .sub_ability + .as_deref() + .is_none_or(|next| !matches!(next.effect.as_ref(), Effect::Unimplemented { .. })), + "the full activated body must be supported: {ability:#?}" + ); +} + /// CR 102.3 + CR 805.4a: the opponent-turn gate is the team-aware /// `IsOpponentsTurn` leaf, NOT `Not(IsYourTurn)` — the latter also admits a /// turn where a teammate holds `active_player`, which under shared team turns diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index cce1415deb..9b7f4e2fcc 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -5145,6 +5145,16 @@ pub enum TargetFilter { Any, Player, Controller, + /// CR 608.2h + CR 113.7a: The controller of this ability's source object. + /// + /// Unlike [`Self::Controller`], which is the controller of the resolving + /// ability (and therefore the activator for an activated ability), this + /// follows the source's exact incarnation. Triggered abilities use their + /// [`TriggerSourceContext`] live-or-LKI authority; other stack abilities + /// use their captured `source_incarnation` and the incarnation-keyed LKI + /// history. This keeps "~'s controller" from rebinding to a later object + /// that reuses the same storage id. + SourceController, /// CR 615 + CR 614.1a: Compound damage recipient "you and [type] permanents /// you control" (Comeuppance's "you and planeswalkers you control"; Channel /// Harm's "you and permanents you control"). A PARSE-LAYER recipient @@ -15099,6 +15109,10 @@ impl TargetFilter { | TargetFilter::SelfRef | TargetFilter::SourceOrPaired | TargetFilter::Controller + // CR 608.2h + CR 113.7a: "~'s controller" is resolved from + // the source's live-or-LKI incarnation, never chosen while + // announcing the ability. + | TargetFilter::SourceController | TargetFilter::OriginalController // CR 608.2c: the reanimator-Aura's pre-rebind source identity is // resolved (concretized to SpecificObject) during resolution, never diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index bedb6053ed..5ee9a358d4 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -307,6 +307,12 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { }; cmp_val(a0, b0) } + GameAction::ChooseEntryController { opponent: a0 } => { + let GameAction::ChooseEntryController { opponent: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } GameAction::OrderTriggers { order: a0 } => { let GameAction::OrderTriggers { order: b0 } = b else { unreachable!("cmp_payload: same-variant invariant"); diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index 1033bf53b7..6513cb966e 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -309,6 +309,11 @@ pub enum GameAction { ChooseReplacement { index: usize, }, + /// CR 614.12a: choose which eligible opponent controls an entering + /// permanent. This is distinct from CR 616 replacement ordering. + ChooseEntryController { + opponent: PlayerId, + }, /// CR 603.3b: Player submits the chosen order for their pending triggers. /// `order` is a permutation of indices into the `OrderTriggers.triggers` /// vec the player was prompted with; index 0 = first placed (bottom of @@ -1687,6 +1692,7 @@ impl GameAction { | GameAction::SelectTargets { .. } | GameAction::ChooseTarget { .. } | GameAction::ChooseReplacement { .. } + | GameAction::ChooseEntryController { .. } | GameAction::OrderTriggers { .. } | GameAction::CancelCast | GameAction::BackToManaPayment diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs index 1787333044..09230cc3a4 100644 --- a/crates/engine/src/types/events.rs +++ b/crates/engine/src/types/events.rs @@ -503,6 +503,7 @@ impl EventObjectSnapshot { // semantics for a nonsensical player-Connives subject rather than inventing one. TargetFilter::Player | TargetFilter::Controller + | TargetFilter::SourceController | TargetFilter::Opponent | TargetFilter::Owner | TargetFilter::AllPlayers diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 2549924e59..5d4a5910e9 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -10109,6 +10109,14 @@ pub enum WaitingFor { #[serde(default)] candidates: Vec, }, + /// CR 614.12a: choose the opponent that a permanent enters under before + /// the zone change is delivered. `candidates` is captured at replacement + /// application time; the pending replacement retains the exact proposed + /// event and replacement-applied set for the shared pipeline resume. + EntryControllerChoice { + player: PlayerId, + candidates: Vec, + }, /// CR 603.3b: When a player controls 2+ triggered abilities placed on the /// stack in the same pass, that player chooses the order. The variant is /// emitted in **choice order** (APNAP per CR 101.4 — active player chooses @@ -12125,6 +12133,7 @@ impl WaitingFor { WaitingFor::EnlistChoice { .. } => "EnlistChoice", WaitingFor::GameOver { .. } => "GameOver", WaitingFor::ReplacementChoice { .. } => "ReplacementChoice", + WaitingFor::EntryControllerChoice { .. } => "EntryControllerChoice", WaitingFor::OrderTriggers { .. } => "OrderTriggers", WaitingFor::CopyTargetChoice { .. } => "CopyTargetChoice", WaitingFor::ExploreChoice { .. } => "ExploreChoice", @@ -12276,6 +12285,7 @@ impl WaitingFor { | WaitingFor::ExertChoice { player, .. } | WaitingFor::EnlistChoice { player, .. } | WaitingFor::ReplacementChoice { player, .. } + | WaitingFor::EntryControllerChoice { player, .. } | WaitingFor::OrderTriggers { player, .. } | WaitingFor::CopyTargetChoice { player, .. } | WaitingFor::ExploreChoice { player, .. } diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index b26e041cd2..e7d2e44bd8 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -512,6 +512,7 @@ pub enum InteractionActionCode { SelectTargets, ChooseTarget, ChooseReplacement, + ChooseEntryController, OrderTriggers, CancelCast, Equip, diff --git a/crates/engine/src/types/proposed_event.rs b/crates/engine/src/types/proposed_event.rs index 26015a9882..902bda9937 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -81,17 +81,44 @@ pub struct BoundSearchFoundCandidate { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] #[serde(tag = "type")] pub enum AppliedReplacementKey { - Object { source: ObjectId, index: usize }, - Floating { index: usize }, - StepEndMana { index: usize }, + Object { + source: ObjectId, + index: usize, + }, + Floating { + index: usize, + }, + StepEndMana { + index: usize, + }, + /// CR 614.12a: The selected controller for an as-enters replacement. + /// This rides the event's existing replacement provenance so the selected + /// answer remains distinguishable from an originating controller override. + EntryControllerChoice { + source: ObjectId, + index: usize, + controller: PlayerId, + }, } #[derive(Debug, Clone, Copy, Deserialize)] #[serde(tag = "type")] enum TaggedAppliedReplacementKey { - Object { source: ObjectId, index: usize }, - Floating { index: usize }, - StepEndMana { index: usize }, + Object { + source: ObjectId, + index: usize, + }, + Floating { + index: usize, + }, + StepEndMana { + index: usize, + }, + EntryControllerChoice { + source: ObjectId, + index: usize, + controller: PlayerId, + }, } #[derive(Debug, Clone, Copy, Deserialize)] @@ -120,6 +147,17 @@ impl AppliedReplacementKeyCompat { AppliedReplacementKeyCompat::Tagged(TaggedAppliedReplacementKey::StepEndMana { index, }) => AppliedReplacementKey::StepEndMana { index }, + AppliedReplacementKeyCompat::Tagged( + TaggedAppliedReplacementKey::EntryControllerChoice { + source, + index, + controller, + }, + ) => AppliedReplacementKey::EntryControllerChoice { + source, + index, + controller, + }, AppliedReplacementKeyCompat::Legacy(ReplacementId { source: ObjectId(0), index, @@ -159,7 +197,8 @@ impl AppliedReplacementKey { pub fn source(self) -> ObjectId { match self { - AppliedReplacementKey::Object { source, .. } => source, + AppliedReplacementKey::Object { source, .. } + | AppliedReplacementKey::EntryControllerChoice { source, .. } => source, AppliedReplacementKey::Floating { .. } | AppliedReplacementKey::StepEndMana { .. } => { ObjectId(0) } @@ -170,7 +209,8 @@ impl AppliedReplacementKey { match self { AppliedReplacementKey::Object { index, .. } | AppliedReplacementKey::Floating { index } - | AppliedReplacementKey::StepEndMana { index } => index, + | AppliedReplacementKey::StepEndMana { index } + | AppliedReplacementKey::EntryControllerChoice { index, .. } => index, } } diff --git a/crates/engine/tests/integration/issue_6916_xantcha_entry_controller.rs b/crates/engine/tests/integration/issue_6916_xantcha_entry_controller.rs new file mode 100644 index 0000000000..a73f9ac52e --- /dev/null +++ b/crates/engine/tests/integration/issue_6916_xantcha_entry_controller.rs @@ -0,0 +1,128 @@ +//! Xantcha regression (#6916): its mandatory self-entry controller choice is +//! made before battlefield delivery, not by seat-order fallback or post-entry +//! control-changing effect. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::game::zones::move_to_zone; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::mana::{ManaColor, ManaCost}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; +use engine::types::PlayerId; + +const XANTCHA_ORACLE: &str = "Xantcha enters under the control of an opponent of your choice.\nXantcha attacks each combat if able and can't attack its owner or planeswalkers its owner controls.\n{3}: Xantcha's controller loses 2 life and you draw a card. Any player may activate this ability."; +const P2: PlayerId = PlayerId(2); + +#[test] +fn xantcha_chooses_entry_controller_before_battlefield_delivery() { + let mut scenario = GameScenario::new_n_player(3, 0x6916); + scenario.at_phase(Phase::PreCombatMain); + for _ in 0..6 { + scenario.add_basic_land(P1, ManaColor::Red); + } + scenario.with_library_top( + P1, + &["Xantcha Activation Draw One", "Xantcha Activation Draw Two"], + ); + let xantcha = scenario + .add_creature_to_hand_from_oracle(P0, "Xantcha, Sleeper Agent", 5, 5, XANTCHA_ORACLE) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(xantcha).resolve(); + let WaitingFor::EntryControllerChoice { player, candidates } = outcome.final_waiting_for() + else { + panic!( + "Xantcha must choose an entry controller, got {:?}", + outcome.final_waiting_for() + ); + }; + assert_eq!(*player, P0); + assert_eq!(candidates, &[P1, P2]); + assert_eq!( + runner.state().objects[&xantcha].zone, + Zone::Stack, + "the entrant must remain out of the battlefield until the choice resolves" + ); + + runner + .act(GameAction::ChooseEntryController { opponent: P2 }) + .expect("the offered opponent is a legal entry controller"); + runner.advance_until_stack_empty(); + + let xantcha_state = &runner.state().objects[&xantcha]; + assert_eq!(xantcha_state.zone, Zone::Battlefield); + assert_eq!(xantcha_state.controller, P2); + + // CR 113.7a: P1 activates, but "Xantcha's controller" is the source's + // current controller P2; the ordinary "you draw" still belongs to P1. + runner.state_mut().active_player = P1; + runner.state_mut().priority_player = P1; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P1 }; + let p1_hand_before = runner.state().players[P1.0 as usize].hand.len(); + let p2_life_before = runner.state().players[P2.0 as usize].life; + runner + .act(GameAction::ActivateAbility { + source_id: xantcha, + ability_index: 0, + }) + .expect("P1 may activate Xantcha from priority"); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().players[P2.0 as usize].life, + p2_life_before - 2 + ); + assert_eq!( + runner.state().players[P1.0 as usize].hand.len(), + p1_hand_before + 1, + "the activation's ordinary controller-relative draw remains the activator" + ); + + // CR 608.2h + CR 113.7a: the activation's source leaves and returns under + // a new incarnation before resolution. Its "~'s controller" reference + // must use P2's exact activation-time LKI, not the same id's new P0 + // incarnation; the ordinary "you draw" remains the P1 activator. + runner.state_mut().active_player = P1; + runner.state_mut().priority_player = P1; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P1 }; + let p1_hand_before_lki = runner.state().players[P1.0 as usize].hand.len(); + let p2_life_before_lki = runner.state().players[P2.0 as usize].life; + runner + .act(GameAction::ActivateAbility { + source_id: xantcha, + ability_index: 0, + }) + .expect("Xantcha activation must reach the stack before priority passes"); + + let mut move_events = Vec::new(); + move_to_zone(runner.state_mut(), xantcha, Zone::Exile, &mut move_events); + move_to_zone( + runner.state_mut(), + xantcha, + Zone::Battlefield, + &mut move_events, + ); + let returned = runner + .state_mut() + .objects + .get_mut(&xantcha) + .expect("Xantcha returns as a new object"); + returned.base_controller = Some(P0); + returned.controller = P0; + assert_eq!(runner.state().objects[&xantcha].controller, P0); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().players[P2.0 as usize].life, + p2_life_before_lki - 2, + "the activated source's departed P2 incarnation remains authoritative" + ); + assert_eq!( + runner.state().players[P1.0 as usize].hand.len(), + p1_hand_before_lki + 1, + "the second activation's ordinary controller-relative draw remains P1's" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index bad7638943..8e86b7e7b2 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -701,6 +701,7 @@ mod issue_688_mind_into_matter; mod issue_689_resonating_lute_hand_size; mod issue_6908_kozilek_discard_mana_value; mod issue_6913_eagle_vision_freerunning; +mod issue_6916_xantcha_entry_controller; mod issue_691_sheoldred_saga_lore; mod issue_6943_faerie_slumber_party; mod issue_6979_land_mana_amplification; diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index 117389d438..7f5ac20826 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -674,7 +674,7 @@ pub fn unsupported_protocol_capabilities() -> &'static [UnsupportedCapability] { /// `upstream.` = the protocol has no primitive for something the engine can do. /// `local.` = the protocol has the primitive but this engine cannot source it, /// or a documented adapter-local extension is intentionally in use. -static UNSUPPORTED_PROTOCOL_CAPABILITIES: [UnsupportedCapability; 87] = [ +static UNSUPPORTED_PROTOCOL_CAPABILITIES: [UnsupportedCapability; 88] = [ UnsupportedCapability { code: "upstream.object-selection-missing", area: "prompts", @@ -777,6 +777,12 @@ static UNSUPPORTED_PROTOCOL_CAPABILITIES: [UnsupportedCapability; 87] = [ reason: "The pinned protocol has no response shape for choosing the player, planeswalker, or battle attacked by an entering creature.", suggested_protocol_extension: "Add an entry-attack destination choice using the existing attack-target reference shape.", }, + UnsupportedCapability { + code: "local.entry-controller-choice-unsupported", + area: "prompts", + reason: "CR 614.12a requires an as-enters controller choice before battlefield delivery. The pinned protocol has no non-target opponent-picker prompt for that pre-entry decision.", + suggested_protocol_extension: "Add a non-target entry-controller choice carrying eligible opponent player ids.", + }, UnsupportedCapability { code: "local.zone-opponent-chooser-unsupported", area: "prompts", @@ -2588,6 +2594,9 @@ pub fn convert_available_action( GameAction::ChooseEntryAttackTarget { .. } => { AvailableActionConversion::Unsupported("local.entry-attack-target-choice-unsupported") } + GameAction::ChooseEntryController { .. } => { + AvailableActionConversion::Unsupported("local.entry-controller-choice-unsupported") + } GameAction::ChooseClashOpponent { .. } => { AvailableActionConversion::Unsupported("local.clash-unsupported") } @@ -8090,6 +8099,16 @@ mod tests { ), AvailableActionConversion::Unsupported("local.announcing-opponent-unsupported") )); + assert!(matches!( + convert_available_action( + &empty_state(), + &GameAction::ChooseEntryController { + opponent: PlayerId(1), + }, + "action-2".to_string(), + ), + AvailableActionConversion::Unsupported("local.entry-controller-choice-unsupported") + )); } #[test] @@ -8138,13 +8157,13 @@ mod tests { #[test] fn unsupported_capability_registry_is_well_formed() { let capabilities = unsupported_protocol_capabilities(); - assert_eq!(capabilities.len(), 87); + assert_eq!(capabilities.len(), 88); let codes: HashSet<_> = capabilities .iter() .map(|capability| capability.code) .collect(); - assert_eq!(codes.len(), 87, "capability codes must be unique"); + assert_eq!(codes.len(), 88, "capability codes must be unique"); for capability in capabilities { assert!( diff --git a/crates/mtgish-import/src/convert/condition.rs b/crates/mtgish-import/src/convert/condition.rs index b20bcbdc90..226b6bf655 100644 --- a/crates/mtgish-import/src/convert/condition.rs +++ b/crates/mtgish-import/src/convert/condition.rs @@ -1061,6 +1061,7 @@ fn target_filter_variant_name(f: &TargetFilter) -> &'static str { TargetFilter::Player => "Player", TargetFilter::AllPlayers => "AllPlayers", TargetFilter::Controller => "Controller", + TargetFilter::SourceController => "SourceController", TargetFilter::Opponent => "Opponent", TargetFilter::OriginalController => "OriginalController", TargetFilter::OriginalSource => "OriginalSource", diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index 85f941cbdc..dfdf41dbba 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -203,7 +203,8 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { | WaitingFor::LoopShortcut { .. } | WaitingFor::RespondToShortcut { .. } | WaitingFor::PrecastCopyShortcutOffer { .. } - | WaitingFor::RespondToPrecastCopyShortcut { .. } => DecisionKind::ActivateAbility, + | WaitingFor::RespondToPrecastCopyShortcut { .. } + | WaitingFor::EntryControllerChoice { .. } => DecisionKind::ActivateAbility, } } diff --git a/crates/phase-ai/src/policies/discard_payoff.rs b/crates/phase-ai/src/policies/discard_payoff.rs index 8e409884a7..c213f9199b 100644 --- a/crates/phase-ai/src/policies/discard_payoff.rs +++ b/crates/phase-ai/src/policies/discard_payoff.rs @@ -210,6 +210,7 @@ fn candidate_discards_controller(ctx: &PolicyContext<'_>) -> bool { | GameAction::SelectTargets { .. } | GameAction::ChooseTarget { .. } | GameAction::ChooseReplacement { .. } + | GameAction::ChooseEntryController { .. } | GameAction::OrderTriggers { .. } | GameAction::CancelCast | GameAction::Equip { .. } diff --git a/crates/phase-ai/src/policies/draw_payoff.rs b/crates/phase-ai/src/policies/draw_payoff.rs index bd3c2d682a..a769b7b4b7 100644 --- a/crates/phase-ai/src/policies/draw_payoff.rs +++ b/crates/phase-ai/src/policies/draw_payoff.rs @@ -237,6 +237,7 @@ fn candidate_draws_structurally(ctx: &PolicyContext<'_>) -> bool { | GameAction::SelectTargets { .. } | GameAction::ChooseTarget { .. } | GameAction::ChooseReplacement { .. } + | GameAction::ChooseEntryController { .. } | GameAction::OrderTriggers { .. } | GameAction::CancelCast | GameAction::Equip { .. } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 15da7f6e85..ff0b940332 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1537,6 +1537,10 @@ pub fn fallback_action( // Replacement choice: pick the first option. WaitingFor::ReplacementChoice { .. } => Some(GameAction::ChooseReplacement { index: 0 }), + WaitingFor::EntryControllerChoice { candidates, .. } => candidates + .first() + .copied() + .map(|opponent| GameAction::ChooseEntryController { opponent }), // Trigger order: keep the engine-provided order. WaitingFor::OrderTriggers { triggers, .. } => Some(GameAction::OrderTriggers { diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 1db7aee593..6259c1337e 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -645,6 +645,7 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { | GameAction::UnspendPoolMana { .. } | GameAction::ChooseTarget { .. } | GameAction::ChooseReplacement { .. } + | GameAction::ChooseEntryController { .. } | GameAction::CancelCast | GameAction::Equip { .. } | GameAction::ActivateStation { .. }