diff --git a/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts b/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts index ecb637b6c9..34348d2278 100644 --- a/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts +++ b/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts @@ -12,7 +12,7 @@ import type Peer from "peerjs"; import type { DataConnection } from "peerjs"; import { P2PGuestAdapter, P2PHostAdapter, playerSlotsFromSeatView } from "../p2p-adapter"; -import { supportsMatchConcede, type FormatConfig, type GameAction, type GameEvent, type GameLogEntry, type GameState } from "../types"; +import { supportsAiDecisionDiagnostics, supportsMatchConcede, type FormatConfig, type GameAction, type GameEvent, type GameLogEntry, type GameState } from "../types"; import { FakeDataConnection } from "../../network/__tests__/fakeDataConnection"; import { WIRE_PROTOCOL_VERSION } from "../../network/protocol"; import { p2pFinalStateCommitment } from "../../services/p2pTerminalResult"; @@ -155,8 +155,32 @@ const mocks = vi.hoisted(() => { })), initializeGame: vi.fn(async () => ({ events: [] })), setMultiplayerMode: vi.fn(async (_enabled: boolean) => undefined), + setAiDecisionDiagnosticsEnabled: vi.fn(), + subscribeAiDecisionDiagnostics: vi.fn(() => () => {}), }; }); + +const nativeWebSocketMocks = vi.hoisted(() => ({ + initializePregame: vi.fn(), + waitForPlayerSlots: vi.fn(), + onEvent: vi.fn(), + sendAbandonGame: vi.fn(), + sendSeatMutation: vi.fn(), + dispose: vi.fn(), +})); + +vi.mock("../ws-adapter", () => ({ + WebSocketAdapter: vi.fn().mockImplementation(function () { + return { + initializePregame: nativeWebSocketMocks.initializePregame, + waitForPlayerSlots: nativeWebSocketMocks.waitForPlayerSlots, + onEvent: nativeWebSocketMocks.onEvent, + sendAbandonGame: nativeWebSocketMocks.sendAbandonGame, + sendSeatMutation: nativeWebSocketMocks.sendSeatMutation, + dispose: nativeWebSocketMocks.dispose, + }; + }), +})); const mockSubmitAction = mocks.submitAction; const mockCheckDeckCompatibility = mocks.checkDeckCompatibility; const mockGetViewerSnapshot = mocks.getViewerSnapshot; @@ -246,6 +270,8 @@ vi.mock("../wasm-adapter", () => ({ applySeatMutation: mocks.applySeatMutation, projectSeatView: mocks.projectSeatView, setMultiplayerMode: mocks.setMultiplayerMode, + setAiDecisionDiagnosticsEnabled: mocks.setAiDecisionDiagnosticsEnabled, + subscribeAiDecisionDiagnostics: mocks.subscribeAiDecisionDiagnostics, dispose: vi.fn(), }; }), @@ -269,6 +295,14 @@ beforeEach(() => { mockGetState.mockClear(); mockGetAiActionProposal.mockClear(); mockSubmitAiActionProposal.mockClear(); + mocks.setAiDecisionDiagnosticsEnabled.mockClear(); + mocks.subscribeAiDecisionDiagnostics.mockClear(); + nativeWebSocketMocks.initializePregame.mockReset(); + nativeWebSocketMocks.waitForPlayerSlots.mockReset(); + nativeWebSocketMocks.onEvent.mockClear(); + nativeWebSocketMocks.sendAbandonGame.mockReset(); + nativeWebSocketMocks.sendSeatMutation.mockReset(); + nativeWebSocketMocks.dispose.mockClear(); }); afterEach(() => { @@ -378,6 +412,43 @@ function makeHost(playerCount: number, gracePeriodMs = 5_000, formatConfig?: For return { adapter, emitConnection }; } +function makeNativeHost() { + const { peer, onGuestConnected, emitConnection } = createFakePeer(); + const adapter = new P2PHostAdapter( + { + player: { main_deck: ["Mountain"], sideboard: [] }, + opponent: { main_deck: ["Forest"], sideboard: [] }, + ai_decks: [], + }, + peer as unknown as Peer, + onGuestConnected, + 2, + commanderConfig(), + undefined, + 5_000, + undefined, + true, + undefined, + undefined, + {}, + ); + return { adapter, emitConnection }; +} + +const NATIVE_HOST_ATTACHMENT = { + playerId: 0, + playerToken: "native-host-token", + gameCode: "native-game", + fullKey: "native-full-key", +}; + +const NATIVE_GUEST_ATTACHMENT = { + playerId: 1, + playerToken: "native-guest-token", + gameCode: "native-game", + fullKey: "native-full-key", +}; + async function joinGuest( emitConnection: (c: DataConnection) => void, msg: { type: "guest_deck"; deckData: unknown } | { type: "reconnect"; playerToken: string }, @@ -401,6 +472,99 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => { vi.useRealTimers(); }); + it("exposes decision diagnostics only on the browser WASM host", () => { + const { adapter } = makeHost(2, 5_000, { ...commanderConfig(), allow_debug_actions: false }); + const guest = new P2PGuestAdapter( + { player: { main_deck: [], sideboard: [] } }, + createFakePeer().peer as unknown as Peer, + "host-peer", + new FakeDataConnection() as unknown as DataConnection, + ); + + expect(supportsAiDecisionDiagnostics(adapter)).toBe(true); + expect(supportsAiDecisionDiagnostics(guest)).toBe(false); + expect("setAiDecisionDiagnosticsEnabled" in P2PHostAdapter.prototype).toBe(false); + if (supportsAiDecisionDiagnostics(adapter)) { + adapter.setAiDecisionDiagnosticsEnabled(true); + } + expect(mocks.setAiDecisionDiagnosticsEnabled).toHaveBeenCalledWith(true); + }); + + it("exposes local diagnostics after native initialization falls back to WASM", async () => { + const { adapter: nativeHost } = makeNativeHost(); + expect(supportsAiDecisionDiagnostics(nativeHost)).toBe(false); + nativeWebSocketMocks.waitForPlayerSlots.mockResolvedValue([]); + nativeWebSocketMocks.initializePregame.mockRejectedValue(new Error("native unavailable")); + + await nativeHost.initialize(); + + expect(nativeWebSocketMocks.initializePregame).toHaveBeenCalledOnce(); + expect(supportsAiDecisionDiagnostics(nativeHost)).toBe(true); + if (supportsAiDecisionDiagnostics(nativeHost)) { + nativeHost.setAiDecisionDiagnosticsEnabled(true); + const listener = vi.fn(); + const unsubscribe = vi.fn(); + mocks.subscribeAiDecisionDiagnostics.mockReturnValueOnce(unsubscribe); + + const returnedUnsubscribe = nativeHost.subscribeAiDecisionDiagnostics(listener); + + expect(mocks.subscribeAiDecisionDiagnostics).toHaveBeenCalledWith(listener); + expect(returnedUnsubscribe).toBe(unsubscribe); + returnedUnsubscribe(); + expect(unsubscribe).toHaveBeenCalledOnce(); + } + expect(mocks.setAiDecisionDiagnosticsEnabled).toHaveBeenCalledWith(true); + }); + + it("exposes local diagnostics after native guest attachment falls back to WASM", async () => { + const { adapter, emitConnection } = makeNativeHost(); + nativeWebSocketMocks.waitForPlayerSlots.mockResolvedValue([]); + nativeWebSocketMocks.initializePregame + .mockResolvedValueOnce(NATIVE_HOST_ATTACHMENT) + .mockRejectedValueOnce(new Error("native guest unavailable")); + + await adapter.initialize(); + expect(supportsAiDecisionDiagnostics(adapter)).toBe(false); + await joinGuest(emitConnection, { + type: "guest_deck", + deckData: { player: { main_deck: ["Plains"], sideboard: [] } }, + }); + await flushPromises(); + + expect(nativeWebSocketMocks.initializePregame).toHaveBeenCalledTimes(2); + expect(supportsAiDecisionDiagnostics(adapter)).toBe(true); + if (supportsAiDecisionDiagnostics(adapter)) { + adapter.setAiDecisionDiagnosticsEnabled(true); + } + expect(mocks.setAiDecisionDiagnosticsEnabled).toHaveBeenCalledWith(true); + }); + + it("exposes local diagnostics after native pregame seat release falls back to WASM", async () => { + const { adapter, emitConnection } = makeNativeHost(); + nativeWebSocketMocks.waitForPlayerSlots.mockResolvedValue([]); + nativeWebSocketMocks.initializePregame + .mockResolvedValueOnce(NATIVE_HOST_ATTACHMENT) + .mockResolvedValueOnce(NATIVE_GUEST_ATTACHMENT); + nativeWebSocketMocks.sendSeatMutation.mockRejectedValue(new Error("native seat sync unavailable")); + + await adapter.initialize(); + const guest = await joinGuest(emitConnection, { + type: "guest_deck", + deckData: { player: { main_deck: ["Plains"], sideboard: [] } }, + }); + await flushPromises(); + expect(supportsAiDecisionDiagnostics(adapter)).toBe(false); + guest.simulateClose(); + await vi.waitFor(() => expect(supportsAiDecisionDiagnostics(adapter)).toBe(true)); + + expect(nativeWebSocketMocks.sendSeatMutation).toHaveBeenCalledOnce(); + expect(supportsAiDecisionDiagnostics(adapter)).toBe(true); + if (supportsAiDecisionDiagnostics(adapter)) { + adapter.setAiDecisionDiagnosticsEnabled(true); + } + expect(mocks.setAiDecisionDiagnosticsEnabled).toHaveBeenCalledWith(true); + }); + it("rejects construction with playerCount outside 2-6", () => { const { peer, onGuestConnected } = createFakePeer(); const hostDeck = { diff --git a/client/src/adapter/__tests__/wasm-adapter.test.ts b/client/src/adapter/__tests__/wasm-adapter.test.ts index 59bb068146..8c548a7ed9 100644 --- a/client/src/adapter/__tests__/wasm-adapter.test.ts +++ b/client/src/adapter/__tests__/wasm-adapter.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { WasmAdapter } from "../wasm-adapter"; import { EngineWorkerClient } from "../engine-worker-client"; -import type { EngineAdapter, SubmitResult } from "../types"; +import type { + AiActionProposal, + AiDecisionDiagnosticReceipt, + EngineAdapter, + SubmitResult, +} from "../types"; import { AdapterError, AdapterErrorCode } from "../types"; import { buildGameState } from "../../test/factories/gameStateFactory"; @@ -34,6 +39,10 @@ const mockWorkerClient = { submitAction: vi .fn() .mockResolvedValue({ events: [], log_entries: [] } as SubmitResult), + submitInteraction: vi.fn().mockResolvedValue({ events: [], log_entries: [] } as SubmitResult), + getAiActionProposal: vi.fn(), + getAiActionProposalWithDiagnostics: vi.fn(), + submitAiActionProposal: vi.fn(), getState: vi.fn().mockResolvedValue(buildGameState({ turn_number: 1, phase: "Untap", @@ -59,6 +68,83 @@ describe("WasmAdapter", () => { beforeEach(() => { vi.clearAllMocks(); adapter = new WasmAdapter(); + mockWorkerClient.getAiActionProposal.mockResolvedValue(null); + mockWorkerClient.getAiActionProposalWithDiagnostics.mockResolvedValue(null); + mockWorkerClient.submitAiActionProposal.mockResolvedValue({ + status: "stale", + reason: "test", + }); + }); + + describe("AI decision diagnostics", () => { + const proposal: AiActionProposal = { + token: "diagnostic-token", + semanticOwner: 0, + actor: 0, + action: { type: "PassPriority" }, + }; + const receipt: AiDecisionDiagnosticReceipt = { + semanticOwner: 0, + authorizedActor: 0, + selectedAction: { type: "PassPriority" }, + status: "direct", + selectionExplanation: "A direct AI policy selected this action; no scored distribution was used.", + samplingTemperature: null, + candidates: [{ + action: { type: "PassPriority" }, + objectName: null, + details: [], + rank: null, + isTopRanked: false, + isSelected: true, + score: null, + weight: null, + probability: null, + }], + }; + + it("uses the legacy proposal endpoint while capture is disabled", async () => { + mockWorkerClient.getAiActionProposal.mockResolvedValue(proposal); + await adapter.initialize(); + + await expect(adapter.getAiActionProposal("Medium", 0)).resolves.toEqual(proposal); + + expect(mockWorkerClient.getAiActionProposal).toHaveBeenCalledWith("Medium", 0); + expect(mockWorkerClient.getAiActionProposalWithDiagnostics).not.toHaveBeenCalled(); + }); + + it("publishes only after apply and retains a rejected proposal for retry", async () => { + mockWorkerClient.getAiActionProposalWithDiagnostics.mockResolvedValue({ proposal, receipt }); + mockWorkerClient.submitAiActionProposal + .mockResolvedValueOnce({ status: "rejected", reason: "retry" }) + .mockResolvedValueOnce({ status: "applied", result: { events: [], log_entries: [] } }); + await adapter.initialize(); + const listener = vi.fn(); + adapter.setAiDecisionDiagnosticsEnabled(true); + adapter.subscribeAiDecisionDiagnostics(listener); + + await expect(adapter.getAiActionProposal("Medium", 0)).resolves.toEqual(proposal); + await expect(adapter.submitAiActionProposal(proposal)).resolves.toMatchObject({ status: "rejected" }); + expect(listener).not.toHaveBeenCalled(); + + await expect(adapter.submitAiActionProposal(proposal)).resolves.toMatchObject({ status: "applied" }); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(receipt); + }); + + it("suppresses stale proposal receipts", async () => { + mockWorkerClient.getAiActionProposalWithDiagnostics.mockResolvedValue({ proposal, receipt }); + mockWorkerClient.submitAiActionProposal.mockResolvedValue({ status: "stale", reason: "old" }); + await adapter.initialize(); + const listener = vi.fn(); + adapter.setAiDecisionDiagnosticsEnabled(true); + adapter.subscribeAiDecisionDiagnostics(listener); + + await adapter.getAiActionProposal("Medium", 0); + await adapter.submitAiActionProposal(proposal); + + expect(listener).not.toHaveBeenCalled(); + }); }); it("implements EngineAdapter interface", () => { diff --git a/client/src/adapter/engine-worker-client.ts b/client/src/adapter/engine-worker-client.ts index 13981603aa..4d4b93d6e3 100644 --- a/client/src/adapter/engine-worker-client.ts +++ b/client/src/adapter/engine-worker-client.ts @@ -6,6 +6,7 @@ */ import type { AiActionProposal, + AiDecisionDiagnosticReceipt, AiProposalSubmission, BatchResolveResult, FormatConfig, @@ -303,6 +304,16 @@ export class EngineWorkerClient { ); } + async getAiActionProposalWithDiagnostics( + difficulty: string, + playerId: number, + ): Promise<{ proposal: AiActionProposal; receipt: AiDecisionDiagnosticReceipt } | null> { + return this.request( + { type: "getAiActionProposalWithDiagnostics", difficulty, playerId }, + ENGINE_AI_TIMEOUT_MS, + ); + } + /** This worker-side endpoint scores only; it cannot mint a proposal. */ async getAiScoredCandidates( difficulty: string, @@ -328,6 +339,18 @@ export class EngineWorkerClient { ); } + async getAiActionProposalFromScoresWithDiagnostics( + scoresJson: string, + difficulty: string, + playerId: number, + seed: number, + ): Promise<{ proposal: AiActionProposal; receipt: AiDecisionDiagnosticReceipt } | null> { + return this.request( + { type: "getAiActionProposalFromScoresWithDiagnostics", scoresJson, difficulty, playerId, seed }, + ENGINE_AI_TIMEOUT_MS, + ); + } + async submitAiActionProposal( proposal: AiActionProposal, ): Promise { diff --git a/client/src/adapter/engine-worker.ts b/client/src/adapter/engine-worker.ts index 5b482e4914..280c2e9fd1 100644 --- a/client/src/adapter/engine-worker.ts +++ b/client/src/adapter/engine-worker.ts @@ -13,7 +13,9 @@ import init, { get_game_state, get_filtered_game_state, get_ai_action_proposal, + get_ai_action_proposal_with_diagnostics, get_ai_action_proposal_from_scores, + get_ai_action_proposal_from_scores_with_diagnostics, get_ai_scored_candidates, submit_ai_action_proposal, get_legal_actions_js, @@ -73,8 +75,10 @@ type EngineRequest = | { type: "getLegalActionsForViewer"; id: number; viewerId: number } | { type: "getViewerSnapshot"; id: number; viewerId: number } | { type: "getAiActionProposal"; id: number; difficulty: string; playerId: number } + | { type: "getAiActionProposalWithDiagnostics"; id: number; difficulty: string; playerId: number } | { type: "getAiScoredCandidates"; id: number; difficulty: string; playerId: number; seed: number } | { type: "getAiActionProposalFromScores"; id: number; scoresJson: string; difficulty: string; playerId: number; seed: number } + | { type: "getAiActionProposalFromScoresWithDiagnostics"; id: number; scoresJson: string; difficulty: string; playerId: number; seed: number } | { type: "submitAiActionProposal"; id: number; proposal: AiActionProposal } | { type: "restoreState"; id: number; stateJson: string } | { type: "resumeMultiplayerHostState"; id: number; stateJson: string } @@ -373,6 +377,11 @@ self.onmessage = async (e: MessageEvent) => { break; } + case "getAiActionProposalWithDiagnostics": { + result(msg.id, get_ai_action_proposal_with_diagnostics(msg.difficulty, msg.playerId) ?? null); + break; + } + case "getAiScoredCandidates": { result(msg.id, get_ai_scored_candidates(msg.difficulty, msg.playerId, BigInt(msg.seed)) ?? []); break; @@ -391,6 +400,11 @@ self.onmessage = async (e: MessageEvent) => { break; } + case "getAiActionProposalFromScoresWithDiagnostics": { + result(msg.id, get_ai_action_proposal_from_scores_with_diagnostics(msg.scoresJson, msg.difficulty, msg.playerId, BigInt(msg.seed)) ?? null); + break; + } + case "submitAiActionProposal": { const outcome = submit_ai_action_proposal( msg.proposal.token, diff --git a/client/src/adapter/p2p-adapter.ts b/client/src/adapter/p2p-adapter.ts index 568496c696..8dcda48411 100644 --- a/client/src/adapter/p2p-adapter.ts +++ b/client/src/adapter/p2p-adapter.ts @@ -3,6 +3,7 @@ import type { DataConnection } from "peerjs"; import type { AiActionProposal, + AiDecisionDiagnosticReceipt, AiProposalSubmission, EngineAdapter, EngineSnapshot, @@ -768,9 +769,26 @@ export class P2PHostAdapter implements EngineAdapter { (revision, views) => this.handleNativeRevision(revision, views), nativeResume, ); + } else { + this.attachBrowserAiDecisionDiagnostics(); } } + /** + * Installs the local-only diagnostics capability once this host is backed by + * browser WASM. It deliberately remains an instance property: native hosts + * and P2P guests must fail capability detection rather than receive a no-op. + */ + private attachBrowserAiDecisionDiagnostics(): void { + if (this.nativeBridge) return; + Object.assign(this, { + setAiDecisionDiagnosticsEnabled: (enabled: boolean) => + this.wasm.setAiDecisionDiagnosticsEnabled(enabled), + subscribeAiDecisionDiagnostics: (listener: (receipt: AiDecisionDiagnosticReceipt) => void) => + this.wasm.subscribeAiDecisionDiagnostics(listener), + }); + } + /** * Restore in-memory adapter maps from a persisted session so the * resumed host agrees with its guests about seat assignments, @@ -1278,6 +1296,7 @@ export class P2PHostAdapter implements EngineAdapter { }); this.nativeBridge.dispose(); this.nativeBridge = null; + this.attachBrowserAiDecisionDiagnostics(); } } // Resume path: load the persisted GameState with a fresh RNG seed @@ -1428,6 +1447,7 @@ export class P2PHostAdapter implements EngineAdapter { }); this.nativeBridge.dispose(); this.nativeBridge = null; + this.attachBrowserAiDecisionDiagnostics(); } } if (!this.ownsAuthority()) { @@ -1533,6 +1553,7 @@ export class P2PHostAdapter implements EngineAdapter { }); this.nativeBridge.dispose(); this.nativeBridge = null; + this.attachBrowserAiDecisionDiagnostics(); } } @@ -1908,6 +1929,7 @@ export class P2PHostAdapter implements EngineAdapter { : this.wasm.getAiActionProposal(difficulty, playerId); } + async submitAiActionProposal( proposal: AiActionProposal, ): Promise { diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 322ecc5ccd..73dc32164c 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -3624,6 +3624,42 @@ export interface AiActionProposal { action: GameAction; } +/** Local-only explanation bound to an opaque AI proposal token. */ +export interface AiDecisionDiagnosticReceipt { + semanticOwner: PlayerId; + authorizedActor: PlayerId; + selectedAction: GameAction; + status: "ranked" | "direct"; + selectionExplanation: string; + samplingTemperature: number | null; + candidates: AiDecisionDiagnosticCandidate[]; +} + +export interface AiDecisionDiagnosticCandidate { + action: GameAction; + objectName: string | null; + details: { label: string; value: string }[]; + rank: number | null; + isTopRanked: boolean; + isSelected: boolean; + score: number | null; + weight: number | null; + probability: number | null; +} + +export interface AiDecisionDiagnosticsCapability { + setAiDecisionDiagnosticsEnabled(enabled: boolean): void; + subscribeAiDecisionDiagnostics(listener: (receipt: AiDecisionDiagnosticReceipt) => void): () => void; +} + +export function supportsAiDecisionDiagnostics( + adapter: EngineAdapter | null, +): adapter is EngineAdapter & AiDecisionDiagnosticsCapability { + return adapter != null + && "setAiDecisionDiagnosticsEnabled" in adapter + && "subscribeAiDecisionDiagnostics" in adapter; +} + /** Result of the engine-owned game-scoped AI worker card-data build. */ export type AiCardSubsetResult = | { kind: "full" } diff --git a/client/src/adapter/wasm-adapter.ts b/client/src/adapter/wasm-adapter.ts index d1b377cb28..590ea83061 100644 --- a/client/src/adapter/wasm-adapter.ts +++ b/client/src/adapter/wasm-adapter.ts @@ -1,5 +1,7 @@ import type { AiActionProposal, + AiDecisionDiagnosticReceipt, + AiDecisionDiagnosticsCapability, AiProposalSubmission, BatchResolveResult, EngineAdapter, @@ -122,7 +124,7 @@ export function getSharedAdapter(): WasmAdapter { * Falls back to direct main-thread WASM calls if Worker creation fails * (e.g., restrictive CSP, very old browser). */ -export class WasmAdapter implements EngineAdapter { +export class WasmAdapter implements EngineAdapter, AiDecisionDiagnosticsCapability { private initialized = false; cardDbLoaded = false; @@ -148,6 +150,51 @@ export class WasmAdapter implements EngineAdapter { // worker's ~90 MB instance. Concurrent callers share one promise. private initPromise: Promise | null = null; private lifecycleGeneration = 0; + private aiDecisionDiagnosticsEnabled = false; + private aiDecisionDiagnosticsEpoch = 0; + private readonly receiptByToken = new Map(); + private readonly tokenBySemanticOwner = new Map(); + private readonly aiDecisionDiagnosticListeners = new Set<(receipt: AiDecisionDiagnosticReceipt) => void>(); + + /** Invalidate local observations whenever the WASM authority invalidates proposals. */ + private invalidateAiDecisionDiagnostics(): void { + this.aiDecisionDiagnosticsEpoch += 1; + this.receiptByToken.clear(); + this.tokenBySemanticOwner.clear(); + } + + setAiDecisionDiagnosticsEnabled(enabled: boolean): void { + if (this.aiDecisionDiagnosticsEnabled === enabled) return; + this.aiDecisionDiagnosticsEnabled = enabled; + this.invalidateAiDecisionDiagnostics(); + } + + subscribeAiDecisionDiagnostics(listener: (receipt: AiDecisionDiagnosticReceipt) => void): () => void { + this.aiDecisionDiagnosticListeners.add(listener); + return () => this.aiDecisionDiagnosticListeners.delete(listener); + } + + private retainAiDecisionDiagnostic( + startEpoch: number, + proposal: AiActionProposal, + receipt: AiDecisionDiagnosticReceipt, + ): void { + if (!this.aiDecisionDiagnosticsEnabled || startEpoch !== this.aiDecisionDiagnosticsEpoch) return; + const previous = this.tokenBySemanticOwner.get(proposal.semanticOwner); + if (previous) this.receiptByToken.delete(previous); + this.tokenBySemanticOwner.set(proposal.semanticOwner, proposal.token); + this.receiptByToken.set(proposal.token, receipt); + } + + private takeAiDecisionDiagnostic(token: string): AiDecisionDiagnosticReceipt | undefined { + const receipt = this.receiptByToken.get(token); + if (!receipt) return undefined; + this.receiptByToken.delete(token); + if (this.tokenBySemanticOwner.get(receipt.semanticOwner) === token) { + this.tokenBySemanticOwner.delete(receipt.semanticOwner); + } + return receipt; + } async initialize(): Promise { if (this.initialized) return; @@ -250,8 +297,9 @@ export class WasmAdapter implements EngineAdapter { await this.ensureCardDb(); } try { - if (this.engine) return await this.engine.submitAction(actor, action); - return await this.fallback!.submitAction(action, actor); + const result = this.engine ? await this.engine.submitAction(actor, action) : await this.fallback!.submitAction(action, actor); + this.invalidateAiDecisionDiagnostics(); + return result; } catch (err) { throw await classifyEngineErrorAsync(err, this.takePanic); } @@ -263,8 +311,9 @@ export class WasmAdapter implements EngineAdapter { ): Promise { this.assertInitialized(); try { - if (this.engine) return await this.engine.submitInteraction(actor, submission); - return await this.fallback!.submitInteraction(submission, actor); + const result = this.engine ? await this.engine.submitInteraction(actor, submission) : await this.fallback!.submitInteraction(submission, actor); + this.invalidateAiDecisionDiagnostics(); + return result; } catch (err) { throw await classifyEngineErrorAsync(err, this.takePanic); } @@ -374,6 +423,47 @@ export class WasmAdapter implements EngineAdapter { ): Promise { this.assertInitialized(); try { + const captureEpoch = this.aiDecisionDiagnosticsEpoch; + const capture = this.aiDecisionDiagnosticsEnabled; + if (capture) { + // Preserve the existing VeryHard score-worker route. Capturing may + // observe its rebinding receipt, but never chooses a different path. + if (difficulty === "VeryHard" && this.engine) { + try { + const state = await this.engine!.getState(); + if (state.waiting_for.type === "Priority") { + const pool = await this.ensureAiPool(); + if (pool) { + const scores = await pool.getAiScoredCandidates( + await this.engine!.exportState(), + difficulty, + playerId, + ); + if (scores?.length) { + const captured = await this.engine!.getAiActionProposalFromScoresWithDiagnostics( + JSON.stringify(scores), + difficulty, + playerId, + Date.now(), + ); + if (captured) { + this.retainAiDecisionDiagnostic(captureEpoch, captured.proposal, captured.receipt); + return captured.proposal; + } + } + } + } + } catch (error) { + if (error instanceof Error && isStateLostMessage(error.message)) throw error; + console.warn("AI worker pool failed; using authoritative single worker", error); + } + } + const captured = this.engine + ? await this.engine.getAiActionProposalWithDiagnostics(difficulty, playerId) + : await this.fallback!.getAiActionProposalWithDiagnostics(difficulty, playerId); + if (captured) this.retainAiDecisionDiagnostic(captureEpoch, captured.proposal, captured.receipt); + return captured?.proposal ?? null; + } if (difficulty === "VeryHard" && this.engine) { try { // A snapshot can become stale while scoring. That is safe: the main @@ -415,8 +505,19 @@ export class WasmAdapter implements EngineAdapter { ): Promise { this.assertInitialized(); try { - if (this.engine) return await this.engine.submitAiActionProposal(proposal); - return await this.fallback!.submitAiActionProposal(proposal); + const outcome = this.engine + ? await this.engine.submitAiActionProposal(proposal) + : await this.fallback!.submitAiActionProposal(proposal); + if (outcome.status === "applied") { + const receipt = this.takeAiDecisionDiagnostic(proposal.token); + if (receipt && this.aiDecisionDiagnosticsEnabled) { + for (const listener of this.aiDecisionDiagnosticListeners) listener(receipt); + } + this.invalidateAiDecisionDiagnostics(); + } else if (outcome.status === "stale") { + this.takeAiDecisionDiagnostic(proposal.token); + } + return outcome; } catch (err) { throw await classifyEngineErrorAsync(err, this.takePanic); } @@ -512,7 +613,9 @@ export class WasmAdapter implements EngineAdapter { ): Promise { this.assertInitialized(); if (this.engine) { - return this.engine.resolveAll(requester, aiSeats, maxResolutions); + const result = await this.engine.resolveAll(requester, aiSeats, maxResolutions); + this.invalidateAiDecisionDiagnostics(); + return result; } throw new Error("resolveAll requires worker-based engine"); } @@ -534,6 +637,7 @@ export class WasmAdapter implements EngineAdapter { const json = JSON.stringify(state); if (this.engine) await this.engine.restoreState(json); else await this.fallback!.restoreState(json); + this.invalidateAiDecisionDiagnostics(); } /** @@ -560,15 +664,20 @@ export class WasmAdapter implements EngineAdapter { } else { this.fallback!.setMultiplayerMode(enabled); } + this.invalidateAiDecisionDiagnostics(); } async applySeatMutation(stateJson: string, mutationJson: string): Promise { this.assertInitialized(); await this.ensureCardDb(); if (this.engine) { - return this.engine.applySeatMutation(stateJson, mutationJson); + const result = await this.engine.applySeatMutation(stateJson, mutationJson); + this.invalidateAiDecisionDiagnostics(); + return result; } - return this.fallback!.applySeatMutation(stateJson, mutationJson); + const result = await this.fallback!.applySeatMutation(stateJson, mutationJson); + this.invalidateAiDecisionDiagnostics(); + return result; } async projectSeatView(stateJson: string): Promise { @@ -597,6 +706,7 @@ export class WasmAdapter implements EngineAdapter { const json = JSON.stringify(state); if (this.engine) await this.engine.resumeMultiplayerHostState(json); else await this.fallback!.resumeMultiplayerHostState(json); + this.invalidateAiDecisionDiagnostics(); } /** Clear the WASM game state without terminating the worker. */ @@ -609,6 +719,7 @@ export class WasmAdapter implements EngineAdapter { if (this.engine) { await this.engine.resetGame(); } + this.invalidateAiDecisionDiagnostics(); } async estimateBracket(deck: BracketDeckRequest): Promise { @@ -673,6 +784,8 @@ export class WasmAdapter implements EngineAdapter { } dispose(): void { + this.setAiDecisionDiagnosticsEnabled(false); + this.aiDecisionDiagnosticListeners.clear(); this.lifecycleGeneration += 1; this.aiPoolGeneration += 1; // Clear the singleton reference so getSharedAdapter() creates a fresh @@ -713,7 +826,7 @@ export class WasmAdapter implements EngineAdapter { } const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); if (this.engine) { - return this.engine.initializeGame( + const result = await this.engine.initializeGame( deckData ?? null, seed, formatConfig ?? null, @@ -721,8 +834,10 @@ export class WasmAdapter implements EngineAdapter { playerCount, firstPlayer, ); + this.invalidateAiDecisionDiagnostics(); + return result; } - return this.fallback!.initializeGame( + const result = await this.fallback!.initializeGame( deckData ?? null, seed, formatConfig ?? null, @@ -730,6 +845,8 @@ export class WasmAdapter implements EngineAdapter { playerCount, firstPlayer, ); + this.invalidateAiDecisionDiagnostics(); + return result; } /** Expose the worker client for AI pool state export (Phase 4). */ @@ -763,6 +880,10 @@ interface MainThreadFallback { getLegalActionsForViewer(viewerId: number): Promise; getViewerSnapshot(viewerId: number): Promise; getAiActionProposal(difficulty: string, playerId: number): Promise; + getAiActionProposalWithDiagnostics( + difficulty: string, + playerId: number, + ): Promise<{ proposal: AiActionProposal; receipt: AiDecisionDiagnosticReceipt } | null>; submitAiActionProposal(proposal: AiActionProposal): Promise; exportState(): Promise; restoreState(stateJson: string): Promise; @@ -883,6 +1004,12 @@ async function createMainThreadFallback(): Promise { getAiActionProposal: (difficulty: string, playerId: number) => enqueue(() => (wasm.get_ai_action_proposal(difficulty, playerId) ?? null) as AiActionProposal | null), + getAiActionProposalWithDiagnostics: (difficulty: string, playerId: number) => + enqueue(() => (wasm.get_ai_action_proposal_with_diagnostics(difficulty, playerId) ?? null) as { + proposal: AiActionProposal; + receipt: AiDecisionDiagnosticReceipt; + } | null), + submitAiActionProposal: (proposal: AiActionProposal) => enqueue(() => wasm.submit_ai_action_proposal( proposal.token, diff --git a/client/src/components/chrome/AiDecisionOverlay.tsx b/client/src/components/chrome/AiDecisionOverlay.tsx new file mode 100644 index 0000000000..064c08fe09 --- /dev/null +++ b/client/src/components/chrome/AiDecisionOverlay.tsx @@ -0,0 +1,198 @@ +import { motion, useDragControls } from "framer-motion"; +import { useRef, useState, type PointerEvent } from "react"; +import { useTranslation } from "react-i18next"; + +import type { AiDecisionDiagnosticReceipt } from "../../adapter/types"; + +function actionLabel(type: string): string { + return type.replace(/([a-z])([A-Z])/g, "$1 $2"); +} + +function probabilityLabel(probability: number | null): string { + if (probability == null) return "—"; + return new Intl.NumberFormat(undefined, { + style: "percent", + maximumFractionDigits: 1, + }).format(probability); +} + +function weightLabel(weight: number | null): string { + if (weight == null) return "—"; + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 3, + }).format(weight); +} + +function scoreLabel(score: number | null): string { + if (score == null) return "—"; + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 3, signDisplay: "always" }).format(score); +} + +function temperatureLabel(temperature: number | null): string { + if (temperature == null) return "—"; + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(temperature); +} + +/** + * A local-only view of an engine-authored AI decision receipt. Its bars read + * the engine's normalized probability directly; it never scores or ranks an + * action in the browser. + */ +export function AiDecisionOverlay({ + receipt, + visible, + onClose, +}: { + receipt: AiDecisionDiagnosticReceipt | null; + visible: boolean; + onClose: () => void; +}) { + const { t } = useTranslation("game"); + const dragControls = useDragControls(); + const constraintsRef = useRef(null); + const [collapsed, setCollapsed] = useState(false); + + if (!visible || !receipt) return null; + + const startDrag = (event: PointerEvent) => { + dragControls.start(event); + }; + + return ( +
+ +
+
+

{t("aiDecisionOverlay.title")}

+

+ {receipt.status === "ranked" + ? t("aiDecisionOverlay.rankedSubtitle", { temperature: temperatureLabel(receipt.samplingTemperature) }) + : t("aiDecisionOverlay.directSubtitle")} +

+
+
event.stopPropagation()} + > + + + +
+
+ + {!collapsed ? ( + <> +
    + {receipt.candidates.map((candidate, index) => { + const color = candidate.isTopRanked + ? "bg-cyan-400" + : candidate.isSelected + ? "bg-amber-400" + : "bg-slate-500"; + const label = actionLabel(candidate.action.type); + + return ( +
  1. +
    + + {candidate.rank ?? "—"} + + + {candidate.objectName ? `${label} — ${candidate.objectName}` : label} + + {candidate.isTopRanked ? ( + + {t("aiDecisionOverlay.top")} + + ) : null} + {candidate.isSelected ? ( + + {t("aiDecisionOverlay.chosen")} + + ) : null} +
    + {candidate.details.length > 0 ? ( +
    + {candidate.details.map((detail) => ( +
    +
    {detail.label}
    +
    {detail.value}
    +
    + ))} +
    + ) : null} + {receipt.status === "ranked" ? ( +
    + + {t("aiDecisionOverlay.metrics", { + score: scoreLabel(candidate.score), + weight: weightLabel(candidate.weight), + })} + +
    +
    +
    + + {probabilityLabel(candidate.probability)} + +
    + ) : null} +
  2. + ); + })} +
+ + {receipt.status === "ranked" ? ( +
+

{receipt.selectionExplanation}

+
+ {t("aiDecisionOverlay.legendTop")} + {t("aiDecisionOverlay.legendChosen")} +
+
+ ) : null} + + ) : null} +
+
+ ); +} diff --git a/client/src/components/chrome/DebugPanel.tsx b/client/src/components/chrome/DebugPanel.tsx index 6d4bd6598f..cedfe16c87 100644 --- a/client/src/components/chrome/DebugPanel.tsx +++ b/client/src/components/chrome/DebugPanel.tsx @@ -58,8 +58,12 @@ function patchConsole(): void { // Patch immediately so we capture logs from app startup patchConsole(); -export function DebugPanel() { - const { t } = useTranslation(); +export function DebugPanel({ + aiDecisionDiagnosticsAvailable = false, +}: { + aiDecisionDiagnosticsAvailable?: boolean; +}) { + const { t } = useTranslation(["common", "game"]); const open = useUiStore((s) => s.debugPanelOpen); const turnCheckpoints = useGameStore((s) => s.turnCheckpoints); const rewindTargets = useGameStore((s) => s.rewindTargets); @@ -93,6 +97,8 @@ export function DebugPanel() { // can open the panel straight to "actions" via `openSandboxTools()`. const activeTab = useUiStore((s) => s.debugPanelTab); const setActiveTab = useUiStore((s) => s.setDebugPanelTab); + const aiDecisionCaptureEnabled = useUiStore((s) => s.aiDecisionCaptureEnabled); + const setAiDecisionCaptureEnabled = useUiStore((s) => s.setAiDecisionCaptureEnabled); // Deliberately NOT `!hasRemoteHumans(gameMode)`, despite reading like a // company question. This is the set of modes whose adapter implements // `restoreState`: `WasmAdapter` does; `WebSocketAdapter.restoreState` @@ -348,6 +354,21 @@ export function DebugPanel() { +
+ + {!aiDecisionDiagnosticsAvailable ? ( +

{t("game:debugPanel.aiDecisionUnavailable")}

+ ) : null} +
+
{activeTab === "actions" ? (
diff --git a/client/src/components/chrome/__tests__/AiDecisionOverlay.test.tsx b/client/src/components/chrome/__tests__/AiDecisionOverlay.test.tsx new file mode 100644 index 0000000000..4fb53b7296 --- /dev/null +++ b/client/src/components/chrome/__tests__/AiDecisionOverlay.test.tsx @@ -0,0 +1,107 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AiDecisionDiagnosticReceipt } from "../../../adapter/types"; +import { AiDecisionOverlay } from "../AiDecisionOverlay"; + +afterEach(cleanup); + +const RANKED_RECEIPT: AiDecisionDiagnosticReceipt = { + semanticOwner: 1, + authorizedActor: 1, + selectedAction: { type: "PassPriority" }, + status: "ranked", + selectionExplanation: "Softmax sampled rank 2 (20.0%) instead of rank 1 (80.0%) at temperature 1.00.", + samplingTemperature: 1, + candidates: [ + { + action: { type: "CancelCast" }, + objectName: null, + details: [{ label: "Object ID", value: "7" }], + rank: 1, + isTopRanked: true, + isSelected: false, + score: 0.85, + weight: 4.2, + probability: 0.8, + }, + { + action: { type: "PassPriority" }, + objectName: null, + details: [], + rank: 2, + isTopRanked: false, + isSelected: true, + score: 0.1, + weight: 1.1, + probability: 0.2, + }, + ], +}; + +describe("AiDecisionOverlay", () => { + it("renders the engine-ranked candidates as a color-coded probability chart", () => { + render( {}} />); + + expect(screen.getByLabelText("AI decision")).toBeInTheDocument(); + expect(screen.getByText("Cancel Cast")).toBeInTheDocument(); + expect(screen.getByText("Pass Priority")).toBeInTheDocument(); + expect(screen.getByText("TOP")).toBeInTheDocument(); + expect(screen.getByText("CHOSEN")).toBeInTheDocument(); + expect(screen.getByText("S +0.85 · W 4.2")).toBeInTheDocument(); + expect(screen.getByText("Object ID")).toBeInTheDocument(); + expect(screen.getByText("7")).toBeInTheDocument(); + expect(screen.getByText(/sampled rank 2/i)).toBeInTheDocument(); + expect(screen.getByText("80%")).toBeInTheDocument(); + expect(screen.getByText("20%")).toBeInTheDocument(); + }); + + it("does not render while the visibility checkbox is off", () => { + render( {}} />); + + expect(screen.queryByLabelText("AI decision")).not.toBeInTheDocument(); + }); + + it("keeps direct-policy decisions legible without inventing rank data", () => { + render( + {}} + receipt={{ + ...RANKED_RECEIPT, + status: "direct", + candidates: [{ + ...RANKED_RECEIPT.candidates[1], + rank: null, + isSelected: true, + probability: null, + }], + }} + />, + ); + + expect(screen.getByText("Selected by a direct AI policy.")).toBeInTheDocument(); + expect(screen.getByText("CHOSEN")).toBeInTheDocument(); + expect(screen.queryByText("20%")).not.toBeInTheDocument(); + }); + + it("collapses details while keeping the decision available to reopen", () => { + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Collapse AI decision details" })); + + expect(screen.getByLabelText("AI decision")).toBeInTheDocument(); + expect(screen.getByText("AI decision")).toBeInTheDocument(); + expect(screen.queryByText("Cancel Cast")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Expand AI decision details" })).toBeInTheDocument(); + }); + + it("closes the overlay through its close control", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Close AI decision overlay" })); + + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 3dd278b05b..c3d8bf47e2 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -2244,6 +2244,23 @@ "deckCount": "{{count}} in deck", "heroCount": "{{count}} heroes" }, + "debugPanel": { + "aiDecisionVisibility": "KI-Entscheidungen anzeigen", + "aiDecisionUnavailable": "KI-Entscheidungsdiagnosen sind für dieses Spiel nicht verfügbar." + }, + "aiDecisionOverlay": { + "title": "KI-Entscheidung", + "rankedSubtitle": "Softmax-Stichprobe bei Temperatur {{temperature}}.", + "directSubtitle": "Von einer direkten KI-Strategie ausgewählt.", + "metrics": "S {{score}} · G {{weight}}", + "top": "BESTE", + "chosen": "GEWÄHLT", + "legendTop": "Bestplatziert", + "legendChosen": "Gewählt", + "collapse": "Details der KI-Entscheidung einklappen", + "expand": "Details der KI-Entscheidung ausklappen", + "close": "KI-Entscheidungsfenster schließen" + }, "status": { "yourPriority": "Your priority", "yourPriorityReason": "Your priority — {{reason}}", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index aadcdfd0a7..2a9b0a4294 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -2250,6 +2250,23 @@ "deckCount": "{{count}} in deck", "heroCount": "{{count}} heroes" }, + "debugPanel": { + "aiDecisionVisibility": "Show AI decisions", + "aiDecisionUnavailable": "AI decision diagnostics are unavailable for this game." + }, + "aiDecisionOverlay": { + "title": "AI decision", + "rankedSubtitle": "Softmax sample at temperature {{temperature}}.", + "directSubtitle": "Selected by a direct AI policy.", + "metrics": "S {{score}} · W {{weight}}", + "top": "TOP", + "chosen": "CHOSEN", + "legendTop": "Top ranked", + "legendChosen": "Chosen", + "collapse": "Collapse AI decision details", + "expand": "Expand AI decision details", + "close": "Close AI decision overlay" + }, "status": { "yourPriority": "Your priority", "yourPriorityReason": "Your priority — {{reason}}", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 88231b9f58..4f0180628a 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -2244,6 +2244,23 @@ "deckCount": "{{count}} in deck", "heroCount": "{{count}} heroes" }, + "debugPanel": { + "aiDecisionVisibility": "Mostrar decisiones de IA", + "aiDecisionUnavailable": "Los diagnósticos de decisiones de IA no están disponibles para esta partida." + }, + "aiDecisionOverlay": { + "title": "Decisión de IA", + "rankedSubtitle": "Muestra softmax a temperatura {{temperature}}.", + "directSubtitle": "Seleccionada por una política de IA directa.", + "metrics": "P {{score}} · W {{weight}}", + "top": "MEJOR", + "chosen": "ELEGIDA", + "legendTop": "Mejor clasificada", + "legendChosen": "Elegida", + "collapse": "Contraer detalles de la decisión de IA", + "expand": "Expandir detalles de la decisión de IA", + "close": "Cerrar la superposición de decisión de IA" + }, "status": { "yourPriority": "Your priority", "yourPriorityReason": "Your priority — {{reason}}", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 5b7f4d2a65..dbfb06eeff 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -2244,6 +2244,23 @@ "deckCount": "{{count}} in deck", "heroCount": "{{count}} heroes" }, + "debugPanel": { + "aiDecisionVisibility": "Afficher les décisions de l’IA", + "aiDecisionUnavailable": "Les diagnostics de décision de l’IA ne sont pas disponibles pour cette partie." + }, + "aiDecisionOverlay": { + "title": "Décision de l’IA", + "rankedSubtitle": "Échantillon softmax à la température {{temperature}}.", + "directSubtitle": "Choisie par une politique d’IA directe.", + "metrics": "S {{score}} · P {{weight}}", + "top": "MEILLEUR", + "chosen": "CHOISIE", + "legendTop": "Mieux classée", + "legendChosen": "Choisie", + "collapse": "Réduire les détails de la décision de l’IA", + "expand": "Développer les détails de la décision de l’IA", + "close": "Fermer la fenêtre de décision de l’IA" + }, "status": { "yourPriority": "Your priority", "yourPriorityReason": "Your priority — {{reason}}", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index af26775051..a1cf9bf479 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -2244,6 +2244,23 @@ "deckCount": "{{count}} in deck", "heroCount": "{{count}} heroes" }, + "debugPanel": { + "aiDecisionVisibility": "Mostra decisioni IA", + "aiDecisionUnavailable": "La diagnostica delle decisioni IA non è disponibile per questa partita." + }, + "aiDecisionOverlay": { + "title": "Decisione IA", + "rankedSubtitle": "Campione softmax alla temperatura {{temperature}}.", + "directSubtitle": "Selezionata da una politica IA diretta.", + "metrics": "P {{score}} · W {{weight}}", + "top": "MIGLIORE", + "chosen": "SCELTA", + "legendTop": "Più alta in classifica", + "legendChosen": "Scelta", + "collapse": "Comprimi i dettagli della decisione IA", + "expand": "Espandi i dettagli della decisione IA", + "close": "Chiudi la finestra della decisione IA" + }, "status": { "yourPriority": "Your priority", "yourPriorityReason": "Your priority — {{reason}}", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 6b5110009c..223fdc491a 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -2244,6 +2244,23 @@ "deckCount": "{{count}} in deck", "heroCount": "{{count}} heroes" }, + "debugPanel": { + "aiDecisionVisibility": "Pokaż decyzje SI", + "aiDecisionUnavailable": "Diagnostyka decyzji SI jest niedostępna w tej grze." + }, + "aiDecisionOverlay": { + "title": "Decyzja SI", + "rankedSubtitle": "Próbka softmax przy temperaturze {{temperature}}.", + "directSubtitle": "Wybrano przez bezpośrednią strategię SI.", + "metrics": "W {{score}} · Waga {{weight}}", + "top": "NAJLEPSZA", + "chosen": "WYBRANA", + "legendTop": "Najwyżej oceniona", + "legendChosen": "Wybrana", + "collapse": "Zwiń szczegóły decyzji SI", + "expand": "Rozwiń szczegóły decyzji SI", + "close": "Zamknij nakładkę decyzji SI" + }, "status": { "yourPriority": "Your priority", "yourPriorityReason": "Your priority — {{reason}}", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 03fed33719..b1527ace5e 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -2244,6 +2244,23 @@ "deckCount": "{{count}} in deck", "heroCount": "{{count}} heroes" }, + "debugPanel": { + "aiDecisionVisibility": "Mostrar decisões da IA", + "aiDecisionUnavailable": "Os diagnósticos de decisão da IA não estão disponíveis para este jogo." + }, + "aiDecisionOverlay": { + "title": "Decisão da IA", + "rankedSubtitle": "Amostra softmax na temperatura {{temperature}}.", + "directSubtitle": "Selecionada por uma política direta de IA.", + "metrics": "P {{score}} · W {{weight}}", + "top": "MELHOR", + "chosen": "ESCOLHIDA", + "legendTop": "Mais bem classificada", + "legendChosen": "Escolhida", + "collapse": "Recolher detalhes da decisão da IA", + "expand": "Expandir detalhes da decisão da IA", + "close": "Fechar sobreposição da decisão da IA" + }, "status": { "yourPriority": "Your priority", "yourPriorityReason": "Your priority — {{reason}}", diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index c824b420c2..620cde14a7 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -18,8 +18,9 @@ import type { MatchConfig, ObjectId, SerializedAbilityCost, + AiDecisionDiagnosticReceipt, } from "../adapter/types"; -import { supportsMatchConcede } from "../adapter/types"; +import { supportsAiDecisionDiagnostics, supportsMatchConcede } from "../adapter/types"; import type { InteractionManaRestriction, InteractionPresentationSurface, @@ -131,6 +132,7 @@ import { type SettingsTabId, } from "../components/settings/PreferencesModal.tsx"; import { DebugPanel } from "../components/chrome/DebugPanel.tsx"; +import { AiDecisionOverlay } from "../components/chrome/AiDecisionOverlay.tsx"; import { GameMenu } from "../components/chrome/GameMenu.tsx"; import { ConcedeDialog } from "../components/multiplayer/ConcedeDialog.tsx"; import { TakebackRequestDialog } from "../components/multiplayer/TakebackRequestDialog.tsx"; @@ -954,6 +956,26 @@ function GamePageContent({ ); const opponentDisplayName = useMultiplayerStore((s) => s.opponentDisplayName); const adapter = useGameStore((s) => s.adapter); + const aiDecisionCaptureEnabled = useUiStore((s) => s.aiDecisionCaptureEnabled); + const setAiDecisionCaptureEnabled = useUiStore((s) => s.setAiDecisionCaptureEnabled); + const [aiDecisionReceipt, setAiDecisionReceipt] = useState(null); + // GamePage owns the only local diagnostic subscription. Adapter events remain + // gameplay-only so no receipt can enter P2P/server state or wire traffic. + useEffect(() => { + setAiDecisionReceipt(null); + if (!supportsAiDecisionDiagnostics(adapter)) { + return; + } + adapter.setAiDecisionDiagnosticsEnabled(aiDecisionCaptureEnabled); + if (!aiDecisionCaptureEnabled) { + return; + } + const unsubscribe = adapter.subscribeAiDecisionDiagnostics(setAiDecisionReceipt); + return () => { + unsubscribe(); + adapter.setAiDecisionDiagnosticsEnabled(false); + }; + }, [adapter, aiDecisionCaptureEnabled]); // The AUTHORITATIVE game mode. The URL-derived `mode` prop structurally // cannot contain `native-ai` (desktop solo arrives as `rawMode === "ai"`), so // it cannot answer "is anyone else at this table?". @@ -1758,7 +1780,14 @@ function GamePageContent({ {/* Overlay layers */} - + + setAiDecisionCaptureEnabled(false)} + /> {preferencesOpen && ( diff --git a/client/src/stores/uiStore.ts b/client/src/stores/uiStore.ts index a5829145bd..efe24f1280 100644 --- a/client/src/stores/uiStore.ts +++ b/client/src/stores/uiStore.ts @@ -222,6 +222,8 @@ interface UiStoreState { * local state so entry points (Sandbox Tools nudge/button) can open the * panel straight to "actions" instead of the default "console" log view. */ debugPanelTab: "console" | "actions"; + /** Local, non-persistent capture control for AI decision diagnostics. */ + aiDecisionCaptureEnabled: boolean; debugInteractionMode: boolean; /** Whether the quick floating Click Mode control is pinned on-screen. The * mode itself stays in `debugInteractionMode`; this only controls access to @@ -309,6 +311,7 @@ interface UiStoreActions { setHandFilter: (filter: FilterKey) => void; toggleDebugPanel: () => void; setDebugPanelTab: (tab: "console" | "actions") => void; + setAiDecisionCaptureEnabled: (enabled: boolean) => void; /** Open the debug panel directly to the Actions ("Sandbox Tools") tab. */ openSandboxTools: () => void; toggleDebugInteractionMode: () => void; @@ -367,6 +370,7 @@ export const useUiStore = create()((set, get) => ({ handFilter: "none", debugPanelOpen: false, debugPanelTab: "console", + aiDecisionCaptureEnabled: false, debugInteractionMode: false, debugClickModeButtonVisible: false, debugContextMenu: null, @@ -675,6 +679,7 @@ export const useUiStore = create()((set, get) => ({ setHandFilter: (filter) => set({ handFilter: filter }), toggleDebugPanel: () => set((state) => ({ debugPanelOpen: !state.debugPanelOpen })), setDebugPanelTab: (tab) => set({ debugPanelTab: tab }), + setAiDecisionCaptureEnabled: (enabled) => set({ aiDecisionCaptureEnabled: enabled }), openSandboxTools: () => set({ debugPanelOpen: true, debugPanelTab: "actions" }), toggleDebugInteractionMode: () => set((state) => ({ debugInteractionMode: !state.debugInteractionMode, diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index 290af99062..9f8b27f63d 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -121,6 +121,20 @@ export function get_ai_action_proposal(difficulty: string, player_id: number): a */ export function get_ai_action_proposal_from_scores(scores_json: string, difficulty: string, player_id: number, rng_seed: bigint): any; +/** + * Diagnostic counterpart of score-worker proposal rebinding. It preserves the + * existing authority filter and selector; the returned receipt is local WASM + * observability data bound to the same opaque token. + */ +export function get_ai_action_proposal_from_scores_with_diagnostics(scores_json: string, difficulty: string, player_id: number, rng_seed: bigint): any; + +/** + * Mint an ordinary opaque proposal together with a local-only diagnostic + * receipt. The receipt is an observation of the minted capability, never an + * additional action-selection API. + */ +export function get_ai_action_proposal_with_diagnostics(difficulty: string, player_id: number): any; + /** * Score candidates inside an isolated AI worker. These are plain, * serializable hints rather than capabilities: they cannot cross the action @@ -486,6 +500,8 @@ export interface InitOutput { readonly getFormatRegistry: () => any; readonly get_ai_action_proposal: (a: number, b: number, c: number) => [number, number, number]; readonly get_ai_action_proposal_from_scores: (a: number, b: number, c: number, d: number, e: number, f: bigint) => [number, number, number]; + readonly get_ai_action_proposal_from_scores_with_diagnostics: (a: number, b: number, c: number, d: number, e: number, f: bigint) => [number, number, number]; + readonly get_ai_action_proposal_with_diagnostics: (a: number, b: number, c: number) => [number, number, number]; readonly get_ai_scored_candidates: (a: number, b: number, c: number, d: bigint) => [number, number, number]; readonly get_card_face_data: (a: number, b: number) => any; readonly get_card_parse_details: (a: number, b: number) => any; diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 875999be9f..7cff6adf57 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -39,9 +39,92 @@ use engine::types::{GameAction, GameState, PlayerId, ReplayHeader, ReplayLog}; use engine::game::resolve_player_deck_list; use engine::starter_decks; +use phase_ai::choose_action_with_session_diagnostic; use phase_ai::deck_profile::{ArchetypeClassification, DeckArchetype, DeckProfile}; use seat_reducer::types::{DeckChoice, DeckResolver, ReducerCtx, SeatMutation, SeatState}; +/// Enrich local diagnostic receipts with names already known to the engine. +/// This remains at the WASM boundary: AI ranking stays state-agnostic, while +/// the display receives the exact card/permanent an action refers to. +fn attach_receipt_object_names( + state: &GameState, + receipt: &mut phase_ai::decision_receipt::AiDecisionDiagnosticReceipt, +) { + for candidate in &mut receipt.candidates { + let object_id = match &candidate.action { + GameAction::CastSpell { object_id, .. } + | GameAction::PlayLand { object_id, .. } + | GameAction::Foretell { object_id, .. } => Some(*object_id), + GameAction::ActivateAbility { source_id, .. } => Some(*source_id), + _ => None, + }; + candidate.object_name = object_id + .and_then(|id| state.objects.get(&id)) + .map(|object| object.name.clone()); + candidate.details = serde_json::to_value(&candidate.action) + .ok() + .and_then(|action| { + action + .get("data") + .and_then(serde_json::Value::as_object) + .cloned() + }) + .map(|data| { + data.into_iter() + .map( + |(label, value)| phase_ai::decision_receipt::AiDecisionDiagnosticField { + label: humanize_diagnostic_field(&label), + value: format_diagnostic_value(&value), + }, + ) + .collect() + }) + .unwrap_or_default(); + } +} + +fn humanize_diagnostic_field(field: &str) -> String { + field + .split('_') + .map(|word| match word { + "id" => "ID".to_string(), + _ => { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + } + }) + .collect::>() + .join(" ") +} + +fn format_diagnostic_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => "None".to_string(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Array(values) => values + .iter() + .map(format_diagnostic_value) + .collect::>() + .join(", "), + serde_json::Value::Object(values) => values + .iter() + .map(|(label, value)| { + format!( + "{}: {}", + humanize_diagnostic_field(label), + format_diagnostic_value(value) + ) + }) + .collect::>() + .join(", "), + } +} + fn decode_restored_game_state(json_str: &str) -> Result { serde_json::from_str::(json_str) .map(PersistedGameState::into_game_state) @@ -2010,6 +2093,58 @@ pub fn get_ai_action_proposal(difficulty: &str, player_id: u8) -> Result Result { + let ai_difficulty = AiDifficulty::from_label(difficulty); + with_state_mut(|state| { + engine::game::layers::flush_layers(state); + let requested_ai = PlayerId(player_id); + let semantic_owner = if state.waiting_for.acting_players().contains(&requested_ai) { + requested_ai + } else { + state + .waiting_for + .acting_player() + .or_else(|| state.waiting_for.acting_players().first().copied()) + .unwrap_or(requested_ai) + }; + let contract = AiDecisionContract::issue(state, semantic_owner); + let config = + create_config_for_players(ai_difficulty, Platform::Wasm, state.players.len() as u8); + let mut rng = rand::rng(); + let session = ai_session_for(state); + let selection = choose_action_with_session_diagnostic( + state, + semantic_owner, + &config, + &mut rng, + &session, + ); + let Some(action) = selection.action else { + return Ok(JsValue::NULL); + }; + if !contract.contains_action(state, &action) { + return Ok(JsValue::NULL); + } + let actor = contract.authorized_actor; + let mut receipt = selection + .receipt + .expect("diagnostic chooser must observe its selected action"); + attach_receipt_object_names(state, &mut receipt); + let token = AI_PROPOSALS.with(|registry| registry.borrow_mut().insert(contract)); + Ok(to_js(&serde_json::json!({ + "proposal": { "token": token, "semanticOwner": semantic_owner.0, "actor": actor.0, "action": action }, + "receipt": receipt, + }))) + })? +} + /// Score candidates inside an isolated AI worker. These are plain, /// serializable hints rather than capabilities: they cannot cross the action /// boundary until the live main engine reissues an exact proposal. @@ -2090,6 +2225,65 @@ pub fn get_ai_action_proposal_from_scores( })? } +/// Diagnostic counterpart of score-worker proposal rebinding. It preserves the +/// existing authority filter and selector; the returned receipt is local WASM +/// observability data bound to the same opaque token. +#[wasm_bindgen] +pub fn get_ai_action_proposal_from_scores_with_diagnostics( + scores_json: &str, + difficulty: &str, + player_id: u8, + rng_seed: u64, +) -> Result { + let scored: Vec<(GameAction, f64)> = serde_json::from_str(scores_json) + .map_err(|error| JsValue::from_str(&format!("Failed to deserialize AI scores: {error}")))?; + let difficulty = AiDifficulty::from_label(difficulty); + with_state_mut(|state| { + engine::game::layers::flush_layers(state); + let requested_ai = PlayerId(player_id); + let semantic_owner = if state.waiting_for.acting_players().contains(&requested_ai) { + requested_ai + } else { + state + .waiting_for + .acting_player() + .or_else(|| state.waiting_for.acting_players().first().copied()) + .unwrap_or(requested_ai) + }; + let contract = AiDecisionContract::issue(state, semantic_owner); + let admissible_scores: Vec<(GameAction, f64)> = scored + .into_iter() + .filter(|(action, _)| contract.contains_action(state, action)) + .collect(); + let config = + create_config_for_players(difficulty, Platform::Wasm, state.players.len() as u8); + let mut rng = ChaCha20Rng::seed_from_u64(rng_seed); + let Some(selected_index) = phase_ai::select_safe_action_index_from_scores( + state, + &admissible_scores, + config.temperature, + &mut rng, + ) else { + return Ok(JsValue::NULL); + }; + let action = admissible_scores[selected_index].0.clone(); + let actor = contract.authorized_actor; + let mut receipt = phase_ai::decision_receipt::ranked_receipt( + &contract, + &admissible_scores, + Some(selected_index), + config.temperature, + action.clone(), + ); + attach_receipt_object_names(state, &mut receipt); + let token = AI_PROPOSALS.with(|registry| registry.borrow_mut().insert(contract)); + Ok(to_js(&serde_json::json!({ + "proposal": { "token": token, "semanticOwner": semantic_owner.0, "actor": actor.0, "action": action }, + "receipt": receipt, + }))) + })? +} + /// Submit an action selected from an engine-issued AI proposal. /// /// A stale or foreign proposal is a normal race outcome and is returned as a diff --git a/crates/phase-ai/src/decision_receipt.rs b/crates/phase-ai/src/decision_receipt.rs new file mode 100644 index 0000000000..24fdad3639 --- /dev/null +++ b/crates/phase-ai/src/decision_receipt.rs @@ -0,0 +1,271 @@ +use std::cmp::Ordering; + +use engine::ai_support::AiDecisionContract; +use engine::types::GameAction; +use serde::Serialize; + +/// A read-only explanation of an already-minted AI proposal. This is deliberately +/// separate from selection: consumers may inspect it, but cannot use it to mint +/// or alter a game action. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiDecisionDiagnosticReceipt { + pub semantic_owner: u8, + pub authorized_actor: u8, + pub selected_action: GameAction, + pub status: AiDecisionReceiptStatus, + /// Engine-authored selection outcome; shown verbatim by local diagnostics. + pub selection_explanation: String, + /// Temperature used by the ranked softmax selector. `None` means a direct + /// policy chose the action without a scored candidate distribution. + pub sampling_temperature: Option, + pub candidates: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AiDecisionReceiptStatus { + Ranked, + Direct, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiDecisionDiagnosticCandidate { + pub action: GameAction, + /// Engine-resolved name of the object this action operates on, when it has + /// one. WASM enriches this from the authoritative game state for display. + pub object_name: Option, + /// Engine-authored display fields for the action payload. The frontend + /// renders these directly instead of exposing serialized JSON. + pub details: Vec, + pub rank: Option, + pub is_top_ranked: bool, + pub is_selected: bool, + pub score: Option, + pub weight: Option, + pub probability: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiDecisionDiagnosticField { + pub label: String, + pub value: String, +} + +fn finite(value: f64) -> Option { + value.is_finite().then_some(value) +} + +/// The diagnostic rank order mirrors `softmax_select_pairs`' degenerate +/// fallback. It is intentionally used only for receipt annotations; selection +/// retains the caller's score-vector order and its existing softmax behavior. +pub fn ranked_candidate_cmp( + left: &(GameAction, f64), + left_index: usize, + right: &(GameAction, f64), + right_index: usize, +) -> Ordering { + right + .1 + .partial_cmp(&left.1) + .unwrap_or(Ordering::Equal) + .then_with(|| right.0.cmp_stable(&left.0)) + .then_with(|| left_index.cmp(&right_index)) +} + +pub fn ranked_receipt( + contract: &AiDecisionContract, + scored: &[(GameAction, f64)], + selected_index: Option, + temperature: f64, + selected_action: GameAction, +) -> AiDecisionDiagnosticReceipt { + let max_score = scored + .iter() + .map(|(_, score)| *score) + .fold(f64::NEG_INFINITY, f64::max); + let weights: Vec = scored + .iter() + .map(|(_, score)| ((*score - max_score) / temperature).exp()) + .collect(); + let total: f64 = weights.iter().sum(); + let probabilities = (total.is_finite() && total > 0.0).then(|| { + weights + .iter() + .map(|weight| *weight / total) + .collect::>() + }); + + let mut order: Vec = (0..scored.len()).collect(); + order.sort_by(|left, right| { + ranked_candidate_cmp(&scored[*left], *left, &scored[*right], *right) + }); + let mut ranks = vec![0; scored.len()]; + for (position, index) in order.into_iter().enumerate() { + ranks[index] = position + 1; + } + + let selection_explanation = match selected_index { + Some(index) if ranks[index] == 1 => format!( + "Softmax sampled the top-ranked action ({:.1}%) at temperature {temperature:.2}.", + probabilities.as_ref().map_or(0.0, |items| items[index] * 100.0), + ), + Some(index) => format!( + "Softmax sampled rank {} ({:.1}%) instead of rank 1 ({:.1}%) at temperature {temperature:.2}.", + ranks[index], + probabilities.as_ref().map_or(0.0, |items| items[index] * 100.0), + probabilities.as_ref().map_or(0.0, |items| { + let top_index = ranks.iter().position(|rank| *rank == 1).expect("rank one exists"); + items[top_index] * 100.0 + }), + ), + None => "No ranked action was selected.".to_string(), + }; + + AiDecisionDiagnosticReceipt { + semantic_owner: contract.semantic_owner.0, + authorized_actor: contract.authorized_actor.0, + selected_action, + status: AiDecisionReceiptStatus::Ranked, + selection_explanation, + sampling_temperature: finite(temperature), + candidates: scored + .iter() + .enumerate() + .map(|(index, (action, score))| AiDecisionDiagnosticCandidate { + action: action.clone(), + object_name: None, + details: Vec::new(), + rank: Some(ranks[index]), + is_top_ranked: ranks[index] == 1, + is_selected: selected_index == Some(index), + score: finite(*score), + weight: finite(weights[index]), + probability: probabilities + .as_ref() + .and_then(|items| finite(items[index])), + }) + .collect(), + } +} + +pub fn direct_receipt( + contract: &AiDecisionContract, + selected_action: GameAction, +) -> AiDecisionDiagnosticReceipt { + let mut selected_row_found = false; + AiDecisionDiagnosticReceipt { + semantic_owner: contract.semantic_owner.0, + authorized_actor: contract.authorized_actor.0, + selected_action: selected_action.clone(), + status: AiDecisionReceiptStatus::Direct, + selection_explanation: + "A direct AI policy selected this action; no scored distribution was used.".to_string(), + sampling_temperature: None, + candidates: contract + .candidates + .iter() + .map(|candidate| { + // Direct strategies retain the selected action separately for + // synthesized actions. When the action was contract-issued, + // mark its first issuance exactly once, preserving candidate + // vector order even if an issuer contains duplicate actions. + let is_selected = !selected_row_found && candidate.action == selected_action; + selected_row_found |= is_selected; + AiDecisionDiagnosticCandidate { + action: candidate.action.clone(), + object_name: None, + details: Vec::new(), + is_selected, + rank: None, + is_top_ranked: false, + score: None, + weight: None, + probability: None, + } + }) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use engine::ai_support::{ActionMetadata, CandidateAction, TacticalClass}; + use engine::types::PlayerId; + + fn contract(actions: Vec) -> AiDecisionContract { + AiDecisionContract { + semantic_owner: PlayerId(0), + authorized_actor: PlayerId(0), + state_revision: 1, + candidates: actions + .into_iter() + .map(|action| CandidateAction { + action, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Utility), + }) + .collect(), + } + } + + #[test] + fn direct_receipt_marks_the_single_issued_selected_row() { + let pass = GameAction::PassPriority; + let receipt = direct_receipt(&contract(vec![pass.clone()]), pass.clone()); + + assert_eq!(receipt.selected_action, pass); + assert_eq!(receipt.status, AiDecisionReceiptStatus::Direct); + assert!(receipt.candidates[0].is_selected); + assert_eq!(receipt.candidates[0].rank, None); + } + + #[test] + fn ranked_receipt_preserves_vector_order_and_annotates_selection() { + let pass = GameAction::PassPriority; + let choice = GameAction::ChoosePlayDraw { play_first: true }; + let contract = contract(vec![pass.clone(), choice.clone()]); + let receipt = ranked_receipt( + &contract, + &[(pass.clone(), 0.0), (choice.clone(), 0.0)], + Some(1), + 1.0, + choice, + ); + + assert_eq!(receipt.candidates[0].action, pass); + assert!(receipt.candidates[1].is_selected); + assert_eq!( + receipt + .candidates + .iter() + .filter(|candidate| candidate.is_top_ranked) + .count(), + 1 + ); + assert_eq!( + receipt.candidates[0].rank.unwrap() + receipt.candidates[1].rank.unwrap(), + 3 + ); + assert_eq!(receipt.candidates[0].weight, Some(1.0)); + assert_eq!(receipt.candidates[0].probability, Some(0.5)); + } + + #[test] + fn nonfinite_metrics_serialize_as_null_options() { + let pass = GameAction::PassPriority; + let receipt = ranked_receipt( + &contract(vec![pass.clone()]), + &[(pass.clone(), f64::NAN)], + Some(0), + 1.0, + pass, + ); + + assert_eq!(receipt.candidates[0].score, None); + assert_eq!(receipt.candidates[0].weight, None); + assert_eq!(receipt.candidates[0].probability, None); + } +} diff --git a/crates/phase-ai/src/lib.rs b/crates/phase-ai/src/lib.rs index ac5cc7eca9..172c3bd06c 100644 --- a/crates/phase-ai/src/lib.rs +++ b/crates/phase-ai/src/lib.rs @@ -11,6 +11,7 @@ pub mod config; pub mod context; pub mod damage_reflection; pub mod decision_kind; +pub mod decision_receipt; pub mod deck_knowledge; pub mod deck_profile; pub mod determinize; @@ -41,6 +42,7 @@ pub use config::{ create_config, create_config_for_players, AiConfig, AiDifficulty, AiProfile, OpponentModel, PlannerMode, Platform, SearchConfig, }; +pub use decision_receipt::{AiDecisionDiagnosticReceipt, AiDecisionReceiptStatus}; pub use deck_profile::ArchetypeMultipliers; pub use draft_eval::{ evaluate_draft_card, evaluate_draft_card_default, rarity_prior, DraftWeights, @@ -52,7 +54,8 @@ pub use eval::{ StrategicIntent, }; pub use search::{ - choose_action, choose_action_with_session, fallback_action, score_candidates, - score_candidates_for_parallel_worker, select_safe_action_from_scores, + choose_action, choose_action_with_session, choose_action_with_session_diagnostic, + fallback_action, score_candidates, score_candidates_for_parallel_worker, + select_safe_action_from_scores, select_safe_action_index_from_scores, }; pub use session::{deck_pools_fingerprint, AiSession, SessionCache}; diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 11c12eb1fd..177364dd12 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -194,7 +194,7 @@ pub fn choose_action( rng: &mut impl Rng, ) -> Option { let session = AiSession::arc_from_game(state); - choose_action_with_session_inner(state, ai_player, config, rng, &session, false) + choose_action_with_session_inner(state, ai_player, config, rng, &session, false, false).action } /// Choose the best action using a caller-owned per-game session cache. @@ -205,7 +205,25 @@ pub fn choose_action_with_session( rng: &mut impl Rng, session: &Arc, ) -> Option { - choose_action_with_session_inner(state, ai_player, config, rng, session, true) + choose_action_with_session_inner(state, ai_player, config, rng, session, true, false).action +} + +/// Select once using the canonical chooser and retain an optional, read-only +/// receipt of that same choice for the local WASM authority. +pub fn choose_action_with_session_diagnostic( + state: &GameState, + ai_player: PlayerId, + config: &AiConfig, + rng: &mut impl Rng, + session: &Arc, +) -> AiDecisionSelection { + choose_action_with_session_inner(state, ai_player, config, rng, session, true, true) +} + +#[derive(Clone, Debug)] +pub struct AiDecisionSelection { + pub action: Option, + pub receipt: Option, } fn choose_action_with_session_inner( @@ -215,8 +233,19 @@ fn choose_action_with_session_inner( rng: &mut impl Rng, session: &Arc, durable_pact_routes: bool, -) -> Option { + diagnostics: bool, +) -> AiDecisionSelection { let contract = AiDecisionContract::issue(state, ai_player); + let direct = |action: Option| AiDecisionSelection { + receipt: diagnostics + .then(|| { + action.as_ref().map(|action| { + crate::decision_receipt::direct_receipt(&contract, action.clone()) + }) + }) + .flatten(), + action, + }; // `AiDecisionContract` holds the finite domain the action boundary accepts. // A heuristic's pick is usable only if the engine's enumerator issued it — // `build_decision_context` states the rule: "the tactical layer must receive @@ -274,12 +303,12 @@ fn choose_action_with_session_inner( WaitingFor::MulliganDecision { pending, .. } if !pending.iter().any(|e| e.player == ai_player) => { - return None; + return direct(None); } WaitingFor::OpeningHandBottomCards { pending, .. } if !pending.iter().any(|e| e.player == ai_player) => { - return None; + return direct(None); } _ => {} } @@ -292,11 +321,13 @@ fn choose_action_with_session_inner( // Do not wait for speculative cast/payment scoring to fail before answering // it: the engine-issued domain already supplies a valid path forward. if target_selection_has_no_modeled_effect(state) { - return issued_domain() - .into_iter() - .find(|action| matches!(action, GameAction::ChooseTarget { .. })) - .or_else(|| fallback_action(state, config, &contract)) - .and_then(&bind_specialist); + return direct( + issued_domain() + .into_iter() + .find(|action| matches!(action, GameAction::ChooseTarget { .. })) + .or_else(|| fallback_action(state, config, &contract)) + .and_then(&bind_specialist), + ); } // Gated on the variant so the hot `Priority` path never materializes the @@ -305,7 +336,7 @@ fn choose_action_with_session_inner( if let Some(action) = random_card_predicate_guess(state, ai_player, &issued_domain(), rng) .and_then(&bind_specialist) { - return Some(action); + return direct(Some(action)); } } @@ -319,7 +350,7 @@ fn choose_action_with_session_inner( }) .and_then(&bind_specialist) { - return Some(action); + return direct(Some(action)); } } @@ -341,7 +372,7 @@ fn choose_action_with_session_inner( if let Ok(mut follow_ups) = session.prospective_fetch_follow_up.write() { follow_ups.insert(ai_player, prompt.follow_up()); } - return Some(action); + return direct(Some(action)); } } } @@ -350,7 +381,7 @@ fn choose_action_with_session_inner( deterministic_choice(state, ai_player, config, &issued_domain(), Some(&context)) .and_then(&bind_specialist) { - return Some(action); + return direct(Some(action)); } } @@ -363,7 +394,7 @@ fn choose_action_with_session_inner( deterministic_choice(state, ai_player, config, &issued_domain(), Some(&context)) .and_then(&bind_specialist) { - return Some(action); + return direct(Some(action)); } } @@ -386,7 +417,7 @@ fn choose_action_with_session_inner( .filter(|action| matches!(action, GameAction::ChooseOption { .. })) .collect(); if let Some(action) = guesses.choose(rng).cloned().and_then(&bind_specialist) { - return Some(action); + return direct(Some(action)); } } @@ -396,7 +427,7 @@ fn choose_action_with_session_inner( .action_for(state, ai_player) .and_then(&bind_specialist) { - return Some(action); + return direct(Some(action)); } } } @@ -414,7 +445,7 @@ fn choose_action_with_session_inner( arm_certified_pact_route(state, &action, ai_player, session); } if let Some(action) = bind_specialist(action) { - return Some(action); + return direct(Some(action)); } } @@ -427,29 +458,49 @@ fn choose_action_with_session_inner( if scored.is_empty() { // No valid candidates from search — fall back to a safe escape action // so the game never deadlocks waiting for the AI. - return fallback_action(state, config, &contract) - .filter(|action| root_action_is_allowed(state, ai_player, action)) - .filter(|action| { - durable_pact_routes || !is_certified_pact_root(state, ai_player, action) - }) - .filter(&in_contract); + return direct( + fallback_action(state, config, &contract) + .filter(|action| root_action_is_allowed(state, ai_player, action)) + .filter(|action| { + durable_pact_routes || !is_certified_pact_root(state, ai_player, action) + }) + .filter(&in_contract), + ); } // Issue #4878: total order before softmax so equal scores never depend on // HashSet/HashMap allocation order. scored.sort_by(|a, b| a.0.cmp_stable(&b.0)); let chosen = if scored.len() == 1 { - Some(scored[0].0.clone()) + Some((0, scored[0].0.clone())) } else { - softmax_select_pairs(&scored, config.temperature, rng) + softmax_select_index(&scored, config.temperature, rng) + .map(|index| (index, scored[index].0.clone())) }; - if let Some(action) = &chosen { + if let Some((_, action)) = &chosen { arm_certified_fetch_prompt(action, ai_player, session); if durable_pact_routes { arm_certified_pact_route(state, action, ai_player, session); } emit_decision_trace(state, ai_player, config, action, session); } - chosen.filter(&in_contract) + let selected_index = chosen.as_ref().map(|(index, _)| *index); + let action = chosen.map(|(_, action)| action).filter(&in_contract); + AiDecisionSelection { + receipt: diagnostics + .then(|| { + action.as_ref().map(|selected| { + crate::decision_receipt::ranked_receipt( + &contract, + &scored, + selected_index, + config.temperature, + selected.clone(), + ) + }) + }) + .flatten(), + action, + } } fn random_card_predicate_guess( @@ -4117,23 +4168,46 @@ pub fn select_safe_action_from_scores( temperature: f64, rng: &mut impl Rng, ) -> Option { - softmax_select_pairs(scored, temperature, rng) - .filter(|action| !is_pact_payment_cast(state, action)) + select_safe_action_index_from_scores(state, scored, temperature, rng) + .map(|index| scored[index].0.clone()) } -/// Internal softmax primitive for the canonical chooser and phase-AI tests. -/// It intentionally has no game-state context, so it must not cross the -/// crate boundary where a Pact result could lose its durable receipt route. +/// Canonical score-worker selection index, retained so diagnostics can mark an +/// exact duplicate row without a second selector pass. +pub fn select_safe_action_index_from_scores( + state: &GameState, + scored: &[(GameAction, f64)], + temperature: f64, + rng: &mut impl Rng, +) -> Option { + softmax_select_index(scored, temperature, rng) + .filter(|index| !is_pact_payment_cast(state, &scored[*index].0)) +} + +/// Test-only softmax wrapper that returns the selected action rather than its +/// index. Production selection keeps the index so diagnostics can identify +/// duplicate rows without comparing actions. +#[cfg(test)] pub(crate) fn softmax_select_pairs( scored: &[(GameAction, f64)], temperature: f64, rng: &mut impl Rng, ) -> Option { + softmax_select_index(scored, temperature, rng).map(|index| scored[index].0.clone()) +} + +/// The canonical selector's chosen vector index. Kept private to the tactical +/// layer so diagnostics can identify duplicate rows without comparing actions. +fn softmax_select_index( + scored: &[(GameAction, f64)], + temperature: f64, + rng: &mut impl Rng, +) -> Option { if scored.is_empty() { return None; } if scored.len() == 1 { - return Some(scored[0].0.clone()); + return Some(0); } // Numerical stability: subtract max score @@ -4150,12 +4224,14 @@ pub(crate) fn softmax_select_pairs( // issue #4878). return scored .iter() - .max_by(|a, b| { - a.1.partial_cmp(&b.1) + .enumerate() + .max_by(|(_, left), (_, right)| { + left.1 + .partial_cmp(&right.1) .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.0.cmp_stable(&b.0)) + .then_with(|| left.0.cmp_stable(&right.0)) }) - .map(|s| s.0.clone()); + .map(|(index, _)| index); } let threshold: f64 = rng.random::() * total; @@ -4163,12 +4239,12 @@ pub(crate) fn softmax_select_pairs( for (i, w) in weights.iter().enumerate() { cumulative += w; if cumulative >= threshold { - return Some(scored[i].0.clone()); + return Some(i); } } // Fallback to last - Some(scored.last().unwrap().0.clone()) + Some(scored.len() - 1) } #[cfg(test)]