Refactor frontend to use HTMX, and do most rendering serverside

This commit is contained in:
sepia 2025-07-18 00:04:32 -05:00
parent d1dbebcc39
commit 8eabbe3211
12 changed files with 739 additions and 775 deletions

View file

@ -15,8 +15,8 @@ export class GameInstance {
private readonly boardSize = 15;
private moveCount = 0;
constructor() {
this.id = uuidv4();
constructor(id?: string) {
this.id = id || uuidv4();
this.board = Array.from({ length: this.boardSize }, () =>
Array(this.boardSize).fill(null),
);

View file

@ -7,8 +7,9 @@ export class GameManager {
this.games = new Map();
}
createGame(): GameInstance {
const game = new GameInstance();
// Overload createGame to optionally accept a gameId
createGame(gameId?: string): GameInstance {
const game = new GameInstance(gameId); // Pass gameId to GameInstance constructor
this.games.set(game.id, game);
return game;
}

View file

@ -3,139 +3,241 @@ import { WebSocketHandler } from './WebSocketHandler';
import { GameManager } from './GameManager';
import { GameInstance } from './GameInstance';
// Mock ElysiaWS type for testing purposes - fully compatible with standard WebSocket
type MockElysiaWS = {
send: ReturnType<typeof mock>;
close: ReturnType<typeof mock>;
on: ReturnType<typeof mock>;
_messageCallback: ((message: string) => void) | null;
_closeCallback: (() => void) | null;
_errorCallback: ((error: Error) => void) | null;
data: {
gameId?: string;
playerId?: string;
query: Record<string, string>;
};
// Standard WebSocket properties
binaryType: 'blob' | 'arraybuffer';
bufferedAmount: number;
extensions: string;
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null;
onerror: ((this: WebSocket, ev: Event) => any) | null;
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null;
onopen: ((this: WebSocket, ev: Event) => any) | null;
protocol: string;
readyState: number;
url: string;
CLOSED: number;
CONNECTING: number;
OPEN: number;
CLOSING: number;
dispatchEvent: ReturnType<typeof mock>;
addEventListener: ReturnType<typeof mock>;
removeEventListener: ReturnType<typeof mock>;
ping: ReturnType<typeof mock>; // Bun.js specific
pong: ReturnType<typeof mock>; // Bun.js specific
subscribe: ReturnType<typeof mock>; // Bun.js specific
unsubscribe: ReturnType<typeof mock>; // Bun.js specific
};
describe('WebSocketHandler', () => {
let gameManager: GameManager;
let webSocketHandler: WebSocketHandler;
let mockWs: any;
let mockWsData: { request: {}; gameId?: string; playerId?: string };
let mockWs: MockElysiaWS;
beforeEach(() => {
gameManager = new GameManager();
mockWsData = { request: {} };
mockWs = {
// Mock standard WebSocket methods
send: mock(() => {}),
on: mock((event: string, callback: Function) => {
if (event === 'message') mockWs._messageCallback = callback;
if (event === 'close') mockWs._closeCallback = callback;
if (event === 'error') mockWs._errorCallback = callback;
close: mock(() => {}),
// Mock custom 'on' method for attaching callbacks
on: mock((event: string, callback: (...args: any[]) => void) => {
if (event === 'message') (mockWs as any)._messageCallback = callback;
if (event === 'close') (mockWs as any)._closeCallback = callback;
if (event === 'error') (mockWs as any)._errorCallback = callback;
}),
_messageCallback: null,
_closeCallback: null,
_errorCallback: null,
data: mockWsData,
};
data: { query: {} },
// Initialize all standard WebSocket properties
binaryType: 'blob',
bufferedAmount: 0,
extensions: '',
onclose: null,
onerror: null,
onmessage: null,
onopen: null,
protocol: '',
readyState: 1,
url: '',
CLOSED: 3,
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
dispatchEvent: mock(() => {}),
addEventListener: mock(() => {}),
removeEventListener: mock(() => {}),
ping: mock(() => {}),
pong: mock(() => {}),
subscribe: mock(() => {}),
unsubscribe: mock(() => {}),
};
gameManager = new GameManager();
webSocketHandler = new WebSocketHandler(gameManager);
});
const triggerMessage = (message: string) => {
if (mockWs._messageCallback) {
mockWs._messageCallback(message);
const triggerMessage = (ws: MockElysiaWS, message: string) => {
if (ws._messageCallback) {
ws._messageCallback(message);
}
};
const triggerClose = () => {
if (mockWs._closeCallback) {
mockWs._closeCallback();
const triggerClose = (ws: MockElysiaWS) => {
if (ws._closeCallback) {
ws._closeCallback();
}
};
it('should handle a new connection', () => {
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
expect(mockWs.on).toHaveBeenCalledWith('message', expect.any(Function));
expect(mockWs.on).toHaveBeenCalledWith('close', expect.any(Function));
expect(mockWs.on).toHaveBeenCalledWith('error', expect.any(Function));
const triggerError = (ws: MockElysiaWS, error: Error) => {
if (ws._errorCallback) {
ws._errorCallback(error);
}
};
const createNewMockWs = (): MockElysiaWS => ({
send: mock(() => {}),
close: mock(() => {}),
on: mock((event: string, callback: (...args: any[]) => void) => {
if (event === 'message')
(createNewMockWs() as any)._messageCallback = callback;
if (event === 'close')
(createNewMockWs() as any)._closeCallback = callback;
if (event === 'error')
(createNewMockWs() as any)._errorCallback = callback;
}),
_messageCallback: null,
_closeCallback: null,
_errorCallback: null,
data: { query: {} },
binaryType: 'blob',
bufferedAmount: 0,
extensions: '',
onclose: null,
onerror: null,
onmessage: null,
onopen: null,
protocol: '',
readyState: 1,
url: '',
CLOSED: 3,
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
dispatchEvent: mock(() => {}),
addEventListener: mock(() => {}),
removeEventListener: mock(() => {}),
ping: mock(() => {}),
pong: mock(() => {}),
subscribe: mock(() => {}),
unsubscribe: mock(() => {}),
});
it('should handle a join_game message for a new game', () => {
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
it('should register a new connection', () => {
mockWs.data.gameId = 'test-game';
mockWs.data.playerId = 'player-alpha';
mockWs.data.query.gameId = 'test-game';
mockWs.data.query.playerId = 'player-alpha';
webSocketHandler.handleConnection(mockWs);
expect((webSocketHandler as any).connections.get('test-game')).toContain(
mockWs,
);
});
it('should process a join_game message for an already connected client', () => {
const gameId = gameManager.createGame().id;
mockWs.data.query.gameId = gameId;
mockWs.data.query.playerId = 'player1';
mockWs.data.gameId = gameId;
mockWs.data.playerId = 'player1';
webSocketHandler.handleConnection(mockWs);
const joinGameMessage = JSON.stringify({
type: 'join_game',
gameId: gameId,
playerId: 'player1',
});
triggerMessage(joinGameMessage);
triggerMessage(mockWs, joinGameMessage);
expect(mockWs.send).toHaveBeenCalledWith(
expect.stringContaining('game_state'),
expect.stringContaining('<div id="game-board"'),
);
expect(mockWsData.gameId).toBeDefined();
expect(mockWsData.playerId).toBe('player1');
});
it('should handle a join_game message for an existing game', () => {
const game = gameManager.createGame();
gameManager.joinGame(game.id, 'player1');
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
const joinGameMessage = JSON.stringify({
type: 'join_game',
gameId: game.id,
playerId: 'player2',
});
triggerMessage(joinGameMessage);
expect(mockWs.send).toHaveBeenCalledWith(
expect.stringContaining('game_state'),
expect.stringContaining('<div id="player-info"'),
);
expect(mockWsData.gameId).toBe(game.id);
expect(mockWsData.playerId).toBe('player2');
});
it('should handle a make_move message', () => {
it('should handle a make_move message and broadcast HTML updates', () => {
const game = gameManager.createGame();
gameManager.joinGame(game.id, 'player1');
gameManager.joinGame(game.id, 'player2');
game.addPlayer('player1');
game.addPlayer('player2');
game.currentPlayer = 'black';
game.status = 'playing';
mockWs.data.gameId = game.id;
mockWs.data.playerId = 'player1';
mockWs.data.query.gameId = game.id;
mockWs.data.query.playerId = 'player1';
webSocketHandler.handleConnection(mockWs);
mockWsData.gameId = game.id;
mockWsData.playerId = 'player1';
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
const makeMoveMessage = JSON.stringify({
type: 'make_move',
gameId: game.id,
playerId: 'player1',
row: 7,
col: 7,
});
triggerMessage(makeMoveMessage);
triggerMessage(mockWs, makeMoveMessage);
expect(mockWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: 'move_result', success: true }),
expect.stringContaining('<div id="game-board"'),
);
expect(mockWs.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="player-info"'),
);
expect(mockWs.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="messages"'),
);
expect(game.board[7][7]).toBe('black');
expect(game.currentPlayer).toBe('white');
});
it('should send an error for an invalid move', () => {
const game = gameManager.createGame();
gameManager.joinGame(game.id, 'player1');
gameManager.joinGame(game.id, 'player2');
game.addPlayer('player1');
game.addPlayer('player2');
game.currentPlayer = 'black';
game.status = 'playing';
mockWsData.gameId = game.id;
mockWsData.playerId = 'player1';
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
mockWs.data.gameId = game.id;
mockWs.data.playerId = 'player1';
mockWs.data.query.gameId = game.id;
mockWs.data.query.playerId = 'player1';
webSocketHandler.handleConnection(mockWs);
const makeMoveMessage1 = JSON.stringify({
type: 'make_move',
gameId: game.id,
playerId: 'player1',
row: 7,
col: 7,
});
triggerMessage(makeMoveMessage1);
expect(mockWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: 'move_result', success: true }),
);
triggerMessage(mockWs, makeMoveMessage1);
mockWs.send.mockClear();
game.currentPlayer = 'black';
const makeMoveMessage2 = JSON.stringify({
type: 'make_move',
row: 7,
col: 7,
});
triggerMessage(makeMoveMessage2);
triggerMessage(mockWs, makeMoveMessage1);
expect(mockWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: 'error', error: 'Cell already occupied' }),
@ -143,26 +245,56 @@ describe('WebSocketHandler', () => {
});
it('should handle ping/pong messages', () => {
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
webSocketHandler.handleConnection(mockWs);
const pingMessage = JSON.stringify({ type: 'ping' });
triggerMessage(pingMessage);
triggerMessage(mockWs, pingMessage);
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }));
});
it('should handle player disconnection', () => {
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
it('should handle player disconnection and notify others', () => {
const game = gameManager.createGame();
const player1Ws = createNewMockWs();
const player2Ws = createNewMockWs();
mockWsData.gameId = 'test-game-id';
mockWsData.playerId = 'test-player-id';
player1Ws.data.gameId = game.id;
player1Ws.data.playerId = 'player1';
player1Ws.data.query.gameId = game.id;
player1Ws.data.query.playerId = 'player1';
triggerClose();
player2Ws.data.gameId = game.id;
player2Ws.data.playerId = 'player2';
player2Ws.data.query.gameId = game.id;
player2Ws.data.query.playerId = 'player2';
webSocketHandler.handleConnection(player1Ws);
webSocketHandler.handleConnection(player2Ws);
player1Ws.send.mockClear();
player2Ws.send.mockClear();
webSocketHandler.handleDisconnect(player2Ws);
expect(player1Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="player-info"'),
);
expect(player1Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="messages"'),
);
expect(
player1Ws.send.mock.calls
.flat()
.some((call) => (call as string).includes('player2 disconnected')),
).toBeTrue();
expect((webSocketHandler as any).connections.get(game.id)).not.toContain(
player2Ws,
);
});
it('should send error for unknown message type', () => {
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
webSocketHandler.handleConnection(mockWs);
const unknownMessage = JSON.stringify({ type: 'unknown_type' });
triggerMessage(unknownMessage);
triggerMessage(mockWs, unknownMessage);
expect(mockWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
@ -170,238 +302,96 @@ describe('WebSocketHandler', () => {
});
it('should send error for invalid JSON message', () => {
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
webSocketHandler.handleConnection(mockWs);
const invalidJsonMessage = 'not a json';
triggerMessage(invalidJsonMessage);
triggerMessage(mockWs, invalidJsonMessage);
expect(mockWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
);
});
});
it('should notify other players and remove a disconnected player', () => {
const gameManager = new GameManager();
const webSocketHandler = new WebSocketHandler(gameManager);
// Player 1
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = {
request: {},
};
const mockWs1: any = {
send: mock(() => {}),
on: mock((event: string, callback: Function) => {
if (event === 'message') mockWs1._messageCallback = callback;
if (event === 'close') mockWs1._closeCallback = callback;
}),
_messageCallback: null,
_closeCallback: null,
data: mockWsData1,
};
// Player 2
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = {
request: {},
};
const mockWs2: any = {
send: mock(() => {}),
on: mock((event: string, callback: Function) => {
if (event === 'message') mockWs2._messageCallback = callback;
if (event === 'close') mockWs2._closeCallback = callback;
}),
_messageCallback: null,
_closeCallback: null,
data: mockWsData2,
};
const triggerMessageForWs = (ws: any, message: string) => {
if (ws._messageCallback) {
ws._messageCallback(message);
}
};
const triggerCloseForWs = (ws: any) => {
if (ws._closeCallback) {
ws._closeCallback();
}
};
// Player 1 joins, creates game
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
triggerMessageForWs(
mockWs1,
JSON.stringify({ type: 'join_game', playerId: 'player1' }),
);
mockWs1.data.gameId = mockWsData1.gameId;
mockWs1.data.playerId = 'player1';
// Player 2 joins same game
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
triggerMessageForWs(
mockWs2,
JSON.stringify({
type: 'join_game',
gameId: mockWsData1.gameId,
playerId: 'player2',
}),
);
mockWs2.data.gameId = mockWsData1.gameId;
mockWs2.data.playerId = 'player2';
// Player 2 disconnects
mockWs1.send.mockClear(); // Clear P1's send history before P2 disconnects
triggerCloseForWs(mockWs2);
// Expect Player 1 to receive player_disconnected message
expect(mockWs1.send).toHaveBeenCalledTimes(1);
const receivedMessage = JSON.parse(mockWs1.send.mock.calls[0][0]);
expect(receivedMessage.type).toBe('player_disconnected');
expect(receivedMessage.playerId).toBe('player2');
expect(receivedMessage.gameId).toBe(mockWsData1.gameId);
// Verify connections map is updated (Player 2 removed)
// @ts-ignore
expect(webSocketHandler.connections.get(mockWsData1.gameId)).toContain(
mockWs1,
);
// @ts-ignore
expect(webSocketHandler.connections.get(mockWsData1.gameId)).not.toContain(
mockWs2,
);
});
it('should broadcast game state to other players when a new player joins', () => {
const gameManager = new GameManager();
const webSocketHandler = new WebSocketHandler(gameManager);
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = {
request: {},
};
const mockWs1: any = {
send: mock(() => {}),
on: mock((event: string, callback: Function) => {
if (event === 'message') mockWs1._messageCallback = callback;
}),
_messageCallback: null,
data: mockWsData1,
};
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = {
request: {},
};
const mockWs2: any = {
send: mock(() => {}),
on: mock((event: string, callback: Function) => {
if (event === 'message') mockWs2._messageCallback = callback;
}),
_messageCallback: null,
data: mockWsData2,
};
const triggerMessageForWs = (ws: any, message: string) => {
if (ws._messageCallback) {
ws._messageCallback(message);
}
};
// Player 1 joins and creates a new game
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
const joinGameMessage1 = JSON.stringify({
type: 'join_game',
playerId: 'player1',
});
triggerMessageForWs(mockWs1, joinGameMessage1);
const player1GameId = mockWsData1.gameId;
// Player 2 joins the same game
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
const joinGameMessage2 = JSON.stringify({
type: 'join_game',
gameId: player1GameId,
playerId: 'player2',
});
triggerMessageForWs(mockWs2, joinGameMessage2);
// Check that Player 1 received the game_state update after Player 2 joined
// Player 1 should have received two messages: initial join and then game_state after P2 joins
expect(mockWs1.send).toHaveBeenCalledTimes(2);
const secondCallArgs = mockWs1.send.mock.calls[1][0];
const receivedMessage = JSON.parse(secondCallArgs);
expect(receivedMessage.type).toBe('game_state');
expect(receivedMessage.state.players.black).toBe('player1');
expect(receivedMessage.state.players.white).toBe('player2');
});
it('should broadcast game state after a successful move', () => {
const gameManager = new GameManager();
const webSocketHandler = new WebSocketHandler(gameManager);
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = {
request: {},
};
const mockWs1: any = {
send: mock(() => {}),
on: mock((event: string, callback: Function) => {
if (event === 'message') mockWs1._messageCallback = callback;
}),
_messageCallback: null,
data: mockWsData1,
};
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = {
request: {},
};
const mockWs2: any = {
send: mock(() => {}),
on: mock((event: string, callback: Function) => {
if (event === 'message') mockWs2._messageCallback = callback;
}),
_messageCallback: null,
data: mockWsData2,
};
const triggerMessageForWs = (ws: any, message: string) => {
if (ws._messageCallback) {
ws._messageCallback(message);
}
};
// Player 1 joins and creates a new game
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
const joinGameMessage1 = JSON.stringify({
type: 'join_game',
playerId: 'player1',
});
triggerMessageForWs(mockWs1, joinGameMessage1);
const player1GameId = mockWsData1.gameId;
mockWs1.data.gameId = player1GameId; // Manually set gameId for mockWs1
mockWs1.data.playerId = 'player1'; // Manually set playerId for mockWs1
// Player 2 joins the same game
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
const joinGameMessage2 = JSON.stringify({
type: 'join_game',
gameId: player1GameId,
playerId: 'player2',
});
triggerMessageForWs(mockWs2, joinGameMessage2);
mockWs2.data.gameId = player1GameId; // Manually set gameId for mockWs2
mockWs2.data.playerId = 'player2'; // Manually set playerId for mockWs2
// Clear previous calls for clean assertion
mockWs1.send.mockClear();
mockWs2.send.mockClear();
// Player 1 makes a move
const makeMoveMessage = JSON.stringify({
type: 'make_move',
row: 7,
col: 7,
});
triggerMessageForWs(mockWs1, makeMoveMessage);
// Expect Player 2 to receive the game state update
expect(mockWs2.send).toHaveBeenCalledTimes(1);
const receivedMessage = JSON.parse(mockWs2.send.mock.calls[0][0]);
expect(receivedMessage.type).toBe('game_state');
expect(receivedMessage.state.board[7][7]).toBe('black');
it('should broadcast game state to a specific client when targetWs is provided', () => {
const game = gameManager.createGame();
game.addPlayer('player1');
game.addPlayer('player2');
game.currentPlayer = 'black';
const player1Ws = createNewMockWs();
player1Ws.data.gameId = game.id;
player1Ws.data.playerId = 'player1';
player1Ws.data.query.gameId = game.id;
player1Ws.data.query.playerId = 'player1';
webSocketHandler.handleConnection(player1Ws);
const player2Ws = createNewMockWs();
player2Ws.data.gameId = game.id;
player2Ws.data.playerId = 'player2';
player2Ws.data.query.gameId = game.id;
player2Ws.data.query.playerId = 'player2';
webSocketHandler.handleConnection(player2Ws);
player1Ws.send.mockClear();
player2Ws.send.mockClear();
webSocketHandler.broadcastGameUpdate(game.id, game, null, null, player1Ws);
expect(player1Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="game-board"'),
);
expect(player1Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="player-info"'),
);
expect(player1Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="messages"'),
);
expect(player2Ws.send).not.toHaveBeenCalled();
});
it('should broadcast game state to all clients if targetWs is not provided', () => {
const game = gameManager.createGame();
game.addPlayer('player1');
game.addPlayer('player2');
game.currentPlayer = 'black';
const player1Ws = createNewMockWs();
player1Ws.data.gameId = game.id;
player1Ws.data.playerId = 'player1';
player1Ws.data.query.gameId = game.id;
player1Ws.data.query.playerId = 'player1';
webSocketHandler.handleConnection(player1Ws);
const player2Ws = createNewMockWs();
player2Ws.data.gameId = game.id;
player2Ws.data.playerId = 'player2';
player2Ws.data.query.gameId = game.id;
player2Ws.data.query.playerId = 'player2';
webSocketHandler.handleConnection(player2Ws);
player1Ws.send.mockClear();
player2Ws.send.mockClear();
webSocketHandler.broadcastGameUpdate(game.id, game);
expect(player1Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="game-board"'),
);
expect(player1Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="player-info"'),
);
expect(player1Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="messages"'),
);
expect(player2Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="game-board"'),
);
expect(player2Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="player-info"'),
);
expect(player2Ws.send).toHaveBeenCalledWith(
expect.stringContaining('<div id="messages"'),
);
});
});

View file

@ -1,187 +1,107 @@
import { GameManager } from './GameManager';
import { GameInstance } from './GameInstance';
import {
renderGameBoardHtml,
renderPlayerInfoHtml,
} from '../view/board-renderer';
interface WebSocketMessage {
type: string;
gameId?: string;
playerId?: string;
row?: number;
col?: number;
state?: any; // GameState
success?: boolean;
error?: string;
interface MakeMoveMessage {
gameId: string;
playerId: string;
row: number;
col: number;
}
export class WebSocketHandler {
private gameManager: GameManager;
private connections: Map<string, Array<any>>; // Use 'any' for the specific Elysia WS object for now
private games: Map<string, GameInstance>;
private connections: Map<string, Array<any>>; // Map of gameId to an array of connected websockets
constructor(gameManager: GameManager) {
this.gameManager = gameManager;
constructor() {
this.connections = new Map();
this.games = new Map();
}
public handleConnection(ws: any, req: any): void {
console.log('WebSocket connected');
public handleConnection(ws: any, gameId: string, playerId: string): void {
if (!this.connections.has(gameId)) {
this.connections.set(gameId, []);
}
ws.data.playerId = playerId;
ws.data.gameId = gameId;
this.connections.get(gameId)?.push(ws);
console.log(
`WebSocket connected, registered for Game ${gameId} as Player ${playerId}`,
);
}
public handleError(ws: any, error: Error): void {
console.error('WebSocket error:', error);
// Optionally send an error message to the client
if (ws) {
ws.send(
JSON.stringify({ type: 'error', error: 'Server-side WebSocket error' }),
this.sendMessage(
ws.data.gameId,
'Error: server-side WebSocket error',
ws,
);
}
}
public handleMessage(ws: any, message: string): void {
try {
const parsedMessage: WebSocketMessage = JSON.parse(message);
console.log('Received message:', parsedMessage);
switch (parsedMessage.type) {
case 'join_game':
this.handleJoinGame(ws, parsedMessage);
break;
case 'make_move':
this.handleMakeMove(ws, parsedMessage);
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
default:
ws.send(
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
);
}
} catch (error) {
console.error('Failed to parse message:', message, error);
ws.send(
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
);
public handleMessage(ws: any, message: any): void {
const type: string = message.type;
// Someday we might have other message types
if (type === 'make_move') {
this.handleMakeMove(ws, message as MakeMoveMessage);
}
}
private handleJoinGame(ws: any, message: WebSocketMessage): void {
const { gameId, playerId } = message;
if (!playerId) {
ws.send(JSON.stringify({ type: 'error', error: 'playerId is required' }));
return;
}
let game: GameInstance | null = null;
let isNewGame = false;
if (gameId) {
game = this.gameManager.getGame(gameId);
if (!game) {
ws.send(JSON.stringify({ type: 'error', error: 'Game not found' }));
return;
}
} else {
// Create a new game if no gameId is provided
game = this.gameManager.createGame();
isNewGame = true;
}
if (game && this.gameManager.joinGame(game.id, playerId)) {
ws.data.gameId = game.id; // Store gameId on the WebSocket object
ws.data.playerId = playerId; // Store playerId on the WebSocket object
if (!this.connections.has(game.id)) {
this.connections.set(game.id, []);
}
this.connections.get(game.id)?.push(ws);
const gameStateMessage = JSON.stringify({
type: 'game_state',
state: {
id: game.id,
board: game.board,
currentPlayer: game.currentPlayer,
status: game.status,
winner: game.winner,
players: game.players,
},
});
ws.send(gameStateMessage);
// Notify other players if any
this.connections.get(game.id)?.forEach((playerWs: any) => {
if (playerWs !== ws) {
// Don't send back to the player who just joined
playerWs.send(gameStateMessage);
}
});
console.log(`${playerId} joined game ${game.id}`);
} else {
ws.send(JSON.stringify({ type: 'error', error: 'Failed to join game' }));
}
}
private handleMakeMove(ws: any, message: WebSocketMessage): void {
private handleMakeMove(ws: any, message: MakeMoveMessage): void {
const { row, col } = message;
const gameId = ws.data.gameId;
const playerId = ws.data.playerId;
console.log(`Handling make_move message in game ${gameId} from player ${playerId}: ${{message}}`);
if (!gameId || !playerId) {
ws.send(JSON.stringify({ type: 'error', error: 'Not in a game' }));
return;
}
const game = this.gameManager.getGame(gameId);
if (!game) {
ws.send(JSON.stringify({ type: 'error', error: 'Game not found' }));
return;
}
if (row === undefined || col === undefined) {
ws.send(
JSON.stringify({ type: 'error', error: 'Invalid move coordinates' }),
if (!gameId || !playerId || row === undefined || col === undefined) {
this.sendMessage(
gameId,
'Error: missing gameId, playerId, row, or col',
ws,
);
return;
}
const playerColor =
game.players.black === playerId
? 'black'
: game.players.white === playerId
? 'white'
: null;
const game = this.games.get(gameId);
if (!game) {
this.sendMessage(gameId, 'Error: game not found', ws);
return;
}
const playerColor = Object.entries(game.players).find(
([_, id]) => id === playerId,
)?.[0] as ('black' | 'white') | undefined;
if (!playerColor) {
ws.send(
JSON.stringify({
type: 'error',
error: 'You are not a player in this game',
}),
this.sendMessage(
gameId,
'Error: you are not a player in this game',
ws,
);
return;
}
if (game.currentPlayer !== playerColor) {
ws.send(JSON.stringify({ type: 'error', error: 'Not your turn' }));
this.sendMessage(gameId, 'Error: It\'s not your turn', ws);
return;
}
try {
const result = game.makeMove(playerId, row, col);
ws.send(JSON.stringify({ type: 'move_result', success: result.success }));
if (result.success) {
// Broadcast updated game state to all players in the game
this.broadcastGameState(gameId, game);
this.broadcastGameState(game.id);
console.log(
`Move made in game ${gameId} by ${playerId}: (${row}, ${col})`,
`Move made in game ${game.id} by ${playerId}: (${row}, ${col})`,
);
} else {
ws.send(
JSON.stringify({
type: 'error',
error: result.error || 'Invalid move',
}),
);
this.sendMessage(gameId, result.error || 'Error: invalid move', ws);
}
} catch (e: any) {
ws.send(JSON.stringify({ type: 'error', error: e.message }));
this.sendMessage(gameId, 'Error: ' + e.message, ws);
}
}
@ -190,44 +110,92 @@ export class WebSocketHandler {
const playerId = ws.data.playerId;
if (gameId && playerId) {
// Remove disconnected player's websocket from connections
const connectionsInGame = this.connections.get(gameId);
if (connectionsInGame) {
this.connections.set(
gameId,
connectionsInGame.filter((conn: any) => conn !== ws),
connectionsInGame.filter((conn) => conn !== ws),
);
if (this.connections.get(gameId)?.length === 0) {
this.connections.delete(gameId); // Clean up if no players left
this.connections.delete(gameId);
}
}
// Notify other players
if (this.connections.has(gameId)) {
const disconnectMessage = JSON.stringify({
type: 'player_disconnected',
playerId: playerId,
gameId: gameId,
});
this.connections.get(gameId)?.forEach((playerWs: any) => {
playerWs.send(disconnectMessage);
});
// Notify remaining players about disconnect
this.sendMessage(gameId, 'message', `${playerId} disconnected.`);
}
console.log(`${playerId} disconnected from game ${gameId}`);
}
}
// Method to send updated game state to all participants in a game
// This would typically be called by GameManager when game state changes
public broadcastGameState(gameId: string, state: any): void {
const message = JSON.stringify({ type: 'game_state', state });
const connectionsInGame = this.connections.get(gameId);
public broadcastGameState(gameId: string): void {
const game = this.games.get(gameId);
if (!game) {
console.warn('Attempted to broadcast state of game ${gameId}, which is not loaded.');
return;
}
if (connectionsInGame) {
connectionsInGame.forEach((ws: any) => {
ws.send(message);
const connectionsToUpdate = this.connections.get(gameId);
if (connectionsToUpdate) {
connectionsToUpdate.forEach((ws) => {
if (!ws.data.playerId) {
console.warn('WebSocket without playerId in game for update', gameId);
return;
}
const updatedBoardHtml = renderGameBoardHtml(game, ws.data.playerId);
ws.send(updatedBoardHtml);
const updatedPlayerInfoHtml = renderPlayerInfoHtml(
game.id,
ws.data.playerId,
);
ws.send(updatedPlayerInfoHtml);
if (game.status === 'finished') {
if (game.winner === 'draw') {
this.sendMessage(gameId, 'Game ended in draw.');
} else if (game.winner) {
this.sendMessage(gameId, `${game.winner.toUpperCase()} wins!`);
}
} else if (game.status === 'playing') {
const clientPlayerColor = Object.entries(game.players).find(
([_, id]) => id === ws.data.playerId,
)?.[0] as ('black' | 'white') | undefined;
if (game.currentPlayer && clientPlayerColor === game.currentPlayer) {
this.sendMessage(gameId, "It's your turn!", ws);
} else if (game.currentPlayer) {
this.sendMessage(gameId, `Waiting for ${game.currentPlayer}'s move.`, ws);
}
} else if (game.status === 'waiting') {
this.sendMessage(gameId, 'Waiting for another player...', ws);
}
});
} else {
console.log(`No connections to update for game ${gameId}.`);
}
}
public sendMessage(
gameId: string,
message: string,
targetWs?: any,
): void {
const connections = targetWs ? [targetWs] : this.connections.get(gameId);
if (connections) {
connections.forEach((ws) => {
ws.send('<div id="messages">' + message + '</div>')
});
}
console.log(`Broadcasting game state for ${gameId}:`, state);
}
public getGame(gameId: string): GameInstance | undefined {
return this.games.get(gameId)
}
createGame(gameId?: string): GameInstance {
const game = new GameInstance(gameId);
this.games.set(game.id, game);
return game;
}
}