Get the client to a point where it at least renders the board

This commit is contained in:
sepia 2025-07-15 18:07:48 -05:00
parent 3be0c40b64
commit e8e982c3d6
15 changed files with 1364 additions and 366 deletions

BIN
bun.lockb

Binary file not shown.

307
dist/bundle.js vendored Normal file
View File

@ -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...)`;
}

305
dist/client-entry.js vendored Normal file
View File

@ -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...)`;
}

72
index.html Normal file
View File

@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gomoku Game</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background-color: #f0f0f0;
}
#game-container {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
text-align: center;
}
#game-board {
margin-top: 20px;
display: grid;
border: 1px solid black;
}
.board-cell {
width: 30px;
height: 30px;
border: 1px solid #ccc;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.board-cell:hover {
background-color: #e0e0e0;
}
.board-cell > div {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid #333;
}
.last-move {
box-shadow: 0 0 5px 3px rgba(255, 255, 0, 0.7); /* Yellow glow */
}
#messages {
margin-top: 10px;
font-size: 0.9em;
color: #555;
}
#player-info {
margin-top: 10px;
font-weight: bold;
}
</style>
</head>
<body>
<div id="game-container">
<h1>Gomoku</h1>
<div id="player-info"></div>
<div id="game-board"></div>
<div id="messages"></div>
</div>
<script type="module" src="./dist/bundle.js"></script>
</body>
</html>

View File

@ -10,7 +10,7 @@ dev:
# Build the project # Build the project
build: 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 # Run tests
test: test:

View File

@ -2,6 +2,7 @@
"name": "gomoku", "name": "gomoku",
"version": "1.0.50", "version": "1.0.50",
"dependencies": { "dependencies": {
"@elysiajs/static": "^1.3.0",
"elysia": "latest", "elysia": "latest",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },

139
src/client-entry.ts Normal file
View File

@ -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...)`;
}

View File

@ -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);
}
}
}

View File

@ -1,90 +1,91 @@
import { expect, test, describe, beforeEach, afterEach, mock } from 'bun:test'; import { expect, test, describe, beforeEach, afterEach, mock } from 'bun:test';
import { GameStateManager } from './GameStateManager'; import { GameStateManager, GameStateType } from './GameStateManager';
describe('GameStateManager', () => { describe('GameStateManager', () => {
let gameStateManager: GameStateManager; let gameStateManager: GameStateManager;
beforeEach(() => { beforeEach(() => {
// Initialize a fresh GameStateManager before each test // Initialize a fresh GameStateManager before each test
gameStateManager = new GameStateManager(); 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', () => { test('should update game state from server updates', () => {
const initialState = gameStateManager.getGameState(); const serverState = {
expect(initialState).toEqual({ id: 'game123',
id: '', board: Array.from({ length: 15 }, () => Array(15).fill(null)),
board: Array(15).fill(Array(15).fill(null)), currentPlayer: 'white' as 'black' | 'white',
currentPlayer: 'black', status: 'playing' as 'waiting' | 'playing' | 'finished',
status: 'waiting', winner: null,
winner: null, players: { black: 'playerA', white: 'playerB' },
players: {}, };
}); gameStateManager.updateGameState(serverState);
}); expect(gameStateManager.getGameState()).toEqual(serverState);
});
test('should update game state from server updates', () => { test('should handle optimistic updates for making a move', () => {
const serverState = { const initialBoard = Array.from({ length: 15 }, () => Array(15).fill(null));
id: 'game123', initialBoard[7][7] = 'black'; // Simulate an optimistic move
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 optimisticState = {
const initialBoard = Array(15).fill(Array(15).fill(null)); id: 'game123',
initialBoard[7][7] = 'black'; // Simulate an optimistic move board: initialBoard,
currentPlayer: 'white' as 'black' | 'white', // Turn changes optimistically
status: 'playing' as 'waiting' | 'playing' | 'finished',
winner: null,
players: { black: 'playerA', white: 'playerB' },
};
const optimisticState = { gameStateManager.updateGameState(optimisticState);
id: 'game123', expect(gameStateManager.getGameState().board[7][7]).toEqual('black');
board: initialBoard, expect(gameStateManager.getGameState().currentPlayer).toEqual('white');
currentPlayer: 'white', // Turn changes optimistically });
status: 'playing',
winner: null,
players: { black: 'playerA', white: 'playerB' },
};
gameStateManager.updateGameState(optimisticState); test('should rollback optimistic updates if server rejects move', () => {
expect(gameStateManager.getGameState().board[7][7]).toEqual('black'); const initialServerState = {
expect(gameStateManager.getGameState().currentPlayer).toEqual('white'); 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);
test('should rollback optimistic updates if server rejects move', () => { // Optimistic update
const initialServerState = { const optimisticBoard = Array.from({ length: 15 }, () =>
id: 'game123', Array(15).fill(null),
board: Array(15).fill(Array(15).fill(null)), );
currentPlayer: 'black', optimisticBoard[7][7] = 'black';
status: 'playing', const optimisticState = {
winner: null, id: 'game123',
players: { black: 'playerA', white: 'playerB' }, board: optimisticBoard,
}; currentPlayer: 'white' as 'black' | 'white',
gameStateManager.updateGameState(initialServerState); status: 'playing' as 'waiting' | 'playing' | 'finished',
winner: null,
players: { black: 'playerA', white: 'playerB' },
};
gameStateManager.updateGameState(optimisticState);
// Optimistic update // Server rejection - rollback to initial state
const optimisticBoard = Array(15).fill(Array(15).fill(null)); gameStateManager.rollbackGameState(); // This method will be implemented
optimisticBoard[7][7] = 'black'; expect(gameStateManager.getGameState()).toEqual(initialServerState);
const optimisticState = { });
id: 'game123',
board: optimisticBoard,
currentPlayer: 'white',
status: 'playing',
winner: null,
players: { black: 'playerA', white: 'playerB' },
};
gameStateManager.updateGameState(optimisticState);
// Server rejection - rollback to initial state // Add more tests for:
gameStateManager.rollbackGameState(); // This method will be implemented // - Win conditions
expect(gameStateManager.getGameState()).toEqual(initialServerState); // - 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
}); });

View File

@ -1,50 +1,52 @@
export interface GameStateType { export interface GameStateType {
id: string; id: string;
board: (null | 'black' | 'white')[][]; board: (null | 'black' | 'white')[][];
currentPlayer: 'black' | 'white'; currentPlayer: 'black' | 'white';
status: 'waiting' | 'playing' | 'finished'; status: 'waiting' | 'playing' | 'finished';
winner: null | 'black' | 'white' | 'draw'; winner: null | 'black' | 'white' | 'draw';
players: { black?: string, white?: string }; players: { black?: string; white?: string };
} }
export class GameStateManager { export class GameStateManager {
private gameState: GameStateType; private gameState: GameStateType;
private stateHistory: GameStateType[]; private stateHistory: GameStateType[];
constructor() { constructor() {
this.gameState = this.getDefaultGameState(); this.gameState = this.getDefaultGameState();
this.stateHistory = []; this.stateHistory = [];
} }
private getDefaultGameState(): GameStateType { private getDefaultGameState(): GameStateType {
const emptyBoard: (null | 'black' | 'white')[][] = Array(15).fill(null).map(() => Array(15).fill(null)); const emptyBoard: (null | 'black' | 'white')[][] = Array(15)
return { .fill(null)
id: '', .map(() => Array(15).fill(null));
board: emptyBoard, return {
currentPlayer: 'black', id: '',
status: 'waiting', board: emptyBoard,
winner: null, currentPlayer: 'black',
players: {}, status: 'waiting',
}; winner: null,
} players: {},
};
}
getGameState(): GameStateType { getGameState(): GameStateType {
return this.gameState; return this.gameState;
} }
updateGameState(newState: GameStateType): void { updateGameState(newState: GameStateType): void {
// Store a deep copy of the current state before updating // 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 is crucial for rollback to work correctly, as objects are passed by reference.
this.stateHistory.push(JSON.parse(JSON.stringify(this.gameState))); this.stateHistory.push(JSON.parse(JSON.stringify(this.gameState)));
this.gameState = newState; this.gameState = newState;
} }
rollbackGameState(): void { rollbackGameState(): void {
if (this.stateHistory.length > 0) { if (this.stateHistory.length > 0) {
this.gameState = this.stateHistory.pop()!; this.gameState = this.stateHistory.pop()!;
} else { } else {
console.warn("No previous state to rollback to."); console.warn('No previous state to rollback to.');
// Optionally, throw an error or reset to default state here // Optionally, throw an error or reset to default state here
}
} }
}
} }

View File

@ -3,10 +3,20 @@ import { WebSocketClient } from './WebSocketClient';
// Define MockWebSocket as a regular class // Define MockWebSocket as a regular class
class MockWebSocket { class MockWebSocket {
onopen: ((this: WebSocket, ev: Event) => any) | null = null; static CONNECTING = 0;
onmessage: ((this: WebSocket, ev: MessageEvent<any>) => any) | null = null; static OPEN = 1;
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null; static CLOSING = 2;
onerror: ((this: WebSocket, ev: Event) => any) | null = null; 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>) => 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 readyState: number = 0; // 0: CONNECTING, 1: OPEN, 2: CLOSING, 3: CLOSED
// Using a plain function for send/close to simplify. // Using a plain function for send/close to simplify.
@ -54,7 +64,7 @@ describe('WebSocketClient', () => {
const url = 'ws://localhost:8080'; const url = 'ws://localhost:8080';
// Using a mock function to wrap the actual global WebSocket constructor // Using a mock function to wrap the actual global WebSocket constructor
let globalWebSocketConstructorMock: ReturnType<typeof mock>; let globalWebSocketConstructorMock: typeof WebSocket;
beforeEach(() => { beforeEach(() => {
// Clear instances and reset mocks before each test // 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, // creates a new MockWebSocket instance, pushes it to our global tracker,
// and then spies on its send and close methods.< // and then spies on its send and close methods.<
globalWebSocketConstructorMock = mock((url: string) => { globalWebSocketConstructorMock = mock((url: string) => {
const instance = new MockWebSocket(url); const instance = new MockWebSocket(url);
createdMockWebSockets.push(instance); createdMockWebSockets.push(instance);
// Wrap send and close methods in mock functions for call tracking (instance as any).send = mock(instance.send.bind(instance));
// We do this directly on the instance for each new WebSocket. (instance as any).close = mock(instance.close.bind(instance));
// 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; return instance;
}) as any; // Cast as any to satisfy TypeScript for global assignment }) as unknown as typeof WebSocket;
global.WebSocket = globalWebSocketConstructorMock; global.WebSocket = globalWebSocketConstructorMock;
}); });
afterEach(() => { afterEach(() => {
@ -165,21 +170,28 @@ describe('WebSocketClient', () => {
client.send('queued message 2'); client.send('queued message 2');
// Simulate reconnection after a short delay // 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 expect(createdMockWebSockets.length).toBe(2); // New instance created for reconnection
createdMockWebSockets[1]._simulateOpen(); // Simulate new connection opening createdMockWebSockets[1]._simulateOpen(); // Simulate new connection opening
// Wait for messages to be flushed // 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(
expect(createdMockWebSockets[1].send).toHaveBeenCalledWith('queued message 2'); 'queued message 1',
);
expect(createdMockWebSockets[1].send).toHaveBeenCalledWith(
'queued message 2',
);
expect(onOpenMock).toHaveBeenCalledTimes(1); // onOpen should be called on successful reconnection expect(onOpenMock).toHaveBeenCalledTimes(1); // onOpen should be called on successful reconnection
}); });
it('should not attempt to reconnect if explicitly closed', async () => { 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(() => {}); const onCloseMock = mock(() => {});
client.onClose(onCloseMock); client.onClose(onCloseMock);
@ -188,10 +200,9 @@ describe('WebSocketClient', () => {
client.close(); // Explicitly close 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. // 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(createdMockWebSockets.length).toBe(1); // Only the initial one
expect(onCloseMock).toHaveBeenCalledTimes(1); // onClose should be called expect(onCloseMock).toHaveBeenCalledTimes(1); // onClose should be called
}); });
}); });

View File

@ -85,7 +85,10 @@ export class WebSocketClient {
private handleClose(event: CloseEvent): void { private handleClose(event: CloseEvent): void {
this.isConnected = false; this.isConnected = false;
this.onCloseHandler(event.code, event.reason); this.onCloseHandler(event.code, event.reason);
if (!this.manualClose && this.reconnectCount < this.options.reconnectAttempts!) { if (
!this.manualClose &&
this.reconnectCount < this.options.reconnectAttempts!
) {
this.reconnectCount++; this.reconnectCount++;
setTimeout(() => this.connect(), this.options.reconnectInterval); setTimeout(() => this.connect(), this.options.reconnectInterval);
} }

View File

@ -179,203 +179,229 @@ describe('WebSocketHandler', () => {
); );
}); });
}); });
it('should notify other players and remove a disconnected player', () => { it('should notify other players and remove a disconnected player', () => {
const gameManager = new GameManager(); const gameManager = new GameManager();
const webSocketHandler = new WebSocketHandler(gameManager); const webSocketHandler = new WebSocketHandler(gameManager);
// Player 1 // Player 1
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = { request: {} }; let mockWsData1: { request: {}; gameId?: string; playerId?: string } = {
const mockWs1: any = { request: {},
send: mock(() => {}), };
on: mock((event: string, callback: Function) => { const mockWs1: any = {
if (event === 'message') mockWs1._messageCallback = callback; send: mock(() => {}),
if (event === 'close') mockWs1._closeCallback = callback; on: mock((event: string, callback: Function) => {
}), if (event === 'message') mockWs1._messageCallback = callback;
_messageCallback: null, if (event === 'close') mockWs1._closeCallback = callback;
_closeCallback: null, }),
data: mockWsData1, _messageCallback: null,
}; _closeCallback: null,
data: mockWsData1,
};
// Player 2 // Player 2
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = { request: {} }; let mockWsData2: { request: {}; gameId?: string; playerId?: string } = {
const mockWs2: any = { request: {},
send: mock(() => {}), };
on: mock((event: string, callback: Function) => { const mockWs2: any = {
if (event === 'message') mockWs2._messageCallback = callback; send: mock(() => {}),
if (event === 'close') mockWs2._closeCallback = callback; on: mock((event: string, callback: Function) => {
}), if (event === 'message') mockWs2._messageCallback = callback;
_messageCallback: null, if (event === 'close') mockWs2._closeCallback = callback;
_closeCallback: null, }),
data: mockWsData2, _messageCallback: null,
}; _closeCallback: null,
data: mockWsData2,
};
const triggerMessageForWs = (ws: any, message: string) => { const triggerMessageForWs = (ws: any, message: string) => {
if (ws._messageCallback) { if (ws._messageCallback) {
ws._messageCallback(message); ws._messageCallback(message);
} }
}; };
const triggerCloseForWs = (ws: any) => { const triggerCloseForWs = (ws: any) => {
if (ws._closeCallback) { if (ws._closeCallback) {
ws._closeCallback(); ws._closeCallback();
} }
}; };
// Player 1 joins, creates game // Player 1 joins, creates game
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request); webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
triggerMessageForWs(mockWs1, JSON.stringify({ type: 'join_game', playerId: 'player1' })); triggerMessageForWs(
mockWs1.data.gameId = mockWsData1.gameId; mockWs1,
mockWs1.data.playerId = 'player1'; JSON.stringify({ type: 'join_game', playerId: 'player1' }),
);
mockWs1.data.gameId = mockWsData1.gameId;
mockWs1.data.playerId = 'player1';
// Player 2 joins same game // Player 2 joins same game
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request); webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
triggerMessageForWs(mockWs2, JSON.stringify({ type: 'join_game', gameId: mockWsData1.gameId, playerId: 'player2' })); triggerMessageForWs(
mockWs2.data.gameId = mockWsData1.gameId; mockWs2,
mockWs2.data.playerId = 'player2'; JSON.stringify({
// Player 2 disconnects
mockWs1.send.mockClear(); // Clear P1's send history before P2 disconnects
triggerCloseForWs(mockWs2);
// Expect Player 1 to receive player_disconnected message
expect(mockWs1.send).toHaveBeenCalledTimes(1);
const receivedMessage = JSON.parse(mockWs1.send.mock.calls[0][0]);
expect(receivedMessage.type).toBe('player_disconnected');
expect(receivedMessage.playerId).toBe('player2');
expect(receivedMessage.gameId).toBe(mockWsData1.gameId);
// Verify connections map is updated (Player 2 removed)
// @ts-ignore
expect(webSocketHandler.connections.get(mockWsData1.gameId)).toContain(mockWs1);
// @ts-ignore
expect(webSocketHandler.connections.get(mockWsData1.gameId)).not.toContain(mockWs2);
});
it('should broadcast game state to other players when a new player joins', () => {
const gameManager = new GameManager();
const webSocketHandler = new WebSocketHandler(gameManager);
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = { request: {} };
const mockWs1: any = {
send: mock(() => {}),
on: mock((event: string, callback: Function) => {
if (event === 'message') mockWs1._messageCallback = callback;
}),
_messageCallback: null,
data: mockWsData1,
};
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = { request: {} };
const mockWs2: any = {
send: mock(() => {}),
on: mock((event: string, callback: Function) => {
if (event === 'message') mockWs2._messageCallback = callback;
}),
_messageCallback: null,
data: mockWsData2,
};
const triggerMessageForWs = (ws: any, message: string) => {
if (ws._messageCallback) {
ws._messageCallback(message);
}
};
// Player 1 joins and creates a new game
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
const joinGameMessage1 = JSON.stringify({
type: 'join_game', type: 'join_game',
playerId: 'player1', gameId: mockWsData1.gameId,
});
triggerMessageForWs(mockWs1, joinGameMessage1);
const player1GameId = mockWsData1.gameId;
// Player 2 joins the same game
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
const joinGameMessage2 = JSON.stringify({
type: 'join_game',
gameId: player1GameId,
playerId: 'player2', 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 2 disconnects
// Player 1 should have received two messages: initial join and then game_state after P2 joins mockWs1.send.mockClear(); // Clear P1's send history before P2 disconnects
expect(mockWs1.send).toHaveBeenCalledTimes(2); triggerCloseForWs(mockWs2);
const secondCallArgs = mockWs1.send.mock.calls[1][0];
const receivedMessage = JSON.parse(secondCallArgs);
expect(receivedMessage.type).toBe('game_state'); // Expect Player 1 to receive player_disconnected message
expect(receivedMessage.state.players.black).toBe('player1'); expect(mockWs1.send).toHaveBeenCalledTimes(1);
expect(receivedMessage.state.players.white).toBe('player2'); 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', () => { // Player 2 joins the same game
const gameManager = new GameManager(); webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
const webSocketHandler = new WebSocketHandler(gameManager); const joinGameMessage2 = JSON.stringify({
type: 'join_game',
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = { request: {} }; gameId: player1GameId,
const mockWs1: any = { playerId: 'player2',
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');
}); });
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');
});

View File

@ -23,22 +23,19 @@ export class WebSocketHandler {
public handleConnection(ws: any, req: any): void { public handleConnection(ws: any, req: any): void {
console.log('WebSocket connected'); 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 { try {
const parsedMessage: WebSocketMessage = JSON.parse(message); const parsedMessage: WebSocketMessage = JSON.parse(message);
console.log('Received message:', parsedMessage); console.log('Received message:', parsedMessage);
@ -111,7 +108,8 @@ export class WebSocketHandler {
ws.send(gameStateMessage); ws.send(gameStateMessage);
// Notify other players if any // Notify other players if any
this.connections.get(game.id)?.forEach((playerWs: 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); 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 gameId = ws.data.gameId;
const playerId = ws.data.playerId; const playerId = ws.data.playerId;
@ -195,7 +193,10 @@ export class WebSocketHandler {
// Remove disconnected player's websocket from connections // Remove disconnected player's websocket from connections
const connectionsInGame = this.connections.get(gameId); const connectionsInGame = this.connections.get(gameId);
if (connectionsInGame) { 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) { if (this.connections.get(gameId)?.length === 0) {
this.connections.delete(gameId); // Clean up if no players left this.connections.delete(gameId); // Clean up if no players left
} }

View File

@ -1,28 +1,55 @@
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import { GameManager } from './game/GameManager'; import { staticPlugin } from '@elysiajs/static';
import { WebSocketHandler } from './game/WebSocketHandler'; 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 gameManager = new GameManager();
const webSocketHandler = new WebSocketHandler(gameManager);
// Initialize WebSocketHandler with the gameManager
const wsHandler = new WebSocketHandler(gameManager);
const app = new Elysia() 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', { .ws('/ws', {
open(ws: any) { open(ws) {
webSocketHandler.handleConnection(ws, ws.data.request); // 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) { message(ws, message) {
// This is handled inside WebSocketHandler.handleMessage 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) { close(ws) {
// This is handled inside WebSocketHandler.handleDisconnect // Call the handler's disconnection logic
wsHandler.handleDisconnect(ws as any);
}, },
err(ws: any, error: any, code: number, message: string) { error(context: any) {
// This is handled inside WebSocketHandler.handleConnection // Call the handler's error logic
wsHandler.handleError(context.ws as any, context.error);
}, },
}) })
.get('/', () => 'Hello Elysia') .get('/', () => Bun.file('index.html'));
.listen(3000);
console.log( app.listen(3000, () => {
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`, console.log(
); `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
);
});
console.log('Elysia server started!');