refactor(games): overhaul WS game system with improved UX and solo test support
Some checks failed
Deploy to Production / test (push) Failing after 35s
Some checks failed
Deploy to Production / test (push) Failing after 35s
Backend: - Fix session never being attached to ws.data at upgrade time - Add GameServer class: connection registry, per-connection room tracking, automatic room cleanup on disconnect via ws.data.rooms - Replace ws-handler.ts with typed event-driven architecture using mitt - Remove redundant subscription tracking from RoomManager - Add JOIN_RESULT with player/spectator lists replacing error-as-control-flow - Add SESSION_REPLACED for multi-tab same-account detection - Add FILL_ROOM command for admin solo testing (fills empty slots with host) - Fix dual-schema routing; remove game types from WsMessageSchema - Per-player personalized views sent directly after each action Chess plugin: - Allow same-player (solo) mode: skip color/turn ownership checks - Fix forfeit and disconnect handling in solo mode (winner: null) Frontend: - Click-to-move with legal move dots and last-move highlight - Auto-scroll move history, forfeit confirmation, turn-reactive board border - JOIN_RESULT initialises player/spectator lists immediately on join - Contextual connecting state, player slot cards in waiting room - Copy-invite button with Copied! flash, Back to Lobby CTA on finish - Session-replaced warning banner with Rejoin here action - Lobby passes preferAs intent through route state - Admin waiting room shows Start Solo Test button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,11 +23,6 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
||||
|
||||
createInitialState(players: string[]): ChessState {
|
||||
const game = new Chess();
|
||||
|
||||
if (players[0] === players[1]) {
|
||||
throw new Error("Cannot create chess game with same player for both sides");
|
||||
}
|
||||
|
||||
return {
|
||||
fen: game.fen(),
|
||||
players: { white: players[0]!, black: players[1]! },
|
||||
@@ -42,7 +37,10 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
||||
return { ok: false, error: "Game is already over" };
|
||||
}
|
||||
|
||||
const solo = state.players.white === state.players.black;
|
||||
|
||||
if (action.type === "forfeit") {
|
||||
if (solo) return { ok: true, state: { ...state, status: "forfeit", winner: null } };
|
||||
const color = getPlayerColor(state, playerId);
|
||||
if (!color) return { ok: false, error: "You are not a player in this game" };
|
||||
const winner = color === "white" ? state.players.black : state.players.white;
|
||||
@@ -50,12 +48,14 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
||||
}
|
||||
|
||||
if (action.type === "move") {
|
||||
const playerColor = getPlayerColor(state, playerId);
|
||||
if (!playerColor) return { ok: false, error: "You are not a player in this game" };
|
||||
|
||||
const game = new Chess(state.fen);
|
||||
const turn = game.turn() === "w" ? "white" : "black";
|
||||
if (playerColor !== turn) return { ok: false, error: "It is not your turn" };
|
||||
|
||||
if (!solo) {
|
||||
const playerColor = getPlayerColor(state, playerId);
|
||||
if (!playerColor) return { ok: false, error: "You are not a player in this game" };
|
||||
if (playerColor !== turn) return { ok: false, error: "It is not your turn" };
|
||||
}
|
||||
|
||||
let move;
|
||||
try {
|
||||
@@ -90,7 +90,11 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
||||
return { ok: false, error: "Unknown action type" };
|
||||
},
|
||||
|
||||
getPlayerView(state: ChessState): ChessState { return state; },
|
||||
getPlayerView(state: ChessState, playerId: string): ChessState {
|
||||
const playerColor = getPlayerColor(state, playerId);
|
||||
if (!playerColor) return state;
|
||||
return state;
|
||||
},
|
||||
getSpectatorView(state: ChessState): ChessState { return state; },
|
||||
|
||||
isGameOver(state: ChessState): GameOverResult | null {
|
||||
@@ -99,6 +103,9 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
||||
},
|
||||
|
||||
onPlayerDisconnect(state: ChessState, playerId: string): ChessState {
|
||||
if (state.players.white === state.players.black) {
|
||||
return { ...state, status: "forfeit", winner: null };
|
||||
}
|
||||
const color = getPlayerColor(state, playerId);
|
||||
if (!color) return state;
|
||||
const winner = color === "white" ? state.players.black : state.players.white;
|
||||
|
||||
@@ -104,17 +104,12 @@ export const MaintenanceModeSchema = z.object({
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
// WebSocket Message Schemas
|
||||
// WebSocket Message Schemas (dashboard messages only — game messages live in api/src/games/types.ts)
|
||||
export const WsMessageSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("PING") }),
|
||||
z.object({ type: z.literal("PONG") }),
|
||||
z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }),
|
||||
z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }),
|
||||
// Game messages (client → server)
|
||||
z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string() }),
|
||||
z.object({ type: z.literal("JOIN_ROOM"), roomId: z.string(), as: z.enum(["player", "spectator"]) }),
|
||||
z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
|
||||
z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.record(z.unknown()) }),
|
||||
]);
|
||||
|
||||
export type WsMessage = z.infer<typeof WsMessageSchema>;
|
||||
|
||||
Reference in New Issue
Block a user