feat(multiplayer): game.action WS message for PlayerActions
Wire up the game.action WebSocket message so multiplayer games can dispatch PlayerActions (F4b of post-epic-deferrals). - Export PlayerAction/ActionResult from @paratype/chess barrel - Add performAction wrapper to GameSession - Protocol: GameActionMessageSchema + ClientMessage union update - Server: handleGameAction handler with turn gate + error mapping - Client: sendAction helper in useMultiplayerGame + net/types update - Reuse game.state broadcast (no new server→client message type) Unit tests: 1709 (baseline 1699 + 10 new: 5 protocol, 3 game-session, 2 net) Playwright: 91 (baseline 89 + 2 new F4b multiplayer scenarios)
This commit is contained in:
parent
8220f1507e
commit
2951a2d547
10 changed files with 939 additions and 1 deletions
|
|
@ -469,6 +469,367 @@ test('F1 color preference: default (no field) keeps legacy creator=white', async
|
||||||
await ctxA.close();
|
await ctxA.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// F4b (post-epic-deferrals): game.action WS message
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a room with a preset list preloaded via raw `room.create`.
|
||||||
|
* Mirrors `wsCreateRoom` but threads the ruleset ids so the
|
||||||
|
* transferable-royalty action has an owning preset on both sides
|
||||||
|
* from move 1 — no drawer interaction required.
|
||||||
|
*/
|
||||||
|
async function wsCreateRoomWithPresets(
|
||||||
|
page: Page,
|
||||||
|
rulesetIds: string[],
|
||||||
|
): Promise<{ code: string; token: string; color: string }> {
|
||||||
|
return page.evaluate(async (ids: string[]) => {
|
||||||
|
return new Promise<{ code: string; token: string; color: string }>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error('wsCreateRoomWithPresets: timeout')),
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
seq: 1,
|
||||||
|
ts: Date.now(),
|
||||||
|
type: 'room.create',
|
||||||
|
payload: { rulesetIds: ids },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ws.onmessage = (e: MessageEvent) => {
|
||||||
|
const msg = JSON.parse(e.data as string) as {
|
||||||
|
type: string;
|
||||||
|
payload: {
|
||||||
|
code: string;
|
||||||
|
token: string;
|
||||||
|
color: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (msg.type === 'room.created') {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.close();
|
||||||
|
resolve(msg.payload);
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.close();
|
||||||
|
reject(new Error(msg.payload.message ?? 'room.create error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error('wsCreateRoomWithPresets: WebSocket error'));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, rulesetIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a `game.action` envelope over a fresh authenticated WebSocket
|
||||||
|
* and return the first server message that either acks (via a
|
||||||
|
* `game.state` broadcast) or rejects (via `error`).
|
||||||
|
*
|
||||||
|
* Opens its own socket per call because the e2e tests interact with
|
||||||
|
* the multiplayer UI pages' sockets too — sharing one socket across
|
||||||
|
* the test scope would race with the UI's PredictionManager.
|
||||||
|
*/
|
||||||
|
async function wsSendAction(
|
||||||
|
page: Page,
|
||||||
|
code: string,
|
||||||
|
token: string,
|
||||||
|
action: { kind: string; fromPieceId: number; toPieceId: number },
|
||||||
|
): Promise<{ kind: 'state'; turn: string } | { kind: 'error'; code: string; message: string }> {
|
||||||
|
return page.evaluate(
|
||||||
|
async (args: {
|
||||||
|
code: string;
|
||||||
|
token: string;
|
||||||
|
action: { kind: string; fromPieceId: number; toPieceId: number };
|
||||||
|
}) => {
|
||||||
|
return new Promise<
|
||||||
|
| { kind: 'state'; turn: string }
|
||||||
|
| { kind: 'error'; code: string; message: string }
|
||||||
|
>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error('wsSendAction: timeout')),
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
let joined = false;
|
||||||
|
let actionSent = false;
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Re-join with the stored token so the server recognises us.
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
seq: 1,
|
||||||
|
ts: Date.now(),
|
||||||
|
token: args.token,
|
||||||
|
type: 'room.join',
|
||||||
|
payload: { code: args.code },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ws.onmessage = (e: MessageEvent) => {
|
||||||
|
const msg = JSON.parse(e.data as string) as {
|
||||||
|
type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (msg.type === 'room.joined' && !joined) {
|
||||||
|
joined = true;
|
||||||
|
// Don't fire action yet — wait for the initial game.state
|
||||||
|
// snapshot that the server sends on join.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'game.state' && !actionSent) {
|
||||||
|
// This is the initial state snapshot from join. Now fire
|
||||||
|
// the action so the next game.state is the post-action one.
|
||||||
|
actionSent = true;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
seq: 2,
|
||||||
|
ts: Date.now(),
|
||||||
|
token: args.token,
|
||||||
|
type: 'game.action',
|
||||||
|
payload: { action: args.action },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'game.state' && actionSent) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.close();
|
||||||
|
resolve({ kind: 'state', turn: msg.payload.turn as string });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.close();
|
||||||
|
resolve({
|
||||||
|
kind: 'error',
|
||||||
|
code: msg.payload.code as string,
|
||||||
|
message: msg.payload.message as string,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error('wsSendAction: WebSocket error'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ code, token, action },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('F4b game.action: transfer-royalty via wire succeeds and broadcasts to both clients', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const ctxA = await browser.newContext();
|
||||||
|
const ctxB = await browser.newContext();
|
||||||
|
const pageA = await ctxA.newPage();
|
||||||
|
const pageB = await ctxB.newPage();
|
||||||
|
|
||||||
|
// 1. Room with transferable-royalty preset active from the start.
|
||||||
|
await pageA.goto('http://localhost:5173/');
|
||||||
|
const roomA = await wsCreateRoomWithPresets(pageA, ['transferable-royalty']);
|
||||||
|
expect(roomA.color).toBe('white');
|
||||||
|
|
||||||
|
await pageB.goto('http://localhost:5173/');
|
||||||
|
const roomB = await wsJoinRoom(pageB, roomA.code);
|
||||||
|
expect(roomB.color).toBe('black');
|
||||||
|
|
||||||
|
// 2. Probe the starting layout so we know which entity ids the engine
|
||||||
|
// assigned to white's king (e1, square 4) and queen (d1, square 3).
|
||||||
|
// A one-shot socket queries `game.state` via room.join.
|
||||||
|
const startingFacts = await pageA.evaluate(
|
||||||
|
async (args: { code: string; token: string }) => {
|
||||||
|
return new Promise<{ id: number; attr: string; value: unknown }[]>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||||
|
const t = setTimeout(() => reject(new Error('timeout')), 5000);
|
||||||
|
ws.onopen = () =>
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
seq: 1,
|
||||||
|
ts: Date.now(),
|
||||||
|
token: args.token,
|
||||||
|
type: 'room.join',
|
||||||
|
payload: { code: args.code },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ws.onmessage = (e: MessageEvent) => {
|
||||||
|
const msg = JSON.parse(e.data as string) as {
|
||||||
|
type: string;
|
||||||
|
payload: {
|
||||||
|
facts?: { id: number; attr: string; value: unknown }[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (msg.type === 'game.state') {
|
||||||
|
clearTimeout(t);
|
||||||
|
ws.close();
|
||||||
|
resolve(msg.payload.facts ?? []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
reject(new Error('ws error'));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ code: roomA.code, token: roomA.token },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Locate white king (e1=4) and queen (d1=3) ids via fact scan.
|
||||||
|
const findPiece = (
|
||||||
|
facts: { id: number; attr: string; value: unknown }[],
|
||||||
|
pieceType: string,
|
||||||
|
square: number,
|
||||||
|
): number | null => {
|
||||||
|
const typeIds = new Set(
|
||||||
|
facts
|
||||||
|
.filter((f) => f.attr === 'PieceType' && f.value === pieceType)
|
||||||
|
.map((f) => f.id),
|
||||||
|
);
|
||||||
|
const hit = facts.find(
|
||||||
|
(f) => f.attr === 'Position' && f.value === square && typeIds.has(f.id),
|
||||||
|
);
|
||||||
|
return hit ? hit.id : null;
|
||||||
|
};
|
||||||
|
const whiteKing = findPiece(startingFacts, 'king', 4);
|
||||||
|
const whiteQueen = findPiece(startingFacts, 'queen', 3);
|
||||||
|
expect(whiteKing).not.toBeNull();
|
||||||
|
expect(whiteQueen).not.toBeNull();
|
||||||
|
|
||||||
|
// 3. White sends game.action: transfer royalty king→queen. Server
|
||||||
|
// accepts and broadcasts a fresh game.state to both sockets.
|
||||||
|
const whiteResult = await wsSendAction(pageA, roomA.code, roomA.token, {
|
||||||
|
kind: 'transfer-royalty',
|
||||||
|
fromPieceId: whiteKing!,
|
||||||
|
toPieceId: whiteQueen!,
|
||||||
|
});
|
||||||
|
expect(whiteResult.kind).toBe('state');
|
||||||
|
if (whiteResult.kind === 'state') {
|
||||||
|
// Turn has flipped to black post-action (performAction consumes
|
||||||
|
// the turn just like applyMove).
|
||||||
|
expect(whiteResult.turn).toBe('black');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Black tries the SAME action. The server rejects with
|
||||||
|
// ILLEGAL_ACTION because white's transfer already consumed the
|
||||||
|
// per-color slot in the preset's state (second transfer by same
|
||||||
|
// color is REJECTED; using white's ids from black's socket also
|
||||||
|
// can't work because black isn't that color). Either way the
|
||||||
|
// wire surface must be ILLEGAL_ACTION — never a crash or silent
|
||||||
|
// accept.
|
||||||
|
const blackResult = await wsSendAction(pageB, roomA.code, roomB.token, {
|
||||||
|
kind: 'transfer-royalty',
|
||||||
|
fromPieceId: whiteKing!,
|
||||||
|
toPieceId: whiteQueen!,
|
||||||
|
});
|
||||||
|
expect(blackResult.kind).toBe('error');
|
||||||
|
if (blackResult.kind === 'error') {
|
||||||
|
// Could be ILLEGAL_ACTION (INVALID_TARGET — can't transfer from
|
||||||
|
// opponent's king) OR NOT_YOUR_TURN if ordering of the two socket
|
||||||
|
// opens lands with the turn check first. Either is acceptable
|
||||||
|
// F4b behaviour — both codes indicate "server refused" rather
|
||||||
|
// than "silently accepted".
|
||||||
|
expect(['ILLEGAL_ACTION', 'NOT_YOUR_TURN']).toContain(blackResult.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctxA.close();
|
||||||
|
await ctxB.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F4b game.action: malformed payload rejected pre-dispatch', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
// Schema-level rejection: unknown action kind never reaches the
|
||||||
|
// engine. Confirms the wire schema fails fast with INVALID_MESSAGE
|
||||||
|
// (fatal) before the token check runs.
|
||||||
|
const ctxA = await browser.newContext();
|
||||||
|
const pageA = await ctxA.newPage();
|
||||||
|
await pageA.goto('http://localhost:5173/');
|
||||||
|
const roomA = await wsCreateRoomWithPresets(pageA, ['transferable-royalty']);
|
||||||
|
|
||||||
|
const result = await pageA.evaluate(
|
||||||
|
async (args: { code: string; token: string }) => {
|
||||||
|
return new Promise<{ code: string; fatal: boolean }>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||||
|
const t = setTimeout(() => reject(new Error('timeout')), 5000);
|
||||||
|
let joined = false;
|
||||||
|
ws.onopen = () =>
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
seq: 1,
|
||||||
|
ts: Date.now(),
|
||||||
|
token: args.token,
|
||||||
|
type: 'room.join',
|
||||||
|
payload: { code: args.code },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ws.onmessage = (e: MessageEvent) => {
|
||||||
|
const msg = JSON.parse(e.data as string) as {
|
||||||
|
type: string;
|
||||||
|
payload: { code?: string; fatal?: boolean };
|
||||||
|
};
|
||||||
|
if (msg.type === 'room.joined' && !joined) {
|
||||||
|
joined = true;
|
||||||
|
// Malformed action kind — schema union should reject.
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
seq: 2,
|
||||||
|
ts: Date.now(),
|
||||||
|
token: args.token,
|
||||||
|
type: 'game.action',
|
||||||
|
payload: {
|
||||||
|
action: {
|
||||||
|
kind: 'bogus-kind',
|
||||||
|
fromPieceId: 1,
|
||||||
|
toPieceId: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
clearTimeout(t);
|
||||||
|
ws.close();
|
||||||
|
resolve({
|
||||||
|
code: msg.payload.code ?? '',
|
||||||
|
fatal: msg.payload.fatal ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
reject(new Error('ws error'));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ code: roomA.code, token: roomA.token },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.code).toBe('INVALID_MESSAGE');
|
||||||
|
expect(result.fatal).toBe(true);
|
||||||
|
await ctxA.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('F1 color preference: UI buttons render in Lobby and are toggleable', async ({
|
test('F1 color preference: UI buttons render in Lobby and are toggleable', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import type { PieceType } from '../schema';
|
||||||
import type { GameResult } from '../engine';
|
import type { GameResult } from '../engine';
|
||||||
import { GameClient } from '../net/client';
|
import { GameClient } from '../net/client';
|
||||||
import { PredictionManager } from '../net/prediction';
|
import { PredictionManager } from '../net/prediction';
|
||||||
import type { Color, PresetActivation, PromotionPiece, ModifierProfileWire, ModifierProfileProposalPendingPayload } from '../net/types';
|
import type { Color, PlayerActionWire, PresetActivation, PromotionPiece, ModifierProfileWire, ModifierProfileProposalPendingPayload } from '../net/types';
|
||||||
import * as audio from '../audio';
|
import * as audio from '../audio';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
|
@ -284,6 +284,19 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
clientRef.current?.sendSetPresets(activations);
|
clientRef.current?.sendSetPresets(activations);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F4b: dispatch a `PlayerAction` over the wire. The post-action
|
||||||
|
* state is reconciled via the existing `game.state` snapshot
|
||||||
|
* handler on PredictionManager — no explicit prediction path is
|
||||||
|
* wired here because actions in v1 are rare (once per game) and
|
||||||
|
* the UI surface (F4c) will live with a fresh-snapshot round-trip.
|
||||||
|
* Return void; callers observe the result by watching the engine
|
||||||
|
* via the tick-driven re-render.
|
||||||
|
*/
|
||||||
|
const sendAction = useCallback((action: PlayerActionWire) => {
|
||||||
|
clientRef.current?.sendAction(action);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadEngine = useCallback(() => {
|
const loadEngine = useCallback(() => {
|
||||||
// Also a no-op: authoritative state is server-driven. The UI should
|
// Also a no-op: authoritative state is server-driven. The UI should
|
||||||
// not have any need to swap the engine under multiplayer — all state
|
// not have any need to swap the engine under multiplayer — all state
|
||||||
|
|
@ -323,6 +336,7 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
lastMove,
|
lastMove,
|
||||||
activations,
|
activations,
|
||||||
setPresets,
|
setPresets,
|
||||||
|
sendAction,
|
||||||
// Multiplayer-only metadata. GameView uses these to gate drag and
|
// Multiplayer-only metadata. GameView uses these to gate drag and
|
||||||
// render a "waiting for opponent" / error overlay.
|
// render a "waiting for opponent" / error overlay.
|
||||||
myColor: meta.myColor,
|
myColor: meta.myColor,
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,14 @@ export {
|
||||||
safeParseCustomModifierDescriptor,
|
safeParseCustomModifierDescriptor,
|
||||||
serializeCustomModifierDescriptor,
|
serializeCustomModifierDescriptor,
|
||||||
} from "./modifiers/custom/schema.js";
|
} from "./modifiers/custom/schema.js";
|
||||||
|
|
||||||
|
// Player actions — non-move, turn-consuming engine operations. Shipped
|
||||||
|
// solo in F4a; F4b (`game.action` WS message) and F4c (UI) are separate
|
||||||
|
// deliverables. Exported at the barrel so the server package can type
|
||||||
|
// its wire schemas against the authoritative action union.
|
||||||
|
export type {
|
||||||
|
PlayerAction,
|
||||||
|
PlayerActionKind,
|
||||||
|
ActionResult,
|
||||||
|
TransferRoyaltyAction,
|
||||||
|
} from "./actions.js";
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
ClientMessage,
|
ClientMessage,
|
||||||
CustomModifierRegisteredPayload,
|
CustomModifierRegisteredPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
|
GameActionPayload,
|
||||||
GameDeltaPayload,
|
GameDeltaPayload,
|
||||||
GameEndPayload,
|
GameEndPayload,
|
||||||
GameMovePayload,
|
GameMovePayload,
|
||||||
|
|
@ -24,6 +25,7 @@ import type {
|
||||||
ModifierProfileConsentReceivedPayload,
|
ModifierProfileConsentReceivedPayload,
|
||||||
ModifierProfileQueuedPayload,
|
ModifierProfileQueuedPayload,
|
||||||
ModifierProfileUpdatedPayload,
|
ModifierProfileUpdatedPayload,
|
||||||
|
PlayerActionWire,
|
||||||
PresetActivation,
|
PresetActivation,
|
||||||
PromotionPiece,
|
PromotionPiece,
|
||||||
RoomCreatedPayload,
|
RoomCreatedPayload,
|
||||||
|
|
@ -271,6 +273,21 @@ export class GameClient {
|
||||||
this.send({ type: "game.move", payload });
|
this.send({ type: "game.move", payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F4b: convenience — dispatch a `PlayerAction` to the server.
|
||||||
|
*
|
||||||
|
* The authoritative post-action state arrives as a `game.state`
|
||||||
|
* broadcast (not a `game.delta` — see handler docs in
|
||||||
|
* server/broadcast.ts) which PredictionManager replaces the base
|
||||||
|
* state from. On error the server sends an `error` event
|
||||||
|
* (ILLEGAL_ACTION / NOT_YOUR_TURN / GAME_OVER); this method
|
||||||
|
* returns void since the caller reacts to events, not a promise.
|
||||||
|
*/
|
||||||
|
sendAction(action: PlayerActionWire): void {
|
||||||
|
const payload: GameActionPayload = { action };
|
||||||
|
this.send({ type: "game.action", payload });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience: send `room.setPresets` to replace the room's entire
|
* Convenience: send `room.setPresets` to replace the room's entire
|
||||||
* active preset set. The server validates and — on success —
|
* active preset set. The server validates and — on success —
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ export type GameEndReason =
|
||||||
|
|
||||||
export type ErrorCode =
|
export type ErrorCode =
|
||||||
| "ILLEGAL_MOVE"
|
| "ILLEGAL_MOVE"
|
||||||
|
// F4b: PlayerAction rejected by the engine. Mirrors the server's
|
||||||
|
// error code family; collapses NO_HANDLER / REJECTED /
|
||||||
|
// INVALID_TARGET onto a single wire code (see server/protocol.ts).
|
||||||
|
| "ILLEGAL_ACTION"
|
||||||
| "NOT_YOUR_TURN"
|
| "NOT_YOUR_TURN"
|
||||||
| "GAME_OVER"
|
| "GAME_OVER"
|
||||||
| "ROOM_NOT_FOUND"
|
| "ROOM_NOT_FOUND"
|
||||||
|
|
@ -374,6 +378,28 @@ export interface GameMovePayload {
|
||||||
promoteTo?: PromotionPiece;
|
promoteTo?: PromotionPiece;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// F4b: PlayerAction wire types. Mirrors the chess-package
|
||||||
|
// `PlayerAction` discriminated union structurally. Declared
|
||||||
|
// independently here so the net-types layer doesn't reach into
|
||||||
|
// `../actions.js` (which itself imports from @paratype/rete) — keeping
|
||||||
|
// the dependency direction one-way: consumers that need the branded
|
||||||
|
// engine type import from the barrel; consumers that only need the
|
||||||
|
// wire shape stay within net/.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TransferRoyaltyActionWire {
|
||||||
|
kind: "transfer-royalty";
|
||||||
|
fromPieceId: number;
|
||||||
|
toPieceId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayerActionWire = TransferRoyaltyActionWire;
|
||||||
|
|
||||||
|
export interface GameActionPayload {
|
||||||
|
action: PlayerActionWire;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Envelope types (for tests and serialization)
|
// Envelope types (for tests and serialization)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -413,6 +439,7 @@ export type ClientMessage =
|
||||||
| MessageEnvelope<"room.join", RoomJoinPayload>
|
| MessageEnvelope<"room.join", RoomJoinPayload>
|
||||||
| MessageEnvelope<"room.leave", Record<string, never>>
|
| MessageEnvelope<"room.leave", Record<string, never>>
|
||||||
| MessageEnvelope<"game.move", GameMovePayload>
|
| MessageEnvelope<"game.move", GameMovePayload>
|
||||||
|
| MessageEnvelope<"game.action", GameActionPayload>
|
||||||
| MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>
|
| MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>
|
||||||
| MessageEnvelope<"modifier-profile.update", ModifierProfileUpdatePayload>
|
| MessageEnvelope<"modifier-profile.update", ModifierProfileUpdatePayload>
|
||||||
| MessageEnvelope<"modifier-profile.propose", ModifierProfileProposePayload>
|
| MessageEnvelope<"modifier-profile.propose", ModifierProfileProposePayload>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
type CustomModifierRegisterPayload,
|
type CustomModifierRegisterPayload,
|
||||||
type ErrorCode,
|
type ErrorCode,
|
||||||
type Fact as WireFact,
|
type Fact as WireFact,
|
||||||
|
type GameActionPayload,
|
||||||
type GameMovePayload,
|
type GameMovePayload,
|
||||||
type ModifierProfileConsentPayload,
|
type ModifierProfileConsentPayload,
|
||||||
type ModifierProfileProposePayload,
|
type ModifierProfileProposePayload,
|
||||||
|
|
@ -48,8 +49,10 @@ import { RoomRegistry } from "./rooms.js";
|
||||||
import { resolveLayoutRequest, toResolvedLayout } from "./layouts.js";
|
import { resolveLayoutRequest, toResolvedLayout } from "./layouts.js";
|
||||||
import {
|
import {
|
||||||
validateProfile,
|
validateProfile,
|
||||||
|
type ActionResult,
|
||||||
type ModifierProfile,
|
type ModifierProfile,
|
||||||
type ModifierValidationErrorCode,
|
type ModifierValidationErrorCode,
|
||||||
|
type PlayerAction,
|
||||||
} from "@paratype/chess";
|
} from "@paratype/chess";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -262,6 +265,9 @@ export function handleMessage(
|
||||||
case "game.move":
|
case "game.move":
|
||||||
handleGameMove(ws, msg.payload);
|
handleGameMove(ws, msg.payload);
|
||||||
break;
|
break;
|
||||||
|
case "game.action":
|
||||||
|
handleGameAction(ws, msg.payload);
|
||||||
|
break;
|
||||||
case "room.setPresets":
|
case "room.setPresets":
|
||||||
handleSetPresets(ws, msg.payload);
|
handleSetPresets(ws, msg.payload);
|
||||||
break;
|
break;
|
||||||
|
|
@ -783,6 +789,191 @@ function handleGameMove(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an `ActionResult.error` code (chess-package) onto the wire's
|
||||||
|
* `ErrorCode` family. F4 introduces one new wire code
|
||||||
|
* (`ILLEGAL_ACTION`) that absorbs the three engine rejection
|
||||||
|
* reasons that don't already have a dedicated wire analogue
|
||||||
|
* (NO_HANDLER / REJECTED / INVALID_TARGET). `NOT_YOUR_TURN` and
|
||||||
|
* `GAME_OVER` pass through unchanged so clients reuse existing
|
||||||
|
* toasts. Unknown / unset codes default to `ILLEGAL_ACTION` so a
|
||||||
|
* future engine error that hasn't been carved out on the wire
|
||||||
|
* degrades predictably.
|
||||||
|
*/
|
||||||
|
function mapActionErrorCode(code: ActionResult["error"]): ErrorCode {
|
||||||
|
switch (code) {
|
||||||
|
case "NOT_YOUR_TURN":
|
||||||
|
return "NOT_YOUR_TURN";
|
||||||
|
case "GAME_OVER":
|
||||||
|
return "GAME_OVER";
|
||||||
|
case "NO_HANDLER":
|
||||||
|
case "REJECTED":
|
||||||
|
case "INVALID_TARGET":
|
||||||
|
case undefined:
|
||||||
|
return "ILLEGAL_ACTION";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a fresh `game.state` snapshot to every connected socket
|
||||||
|
* in the room. Used as the post-success reconciliation signal for
|
||||||
|
* `game.action` (per F4b design decision: reuse the existing state-
|
||||||
|
* snapshot broadcast rather than adding a new server→client message
|
||||||
|
* type). The snapshot carries the authoritative turn, facts, profile,
|
||||||
|
* and preset activations — i.e. everything a client needs to mirror
|
||||||
|
* the post-action session.
|
||||||
|
*
|
||||||
|
* `lastSeq=0` because a successful action is not tracked via
|
||||||
|
* per-token delta seqs the way moves are; the snapshot is authoritative
|
||||||
|
* and supersedes any pending deltas a client may have buffered.
|
||||||
|
*/
|
||||||
|
function broadcastGameStateSnapshot(
|
||||||
|
roomCode: string,
|
||||||
|
session: GameSession,
|
||||||
|
): void {
|
||||||
|
const room = roomRegistry.getRoom(roomCode);
|
||||||
|
if (!room) return;
|
||||||
|
const activeProfile = session.getProfile();
|
||||||
|
const customModifiers = [...(room.customModifiers?.values() ?? [])];
|
||||||
|
broadcastToRoom(
|
||||||
|
roomCode,
|
||||||
|
envelope("game.state", {
|
||||||
|
facts: session.getAllFacts(),
|
||||||
|
turn: session.getTurn(),
|
||||||
|
lastSeq: 0,
|
||||||
|
moveHistory: [],
|
||||||
|
activeRules: [...room.rulesetIds],
|
||||||
|
activations: session.getPresetActivations(),
|
||||||
|
...(activeProfile !== undefined ? { profile: activeProfile } : {}),
|
||||||
|
...(customModifiers.length > 0 ? { customModifiers } : {}),
|
||||||
|
fen: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F4b: handle a client-submitted `game.action` frame.
|
||||||
|
*
|
||||||
|
* Mirrors `handleGameMove` structurally — authenticate, gate on
|
||||||
|
* turn, delegate to the session, broadcast post-success state —
|
||||||
|
* with three deliberate differences:
|
||||||
|
*
|
||||||
|
* 1. No `game.delta` is emitted. Actions don't have a natural
|
||||||
|
* "moveNotation" + inserted/retracted shape (they mutate facts
|
||||||
|
* via preset hooks, not a single piece position change), so
|
||||||
|
* we broadcast a fresh `game.state` snapshot instead. See the
|
||||||
|
* F4b design note in the plan file — this saves wire surface
|
||||||
|
* at the cost of slightly more traffic (snapshots > deltas),
|
||||||
|
* which is acceptable given actions fire once per game in v1.
|
||||||
|
*
|
||||||
|
* 2. Error codes map through `mapActionErrorCode` — the engine's
|
||||||
|
* `ActionResult.error` taxonomy collapses onto the single
|
||||||
|
* `ILLEGAL_ACTION` wire code for its three rejection reasons
|
||||||
|
* (NO_HANDLER / REJECTED / INVALID_TARGET) while passing
|
||||||
|
* `NOT_YOUR_TURN` / `GAME_OVER` through verbatim.
|
||||||
|
*
|
||||||
|
* 3. No preset duration tick or pending-profile drain here. An
|
||||||
|
* action that succeeds goes through `performAction` which
|
||||||
|
* calls `advanceTurnAfterMutation` internally (same pipeline
|
||||||
|
* as applyMove), so preset tick + onTurnStart fire on the
|
||||||
|
* engine side. The turn-boundary pending-profile queue
|
||||||
|
* (T2-ADR-1) intentionally does NOT drain on an action —
|
||||||
|
* per ADR-1 the profile queue is "after the next successful
|
||||||
|
* move", not any turn-consuming event. Future work: extend
|
||||||
|
* to actions once F4 hits general use.
|
||||||
|
*/
|
||||||
|
function handleGameAction(
|
||||||
|
ws: ServerWebSocket<ClientData>,
|
||||||
|
payload: GameActionPayload,
|
||||||
|
): void {
|
||||||
|
const { roomCode, token } = ws.data;
|
||||||
|
if (roomCode === undefined || token === undefined) {
|
||||||
|
sendTo(
|
||||||
|
ws,
|
||||||
|
errorMessage("BAD_TOKEN", "not authenticated into a room", false),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const player = roomRegistry.getPlayerByToken(roomCode, token);
|
||||||
|
if (!player) {
|
||||||
|
sendTo(ws, errorMessage("BAD_TOKEN", "unknown token for room", false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = sessionRegistry.get(roomCode);
|
||||||
|
if (!session) {
|
||||||
|
sendTo(
|
||||||
|
ws,
|
||||||
|
errorMessage(
|
||||||
|
"INVALID_MESSAGE",
|
||||||
|
"internal error: missing game session",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn gate — same rationale as handleGameMove. We surface
|
||||||
|
// NOT_YOUR_TURN before consulting the engine so clients can
|
||||||
|
// distinguish off-turn from "action legal but rejected".
|
||||||
|
if (player.color !== session.getTurn()) {
|
||||||
|
sendTo(
|
||||||
|
ws,
|
||||||
|
errorMessage("NOT_YOUR_TURN", "it is not your turn", false),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire shape → engine action. The Zod union structurally matches
|
||||||
|
// the chess-package `PlayerAction` union (identical discriminant +
|
||||||
|
// payload keys); the typed cast is the boundary between the
|
||||||
|
// server's zod-v3 inferred shape and the engine's readonly
|
||||||
|
// `PlayerAction` interface. `value: unknown` style variance
|
||||||
|
// isn't a concern here (actions are plain id/discriminant
|
||||||
|
// records), but the shape check is still preserved by the
|
||||||
|
// discriminated union: a new engine action kind that forgets to
|
||||||
|
// update the wire schema fails to parse in `validateMessage`
|
||||||
|
// before reaching this handler.
|
||||||
|
const action = payload.action as PlayerAction;
|
||||||
|
const result = session.performAction(action);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
sendTo(
|
||||||
|
ws,
|
||||||
|
errorMessage(
|
||||||
|
mapActionErrorCode(result.error),
|
||||||
|
result.reason ?? `action rejected (${result.error ?? "unknown"})`,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — mirror the move path by broadcasting authoritative
|
||||||
|
// state to every peer. We reuse `game.state` rather than minting
|
||||||
|
// a new server→client message type (per F4b design decision):
|
||||||
|
// client-side PredictionManager already has a handler that
|
||||||
|
// replaces base state entirely on receipt, so actions plug into
|
||||||
|
// the existing reconciliation path for free.
|
||||||
|
broadcastGameStateSnapshot(roomCode, session);
|
||||||
|
|
||||||
|
// Terminal-state guard — mirrors handleGameMove. If the action
|
||||||
|
// happened to end the game (rare in v1 but possible once future
|
||||||
|
// presets ship), emit game.end so the UI can transition out of
|
||||||
|
// the interactive state.
|
||||||
|
const gameOver = session.getGameOver();
|
||||||
|
if (gameOver !== null) {
|
||||||
|
broadcastToRoom(
|
||||||
|
roomCode,
|
||||||
|
envelope("game.end", {
|
||||||
|
winner: gameOver.winner,
|
||||||
|
reason: gameOver.reason,
|
||||||
|
finalFen: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a client request to hot-swap the room's modifier profile.
|
* Handle a client request to hot-swap the room's modifier profile.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { PlayerAction } from "@paratype/chess";
|
||||||
import {
|
import {
|
||||||
GameSession,
|
GameSession,
|
||||||
GameSessionRegistry,
|
GameSessionRegistry,
|
||||||
|
|
@ -6,6 +7,21 @@ import {
|
||||||
type Fact,
|
type Fact,
|
||||||
} from "./game-session.js";
|
} from "./game-session.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chess-package `PlayerAction` carries branded `EntityId`s for
|
||||||
|
* piece refs. Tests here construct actions from raw fact-id numbers,
|
||||||
|
* so we cast through the wire-agnostic shape. Same rationale as the
|
||||||
|
* server's `handleGameAction` handler in `broadcast.ts` — the wire
|
||||||
|
* erases the brand and the engine accepts either at runtime.
|
||||||
|
*/
|
||||||
|
function asAction(a: {
|
||||||
|
kind: "transfer-royalty";
|
||||||
|
fromPieceId: number;
|
||||||
|
toPieceId: number;
|
||||||
|
}): PlayerAction {
|
||||||
|
return a as unknown as PlayerAction;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per-session isolation & ID authority
|
// Per-session isolation & ID authority
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -246,3 +262,97 @@ describe("GameSessionRegistry", () => {
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// F4b — performAction delegation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("GameSession.performAction", () => {
|
||||||
|
it("NO_HANDLER when no action-owning preset is active", () => {
|
||||||
|
// Vanilla session has no presets → no performAction hook claims
|
||||||
|
// any kind → engine returns NO_HANDLER. The session forwards it
|
||||||
|
// verbatim without translating the error code.
|
||||||
|
const s = new GameSession();
|
||||||
|
const result = s.performAction(
|
||||||
|
asAction({
|
||||||
|
kind: "transfer-royalty",
|
||||||
|
fromPieceId: 1,
|
||||||
|
toPieceId: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("NO_HANDLER");
|
||||||
|
// No turn consumed, no state dirtied.
|
||||||
|
expect(s.getTurn()).toBe("white");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("successful action consumes the turn and ticks current color", () => {
|
||||||
|
// transferable-royalty is active → engine finds a handler. The
|
||||||
|
// default FIDE layout has the white king on e1 (entity 4) and the
|
||||||
|
// white queen on d1 (entity 3) — see ChessEngine init order. A
|
||||||
|
// king→queen transfer is legal under the preset's contract.
|
||||||
|
const s = new GameSession(["transferable-royalty"]);
|
||||||
|
// Lookup the actual entity ids via fact scan rather than hard-
|
||||||
|
// coding — the engine's entity-minting order is implementation
|
||||||
|
// detail and could change.
|
||||||
|
const facts = s.getAllFacts();
|
||||||
|
const findPiece = (type: string, square: number): number | null => {
|
||||||
|
const typeMatches = new Set(
|
||||||
|
facts
|
||||||
|
.filter((f) => f.attr === "PieceType" && f.value === type)
|
||||||
|
.map((f) => f.id),
|
||||||
|
);
|
||||||
|
const found = facts.find(
|
||||||
|
(f) =>
|
||||||
|
f.attr === "Position" && f.value === square && typeMatches.has(f.id),
|
||||||
|
);
|
||||||
|
return found ? found.id : null;
|
||||||
|
};
|
||||||
|
const king = findPiece("king", 4); // e1
|
||||||
|
const queen = findPiece("queen", 3); // d1
|
||||||
|
expect(king).not.toBeNull();
|
||||||
|
expect(queen).not.toBeNull();
|
||||||
|
|
||||||
|
expect(s.getTurn()).toBe("white");
|
||||||
|
const result = s.performAction(
|
||||||
|
asAction({
|
||||||
|
kind: "transfer-royalty",
|
||||||
|
fromPieceId: king!,
|
||||||
|
toPieceId: queen!,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
// Action pipeline runs through advanceTurnAfterMutation, so the
|
||||||
|
// turn flips just like a successful move.
|
||||||
|
expect(s.getTurn()).toBe("black");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("failed action does NOT consume the turn or dirty subsequent deltas", () => {
|
||||||
|
// INVALID_TARGET returns from the preset's validator path.
|
||||||
|
// Mover stays the same; the engine is unchanged and a following
|
||||||
|
// applyMove produces a clean delta keyed to the pre-action state.
|
||||||
|
const s = new GameSession(["transferable-royalty"]);
|
||||||
|
// Pass obviously-bogus piece ids so the preset rejects the action
|
||||||
|
// without needing a live board setup. EntityId 9999 was never
|
||||||
|
// minted → dead target → INVALID_TARGET.
|
||||||
|
const result = s.performAction(
|
||||||
|
asAction({
|
||||||
|
kind: "transfer-royalty",
|
||||||
|
fromPieceId: 9999,
|
||||||
|
toPieceId: 9998,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("INVALID_TARGET");
|
||||||
|
// Turn unchanged.
|
||||||
|
expect(s.getTurn()).toBe("white");
|
||||||
|
|
||||||
|
// The follow-up move should still succeed and produce a normal
|
||||||
|
// delta — the failed action left no state to drag into the diff.
|
||||||
|
const move = s.applyMove("e2", "e4");
|
||||||
|
expect(move.ok).toBe(true);
|
||||||
|
if (!move.ok) return;
|
||||||
|
expect(move.inserted.length).toBeGreaterThan(0);
|
||||||
|
expect(move.retracted.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,11 @@ import {
|
||||||
PresetActivationError,
|
PresetActivationError,
|
||||||
reconcileProfileSwap,
|
reconcileProfileSwap,
|
||||||
validateProfile,
|
validateProfile,
|
||||||
|
type ActionResult,
|
||||||
type ActivationRequest,
|
type ActivationRequest,
|
||||||
type ModifierProfile,
|
type ModifierProfile,
|
||||||
type ModifierValidationErrorCode,
|
type ModifierValidationErrorCode,
|
||||||
|
type PlayerAction,
|
||||||
type PresetActivation,
|
type PresetActivation,
|
||||||
type GameResult,
|
type GameResult,
|
||||||
type PieceColor,
|
type PieceColor,
|
||||||
|
|
@ -359,6 +361,50 @@ export class GameSession {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a `PlayerAction` — the non-move counterpart to
|
||||||
|
* `applyMove`. Delegates verbatim to `engine.performAction` and
|
||||||
|
* refreshes the internal fact snapshot on success so the next
|
||||||
|
* `applyMove` computes its delta against post-action state.
|
||||||
|
*
|
||||||
|
* Engine semantics:
|
||||||
|
* - Success (`{ ok: true }`): consumes the turn via the same
|
||||||
|
* pipeline as `applyMove` (shouldAdvanceTurn poll, onTurnStart
|
||||||
|
* fire, preset duration tick).
|
||||||
|
* - Failure: no turn consumed; the engine is unchanged so
|
||||||
|
* `prevFacts` stays valid without refresh.
|
||||||
|
*
|
||||||
|
* Sticky GAME_OVER: after a successful action that leaves the game
|
||||||
|
* in a terminal state the engine's own `checkGameResult` guard
|
||||||
|
* flips subsequent calls to `{ ok: false, error: "GAME_OVER" }`.
|
||||||
|
* We mirror that into `finalGameOver` on transition so the server's
|
||||||
|
* broadcast path can emit `game.end` without re-checking engine
|
||||||
|
* state.
|
||||||
|
*/
|
||||||
|
performAction(action: PlayerAction): ActionResult {
|
||||||
|
const result = this.engine.performAction(action);
|
||||||
|
if (result.ok) {
|
||||||
|
// Re-seat the fact snapshot so the NEXT applyMove's delta
|
||||||
|
// starts from post-action state — not the pre-action set.
|
||||||
|
this.prevFacts = this.snapshotFacts();
|
||||||
|
// Lift any terminal result the action triggered into the
|
||||||
|
// sticky flag so applyMove short-circuits identically whether
|
||||||
|
// the prior turn was a move or an action.
|
||||||
|
const terminal = this.engine.checkGameResult();
|
||||||
|
if (terminal !== "ongoing" && this.finalGameOver === null) {
|
||||||
|
// Map the engine's GameResult onto the sticky shape. The
|
||||||
|
// action that just succeeded was made by the side whose
|
||||||
|
// turn it WAS — we recover that by asking the engine for
|
||||||
|
// the turn AFTER the action (which is the opponent) and
|
||||||
|
// inverting.
|
||||||
|
const moverColor: PieceColor =
|
||||||
|
this.engine.getCurrentTurn() === "white" ? "black" : "white";
|
||||||
|
this.finalGameOver = mapGameResult(terminal, moverColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** Snapshot helper — normalises Session fact records to wire shape. */
|
/** Snapshot helper — normalises Session fact records to wire shape. */
|
||||||
private snapshotFacts(): Fact[] {
|
private snapshotFacts(): Fact[] {
|
||||||
return this.engine.session.allFacts().map((f) => ({
|
return this.engine.session.allFacts().map((f) => ({
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,14 @@ const fixtures: Record<string, AnyMessage> = {
|
||||||
token: UUID,
|
token: UUID,
|
||||||
payload: { from: "a7", to: "a8", promoteTo: "queen" },
|
payload: { from: "a7", to: "a8", promoteTo: "queen" },
|
||||||
},
|
},
|
||||||
|
"game.action (transfer-royalty)": {
|
||||||
|
...envelope,
|
||||||
|
type: "game.action",
|
||||||
|
token: UUID,
|
||||||
|
payload: {
|
||||||
|
action: { kind: "transfer-royalty", fromPieceId: 4, toPieceId: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
"room.created": {
|
"room.created": {
|
||||||
...envelope,
|
...envelope,
|
||||||
seq: 1,
|
seq: 1,
|
||||||
|
|
@ -921,3 +929,101 @@ describe("Modifier profile error codes", () => {
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// F4b — game.action schema validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("game.action schema validation", () => {
|
||||||
|
it("accepts a transfer-royalty action with non-negative piece ids", () => {
|
||||||
|
const r = validateMessage({
|
||||||
|
...envelope,
|
||||||
|
type: "game.action",
|
||||||
|
token: UUID,
|
||||||
|
payload: {
|
||||||
|
action: {
|
||||||
|
kind: "transfer-royalty",
|
||||||
|
fromPieceId: 12,
|
||||||
|
toPieceId: 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.data.type).toBe("game.action");
|
||||||
|
// The narrowed payload still carries the full action shape —
|
||||||
|
// no data loss through the discriminated union.
|
||||||
|
if (r.data.type === "game.action") {
|
||||||
|
expect(r.data.payload.action.kind).toBe("transfer-royalty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown action kinds (discriminated union gate)", () => {
|
||||||
|
// The engine would fail this with NO_HANDLER at dispatch time,
|
||||||
|
// but the wire schema SHOULD also reject it structurally so we
|
||||||
|
// never get that far — a typo client-side surfaces as
|
||||||
|
// INVALID_MESSAGE, not a post-auth error.
|
||||||
|
const r = validateMessage({
|
||||||
|
...envelope,
|
||||||
|
type: "game.action",
|
||||||
|
token: UUID,
|
||||||
|
payload: {
|
||||||
|
action: {
|
||||||
|
kind: "transfer-crown",
|
||||||
|
fromPieceId: 1,
|
||||||
|
toPieceId: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative piece ids (non-negative integer gate)", () => {
|
||||||
|
const r = validateMessage({
|
||||||
|
...envelope,
|
||||||
|
type: "game.action",
|
||||||
|
token: UUID,
|
||||||
|
payload: {
|
||||||
|
action: {
|
||||||
|
kind: "transfer-royalty",
|
||||||
|
fromPieceId: -1,
|
||||||
|
toPieceId: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing action payload field", () => {
|
||||||
|
const r = validateMessage({
|
||||||
|
...envelope,
|
||||||
|
type: "game.action",
|
||||||
|
token: UUID,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts ILLEGAL_ACTION as a valid error code", () => {
|
||||||
|
// Mirrors how the server reports engine rejection reasons.
|
||||||
|
const r = validateMessage({
|
||||||
|
...envelope,
|
||||||
|
type: "error",
|
||||||
|
payload: {
|
||||||
|
code: "ILLEGAL_ACTION",
|
||||||
|
message: "action rejected (INVALID_TARGET)",
|
||||||
|
fatal: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces game.action in the KNOWN_MESSAGE_TYPES list", async () => {
|
||||||
|
const mod = await import("./protocol.js");
|
||||||
|
expect(mod.KNOWN_MESSAGE_TYPES).toContain("game.action");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,12 @@ export type Fact = z.infer<typeof FactSchema>;
|
||||||
|
|
||||||
export const ErrorCodeSchema = z.enum([
|
export const ErrorCodeSchema = z.enum([
|
||||||
"ILLEGAL_MOVE",
|
"ILLEGAL_MOVE",
|
||||||
|
// F4b: PlayerAction rejected by the engine (NO_HANDLER / REJECTED
|
||||||
|
// / INVALID_TARGET all map here — callers read the human-readable
|
||||||
|
// `message` for specifics). Mirrors the `ILLEGAL_MOVE` philosophy:
|
||||||
|
// one coarse code keeps the UI's switch statement small and avoids
|
||||||
|
// leaking preset-specific internals to the wire.
|
||||||
|
"ILLEGAL_ACTION",
|
||||||
"NOT_YOUR_TURN",
|
"NOT_YOUR_TURN",
|
||||||
"GAME_OVER",
|
"GAME_OVER",
|
||||||
"ROOM_NOT_FOUND",
|
"ROOM_NOT_FOUND",
|
||||||
|
|
@ -594,6 +600,48 @@ export const GameMovePayloadSchema = z.object({
|
||||||
});
|
});
|
||||||
export type GameMovePayload = z.infer<typeof GameMovePayloadSchema>;
|
export type GameMovePayload = z.infer<typeof GameMovePayloadSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Player actions — post-epic-deferrals F4b.
|
||||||
|
//
|
||||||
|
// Mirrors the chess-package `PlayerAction` discriminated union. Each
|
||||||
|
// member is one "action kind"; the union grows additively as new
|
||||||
|
// presets ship. Server does structural validation only — the engine
|
||||||
|
// dispatches on `kind` and the active preset decides whether the
|
||||||
|
// action is legal.
|
||||||
|
//
|
||||||
|
// Piece ids are non-negative integers (EntityId is numeric in the
|
||||||
|
// Rete session). We keep them as plain numbers on the wire rather
|
||||||
|
// than branding so the schema stays portable across the zod major
|
||||||
|
// boundary between server and client packages.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const TransferRoyaltyActionPayloadSchema = z.object({
|
||||||
|
kind: z.literal("transfer-royalty"),
|
||||||
|
fromPieceId: z.number().int().nonnegative(),
|
||||||
|
toPieceId: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
export type TransferRoyaltyActionWire = z.infer<
|
||||||
|
typeof TransferRoyaltyActionPayloadSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union of every wire-accepted player action. Mirrors
|
||||||
|
* the chess-side `PlayerAction` union one-to-one — add new members
|
||||||
|
* in lockstep with the engine's extension. The server trusts the
|
||||||
|
* engine to reject unknown kinds dynamically via NO_HANDLER, so
|
||||||
|
* widening this schema is the only place "I shipped a new action
|
||||||
|
* kind" needs to land on the wire.
|
||||||
|
*/
|
||||||
|
export const PlayerActionSchema = z.discriminatedUnion("kind", [
|
||||||
|
TransferRoyaltyActionPayloadSchema,
|
||||||
|
]);
|
||||||
|
export type PlayerActionWire = z.infer<typeof PlayerActionSchema>;
|
||||||
|
|
||||||
|
export const GameActionPayloadSchema = z.object({
|
||||||
|
action: PlayerActionSchema,
|
||||||
|
});
|
||||||
|
export type GameActionPayload = z.infer<typeof GameActionPayloadSchema>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Preset scope + activation — used by both directions of the preset sync.
|
// Preset scope + activation — used by both directions of the preset sync.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -749,6 +797,10 @@ export const RoomCreateMessageSchema = msg(
|
||||||
export const RoomJoinMessageSchema = msg("room.join", RoomJoinPayloadSchema);
|
export const RoomJoinMessageSchema = msg("room.join", RoomJoinPayloadSchema);
|
||||||
export const RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema);
|
export const RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema);
|
||||||
export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema);
|
export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema);
|
||||||
|
export const GameActionMessageSchema = msg(
|
||||||
|
"game.action",
|
||||||
|
GameActionPayloadSchema,
|
||||||
|
);
|
||||||
export const RoomSetPresetsMessageSchema = msg(
|
export const RoomSetPresetsMessageSchema = msg(
|
||||||
"room.setPresets",
|
"room.setPresets",
|
||||||
RoomSetPresetsPayloadSchema,
|
RoomSetPresetsPayloadSchema,
|
||||||
|
|
@ -775,6 +827,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
RoomJoinMessageSchema,
|
RoomJoinMessageSchema,
|
||||||
RoomLeaveMessageSchema,
|
RoomLeaveMessageSchema,
|
||||||
GameMoveMessageSchema,
|
GameMoveMessageSchema,
|
||||||
|
GameActionMessageSchema,
|
||||||
RoomSetPresetsMessageSchema,
|
RoomSetPresetsMessageSchema,
|
||||||
ModifierProfileUpdateMessageSchema,
|
ModifierProfileUpdateMessageSchema,
|
||||||
ModifierProfileProposeMessageSchema,
|
ModifierProfileProposeMessageSchema,
|
||||||
|
|
@ -846,6 +899,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
||||||
RoomJoinMessageSchema,
|
RoomJoinMessageSchema,
|
||||||
RoomLeaveMessageSchema,
|
RoomLeaveMessageSchema,
|
||||||
GameMoveMessageSchema,
|
GameMoveMessageSchema,
|
||||||
|
GameActionMessageSchema,
|
||||||
RoomSetPresetsMessageSchema,
|
RoomSetPresetsMessageSchema,
|
||||||
ModifierProfileUpdateMessageSchema,
|
ModifierProfileUpdateMessageSchema,
|
||||||
ModifierProfileProposeMessageSchema,
|
ModifierProfileProposeMessageSchema,
|
||||||
|
|
@ -872,6 +926,7 @@ export const KNOWN_MESSAGE_TYPES = [
|
||||||
"room.join",
|
"room.join",
|
||||||
"room.leave",
|
"room.leave",
|
||||||
"game.move",
|
"game.move",
|
||||||
|
"game.action",
|
||||||
"room.setPresets",
|
"room.setPresets",
|
||||||
"modifier-profile.update",
|
"modifier-profile.update",
|
||||||
"modifier-profile.propose",
|
"modifier-profile.propose",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue