diff --git a/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts b/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts index f783bdaf92..5a4bd0464d 100644 --- a/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts +++ b/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts @@ -11,20 +11,17 @@ import { repoRoot, rustEnumVariants } from "./rustEnumVariants"; * Engine `WaitingFor` variants that are never surfaced to a human and so * legitimately have no frontend UI handler. * - * This set is intentionally EMPTY. An audit of `WaitingFor::acting_player()` - * (crates/engine/src/types/game_state.rs) shows every variant routes its - * authorization to a human player — single-pending mulligan variants resolve - * to the one pending player, `VoteChoice` to `actor.resolve(player)`, - * `AssistPayment` to `chosen`, and every remaining variant to `Some(*player)`. - * The sole exception is `GameOver`, which returns `None` (terminal lifecycle - * state) and is already present in `HANDLED_WAITING_FOR_TYPES`. No - * internal-only, never-player-facing variant exists today. + * `ResolveAllReady` is deliberately inert: `WaitingFor::acting_player()` in + * `crates/engine/src/types/game_state.rs` returns `None` for it, and the + * consent submitter consumes the already-authorized run through + * `dispatchResolveAll`. `GameOver` likewise returns `None` but remains in + * `HANDLED_WAITING_FOR_TYPES` because it has a rendered terminal screen. * * If a future variant is genuinely never player-facing, add it here WITH a * cited `acting_player()` reference proving it returns `None`. */ const INTERNAL_NEVER_PLAYER_FACING: ReadonlySet = - new Set([]); + new Set(["ResolveAllReady"]); describe("WaitingFor handler parity", () => { it("registers both interactive meld waiting states", () => { diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 8495461144..f7f8474869 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1692,6 +1692,8 @@ export type MulliganDecisionPhase = export type WaitingFor = | { type: "Priority"; data: { player: PlayerId } } + | { type: "ResolveAllConsent"; data: { epoch: number; representative: PlayerId } } + | { type: "ResolveAllReady"; data: { epoch: number } } | { type: "MeldPairChoice"; data: { player: PlayerId; choices: MeldSelection[] } } | { type: "MeldAttackTargetChoice"; data: { player: PlayerId; context: MeldSelection; valid_targets: AttackTarget[] } } | { type: "EntryAttackTargetChoice"; data: { player: PlayerId; object_id: ObjectId; valid_targets: AttackTarget[] } } @@ -2247,6 +2249,12 @@ export type PrecastCopyShortcutResponse = export type GameAction = | { type: "PassPriority" } + | { type: "BeginResolveAll"; data: { max_resolutions: number } } + | { + type: "RespondResolveAllConsent"; + data: { epoch: number; decision: { type: "Grant" } | { type: "Decline" } }; + } + | { type: "RevokeResolveAllConsent"; data: { epoch: number; representative: PlayerId } } | { type: "ChooseMeldPair"; data: { source_id: ObjectId; partner_id: ObjectId } } | { type: "ChooseEntryAttackTarget"; data: { target: AttackTarget } } | { type: "RollPlanarDie" } diff --git a/client/src/components/modal/ResolveAllConsentModal.tsx b/client/src/components/modal/ResolveAllConsentModal.tsx new file mode 100644 index 0000000000..bab7be0cc7 --- /dev/null +++ b/client/src/components/modal/ResolveAllConsentModal.tsx @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import { dispatchResolveAll } from "../../game/dispatch.ts"; +import { useGameDispatch } from "../../hooks/useGameDispatch.ts"; +import { useGameStore } from "../../stores/gameStore.ts"; +import { DialogShell } from "./DialogShell.tsx"; + +/** + * Engine-authored Resolve All consent prompt. The browser only presents the + * representative and echoes a finite Grant/Decline action; authorization and + * the subsequent safe prefix stay entirely in the engine. + */ +export function ResolveAllConsentModal({ playerId }: { playerId: number }) { + const { t } = useTranslation("game"); + const waitingFor = useGameStore((s) => s.waitingFor); + const dispatch = useGameDispatch(); + + const visible = + waitingFor?.type === "ResolveAllConsent" && + waitingFor.data.representative === playerId; + + const respond = useCallback( + async (decision: "Grant" | "Decline") => { + if (waitingFor?.type !== "ResolveAllConsent") return; + const grantedEpoch = waitingFor.data.epoch; + await dispatch({ + type: "RespondResolveAllConsent", + data: { epoch: grantedEpoch, decision: { type: decision } }, + }); + const waitingForAfterSubmission = useGameStore.getState().gameState?.waiting_for; + if ( + decision === "Grant" && + waitingForAfterSubmission?.type === "ResolveAllReady" && + waitingForAfterSubmission.data.epoch === grantedEpoch + ) { + await dispatchResolveAll(playerId, []); + } + }, + [dispatch, playerId, waitingFor], + ); + + if (!visible) return null; + + return ( + +
+ + +
+
+ ); +} diff --git a/client/src/game/__tests__/dispatchResolveAll.test.ts b/client/src/game/__tests__/dispatchResolveAll.test.ts index fe364ff8a0..4e48a6a67a 100644 --- a/client/src/game/__tests__/dispatchResolveAll.test.ts +++ b/client/src/game/__tests__/dispatchResolveAll.test.ts @@ -18,6 +18,13 @@ function stateWithStack(len: number): GameState { }); } +function readyStateWithStack(len: number): GameState { + return buildGameState({ + waiting_for: { type: "ResolveAllReady", data: { epoch: 1 } }, + stack: Array.from({ length: len }, (_, index) => buildStackEntry({ id: index + 1 })), + }); +} + function chunk(itemsResolved: number, total: number): BatchResolveResult { return { events: [], waitingFor: priorityWf, logEntries: [], itemsResolved, total }; } @@ -43,10 +50,10 @@ describe("dispatchResolveAll progress", () => { progressCalls = []; usePreferencesStore.setState({ animationSpeedMultiplier: 1.0 }); useAppNotificationStore.setState({ notification: null, expiresAt: 0 }); - // Stack length read at each iteration start to classify pressure; keep it - // in the "Instant" band (>=100) so the rAF-yield branch is exercised. + // Keep the stack in the Instant pressure band so the ready consumer uses + // the engine's larger bounded-prefix cap. useGameStore.setState({ - gameState: stateWithStack(200), + gameState: readyStateWithStack(200), resolutionProgress: null, isResolvingAll: false, // Capture every setResolutionProgress call for assertions. @@ -61,21 +68,12 @@ describe("dispatchResolveAll progress", () => { vi.restoreAllMocks(); }); - it("latches the first chunk's total, accumulates + clamps the numerator, and clears at the end", async () => { - // Per-chunk `total` SHRINKS (engine reports remaining stack); the latch must - // keep the first chunk's 200. itemsResolved sums 80+80+80=240 > 200 → clamp. - const resolveAll = vi - .fn() - .mockResolvedValueOnce(chunk(80, 200)) - .mockResolvedValueOnce(chunk(80, 150)) - .mockResolvedValueOnce(chunk(80, 100)); + it("reports the engine-proved prefix once and clears progress at the end", async () => { + const resolveAll = vi.fn().mockResolvedValueOnce(chunk(80, 200)); - // getState reports the board after each chunk; the 3rd empties the stack → done. - const getState = vi - .fn<() => Promise>() - .mockResolvedValueOnce(stateWithStack(200)) - .mockResolvedValueOnce(stateWithStack(200)) - .mockResolvedValueOnce(stateWithStack(0)); + // The engine resolves the entire proved prefix in one bounded call, then + // supplies one authoritative post-prefix snapshot. + const getState = vi.fn<() => Promise>().mockResolvedValueOnce(stateWithStack(120)); const rafSpy = vi .spyOn(globalThis, "requestAnimationFrame") @@ -97,25 +95,18 @@ describe("dispatchResolveAll progress", () => { // to the SetAutoPass fallback instead of the batch drain under test. await dispatchResolveAll(0, [{ playerId: 1, difficulty: "Medium" }]); - // Three progress updates: total latched at 200 throughout; resolved - // accumulates 80 -> 160 -> clamped 200. - expect(progressCalls.slice(0, 3)).toEqual([ - { resolved: 80, total: 200 }, - { resolved: 160, total: 200 }, - { resolved: 200, total: 200 }, // min(240, 200) clamp - ]); + expect(resolveAll).toHaveBeenCalledTimes(1); + expect(progressCalls).toEqual([{ resolved: 80, total: 200 }, null]); // Final call clears progress. expect(progressCalls[progressCalls.length - 1]).toBeNull(); expect(useGameStore.getState().resolutionProgress).toBeNull(); expect(useGameStore.getState().isResolvingAll).toBe(false); - // rAF yield fired between the instant chunks (the load-bearing repaint fix): - // 2 yields between 3 chunks. - expect(rafSpy).toHaveBeenCalledTimes(2); + expect(rafSpy).not.toHaveBeenCalled(); }); it("uses responsive instant chunks for giant stacks and marks Resolve All busy", async () => { - useGameStore.setState({ gameState: stateWithStack(19192) }); + useGameStore.setState({ gameState: readyStateWithStack(19192) }); const resolveAll = vi.fn(async (_requester, _aiSeats, maxResolutions) => { expect(useGameStore.getState().isResolvingAll).toBe(true); @@ -169,12 +160,79 @@ describe("dispatchResolveAll progress", () => { ); }); + it("consumes Ready consent before considering the empty-AI fallback", async () => { + const resolveAll = vi.fn().mockResolvedValue(chunk(1, 2)); + const submitAction = vi.fn(); + const getState = vi.fn().mockResolvedValue(stateWithStack(1)); + useGameStore.setState({ + gameState: readyStateWithStack(2), + adapter: { + resolveAll, + submitAction, + getState, + getLegalActions: vi.fn().mockResolvedValue({ actions: [], autoPassRecommended: false }), + getSnapshot: snapshotVia(getState), + } as never, + }); + + await dispatchResolveAll(0, []); + + expect(resolveAll).toHaveBeenCalledWith(0, [], 5); + expect(submitAction).not.toHaveBeenCalled(); + }); + + it("begins consent before the batch drain and retains its AI seats until Ready", async () => { + const seats = [{ playerId: 1, difficulty: "Medium" }]; + const phaseOneResolveAll = vi.fn(); + const submitAction = vi.fn().mockResolvedValue({ events: [] }); + const consent = buildGameState({ + waiting_for: { type: "ResolveAllConsent", data: { epoch: 1, representative: 1 } }, + stack: Array.from({ length: 2 }, (_, index) => buildStackEntry({ id: index + 1 })), + }); + useGameStore.setState({ + gameState: stateWithStack(2), + adapter: { + resolveAll: phaseOneResolveAll, + submitAction, + getSnapshot: vi.fn(async () => ({ + state: consent, + legalResult: { actions: [], autoPassRecommended: false }, + seq: nextSnapshotSeq(), + })), + } as never, + }); + + await dispatchResolveAll(0, seats); + + expect(submitAction).toHaveBeenCalledWith( + { type: "BeginResolveAll", data: { max_resolutions: 5 } }, + 0, + ); + expect(phaseOneResolveAll).not.toHaveBeenCalled(); + + const resolveAll = vi.fn().mockResolvedValue(chunk(1, 2)); + const getState = vi.fn().mockResolvedValue(stateWithStack(1)); + useGameStore.setState({ + gameState: readyStateWithStack(2), + adapter: { + resolveAll, + getState, + getLegalActions: vi.fn().mockResolvedValue({ actions: [], autoPassRecommended: false }), + getSnapshot: snapshotVia(getState), + } as never, + }); + + await dispatchResolveAll(0, []); + + expect(resolveAll).toHaveBeenCalledWith(0, seats, 5); + }); + it("uses an empty AI-seat list when the adapter delegates native AI ownership to its server", async () => { const resolveAll = vi.fn().mockResolvedValue(chunk(0, 2)); const getState = vi.fn().mockResolvedValue(stateWithStack(0)); const submitAction = vi.fn(); useGameStore.setState({ - gameState: stateWithStack(2), + gameState: readyStateWithStack(2), adapter: { resolveAll, resolveAllUsesServerAi: true, @@ -203,7 +261,7 @@ describe("dispatchResolveAll progress", () => { ); const getState = vi.fn().mockResolvedValue(stateWithStack(2)); useGameStore.setState({ - gameState: stateWithStack(2), + gameState: readyStateWithStack(2), adapter: { resolveAll, resolveAllUsesServerAi: true, @@ -226,7 +284,7 @@ describe("dispatchResolveAll progress", () => { .mockRejectedValue(new Error("batch snapshot rejected")); const getState = vi.fn().mockResolvedValue(stateWithStack(2)); useGameStore.setState({ - gameState: stateWithStack(2), + gameState: readyStateWithStack(2), adapter: { resolveAll, resolveAllUsesServerAi: true, diff --git a/client/src/game/controllers/__tests__/aiController.test.ts b/client/src/game/controllers/__tests__/aiController.test.ts index e31af29724..498bed00e6 100644 --- a/client/src/game/controllers/__tests__/aiController.test.ts +++ b/client/src/game/controllers/__tests__/aiController.test.ts @@ -14,14 +14,20 @@ const dispatchMocks = vi.hoisted(() => ({ dispatchAiActionProposal: vi.fn< (proposal: AiActionProposal) => Promise<{ status: "applied" | "stale" }> >(), + dispatchResolveAll: vi.fn< + (requester: number, seats: { playerId: number; difficulty: string }[]) => Promise + >(), })); -const { dispatchAiActionProposal } = dispatchMocks; +const { dispatchAiActionProposal, dispatchResolveAll } = dispatchMocks; const notifyEngineLost = vi.fn(); const attemptStateRehydrate = vi.fn(async () => false); const isEnginePanic = vi.fn<(error: unknown) => boolean>(() => false); const routePanic = vi.fn<(reason: string, panic?: string) => Promise>(async () => {}); -vi.mock("../../dispatch", () => ({ dispatchAiActionProposal: dispatchMocks.dispatchAiActionProposal })); +vi.mock("../../dispatch", () => ({ + dispatchAiActionProposal: dispatchMocks.dispatchAiActionProposal, + dispatchResolveAll: dispatchMocks.dispatchResolveAll, +})); vi.mock("../../engineRecovery", () => ({ attemptStateRehydrate: () => attemptStateRehydrate(), isEnginePanic: (error: unknown) => isEnginePanic(error), @@ -88,6 +94,8 @@ beforeEach(() => { // of nested retries to fit inside that same window. randomSpy = vi.spyOn(Math, "random").mockReturnValue(1); dispatchAiActionProposal.mockReset(); + dispatchResolveAll.mockReset(); + dispatchResolveAll.mockResolvedValue(undefined); notifyEngineLost.mockReset(); attemptStateRehydrate.mockReset(); attemptStateRehydrate.mockResolvedValue(false); @@ -250,6 +258,204 @@ describe("AI proposal controller", () => { controller.dispose(); }); + it("starts Resolve All with the engine-issued actor after an AI representative grants consent", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const ready = { type: "ResolveAllReady", data: { epoch: 7 } } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + storeState.gameState = state; + storeState.waitingFor = consent; + const issued: AiActionProposal = { + token: "engine-bound-consent", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Grant" } }, + }, + }; + const getAiActionProposal = vi.fn(async () => issued); + storeState.adapter = { getAiActionProposal }; + dispatchAiActionProposal.mockImplementation(async () => { + storeState.gameState = { ...state, waiting_for: ready }; + storeState.waitingFor = ready; + storeSubscriber?.(); + return { status: "applied" }; + }); + + const seats = [{ playerId: 1, difficulty: "Medium" }]; + const controller = createAIController({ seats }); + controller.start(); + await runOnce(); + + expect(getAiActionProposal).toHaveBeenCalledWith("Medium", 1); + expect(dispatchAiActionProposal).toHaveBeenCalledWith(issued); + expect(dispatchResolveAll).toHaveBeenCalledWith(0, seats); + controller.dispose(); + }); + + it("does not start Resolve All after an AI representative declines consent", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + const issued: AiActionProposal = { + token: "engine-bound-consent-decline", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Decline" } }, + }, + }; + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { getAiActionProposal: vi.fn(async () => issued) }; + dispatchAiActionProposal.mockResolvedValue({ status: "applied" }); + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + expect(dispatchAiActionProposal).toHaveBeenCalledWith(issued); + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + + it("does not act when the local human is the Resolve All consent representative", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 0 }, + } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 1, stack: [] }); + const getAiActionProposal = vi.fn(async () => proposal(PASS)); + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { getAiActionProposal }; + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + expect(getAiActionProposal).not.toHaveBeenCalled(); + expect(dispatchAiActionProposal).not.toHaveBeenCalled(); + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + + it("does not start Resolve All when the Ready epoch differs from the submitted consent", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const ready = { type: "ResolveAllReady", data: { epoch: 8 } } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { + getAiActionProposal: vi.fn<() => Promise>(async () => ({ + token: "engine-bound-consent", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Grant" } }, + }, + })), + }; + dispatchAiActionProposal.mockImplementation(async () => { + storeState.gameState = { ...state, waiting_for: ready }; + storeState.waitingFor = ready; + storeSubscriber?.(); + return { status: "applied" }; + }); + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + + it("does not start Resolve All for a stale consent submission even if Ready coincides", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const ready = { type: "ResolveAllReady", data: { epoch: 7 } } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { + getAiActionProposal: vi.fn<() => Promise>(async () => ({ + token: "engine-bound-consent", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Grant" } }, + }, + })), + }; + dispatchAiActionProposal.mockImplementation(async () => { + storeState.gameState = { ...state, waiting_for: ready }; + storeState.waitingFor = ready; + storeSubscriber?.(); + return { status: "stale" }; + }); + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + + it("does not start Resolve All after a pending consent submission is superseded by a new session", async () => { + const consent = { + type: "ResolveAllConsent", + data: { epoch: 7, representative: 1 }, + } as WaitingFor; + const ready = { type: "ResolveAllReady", data: { epoch: 7 } } as WaitingFor; + const state = buildGameState({ waiting_for: consent, priority_player: 0, stack: [] }); + const pendingSubmission = deferred<{ status: "applied" | "stale" }>(); + storeState.gameState = state; + storeState.waitingFor = consent; + storeState.adapter = { + getAiActionProposal: vi.fn<() => Promise>(async () => ({ + token: "engine-bound-consent", + semanticOwner: 1, + actor: 0, + action: { + type: "RespondResolveAllConsent", + data: { epoch: 7, decision: { type: "Grant" } }, + }, + })), + }; + dispatchAiActionProposal.mockReturnValue(pendingSubmission.promise); + + const controller = createAIController({ seats: [{ playerId: 1, difficulty: "Medium" }] }); + controller.start(); + await runOnce(); + + storeState.gameSessionGeneration += 1; + storeSubscriber?.(); + storeState.gameState = { ...state, waiting_for: ready }; + storeState.waitingFor = ready; + storeSubscriber?.(); + pendingSubmission.resolve({ status: "applied" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(dispatchResolveAll).not.toHaveBeenCalled(); + controller.dispose(); + }); + it("does not retry state recovery after a newer game session supersedes the attempt", async () => { const pendingFailure = deferred(); const getAiActionProposal = vi diff --git a/client/src/game/controllers/aiController.ts b/client/src/game/controllers/aiController.ts index 6a5b0a18df..25458710dc 100644 --- a/client/src/game/controllers/aiController.ts +++ b/client/src/game/controllers/aiController.ts @@ -5,7 +5,7 @@ import { AdapterError, AdapterErrorCode } from "../../adapter/types"; import { pressureMultiplier } from "../../utils/stackPressure"; import { effectiveStackPressure } from "../../utils/stackThroughput"; import { debugLog } from "../debugLog"; -import { dispatchAiActionProposal } from "../dispatch"; +import { dispatchAiActionProposal, dispatchResolveAll } from "../dispatch"; import { attemptStateRehydrate, isEnginePanic, notifyEngineLost, routePanic } from "../engineRecovery"; import type { OpponentController } from "./types"; @@ -83,6 +83,7 @@ export function createAIController(config: AIControllerConfig): AIController { waitingForFingerprint: string; playerId: number; isPriority: boolean; + resolveAllConsentEpoch: number | null; } let currentAttempt: AIAttempt | null = null; @@ -154,6 +155,11 @@ export function createAIController(config: AIControllerConfig): AIController { ) { return null; } + if (waitingFor.type === "ResolveAllConsent") { + return waitingFor.data.representative === PLAYER_ID + ? null + : waitingFor.data.representative; + } if ( !("data" in waitingFor) || !waitingFor.data || @@ -174,6 +180,8 @@ export function createAIController(config: AIControllerConfig): AIController { waitingForFingerprint: waitingForFingerprint(waitingFor), playerId, isPriority: waitingFor.type === "Priority", + resolveAllConsentEpoch: + waitingFor.type === "ResolveAllConsent" ? waitingFor.data.epoch : null, }; currentAttempt = attempt; pending = true; @@ -390,6 +398,21 @@ export function createAIController(config: AIControllerConfig): AIController { // the authority boundary. if (!isAttemptCurrent(attempt)) return; const submission = await dispatchAiActionProposal(proposal); + const store = useGameStore.getState(); + const waitingForAfterSubmission = store.gameState?.waiting_for ?? null; + if ( + active && + submission.status === "applied" && + attempt.resolveAllConsentEpoch !== null && + proposal.action.type === "RespondResolveAllConsent" && + proposal.action.data.decision.type === "Grant" && + proposal.action.data.epoch === attempt.resolveAllConsentEpoch && + store.gameSessionGeneration === attempt.gameSessionGeneration && + waitingForAfterSubmission?.type === "ResolveAllReady" && + waitingForAfterSubmission.data.epoch === attempt.resolveAllConsentEpoch + ) { + void dispatchResolveAll(proposal.actor, config.seats); + } if (!isAttemptCurrent(attempt)) return; // The proposal boundary returns a tagged stale result without mutating // the store. That is a normal race, not a failed AI decision: leave diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index 74b272d087..1da1567d7d 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -966,8 +966,8 @@ const BATCH_CHUNK_SIZE = 5; // thread responsive, while this still lets the overlay update during truly // pathological stacks. const BATCH_CHUNK_INSTANT = 5_000; -const BATCH_CHUNK_BASE_DELAY_MS = 150; let batchResolveInProgress = false; +let pendingResolveAllSeats: { playerId: number; difficulty: string }[] | null = null; export async function dispatchResolveAll( requester: number, @@ -979,7 +979,13 @@ export async function dispatchResolveAll( debugLog("dispatchResolveAll: no adapter"); return; } - if ( + const waitingFor = useGameStore.getState().gameState?.waiting_for; + if (waitingFor?.type === "ResolveAllReady") { + if (!batchAdapter.resolveAll) { + debugLog("dispatchResolveAll: consent is Ready but the adapter cannot consume it"); + return; + } + } else if ( !batchAdapter.resolveAll || (aiSeats.length === 0 && batchAdapter.resolveAllUsesServerAi !== true) ) { @@ -998,8 +1004,27 @@ export async function dispatchResolveAll( return; } + // Resolve All starts as an ordinary engine action so every representative + // receives the explicit Phase-1 Grant/Decline prompt. A later invocation + // after Ready consumes that already-issued authorization; it never starts a + // second run or asks a future AI decision speculatively. + if (waitingFor?.type !== "ResolveAllReady") { + pendingResolveAllSeats = aiSeats; + const stackLen = useGameStore.getState().gameState?.stack.length ?? 0; + const maxResolutions = + stackPressureFromLength(stackLen) === "Instant" + ? BATCH_CHUNK_INSTANT + : BATCH_CHUNK_SIZE; + await dispatchAction( + { type: "BeginResolveAll", data: { max_resolutions: maxResolutions } }, + requester, + ); + return; + } + + const resolvedSeats = pendingResolveAllSeats ?? aiSeats; + batchResolveInProgress = true; - const multiplier = usePreferencesStore.getState().animationSpeedMultiplier; const { setIsResolvingAll, setResolutionProgress } = useGameStore.getState(); setIsResolvingAll(true); // Storm-origin denominator: latched from the FIRST chunk's `total` because @@ -1010,70 +1035,30 @@ export async function dispatchResolveAll( let resolvedSoFar = 0; try { - for (;;) { - // Re-evaluate pressure each iteration: a storm shrinks as it drains, so - // it eventually drops back to the animated 5-at-a-time path near the end. - const stackLen = useGameStore.getState().gameState?.stack.length ?? 0; - const instant = stackPressureFromLength(stackLen) === "Instant"; - const chunkSize = instant ? BATCH_CHUNK_INSTANT : BATCH_CHUNK_SIZE; - - const batchResult: BatchResolveResult = await batchAdapter.resolveAll( - requester, aiSeats, chunkSize, - ); - - if (latchedTotal === 0) latchedTotal = batchResult.total; - resolvedSoFar += batchResult.itemsResolved; - // Keep the throughput tracker warm so a storm draining below Instant keeps - // its animated tail fast instead of snapping back to full pacing. - // `itemsResolved` is a net-shrink count (can lag the true gross when a - // resolution spawns triggers) — an acceptable under-count here since the - // batch path is already depth-gated, where the depth axis dominates pacing. - if (batchResult.itemsResolved > 0) recordStackResolutions(batchResult.itemsResolved); - // Surface progress only for a genuine storm (trivial multi-item resolves - // drain too fast to render). Clamp to the latched total: `itemsResolved` - // is a net-shrink count that can lag the true gross when a resolution - // spawns triggers, so clamping keeps the bar monotonic and lets it - // complete. `resolved`/`total` are engine-provided — no frontend derivation. - if (latchedTotal >= STACK_PRESSURE_ELEVATED) { - setResolutionProgress({ - resolved: Math.min(resolvedSoFar, latchedTotal), - total: latchedTotal, - }); - } - - // One atomic pair per chunk, committed through the single authority. The - // store's `waitingFor` therefore comes from the snapshot's own state, not - // from `batchResult.waitingFor` — the pair must stay self-consistent. - // Equivalent or fresher: worker FIFO or ordered WebSocket state updates - // guarantee this snapshot reflects at least the chunk's end state. - const snapshot = await batchAdapter.getSnapshot(); - useGameStore.getState().commitEngineSnapshot(snapshot); - - // Anything other than Priority ends the drain — GameOver included, since - // the drain only continues while this seat keeps receiving priority. - const done = - batchResult.itemsResolved === 0 || - snapshot.state.stack.length === 0 || - snapshot.state.waiting_for.type !== "Priority"; - if (done) break; - - if (instant) { - // Yield one frame so the resolution-progress overlay repaints between - // chunks. This rAF is the load-bearing progress fix — without it, - // back-to-back Instant chunks never let the browser paint, producing - // the "wait, then N vanish at once" symptom. - await new Promise((r) => requestAnimationFrame(() => r())); - continue; - } + const stackLen = useGameStore.getState().gameState?.stack.length ?? 0; + const maxResolutions = + stackPressureFromLength(stackLen) === "Instant" + ? BATCH_CHUNK_INSTANT + : BATCH_CHUNK_SIZE; + const batchResult: BatchResolveResult = await batchAdapter.resolveAll( + requester, resolvedSeats, maxResolutions, + ); - const chunkDelay = Math.round(BATCH_CHUNK_BASE_DELAY_MS * multiplier); - if (chunkDelay > 0) { - await new Promise((r) => setTimeout(r, chunkDelay)); - } else { - await new Promise((r) => requestAnimationFrame(() => r())); - } + if (latchedTotal === 0) latchedTotal = batchResult.total; + resolvedSoFar += batchResult.itemsResolved; + if (batchResult.itemsResolved > 0) recordStackResolutions(batchResult.itemsResolved); + if (latchedTotal >= STACK_PRESSURE_ELEVATED) { + setResolutionProgress({ + resolved: Math.min(resolvedSoFar, latchedTotal), + total: latchedTotal, + }); } + // The Ready authorization is one run only. Commit one atomic snapshot + // after its proved prefix, then return control to ordinary priority. + const snapshot = await batchAdapter.getSnapshot(); + useGameStore.getState().commitEngineSnapshot(snapshot); + const { gameId, adapter } = useGameStore.getState(); const newState = useGameStore.getState().gameState; if (gameId && adapter && newState) { @@ -1087,5 +1072,6 @@ export async function dispatchResolveAll( batchResolveInProgress = false; setIsResolvingAll(false); setResolutionProgress(null); + pendingResolveAllSeats = null; } } diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index 5b3c74094d..bbad7104f4 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -33,6 +33,8 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet = new Set([ // Active priority — passes via PassButton / mana payment / cast. "Priority", + // ResolveAllConsentModal presents the engine-issued Grant/Decline prompt. + "ResolveAllConsent", // CR 701.42 / CR 508.4: meld pair and attacking-entry destination dialogs. "MeldPairChoice", "MeldAttackTargetChoice", diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index df4f1d69a6..ad51f34b0f 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -49,6 +49,13 @@ "collapse": "Auflösungsfortschritt einklappen", "expand": "Auflösungsfortschritt ausklappen" }, + "resolveAllConsent": { + "eyebrow": "Alles auflösen", + "title": "Schnelles Auflösen genehmigen?", + "subtitle": "Alle Spieler müssen zustimmen, bevor die Engine einen sicheren Stapelpräfix auflöst.", + "grant": "Genehmigen", + "decline": "Priorität behalten" + }, "debugCreate": { "copies": "Kopien", "tokenPower": "Power", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index cfc73722f6..7e57452b0a 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -55,6 +55,13 @@ "collapse": "Collapse resolving progress", "expand": "Expand resolving progress" }, + "resolveAllConsent": { + "eyebrow": "Resolve All", + "title": "Approve fast resolution?", + "subtitle": "All players must approve before the engine resolves a safe stack prefix.", + "grant": "Approve", + "decline": "Keep priority" + }, "debugCreate": { "copies": "Copies", "tokenPower": "Power", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index a721912f4c..da54a18715 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -49,6 +49,13 @@ "collapse": "Contraer progreso de resolución", "expand": "Expandir progreso de resolución" }, + "resolveAllConsent": { + "eyebrow": "Resolver todo", + "title": "¿Aprobar la resolución rápida?", + "subtitle": "Todos los jugadores deben aprobar antes de que el motor resuelva un prefijo seguro de la pila.", + "grant": "Aprobar", + "decline": "Conservar prioridad" + }, "debugCreate": { "copies": "Copias", "tokenPower": "Power", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 3149b0ebd3..f6af513c1c 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -49,6 +49,13 @@ "collapse": "Réduire la progression de résolution", "expand": "Développer la progression de résolution" }, + "resolveAllConsent": { + "eyebrow": "Tout résoudre", + "title": "Approuver la résolution rapide ?", + "subtitle": "Tous les joueurs doivent approuver avant que le moteur ne résolve un préfixe sûr de la pile.", + "grant": "Approuver", + "decline": "Garder la priorité" + }, "debugCreate": { "copies": "Copies", "tokenPower": "Power", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 4bda263127..58dad4225c 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -49,6 +49,13 @@ "collapse": "Comprimi avanzamento risoluzione", "expand": "Espandi avanzamento risoluzione" }, + "resolveAllConsent": { + "eyebrow": "Risolvi tutto", + "title": "Approvare la risoluzione rapida?", + "subtitle": "Tutti i giocatori devono approvare prima che il motore risolva un prefisso sicuro della pila.", + "grant": "Approva", + "decline": "Mantieni priorità" + }, "debugCreate": { "copies": "Copie", "tokenPower": "Power", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 6c22be57ad..24d96ae94c 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -49,6 +49,13 @@ "collapse": "Zwiń postęp rozwiązywania", "expand": "Rozwiń postęp rozwiązywania" }, + "resolveAllConsent": { + "eyebrow": "Rozpatrz wszystko", + "title": "Zatwierdzić szybkie rozpatrywanie?", + "subtitle": "Wszyscy gracze muszą zatwierdzić, zanim silnik rozpatrzy bezpieczny prefiks stosu.", + "grant": "Zatwierdź", + "decline": "Zachowaj priorytet" + }, "debugCreate": { "copies": "Kopie", "tokenPower": "Power", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 0d3a4afdaf..9d8ad0022e 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -49,6 +49,13 @@ "collapse": "Recolher progresso de resolução", "expand": "Expandir progresso de resolução" }, + "resolveAllConsent": { + "eyebrow": "Resolver tudo", + "title": "Aprovar a resolução rápida?", + "subtitle": "Todos os jogadores precisam aprovar antes que o mecanismo resolva um prefixo seguro da pilha.", + "grant": "Aprovar", + "decline": "Manter prioridade" + }, "debugCreate": { "copies": "Cópias", "tokenPower": "Power", diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 576d2086a3..106a29d61c 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -100,6 +100,7 @@ import { RespondToPrecastCopyShortcutModal, } from "../components/modal/PrecastCopyShortcutModal.tsx"; import { ReplacementModal } from "../components/modal/ReplacementModal.tsx"; +import { ResolveAllConsentModal } from "../components/modal/ResolveAllConsentModal.tsx"; import { TriggerOrderModal } from "../components/modal/TriggerOrderModal.tsx"; import { PeekTab } from "../components/modal/DialogShell.tsx"; import { PeekRestoreTab } from "../components/modal/DialogHost.tsx"; @@ -1868,6 +1869,7 @@ function GamePageContent({ canActForWaitingState && } {waitingFor?.type === "ReplacementChoice" && canActForWaitingState && } + {canActForWaitingState && } {waitingFor?.type === "OrderTriggers" && canActForWaitingState && } diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 29c7d0b149..f4a3930079 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -13,10 +13,8 @@ use engine::ai_support::{ }; use engine::database::legality::{any_ai_difficulty_is_cedh, validate_cedh_bracket}; use engine::database::{CardDatabase, CardSearchQuery}; -use engine::game::engine::{ - apply, apply_for_simulation, resolve_all_fast_forward, ResolveAllCallbackDecision, - ResolveAllFastForwardResult as BatchResolveResult, -}; +use engine::game::engine::{apply, apply_for_simulation}; +use engine::game::engine_resolve_batch::resolve_all_ready_prefix; use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::preview::{compute_preview_diff, preview_auto_payment_sources}; use engine::game::{ @@ -2175,8 +2173,8 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { /// The natively-callable body of [`restore_game_state`]. /// -/// Split for the same reason — and in the same shape — as `resolve_all_inner` -/// and `scored_candidates_inner`: the `#[wasm_bindgen]` shell may only run on +/// Split for the same reason — and in the same shape — as `scored_candidates_inner`: +/// the `#[wasm_bindgen]` shell may only run on /// wasm32. Off-target, `JsValue::from_str` panics inside a function that cannot /// unwind, so a shell that merely RETURNS an error aborts the whole process with /// SIGABRT instead of failing the test. A native test that calls the shell is @@ -2801,7 +2799,7 @@ pub fn get_ai_tactical_action_proposal_with_diagnostics( /// Split out of [`get_ai_scored_candidates`] so native tests can drive the real /// scoring path: the `#[wasm_bindgen]` shell returns through `to_js`, which calls /// the real `JSON.parse` binding and panics outside a wasm32 runtime (same reason -/// `resolve_all_inner` exists). +/// `scored_candidates_inner` exists). fn scored_candidates_inner( state: &mut GameState, difficulty: AiDifficulty, @@ -3035,6 +3033,10 @@ pub fn submit_ai_action_proposal(token: &str, actor: u8, action: JsValue) -> JsV /// - AI declines to pass priority /// - Game ends /// - Safety cap reached (prevents infinite loops from cascading triggers) +#[expect( + dead_code, + reason = "the legacy Resolve All wire payload remains validated for compatibility, but unanimous engine consent now owns seat decisions" +)] #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct AiSeatConfig { @@ -3042,49 +3044,6 @@ struct AiSeatConfig { difficulty: String, } -fn resolve_all_inner( - state: &mut GameState, - requester: PlayerId, - ai_seats: &[AiSeatConfig], - max_resolutions: u32, - rng: &mut impl Rng, -) -> BatchResolveResult { - // The first AI decision in the fast-forward loop can run before any - // `apply()` (which would flush internally); flush up front so it sees - // precise derived state + presence index. No-op when layers are clean. - engine::game::layers::flush_layers(state); - let session = ai_session_for(state); - resolve_all_fast_forward(state, requester, max_resolutions, |state, actor| { - if let Some(seat) = ai_seats - .iter() - .find(|seat| PlayerId(seat.player_id) == actor) - { - let ai_difficulty = AiDifficulty::from_label(&seat.difficulty); - let config = - create_config_for_players(ai_difficulty, Platform::Wasm, state.players.len() as u8); - // Seeding asks whether a later seat will pass before the live - // `WaitingFor` advances to that seat. Give the AI the exact - // future priority prompt on a clone; its contract otherwise has - // an empty candidate domain and it cannot select PassPriority. - let mut decision_state = state.clone(); - decision_state.waiting_for = WaitingFor::Priority { player: actor }; - decision_state.priority_player = actor; - match choose_action_with_session(&decision_state, actor, &config, rng, &session) { - // `seed_remaining_priority_cycle_passes` asks about future - // priority seats before `WaitingFor` advances to them. A - // priority pass is the sole raw action the batch accepts, so - // it remains valid without fabricating a future contract. - Some(GameAction::PassPriority) => { - ResolveAllCallbackDecision::Action(GameAction::PassPriority) - } - Some(_) | None => ResolveAllCallbackDecision::Stop, - } - } else { - ResolveAllCallbackDecision::Stop - } - }) -} - #[wasm_bindgen] pub fn resolve_all( requester: u8, @@ -3097,8 +3056,16 @@ pub fn resolve_all( let requester = PlayerId(requester); with_state_mut(|state| { - let mut rng = rand::rng(); - let mut result = resolve_all_inner(state, requester, &ai_seats, max_resolutions, &mut rng); + // Phase 2 consumes only the already-issued, unanimous consent run. + // AI consent is answered through ordinary engine candidates before this + // call; Resolve All must never ask an AI about a speculative future + // priority window. Keep the legacy payload parse as a wire-compatible + // boundary while the consent action owns the authoritative cap. + let _ = (ai_seats, max_resolutions); + if !matches!(&state.waiting_for, WaitingFor::ResolveAllReady { .. }) { + return Err(JsValue::from_str("Resolve All consent is not ready")); + } + let mut result = resolve_all_ready_prefix(state, requester); // A Resolve All burst applies real actions directly via // `apply_action_boundary_with_stack_limit` (bypassing `submit_action`, // which is the only other place REPLAY_LOG is appended to) — without @@ -3259,94 +3226,6 @@ mod bracket_estimate_tests { } } -#[cfg(test)] -mod resolve_all_tests { - use super::*; - use engine::types::ability::{Effect, ResolvedAbility}; - use engine::types::game_state::{StackEntry, StackEntryKind, WaitingFor}; - use engine::types::identifiers::ObjectId; - - fn no_op_entry(id: u64, controller: PlayerId) -> StackEntry { - let object_id = ObjectId(id); - StackEntry { - id: object_id, - source_id: object_id, - controller, - kind: StackEntryKind::ActivatedAbility { - source_id: object_id, - ability: Box::new(ResolvedAbility::new( - Effect::NoOp, - vec![], - object_id, - controller, - )), - }, - } - } - - fn priority_state(semantic_seat: PlayerId, stack: Vec) -> GameState { - let mut state = GameState::new_two_player(7); - state.waiting_for = WaitingFor::Priority { - player: semantic_seat, - }; - state.priority_player = semantic_seat; - state.stack = stack.into_iter().collect(); - state - } - - #[test] - fn resolve_all_tls_production_path_substitute_routes_controlled_priority() { - let mut state = priority_state(PlayerId(1), vec![no_op_entry(1, PlayerId(1))]); - state.active_player = PlayerId(1); - state.turn_decision_controller = Some(PlayerId(0)); - state.priority_player = PlayerId(0); - state.priority_passes.insert(PlayerId(0)); - GAME_STATE.with(|cell| cell.set(Some(state))); - - let ai_seats: Vec = serde_json::from_str("[]").unwrap(); - let result = with_state_mut(|state| { - let mut rng = ChaCha20Rng::seed_from_u64(13); - resolve_all_inner(state, PlayerId(0), &ai_seats, 0, &mut rng) - }) - .unwrap(); - - assert_eq!(result.items_resolved, 1); - with_state(|state| assert!(state.stack.is_empty())).unwrap(); - clear_game_state(); - } - - #[test] - fn resolve_all_tls_production_path_seeds_ai_priority_passes() { - let mut state = GameState::new(FormatConfig::free_for_all(), 3, 7); - state.waiting_for = WaitingFor::Priority { - player: PlayerId(0), - }; - state.priority_player = PlayerId(0); - state.stack.push_back(no_op_entry(1, PlayerId(2))); - GAME_STATE.with(|cell| cell.set(Some(state))); - - let ai_seats = vec![ - AiSeatConfig { - player_id: 1, - difficulty: "Medium".to_string(), - }, - AiSeatConfig { - player_id: 2, - difficulty: "Medium".to_string(), - }, - ]; - let result = with_state_mut(|state| { - let mut rng = ChaCha20Rng::seed_from_u64(13); - resolve_all_inner(state, PlayerId(0), &ai_seats, 0, &mut rng) - }) - .unwrap(); - - assert_eq!(result.items_resolved, 1); - with_state(|state| assert!(state.stack.is_empty())).unwrap(); - clear_game_state(); - } -} - #[cfg(all(test, target_arch = "wasm32"))] mod tests { use super::*; @@ -4617,6 +4496,31 @@ mod tests { state.priority_player = PlayerId(0); state.priority_passes.insert(PlayerId(0)); state.stack.push_back(no_op_stack_entry(1, PlayerId(1))); + apply( + &mut state, + PlayerId(0), + GameAction::BeginResolveAll { max_resolutions: 0 }, + ) + .expect("the controlled priority holder begins Resolve All consent"); + let epoch = match state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } => epoch, + ref other => { + panic!("Resolve All must prompt the remaining representative, got {other:?}") + } + }; + apply( + &mut state, + PlayerId(0), + GameAction::RespondResolveAllConsent { + epoch, + decision: engine::types::actions::ResolveAllConsentDecision::Grant, + }, + ) + .expect("the controlled representative grants Resolve All consent"); + assert!(matches!( + state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if ready_epoch == epoch + )); GAME_STATE.with(|cell| cell.set(Some(state))); let value = resolve_all(0, "[]", 0).unwrap(); diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 5853fe3380..890f6d9272 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -11,6 +11,7 @@ use crate::game::mana_sources; use crate::types::ability::{ChoiceType, CounterCostSelection, TargetRef}; use crate::types::actions::{ CastChoice, GameAction, LearnOption, MulliganChoice, OutsideGameSelection, + ResolveAllConsentDecision, }; use crate::types::card::LayoutKind; use crate::types::card_type::CoreType; @@ -381,6 +382,36 @@ fn permute_into( /// constructing `GameAction::Concede { player_id }` directly. pub fn candidate_actions_exact(state: &GameState) -> Vec { match &state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch, + representative, + } => { + let mut actions = vec![ + candidate( + GameAction::RespondResolveAllConsent { + epoch: *epoch, + decision: ResolveAllConsentDecision::Grant, + }, + TacticalClass::Selection, + Some(*representative), + ), + candidate( + GameAction::RespondResolveAllConsent { + epoch: *epoch, + decision: ResolveAllConsentDecision::Decline, + }, + TacticalClass::Selection, + Some(*representative), + ), + ]; + append_resolve_all_revocations(state, *epoch, &mut actions); + actions + } + WaitingFor::ResolveAllReady { epoch } => { + let mut actions = Vec::new(); + append_resolve_all_revocations(state, *epoch, &mut actions); + actions + } WaitingFor::MeldPairChoice { player, choices } => choices .iter() .map(|choice| { @@ -848,6 +879,35 @@ pub fn candidate_actions_exact(state: &GameState) -> Vec { } } +fn append_resolve_all_revocations( + state: &GameState, + epoch: u64, + actions: &mut Vec, +) { + let Some(run) = state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == epoch) + else { + return; + }; + actions.extend( + run.participants + .iter() + .filter(|participant| participant.granted) + .map(|participant| { + candidate( + GameAction::RevokeResolveAllConsent { + epoch, + representative: participant.representative, + }, + TacticalClass::Selection, + Some(participant.representative), + ) + }), + ); +} + pub fn candidate_actions_broad(state: &GameState) -> Vec { candidate_actions_broad_with_probe(state, None) } @@ -857,7 +917,9 @@ pub fn candidate_actions_broad_with_probe( probe: Option<&casting::PriorityCastProbe>, ) -> Vec { let actions = match &state.waiting_for { - WaitingFor::MeldPairChoice { .. } + WaitingFor::ResolveAllConsent { .. } + | WaitingFor::ResolveAllReady { .. } + | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } | WaitingFor::EntryAttackTargetChoice { .. } => candidate_actions_exact(state), WaitingFor::Priority { player } => priority_actions_with_probe(state, *player, probe), @@ -3512,7 +3574,16 @@ fn semantic_candidate_actions_with_probe( probe: Option<&casting::PriorityCastProbe>, ) -> Vec { let mut actions = candidate_actions_exact(state); - actions.extend(candidate_actions_broad_with_probe(state, probe)); + // Resolve All consent is wholly represented by its finite exact domain. + // The broad enumerator delegates these same states to `candidate_actions_exact` + // for broad-only callers, so composing both here would expose every + // Grant, Decline, and Revoke choice twice. + if !matches!( + &state.waiting_for, + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } + ) { + actions.extend(candidate_actions_broad_with_probe(state, probe)); + } let has_pending_cast = state.waiting_for.has_pending_cast() || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) @@ -3538,9 +3609,31 @@ fn semantic_candidate_actions_with_probe( fn authorize_candidate_actors(state: &GameState, actions: &mut [CandidateAction]) { for action in actions { - action.metadata.actor = action.metadata.actor.map(|player| { - crate::game::turn_control::authorized_submitter_for_player(state, player) - }); + action.metadata.actor = match &action.action { + GameAction::RespondResolveAllConsent { epoch, .. } => match &state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch: active_epoch, + representative, + } if *epoch == *active_epoch => { + Some(crate::game::turn_control::authorized_submitter_for_player( + state, + *representative, + )) + } + _ => None, + }, + GameAction::RevokeResolveAllConsent { + epoch, + representative, + } => crate::game::turn_control::resolve_all_granted_submitter( + state, + *epoch, + *representative, + ), + _ => action.metadata.actor.map(|player| { + crate::game::turn_control::authorized_submitter_for_player(state, player) + }), + }; } } diff --git a/crates/engine/src/ai_support/context.rs b/crates/engine/src/ai_support/context.rs index abfe280528..22ae575201 100644 --- a/crates/engine/src/ai_support/context.rs +++ b/crates/engine/src/ai_support/context.rs @@ -35,7 +35,9 @@ impl AiDecisionContract { pub fn issue(state: &GameState, semantic_owner: PlayerId) -> Self { Self { semantic_owner, - authorized_actor: turn_control::authorized_submitter_for_player(state, semantic_owner), + authorized_actor: resolve_all_frozen_actor(state, semantic_owner).unwrap_or_else( + || turn_control::authorized_submitter_for_player(state, semantic_owner), + ), state_revision: state.state_revision, // The engine's candidate enumerator is the authoritative finite // domain for this prompt. Combat and search continuations remain @@ -60,13 +62,29 @@ impl AiDecisionContract { /// opaque proposal token (WASM/server), because a restored state resets its /// serialized revision. pub fn permits(&self, state: &GameState, actor: PlayerId, action: &GameAction) -> bool { + let semantic_owner_is_active = state + .waiting_for + .acting_players() + .contains(&self.semantic_owner) + || matches!( + action, + GameAction::RevokeResolveAllConsent { representative, .. } + if *representative == self.semantic_owner + ); + let authorized_actor = match action { + GameAction::RevokeResolveAllConsent { + epoch, + representative, + } => turn_control::resolve_all_granted_submitter(state, *epoch, *representative), + _ => Some(turn_control::authorized_submitter_for_player( + state, + self.semantic_owner, + )), + }; self.state_revision == state.state_revision - && state - .waiting_for - .acting_players() - .contains(&self.semantic_owner) + && semantic_owner_is_active && self.authorized_actor == actor - && turn_control::authorized_submitter_for_player(state, self.semantic_owner) == actor + && authorized_actor == Some(actor) && self.contains_action(state, action) } @@ -102,6 +120,22 @@ impl AiDecisionContract { } } +fn resolve_all_frozen_actor(state: &GameState, representative: PlayerId) -> Option { + let epoch = match &state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } | WaitingFor::ResolveAllReady { epoch } => { + *epoch + } + _ => return None, + }; + turn_control::resolve_all_granted_submitter(state, epoch, representative).or_else(|| { + state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == epoch) + .and_then(|run| run.authorized_submitter_for(representative)) + }) +} + pub(crate) fn target_selection_requires_reducer_validation(state: &GameState) -> bool { // CR 601.2c + CR 601.2e-h + CR 602.2b: selecting a target can complete // target declaration and immediately check legality and pay the proposed diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 10907bceb0..755a36b252 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -249,6 +249,20 @@ pub(crate) fn structurally_valid_tap_for_convoke_payment( } fn cheap_reject_candidate(state: &GameState, action: &GameAction) -> bool { + // Ready intentionally has no current acting player, but an active consent + // grantor may still revoke. The candidate pipeline retains the frozen actor + // and its SimulationFilter checks that authority before admitting it. + if let GameAction::RevokeResolveAllConsent { + epoch, + representative, + } = action + { + if crate::game::turn_control::resolve_all_granted_submitter(state, *epoch, *representative) + .is_some() + { + return false; + } + } // CR 103.5 / TL:R 906.6a: For simultaneous-decision states // `acting_player()` is None when multiple players are pending. The // Priority-branch check below only fires for the Priority variant, so we @@ -1316,7 +1330,10 @@ fn classify_flat_priority_action(action: &GameAction) -> FlatPriorityActionClass | GameAction::DeclareShortcut { .. } | GameAction::RespondToShortcut { .. } | GameAction::DeclineShortcut - | GameAction::PrecastCopyShortcut { .. } => FlatPriorityActionClass::Other, + | GameAction::PrecastCopyShortcut { .. } + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } => FlatPriorityActionClass::Other, } } @@ -2329,7 +2346,7 @@ pub fn mana_payment_shortcut_actions( } /// Returns `legal_actions_full` scoped to a specific viewer. Empty tuple if -/// `viewer` is not the player currently expected to act. +/// `viewer` has no current action authority. /// /// CR 117.1 — "which player can take actions at any given time is determined by /// a system of priority. The player with priority may cast spells, activate @@ -2340,8 +2357,12 @@ pub fn mana_payment_shortcut_actions( /// This is the single engine-side authority for "what does player X need to /// know" and exists to keep game-logic gating out of transport adapters. The /// P2P multiplayer host broadcasts a filtered state + legal-actions payload -/// per guest; only the acting guest needs a populated legal-actions map. +/// per guest; the Resolve All consent protocol additionally exposes each +/// frozen grantor's own revocation while a different representative is queued. pub fn legal_actions_for_viewer(state: &GameState, viewer: PlayerId) -> LegalActionsFull { + if let Some(actions) = resolve_all_actions_for_viewer(state, viewer) { + return (actions, HashMap::new(), HashMap::new()); + } // CR 103.5: For simultaneous-decision states (MulliganDecision, // OpeningHandBottomCards), every pending player has a // legal action set, so guests in a multiplayer mulligan can see and submit @@ -2364,6 +2385,47 @@ pub fn legal_actions_for_viewer(state: &GameState, viewer: PlayerId) -> LegalAct } } +fn resolve_all_actions_for_viewer(state: &GameState, viewer: PlayerId) -> Option> { + let epoch = match &state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } | WaitingFor::ResolveAllReady { epoch } => { + *epoch + } + _ => return None, + }; + let run = state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == epoch)?; + let mut actions = Vec::new(); + if let WaitingFor::ResolveAllConsent { representative, .. } = &state.waiting_for { + if run.authorized_submitter_for(*representative) == Some(viewer) { + actions.extend([ + GameAction::RespondResolveAllConsent { + epoch, + decision: crate::types::actions::ResolveAllConsentDecision::Grant, + }, + GameAction::RespondResolveAllConsent { + epoch, + decision: crate::types::actions::ResolveAllConsentDecision::Decline, + }, + ]); + } + } + actions.extend(run.participants.iter().filter_map(|participant| { + (participant.granted + && crate::game::turn_control::resolve_all_granted_submitter( + state, + epoch, + participant.representative, + ) == Some(viewer)) + .then_some(GameAction::RevokeResolveAllConsent { + epoch, + representative: participant.representative, + }) + })); + Some(actions) +} + /// Non-fatal diagnostic describing a wedged decision point. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 42e8a9e1a0..2e07be8160 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -827,6 +827,11 @@ fn do_eliminate( super::turn_control::recompute_active_player_control(state); } + // A consent run freezes canonical representatives and submitters. Player + // elimination changes that topology, so discard the run rather than + // allowing a stale prompt or Ready state to authorize anyone. + super::turn_control::invalidate_resolve_all_consent(state); + // CR 800.4a + CR 800.4b: a departing searcher/zone owner invalidates its // live session, while a departing latched controller ends only that // controller's decision/knowledge role and falls back to the searcher. diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 72101f92af..a904a3329a 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -6,14 +6,16 @@ use crate::types::ability::{EffectKind, KeywordAction, TargetRef}; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; use crate::types::actions::{ - DebugAction, GameAction, MayTriggerAutoChoiceOp, PriorityYieldOp, TriggerOrderTemplateOp, + DebugAction, GameAction, MayTriggerAutoChoiceOp, PriorityYieldOp, ResolveAllConsentDecision, + TriggerOrderTemplateOp, }; use crate::types::events::{BendingType, ContestRound, GameEvent, ManaTapState}; use crate::types::game_state::{ ActionResult, AssistState, AutoMayChoice, AutoPassMode, AutoPassRequest, CastOfferKind, CastingVariant, ConvokeMode, CostResume, GameState, LandPlayRecord, LoopDetectionMode, ManaAbilityResume, MayTriggerAutoChoiceKey, PayCostKind, PendingCostMoveResume, - PendingCounterPostAction, PendingEffectResolved, RetargetScope, StackEntry, StackEntryKind, + PendingCounterPostAction, PendingEffectResolved, ResolveAllConsentParticipant, + ResolveAllConsentRun, ResolveAllPrioritySnapshot, RetargetScope, StackEntry, StackEntryKind, WaitingFor, }; use crate::types::identifiers::{CardId, DelayedTriggerOrigin, ObjectId, ObjectIncarnationRef}; @@ -6391,6 +6393,16 @@ fn check_actor_authorization( { return Ok(()); } + if let GameAction::RevokeResolveAllConsent { + epoch, + representative, + } = action + { + return (turn_control::resolve_all_granted_submitter(state, *epoch, *representative) + == Some(actor)) + .then_some(()) + .ok_or(EngineError::WrongPlayer); + } // CR 103.5: For simultaneous-decision states (MulliganDecision, // OpeningHandBottomCards), authorize against the full pending set so any // pending player may submit in any order. Falls back to single-player @@ -7572,6 +7584,150 @@ fn finalize_copy_retarget( Ok(()) } +fn begin_resolve_all_consent( + state: &mut GameState, + priority_player: PlayerId, + max_resolutions: u32, +) -> Result { + if state.priority_player + != turn_control::authorized_submitter_for_player(state, priority_player) + { + return Err(EngineError::NotYourPriority); + } + let current_representative = + super::topology::priority_pass_representative(state, priority_player); + let mut representatives = super::topology::priority_pass_participants(state); + let Some(current_index) = representatives + .iter() + .position(|representative| *representative == current_representative) + else { + return Err(EngineError::ActionNotAllowed( + "Resolve All requires a live priority representative".to_string(), + )); + }; + representatives.rotate_left(current_index); + + let epoch = state.next_resolve_all_consent_epoch; + let next_epoch = epoch.checked_add(1).ok_or_else(|| { + EngineError::ActionNotAllowed("Resolve All consent epoch space exhausted".to_string()) + })?; + state.next_resolve_all_consent_epoch = next_epoch; + state.resolve_all_consent_run = Some(ResolveAllConsentRun { + epoch, + max_resolutions, + priority_snapshot: ResolveAllPrioritySnapshot { + waiting_player: priority_player, + priority_player: state.priority_player, + priority_pass_count: state.priority_pass_count, + priority_passes: state.priority_passes.clone(), + }, + participants: representatives + .into_iter() + .map(|representative| ResolveAllConsentParticipant { + representative, + authorized_submitter: turn_control::authorized_submitter_for_player( + state, + representative, + ), + granted: representative == current_representative, + }) + .collect(), + }); + + resolve_all_consent_waiting_for(state).ok_or_else(|| { + EngineError::ActionNotAllowed( + "Resolve All requires at least one representative".to_string(), + ) + }) +} + +fn resolve_all_consent_waiting_for(state: &GameState) -> Option { + let run = state.resolve_all_consent_run.as_ref()?; + Some( + run.next_pending_representative() + .map(|representative| WaitingFor::ResolveAllConsent { + epoch: run.epoch, + representative, + }) + .unwrap_or(WaitingFor::ResolveAllReady { epoch: run.epoch }), + ) +} + +// CR 117.3d + CR 117.4: A declined shortcut resumes the exact ordinary +// priority-pass sequence it interrupted; no spell or ability has resolved. +fn restore_resolve_all_priority_snapshot(state: &mut GameState) -> Result { + let run = state.resolve_all_consent_run.take().ok_or_else(|| { + EngineError::InvalidAction("Resolve All consent is not active".to_string()) + })?; + let snapshot = run.priority_snapshot; + state.priority_player = snapshot.priority_player; + state.priority_pass_count = snapshot.priority_pass_count; + state.priority_passes = snapshot.priority_passes; + Ok(WaitingFor::Priority { + player: snapshot.waiting_player, + }) +} + +fn respond_resolve_all_consent( + state: &mut GameState, + epoch: u64, + representative: PlayerId, + response_epoch: u64, + decision: ResolveAllConsentDecision, +) -> Result { + if epoch != response_epoch { + return Err(EngineError::InvalidAction( + "Resolve All consent epoch is stale".to_string(), + )); + } + { + let run = state.resolve_all_consent_run.as_mut().ok_or_else(|| { + EngineError::InvalidAction("Resolve All consent is not active".to_string()) + })?; + if run.epoch != epoch || run.next_pending_representative() != Some(representative) { + return Err(EngineError::InvalidAction( + "Resolve All consent response is no longer pending".to_string(), + )); + } + match decision { + ResolveAllConsentDecision::Grant => { + let participant = run + .participants + .iter_mut() + .find(|participant| participant.representative == representative) + .expect("pending Resolve All representative must be a participant"); + participant.granted = true; + } + ResolveAllConsentDecision::Decline => {} + } + } + match decision { + ResolveAllConsentDecision::Decline => restore_resolve_all_priority_snapshot(state), + ResolveAllConsentDecision::Grant => { + resolve_all_consent_waiting_for(state).ok_or_else(|| { + EngineError::InvalidAction("Resolve All consent is not active".to_string()) + }) + } + } +} + +fn revoke_resolve_all_consent( + state: &mut GameState, + epoch: u64, + representative: PlayerId, +) -> Result { + let active = state + .resolve_all_consent_run + .as_ref() + .is_some_and(|run| run.epoch == epoch && run.is_granted(representative)); + if !active { + return Err(EngineError::InvalidAction( + "Resolve All consent revocation is stale".to_string(), + )); + } + restore_resolve_all_priority_snapshot(state) +} + fn apply_action( state: &mut GameState, actor: PlayerId, @@ -7996,6 +8152,32 @@ fn apply_action( log_entries: vec![], }); } + (WaitingFor::Priority { player }, GameAction::BeginResolveAll { max_resolutions }) => { + begin_resolve_all_consent(state, *player, max_resolutions)? + } + ( + WaitingFor::ResolveAllConsent { + epoch, + representative, + }, + GameAction::RespondResolveAllConsent { + epoch: response_epoch, + decision, + }, + ) => respond_resolve_all_consent( + state, + *epoch, + *representative, + response_epoch, + decision, + )?, + ( + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. }, + GameAction::RevokeResolveAllConsent { + epoch, + representative, + }, + ) => revoke_resolve_all_consent(state, epoch, representative)?, (WaitingFor::Priority { player }, GameAction::PlayLand { object_id, card_id }) => { if state.priority_player != turn_control::authorized_submitter_for_player(state, *player) @@ -15025,6 +15207,7 @@ mod priority_reducer_census_tests { "ActivateManaSource", "ActivateNinjutsu", "ActivateStation", + "BeginResolveAll", "CastPreparedCopy", "CastSpell", "CastSpellAsSneak", @@ -15058,7 +15241,12 @@ mod priority_reducer_census_tests { .collect::>(); let expected_preflight_families = expected .iter() - .filter(|family| *family != "PassPriority" && *family != "SetAutoPass") + .filter(|family| { + !matches!( + family.as_str(), + "BeginResolveAll" | "PassPriority" | "SetAutoPass" + ) + }) .map(|family| (*family).to_owned()) .collect::>(); assert_eq!(preflight_families, expected_preflight_families); @@ -19573,7 +19761,7 @@ mod stage2_injector_tests { // `begin_pending_trigger_target_selection` is STILL 134 — the function opens // `:12722 ⇒ :12778`, moving by the same `+56` as the pin, so the control // that caught this row's one historical silent drift is intact. - "game/engine.rs:12912".to_string(), + "game/engine.rs:13094".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_resolve_batch.rs b/crates/engine/src/game/engine_resolve_batch.rs index 168ced5004..d7abc8810f 100644 --- a/crates/engine/src/game/engine_resolve_batch.rs +++ b/crates/engine/src/game/engine_resolve_batch.rs @@ -3,13 +3,13 @@ use serde::{Deserialize, Serialize}; use crate::ai_support::AiDecisionContract; use crate::types::actions::GameAction; use crate::types::events::GameEvent; -use crate::types::game_state::{GameState, WaitingFor}; +use crate::types::game_state::{GameState, ResolveAllConsentRun, WaitingFor}; use crate::types::log::GameLogEntry; use crate::types::player::PlayerId; use super::engine::{apply_action_boundary_with_stack_limit, PublicFinalizeMode}; use super::public_state::finalize_display_state; -use super::{topology, turn_control}; +use super::{interaction, stack, topology, turn_control}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -49,6 +49,204 @@ pub enum ResolveAllCallbackDecision { Stop, } +/// Resolves the greatest prefix which has already received every priority +/// representative's explicit, run-scoped Resolve All consent. +/// +/// Unlike the legacy callback fast-forward below, this path never asks an AI +/// (or any future priority holder) whether to pass. Consent is the sole +/// authority. Each prospective resolution is materialized on a clone through +/// a complete, ordinary priority cycle, with `Some(1)` preventing any of the +/// existing stack batchers from consuming more than its one stack entry. The +/// clone is committed only when it settles to an unchanged-topology Priority +/// checkpoint with exactly that entry removed. This is intentionally a +/// greatest-safe-prefix proof, not loop detection or state equality. +pub fn resolve_all_ready_prefix( + state: &mut GameState, + requester: PlayerId, +) -> ResolveAllFastForwardResult { + let total = state.stack.len() as u32; + let mut events = Vec::new(); + let mut log_entries = Vec::new(); + let mut recorded_actions = Vec::new(); + let mut items_resolved = 0; + + let Some(run) = ready_consent_run(state, requester).cloned() else { + if matches!(&state.waiting_for, WaitingFor::ResolveAllReady { .. }) { + turn_control::invalidate_resolve_all_consent(state); + finalize_display_state(state); + interaction::ensure_interaction_authority(state); + } + return ResolveAllFastForwardResult { + events, + waiting_for: state.waiting_for.clone(), + log_entries, + items_resolved, + total, + recorded_actions, + }; + }; + + // Ready is deliberately inert. Materialize the saved priority checkpoint + // only inside this Resolve All consumer; ordinary actions can never pass + // through Ready. + state.waiting_for = WaitingFor::Priority { + player: run.priority_snapshot.waiting_player, + }; + + let resolution_cap = if run.max_resolutions == 0 { + u32::MAX + } else { + run.max_resolutions + }; + + while items_resolved < resolution_cap && !state.stack.is_empty() { + let mut proof = state.clone(); + let stack_before = proof.stack.len(); + let Some((boundary, mut actions)) = materialize_one_consented_resolution(&mut proof, &run) + else { + break; + }; + + // CR 117.5 + CR 704.3 + CR 603.3b: do not collapse across any new + // checkpoint work. In particular, if item N causes a shuffle/dies/etc. + // trigger, this refuses N and leaves it on the live stack for ordinary + // priority, while earlier committed entries remain collapsed. + if stack_resolved_count(&boundary.events) != 1 + || proof.stack.len().saturating_add(1) != stack_before + || !matches!(proof.waiting_for, WaitingFor::Priority { .. }) + || !stack::priority_checkpoint_is_settled(&proof) + || !consent_authorization_matches(&proof, &run) + { + break; + } + + items_resolved += 1; + events.extend(boundary.events); + log_entries.extend(boundary.log_entries); + recorded_actions.append(&mut actions); + *state = proof; + } + + // Authorization is one run only. Once the proved prefix ends (including + // a zero-length or cap boundary), return the remaining stack to ordinary + // priority; no later stack entry inherits this consent. + state.resolve_all_consent_run = None; + finalize_display_state(state); + interaction::ensure_interaction_authority(state); + + ResolveAllFastForwardResult { + events, + waiting_for: state.waiting_for.clone(), + log_entries, + items_resolved, + total, + recorded_actions, + } +} + +/// Returns whether the frozen Ready consent run authorizes this requester. +/// Transport callers must reject an unauthorized request before mutating the +/// authoritative session; the resolver's fail-closed invalidation remains its +/// defense-in-depth boundary. +pub fn resolve_all_ready_requester_is_authorized(state: &GameState, requester: PlayerId) -> bool { + ready_consent_run(state, requester).is_some() +} + +/// Validates the frozen Phase-1 consent against the live topology before the +/// Ready state is materialized. A changed controller, eliminated player, or +/// stale requester fails closed without invoking a speculative callback. +fn ready_consent_run(state: &GameState, requester: PlayerId) -> Option<&ResolveAllConsentRun> { + let WaitingFor::ResolveAllReady { epoch } = &state.waiting_for else { + return None; + }; + let run = state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == *epoch && run.participants.iter().all(|p| p.granted))?; + (run.participants + .iter() + .any(|participant| participant.authorized_submitter == requester) + && state.priority_player == run.priority_snapshot.priority_player + && state.priority_pass_count == run.priority_snapshot.priority_pass_count + && state.priority_passes == run.priority_snapshot.priority_passes + && consent_authorization_matches(state, run)) + .then_some(run) +} + +fn consent_authorization_matches(state: &GameState, run: &ResolveAllConsentRun) -> bool { + let mut representatives = topology::priority_pass_participants(state); + let current = + topology::priority_pass_representative(state, run.priority_snapshot.waiting_player); + let Some(current_index) = representatives + .iter() + .position(|representative| *representative == current) + else { + return false; + }; + representatives.rotate_left(current_index); + representatives.len() == run.participants.len() + && representatives + .iter() + .zip(&run.participants) + .all(|(live, frozen)| { + *live == frozen.representative + && turn_control::authorized_submitter_for_player(state, *live) + == frozen.authorized_submitter + }) +} + +/// Performs exactly one actual priority cycle on a proof clone. Every seeded +/// pass is recorded in application order so replay can submit the same normal +/// `PassPriority` actions without a hidden batch-only transition. +fn materialize_one_consented_resolution( + state: &mut GameState, + run: &ResolveAllConsentRun, +) -> Option<( + crate::types::game_state::ActionResult, + Vec<(PlayerId, GameAction)>, +)> { + let WaitingFor::Priority { player } = &state.waiting_for else { + return None; + }; + let player = *player; + if !consent_authorization_matches(state, run) { + return None; + } + let actor = turn_control::authorized_submitter_for_player(state, player); + let mut recorded = Vec::new(); + seed_remaining_consented_priority_passes(state, player, &mut recorded)?; + let boundary = apply_action_boundary_with_stack_limit( + state, + actor, + player, + GameAction::PassPriority, + PublicFinalizeMode::DeferredDisplay, + Some(1), + ) + .ok()?; + recorded.insert(0, (actor, GameAction::PassPriority)); + Some((boundary, recorded)) +} + +fn seed_remaining_consented_priority_passes( + state: &mut GameState, + current_seat: PlayerId, + recorded: &mut Vec<(PlayerId, GameAction)>, +) -> Option<()> { + let current_rep = topology::priority_pass_representative(state, current_seat); + let participants = topology::priority_pass_participants(state); + let current_idx = participants.iter().position(|seat| *seat == current_rep)?; + for offset in 1..participants.len() { + let representative = participants[(current_idx + offset) % participants.len()]; + if !state.priority_passes.contains(&representative) { + let actor = turn_control::authorized_submitter_for_player(state, representative); + state.priority_passes.insert(representative); + recorded.push((actor, GameAction::PassPriority)); + } + } + Some(()) +} + enum PriorityCycleFastForward { Seeded, CannotSeed, @@ -285,6 +483,7 @@ mod tests { AbilityCost, AbilityDefinition, AbilityKind, CopyRetargetPermission, Effect, ManaContribution, ManaProduction, ResolvedAbility, TargetFilter, }; + use crate::types::actions::ResolveAllConsentDecision; use crate::types::card_type::{CardType, CoreType}; use crate::types::format::FormatConfig; use crate::types::game_state::{PublicStateDirty, StackEntry, StackEntryKind}; @@ -643,4 +842,126 @@ mod tests { assert_eq!(state.public_state_dirty, PublicStateDirty::default()); assert!(!state.objects[&land_id].has_mana_ability); } + + fn ready_state(stack: Vec) -> GameState { + ready_state_with_active_player(PlayerId(0), stack) + } + + fn ready_state_with_active_player( + active_player: PlayerId, + stack: Vec, + ) -> GameState { + let mut state = priority_state(PlayerId(0), stack); + state.active_player = active_player; + super::super::engine::apply( + &mut state, + PlayerId(0), + GameAction::BeginResolveAll { max_resolutions: 0 }, + ) + .expect("priority holder begins the consent run"); + let epoch = match &state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } => *epoch, + _ => panic!("second representative should be queued"), + }; + super::super::engine::apply( + &mut state, + PlayerId(1), + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("second representative grants"); + assert!(matches!( + &state.waiting_for, + WaitingFor::ResolveAllReady { .. } + )); + state + } + + #[test] + fn ready_consent_uses_the_priority_holder_first_when_active_player_has_passed() { + let mut state = + ready_state_with_active_player(PlayerId(1), vec![no_op_entry(1, PlayerId(0))]); + + let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); + + assert_eq!(result.items_resolved, 1); + assert!(state.stack.is_empty()); + } + + #[test] + fn ready_consent_commits_the_greatest_settled_prefix_and_records_passes() { + // The lower self-copy creates a new stack object. It is deliberately + // left for ordinary priority, while both safe entries above it commit. + let mut state = ready_state(vec![ + self_copy_entry(1, PlayerId(0)), + no_op_entry(2, PlayerId(0)), + no_op_entry(3, PlayerId(0)), + ]); + let run = ready_consent_run(&state, PlayerId(0)) + .expect("the initiating representative remains authorized at Ready") + .clone(); + let mut proof = state.clone(); + proof.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + let (boundary, _) = materialize_one_consented_resolution(&mut proof, &run) + .expect("a full consent run materializes one ordinary priority cycle"); + assert_eq!(stack_resolved_count(&boundary.events), 1); + assert_eq!(proof.stack.len(), 2); + assert!(matches!(proof.waiting_for, WaitingFor::Priority { .. })); + assert!(stack::priority_checkpoint_is_settled(&proof)); + assert!(consent_authorization_matches(&proof, &run)); + + let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); + + assert_eq!(result.items_resolved, 2); + assert_eq!(state.stack.len(), 1, "unsafe item remains on the stack"); + assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); + assert!(state.resolve_all_consent_run.is_none()); + assert_eq!( + result.recorded_actions, + vec![ + (PlayerId(0), GameAction::PassPriority), + (PlayerId(1), GameAction::PassPriority), + (PlayerId(0), GameAction::PassPriority), + (PlayerId(1), GameAction::PassPriority), + ], + "the collapsed prefix remains reproducible through ordinary actions" + ); + } + + #[test] + fn ready_consent_honors_its_saved_resolution_cap() { + let mut state = ready_state(vec![ + no_op_entry(1, PlayerId(0)), + no_op_entry(2, PlayerId(0)), + ]); + state + .resolve_all_consent_run + .as_mut() + .expect("Ready retains its frozen run") + .max_resolutions = 1; + + let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); + + assert_eq!(result.items_resolved, 1); + assert_eq!(state.stack.len(), 1); + assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); + assert!(state.resolve_all_consent_run.is_none()); + } + + #[test] + fn changed_controller_invalidates_ready_consent_without_resolving() { + let mut state = ready_state(vec![no_op_entry(1, PlayerId(0))]); + state.turn_decision_controller = Some(PlayerId(1)); + + let result = resolve_all_ready_prefix(&mut state, PlayerId(0)); + + assert_eq!(result.items_resolved, 0); + assert_eq!(state.stack.len(), 1); + assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); + assert!(state.resolve_all_consent_run.is_none()); + } } diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 6e9e4d0c9f..ea293be5a6 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -245,6 +245,11 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H WaitingFor::OutsideGameChoice { .. } => HumanResponseModel::OutsideSelection, WaitingFor::NamedChoice { .. } => HumanResponseModel::TextChoice, WaitingFor::RespondToShortcut { .. } => HumanResponseModel::ShortcutReply, + // Resolve All consent has a finite, engine-authored Grant/Decline or + // Revoke domain. It is not a CR 732 shortcut-reply protocol. + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } => { + HumanResponseModel::ExactCandidates(AuditedExactCandidates) + } WaitingFor::PrecastCopyShortcutOffer { .. } | WaitingFor::RespondToPrecastCopyShortcut { .. } | WaitingFor::CommanderZoneChoice { .. } @@ -431,7 +436,10 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { None, Some(InteractionSlotKind::Single), ), - WaitingFor::LoopShortcut { .. } | WaitingFor::RespondToShortcut { .. } => ( + WaitingFor::LoopShortcut { .. } + | WaitingFor::RespondToShortcut { .. } + | WaitingFor::ResolveAllConsent { .. } + | WaitingFor::ResolveAllReady { .. } => ( InteractionWaitingForCode::Shortcut, None, Some(InteractionSlotKind::Single), @@ -571,16 +579,70 @@ fn waiting_for_kind(waiting_for: &WaitingFor) -> InteractionWaitingForKind { } } -fn semantic_slots(waiting_for: &WaitingFor) -> Vec<(PlayerId, InteractionSlotKind)> { - let classification = classify_waiting_for(waiting_for); +fn semantic_slots(state: &GameState) -> Vec<(PlayerId, InteractionSlotKind)> { + let classification = classify_waiting_for(&state.waiting_for); let Some(slot_kind) = classification.slot_kind else { return Vec::new(); }; - waiting_for - .acting_players() - .into_iter() - .map(|player| (player, slot_kind)) - .collect() + match &state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch, + representative, + } => state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == *epoch) + .map(|run| { + run.participants + .iter() + .filter(|participant| { + participant.granted && participant.representative != *representative + }) + .map(|participant| (participant.representative, slot_kind)) + .chain(std::iter::once((*representative, slot_kind))) + .collect() + }) + .unwrap_or_default(), + WaitingFor::ResolveAllReady { epoch } => state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == *epoch) + .map(|run| { + run.participants + .iter() + .filter(|participant| participant.granted) + .map(|participant| (participant.representative, slot_kind)) + .collect() + }) + .unwrap_or_default(), + _ => state + .waiting_for + .acting_players() + .into_iter() + .map(|player| (player, slot_kind)) + .collect(), + } +} + +fn interaction_submitter_for_owner(state: &GameState, semantic_owner: PlayerId) -> PlayerId { + let frozen = match &state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } | WaitingFor::ResolveAllReady { epoch } => { + turn_control::resolve_all_granted_submitter(state, *epoch, semantic_owner) + } + _ => None, + }; + frozen.unwrap_or_else(|| turn_control::authorized_submitter_for_player(state, semantic_owner)) +} + +fn interaction_authorized_submitters(state: &GameState) -> Vec { + let mut submitters = Vec::new(); + for (owner, _) in semantic_slots(state) { + let submitter = interaction_submitter_for_owner(state, owner); + if !submitters.contains(&submitter) { + submitters.push(submitter); + } + } + submitters } fn interaction_serial_is_valid(value: &str) -> bool { @@ -651,7 +713,7 @@ fn allocate_interaction_ids( } fn bind_all_current_slots(state: &mut GameState) -> bool { - let semantic = semantic_slots(&state.waiting_for); + let semantic = semantic_slots(state); let Some((ids, generation, serial)) = allocate_interaction_ids(state, semantic.len()) else { return false; }; @@ -741,7 +803,7 @@ pub(crate) fn ensure_interaction_authority(state: &mut GameState) { state.active_interaction_slots.clear(); return; } - let expected = semantic_slots(&state.waiting_for); + let expected = semantic_slots(state); let matches = expected.len() == state.active_interaction_slots.len() && expected.iter().all(|(owner, kind)| { state @@ -757,16 +819,10 @@ pub(crate) fn ensure_interaction_authority(state: &mut GameState) { } pub(crate) fn semantic_owner_for_actor(state: &GameState, actor: PlayerId) -> Option { - let acting = state.waiting_for.acting_players(); - acting - .iter() - .copied() - .find(|owner| *owner == actor) - .or_else(|| { - acting - .into_iter() - .find(|owner| turn_control::authorized_submitter_for_player(state, *owner) == actor) - }) + semantic_slots(state) + .into_iter() + .map(|(owner, _)| owner) + .find(|owner| interaction_submitter_for_owner(state, *owner) == actor) } pub(crate) fn action_preserves_interaction(action: &GameAction) -> bool { @@ -804,7 +860,7 @@ pub(crate) fn rebind_interaction_slots_after_action( }); } let prior = classify_waiting_for(previous_waiting); - let next = semantic_slots(&state.waiting_for); + let next = semantic_slots(state); let preserve_other_simultaneous = prior.simultaneous.is_some(); let mut rebound = Vec::with_capacity(next.len()); let mut needs_id = Vec::new(); @@ -859,7 +915,7 @@ pub(crate) fn debug_assert_interaction_consistency(state: &GameState) { if !interaction_serial_is_valid(&state.next_interaction_serial) { return; } - let expected = semantic_slots(&state.waiting_for); + let expected = semantic_slots(state); debug_assert_eq!(expected.len(), state.active_interaction_slots.len()); let mut ids = HashSet::new(); for (owner, kind) in expected { @@ -3029,6 +3085,7 @@ fn selection_projection( } Ok(match waiting_for { + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } => None, WaitingFor::OpeningHandBottomCards { pending, .. } => pending .iter() .find(|entry| entry.player == semantic_owner) @@ -5102,6 +5159,27 @@ fn project_action_payload( }; surfaces.push(InteractionPresentationSurface::ShortcutResponse { response }); } + GameAction::BeginResolveAll { .. } => { + surfaces.push(InteractionPresentationSurface::ShortcutResponse { + response: InteractionShortcutResponseCode::Propose, + }); + } + GameAction::RespondResolveAllConsent { decision, .. } => { + let response = match decision { + crate::types::actions::ResolveAllConsentDecision::Grant => { + InteractionShortcutResponseCode::Accept + } + crate::types::actions::ResolveAllConsentDecision::Decline => { + InteractionShortcutResponseCode::Decline + } + }; + surfaces.push(InteractionPresentationSurface::ShortcutResponse { response }); + } + GameAction::RevokeResolveAllConsent { .. } => { + surfaces.push(InteractionPresentationSurface::ShortcutResponse { + response: InteractionShortcutResponseCode::Decline, + }); + } // CR 116.2c: two live pay-to-end permissions are two distinct // candidates, so the group key must reach the surface list or they // project identically. The permanent whose resolution installed the @@ -5341,6 +5419,9 @@ fn action_code(action: &GameAction) -> InteractionActionCode { GameAction::RespondToShortcut { .. } => InteractionActionCode::RespondToShortcut, GameAction::DeclineShortcut => InteractionActionCode::DeclineShortcut, GameAction::PrecastCopyShortcut { .. } => InteractionActionCode::PrecastCopyShortcut, + GameAction::BeginResolveAll { .. } => InteractionActionCode::DeclareShortcut, + GameAction::RespondResolveAllConsent { .. } => InteractionActionCode::RespondToShortcut, + GameAction::RevokeResolveAllConsent { .. } => InteractionActionCode::DeclineShortcut, GameAction::Debug(_) => InteractionActionCode::Debug, } } @@ -7425,7 +7506,7 @@ pub fn derive_viewer_interaction( // is a value here, not a silently emptied map: the finalizer is the only // place allowed to decide what an unbounded projection becomes. let attachment_views = attachment_views_for_viewer(filtered_state); - let authorized_submitters = turn_control::authorized_submitters(authoritative_state); + let authorized_submitters = interaction_authorized_submitters(authoritative_state); let can_submit = authorized_submitters.contains(&viewer); let kind = waiting_for_kind(&authoritative_state.waiting_for); if kind.terminal { @@ -7512,10 +7593,8 @@ pub fn derive_viewer_interaction( .active_interaction_slots .iter() .filter(|slot| { - turn_control::authorized_submitter_for_player( - authoritative_state, - PlayerId(slot.semantic_owner), - ) == viewer + interaction_submitter_for_owner(authoritative_state, PlayerId(slot.semantic_owner)) + == viewer }) .collect(); if slots.len() > MAX_INTERACTION_LIST_LEN { @@ -8372,8 +8451,7 @@ fn slot_for_submission<'a>( .iter() .find(|slot| slot.interaction_id == *interaction_id) .ok_or(InteractionReasonCode::StaleInteraction)?; - let authorized = - turn_control::authorized_submitter_for_player(state, PlayerId(slot.semantic_owner)); + let authorized = interaction_submitter_for_owner(state, PlayerId(slot.semantic_owner)); if authorized != actor { return Err(InteractionReasonCode::NotAuthorized); } diff --git a/crates/engine/src/game/public_state.rs b/crates/engine/src/game/public_state.rs index 27cc59babc..21ea7fee72 100644 --- a/crates/engine/src/game/public_state.rs +++ b/crates/engine/src/game/public_state.rs @@ -158,6 +158,15 @@ fn normalize_legacy_attach_waiting_for(state: &mut GameState) { } fn sync_priority_player_from_waiting_for(state: &mut GameState) { + // Resolve All temporarily presents consent slots without transferring the + // underlying priority window. Its run snapshot owns that priority until a + // decline restores it or the Ready consumer materializes its passes. + if matches!( + &state.waiting_for, + WaitingFor::ResolveAllConsent { .. } | WaitingFor::ResolveAllReady { .. } + ) { + return; + } if let Some(player) = state.waiting_for.acting_player() { state.priority_player = turn_control::authorized_submitter_for_player(state, player); } diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index bf85063a23..731f63bb67 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1846,6 +1846,8 @@ impl GameRunner { pub fn waiting_for_kind(&self) -> &'static str { match &self.state.waiting_for { WaitingFor::Priority { .. } => "Priority", + WaitingFor::ResolveAllConsent { .. } => "ResolveAllConsent", + WaitingFor::ResolveAllReady { .. } => "ResolveAllReady", WaitingFor::MeldPairChoice { .. } => "MeldPairChoice", WaitingFor::MeldAttackTargetChoice { .. } => "MeldAttackTargetChoice", WaitingFor::EntryAttackTargetChoice { .. } => "EntryAttackTargetChoice", diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index bd58e67dce..a83830bfe6 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -2917,7 +2917,7 @@ fn resolve_proven_inert_trigger_batch( run_len: u32, pipeline_invariant: Option, ) -> Option { - if !inert_trigger_batch_state_is_settled(state) { + if !priority_checkpoint_is_settled(state) { return None; } @@ -2966,7 +2966,7 @@ fn resolve_proven_inert_trigger_batch( || counters_after_resolution .is_some_and(|before| battlefield_counter_snapshot(&proof) != before) || initial_len.saturating_sub(proof.stack.len()) != expected_consumed - || !inert_trigger_batch_state_is_settled(&proof) + || !priority_checkpoint_is_settled(&proof) { return None; } @@ -3025,7 +3025,11 @@ fn consumed_trigger_event_occurrences( .collect() } -fn inert_trigger_batch_state_is_settled(state: &GameState) -> bool { +/// True when resolution has reached a full priority checkpoint with no latent +/// trigger, replacement, or continuation work. Batch consumers that prove a +/// sequence on a clone share this boundary rather than inferring safety from +/// stack depth alone. +pub(crate) fn priority_checkpoint_is_settled(state: &GameState) -> bool { state.pending_replacement.is_none() && state.pending_trigger.is_none() && state.pending_trigger_event_batch.is_empty() @@ -7490,8 +7494,8 @@ mod tests { // Driver internals under test (the stack module). use super::super::{ batch_run_len, effects, fixed_controller_gain_life_run_len, - fixed_opponent_lose_life_run_len, inert_trigger_batch_state_is_settled, - observers_are_batch_safe, resolve_next, resolve_next_with_limit, resolve_top, + fixed_opponent_lose_life_run_len, observers_are_batch_safe, + priority_checkpoint_is_settled, resolve_next, resolve_next_with_limit, resolve_top, self_counter_run_len, }; // Test fixtures from the parent `tests` module. @@ -8160,7 +8164,7 @@ mod tests { }); assert!( - !inert_trigger_batch_state_is_settled(&state), + !priority_checkpoint_is_settled(&state), "an active resolution frame makes a skipped priority checkpoint observable" ); } diff --git a/crates/engine/src/game/turn_control.rs b/crates/engine/src/game/turn_control.rs index cc8531bfaf..f408b5942b 100644 --- a/crates/engine/src/game/turn_control.rs +++ b/crates/engine/src/game/turn_control.rs @@ -352,6 +352,26 @@ fn search_decision_authority( } pub fn authorized_submitter_for_player(state: &GameState, semantic_player: PlayerId) -> PlayerId { + // Resolve All consent freezes the submitting authority at proposal time. + // This must win over live turn control: otherwise a control effect that + // changes while a representative is queued could redirect an already-issued + // response to a different actor. + if let WaitingFor::ResolveAllConsent { + epoch, + representative, + } = &state.waiting_for + { + if *representative == semantic_player { + if let Some(submitter) = state + .resolve_all_consent_run + .as_ref() + .filter(|run| run.epoch == *epoch) + .and_then(|run| run.authorized_submitter_for(*representative)) + { + return submitter; + } + } + } match search_decision_authority(state, semantic_player) { Some(ActiveSearchDecisionAuthority::LatchedController { controller }) => controller, Some(ActiveSearchDecisionAuthority::SearcherFallback) => semantic_player, @@ -359,6 +379,49 @@ pub fn authorized_submitter_for_player(state: &GameState, semantic_player: Playe } } +/// Returns the frozen submitter who may revoke one granted Resolve All consent. +/// Revoke is valid while a later representative is queued and after the run is +/// Ready, so it cannot be expressed through the ordinary single-actor prompt. +pub fn resolve_all_granted_submitter( + state: &GameState, + epoch: u64, + representative: PlayerId, +) -> Option { + matches!( + &state.waiting_for, + WaitingFor::ResolveAllConsent { epoch: active, .. } + | WaitingFor::ResolveAllReady { epoch: active } + if *active == epoch + ) + .then(|| state.resolve_all_consent_run.as_ref()) + .flatten() + .filter(|run| run.epoch == epoch && run.is_granted(representative)) + .and_then(|run| run.authorized_submitter_for(representative)) +} + +/// Drops an active Resolve All consent run when player topology changes. A +/// frozen representative set is no longer meaningful after elimination, so +/// restart ordinary priority from a living representative instead of trying to +/// repair the proposal in place. +pub fn invalidate_resolve_all_consent(state: &mut GameState) { + if state.resolve_all_consent_run.take().is_none() { + return; + } + let preferred = super::topology::priority_pass_representative(state, state.active_player); + let player = super::players::is_alive(state, preferred) + .then_some(preferred) + .or_else(|| { + super::topology::priority_pass_participants(state) + .first() + .copied() + }) + .unwrap_or(preferred); + state.waiting_for = WaitingFor::Priority { player }; + state.priority_player = authorized_submitter_for_player(state, player); + state.priority_pass_count = 0; + state.priority_passes.clear(); +} + /// CR 723.4: A controlled player and the player controlling them may see the /// controlled player's private information while that control applies. pub fn decision_audience_for_player(state: &GameState, semantic_player: PlayerId) -> Vec { diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 22edde1c34..6aa57d3ec3 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -238,6 +238,10 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState filtered.interaction_generation = 0; filtered.next_interaction_serial = "1".to_string(); filtered.active_interaction_slots.clear(); + // Resolve All consent's frozen authority and priority restoration snapshot + // are server-private. The public WaitingFor state is sufficient to render + // the current consent or ready status. + filtered.resolve_all_consent_run = None; // Product knowledge is projection authority, never transport payload. Its // effect is applied below before hidden cards are redacted; viewers receive // identities they learned, not the audience facts or library epochs behind diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index 5ee9a358d4..8dc6596645 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -809,6 +809,43 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { .then_with(|| cmp_val(a1, b1)) .then_with(|| cmp_val(a2, b2)) } + GameAction::BeginResolveAll { + max_resolutions: a0, + } => { + let GameAction::BeginResolveAll { + max_resolutions: b0, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } + GameAction::RespondResolveAllConsent { + epoch: a0, + decision: a1, + } => { + let GameAction::RespondResolveAllConsent { + epoch: b0, + decision: b1, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } + GameAction::RevokeResolveAllConsent { + epoch: a0, + representative: a1, + } => { + let GameAction::RevokeResolveAllConsent { + epoch: b0, + representative: b1, + } = b + else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0).then_with(|| cmp_val(a1, b1)) + } GameAction::DiscoverChoice { choice: a0 } => { let GameAction::DiscoverChoice { choice: b0 } = b else { unreachable!("cmp_payload: same-variant invariant"); @@ -1661,7 +1698,9 @@ mod tests { DecisionGroupKey, DecisionKind, DecisionTemplate, IterationCount, ReplayMode, }; use crate::game::combat::AttackTarget; - use crate::types::actions::{MayTriggerAutoChoiceOp, PrecastCopyShortcutResponse}; + use crate::types::actions::{ + MayTriggerAutoChoiceOp, PrecastCopyShortcutResponse, ResolveAllConsentDecision, + }; use crate::types::game_state::{EndEffectGroupId, MayTriggerAutoChoiceKey, MayTriggerOrigin}; use crate::types::identifiers::ObjectId; use crate::types::mana::{ManaCost, ManaCostShard}; @@ -1674,6 +1713,30 @@ mod tests { #[test] fn newer_action_variants_compare_their_payloads() { + assert_distinct_order( + GameAction::BeginResolveAll { max_resolutions: 1 }, + GameAction::BeginResolveAll { max_resolutions: 2 }, + ); + assert_distinct_order( + GameAction::RespondResolveAllConsent { + epoch: 1, + decision: ResolveAllConsentDecision::Grant, + }, + GameAction::RespondResolveAllConsent { + epoch: 1, + decision: ResolveAllConsentDecision::Decline, + }, + ); + assert_distinct_order( + GameAction::RevokeResolveAllConsent { + epoch: 1, + representative: PlayerId(0), + }, + GameAction::RevokeResolveAllConsent { + epoch: 1, + representative: PlayerId(1), + }, + ); assert_distinct_order( GameAction::EndContinuousEffect { group: EndEffectGroupId(1), diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index 6513cb966e..b3922af953 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -941,15 +941,41 @@ pub enum GameAction { /// display them verbatim and echo them back; dispatch revalidates `group` /// against live state and never trusts either echoed value. /// - /// Appended at the END of this enum on purpose: `GameActionKind` derives - /// `PartialOrd, Ord`, so a mid-enum insertion would renumber later - /// discriminants and shift `cmp_stable` ordering (AI candidate ordering and - /// replay determinism). + /// Kept after the existing action variants so their derived + /// `GameActionKind` ordering remains stable for deterministic replay. EndContinuousEffect { group: crate::types::game_state::EndEffectGroupId, source_name: String, cost: crate::types::mana::ManaCost, }, + /// Begins the table-consent protocol for the forthcoming Resolve All batch. + /// Phase 1 only records unanimous consent; it deliberately does not drive + /// priority or resolve the batch. + BeginResolveAll { + max_resolutions: u32, + }, + /// Answers the currently queued Resolve All consent prompt. `epoch` makes + /// delayed transport submissions fail closed rather than answering a newer + /// proposal. + RespondResolveAllConsent { + epoch: u64, + decision: ResolveAllConsentDecision, + }, + /// Withdraws a representative's prior Resolve All consent while the exact + /// epoch remains active. This is intentionally available from Ready as + /// well as while another representative is queued. + RevokeResolveAllConsent { + epoch: u64, + representative: PlayerId, + }, +} + +/// One representative's explicit Resolve All decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum ResolveAllConsentDecision { + Grant, + Decline, } /// CR 117.3d: The mutation a `GameAction::SetPriorityYield` performs on the @@ -1775,6 +1801,9 @@ impl GameAction { | GameAction::RespondToShortcut { .. } | GameAction::DeclineShortcut | GameAction::PrecastCopyShortcut { .. } + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } // CR 116.2c: the payload names a continuous-effect GROUP, not a // permanent — a global action with no source object (frontend // Pattern A). The Licid that installed the effect is not addressed diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 26cce430a9..84cf0b5d64 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -85,6 +85,10 @@ fn initial_delayed_trigger_instance_id() -> u64 { 1 } +fn initial_resolve_all_consent_epoch() -> u64 { + 1 +} + fn default_interaction_serial() -> String { "1".to_string() } @@ -1904,6 +1908,58 @@ pub struct PriorityYield { pub target: YieldTarget, } +/// Exact priority state restored if a Resolve All consent run is declined, +/// revoked, or invalidated before it becomes actionable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveAllPrioritySnapshot { + pub waiting_player: PlayerId, + pub priority_player: PlayerId, + pub priority_pass_count: u8, + pub priority_passes: BTreeSet, +} + +/// One canonical priority representative and the submitter authorized for that +/// representative when a Resolve All consent run began. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveAllConsentParticipant { + pub representative: PlayerId, + pub authorized_submitter: PlayerId, + pub granted: bool, +} + +/// Server-authoritative state behind the public Resolve All consent prompts. +/// The frozen submitters prevent turn-control changes from rebinding a queued +/// response or a later revocation to a different person. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveAllConsentRun { + pub epoch: u64, + pub max_resolutions: u32, + pub priority_snapshot: ResolveAllPrioritySnapshot, + pub participants: Vec, +} + +impl ResolveAllConsentRun { + pub fn authorized_submitter_for(&self, representative: PlayerId) -> Option { + self.participants + .iter() + .find(|participant| participant.representative == representative) + .map(|participant| participant.authorized_submitter) + } + + pub fn is_granted(&self, representative: PlayerId) -> bool { + self.participants + .iter() + .any(|participant| participant.representative == representative && participant.granted) + } + + pub fn next_pending_representative(&self) -> Option { + self.participants + .iter() + .find(|participant| !participant.granted) + .map(|participant| participant.representative) + } +} + /// CR 609.7a: A source of damage chosen while creating a prevention or /// replacement effect. The original filter is retained so property-based /// choices such as "red source of your choice" recheck source qualities when @@ -10624,6 +10680,17 @@ pub enum WaitingFor { Priority { player: PlayerId, }, + /// Public Resolve All consent prompt. The protocol details and frozen + /// submitter ledger remain in `GameState::resolve_all_consent_run`. + ResolveAllConsent { + epoch: u64, + representative: PlayerId, + }, + /// Every canonical representative granted the same Resolve All epoch. + /// Phase 1 deliberately keeps this state inert; a later phase consumes it. + ResolveAllReady { + epoch: u64, + }, /// CR 608.2d + CR 701.42: choose the exact pair of current battlefield /// referents the meld instruction will exile. Candidate identity is frozen /// in the tuples; the physical meld-card check intentionally happens later. @@ -12954,6 +13021,8 @@ impl WaitingFor { pub fn variant_name(&self) -> &'static str { match self { WaitingFor::Priority { .. } => "Priority", + WaitingFor::ResolveAllConsent { .. } => "ResolveAllConsent", + WaitingFor::ResolveAllReady { .. } => "ResolveAllReady", WaitingFor::MeldPairChoice { .. } => "MeldPairChoice", WaitingFor::MeldAttackTargetChoice { .. } => "MeldAttackTargetChoice", WaitingFor::EntryAttackTargetChoice { .. } => "EntryAttackTargetChoice", @@ -13108,7 +13177,12 @@ impl WaitingFor { None } } + WaitingFor::ResolveAllReady { .. } => None, WaitingFor::Priority { player } + | WaitingFor::ResolveAllConsent { + representative: player, + .. + } | WaitingFor::MeldPairChoice { player, .. } | WaitingFor::MeldAttackTargetChoice { player, .. } | WaitingFor::EntryAttackTargetChoice { player, .. } @@ -15103,6 +15177,13 @@ declare_game_state! { // Game flow pub waiting_for: WaitingFor, + /// Persisted allocation source for Resolve All consent epochs. Starts at + /// one for legacy saves and is minted only by `BeginResolveAll`. + #[serde(default = "initial_resolve_all_consent_epoch")] + pub next_resolve_all_consent_epoch: u64, + /// Private protocol ledger behind the public consent/ready waiting states. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolve_all_consent_run: Option, /// Trusted interaction capability scope. Viewer-filtered copies always /// redact this field; only the engine uses it to mint opaque decision IDs. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -21033,6 +21114,8 @@ impl GameState { waiting_for: WaitingFor::Priority { player: starting_player, }, + next_resolve_all_consent_epoch: initial_resolve_all_consent_epoch(), + resolve_all_consent_run: None, interaction_session_id: None, interaction_generation: 0, next_interaction_serial: default_interaction_serial(), @@ -22864,6 +22947,8 @@ fn _gamestate_partition_is_total(s: &GameState) { rng: _, combat: _, waiting_for: _, + next_resolve_all_consent_epoch: _, + resolve_all_consent_run: _, interaction_session_id: _, interaction_generation: _, next_interaction_serial: _, @@ -23210,6 +23295,8 @@ impl PartialEq for GameState { && self.rng_seed == other.rng_seed && self.combat == other.combat && self.waiting_for == other.waiting_for + && self.next_resolve_all_consent_epoch == other.next_resolve_all_consent_epoch + && self.resolve_all_consent_run == other.resolve_all_consent_run && self.lands_played_this_turn == other.lands_played_this_turn && self.max_lands_per_turn == other.max_lands_per_turn && self.priority_pass_count == other.priority_pass_count @@ -29103,6 +29190,11 @@ mod tests { variants.push(Box::new(WaitingFor::Priority { player: PlayerId(0), })); + variants.push(Box::new(WaitingFor::ResolveAllConsent { + epoch: 1, + representative: PlayerId(0), + })); + variants.push(Box::new(WaitingFor::ResolveAllReady { epoch: 1 })); variants.push(Box::new(WaitingFor::MulliganDecision { pending: vec![MulliganDecisionEntry { player: PlayerId(0), @@ -29437,7 +29529,7 @@ mod tests { mana_reduction: ManaCost::zero(), pending_cast: dummy_pending(), })); - assert_eq!(variants.len(), 37); + assert_eq!(variants.len(), 39); } #[test] diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 8fd48cb658..bf1aae1c26 100644 Binary files a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz and b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz differ diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index f54f335817..3c12b6f675 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -4818,8 +4818,8 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac // ── the classifier's own reach-guard: the enum was actually found ── let total = enum_variants(&enum_src, "WaitingFor").len(); assert_eq!( - total, 130, - "`WaitingFor` has 130 variants at this tip, read off the `syn` parse. This number is \ + total, 132, + "`WaitingFor` has 132 variants at this tip, read off the `syn` parse. This number is \ pinned so a variant REMOVED is as visible as one added; if you added a variant and it \ carries no `DecisionTemplate`, update this number. A wildly different count means the \ reader lost its anchor, and every assertion below would then be measuring an empty enum" @@ -4839,6 +4839,8 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac // contradicting itself, and a second instrument that disagrees with the `syn` parse is worth // less than no second instrument. The reach-guard did its whole job here: this drift produced // no merge conflict and could not have, so CI was the only thing between it and shipping. + // 130 ⇒ 132 is ADJUDICATED: ResolveAllConsent and ResolveAllReady are control-protocol states + // with no DecisionTemplate payload, so neither expands the carrier set nor the redaction duty. let carriers = carriers_in_source(&enum_src, "WaitingFor", &corpus, &marker, true); assert_eq!( diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index d9cfb8b21e..ca1b5668f2 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -936,6 +936,7 @@ mod relic_of_progenitus_6446; mod render_silent_cant_cast; mod replacement_mill_double_application; mod repro_pilot_crew; +mod resolve_all_consent; mod retarget_prompt_softlock; mod revealed_card_type_disjunction_518; mod rhys_evermore_remove_counters; diff --git a/crates/engine/tests/integration/resolve_all_consent.rs b/crates/engine/tests/integration/resolve_all_consent.rs new file mode 100644 index 0000000000..e467a9790d --- /dev/null +++ b/crates/engine/tests/integration/resolve_all_consent.rs @@ -0,0 +1,337 @@ +//! Phase-1 protocol coverage for explicit Resolve All consent. + +use engine::ai_support::{candidate_actions, legal_actions_for_viewer}; +use engine::game::elimination::eliminate_player; +use engine::game::engine::apply; +use engine::game::interaction::{ + bind_interaction_authority, derive_viewer_interaction, resolve_interaction_response, +}; +use engine::game::visibility::filter_state_for_viewer; +use engine::types::actions::{GameAction, ResolveAllConsentDecision}; +use engine::types::format::FormatConfig; +use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::interaction::{ + InteractionOpportunityResponse, InteractionResponse, InteractionSessionId, + InteractionSubmission, +}; +use engine::types::player::PlayerId; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); +const P2: PlayerId = PlayerId(2); + +fn begin(state: &mut GameState) -> u64 { + apply( + state, + P0, + GameAction::BeginResolveAll { max_resolutions: 7 }, + ) + .expect("priority holder may begin Resolve All consent"); + match &state.waiting_for { + WaitingFor::ResolveAllConsent { + epoch, + representative, + } => { + assert_eq!( + *representative, P1, + "initiator grants before the queue opens" + ); + *epoch + } + ref other => panic!("expected queued consent, got {other:?}"), + } +} + +#[test] +fn consent_queue_reaches_inert_ready_only_after_every_representative_grants() { + let mut state = GameState::new_two_player(42); + let epoch = begin(&mut state); + + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("queued representative may grant"); + assert!(matches!( + &state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if *ready_epoch == epoch + )); + assert!(apply(&mut state, P1, GameAction::PassPriority).is_err()); + assert!(matches!( + &state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if *ready_epoch == epoch + )); +} + +#[test] +fn stale_epoch_and_decline_restore_the_exact_priority_snapshot() { + let mut state = GameState::new_two_player(43); + state.priority_pass_count = 3; + state.priority_passes.insert(P0); + let epoch = begin(&mut state); + + assert!(apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch: epoch + 1, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .is_err()); + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Decline, + }, + ) + .expect("queued representative may decline"); + + assert!(matches!(&state.waiting_for, WaitingFor::Priority { player } if *player == P0)); + assert_eq!(state.priority_player, P0); + assert_eq!(state.priority_pass_count, 3); + assert!(state.priority_passes.contains(&P0)); + assert!(state.resolve_all_consent_run.is_none()); + assert!(apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .is_err()); +} + +#[test] +fn eliminating_a_consent_representative_drops_the_run_and_restores_living_priority() { + let mut state = GameState::new(FormatConfig::free_for_all(), 3, 44); + apply( + &mut state, + P0, + GameAction::BeginResolveAll { max_resolutions: 7 }, + ) + .expect("priority holder may begin Resolve All consent"); + assert!(matches!( + &state.waiting_for, + WaitingFor::ResolveAllConsent { + representative: P1, + .. + } + )); + state.priority_pass_count = 2; + state.priority_passes.insert(P0); + + eliminate_player(&mut state, P1, &mut Vec::new()); + + assert!(state.players[P1.0 as usize].is_eliminated); + assert!(state.resolve_all_consent_run.is_none()); + assert!(matches!(&state.waiting_for, WaitingFor::Priority { player } if *player == P0)); + assert_eq!(state.priority_player, P0); + assert_eq!(state.priority_pass_count, 0); + assert!(state.priority_passes.is_empty()); + assert!(!state.players[P2.0 as usize].is_eliminated); +} + +#[test] +fn queued_response_and_candidate_keep_the_frozen_submitter_after_control_changes() { + let mut state = GameState::new_two_player(44); + let epoch = begin(&mut state); + state.active_player = P1; + state.turn_decision_controller = Some(P0); + + let candidates = candidate_actions(&state); + assert!(candidates.iter().any(|candidate| { + matches!( + candidate.action, + GameAction::RespondResolveAllConsent { + epoch: candidate_epoch, + decision: ResolveAllConsentDecision::Grant, + } if candidate_epoch == epoch + ) && candidate.metadata.actor == Some(P1) + })); + assert!(apply( + &mut state, + P0, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .is_err()); + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("frozen submitter, not the new live controller, answers the prompt"); +} + +#[test] +fn granted_representative_can_revoke_off_queue_and_private_run_is_not_visible() { + let mut state = GameState::new_two_player(45); + let epoch = begin(&mut state); + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("reach ready state"); + + let view = filter_state_for_viewer(&state, P1); + assert!(matches!(&view.waiting_for, WaitingFor::ResolveAllReady { epoch: e } if *e == epoch)); + assert!(view.resolve_all_consent_run.is_none()); + + let candidates = candidate_actions(&state); + assert!(candidates.iter().any(|candidate| { + matches!( + candidate.action, + GameAction::RevokeResolveAllConsent { + epoch: candidate_epoch, + representative: P0, + } if candidate_epoch == epoch + ) && candidate.metadata.actor == Some(P0) + })); + apply( + &mut state, + P0, + GameAction::RevokeResolveAllConsent { + epoch, + representative: P0, + }, + ) + .expect("a granted representative may revoke from Ready"); + assert!(matches!(&state.waiting_for, WaitingFor::Priority { player } if *player == P0)); + assert!(state.resolve_all_consent_run.is_none()); +} + +#[test] +fn transport_surfaces_only_each_grantors_own_revoke_and_uses_exact_consent_choices() { + let mut state = GameState::new_two_player(46); + let epoch = begin(&mut state); + + let p0_actions = legal_actions_for_viewer(&state, P0).0; + assert_eq!( + p0_actions, + vec![GameAction::RevokeResolveAllConsent { + epoch, + representative: P0, + }], + "an off-prompt grantor receives only its own frozen revoke" + ); + let p1_actions = legal_actions_for_viewer(&state, P1).0; + assert!(p1_actions + .iter() + .all(|action| { !matches!(action, GameAction::RevokeResolveAllConsent { .. }) })); + assert!(p1_actions.iter().any(|action| { + matches!( + action, + GameAction::RespondResolveAllConsent { + epoch: action_epoch, + decision: ResolveAllConsentDecision::Grant, + } if *action_epoch == epoch + ) + })); + + bind_interaction_authority(&mut state, InteractionSessionId("resolve-all".to_string())) + .expect("consent slots bind for each authorized owner"); + let p0_view = derive_viewer_interaction(&state, &filter_state_for_viewer(&state, P0), P0); + let p1_view = derive_viewer_interaction(&state, &filter_state_for_viewer(&state, P1), P1); + assert!(p0_view.can_submit); + assert!(p1_view.can_submit); + assert_eq!(p0_view.opportunities.len(), 1); + assert_eq!(p1_view.opportunities.len(), 1); + + let InteractionOpportunityResponse::ExactChoices { choices } = + &p0_view.opportunities[0].response + else { + panic!("off-prompt revoke must use an exact choice, not the CR 732 reply schema"); + }; + let choice_id = choices + .first() + .expect("grantor has one revoke choice") + .id + .clone(); + let action = resolve_interaction_response( + &state, + P0, + &InteractionSubmission { + interaction_id: p0_view.opportunities[0].interaction_id.clone(), + response: InteractionResponse::Choose { choice_id }, + }, + ) + .expect("transport may materialize the off-prompt revoke"); + assert_eq!( + action, + GameAction::RevokeResolveAllConsent { + epoch, + representative: P0, + } + ); + + let InteractionOpportunityResponse::ExactChoices { choices } = + &p1_view.opportunities[0].response + else { + panic!("queued consent must use bounded exact grant/decline choices"); + }; + assert_eq!(choices.len(), 2); +} + +#[test] +fn ready_state_transport_materializes_each_grantors_frozen_revoke() { + let mut state = GameState::new_two_player(47); + let epoch = begin(&mut state); + apply( + &mut state, + P1, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("the final grant reaches Ready"); + + bind_interaction_authority( + &mut state, + InteractionSessionId("resolve-all-ready".to_string()), + ) + .expect("Ready binds one slot per frozen grantor"); + let p0_view = derive_viewer_interaction(&state, &filter_state_for_viewer(&state, P0), P0); + assert_eq!(p0_view.opportunities.len(), 1); + let InteractionOpportunityResponse::ExactChoices { choices } = + &p0_view.opportunities[0].response + else { + panic!("Ready revoke must remain an exact choice"); + }; + assert_eq!(choices.len(), 1); + let action = resolve_interaction_response( + &state, + P0, + &InteractionSubmission { + interaction_id: p0_view.opportunities[0].interaction_id.clone(), + response: InteractionResponse::Choose { + choice_id: choices[0].id.clone(), + }, + }, + ) + .expect("Ready has no acting player, but its frozen grantor may still revoke"); + assert_eq!( + action, + GameAction::RevokeResolveAllConsent { + epoch, + representative: P0, + } + ); +} diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index 7f5ac20826..c74f99c42d 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; 88] = [ +static UNSUPPORTED_PROTOCOL_CAPABILITIES: [UnsupportedCapability; 89] = [ UnsupportedCapability { code: "upstream.object-selection-missing", area: "prompts", @@ -765,6 +765,12 @@ static UNSUPPORTED_PROTOCOL_CAPABILITIES: [UnsupportedCapability; 88] = [ reason: "v2 added ChooseActionOutput::Pass.exhaustStack (pass until the stack empties). Like pass.until it is a multi-window intent, and Phase's PassPriority yields exactly one priority window.", suggested_protocol_extension: "Clarify whether exhaustStack is advisory or requires an engine-backed auto-pass contract, alongside pass.until.", }, + UnsupportedCapability { + code: "local.resolve-all-unsupported", + area: "responses", + reason: "Phase's Resolve All consent protocol has no upstream action family and cannot be faithfully round-tripped as ordinary priority passing.", + suggested_protocol_extension: "Add an explicit consent-backed stack-resolution shortcut protocol, including grant, decline, and revocation semantics.", + }, UnsupportedCapability { code: "local.meld-pair-choice-unsupported", area: "prompts", @@ -2577,6 +2583,13 @@ pub fn convert_available_action( | GameAction::CancelCast | GameAction::BackToManaPayment | GameAction::Concede { .. } => AvailableActionConversion::Skip, + // The upstream protocol has no consent-shortcut action family. Do not + // advertise an action it cannot round-trip; surface the fidelity gap. + GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } => { + AvailableActionConversion::Unsupported("local.resolve-all-unsupported") + } GameAction::DeclareAttackers { .. } => AvailableActionConversion::Skip, GameAction::DeclareBlockers { .. } => AvailableActionConversion::Skip, GameAction::ChooseUntap { .. } => { @@ -8157,13 +8170,13 @@ mod tests { #[test] fn unsupported_capability_registry_is_well_formed() { let capabilities = unsupported_protocol_capabilities(); - assert_eq!(capabilities.len(), 88); + assert_eq!(capabilities.len(), 89); let codes: HashSet<_> = capabilities .iter() .map(|capability| capability.code) .collect(); - assert_eq!(codes.len(), 88, "capability codes must be unique"); + assert_eq!(codes.len(), 89, "capability codes must be unique"); for capability in capabilities { assert!( @@ -8334,6 +8347,7 @@ mod tests { "local.harmonize-tap-unsupported", "local.payment-resource-actions-missing", "local.exhaust-stack-pass-unsupported", + "local.resolve-all-unsupported", // Every code the adapter can emit must be declared here, or a // client that receives it looks it up and finds nothing. "local.dungeon-room-unsupported", diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index dfdf41dbba..a7b4b4c208 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -73,7 +73,9 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { // All other WaitingFor states are mechanical/forced choices that no // tactical policy currently routes on. Map them to ActivateAbility as // the catch-all bucket so policies that explicitly opt in still run. - WaitingFor::ReplacementChoice { .. } + WaitingFor::ResolveAllConsent { .. } + | WaitingFor::ResolveAllReady { .. } + | WaitingFor::ReplacementChoice { .. } | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } | WaitingFor::EntryAttackTargetChoice { .. } diff --git a/crates/phase-ai/src/policies/discard_payoff.rs b/crates/phase-ai/src/policies/discard_payoff.rs index c213f9199b..8e51205135 100644 --- a/crates/phase-ai/src/policies/discard_payoff.rs +++ b/crates/phase-ai/src/policies/discard_payoff.rs @@ -180,6 +180,9 @@ fn candidate_discards_controller(ctx: &PolicyContext<'_>) -> bool { // this match at compile time and forces an intentional classification // instead of silently bypassing the discard payoff (CR 701.9). GameAction::PassPriority + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } | GameAction::ChooseMeldPair { .. } | GameAction::ChooseEntryAttackTarget { .. } | GameAction::PlayLand { .. } diff --git a/crates/phase-ai/src/policies/draw_payoff.rs b/crates/phase-ai/src/policies/draw_payoff.rs index a769b7b4b7..29fe95603d 100644 --- a/crates/phase-ai/src/policies/draw_payoff.rs +++ b/crates/phase-ai/src/policies/draw_payoff.rs @@ -207,6 +207,9 @@ fn candidate_draws_structurally(ctx: &PolicyContext<'_>) -> bool { // this match at compile time and forces an intentional classification // instead of silently bypassing the draw payoff (CR 121.1). GameAction::PassPriority + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } | GameAction::ChooseMeldPair { .. } | GameAction::ChooseEntryAttackTarget { .. } | GameAction::PlayLand { .. } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index c7d08fb1ab..d5395c014f 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1232,6 +1232,22 @@ pub fn fallback_action( // Terminal — no action possible. WaitingFor::GameOver { .. } => None, + // Resolve All is opt-in. If no policy selected one of the engine-issued + // consent actions, decline the shortcut rather than leave its + // representative's decision unanswered. + WaitingFor::ResolveAllConsent { .. } => issued(|action| { + matches!( + action, + GameAction::RespondResolveAllConsent { + decision: engine::types::actions::ResolveAllConsentDecision::Decline, + .. + } + ) + }), + // Ready has no acting player; the authorized frontend consumer starts + // the bounded prefix drain. + WaitingFor::ResolveAllReady { .. } => None, + // Priority is the only state where PassPriority is valid. WaitingFor::Priority { .. } => Some(GameAction::PassPriority), diff --git a/crates/phase-server/src/main.rs b/crates/phase-server/src/main.rs index 9505420668..b2060e3921 100644 --- a/crates/phase-server/src/main.rs +++ b/crates/phase-server/src/main.rs @@ -7743,9 +7743,9 @@ async fn handle_client_message( #[cfg(test)] mod state_transport_derived_tests { use super::*; - use engine::game::deck_loading::PlayerDeckPayload; + use engine::game::{deck_loading::PlayerDeckPayload, engine::apply}; use engine::types::ability::{Effect, ResolvedAbility, SearchSelectionConstraint}; - use engine::types::actions::GameAction; + use engine::types::actions::{GameAction, ResolveAllConsentDecision}; use engine::types::game_state::{ ActiveSearchDecisionAuthority, ActiveSearchDecisionControl, PriorityPassingMode, StackEntry, StackEntryKind, WaitingFor, @@ -7927,6 +7927,31 @@ mod state_transport_derived_tests { )), }, }); + apply( + &mut session.state, + PlayerId(0), + GameAction::BeginResolveAll { max_resolutions: 1 }, + ) + .expect("priority holder may start Resolve All consent"); + let epoch = match session.state.waiting_for { + WaitingFor::ResolveAllConsent { epoch, .. } => epoch, + ref other => { + panic!("Resolve All consent must await the AI representative, got {other:?}") + } + }; + apply( + &mut session.state, + ai_player, + GameAction::RespondResolveAllConsent { + epoch, + decision: ResolveAllConsentDecision::Grant, + }, + ) + .expect("AI representative may grant Resolve All consent"); + assert!(matches!( + session.state.waiting_for, + WaitingFor::ResolveAllReady { epoch: ready_epoch } if ready_epoch == epoch + )); let revision_before = session.state_revision; let state: SharedState = Arc::new(Mutex::new(manager)); diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index f3e4e72aed..22b1eb17d1 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -647,6 +647,9 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { guard_debug_action_payload(debug_action)?; } GameAction::PassPriority + | GameAction::BeginResolveAll { .. } + | GameAction::RespondResolveAllConsent { .. } + | GameAction::RevokeResolveAllConsent { .. } | GameAction::PlayLand { .. } | GameAction::Foretell { .. } | GameAction::ActivateAbility { .. } diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index f9aebd2542..4ad58921f6 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -2,14 +2,13 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use engine::ai_support::{ - auto_pass_recommended, legal_actions_full as engine_legal_actions_full, AiDecisionContract, -}; +use engine::ai_support::{auto_pass_recommended, legal_actions_full as engine_legal_actions_full}; use engine::database::legality::{validate_cedh_bracket, CedhBracketError}; use engine::database::CardDatabase; use engine::game::deck_loading::{DeckPayload, PlayerDeckPayload}; -use engine::game::engine::{ - apply, resolve_all_fast_forward, start_game, ResolveAllCallbackDecision, +use engine::game::engine::{apply, start_game}; +use engine::game::engine_resolve_batch::{ + resolve_all_ready_prefix, resolve_all_ready_requester_is_authorized, }; use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::layers::flush_layers; @@ -33,7 +32,6 @@ use engine::types::mana::ManaCost; use engine::types::match_config::MatchConfig; use engine::types::match_config::MatchForfeitCause; use engine::types::player::PlayerId; -use phase_ai::choose_action_with_session; use phase_ai::config::{AiConfig, AiDifficulty, Platform}; use phase_ai::session::AiSession; use rand::{Rng, SeedableRng}; @@ -100,8 +98,8 @@ pub type ActionResult = ( pub type RevisionedActionResult = (u64, ActionResult); /// Maximum server-authorized stack entries in one remote Resolve All request. -/// The wire request is untrusted; `0` is intentionally not the engine's -/// unlimited sentinel on this transport. +/// The wire request is untrusted, but `0` remains the engine-defined uncapped +/// sentinel; the active consent run, not this transport value, owns the cap. pub const MAX_RESOLVE_ALL_RESOLUTIONS: u32 = 5_000; #[derive(Debug, Clone)] @@ -1574,17 +1572,18 @@ impl SessionManager { )) } - /// Fast-forwards stack resolution for an authenticated player while every - /// non-requester priority holder is a server-configured AI seat. + /// Consumes an engine-issued Resolve All consent run for an authenticated + /// player. Every priority representative has already granted consent, so + /// the state must be `WaitingFor::ResolveAllReady`. pub fn resolve_all_for_player( &mut self, game_code: &str, player_token: &str, max_resolutions: u32, ) -> Result { - if max_resolutions == 0 || max_resolutions > MAX_RESOLVE_ALL_RESOLUTIONS { + if max_resolutions > MAX_RESOLVE_ALL_RESOLUTIONS { return Err(format!( - "Resolve All maximum must be between 1 and {MAX_RESOLVE_ALL_RESOLUTIONS}" + "Resolve All maximum must not exceed {MAX_RESOLVE_ALL_RESOLUTIONS}" )); } @@ -1603,65 +1602,31 @@ impl SessionManager { ); } - // This is a priority shortcut, never an authorization bypass. A human - // may start it only while they currently hold the engine's priority. - if acting_player(&session.state) != Some(requester) { - return Err("Resolve All requires your priority".to_string()); + if !matches!( + &session.state.waiting_for, + engine::types::game_state::WaitingFor::ResolveAllReady { .. } + ) { + return Err("Resolve All consent is not ready".to_string()); + } + if !resolve_all_ready_requester_is_authorized(&session.state, requester) { + return Err( + "Resolve All requester is not authorized by the active consent".to_string(), + ); } session.state.log_player_names = session.display_names.clone(); flush_layers(&mut session.state); - let ai_seats = session.ai_seats.clone(); - let ai_configs = session.ai_configs.clone(); - let ai_session = Arc::clone( - session - .ai_session - .get_or_insert_with(|| AiSession::arc_from_game(&session.state)), - ); let pre_action_state = session.state.clone(); - let mut rng = rand::rng(); - let batch = resolve_all_fast_forward( - &mut session.state, - requester, - max_resolutions, - |state, actor| { - if !ai_seats.contains(&actor) { - return ResolveAllCallbackDecision::Stop; - } - let Some(config) = ai_configs.get(&actor) else { - return ResolveAllCallbackDecision::Stop; - }; - let Some(semantic_owner) = state - .waiting_for - .acting_player() - .or_else(|| state.waiting_for.acting_players().first().copied()) - else { - return ResolveAllCallbackDecision::Stop; - }; - let contract = AiDecisionContract::issue(state, semantic_owner); - match choose_action_with_session( - state, - semantic_owner, - config, - &mut rng, - &ai_session, - ) { - Some(action) if contract.permits(state, actor, &action) => { - ResolveAllCallbackDecision::Proposal { contract, action } - } - Some(_) | None => ResolveAllCallbackDecision::Stop, - } - }, - ); + // The cap is frozen into the consent run by BeginResolveAll. The wire + // argument remains range-checked above for transport compatibility but + // cannot enlarge or replace that explicit authorization. + let _ = max_resolutions; + let batch = resolve_all_ready_prefix(&mut session.state, requester); let summary = ResolveAllSummary { items_resolved: batch.items_resolved, total: batch.total, }; - if batch.recorded_actions.is_empty() { - return Ok((None, summary)); - } - session.push_takeback_state(requester, pre_action_state); let (legal_actions, spell_costs, by_object) = engine_legal_actions_full(&session.state); let auto_pass = auto_pass_recommended(&session.state, &legal_actions);