From 2951a2d547b185bb0459546da3f9bb4a9e95949f Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 11:56:35 -0600 Subject: [PATCH] feat(multiplayer): game.action WS message for PlayerActions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/chess/e2e/multiplayer.spec.ts | 361 ++++++++++++++++++ .../chess/src/hooks/useMultiplayerGame.ts | 16 +- packages/chess/src/index.ts | 11 + packages/chess/src/net/client.ts | 17 + packages/chess/src/net/types.ts | 27 ++ packages/server/src/broadcast.ts | 191 +++++++++ packages/server/src/game-session.test.ts | 110 ++++++ packages/server/src/game-session.ts | 46 +++ packages/server/src/protocol.test.ts | 106 +++++ packages/server/src/protocol.ts | 55 +++ 10 files changed, 939 insertions(+), 1 deletion(-) diff --git a/packages/chess/e2e/multiplayer.spec.ts b/packages/chess/e2e/multiplayer.spec.ts index 37e865e..4bf2fe4 100644 --- a/packages/chess/e2e/multiplayer.spec.ts +++ b/packages/chess/e2e/multiplayer.spec.ts @@ -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; + }; + 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, }) => { diff --git a/packages/chess/src/hooks/useMultiplayerGame.ts b/packages/chess/src/hooks/useMultiplayerGame.ts index db401a8..d79118d 100644 --- a/packages/chess/src/hooks/useMultiplayerGame.ts +++ b/packages/chess/src/hooks/useMultiplayerGame.ts @@ -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, diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index d01c11b..9982044 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -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"; diff --git a/packages/chess/src/net/client.ts b/packages/chess/src/net/client.ts index 7f9c746..f7932db 100644 --- a/packages/chess/src/net/client.ts +++ b/packages/chess/src/net/client.ts @@ -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 — diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index 2f6851c..18b66be 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -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> | MessageEnvelope<"game.move", GameMovePayload> + | MessageEnvelope<"game.action", GameActionPayload> | MessageEnvelope<"room.setPresets", RoomSetPresetsPayload> | MessageEnvelope<"modifier-profile.update", ModifierProfileUpdatePayload> | MessageEnvelope<"modifier-profile.propose", ModifierProfileProposePayload> diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 899326d..c655f05 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -32,6 +32,7 @@ import { type CustomModifierRegisterPayload, type ErrorCode, type Fact as WireFact, + type GameActionPayload, type GameMovePayload, type ModifierProfileConsentPayload, type ModifierProfileProposePayload, @@ -48,8 +49,10 @@ import { RoomRegistry } from "./rooms.js"; import { resolveLayoutRequest, toResolvedLayout } from "./layouts.js"; import { validateProfile, + type ActionResult, type ModifierProfile, type ModifierValidationErrorCode, + type PlayerAction, } from "@paratype/chess"; /** @@ -262,6 +265,9 @@ export function handleMessage( case "game.move": handleGameMove(ws, msg.payload); break; + case "game.action": + handleGameAction(ws, msg.payload); + break; case "room.setPresets": handleSetPresets(ws, msg.payload); 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, + 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. * diff --git a/packages/server/src/game-session.test.ts b/packages/server/src/game-session.test.ts index 7a49e16..1143d15 100644 --- a/packages/server/src/game-session.test.ts +++ b/packages/server/src/game-session.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import type { PlayerAction } from "@paratype/chess"; import { GameSession, GameSessionRegistry, @@ -6,6 +7,21 @@ import { type Fact, } 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 // --------------------------------------------------------------------------- @@ -246,3 +262,97 @@ describe("GameSessionRegistry", () => { ).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); + }); +}); diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts index d0364e7..05d1ef0 100644 --- a/packages/server/src/game-session.ts +++ b/packages/server/src/game-session.ts @@ -16,9 +16,11 @@ import { PresetActivationError, reconcileProfileSwap, validateProfile, + type ActionResult, type ActivationRequest, type ModifierProfile, type ModifierValidationErrorCode, + type PlayerAction, type PresetActivation, type GameResult, 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. */ private snapshotFacts(): Fact[] { return this.engine.session.allFacts().map((f) => ({ diff --git a/packages/server/src/protocol.test.ts b/packages/server/src/protocol.test.ts index c46277c..c3d3cc5 100644 --- a/packages/server/src/protocol.test.ts +++ b/packages/server/src/protocol.test.ts @@ -59,6 +59,14 @@ const fixtures: Record = { token: UUID, 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": { ...envelope, seq: 1, @@ -921,3 +929,101 @@ describe("Modifier profile error codes", () => { 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"); + }); +}); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 4bb787a..80876cb 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -40,6 +40,12 @@ export type Fact = z.infer; export const ErrorCodeSchema = z.enum([ "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", "GAME_OVER", "ROOM_NOT_FOUND", @@ -594,6 +600,48 @@ export const GameMovePayloadSchema = z.object({ }); export type GameMovePayload = z.infer; +// --------------------------------------------------------------------------- +// 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; + +export const GameActionPayloadSchema = z.object({ + action: PlayerActionSchema, +}); +export type GameActionPayload = z.infer; + // --------------------------------------------------------------------------- // 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 RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema); export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema); +export const GameActionMessageSchema = msg( + "game.action", + GameActionPayloadSchema, +); export const RoomSetPresetsMessageSchema = msg( "room.setPresets", RoomSetPresetsPayloadSchema, @@ -775,6 +827,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [ RoomJoinMessageSchema, RoomLeaveMessageSchema, GameMoveMessageSchema, + GameActionMessageSchema, RoomSetPresetsMessageSchema, ModifierProfileUpdateMessageSchema, ModifierProfileProposeMessageSchema, @@ -846,6 +899,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [ RoomJoinMessageSchema, RoomLeaveMessageSchema, GameMoveMessageSchema, + GameActionMessageSchema, RoomSetPresetsMessageSchema, ModifierProfileUpdateMessageSchema, ModifierProfileProposeMessageSchema, @@ -872,6 +926,7 @@ export const KNOWN_MESSAGE_TYPES = [ "room.join", "room.leave", "game.move", + "game.action", "room.setPresets", "modifier-profile.update", "modifier-profile.propose",