diff --git a/bun.lockb b/bun.lockb index 0a3de14..ae867a0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/favicon.ico b/favicon.ico new file mode 100755 index 0000000..02cc8f9 Binary files /dev/null and b/favicon.ico differ diff --git a/index.html b/index.html index f6923ed..20d1740 100644 --- a/index.html +++ b/index.html @@ -4,10 +4,19 @@ Gomoku Game + + + + -
-

Gomoku

+
-
-
+
+
- + +
+ Connecting... +
+ diff --git a/package.json b/package.json index a10719c..725c031 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "gomoku", "version": "1.0.50", "dependencies": { + "@elysiajs/cookie": "^0.8.0", "@elysiajs/static": "^1.3.0", "elysia": "latest", "uuid": "^11.1.0" diff --git a/src/client-entry.ts b/src/client-entry.ts index f4a13fb..9dfd992 100644 --- a/src/client-entry.ts +++ b/src/client-entry.ts @@ -1,167 +1,22 @@ -// src/client-entry.ts - -import { WebSocketClient } from './game-client/WebSocketClient'; -import { - GameStateManager, - GameStateType, -} from './game-client/GameStateManager'; -import { GameBoardUI } from './game-client/GameBoardUI'; - -console.log('Gomoku client entry point loaded.'); +console.log('Gomoku client entry point -- HTMX mode.'); const WS_URL = process.env.WS_URL || 'ws://localhost:3000/ws'; -// Function to get a query parameter from the URL -function getQueryParam(name: string): string | null { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get(name); -} +// 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 -// Get gameId from URL, if present -const gameIdFromUrl = getQueryParam('gameId'); - -let playerId: string; // Declare playerId here, accessible throughout the module - -// Initialize components -const gameStateManager = new GameStateManager(); -const wsClient = new WebSocketClient(WS_URL); - -let gameBoardUI: GameBoardUI; - -const gameBoardElement = document.getElementById('game-board'); -console.log('gameBoardElement: ', gameBoardElement); // Log to check if element is found - -const messagesElement = document.getElementById('messages'); -const playerInfoElement = document.getElementById('player-info'); - -if (!gameBoardElement || !messagesElement || !playerInfoElement) { - console.error( - 'Missing essential DOM elements (game-board, messages, or player-info)', - ); - throw new Error( - 'Missing essential DOM elements (game-board, messages, or player-info)', - ); -} -wsClient.onMessage((message) => { - try { - const msg = JSON.parse(message); - console.log('Parsed message:', msg); - - switch (msg.type) { - case 'game_state': - gameStateManager.updateGameState(msg.state as GameStateType); - gameBoardUI.updateBoard(gameStateManager.getGameState()); - console.log('Game state updated: ', gameStateManager.getGameState()); - - // Update player info with game ID and shareable link - if (playerInfoElement && msg.state.id) { - const gameLink = `${window.location.origin}/?gameId=${msg.state.id}`; - playerInfoElement.innerHTML = `You are: ${playerId}
Game ID: ${msg.state.id}
Share this link: ${gameLink}`; - } - break; - case 'move_result': - if (msg.success) { - console.log('Move successful!'); - } else { - console.error(`Move failed: ${msg.error}`); - gameStateManager.rollbackGameState(); - gameBoardUI.updateBoard(gameStateManager.getGameState()); // Re-render after rollback - } - break; - case 'player_joined': - console.log(`${msg.playerId} joined the game.`); - break; - case 'player_disconnected': - console.log(`${msg.playerId} disconnected.`); - break; - case 'ping': - break; - default: - console.log(`Unknown message type: ${msg.type}`); +// 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.'); } - } catch (e) { - console.error( - 'Error parsing WebSocket message:', - e, - 'Message was:', - message, - ); + } else { + console.error('Game container not found.'); } -}); - -// GameBoardUI -> WebSocketClient (for making moves) -// This will be set up inside wsClient.onOpen - -// Initial board render (empty board until server sends state) -// This initial render is no longer needed as updateBoard is called within onOpen and onMessage - -// Initial setup for player info -if (playerInfoElement) { - playerInfoElement.textContent = `You are: (Connecting...)`; -} -wsClient.onOpen(() => { - console.log('Connected to game server.'); - playerId = `player-${Math.random().toString(36).substring(2, 9)}`; - - gameBoardUI = new GameBoardUI(gameBoardElement, playerId); - console.log('GameBoardUI initialized.', gameBoardUI); // Log to confirm GameBoardUI construction - - const joinMessage: any = { - type: 'join_game', - playerId: playerId, - }; - - if (gameIdFromUrl) { - joinMessage.gameId = gameIdFromUrl; - } - wsClient.send(JSON.stringify(joinMessage)); - if (playerInfoElement) { - playerInfoElement.textContent = `You are: ${playerId} (Waiting for game state...)`; - } - - // Initial board render (empty board until server sends state) - gameBoardUI.updateBoard(gameStateManager.getGameState()); - - gameBoardUI.setOnCellClick((row, col) => { - const moveMessage = { - type: 'make_move', - row: row, - col: col, - }; - console.log('Sending move:', moveMessage); - wsClient.send(JSON.stringify(moveMessage)); - - // Optimistic Update: Apply the move to local state immediately - const currentGameState = gameStateManager.getGameState(); - const nextPlayer = - currentGameState.currentPlayer === 'black' ? 'white' : 'black'; - const newBoard = currentGameState.board.map((rowArr) => [...rowArr]); // Deep copy board - newBoard[row][col] = currentGameState.currentPlayer; // Place stone optimistically - - const optimisticState: GameStateType = { - ...currentGameState, - board: newBoard, - currentPlayer: nextPlayer, // Optimistically switch turn - }; - gameStateManager.updateGameState(optimisticState); - gameBoardUI.updateBoard(gameStateManager.getGameState()); - }); -}); - -wsClient.onClose(() => { - console.log('Disconnected from game server. Attempting to reconnect...'); -}); - -wsClient.onError((error: Event) => { - console.error( - `WebSocket error: ${error instanceof ErrorEvent ? error.message : String(error)}`, - ); -}); - -// --- Start Connection --- -wsClient.connect(); - -// Initial setup for player info -if (playerInfoElement) { - playerInfoElement.textContent = `You are: (Connecting...)`; -} +}; diff --git a/src/game-client/GameBoardUI.ts b/src/game-client/GameBoardUI.ts deleted file mode 100644 index ec5d627..0000000 --- a/src/game-client/GameBoardUI.ts +++ /dev/null @@ -1,105 +0,0 @@ -// src/game-client/GameBoardUI.ts - -import { GameStateType } from './GameStateManager'; - -export class GameBoardUI { - private boardElement: HTMLElement; - private cells: HTMLElement[][] = []; - private onCellClickCallback: ((row: number, col: number) => void) | null = - null; - private isInteractionEnabled: boolean = true; - private thisClientPlayerId: string; - - constructor(boardElement: HTMLElement, thisClientPlayerId: string) { - this.boardElement = boardElement; - this.thisClientPlayerId = thisClientPlayerId; - this.initializeBoard(); - } - - private initializeBoard(): void { - this.boardElement.innerHTML = ''; // Clear existing content - this.boardElement.style.display = 'grid'; - this.boardElement.style.gridTemplateColumns = 'repeat(15, 1fr)'; - this.boardElement.style.width = '450px'; // 15*30px, assuming cell size - this.boardElement.style.height = '450px'; - this.boardElement.style.border = '1px solid black'; - - for (let row = 0; row < 15; row++) { - this.cells[row] = []; - for (let col = 0; col < 15; col++) { - const cell = document.createElement('div'); - cell.classList.add('board-cell'); - cell.style.width = '30px'; - cell.style.height = '30px'; - cell.style.border = '1px solid #ccc'; - cell.style.boxSizing = 'border-box'; - cell.style.display = 'flex'; - cell.style.justifyContent = 'center'; - cell.style.alignItems = 'center'; - cell.dataset.row = row.toString(); - cell.dataset.col = col.toString(); - cell.addEventListener('click', () => this.handleCellClick(row, col)); - this.boardElement.appendChild(cell); - this.cells[row][col] = cell; - } - } - } - - public updateBoard(gameState: GameStateType): void { - const board = gameState.board; - const lastMove = { row: -1, col: -1 }; // Placeholder for last move highlighting (needs actual last move from state) - - const thisClientColor = Object.entries(gameState.players).find(([color, id]) => id === this.thisClientPlayerId)?.[0] || null; - - for (let row = 0; row < 15; row++) { - for (let col = 0; col < 15; col++) { - const cell = this.cells[row][col]; - cell.innerHTML = ''; // Clear previous stone - - const stone = board[row][col]; - if (stone) { - const stoneElement = document.createElement('div'); - stoneElement.style.width = '24px'; - stoneElement.style.height = '24px'; - stoneElement.style.borderRadius = '50%'; - stoneElement.style.backgroundColor = - stone === 'black' ? 'black' : 'white'; - stoneElement.style.border = '1px solid #333'; - cell.appendChild(stoneElement); - } - - // Remove highlight from previous last moves - cell.classList.remove('last-move'); - } - } - - // Apply highlight to the last move (this would require 'lastMove' to be part of GameStateType) - // if (lastMove.row !== -1) { - // this.cells[lastMove.row][lastMove.col].classList.add('last-move'); - // } - - // Disable interaction if it's not our turn or game is over - // This logic needs to know which player 'we' are, and the current player from gameState - this.isInteractionEnabled = - gameState.status === 'playing' && gameState.currentPlayer === (thisClientColor as 'black' | 'white'); - this.boardElement.style.pointerEvents = this.isInteractionEnabled - ? 'auto' - : 'none'; - this.boardElement.style.opacity = this.isInteractionEnabled ? '1' : '0.7'; - - // Update turn indicator and status (these elements would need to be passed in or managed by a parent UI component) - console.log( - `Current Player: ${gameState.currentPlayer}, Status: ${gameState.status}`, - ); - } - - public setOnCellClick(callback: (row: number, col: number) => void): void { - this.onCellClickCallback = callback; - } - - private handleCellClick(row: number, col: number): void { - if (this.isInteractionEnabled && this.onCellClickCallback) { - this.onCellClickCallback(row, col); - } - } -} diff --git a/src/game/GameInstance.ts b/src/game/GameInstance.ts index 488fff5..d57aa0b 100644 --- a/src/game/GameInstance.ts +++ b/src/game/GameInstance.ts @@ -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), ); diff --git a/src/game/GameManager.ts b/src/game/GameManager.ts index a7c7a5d..a575857 100644 --- a/src/game/GameManager.ts +++ b/src/game/GameManager.ts @@ -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; } diff --git a/src/game/WebSocketHandler.test.ts b/src/game/WebSocketHandler.test.ts index 8dd35c8..e8fe83f 100644 --- a/src/game/WebSocketHandler.test.ts +++ b/src/game/WebSocketHandler.test.ts @@ -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; + 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: 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('
{ - 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('
{ + 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('
{ 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('
(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('
{ + 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('
>; // Use 'any' for the specific Elysia WS object for now + private games: Map; - private connections: Map>; // 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('
' + message + '
') }); } - 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; } } diff --git a/src/index.ts b/src/index.ts index ccdd9ed..16a8e2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,55 +1,143 @@ -import { Elysia } from 'elysia'; +import { Elysia, t } from 'elysia'; import { staticPlugin } from '@elysiajs/static'; +import { cookie } from '@elysiajs/cookie'; import { WebSocketHandler } from './game/WebSocketHandler'; -import { GameManager } from './game/GameManager'; -import { GameInstance } from './game/GameInstance'; // Make sure GameInstance is accessible if GameInstance.addPlayer is used directly +import { GameInstance } from './game/GameInstance'; -// Initialize GameManager (server-side) -const gameManager = new GameManager(); - -// Initialize WebSocketHandler with the gameManager -const wsHandler = new WebSocketHandler(gameManager); +// Initialize WebSocketHandler +const wsHandler = new WebSocketHandler(); const app = new Elysia() .use( staticPlugin({ - assets: 'dist', // Serve static files from the dist directory - prefix: '/dist', // Serve them under the /dist path + assets: 'dist', + prefix: '/dist', }), ) + .use( + staticPlugin({ + assets: '.', + }), + ) + .use(cookie()) .ws('/ws', { + query: t.Object({ + gameId: t.String(), + playerId: t.String(), + }), open(ws) { - // Call the handler's connection logic - // Elysia's ws context directly provides the ws object - wsHandler.handleConnection(ws as any, {}); + const { gameId, playerId } = ws.data.query; + + if (!gameId || !playerId) { + console.error( + 'WebSocket connection missing gameId or playerId in query params.', + ); + ws.send( + JSON.stringify({ + type: 'error', + error: 'Missing gameId or playerId.', + }), + ); + 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', 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}`); }, message(ws, message) { let msgString: string; if (message instanceof Buffer) { msgString = message.toString(); - } else if (typeof message === 'object') { - // If Elysia already parsed it to an object, stringify it - msgString = JSON.stringify(message); } else { + // Assuming it's always a stringified JSON for 'make_move' msgString = message as string; } - wsHandler.handleMessage(ws as any, msgString); + wsHandler.handleMessage(ws, msgString); }, close(ws) { - // Call the handler's disconnection logic - wsHandler.handleDisconnect(ws as any); - }, - error(context: any) { - // Call the handler's error logic - wsHandler.handleError(context.ws as any, context.error); + wsHandler.handleDisconnect(ws); }, }) - .get('/', () => Bun.file('index.html')); + .get('/', async ({ query, cookie, request }) => { + const htmlTemplate = await Bun.file('/home/sepia/gomoku/index.html').text(); + const urlGameId = query.gameId as string | undefined; + + let playerId: string; + const existingPlayerId = cookie.playerId?.value; + if (existingPlayerId) { + playerId = existingPlayerId; + console.log(`Using existing playerId from cookie: ${playerId}`); + } else { + playerId = `player-${Math.random().toString(36).substring(2, 9)}`; + cookie.playerId.set({ + value: playerId, + httpOnly: true, + path: '/', + maxAge: 30 * 24 * 60 * 60, + }); + console.log(`Generated new playerId and set cookie: ${playerId}`); + } + + let game: GameInstance; + if (urlGameId) { + let existingGame = wsHandler.getGame(urlGameId); + if (existingGame) { + game = existingGame; + console.log(`Found existing game: ${urlGameId}`); + } else { + game = wsHandler.createGame(urlGameId); + console.log(`Created new game with provided ID: ${urlGameId}`); + } + } else { + game = wsHandler.createGame(); + console.log(`Created new game without specific ID: ${game.id}`); + } + + game.addPlayer(playerId); + wsHandler.broadcastGameState(game.id); + + let finalHtml = htmlTemplate + .replace( + '', + ``, + ) + .replace( + '', + ``, + ) + .replace( + ``, + ``, + ); + + return new Response(finalHtml, { + headers: { 'Content-Type': 'text/html' }, + status: 200, + }); + }); app.listen(3000, () => { console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`, ); }); - -console.log('Elysia server started!'); diff --git a/src/view/board-renderer.ts b/src/view/board-renderer.ts new file mode 100644 index 0000000..82181cc --- /dev/null +++ b/src/view/board-renderer.ts @@ -0,0 +1,55 @@ +import { GameInstance } from '../game/GameInstance'; + +export type GameStateType = Pick< + GameInstance, + 'id' | 'board' | 'currentPlayer' | 'status' | 'winner' | 'players' +>; + +export function renderGameBoardHtml( + gameState: GameStateType, + playerId: string, +): string { + let boardHtml = '
'; + + const currentPlayerColor = + Object.entries(gameState.players).find( + ([_, id]) => id === playerId, + )?.[0] || null; + const isPlayersTurn = + gameState.status === 'playing' && + gameState.currentPlayer === currentPlayerColor; + + for (let row = 0; row < gameState.board.length; row++) { + for (let col = 0; col < gameState.board[row].length; col++) { + const stone = gameState.board[row][col]; + const cellId = `cell-${row}-${col}`; + let stoneHtml = ''; + if (stone) { + const color = stone === 'black' ? 'black' : 'white'; + stoneHtml = `
`; + } + + // HTMX attributes for making a move + const wsAttrs = isPlayersTurn && !stone + ? `ws-send="click"` + : ''; + + boardHtml += ` +
+ ${stoneHtml} +
`; + } + } + boardHtml += `
`; + return boardHtml; +} + +export function renderPlayerInfoHtml(gameId: string, playerId: string): string { + return `
You are: ${playerId}
Game ID: ${gameId}
`; +}