From e8e982c3d6cb94a0d8770506894111e979bcaddb Mon Sep 17 00:00:00 2001 From: sepia Date: Tue, 15 Jul 2025 18:07:48 -0500 Subject: [PATCH] Get the client to a point where it at least renders the board --- bun.lockb | Bin 132472 -> 133558 bytes dist/bundle.js | 307 +++++++++++++++++ dist/client-entry.js | 305 +++++++++++++++++ index.html | 72 ++++ justfile | 2 +- package.json | 1 + src/client-entry.ts | 139 ++++++++ src/game-client/GameBoardUI.ts | 103 ++++++ src/game-client/GameStateManager.test.ts | 153 ++++----- src/game-client/GameStateManager.ts | 80 ++--- src/game-client/WebSocketClient.test.ts | 71 ++-- src/game-client/WebSocketClient.ts | 5 +- src/game/WebSocketHandler.test.ts | 400 ++++++++++++----------- src/game/WebSocketHandler.ts | 35 +- src/index.ts | 57 +++- 15 files changed, 1364 insertions(+), 366 deletions(-) create mode 100644 dist/bundle.js create mode 100644 dist/client-entry.js create mode 100644 index.html create mode 100644 src/client-entry.ts create mode 100644 src/game-client/GameBoardUI.ts diff --git a/bun.lockb b/bun.lockb index 70a9c2ad101084c20bf10adc241b8800a3e20012..0a3de1469cccfa106b7eb1b5dc593057806efe91 100755 GIT binary patch delta 25225 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 a#%C|-waS?v-AFTOB(GpE=P%bw4*Ng+<|Up0 delta 25097 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

0MNz&#SvtLbnuGr><&RH?J$V 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 diff --git a/dist/bundle.js b/dist/bundle.js new file mode 100644 index 0000000..4cdf4ea --- /dev/null +++ b/dist/bundle.js @@ -0,0 +1,307 @@ +// src/game-client/WebSocketClient.ts +class WebSocketClient { + ws = null; + url; + messageQueue = []; + isConnected = false; + reconnectCount = 0; + options; + manualClose = false; + onMessageHandler = () => {}; + onOpenHandler = () => {}; + onCloseHandler = () => {}; + onErrorHandler = () => {}; + constructor(url, options) { + this.url = url; + this.options = { + reconnectAttempts: 5, + reconnectInterval: 3000, + ...options, + }; + } + connect() { + this.manualClose = false; + this.ws = new WebSocket(this.url); + this.ws.onopen = this.handleOpen.bind(this); + this.ws.onmessage = this.handleMessage.bind(this); + this.ws.onclose = this.handleClose.bind(this); + this.ws.onerror = this.handleError.bind(this); + } + send(message) { + if (this.isConnected && this.ws) { + this.ws.send(message); + } else { + this.messageQueue.push(message); + } + } + close() { + this.manualClose = true; + if (this.ws) { + this.ws.close(); + } + } + onMessage(handler) { + this.onMessageHandler = handler; + } + onOpen(handler) { + this.onOpenHandler = handler; + } + onClose(handler) { + this.onCloseHandler = handler; + } + onError(handler) { + this.onErrorHandler = handler; + } + handleOpen() { + this.isConnected = true; + this.reconnectCount = 0; + this.onOpenHandler(); + this.flushMessageQueue(); + } + handleMessage(event) { + this.onMessageHandler(event.data); + } + handleClose(event) { + this.isConnected = false; + this.onCloseHandler(event.code, event.reason); + if ( + !this.manualClose && + this.reconnectCount < this.options.reconnectAttempts + ) { + this.reconnectCount++; + setTimeout(() => this.connect(), this.options.reconnectInterval); + } + } + handleError(event) { + this.onErrorHandler(event); + } + flushMessageQueue() { + while (this.messageQueue.length > 0 && this.isConnected && this.ws) { + const message = this.messageQueue.shift(); + if (message) { + this.ws.send(message); + } + } + } +} + +// src/game-client/GameStateManager.ts +class GameStateManager { + gameState; + stateHistory; + constructor() { + this.gameState = this.getDefaultGameState(); + this.stateHistory = []; + } + getDefaultGameState() { + const emptyBoard = Array(15) + .fill(null) + .map(() => Array(15).fill(null)); + return { + id: '', + board: emptyBoard, + currentPlayer: 'black', + status: 'waiting', + winner: null, + players: {}, + }; + } + getGameState() { + return this.gameState; + } + updateGameState(newState) { + this.stateHistory.push(JSON.parse(JSON.stringify(this.gameState))); + this.gameState = newState; + } + rollbackGameState() { + if (this.stateHistory.length > 0) { + this.gameState = this.stateHistory.pop(); + } else { + console.warn('No previous state to rollback to.'); + } + } +} + +// src/game-client/GameBoardUI.ts +class GameBoardUI { + boardElement; + cells = []; + onCellClickCallback = null; + isInteractionEnabled = true; + constructor(boardElement) { + this.boardElement = boardElement; + this.initializeBoard(); + } + initializeBoard() { + this.boardElement.innerHTML = ''; + this.boardElement.style.display = 'grid'; + this.boardElement.style.gridTemplateColumns = 'repeat(15, 1fr)'; + this.boardElement.style.width = '450px'; + 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; + } + } + } + updateBoard(gameState) { + const board = gameState.board; + const lastMove = { row: -1, col: -1 }; + for (let row = 0; row < 15; row++) { + for (let col = 0; col < 15; col++) { + const cell = this.cells[row][col]; + cell.innerHTML = ''; + 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); + } + cell.classList.remove('last-move'); + } + } + this.isInteractionEnabled = + gameState.status === 'playing' && gameState.currentPlayer === 'black'; + this.boardElement.style.pointerEvents = this.isInteractionEnabled + ? 'auto' + : 'none'; + this.boardElement.style.opacity = this.isInteractionEnabled ? '1' : '0.7'; + console.log( + `Current Player: ${gameState.currentPlayer}, Status: ${gameState.status}`, + ); + } + setOnCellClick(callback) { + this.onCellClickCallback = callback; + } + handleCellClick(row, col) { + if (this.isInteractionEnabled && this.onCellClickCallback) { + this.onCellClickCallback(row, col); + } + } +} + +// src/client-entry.ts +console.log('Gomoku client entry point loaded.'); +var WS_URL = 'ws://localhost:3000/ws'; +var gameStateManager = new GameStateManager(); +var wsClient = new WebSocketClient(WS_URL); +var gameBoardElement = document.getElementById('game-board'); +console.log('gameBoardElement: ', gameBoardElement); +var messagesElement = document.getElementById('messages'); +var 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)', + ); +} +var gameBoardUI = new GameBoardUI(gameBoardElement); +console.log('GameBoardUI initialized.', gameBoardUI); +wsClient.onMessage((message) => { + try { + const msg = JSON.parse(message); + console.log('Parsed message:', msg); + switch (msg.type) { + case 'game_state': + gameStateManager.updateGameState(msg.state); + gameBoardUI.updateBoard(gameStateManager.getGameState()); + console.log('Game state updated: ', gameStateManager.getGameState()); + break; + case 'move_result': + if (msg.success) { + console.log('Move successful!'); + } else { + console.error(`Move failed: ${msg.error}`); + gameStateManager.rollbackGameState(); + gameBoardUI.updateBoard(gameStateManager.getGameState()); + } + 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}`); + } + } catch (e) { + console.error( + 'Error parsing WebSocket message:', + e, + 'Message was:', + message, + ); + } +}); +gameBoardUI.setOnCellClick((row, col) => { + const moveMessage = { + type: 'make_move', + row, + col, + }; + console.log('Sending move:', moveMessage); + wsClient.send(JSON.stringify(moveMessage)); + const currentGameState = gameStateManager.getGameState(); + const nextPlayer = + currentGameState.currentPlayer === 'black' ? 'white' : 'black'; + const newBoard = currentGameState.board.map((rowArr) => [...rowArr]); + newBoard[row][col] = currentGameState.currentPlayer; + const optimisticState = { + ...currentGameState, + board: newBoard, + currentPlayer: nextPlayer, + }; + gameStateManager.updateGameState(optimisticState); + gameBoardUI.updateBoard(gameStateManager.getGameState()); +}); +wsClient.onOpen(() => { + console.log('Connected to game server.'); + const playerId = `player-${Math.random().toString(36).substring(2, 9)}`; + const joinMessage = { + type: 'join_game', + gameId: 'some-game-id', + playerId, + }; + wsClient.send(JSON.stringify(joinMessage)); + if (playerInfoElement) { + playerInfoElement.textContent = `You are: ${playerId} (Waiting for game state...)`; + } +}); +wsClient.onClose(() => { + console.log('Disconnected from game server. Attempting to reconnect...'); +}); +wsClient.onError((error) => { + console.error( + `WebSocket error: ${error instanceof ErrorEvent ? error.message : String(error)}`, + ); +}); +wsClient.connect(); +gameBoardUI.updateBoard(gameStateManager.getGameState()); +if (playerInfoElement) { + playerInfoElement.textContent = `You are: (Connecting...)`; +} diff --git a/dist/client-entry.js b/dist/client-entry.js new file mode 100644 index 0000000..3345011 --- /dev/null +++ b/dist/client-entry.js @@ -0,0 +1,305 @@ +// src/game-client/WebSocketClient.ts +class WebSocketClient { + ws = null; + url; + messageQueue = []; + isConnected = false; + reconnectCount = 0; + options; + manualClose = false; + onMessageHandler = () => {}; + onOpenHandler = () => {}; + onCloseHandler = () => {}; + onErrorHandler = () => {}; + constructor(url, options) { + this.url = url; + this.options = { + reconnectAttempts: 5, + reconnectInterval: 3000, + ...options, + }; + } + connect() { + this.manualClose = false; + this.ws = new WebSocket(this.url); + this.ws.onopen = this.handleOpen.bind(this); + this.ws.onmessage = this.handleMessage.bind(this); + this.ws.onclose = this.handleClose.bind(this); + this.ws.onerror = this.handleError.bind(this); + } + send(message) { + if (this.isConnected && this.ws) { + this.ws.send(message); + } else { + this.messageQueue.push(message); + } + } + close() { + this.manualClose = true; + if (this.ws) { + this.ws.close(); + } + } + onMessage(handler) { + this.onMessageHandler = handler; + } + onOpen(handler) { + this.onOpenHandler = handler; + } + onClose(handler) { + this.onCloseHandler = handler; + } + onError(handler) { + this.onErrorHandler = handler; + } + handleOpen() { + this.isConnected = true; + this.reconnectCount = 0; + this.onOpenHandler(); + this.flushMessageQueue(); + } + handleMessage(event) { + this.onMessageHandler(event.data); + } + handleClose(event) { + this.isConnected = false; + this.onCloseHandler(event.code, event.reason); + if ( + !this.manualClose && + this.reconnectCount < this.options.reconnectAttempts + ) { + this.reconnectCount++; + setTimeout(() => this.connect(), this.options.reconnectInterval); + } + } + handleError(event) { + this.onErrorHandler(event); + } + flushMessageQueue() { + while (this.messageQueue.length > 0 && this.isConnected && this.ws) { + const message = this.messageQueue.shift(); + if (message) { + this.ws.send(message); + } + } + } +} + +// src/game-client/GameStateManager.ts +class GameStateManager { + gameState; + stateHistory; + constructor() { + this.gameState = this.getDefaultGameState(); + this.stateHistory = []; + } + getDefaultGameState() { + const emptyBoard = Array(15) + .fill(null) + .map(() => Array(15).fill(null)); + return { + id: '', + board: emptyBoard, + currentPlayer: 'black', + status: 'waiting', + winner: null, + players: {}, + }; + } + getGameState() { + return this.gameState; + } + updateGameState(newState) { + this.stateHistory.push(JSON.parse(JSON.stringify(this.gameState))); + this.gameState = newState; + } + rollbackGameState() { + if (this.stateHistory.length > 0) { + this.gameState = this.stateHistory.pop(); + } else { + console.warn('No previous state to rollback to.'); + } + } +} + +// src/game-client/GameBoardUI.ts +class GameBoardUI { + boardElement; + cells = []; + onCellClickCallback = null; + isInteractionEnabled = true; + constructor(boardElement) { + this.boardElement = boardElement; + this.initializeBoard(); + } + initializeBoard() { + this.boardElement.innerHTML = ''; + this.boardElement.style.display = 'grid'; + this.boardElement.style.gridTemplateColumns = 'repeat(15, 1fr)'; + this.boardElement.style.width = '450px'; + 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; + } + } + } + updateBoard(gameState) { + const board = gameState.board; + const lastMove = { row: -1, col: -1 }; + for (let row = 0; row < 15; row++) { + for (let col = 0; col < 15; col++) { + const cell = this.cells[row][col]; + cell.innerHTML = ''; + 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); + } + cell.classList.remove('last-move'); + } + } + this.isInteractionEnabled = + gameState.status === 'playing' && gameState.currentPlayer === 'black'; + this.boardElement.style.pointerEvents = this.isInteractionEnabled + ? 'auto' + : 'none'; + this.boardElement.style.opacity = this.isInteractionEnabled ? '1' : '0.7'; + console.log( + `Current Player: ${gameState.currentPlayer}, Status: ${gameState.status}`, + ); + } + setOnCellClick(callback) { + this.onCellClickCallback = callback; + } + handleCellClick(row, col) { + if (this.isInteractionEnabled && this.onCellClickCallback) { + this.onCellClickCallback(row, col); + } + } +} + +// src/client-entry.ts +console.log('Gomoku client entry point loaded.'); +var WS_URL = process.env.WS_URL || 'ws://localhost:3000/ws'; +var gameStateManager = new GameStateManager(); +var wsClient = new WebSocketClient(WS_URL); +var gameBoardElement = document.getElementById('game-board'); +console.log('gameBoardElement: ', gameBoardElement); +var messagesElement = document.getElementById('messages'); +var 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)', + ); +} +var gameBoardUI = new GameBoardUI(gameBoardElement); +console.log('GameBoardUI initialized.', gameBoardUI); +wsClient.onMessage((message) => { + try { + const msg = JSON.parse(message); + console.log('Parsed message:', msg); + switch (msg.type) { + case 'game_state': + gameStateManager.updateGameState(msg.state); + gameBoardUI.updateBoard(gameStateManager.getGameState()); + console.log('Game state updated: ', gameStateManager.getGameState()); + break; + case 'move_result': + if (msg.success) { + console.log('Move successful!'); + } else { + console.error(`Move failed: ${msg.error}`); + gameStateManager.rollbackGameState(); + gameBoardUI.updateBoard(gameStateManager.getGameState()); + } + 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}`); + } + } catch (e) { + console.error( + 'Error parsing WebSocket message:', + e, + 'Message was:', + message, + ); + } +}); +gameBoardUI.setOnCellClick((row, col) => { + const moveMessage = { + type: 'make_move', + row, + col, + }; + console.log('Sending move:', moveMessage); + wsClient.send(JSON.stringify(moveMessage)); + const currentGameState = gameStateManager.getGameState(); + const nextPlayer = + currentGameState.currentPlayer === 'black' ? 'white' : 'black'; + const newBoard = currentGameState.board.map((rowArr) => [...rowArr]); + newBoard[row][col] = currentGameState.currentPlayer; + const optimisticState = { + ...currentGameState, + board: newBoard, + currentPlayer: nextPlayer, + }; + gameStateManager.updateGameState(optimisticState); + gameBoardUI.updateBoard(gameStateManager.getGameState()); +}); +wsClient.onOpen(() => { + console.log('Connected to game server.'); + const playerId = `player-${Math.random().toString(36).substring(2, 9)}`; + const joinMessage = { + type: 'join_game', + gameId: 'some-game-id', + playerId, + }; + wsClient.send(JSON.stringify(joinMessage)); + if (playerInfoElement) { + playerInfoElement.textContent = `You are: ${playerId} (Waiting for game state...)`; + } +}); +wsClient.onClose(() => { + console.log('Disconnected from game server. Attempting to reconnect...'); +}); +wsClient.onError((error) => { + console.error(`WebSocket error: ${error.message}`); +}); +wsClient.connect(); +gameBoardUI.updateBoard(gameStateManager.getGameState()); +if (playerInfoElement) { + playerInfoElement.textContent = `You are: (Connecting...)`; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..f6923ed --- /dev/null +++ b/index.html @@ -0,0 +1,72 @@ + + + + + + Gomoku Game + + + +

+

Gomoku

+
+
+
+
+ + + diff --git a/justfile b/justfile index d44198d..65a2f12 100644 --- a/justfile +++ b/justfile @@ -10,7 +10,7 @@ dev: # Build the project build: - bun run build + bun build src/client-entry.ts --outfile dist/bundle.js --define "process.env.WS_URL='ws://localhost:3000/ws'" # Run tests test: diff --git a/package.json b/package.json index a5d95b4..a10719c 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "gomoku", "version": "1.0.50", "dependencies": { + "@elysiajs/static": "^1.3.0", "elysia": "latest", "uuid": "^11.1.0" }, diff --git a/src/client-entry.ts b/src/client-entry.ts new file mode 100644 index 0000000..5504521 --- /dev/null +++ b/src/client-entry.ts @@ -0,0 +1,139 @@ +// 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.'); + +const WS_URL = process.env.WS_URL || 'ws://localhost:3000/ws'; + +// Initialize components +const gameStateManager = new GameStateManager(); +const wsClient = new WebSocketClient(WS_URL); + +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)', + ); +} + +const gameBoardUI = new GameBoardUI(gameBoardElement); +console.log('GameBoardUI initialized.', gameBoardUI); // Log to confirm GameBoardUI construction + +// --- Event Handlers and Wiring --- + +// WebSocketClient -> GameStateManager -> GameBoardUI +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()); + 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}`); + } + } catch (e) { + console.error( + 'Error parsing WebSocket message:', + e, + 'Message was:', + message, + ); + } +}); + +// GameBoardUI -> WebSocketClient (for making moves) +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()); +}); + +// WebSocketClient connection status messages +wsClient.onOpen(() => { + console.log('Connected to game server.'); + const playerId = `player-${Math.random().toString(36).substring(2, 9)}`; + const joinMessage = { + type: 'join_game', + gameId: 'some-game-id', + playerId: playerId, + }; + wsClient.send(JSON.stringify(joinMessage)); + if (playerInfoElement) { + playerInfoElement.textContent = `You are: ${playerId} (Waiting for game state...)`; + } +}); + +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 board render (empty board until server sends state) +gameBoardUI.updateBoard(gameStateManager.getGameState()); +// 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 new file mode 100644 index 0000000..965ee79 --- /dev/null +++ b/src/game-client/GameBoardUI.ts @@ -0,0 +1,103 @@ +// 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; + + constructor(boardElement: HTMLElement) { + this.boardElement = boardElement; + 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) + + 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 + // For simplicity, let's assume 'black' is the client for now, and enable/disable + // based on if it's black's turn. This will need refinement for multi-player. + this.isInteractionEnabled = + gameState.status === 'playing' && gameState.currentPlayer === 'black'; // Simplified for now + 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-client/GameStateManager.test.ts b/src/game-client/GameStateManager.test.ts index 3a9fa64..0dd3126 100644 --- a/src/game-client/GameStateManager.test.ts +++ b/src/game-client/GameStateManager.test.ts @@ -1,90 +1,91 @@ - import { expect, test, describe, beforeEach, afterEach, mock } from 'bun:test'; -import { GameStateManager } from './GameStateManager'; +import { GameStateManager, GameStateType } from './GameStateManager'; describe('GameStateManager', () => { - let gameStateManager: GameStateManager; + let gameStateManager: GameStateManager; - beforeEach(() => { - // Initialize a fresh GameStateManager before each test - gameStateManager = new GameStateManager(); + beforeEach(() => { + // Initialize a fresh GameStateManager before each test + gameStateManager = new GameStateManager(); + }); + + test('should initialize with a default empty game state', () => { + const initialState = gameStateManager.getGameState(); + expect(initialState).toEqual({ + id: '', + board: Array(15).fill(Array(15).fill(null)), + currentPlayer: 'black', + status: 'waiting', + winner: null, + players: {}, }); + }); - test('should initialize with a default empty game state', () => { - const initialState = gameStateManager.getGameState(); - expect(initialState).toEqual({ - id: '', - board: Array(15).fill(Array(15).fill(null)), - currentPlayer: 'black', - status: 'waiting', - winner: null, - players: {}, - }); - }); + test('should update game state from server updates', () => { + const serverState = { + id: 'game123', + board: Array.from({ length: 15 }, () => Array(15).fill(null)), + currentPlayer: 'white' as 'black' | 'white', + status: 'playing' as 'waiting' | 'playing' | 'finished', + winner: null, + players: { black: 'playerA', white: 'playerB' }, + }; + gameStateManager.updateGameState(serverState); + expect(gameStateManager.getGameState()).toEqual(serverState); + }); - test('should update game state from server updates', () => { - const serverState = { - id: 'game123', - board: Array(15).fill(Array(15).fill(null)), - currentPlayer: 'white', - status: 'playing', - winner: null, - players: { black: 'playerA', white: 'playerB' }, - }; - gameStateManager.updateGameState(serverState); - expect(gameStateManager.getGameState()).toEqual(serverState); - }); + test('should handle optimistic updates for making a move', () => { + const initialBoard = Array.from({ length: 15 }, () => Array(15).fill(null)); + initialBoard[7][7] = 'black'; // Simulate an optimistic move - test('should handle optimistic updates for making a move', () => { - const initialBoard = Array(15).fill(Array(15).fill(null)); - initialBoard[7][7] = 'black'; // Simulate an optimistic move - - const optimisticState = { - id: 'game123', - board: initialBoard, - currentPlayer: 'white', // Turn changes optimistically - status: 'playing', - winner: null, - players: { black: 'playerA', white: 'playerB' }, - }; + const optimisticState = { + id: 'game123', + board: initialBoard, + currentPlayer: 'white' as 'black' | 'white', // Turn changes optimistically + status: 'playing' as 'waiting' | 'playing' | 'finished', + winner: null, + players: { black: 'playerA', white: 'playerB' }, + }; - gameStateManager.updateGameState(optimisticState); - expect(gameStateManager.getGameState().board[7][7]).toEqual('black'); - expect(gameStateManager.getGameState().currentPlayer).toEqual('white'); - }); + gameStateManager.updateGameState(optimisticState); + expect(gameStateManager.getGameState().board[7][7]).toEqual('black'); + expect(gameStateManager.getGameState().currentPlayer).toEqual('white'); + }); - test('should rollback optimistic updates if server rejects move', () => { - const initialServerState = { - id: 'game123', - board: Array(15).fill(Array(15).fill(null)), - currentPlayer: 'black', - status: 'playing', - winner: null, - players: { black: 'playerA', white: 'playerB' }, - }; - gameStateManager.updateGameState(initialServerState); + test('should rollback optimistic updates if server rejects move', () => { + const initialServerState = { + id: 'game123', + board: Array.from({ length: 15 }, () => Array(15).fill(null)), + currentPlayer: 'black' as 'black' | 'white', + status: 'playing' as 'waiting' | 'playing' | 'finished', + winner: null, + players: { black: 'playerA', white: 'playerB' }, + }; + gameStateManager.updateGameState(initialServerState); - // Optimistic update - const optimisticBoard = Array(15).fill(Array(15).fill(null)); - optimisticBoard[7][7] = 'black'; - const optimisticState = { - id: 'game123', - board: optimisticBoard, - currentPlayer: 'white', - status: 'playing', - winner: null, - players: { black: 'playerA', white: 'playerB' }, - }; - gameStateManager.updateGameState(optimisticState); + // Optimistic update + const optimisticBoard = Array.from({ length: 15 }, () => + Array(15).fill(null), + ); + optimisticBoard[7][7] = 'black'; + const optimisticState = { + id: 'game123', + board: optimisticBoard, + currentPlayer: 'white' as 'black' | 'white', + status: 'playing' as 'waiting' | 'playing' | 'finished', + winner: null, + players: { black: 'playerA', white: 'playerB' }, + }; + gameStateManager.updateGameState(optimisticState); - // Server rejection - rollback to initial state - gameStateManager.rollbackGameState(); // This method will be implemented - expect(gameStateManager.getGameState()).toEqual(initialServerState); - }); + // Server rejection - rollback to initial state + gameStateManager.rollbackGameState(); // This method will be implemented + expect(gameStateManager.getGameState()).toEqual(initialServerState); + }); - // Add more tests for: - // - Win conditions - // - Draw conditions - // - Invalid moves (already occupied, out of bounds - though this might be server-side validation primarily) - // - Player disconnection/reconnection behavior + // Add more tests for: + // - Win conditions + // - Draw conditions + // - Invalid moves (already occupied, out of bounds - though this might be server-side validation primarily) + // - Player disconnection/reconnection behavior }); diff --git a/src/game-client/GameStateManager.ts b/src/game-client/GameStateManager.ts index 74ff918..27b013c 100644 --- a/src/game-client/GameStateManager.ts +++ b/src/game-client/GameStateManager.ts @@ -1,50 +1,52 @@ export interface GameStateType { - id: string; - board: (null | 'black' | 'white')[][]; - currentPlayer: 'black' | 'white'; - status: 'waiting' | 'playing' | 'finished'; - winner: null | 'black' | 'white' | 'draw'; - players: { black?: string, white?: string }; + id: string; + board: (null | 'black' | 'white')[][]; + currentPlayer: 'black' | 'white'; + status: 'waiting' | 'playing' | 'finished'; + winner: null | 'black' | 'white' | 'draw'; + players: { black?: string; white?: string }; } export class GameStateManager { - private gameState: GameStateType; - private stateHistory: GameStateType[]; + private gameState: GameStateType; + private stateHistory: GameStateType[]; - constructor() { - this.gameState = this.getDefaultGameState(); - this.stateHistory = []; - } + constructor() { + this.gameState = this.getDefaultGameState(); + this.stateHistory = []; + } - private getDefaultGameState(): GameStateType { - const emptyBoard: (null | 'black' | 'white')[][] = Array(15).fill(null).map(() => Array(15).fill(null)); - return { - id: '', - board: emptyBoard, - currentPlayer: 'black', - status: 'waiting', - winner: null, - players: {}, - }; - } + private getDefaultGameState(): GameStateType { + const emptyBoard: (null | 'black' | 'white')[][] = Array(15) + .fill(null) + .map(() => Array(15).fill(null)); + return { + id: '', + board: emptyBoard, + currentPlayer: 'black', + status: 'waiting', + winner: null, + players: {}, + }; + } - getGameState(): GameStateType { - return this.gameState; - } + getGameState(): GameStateType { + return this.gameState; + } - updateGameState(newState: GameStateType): void { - // Store a deep copy of the current state before updating - // This is crucial for rollback to work correctly, as objects are passed by reference. - this.stateHistory.push(JSON.parse(JSON.stringify(this.gameState))); - this.gameState = newState; - } + updateGameState(newState: GameStateType): void { + // Store a deep copy of the current state before updating + // This is crucial for rollback to work correctly, as objects are passed by reference. + this.stateHistory.push(JSON.parse(JSON.stringify(this.gameState))); + this.gameState = newState; + } - rollbackGameState(): void { - if (this.stateHistory.length > 0) { - this.gameState = this.stateHistory.pop()!; - } else { - console.warn("No previous state to rollback to."); - // Optionally, throw an error or reset to default state here - } + rollbackGameState(): void { + if (this.stateHistory.length > 0) { + this.gameState = this.stateHistory.pop()!; + } else { + console.warn('No previous state to rollback to.'); + // Optionally, throw an error or reset to default state here } + } } diff --git a/src/game-client/WebSocketClient.test.ts b/src/game-client/WebSocketClient.test.ts index 8964f8d..7cecfef 100644 --- a/src/game-client/WebSocketClient.test.ts +++ b/src/game-client/WebSocketClient.test.ts @@ -3,10 +3,20 @@ import { WebSocketClient } from './WebSocketClient'; // Define MockWebSocket as a regular class class MockWebSocket { - onopen: ((this: WebSocket, ev: Event) => any) | null = null; - onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null; - onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null; - onerror: ((this: WebSocket, ev: Event) => any) | null = null; + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + constructor(url?: string) { + // In a real scenario, you might do something with the URL + // For this mock, we just need to accept it. + } + + onopen: ((this: any, ev: Event) => any) | null = null; + onmessage: ((this: any, ev: MessageEvent) => any) | null = null; + onclose: ((this: any, ev: CloseEvent) => any) | null = null; + onerror: ((this: any, ev: Event) => any) | null = null; readyState: number = 0; // 0: CONNECTING, 1: OPEN, 2: CLOSING, 3: CLOSED // Using a plain function for send/close to simplify. @@ -54,7 +64,7 @@ describe('WebSocketClient', () => { const url = 'ws://localhost:8080'; // Using a mock function to wrap the actual global WebSocket constructor - let globalWebSocketConstructorMock: ReturnType; + let globalWebSocketConstructorMock: typeof WebSocket; beforeEach(() => { // Clear instances and reset mocks before each test @@ -64,21 +74,16 @@ describe('WebSocketClient', () => { // creates a new MockWebSocket instance, pushes it to our global tracker, // and then spies on its send and close methods.< globalWebSocketConstructorMock = mock((url: string) => { - const instance = new MockWebSocket(url); - createdMockWebSockets.push(instance); - - // Wrap send and close methods in mock functions for call tracking - // We do this directly on the instance for each new WebSocket. - // Using mock() directly on the method reference, and binding 'this' - // to ensure it operates in the context of the instance. - (instance as any).send = mock(instance.send.bind(instance)); - (instance as any).close = mock(instance.close.bind(instance)); - - return instance; - }) as any; // Cast as any to satisfy TypeScript for global assignment + const instance = new MockWebSocket(url); + createdMockWebSockets.push(instance); + + (instance as any).send = mock(instance.send.bind(instance)); + (instance as any).close = mock(instance.close.bind(instance)); + + return instance; + }) as unknown as typeof WebSocket; global.WebSocket = globalWebSocketConstructorMock; - }); afterEach(() => { @@ -108,7 +113,7 @@ describe('WebSocketClient', () => { client = new WebSocketClient(url); client.onMessage(onMessageMock); client.connect(); - + createdMockWebSockets[0]._simulateOpen(); createdMockWebSockets[0]._simulateMessage('test message'); @@ -147,7 +152,7 @@ describe('WebSocketClient', () => { // Expect the mocked `send` method on the MockWebSocket instance to have been called expect(createdMockWebSockets[0].send).toHaveBeenCalledWith('hello'); }); - + it('should queue messages when disconnected and send them upon reconnection', async () => { client = new WebSocketClient(url, { reconnectInterval: 10 }); // Shorter interval for faster test const onOpenMock = mock(() => {}); @@ -159,39 +164,45 @@ describe('WebSocketClient', () => { // Simulate immediate disconnection before open firstWs._simulateClose(); - + // Send messages while disconnected, they should be queued client.send('queued message 1'); client.send('queued message 2'); - + // Simulate reconnection after a short delay - await new Promise(resolve => setTimeout(resolve, 20)); // Allow for reconnectInterval + await new Promise((resolve) => setTimeout(resolve, 20)); // Allow for reconnectInterval expect(createdMockWebSockets.length).toBe(2); // New instance created for reconnection createdMockWebSockets[1]._simulateOpen(); // Simulate new connection opening // Wait for messages to be flushed - await new Promise(resolve => setTimeout(resolve, 5)); + await new Promise((resolve) => setTimeout(resolve, 5)); - expect(createdMockWebSockets[1].send).toHaveBeenCalledWith('queued message 1'); - expect(createdMockWebSockets[1].send).toHaveBeenCalledWith('queued message 2'); + expect(createdMockWebSockets[1].send).toHaveBeenCalledWith( + 'queued message 1', + ); + expect(createdMockWebSockets[1].send).toHaveBeenCalledWith( + 'queued message 2', + ); expect(onOpenMock).toHaveBeenCalledTimes(1); // onOpen should be called on successful reconnection }); it('should not attempt to reconnect if explicitly closed', async () => { - client = new WebSocketClient(url, { reconnectAttempts: 3, reconnectInterval: 10 }); + client = new WebSocketClient(url, { + reconnectAttempts: 3, + reconnectInterval: 10, + }); const onCloseMock = mock(() => {}); client.onClose(onCloseMock); - + client.connect(); createdMockWebSockets[0]._simulateOpen(); client.close(); // Explicitly close // Allow some time for potential reconnect attempts. If no new WebSocket is created after the attempts would have happened, then we know it's not reconnecting. - await new Promise(resolve => setTimeout(resolve, 50)); // (reconnectAttempts * reconnectInterval) + buffer + await new Promise((resolve) => setTimeout(resolve, 50)); // (reconnectAttempts * reconnectInterval) + buffer expect(createdMockWebSockets.length).toBe(1); // Only the initial one expect(onCloseMock).toHaveBeenCalledTimes(1); // onClose should be called }); - }); diff --git a/src/game-client/WebSocketClient.ts b/src/game-client/WebSocketClient.ts index acdb03c..0a1c57e 100644 --- a/src/game-client/WebSocketClient.ts +++ b/src/game-client/WebSocketClient.ts @@ -85,7 +85,10 @@ export class WebSocketClient { private handleClose(event: CloseEvent): void { this.isConnected = false; this.onCloseHandler(event.code, event.reason); - if (!this.manualClose && this.reconnectCount < this.options.reconnectAttempts!) { + if ( + !this.manualClose && + this.reconnectCount < this.options.reconnectAttempts! + ) { this.reconnectCount++; setTimeout(() => this.connect(), this.options.reconnectInterval); } diff --git a/src/game/WebSocketHandler.test.ts b/src/game/WebSocketHandler.test.ts index d4b1569..8dd35c8 100644 --- a/src/game/WebSocketHandler.test.ts +++ b/src/game/WebSocketHandler.test.ts @@ -179,203 +179,229 @@ describe('WebSocketHandler', () => { ); }); }); - it('should notify other players and remove a disconnected player', () => { - const gameManager = new GameManager(); - const webSocketHandler = new WebSocketHandler(gameManager); +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 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, - }; + // 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 triggerMessageForWs = (ws: any, message: string) => { + if (ws._messageCallback) { + ws._messageCallback(message); + } + }; - const triggerCloseForWs = (ws: any) => { - if (ws._closeCallback) { - ws._closeCallback(); - } - }; + 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 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({ + // Player 2 joins same game + webSocketHandler.handleConnection(mockWs2, mockWs2.data.request); + triggerMessageForWs( + mockWs2, + 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, + gameId: mockWsData1.gameId, playerId: 'player2', - }); - triggerMessageForWs(mockWs2, joinGameMessage2); + }), + ); + mockWs2.data.gameId = mockWsData1.gameId; + mockWs2.data.playerId = 'player2'; - // 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); + // Player 2 disconnects + mockWs1.send.mockClear(); // Clear P1's send history before P2 disconnects + triggerCloseForWs(mockWs2); - expect(receivedMessage.type).toBe('game_state'); - expect(receivedMessage.state.players.black).toBe('player1'); - expect(receivedMessage.state.players.white).toBe('player2'); + // 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; - 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'); + // 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'); +}); diff --git a/src/game/WebSocketHandler.ts b/src/game/WebSocketHandler.ts index 7b62836..2ac6e90 100644 --- a/src/game/WebSocketHandler.ts +++ b/src/game/WebSocketHandler.ts @@ -23,22 +23,19 @@ export class WebSocketHandler { public handleConnection(ws: any, req: any): void { console.log('WebSocket connected'); - - ws.on('message', (message: string) => { - this.handleMessage(ws, message); - }); - - ws.on('close', () => { - console.log('WebSocket disconnected'); - this.handleDisconnect(ws); - }); - - ws.on('error', (error: Error) => { - console.error('WebSocket error:', error); - }); } - private handleMessage(ws: any, message: string): void { + 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' }), + ); + } + } + + public handleMessage(ws: any, message: string): void { try { const parsedMessage: WebSocketMessage = JSON.parse(message); console.log('Received message:', parsedMessage); @@ -111,7 +108,8 @@ export class WebSocketHandler { 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 + if (playerWs !== ws) { + // Don't send back to the player who just joined playerWs.send(gameStateMessage); } }); @@ -187,7 +185,7 @@ export class WebSocketHandler { } } - private handleDisconnect(ws: any): void { + public handleDisconnect(ws: any): void { const gameId = ws.data.gameId; const playerId = ws.data.playerId; @@ -195,7 +193,10 @@ export class WebSocketHandler { // 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)); + this.connections.set( + gameId, + connectionsInGame.filter((conn: any) => conn !== ws), + ); if (this.connections.get(gameId)?.length === 0) { this.connections.delete(gameId); // Clean up if no players left } diff --git a/src/index.ts b/src/index.ts index 9a1d1f6..ccdd9ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,55 @@ import { Elysia } from 'elysia'; -import { GameManager } from './game/GameManager'; +import { staticPlugin } from '@elysiajs/static'; 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 +// Initialize GameManager (server-side) const gameManager = new GameManager(); -const webSocketHandler = new WebSocketHandler(gameManager); + +// Initialize WebSocketHandler with the gameManager +const wsHandler = new WebSocketHandler(gameManager); const app = new Elysia() + .use( + staticPlugin({ + assets: 'dist', // Serve static files from the dist directory + prefix: '/dist', // Serve them under the /dist path + }), + ) .ws('/ws', { - open(ws: any) { - webSocketHandler.handleConnection(ws, ws.data.request); + open(ws) { + // Call the handler's connection logic + // Elysia's ws context directly provides the ws object + wsHandler.handleConnection(ws as any, {}); }, - message(ws: any, message: any) { - // This is handled inside WebSocketHandler.handleMessage + 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 { + msgString = message as string; + } + wsHandler.handleMessage(ws as any, msgString); }, - close(ws: any) { - // This is handled inside WebSocketHandler.handleDisconnect + close(ws) { + // Call the handler's disconnection logic + wsHandler.handleDisconnect(ws as any); }, - err(ws: any, error: any, code: number, message: string) { - // This is handled inside WebSocketHandler.handleConnection + error(context: any) { + // Call the handler's error logic + wsHandler.handleError(context.ws as any, context.error); }, }) - .get('/', () => 'Hello Elysia') - .listen(3000); + .get('/', () => Bun.file('index.html')); -console.log( - `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`, -); +app.listen(3000, () => { + console.log( + `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`, + ); +}); + +console.log('Elysia server started!');