diff --git a/.goosehints b/.goosehints new file mode 100644 index 0000000..07afeaa --- /dev/null +++ b/.goosehints @@ -0,0 +1,8 @@ +Frontend: HTMX and plain JS/CSS +Backend: Elysia (TypeScript) + +Important files: +* src/index.ts - backend entrypoint +* src/game/GameInstance.ts - gomoku game logic +* src/game/WebSocketHandler.ts - network with players via websockets +* src/view/board-renderer.ts - render frontend components diff --git a/index.html b/index.html index 886f33c..f16ba75 100644 --- a/index.html +++ b/index.html @@ -28,7 +28,7 @@
- Connecting... + Disconnected
diff --git a/src/client-entry.ts b/src/client-entry.ts deleted file mode 100644 index 9dfd992..0000000 --- a/src/client-entry.ts +++ /dev/null @@ -1,22 +0,0 @@ -console.log('Gomoku client entry point -- HTMX mode.'); - -const WS_URL = process.env.WS_URL || 'ws://localhost:3000/ws'; - -// This will be handled by HTMX's ws-connect -// However, we might still need a way to send messages from client-side JS if required by HTMX - -// Example of how to send a message via the established HTMX WebSocket. -// This is a placeholder and might evolve as we refactor. -(window as any).sendWebSocketMessage = (message: any) => { - const gameContainer = document.getElementById('game-container'); - if (gameContainer) { - const ws = (gameContainer as any)._htmx_ws; - if (ws) { - ws.send(JSON.stringify(message)); - } else { - console.error('HTMX WebSocket not found on game-container.'); - } - } else { - console.error('Game container not found.'); - } -}; diff --git a/src/game-client/GameStateManager.test.ts b/src/game-client/GameStateManager.test.ts deleted file mode 100644 index 0dd3126..0000000 --- a/src/game-client/GameStateManager.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { expect, test, describe, beforeEach, afterEach, mock } from 'bun:test'; -import { GameStateManager, GameStateType } from './GameStateManager'; - -describe('GameStateManager', () => { - let gameStateManager: GameStateManager; - - beforeEach(() => { - // Initialize a fresh GameStateManager before each test - gameStateManager = new GameStateManager(); - }); - - test('should initialize with a default empty game state', () => { - const initialState = gameStateManager.getGameState(); - expect(initialState).toEqual({ - id: '', - board: Array(15).fill(Array(15).fill(null)), - currentPlayer: 'black', - status: 'waiting', - winner: null, - players: {}, - }); - }); - - test('should update game state from server updates', () => { - const serverState = { - id: 'game123', - board: Array.from({ length: 15 }, () => Array(15).fill(null)), - currentPlayer: 'white' as 'black' | 'white', - status: 'playing' as 'waiting' | 'playing' | 'finished', - winner: null, - players: { black: 'playerA', white: 'playerB' }, - }; - gameStateManager.updateGameState(serverState); - expect(gameStateManager.getGameState()).toEqual(serverState); - }); - - test('should handle optimistic updates for making a move', () => { - const initialBoard = Array.from({ length: 15 }, () => Array(15).fill(null)); - initialBoard[7][7] = 'black'; // Simulate an optimistic move - - const optimisticState = { - id: 'game123', - board: initialBoard, - currentPlayer: 'white' as 'black' | 'white', // Turn changes optimistically - status: 'playing' as 'waiting' | 'playing' | 'finished', - winner: null, - players: { black: 'playerA', white: 'playerB' }, - }; - - gameStateManager.updateGameState(optimisticState); - expect(gameStateManager.getGameState().board[7][7]).toEqual('black'); - expect(gameStateManager.getGameState().currentPlayer).toEqual('white'); - }); - - test('should rollback optimistic updates if server rejects move', () => { - const initialServerState = { - id: 'game123', - board: Array.from({ length: 15 }, () => Array(15).fill(null)), - currentPlayer: 'black' as 'black' | 'white', - status: 'playing' as 'waiting' | 'playing' | 'finished', - winner: null, - players: { black: 'playerA', white: 'playerB' }, - }; - gameStateManager.updateGameState(initialServerState); - - // Optimistic update - const optimisticBoard = Array.from({ length: 15 }, () => - Array(15).fill(null), - ); - optimisticBoard[7][7] = 'black'; - const optimisticState = { - id: 'game123', - board: optimisticBoard, - currentPlayer: 'white' as 'black' | 'white', - status: 'playing' as 'waiting' | 'playing' | 'finished', - winner: null, - players: { black: 'playerA', white: 'playerB' }, - }; - gameStateManager.updateGameState(optimisticState); - - // Server rejection - rollback to initial state - gameStateManager.rollbackGameState(); // This method will be implemented - expect(gameStateManager.getGameState()).toEqual(initialServerState); - }); - - // Add more tests for: - // - Win conditions - // - Draw conditions - // - Invalid moves (already occupied, out of bounds - though this might be server-side validation primarily) - // - Player disconnection/reconnection behavior -}); diff --git a/src/game-client/GameStateManager.ts b/src/game-client/GameStateManager.ts deleted file mode 100644 index 27b013c..0000000 --- a/src/game-client/GameStateManager.ts +++ /dev/null @@ -1,52 +0,0 @@ -export interface GameStateType { - id: string; - board: (null | 'black' | 'white')[][]; - currentPlayer: 'black' | 'white'; - status: 'waiting' | 'playing' | 'finished'; - winner: null | 'black' | 'white' | 'draw'; - players: { black?: string; white?: string }; -} - -export class GameStateManager { - private gameState: GameStateType; - private stateHistory: GameStateType[]; - - constructor() { - this.gameState = this.getDefaultGameState(); - this.stateHistory = []; - } - - private getDefaultGameState(): GameStateType { - const emptyBoard: (null | 'black' | 'white')[][] = Array(15) - .fill(null) - .map(() => Array(15).fill(null)); - return { - id: '', - board: emptyBoard, - currentPlayer: 'black', - status: 'waiting', - winner: null, - players: {}, - }; - } - - getGameState(): GameStateType { - return this.gameState; - } - - updateGameState(newState: GameStateType): void { - // Store a deep copy of the current state before updating - // This is crucial for rollback to work correctly, as objects are passed by reference. - this.stateHistory.push(JSON.parse(JSON.stringify(this.gameState))); - this.gameState = newState; - } - - rollbackGameState(): void { - if (this.stateHistory.length > 0) { - this.gameState = this.stateHistory.pop()!; - } else { - console.warn('No previous state to rollback to.'); - // Optionally, throw an error or reset to default state here - } - } -} diff --git a/src/game-client/WebSocketClient.test.ts b/src/game-client/WebSocketClient.test.ts deleted file mode 100644 index 7cecfef..0000000 --- a/src/game-client/WebSocketClient.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; -import { WebSocketClient } from './WebSocketClient'; - -// Define MockWebSocket as a regular class -class MockWebSocket { - static CONNECTING = 0; - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; - - constructor(url?: string) { - // In a real scenario, you might do something with the URL - // For this mock, we just need to accept it. - } - - onopen: ((this: any, ev: Event) => any) | null = null; - onmessage: ((this: any, ev: MessageEvent) => any) | null = null; - onclose: ((this: any, ev: CloseEvent) => any) | null = null; - onerror: ((this: any, ev: Event) => any) | null = null; - readyState: number = 0; // 0: CONNECTING, 1: OPEN, 2: CLOSING, 3: CLOSED - - // Using a plain function for send/close to simplify. - send(data: string) { - if (this.readyState === WebSocket.OPEN) { - // Simulate server echoing back message for testing received messages - this.onmessage?.(new MessageEvent('message', { data })); - } - } - close() { - this.readyState = WebSocket.CLOSING; - this.onclose?.(new CloseEvent('close')); - this.readyState = WebSocket.CLOSED; - } - - // Helper to simulate events - _simulateOpen() { - this.readyState = WebSocket.OPEN; - this.onopen?.(new Event('open')); - } - - _simulateMessage(data: string) { - this.onmessage?.(new MessageEvent('message', { data })); - } - - _simulateClose(code = 1000, reason = '', wasClean = true) { - this.readyState = WebSocket.CLOSING; - this.onclose?.(new CloseEvent('close', { code, reason, wasClean })); - this.readyState = WebSocket.CLOSED; - } - - _simulateError(error: Event) { - this.onerror?.(error); - } -} - -// Global array to track MockWebSocket instances created -let createdMockWebSockets: MockWebSocket[] = []; - -// Store original WebSocket for restoration -const originalWebSocket = global.WebSocket; - -describe('WebSocketClient', () => { - let client: WebSocketClient; - const url = 'ws://localhost:8080'; - - // Using a mock function to wrap the actual global WebSocket constructor - let globalWebSocketConstructorMock: typeof WebSocket; - - beforeEach(() => { - // Clear instances and reset mocks before each test - createdMockWebSockets = []; - - // Create a mock function that, when called as the WebSocket constructor, - // creates a new MockWebSocket instance, pushes it to our global tracker, - // and then spies on its send and close methods.< - globalWebSocketConstructorMock = mock((url: string) => { - const instance = new MockWebSocket(url); - createdMockWebSockets.push(instance); - - (instance as any).send = mock(instance.send.bind(instance)); - (instance as any).close = mock(instance.close.bind(instance)); - - return instance; - }) as unknown as typeof WebSocket; - - global.WebSocket = globalWebSocketConstructorMock; - }); - - afterEach(() => { - global.WebSocket = originalWebSocket; // Restore original WebSocket - }); - - it('should connect to the specified URL', () => { - client = new WebSocketClient(url); - client.connect(); - - expect(globalWebSocketConstructorMock).toHaveBeenCalledWith(url); - expect(createdMockWebSockets.length).toBe(1); - }); - - it('should call onOpen callback when connection is established', () => { - const onOpenMock = mock(() => {}); - client = new WebSocketClient(url); - client.onOpen(onOpenMock); - client.connect(); - - createdMockWebSockets[0]._simulateOpen(); - expect(onOpenMock).toHaveBeenCalledTimes(1); - }); - - it('should call onMessage callback when a message is received', () => { - const onMessageMock = mock(() => {}); - client = new WebSocketClient(url); - client.onMessage(onMessageMock); - client.connect(); - - createdMockWebSockets[0]._simulateOpen(); - createdMockWebSockets[0]._simulateMessage('test message'); - - expect(onMessageMock).toHaveBeenCalledWith('test message'); - }); - - it('should call onClose callback when connection is closed', () => { - const onCloseMock = mock(() => {}); - client = new WebSocketClient(url); - client.onClose(onCloseMock); - client.connect(); - - createdMockWebSockets[0]._simulateOpen(); - createdMockWebSockets[0]._simulateClose(); - - expect(onCloseMock).toHaveBeenCalledTimes(1); - }); - - it('should call onError callback when an error occurs', () => { - const onErrorMock = mock(() => {}); - client = new WebSocketClient(url); - client.onError(onErrorMock); - client.connect(); - - createdMockWebSockets[0]._simulateError(new Event('error')); - - expect(onErrorMock).toHaveBeenCalledTimes(1); - }); - - it('should send a message when connected', () => { - client = new WebSocketClient(url); - client.connect(); - createdMockWebSockets[0]._simulateOpen(); - - client.send('hello'); - // Expect the mocked `send` method on the MockWebSocket instance to have been called - expect(createdMockWebSockets[0].send).toHaveBeenCalledWith('hello'); - }); - - it('should queue messages when disconnected and send them upon reconnection', async () => { - client = new WebSocketClient(url, { reconnectInterval: 10 }); // Shorter interval for faster test - const onOpenMock = mock(() => {}); - client.onOpen(onOpenMock); - - client.connect(); // Connect for the first time - expect(createdMockWebSockets.length).toBe(1); // First instance - const firstWs = createdMockWebSockets[0]; - - // Simulate immediate disconnection before open - firstWs._simulateClose(); - - // Send messages while disconnected, they should be queued - client.send('queued message 1'); - client.send('queued message 2'); - - // Simulate reconnection after a short delay - await new Promise((resolve) => setTimeout(resolve, 20)); // Allow for reconnectInterval - expect(createdMockWebSockets.length).toBe(2); // New instance created for reconnection - createdMockWebSockets[1]._simulateOpen(); // Simulate new connection opening - - // Wait for messages to be flushed - await new Promise((resolve) => setTimeout(resolve, 5)); - - expect(createdMockWebSockets[1].send).toHaveBeenCalledWith( - 'queued message 1', - ); - expect(createdMockWebSockets[1].send).toHaveBeenCalledWith( - 'queued message 2', - ); - - expect(onOpenMock).toHaveBeenCalledTimes(1); // onOpen should be called on successful reconnection - }); - - it('should not attempt to reconnect if explicitly closed', async () => { - client = new WebSocketClient(url, { - reconnectAttempts: 3, - reconnectInterval: 10, - }); - const onCloseMock = mock(() => {}); - client.onClose(onCloseMock); - - client.connect(); - createdMockWebSockets[0]._simulateOpen(); - client.close(); // Explicitly close - - // Allow some time for potential reconnect attempts. If no new WebSocket is created after the attempts would have happened, then we know it's not reconnecting. - await new Promise((resolve) => setTimeout(resolve, 50)); // (reconnectAttempts * reconnectInterval) + buffer - - expect(createdMockWebSockets.length).toBe(1); // Only the initial one - expect(onCloseMock).toHaveBeenCalledTimes(1); // onClose should be called - }); -}); diff --git a/src/game-client/WebSocketClient.ts b/src/game-client/WebSocketClient.ts deleted file mode 100644 index 0a1c57e..0000000 --- a/src/game-client/WebSocketClient.ts +++ /dev/null @@ -1,109 +0,0 @@ -type MessageHandler = (message: string) => void; -type OpenHandler = () => void; -type CloseHandler = (code: number, reason: string) => void; -type ErrorHandler = (event: Event) => void; - -interface WebSocketClientOptions { - reconnectAttempts?: number; - reconnectInterval?: number; -} - -export class WebSocketClient { - private ws: WebSocket | null = null; - private url: string; - private messageQueue: string[] = []; - private isConnected: boolean = false; - private reconnectCount: number = 0; - private options: WebSocketClientOptions; - private manualClose: boolean = false; - - private onMessageHandler: MessageHandler = () => {}; - private onOpenHandler: OpenHandler = () => {}; - private onCloseHandler: CloseHandler = () => {}; - private onErrorHandler: ErrorHandler = () => {}; - - constructor(url: string, options?: WebSocketClientOptions) { - this.url = url; - this.options = { - reconnectAttempts: 5, // Default reconnect attempts - reconnectInterval: 3000, // Default reconnect interval in ms - ...options, - }; - } - - public connect(): void { - this.manualClose = false; - this.ws = new WebSocket(this.url); - this.ws.onopen = this.handleOpen.bind(this); - this.ws.onmessage = this.handleMessage.bind(this); - this.ws.onclose = this.handleClose.bind(this); - this.ws.onerror = this.handleError.bind(this); - } - - public send(message: string): void { - if (this.isConnected && this.ws) { - this.ws.send(message); - } else { - this.messageQueue.push(message); - } - } - - public close(): void { - this.manualClose = true; - if (this.ws) { - this.ws.close(); - } - } - - public onMessage(handler: MessageHandler): void { - this.onMessageHandler = handler; - } - - public onOpen(handler: OpenHandler): void { - this.onOpenHandler = handler; - } - - public onClose(handler: CloseHandler): void { - this.onCloseHandler = handler; - } - - public onError(handler: ErrorHandler): void { - this.onErrorHandler = handler; - } - - private handleOpen(): void { - this.isConnected = true; - this.reconnectCount = 0; - this.onOpenHandler(); - this.flushMessageQueue(); - } - - private handleMessage(event: MessageEvent): void { - this.onMessageHandler(event.data); - } - - private handleClose(event: CloseEvent): void { - this.isConnected = false; - this.onCloseHandler(event.code, event.reason); - if ( - !this.manualClose && - this.reconnectCount < this.options.reconnectAttempts! - ) { - this.reconnectCount++; - setTimeout(() => this.connect(), this.options.reconnectInterval); - } - } - - private handleError(event: Event): void { - this.onErrorHandler(event); - } - - private flushMessageQueue(): void { - while (this.messageQueue.length > 0 && this.isConnected && this.ws) { - const message = this.messageQueue.shift(); - if (message) { - this.ws.send(message); - } - } - } -} diff --git a/src/game/GameManager.test.ts b/src/game/GameManager.test.ts deleted file mode 100644 index e69f95d..0000000 --- a/src/game/GameManager.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; -import { GameManager } from './GameManager'; -import { GameInstance } from './GameInstance'; - -describe('GameManager', () => { - let gameManager: GameManager; - - beforeEach(() => { - gameManager = new GameManager(); - }); - - it('should create a new game', () => { - const game = gameManager.createGame(); - expect(game).toBeInstanceOf(GameInstance); - expect(gameManager.getGame(game.id)).toBe(game); - }); - - it('should allow players to join games', () => { - const game = gameManager.createGame(); - const playerId = 'player1'; - const result = gameManager.joinGame(game.id, playerId); - expect(result).toBe(true); - // Add more assertions based on GameInstance implementation - }); - - it('should not allow joining non-existent games', () => { - const result = gameManager.joinGame('non-existent-id', 'player1'); - expect(result).toBe(false); - }); - - it('should retrieve existing games', () => { - const game = gameManager.createGame(); - const retrievedGame = gameManager.getGame(game.id); - expect(retrievedGame).toBe(game); - }); - - it('should return null for non-existent games', () => { - const game = gameManager.getGame('non-existent-id'); - expect(game).toBeNull(); - }); - - it('should remove games', () => { - const game = gameManager.createGame(); - gameManager.removeGame(game.id); - const retrievedGame = gameManager.getGame(game.id); - expect(retrievedGame).toBeNull(); - }); -}); diff --git a/src/game/GameManager.ts b/src/game/GameManager.ts deleted file mode 100644 index a575857..0000000 --- a/src/game/GameManager.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { GameInstance } from './GameInstance'; - -export class GameManager { - private games: Map; - - constructor() { - this.games = new Map(); - } - - // 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; - } - - getGame(gameId: string): GameInstance | null { - return this.games.get(gameId) || null; - } - - public joinGame(gameId: string, playerId: string): boolean { - const game = this.games.get(gameId); - if (!game) { - return false; - } - return game.addPlayer(playerId); - } - - removeGame(gameId: string): void { - this.games.delete(gameId); - } -} diff --git a/src/game/WebSocketHandler.test.ts b/src/game/WebSocketHandler.test.ts deleted file mode 100644 index e8fe83f..0000000 --- a/src/game/WebSocketHandler.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { describe, it, expect, beforeEach, mock } from 'bun:test'; -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; - close: ReturnType; - on: ReturnType; - _messageCallback: ((message: string) => void) | null; - _closeCallback: (() => void) | null; - _errorCallback: ((error: Error) => void) | null; - data: { - gameId?: string; - playerId?: string; - query: Record; - }; - // 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; - addEventListener: ReturnType; - removeEventListener: ReturnType; - ping: ReturnType; // Bun.js specific - pong: ReturnType; // Bun.js specific - subscribe: ReturnType; // Bun.js specific - unsubscribe: ReturnType; // Bun.js specific -}; - -describe('WebSocketHandler', () => { - let gameManager: GameManager; - let webSocketHandler: WebSocketHandler; - let mockWs: MockElysiaWS; - - beforeEach(() => { - mockWs = { - // Mock standard WebSocket methods - send: mock(() => {}), - 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: { 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 = (ws: MockElysiaWS, message: string) => { - if (ws._messageCallback) { - ws._messageCallback(message); - } - }; - - const triggerClose = (ws: MockElysiaWS) => { - if (ws._closeCallback) { - ws._closeCallback(); - } - }; - - 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 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(mockWs, joinGameMessage); - - expect(mockWs.send).toHaveBeenCalledWith( - expect.stringContaining('
{ - const game = gameManager.createGame(); - game.addPlayer('player1'); - game.addPlayer('player2'); - game.currentPlayer = 'black'; - - mockWs.data.gameId = game.id; - mockWs.data.playerId = 'player1'; - mockWs.data.query.gameId = game.id; - mockWs.data.query.playerId = 'player1'; - webSocketHandler.handleConnection(mockWs); - - const makeMoveMessage = JSON.stringify({ - type: 'make_move', - gameId: game.id, - playerId: 'player1', - row: 7, - col: 7, - }); - triggerMessage(mockWs, makeMoveMessage); - - expect(mockWs.send).toHaveBeenCalledWith( - expect.stringContaining('
{ - const game = gameManager.createGame(); - game.addPlayer('player1'); - game.addPlayer('player2'); - game.currentPlayer = 'black'; - - 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(mockWs, makeMoveMessage1); - mockWs.send.mockClear(); - - triggerMessage(mockWs, makeMoveMessage1); - - expect(mockWs.send).toHaveBeenCalledWith( - JSON.stringify({ type: 'error', error: 'Cell already occupied' }), - ); - }); - - it('should handle ping/pong messages', () => { - webSocketHandler.handleConnection(mockWs); - const pingMessage = JSON.stringify({ type: 'ping' }); - triggerMessage(mockWs, pingMessage); - - expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' })); - }); - - it('should handle player disconnection and notify others', () => { - const game = gameManager.createGame(); - const player1Ws = createNewMockWs(); - const player2Ws = createNewMockWs(); - - player1Ws.data.gameId = game.id; - player1Ws.data.playerId = 'player1'; - player1Ws.data.query.gameId = game.id; - player1Ws.data.query.playerId = 'player1'; - - 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('
(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); - const unknownMessage = JSON.stringify({ type: 'unknown_type' }); - triggerMessage(mockWs, unknownMessage); - - expect(mockWs.send).toHaveBeenCalledWith( - JSON.stringify({ type: 'error', error: 'Unknown message type' }), - ); - }); - - it('should send error for invalid JSON message', () => { - webSocketHandler.handleConnection(mockWs); - const invalidJsonMessage = 'not a json'; - triggerMessage(mockWs, invalidJsonMessage); - - expect(mockWs.send).toHaveBeenCalledWith( - JSON.stringify({ type: 'error', error: 'Invalid message format' }), - ); - }); - - 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('
{ - 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('
; export class WebSocketHandler { - private connections: Map>; // Use 'any' for the specific Elysia WS object for now + private connections: Map>; private games: Map; constructor() { @@ -20,31 +22,28 @@ export class WebSocketHandler { this.games = new Map(); } - public handleConnection(ws: any, gameId: string, playerId: string): void { + public handleConnection(ws: WS): void { + const {gameId, playerId} = ws.data.query; + if (!this.connections.has(gameId)) { this.connections.set(gameId, []); } - ws.data.playerId = playerId; - ws.data.gameId = gameId; this.connections.get(gameId)?.push(ws); + const game = this.getGame(gameId); + if (game) { + this.broadcastGameState(game.id); + } else { + ws.send('Error: game not found'); + ws.close(); + } + console.log( `WebSocket connected, registered for Game ${gameId} as Player ${playerId}`, ); } - public handleError(ws: any, error: Error): void { - console.error('WebSocket error:', error); - if (ws) { - this.sendMessage( - ws.data.gameId, - 'Error: server-side WebSocket error', - ws, - ); - } - } - - public handleMessage(ws: any, message: any): void { + public handleMessage(ws: WS, message: any): void { const type: string = message.type; // Someday we might have other message types if (type === 'make_move') { @@ -52,24 +51,22 @@ export class WebSocketHandler { } } - private handleMakeMove(ws: any, message: MakeMoveMessage): void { + private handleMakeMove(ws: WS, message: MakeMoveMessage): void { const { row, col } = message; - const gameId = ws.data.gameId; - const playerId = ws.data.playerId; + const {gameId, playerId} = ws.data.query; console.log(`Handling make_move message in game ${gameId} from player ${playerId}: ${{message}}`); if (!gameId || !playerId || row === undefined || col === undefined) { this.sendMessage( - gameId, - 'Error: missing gameId, playerId, row, or col', ws, + 'Error: missing gameId, playerId, row, or col', ); return; } const game = this.games.get(gameId); if (!game) { - this.sendMessage(gameId, 'Error: game not found', ws); + this.sendMessage(ws, 'Error: game not found'); return; } @@ -78,15 +75,14 @@ export class WebSocketHandler { )?.[0] as ('black' | 'white') | undefined; if (!playerColor) { this.sendMessage( - gameId, - 'Error: you are not a player in this game', ws, + 'Error: you are not a player in this game', ); return; } if (game.currentPlayer !== playerColor) { - this.sendMessage(gameId, 'Error: It\'s not your turn', ws); + this.sendMessage(ws, "Error: It's not your turn"); return; } @@ -98,35 +94,34 @@ export class WebSocketHandler { `Move made in game ${game.id} by ${playerId}: (${row}, ${col})`, ); } else { - this.sendMessage(gameId, result.error || 'Error: invalid move', ws); + this.sendMessage(ws, result.error || 'Error: invalid move'); } } catch (e: any) { - this.sendMessage(gameId, 'Error: ' + e.message, ws); + this.sendMessage(ws, 'Error: ' + e.message); } } - public handleDisconnect(ws: any): void { - const gameId = ws.data.gameId; - const playerId = ws.data.playerId; + public handleDisconnect(ws: WS): void { + const {gameId, playerId} = ws.data.query; - if (gameId && playerId) { - const connectionsInGame = this.connections.get(gameId); - if (connectionsInGame) { - this.connections.set( - gameId, - connectionsInGame.filter((conn) => conn !== ws), - ); - if (this.connections.get(gameId)?.length === 0) { - this.connections.delete(gameId); - } - } - - if (this.connections.has(gameId)) { - // Notify remaining players about disconnect - this.sendMessage(gameId, 'message', `${playerId} disconnected.`); - } - console.log(`${playerId} disconnected from game ${gameId}`); + const connectionsInGame = this.connections.get(gameId); + if (!connectionsInGame) { + console.error(`Disconnecting WebSocket for player ${playerId} from game ${gameId}, but that game has no connections!`); + return; } + this.connections.set( + gameId, + connectionsInGame.filter((conn) => conn !== ws), + ); + if (this.connections.get(gameId)?.length === 0) { + this.connections.delete(gameId); + } + + if (this.connections.has(gameId)) { + // Notify remaining players about disconnect + this.sendMessageToGame(gameId, `${playerId} disconnected.`); + } + console.log(`${playerId} disconnected from game ${gameId}`); } public broadcastGameState(gameId: string): void { @@ -139,36 +134,33 @@ export class WebSocketHandler { 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 {gameId, playerId} = ws.data.query; - const updatedBoardHtml = renderGameBoardHtml(game, ws.data.playerId); + const updatedBoardHtml = renderGameBoardHtml(game, playerId); ws.send(updatedBoardHtml); const updatedPlayerInfoHtml = renderPlayerInfoHtml( game.id, - ws.data.playerId, + playerId, ); ws.send(updatedPlayerInfoHtml); if (game.status === 'finished') { if (game.winner === 'draw') { - this.sendMessage(gameId, 'Game ended in draw.'); + this.sendMessageToGame(gameId, 'Game ended in draw.'); } else if (game.winner) { - this.sendMessage(gameId, `${game.winner.toUpperCase()} wins!`); + this.sendMessageToGame(gameId, `${game.winner.toUpperCase()} wins!`); } } else if (game.status === 'playing') { const clientPlayerColor = Object.entries(game.players).find( - ([_, id]) => id === ws.data.playerId, + ([_, id]) => id === playerId, )?.[0] as ('black' | 'white') | undefined; if (game.currentPlayer && clientPlayerColor === game.currentPlayer) { - this.sendMessage(gameId, "It's your turn!", ws); + this.sendMessage(ws, "It's your turn!"); } else if (game.currentPlayer) { - this.sendMessage(gameId, `Waiting for ${game.currentPlayer}'s move.`, ws); + this.sendMessage(ws, `Waiting for ${game.currentPlayer}'s move.`); } } else if (game.status === 'waiting') { - this.sendMessage(gameId, 'Waiting for another player...', ws); + this.sendMessage(ws, 'Waiting for another player...'); } }); } else { @@ -176,12 +168,11 @@ export class WebSocketHandler { } } - public sendMessage( + public sendMessageToGame( gameId: string, message: string, - targetWs?: any, ): void { - const connections = targetWs ? [targetWs] : this.connections.get(gameId); + const connections = this.connections.get(gameId); if (connections) { connections.forEach((ws) => { ws.send('
' + message + '
') @@ -189,6 +180,13 @@ export class WebSocketHandler { } } + public sendMessage( + targetWs: WS, + message: string, + ): void { + targetWs.send('
' + message + '
') + } + public getGame(gameId: string): GameInstance | undefined { return this.games.get(gameId) } diff --git a/src/index.ts b/src/index.ts index bdc0ff4..d5bda5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ import { cookie } from '@elysiajs/cookie'; import { WebSocketHandler } from './game/WebSocketHandler'; import { GameInstance } from './game/GameInstance'; -// Initialize WebSocketHandler const wsHandler = new WebSocketHandler(); const app = new Elysia() @@ -30,48 +29,16 @@ const app = new Elysia() ws.close(); return; } - - wsHandler.handleConnection(ws, gameId, playerId); - - const game = wsHandler.getGame(gameId); - if (game) { - wsHandler.broadcastGameState(game.id); - let message = ''; - if (game.getPlayerCount() === 2 && game.status === 'playing') { - message = `${game.currentPlayer}'s turn.`; - } else if (game.getPlayerCount() === 1 && game.status === 'waiting') { - message = `You are ${playerId}. Waiting for another player to join.`; - } - wsHandler.sendMessage(game.id, message, ws); - } else { - ws.send( - JSON.stringify({ - type: 'error', - error: 'Game not found after WebSocket connection.', - }), - ); - ws.close(); - } - console.log(`WebSocket connected: Player ${playerId} for Game ${gameId}`); + wsHandler.handleConnection(ws); }, message(ws, message) { - let msgString: string; - if (message instanceof Buffer) { - msgString = message.toString(); - } else { - // Assuming it's always a stringified JSON for 'make_move' - msgString = message as string; - } - wsHandler.handleMessage(ws, msgString); + wsHandler.handleMessage(ws, message); }, close(ws) { wsHandler.handleDisconnect(ws); }, }) - .get('/', async ({ query, cookie, request }) => { - const htmlTemplate = await Bun.file('/home/sepia/gomoku/index.html').text(); - const urlGameId = query.gameId as string | undefined; - + .get('/', async ({ query, cookie, request: _request }) => { let playerId: string; const existingPlayerId = cookie.playerId?.value; if (existingPlayerId) { @@ -88,6 +55,7 @@ const app = new Elysia() console.log(`Generated new playerId and set cookie: ${playerId}`); } + const urlGameId = query.gameId as string | undefined; let game: GameInstance; if (urlGameId) { let existingGame = wsHandler.getGame(urlGameId); @@ -106,6 +74,7 @@ const app = new Elysia() game.addPlayer(playerId); wsHandler.broadcastGameState(game.id); + const htmlTemplate = await Bun.file('./index.html').text(); let finalHtml = htmlTemplate .replace( '', @@ -114,10 +83,6 @@ const app = new Elysia() .replace( '', ``, - ) - .replace( - ``, - ``, ); return new Response(finalHtml, {