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();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import type { PieceType } from '../schema';
|
|||
import type { GameResult } from '../engine';
|
||||
import { GameClient } from '../net/client';
|
||||
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 { toast } from 'sonner';
|
||||
|
||||
|
|
@ -284,6 +284,19 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
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(() => {
|
||||
// Also a no-op: authoritative state is server-driven. The UI should
|
||||
// not have any need to swap the engine under multiplayer — all state
|
||||
|
|
@ -323,6 +336,7 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
lastMove,
|
||||
activations,
|
||||
setPresets,
|
||||
sendAction,
|
||||
// Multiplayer-only metadata. GameView uses these to gate drag and
|
||||
// render a "waiting for opponent" / error overlay.
|
||||
myColor: meta.myColor,
|
||||
|
|
|
|||
|
|
@ -99,3 +99,14 @@ export {
|
|||
safeParseCustomModifierDescriptor,
|
||||
serializeCustomModifierDescriptor,
|
||||
} 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,
|
||||
CustomModifierRegisteredPayload,
|
||||
ErrorPayload,
|
||||
GameActionPayload,
|
||||
GameDeltaPayload,
|
||||
GameEndPayload,
|
||||
GameMovePayload,
|
||||
|
|
@ -24,6 +25,7 @@ import type {
|
|||
ModifierProfileConsentReceivedPayload,
|
||||
ModifierProfileQueuedPayload,
|
||||
ModifierProfileUpdatedPayload,
|
||||
PlayerActionWire,
|
||||
PresetActivation,
|
||||
PromotionPiece,
|
||||
RoomCreatedPayload,
|
||||
|
|
@ -271,6 +273,21 @@ export class GameClient {
|
|||
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
|
||||
* active preset set. The server validates and — on success —
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export type GameEndReason =
|
|||
|
||||
export type ErrorCode =
|
||||
| "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"
|
||||
| "GAME_OVER"
|
||||
| "ROOM_NOT_FOUND"
|
||||
|
|
@ -374,6 +378,28 @@ export interface GameMovePayload {
|
|||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -413,6 +439,7 @@ export type ClientMessage =
|
|||
| MessageEnvelope<"room.join", RoomJoinPayload>
|
||||
| MessageEnvelope<"room.leave", Record<string, never>>
|
||||
| MessageEnvelope<"game.move", GameMovePayload>
|
||||
| MessageEnvelope<"game.action", GameActionPayload>
|
||||
| MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>
|
||||
| MessageEnvelope<"modifier-profile.update", ModifierProfileUpdatePayload>
|
||||
| MessageEnvelope<"modifier-profile.propose", ModifierProfileProposePayload>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue