From 8eabbe32117ba9e770b708885a93566957d7c259 Mon Sep 17 00:00:00 2001 From: sepia Date: Fri, 18 Jul 2025 00:04:32 -0500 Subject: [PATCH] Refactor frontend to use HTMX, and do most rendering serverside --- bun.lockb | Bin 133558 -> 135470 bytes favicon.ico | Bin 0 -> 67646 bytes index.html | 127 +++++- package.json | 1 + src/client-entry.ts | 177 +-------- src/game-client/GameBoardUI.ts | 105 ----- src/game/GameInstance.ts | 4 +- src/game/GameManager.ts | 5 +- src/game/WebSocketHandler.test.ts | 614 +++++++++++++++--------------- src/game/WebSocketHandler.ts | 286 ++++++-------- src/index.ts | 140 +++++-- src/view/board-renderer.ts | 55 +++ 12 files changed, 739 insertions(+), 775 deletions(-) create mode 100755 favicon.ico delete mode 100644 src/game-client/GameBoardUI.ts create mode 100644 src/view/board-renderer.ts diff --git a/bun.lockb b/bun.lockb index 0a3de1469cccfa106b7eb1b5dc593057806efe91..ae867a03b17d9c27515fff5c24b1e09393286b05 100755 GIT binary patch delta 27052 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 S{*bZhIo-3n$8-8;gZ>u*$y!tZ diff --git a/favicon.ico b/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..02cc8f9bb937857a8d3fec1b2e5c22e4730bca35 GIT binary patch literal 67646 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 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}
`; +}