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
114 changes: 114 additions & 0 deletions client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,69 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => {
expect(mockGetViewerSnapshot).toHaveBeenCalledWith(2);
});

it("keeps a host zero-count debug create out of transition side effects", async () => {
const { adapter } = makeHost(2);
await adapter.initialize();
const revisionBefore = (adapter as unknown as { authoritativeRevision: number })
.authoritativeRevision;

await expect(adapter.submitAction({
type: "Debug",
data: {
type: "CreateCard",
data: {
card_name: "Lightning Bolt",
owner: 0,
zone: "Hand",
run_etb: false,
nonlegendary: false,
count: 0,
},
},
}, 0)).resolves.toEqual({ events: [] });

expect(mockSubmitAction).toHaveBeenCalledOnce();
expect((adapter as unknown as { authoritativeRevision: number }).authoritativeRevision)
.toBe(revisionBefore);
expect(mockGetViewerSnapshot).not.toHaveBeenCalled();
expect(mockGetState).not.toHaveBeenCalled();
});

it("acknowledges a guest zero-count debug create without broadcasting a transition", async () => {
const { adapter, emitConnection } = makeHost(2);
await adapter.initialize();
const guest = await joinGuest(emitConnection, {
type: "guest_deck",
deckData: { player: { main_deck: [], sideboard: [] } },
});
await adapter.initializeGame();
guest.sent.length = 0;
mockGetViewerSnapshot.mockClear();
mockGetState.mockClear();
const revisionBefore = (adapter as unknown as { authoritativeRevision: number })
.authoritativeRevision;

await guest.simulateData({
type: "action",
senderPlayerId: 1,
action: {
type: "Debug",
data: {
type: "CreateTokenCopy",
data: { source_id: 1, owner: 1, nonlegendary: false, count: 0 },
},
},
});

expect(await guest.getSentMessages()).toEqual([
expect.objectContaining({ type: "action_noop" }),
]);
expect((adapter as unknown as { authoritativeRevision: number }).authoritativeRevision)
.toBe(revisionBefore);
expect(mockGetViewerSnapshot).not.toHaveBeenCalled();
expect(mockGetState).not.toHaveBeenCalled();
});

it("holds the seat on guest disconnect and NEVER auto-concedes on grace expiry", async () => {
const { adapter, emitConnection } = makeHost(3, 5_000);
await adapter.initialize();
Expand Down Expand Up @@ -1772,6 +1835,57 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => {
);
});

it("guest receive path resolves action_noop without replacing its cached snapshot", async () => {
const { peer } = createFakePeer();
const conn = new FakeDataConnection();
const adapter = new P2PGuestAdapter(
{ player: { main_deck: [], sideboard: [] } },
peer as unknown as Peer,
"host-peer",
conn as unknown as DataConnection,
);
const emitted = vi.fn();
adapter.onEvent(emitted);
await adapter.initialize();
const setupState = remoteState("setup");
await conn.simulateData({
type: "game_setup",
wireProtocolVersion: WIRE_PROTOCOL_VERSION,
assignedPlayerId: 1,
playerToken: "seat-token",
state: setupState,
events: [],
legalActions: [],
autoPassRecommended: false,
manaPaymentShortcutActions: [],
});
await adapter.initializeGame();
const cachedSnapshot = await adapter.getSnapshot();
emitted.mockClear();

const pending = adapter.submitAction({
type: "Debug",
data: {
type: "CreateCard",
data: {
card_name: "Lightning Bolt",
owner: 1,
zone: "Hand",
run_etb: false,
nonlegendary: false,
count: 0,
},
},
}, 1);
await conn.simulateData({ type: "action_noop" });

await expect(pending).resolves.toEqual({ events: [], log_entries: [] });
expect(await adapter.getSnapshot()).toBe(cachedSnapshot);
expect(emitted).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "stateChanged" }),
);
});

// Issue #5913: the host relays the engine's verdict verbatim, so a guest must
// classify a stale ReorderHand exactly as the local-WASM seat does. Before the
// shared classifier this path built a generic ACTION_REJECTED, and
Expand Down
60 changes: 60 additions & 0 deletions client/src/adapter/__tests__/wasm-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,21 @@ describe("WasmAdapter", () => {
});

describe("submitAction", () => {
const createCard = (count: number) => ({
type: "Debug" as const,
data: {
type: "CreateCard" as const,
data: {
card_name: "Lightning Bolt",
owner: 0,
zone: "Hand" as const,
run_etb: false,
nonlegendary: false,
count,
},
},
});

it("throws AdapterError with NOT_INITIALIZED if not initialized", async () => {
await expect(
adapter.submitAction({ type: "PassPriority" }, 0),
Expand All @@ -404,6 +419,51 @@ describe("WasmAdapter", () => {
);
});

it("submits a zero-count debug create without loading the card database", async () => {
await adapter.initialize();

await expect(adapter.submitAction(createCard(0), 0)).resolves.toEqual({
events: [],
log_entries: [],
});

expect(mockWorkerClient.submitAction).toHaveBeenCalledOnce();
expect(mockWorkerClient.loadCardDbFromUrl).not.toHaveBeenCalled();
});

it("does not load the card database when Rust rejects debug-create preflight", async () => {
mockWorkerClient.submitAction.mockRejectedValueOnce(
new Error("Engine error: DebugAction is only allowed in Sandbox mode"),
);
await adapter.initialize();

await expect(adapter.submitAction(createCard(1), 0)).rejects.toThrow(
"DebugAction is only allowed in Sandbox mode",
);

expect(mockWorkerClient.submitAction).toHaveBeenCalledOnce();
expect(mockWorkerClient.loadCardDbFromUrl).not.toHaveBeenCalled();
});

it("loads the card database and retries only after Rust admits a nonzero create", async () => {
mockWorkerClient.submitAction
.mockRejectedValueOnce(new Error("Engine error: card database not loaded"))
.mockResolvedValueOnce({ events: [], log_entries: [] });
await adapter.initialize();

await expect(adapter.submitAction(createCard(1), 0)).resolves.toEqual({
events: [],
log_entries: [],
});

expect(mockWorkerClient.submitAction).toHaveBeenCalledTimes(2);
expect(mockWorkerClient.loadCardDbFromUrl).toHaveBeenCalledOnce();
expect(mockWorkerClient.submitAction.mock.invocationCallOrder[0])
.toBeLessThan(mockWorkerClient.loadCardDbFromUrl.mock.invocationCallOrder[0]);
expect(mockWorkerClient.loadCardDbFromUrl.mock.invocationCallOrder[0])
.toBeLessThan(mockWorkerClient.submitAction.mock.invocationCallOrder[1]);
});

// Regression: state-loss classification splits on whether the panic
// hook captured a message. ENGINE_PANIC must NOT be retried (re-running
// the same input re-panics — the user-reported "ai-getAction-retry"
Expand Down
30 changes: 30 additions & 0 deletions client/src/adapter/__tests__/ws-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,36 @@ describe("WebSocketAdapter", () => {
});
});

it("resolves an accepted no-op without publishing a state transition", async () => {
const listener = vi.fn();
adapter.onEvent(listener);
const pending = adapter.submitAction(
{
type: "Debug",
data: {
type: "CreateCard",
data: {
card_name: "Lightning Bolt",
owner: 0,
zone: "Hand",
run_etb: false,
nonlegendary: false,
count: 0,
},
},
},
0,
);

ws.dispatchSynthetic("message", JSON.stringify({ type: "ActionNoOp" }));

await expect(pending).resolves.toEqual({ events: [], log_entries: [] });
expect(listener).toHaveBeenCalledWith({ type: "actionPendingChanged", pending: false });
expect(listener).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "stateChanged" }),
);
});

// A refused takeback answers a fire-and-forget request, so no promise owns
// the rejection. Before this branch the whole `if (this.pendingReject)`
// body was skipped and the refusal was dropped on the floor — which is why
Expand Down
12 changes: 0 additions & 12 deletions client/src/adapter/engine-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,18 +251,6 @@ self.onmessage = async (e: MessageEvent<EngineRequest>) => {
}

case "submitAction": {
if (
!cardDbLoaded &&
msg.action?.type === "Debug" &&
msg.action?.data?.type === "CreateCard"
) {
const resp = await fetch(__CARD_DATA_URL__);
if (resp.ok) {
const text = await resp.text();
load_card_database(text);
cardDbLoaded = true;
}
}
const actionResult = submit_action(msg.actor, msg.action);
if (typeof actionResult === "string") {
// Rust's submit_action error contract: returns the error string
Expand Down
26 changes: 26 additions & 0 deletions client/src/adapter/p2p-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,18 @@ function traceAdapter(side: "Host" | "Guest", event: string, data?: Record<strin
console.debug(`[P2P ${side} Adapter]`, performance.now().toFixed(1), event, data ?? {});
}

function isZeroCountDebugCreate(action: GameAction): boolean {
if (action.type !== "Debug") return false;
switch (action.data.type) {
case "CreateCard":
case "CreateToken":
case "CreateTokenCopy":
return action.data.data.count === 0;
default:
return false;
}
}
Comment on lines +570 to +580

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Keep the no-op outcome engine-owned and typed.

The adapters derive no-op semantics from GameAction and then resolve ActionNoOp as a normal SubmitResult. client/src/game/dispatch.ts calls adapter.getSnapshot() after every resolved action. Therefore, the host path at Line 1709 still requests a worker snapshot for a zero-count action.

Expose a typed engine result such as Applied or NoOp. Propagate it through both adapters. Update client/src/game/dispatch.ts to return before snapshot retrieval for NoOp.

  • client/src/adapter/p2p-adapter.ts#L570-L580: remove the local action-shape classifier.
  • client/src/adapter/p2p-adapter.ts#L1706-L1710: return the typed engine outcome without erasing NoOp.
  • client/src/adapter/p2p-adapter.ts#L2165-L2169: send action_noop only from the typed engine outcome.
  • client/src/adapter/p2p-adapter.ts#L3058-L3065: resolve the pending request with the typed NoOp outcome.
  • client/src/adapter/ws-adapter.ts#L1453-L1461: preserve the typed NoOp outcome instead of returning a normal empty result.
📍 Affects 2 files
  • client/src/adapter/p2p-adapter.ts#L570-L580 (this comment)
  • client/src/adapter/p2p-adapter.ts#L1706-L1710
  • client/src/adapter/p2p-adapter.ts#L2165-L2169
  • client/src/adapter/p2p-adapter.ts#L3058-L3065
  • client/src/adapter/ws-adapter.ts#L1453-L1461
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/adapter/p2p-adapter.ts` around lines 570 - 580, Replace
action-shape-based no-op detection with a typed engine outcome such as Applied
or NoOp, and propagate it without erasing NoOp. In
client/src/adapter/p2p-adapter.ts at lines 570-580 remove
isZeroCountDebugCreate; update lines 1706-1710 to return the typed outcome,
lines 2165-2169 to emit action_noop only for NoOp, and lines 3058-3065 to
resolve pending requests with that outcome. In client/src/adapter/ws-adapter.ts
lines 1453-1461 preserve NoOp rather than converting it to an empty normal
result. Update client/src/game/dispatch.ts to return before getSnapshot() when
the resolved outcome is NoOp.

Source: Path instructions


/**
* Host-side P2P adapter.
*
Expand Down Expand Up @@ -1694,6 +1706,7 @@ export class P2PHostAdapter implements EngineAdapter {
const result = this.nativeBridge
? await this.nativeBridge.submitAction(action, actor)
: await this.wasm.submitAction(action, actor);
if (isZeroCountDebugCreate(action)) return result;
await this.broadcastStateUpdate(result.events, result.log_entries);
await this.runAiLoop();
this.persistAuthoritativeState();
Expand Down Expand Up @@ -2149,6 +2162,11 @@ export class P2PHostAdapter implements EngineAdapter {
const result = this.nativeBridge
? await this.nativeBridge.submitAction(msg.action, pid)
: await this.wasm.submitAction(msg.action, pid);
if (isZeroCountDebugCreate(msg.action)) {
const session = this.guestSessions.get(pid);
if (session) await this.send(session, { type: "action_noop" });
break;
}
await this.broadcastStateUpdate(result.events, result.log_entries);
// Wake the AI loop. After a guest's action lands, priority may have
// shifted to an AI seat — without this, the AI never gets a turn
Expand Down Expand Up @@ -3037,6 +3055,14 @@ export class P2PGuestAdapter implements EngineAdapter {
}
break;
}
case "action_noop": {
if (this.pendingResolve) {
this.pendingResolve({ events: [], log_entries: [] });
this.pendingResolve = null;
this.pendingReject = null;
}
break;
}
case "mana_payment_preview": {
const pending = this.pendingManaPaymentPreviews.get(msg.requestId);
if (pending) {
Expand Down
13 changes: 12 additions & 1 deletion client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2424,6 +2424,17 @@ export type PlanarDieFace = "Planeswalk" | "Chaos" | "Blank";

// ── Game Events (discriminated union, tag="type", content="data") ────────

/** Exact serde spellings of the engine's `PlayerActionKind` enum. */
export type PlayerActionKind =
| "AcceptedOptionalEffect"
| "SearchedLibrary"
| "Scry"
| "Surveil"
| "CollectEvidence"
| "ShuffledLibrary"
| "Proliferate"
| "Investigate";

export type GameEvent =
| { type: "GameStarted" }
| {
Expand Down Expand Up @@ -2470,7 +2481,7 @@ export type GameEvent =
type: "PlayerPerformedAction";
data: {
player_id: PlayerId;
action: string;
action: PlayerActionKind;
look_count?: number;
scry_bottom_count?: number;
scry_top_count?: number;
Expand Down
24 changes: 20 additions & 4 deletions client/src/adapter/wasm-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ function isMemoryConstrainedDevice(): boolean {
// Parallel scoring is optional. Bound its queued restore-and-score work so a
// stalled score worker cannot make a healthy local game appear hung.
const AI_POOL_SCORE_TIMEOUT_MS = 5_000;
const DEBUG_CREATE_CARD_DB_MISSING = "Engine error: card database not loaded";

function isDebugCreateCard(action: GameAction): boolean {
return action.type === "Debug" && action.data.type === "CreateCard";
}

function isDebugCreateCardDbMissing(error: unknown): boolean {
return error instanceof Error && error.message === DEBUG_CREATE_CARD_DB_MISSING;
}

class AiPoolScoreTimeoutError extends Error {
constructor() {
Expand Down Expand Up @@ -304,11 +313,18 @@ export class WasmAdapter implements EngineAdapter, AiDecisionDiagnosticsCapabili

async submitAction(action: GameAction, actor: PlayerId): Promise<SubmitResult> {
this.assertInitialized();
if (action.type === "Debug" && action.data.type === "CreateCard") {
await this.ensureCardDb();
}
try {
const result = this.engine ? await this.engine.submitAction(actor, action) : await this.fallback!.submitAction(action, actor);
const submit = () => this.engine
? this.engine.submitAction(actor, action)
: this.fallback!.submitAction(action, actor);
let result: SubmitResult;
try {
result = await submit();
} catch (error) {
if (!isDebugCreateCard(action) || !isDebugCreateCardDbMissing(error)) throw error;
await this.ensureCardDb();
result = await submit();
}
this.invalidateAiDecisionDiagnostics();
return result;
} catch (err) {
Expand Down
Loading
Loading