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
+
+
+
+
+
+
+
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!');