diff --git a/bun.lockb b/bun.lockb index 70a9c2a..0a3de14 100755 Binary files a/bun.lockb and b/bun.lockb differ 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!');