Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 165 additions & 1 deletion client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
};
}),
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 },
Expand All @@ -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 = {
Expand Down
88 changes: 87 additions & 1 deletion client/src/adapter/__tests__/wasm-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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",
Expand All @@ -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", () => {
Expand Down
23 changes: 23 additions & 0 deletions client/src/adapter/engine-worker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
import type {
AiActionProposal,
AiDecisionDiagnosticReceipt,
AiProposalSubmission,
BatchResolveResult,
FormatConfig,
Expand Down Expand Up @@ -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,
Expand All @@ -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<AiProposalSubmission> {
Expand Down
Loading
Loading