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:
Joey Yakimowich-Payne 2026-04-21 11:56:35 -06:00
commit 2951a2d547
No known key found for this signature in database
10 changed files with 939 additions and 1 deletions

View file

@ -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<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.
*

View file

@ -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);
});
});

View file

@ -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) => ({

View file

@ -59,6 +59,14 @@ const fixtures: Record<string, AnyMessage> = {
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");
});
});

View file

@ -40,6 +40,12 @@ export type Fact = z.infer<typeof FactSchema>;
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<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.
// ---------------------------------------------------------------------------
@ -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",