Refactor frontend to use HTMX, and do most rendering serverside
This commit is contained in:
parent
d1dbebcc39
commit
8eabbe3211
12 changed files with 739 additions and 775 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
BIN
favicon.ico
Executable file
BIN
favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
127
index.html
127
index.html
|
|
@ -4,10 +4,19 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gomoku Game</title>
|
<title>Gomoku Game</title>
|
||||||
|
<meta name="gameId" content="" />
|
||||||
|
<meta name="playerId" content="" />
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -22,10 +31,13 @@
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
#game-board {
|
.game-board-grid {
|
||||||
margin-top: 20px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(15, 1fr);
|
||||||
|
width: 450px; /* 15 * 30px */
|
||||||
|
height: 450px; /* 15 * 30px */
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
.board-cell {
|
.board-cell {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
|
|
@ -61,12 +73,111 @@
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="game-container">
|
<div id="game-container" hx-ext="ws">
|
||||||
<h1>Gomoku</h1>
|
|
||||||
<div id="player-info"></div>
|
<div id="player-info"></div>
|
||||||
<div id="game-board"></div>
|
<div id="game-board" class="game-board-grid"></div>
|
||||||
<div id="messages"></div>
|
<div id="messages" hx-swap-oob="beforeend"></div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="./dist/bundle.js"></script>
|
<div id="game-link-container">
|
||||||
|
Share link to this game:
|
||||||
|
<input type="text" id="game-link" size="50" readonly />
|
||||||
|
<button onclick="copyGameLink()">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div id="ws-status" style="margin-top: 10px; color: grey">
|
||||||
|
Connecting...
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Get gameId and playerId from meta tags generated by the server
|
||||||
|
const gameIdMeta = document.querySelector('meta[name="gameId"]');
|
||||||
|
const playerIdMeta = document.querySelector('meta[name="playerId"]');
|
||||||
|
|
||||||
|
if (gameIdMeta && playerIdMeta) {
|
||||||
|
const gameId = gameIdMeta.content;
|
||||||
|
const playerId = playerIdMeta.content;
|
||||||
|
|
||||||
|
// Dynamically construct WebSocket URL
|
||||||
|
const wsUrl = `ws://${window.location.host}/ws?gameId=${gameId}&playerId=${playerId}`;
|
||||||
|
|
||||||
|
// Get the game container element
|
||||||
|
const gameContainer = document.getElementById('game-container');
|
||||||
|
|
||||||
|
// Set the ws-connect attribute
|
||||||
|
gameContainer.setAttribute('ws-connect', wsUrl);
|
||||||
|
|
||||||
|
// Tell HTMX to connect the WebSocket
|
||||||
|
htmx.trigger(gameContainer, ' and connect');
|
||||||
|
|
||||||
|
// Update the game link input
|
||||||
|
const gameLinkInput = document.getElementById('game-link');
|
||||||
|
if (gameLinkInput) {
|
||||||
|
gameLinkInput.value =
|
||||||
|
window.location.origin +
|
||||||
|
window.location.pathname +
|
||||||
|
`?gameId=${gameId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsStatusDiv = document.getElementById('ws-status');
|
||||||
|
|
||||||
|
// Listen for new messages over the WebSocket
|
||||||
|
gameContainer.addEventListener('htmx:wsOpen', function () {
|
||||||
|
if (wsStatusDiv) wsStatusDiv.textContent = 'Connected';
|
||||||
|
// Re-render the board for new game states
|
||||||
|
gameContainer.addEventListener(
|
||||||
|
'htmx:wsAfterMessage',
|
||||||
|
function (event) {
|
||||||
|
const data = JSON.parse(event.detail.message);
|
||||||
|
if (data.type === 'game_state') {
|
||||||
|
document.getElementById('game-board').innerHTML =
|
||||||
|
data.boardHtml;
|
||||||
|
document.getElementById('player-info').innerHTML =
|
||||||
|
data.playerInfoHtml;
|
||||||
|
} else if (data.type === 'message') {
|
||||||
|
document.getElementById('messages').innerHTML =
|
||||||
|
data.messagesHtml;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listener for board cells to send move actions
|
||||||
|
document.addEventListener('htmx:wsConfigSend', function(e) {
|
||||||
|
if (e.target.classList.contains('board-cell')) {
|
||||||
|
const row = parseInt(e.target.dataset.row);
|
||||||
|
const col = parseInt(e.target.dataset.col);
|
||||||
|
|
||||||
|
// Set the custom JSON data
|
||||||
|
e.detail.parameters = {
|
||||||
|
type: "make_move",
|
||||||
|
row: row,
|
||||||
|
col: col
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gameContainer.addEventListener('htmx:wsClose', function () {
|
||||||
|
if (wsStatusDiv) wsStatusDiv.textContent = 'Disconnected';
|
||||||
|
});
|
||||||
|
|
||||||
|
gameContainer.addEventListener('htmx:wsError', function () {
|
||||||
|
if (wsStatusDiv) wsStatusDiv.textContent = 'Connection Error';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('Game ID or Player ID meta tags not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyGameLink() {
|
||||||
|
const gameLinkInput = document.getElementById('game-link');
|
||||||
|
if (gameLinkInput) {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(gameLinkInput.value)
|
||||||
|
.then(() => {
|
||||||
|
alert('Game link copied to clipboard!');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to copy link: ', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"name": "gomoku",
|
"name": "gomoku",
|
||||||
"version": "1.0.50",
|
"version": "1.0.50",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@elysiajs/cookie": "^0.8.0",
|
||||||
"@elysiajs/static": "^1.3.0",
|
"@elysiajs/static": "^1.3.0",
|
||||||
"elysia": "latest",
|
"elysia": "latest",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
|
|
|
||||||
|
|
@ -1,167 +1,22 @@
|
||||||
// src/client-entry.ts
|
console.log('Gomoku client entry point -- HTMX mode.');
|
||||||
|
|
||||||
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';
|
const WS_URL = process.env.WS_URL || 'ws://localhost:3000/ws';
|
||||||
|
|
||||||
// Function to get a query parameter from the URL
|
// This will be handled by HTMX's ws-connect
|
||||||
function getQueryParam(name: string): string | null {
|
// However, we might still need a way to send messages from client-side JS if required by HTMX
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
return urlParams.get(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get gameId from URL, if present
|
// Example of how to send a message via the established HTMX WebSocket.
|
||||||
const gameIdFromUrl = getQueryParam('gameId');
|
// This is a placeholder and might evolve as we refactor.
|
||||||
|
(window as any).sendWebSocketMessage = (message: any) => {
|
||||||
let playerId: string; // Declare playerId here, accessible throughout the module
|
const gameContainer = document.getElementById('game-container');
|
||||||
|
if (gameContainer) {
|
||||||
// Initialize components
|
const ws = (gameContainer as any)._htmx_ws;
|
||||||
const gameStateManager = new GameStateManager();
|
if (ws) {
|
||||||
const wsClient = new WebSocketClient(WS_URL);
|
ws.send(JSON.stringify(message));
|
||||||
|
|
||||||
let gameBoardUI: GameBoardUI;
|
|
||||||
|
|
||||||
const gameBoardElement = document.getElementById('game-board');
|
|
||||||
console.log('gameBoardElement: ', gameBoardElement); // Log to check if element is found
|
|
||||||
|
|
||||||
const messagesElement = document.getElementById('messages');
|
|
||||||
const playerInfoElement = document.getElementById('player-info');
|
|
||||||
|
|
||||||
if (!gameBoardElement || !messagesElement || !playerInfoElement) {
|
|
||||||
console.error(
|
|
||||||
'Missing essential DOM elements (game-board, messages, or player-info)',
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
'Missing essential DOM elements (game-board, messages, or player-info)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
wsClient.onMessage((message) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(message);
|
|
||||||
console.log('Parsed message:', msg);
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'game_state':
|
|
||||||
gameStateManager.updateGameState(msg.state as GameStateType);
|
|
||||||
gameBoardUI.updateBoard(gameStateManager.getGameState());
|
|
||||||
console.log('Game state updated: ', gameStateManager.getGameState());
|
|
||||||
|
|
||||||
// Update player info with game ID and shareable link
|
|
||||||
if (playerInfoElement && msg.state.id) {
|
|
||||||
const gameLink = `${window.location.origin}/?gameId=${msg.state.id}`;
|
|
||||||
playerInfoElement.innerHTML = `You are: ${playerId}<br/>Game ID: ${msg.state.id}<br/>Share this link: <a href="${gameLink}">${gameLink}</a>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'move_result':
|
|
||||||
if (msg.success) {
|
|
||||||
console.log('Move successful!');
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`Move failed: ${msg.error}`);
|
console.error('HTMX WebSocket not found on game-container.');
|
||||||
gameStateManager.rollbackGameState();
|
|
||||||
gameBoardUI.updateBoard(gameStateManager.getGameState()); // Re-render after rollback
|
|
||||||
}
|
}
|
||||||
break;
|
} else {
|
||||||
case 'player_joined':
|
console.error('Game container not found.');
|
||||||
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)
|
|
||||||
// This will be set up inside wsClient.onOpen
|
|
||||||
|
|
||||||
// Initial board render (empty board until server sends state)
|
|
||||||
// This initial render is no longer needed as updateBoard is called within onOpen and onMessage
|
|
||||||
|
|
||||||
// Initial setup for player info
|
|
||||||
if (playerInfoElement) {
|
|
||||||
playerInfoElement.textContent = `You are: (Connecting...)`;
|
|
||||||
}
|
|
||||||
wsClient.onOpen(() => {
|
|
||||||
console.log('Connected to game server.');
|
|
||||||
playerId = `player-${Math.random().toString(36).substring(2, 9)}`;
|
|
||||||
|
|
||||||
gameBoardUI = new GameBoardUI(gameBoardElement, playerId);
|
|
||||||
console.log('GameBoardUI initialized.', gameBoardUI); // Log to confirm GameBoardUI construction
|
|
||||||
|
|
||||||
const joinMessage: any = {
|
|
||||||
type: 'join_game',
|
|
||||||
playerId: playerId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (gameIdFromUrl) {
|
|
||||||
joinMessage.gameId = gameIdFromUrl;
|
|
||||||
}
|
|
||||||
wsClient.send(JSON.stringify(joinMessage));
|
|
||||||
if (playerInfoElement) {
|
|
||||||
playerInfoElement.textContent = `You are: ${playerId} (Waiting for game state...)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial board render (empty board until server sends state)
|
|
||||||
gameBoardUI.updateBoard(gameStateManager.getGameState());
|
|
||||||
|
|
||||||
gameBoardUI.setOnCellClick((row, col) => {
|
|
||||||
const moveMessage = {
|
|
||||||
type: 'make_move',
|
|
||||||
row: row,
|
|
||||||
col: col,
|
|
||||||
};
|
|
||||||
console.log('Sending move:', moveMessage);
|
|
||||||
wsClient.send(JSON.stringify(moveMessage));
|
|
||||||
|
|
||||||
// Optimistic Update: Apply the move to local state immediately
|
|
||||||
const currentGameState = gameStateManager.getGameState();
|
|
||||||
const nextPlayer =
|
|
||||||
currentGameState.currentPlayer === 'black' ? 'white' : 'black';
|
|
||||||
const newBoard = currentGameState.board.map((rowArr) => [...rowArr]); // Deep copy board
|
|
||||||
newBoard[row][col] = currentGameState.currentPlayer; // Place stone optimistically
|
|
||||||
|
|
||||||
const optimisticState: GameStateType = {
|
|
||||||
...currentGameState,
|
|
||||||
board: newBoard,
|
|
||||||
currentPlayer: nextPlayer, // Optimistically switch turn
|
|
||||||
};
|
|
||||||
gameStateManager.updateGameState(optimisticState);
|
|
||||||
gameBoardUI.updateBoard(gameStateManager.getGameState());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wsClient.onClose(() => {
|
|
||||||
console.log('Disconnected from game server. Attempting to reconnect...');
|
|
||||||
});
|
|
||||||
|
|
||||||
wsClient.onError((error: Event) => {
|
|
||||||
console.error(
|
|
||||||
`WebSocket error: ${error instanceof ErrorEvent ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Start Connection ---
|
|
||||||
wsClient.connect();
|
|
||||||
|
|
||||||
// Initial setup for player info
|
|
||||||
if (playerInfoElement) {
|
|
||||||
playerInfoElement.textContent = `You are: (Connecting...)`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
// src/game-client/GameBoardUI.ts
|
|
||||||
|
|
||||||
import { GameStateType } from './GameStateManager';
|
|
||||||
|
|
||||||
export class GameBoardUI {
|
|
||||||
private boardElement: HTMLElement;
|
|
||||||
private cells: HTMLElement[][] = [];
|
|
||||||
private onCellClickCallback: ((row: number, col: number) => void) | null =
|
|
||||||
null;
|
|
||||||
private isInteractionEnabled: boolean = true;
|
|
||||||
private thisClientPlayerId: string;
|
|
||||||
|
|
||||||
constructor(boardElement: HTMLElement, thisClientPlayerId: string) {
|
|
||||||
this.boardElement = boardElement;
|
|
||||||
this.thisClientPlayerId = thisClientPlayerId;
|
|
||||||
this.initializeBoard();
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeBoard(): void {
|
|
||||||
this.boardElement.innerHTML = ''; // Clear existing content
|
|
||||||
this.boardElement.style.display = 'grid';
|
|
||||||
this.boardElement.style.gridTemplateColumns = 'repeat(15, 1fr)';
|
|
||||||
this.boardElement.style.width = '450px'; // 15*30px, assuming cell size
|
|
||||||
this.boardElement.style.height = '450px';
|
|
||||||
this.boardElement.style.border = '1px solid black';
|
|
||||||
|
|
||||||
for (let row = 0; row < 15; row++) {
|
|
||||||
this.cells[row] = [];
|
|
||||||
for (let col = 0; col < 15; col++) {
|
|
||||||
const cell = document.createElement('div');
|
|
||||||
cell.classList.add('board-cell');
|
|
||||||
cell.style.width = '30px';
|
|
||||||
cell.style.height = '30px';
|
|
||||||
cell.style.border = '1px solid #ccc';
|
|
||||||
cell.style.boxSizing = 'border-box';
|
|
||||||
cell.style.display = 'flex';
|
|
||||||
cell.style.justifyContent = 'center';
|
|
||||||
cell.style.alignItems = 'center';
|
|
||||||
cell.dataset.row = row.toString();
|
|
||||||
cell.dataset.col = col.toString();
|
|
||||||
cell.addEventListener('click', () => this.handleCellClick(row, col));
|
|
||||||
this.boardElement.appendChild(cell);
|
|
||||||
this.cells[row][col] = cell;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public updateBoard(gameState: GameStateType): void {
|
|
||||||
const board = gameState.board;
|
|
||||||
const lastMove = { row: -1, col: -1 }; // Placeholder for last move highlighting (needs actual last move from state)
|
|
||||||
|
|
||||||
const thisClientColor = Object.entries(gameState.players).find(([color, id]) => id === this.thisClientPlayerId)?.[0] || null;
|
|
||||||
|
|
||||||
for (let row = 0; row < 15; row++) {
|
|
||||||
for (let col = 0; col < 15; col++) {
|
|
||||||
const cell = this.cells[row][col];
|
|
||||||
cell.innerHTML = ''; // Clear previous stone
|
|
||||||
|
|
||||||
const stone = board[row][col];
|
|
||||||
if (stone) {
|
|
||||||
const stoneElement = document.createElement('div');
|
|
||||||
stoneElement.style.width = '24px';
|
|
||||||
stoneElement.style.height = '24px';
|
|
||||||
stoneElement.style.borderRadius = '50%';
|
|
||||||
stoneElement.style.backgroundColor =
|
|
||||||
stone === 'black' ? 'black' : 'white';
|
|
||||||
stoneElement.style.border = '1px solid #333';
|
|
||||||
cell.appendChild(stoneElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove highlight from previous last moves
|
|
||||||
cell.classList.remove('last-move');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply highlight to the last move (this would require 'lastMove' to be part of GameStateType)
|
|
||||||
// if (lastMove.row !== -1) {
|
|
||||||
// this.cells[lastMove.row][lastMove.col].classList.add('last-move');
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Disable interaction if it's not our turn or game is over
|
|
||||||
// This logic needs to know which player 'we' are, and the current player from gameState
|
|
||||||
this.isInteractionEnabled =
|
|
||||||
gameState.status === 'playing' && gameState.currentPlayer === (thisClientColor as 'black' | 'white');
|
|
||||||
this.boardElement.style.pointerEvents = this.isInteractionEnabled
|
|
||||||
? 'auto'
|
|
||||||
: 'none';
|
|
||||||
this.boardElement.style.opacity = this.isInteractionEnabled ? '1' : '0.7';
|
|
||||||
|
|
||||||
// Update turn indicator and status (these elements would need to be passed in or managed by a parent UI component)
|
|
||||||
console.log(
|
|
||||||
`Current Player: ${gameState.currentPlayer}, Status: ${gameState.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setOnCellClick(callback: (row: number, col: number) => void): void {
|
|
||||||
this.onCellClickCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleCellClick(row: number, col: number): void {
|
|
||||||
if (this.isInteractionEnabled && this.onCellClickCallback) {
|
|
||||||
this.onCellClickCallback(row, col);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,8 +15,8 @@ export class GameInstance {
|
||||||
private readonly boardSize = 15;
|
private readonly boardSize = 15;
|
||||||
private moveCount = 0;
|
private moveCount = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor(id?: string) {
|
||||||
this.id = uuidv4();
|
this.id = id || uuidv4();
|
||||||
this.board = Array.from({ length: this.boardSize }, () =>
|
this.board = Array.from({ length: this.boardSize }, () =>
|
||||||
Array(this.boardSize).fill(null),
|
Array(this.boardSize).fill(null),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ export class GameManager {
|
||||||
this.games = new Map();
|
this.games = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
createGame(): GameInstance {
|
// Overload createGame to optionally accept a gameId
|
||||||
const game = new GameInstance();
|
createGame(gameId?: string): GameInstance {
|
||||||
|
const game = new GameInstance(gameId); // Pass gameId to GameInstance constructor
|
||||||
this.games.set(game.id, game);
|
this.games.set(game.id, game);
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,139 +3,241 @@ import { WebSocketHandler } from './WebSocketHandler';
|
||||||
import { GameManager } from './GameManager';
|
import { GameManager } from './GameManager';
|
||||||
import { GameInstance } from './GameInstance';
|
import { GameInstance } from './GameInstance';
|
||||||
|
|
||||||
|
// Mock ElysiaWS type for testing purposes - fully compatible with standard WebSocket
|
||||||
|
type MockElysiaWS = {
|
||||||
|
send: ReturnType<typeof mock>;
|
||||||
|
close: ReturnType<typeof mock>;
|
||||||
|
on: ReturnType<typeof mock>;
|
||||||
|
_messageCallback: ((message: string) => void) | null;
|
||||||
|
_closeCallback: (() => void) | null;
|
||||||
|
_errorCallback: ((error: Error) => void) | null;
|
||||||
|
data: {
|
||||||
|
gameId?: string;
|
||||||
|
playerId?: string;
|
||||||
|
query: Record<string, string>;
|
||||||
|
};
|
||||||
|
// Standard WebSocket properties
|
||||||
|
binaryType: 'blob' | 'arraybuffer';
|
||||||
|
bufferedAmount: number;
|
||||||
|
extensions: string;
|
||||||
|
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null;
|
||||||
|
onerror: ((this: WebSocket, ev: Event) => any) | null;
|
||||||
|
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null;
|
||||||
|
onopen: ((this: WebSocket, ev: Event) => any) | null;
|
||||||
|
protocol: string;
|
||||||
|
readyState: number;
|
||||||
|
url: string;
|
||||||
|
CLOSED: number;
|
||||||
|
CONNECTING: number;
|
||||||
|
OPEN: number;
|
||||||
|
CLOSING: number;
|
||||||
|
dispatchEvent: ReturnType<typeof mock>;
|
||||||
|
addEventListener: ReturnType<typeof mock>;
|
||||||
|
removeEventListener: ReturnType<typeof mock>;
|
||||||
|
ping: ReturnType<typeof mock>; // Bun.js specific
|
||||||
|
pong: ReturnType<typeof mock>; // Bun.js specific
|
||||||
|
subscribe: ReturnType<typeof mock>; // Bun.js specific
|
||||||
|
unsubscribe: ReturnType<typeof mock>; // Bun.js specific
|
||||||
|
};
|
||||||
|
|
||||||
describe('WebSocketHandler', () => {
|
describe('WebSocketHandler', () => {
|
||||||
let gameManager: GameManager;
|
let gameManager: GameManager;
|
||||||
let webSocketHandler: WebSocketHandler;
|
let webSocketHandler: WebSocketHandler;
|
||||||
let mockWs: any;
|
let mockWs: MockElysiaWS;
|
||||||
let mockWsData: { request: {}; gameId?: string; playerId?: string };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
gameManager = new GameManager();
|
|
||||||
|
|
||||||
mockWsData = { request: {} };
|
|
||||||
|
|
||||||
mockWs = {
|
mockWs = {
|
||||||
|
// Mock standard WebSocket methods
|
||||||
send: mock(() => {}),
|
send: mock(() => {}),
|
||||||
on: mock((event: string, callback: Function) => {
|
close: mock(() => {}),
|
||||||
if (event === 'message') mockWs._messageCallback = callback;
|
|
||||||
if (event === 'close') mockWs._closeCallback = callback;
|
// Mock custom 'on' method for attaching callbacks
|
||||||
if (event === 'error') mockWs._errorCallback = callback;
|
on: mock((event: string, callback: (...args: any[]) => void) => {
|
||||||
|
if (event === 'message') (mockWs as any)._messageCallback = callback;
|
||||||
|
if (event === 'close') (mockWs as any)._closeCallback = callback;
|
||||||
|
if (event === 'error') (mockWs as any)._errorCallback = callback;
|
||||||
|
}),
|
||||||
|
|
||||||
|
_messageCallback: null,
|
||||||
|
_closeCallback: null,
|
||||||
|
_errorCallback: null,
|
||||||
|
|
||||||
|
data: { query: {} },
|
||||||
|
|
||||||
|
// Initialize all standard WebSocket properties
|
||||||
|
binaryType: 'blob',
|
||||||
|
bufferedAmount: 0,
|
||||||
|
extensions: '',
|
||||||
|
onclose: null,
|
||||||
|
onerror: null,
|
||||||
|
onmessage: null,
|
||||||
|
onopen: null,
|
||||||
|
protocol: '',
|
||||||
|
readyState: 1,
|
||||||
|
url: '',
|
||||||
|
CLOSED: 3,
|
||||||
|
CONNECTING: 0,
|
||||||
|
OPEN: 1,
|
||||||
|
CLOSING: 2,
|
||||||
|
dispatchEvent: mock(() => {}),
|
||||||
|
addEventListener: mock(() => {}),
|
||||||
|
removeEventListener: mock(() => {}),
|
||||||
|
ping: mock(() => {}),
|
||||||
|
pong: mock(() => {}),
|
||||||
|
subscribe: mock(() => {}),
|
||||||
|
unsubscribe: mock(() => {}),
|
||||||
|
};
|
||||||
|
gameManager = new GameManager();
|
||||||
|
webSocketHandler = new WebSocketHandler(gameManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerMessage = (ws: MockElysiaWS, message: string) => {
|
||||||
|
if (ws._messageCallback) {
|
||||||
|
ws._messageCallback(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerClose = (ws: MockElysiaWS) => {
|
||||||
|
if (ws._closeCallback) {
|
||||||
|
ws._closeCallback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerError = (ws: MockElysiaWS, error: Error) => {
|
||||||
|
if (ws._errorCallback) {
|
||||||
|
ws._errorCallback(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewMockWs = (): MockElysiaWS => ({
|
||||||
|
send: mock(() => {}),
|
||||||
|
close: mock(() => {}),
|
||||||
|
on: mock((event: string, callback: (...args: any[]) => void) => {
|
||||||
|
if (event === 'message')
|
||||||
|
(createNewMockWs() as any)._messageCallback = callback;
|
||||||
|
if (event === 'close')
|
||||||
|
(createNewMockWs() as any)._closeCallback = callback;
|
||||||
|
if (event === 'error')
|
||||||
|
(createNewMockWs() as any)._errorCallback = callback;
|
||||||
}),
|
}),
|
||||||
_messageCallback: null,
|
_messageCallback: null,
|
||||||
_closeCallback: null,
|
_closeCallback: null,
|
||||||
_errorCallback: null,
|
_errorCallback: null,
|
||||||
data: mockWsData,
|
data: { query: {} },
|
||||||
};
|
binaryType: 'blob',
|
||||||
|
bufferedAmount: 0,
|
||||||
webSocketHandler = new WebSocketHandler(gameManager);
|
extensions: '',
|
||||||
|
onclose: null,
|
||||||
|
onerror: null,
|
||||||
|
onmessage: null,
|
||||||
|
onopen: null,
|
||||||
|
protocol: '',
|
||||||
|
readyState: 1,
|
||||||
|
url: '',
|
||||||
|
CLOSED: 3,
|
||||||
|
CONNECTING: 0,
|
||||||
|
OPEN: 1,
|
||||||
|
CLOSING: 2,
|
||||||
|
dispatchEvent: mock(() => {}),
|
||||||
|
addEventListener: mock(() => {}),
|
||||||
|
removeEventListener: mock(() => {}),
|
||||||
|
ping: mock(() => {}),
|
||||||
|
pong: mock(() => {}),
|
||||||
|
subscribe: mock(() => {}),
|
||||||
|
unsubscribe: mock(() => {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const triggerMessage = (message: string) => {
|
it('should register a new connection', () => {
|
||||||
if (mockWs._messageCallback) {
|
mockWs.data.gameId = 'test-game';
|
||||||
mockWs._messageCallback(message);
|
mockWs.data.playerId = 'player-alpha';
|
||||||
}
|
mockWs.data.query.gameId = 'test-game';
|
||||||
};
|
mockWs.data.query.playerId = 'player-alpha';
|
||||||
|
webSocketHandler.handleConnection(mockWs);
|
||||||
const triggerClose = () => {
|
expect((webSocketHandler as any).connections.get('test-game')).toContain(
|
||||||
if (mockWs._closeCallback) {
|
mockWs,
|
||||||
mockWs._closeCallback();
|
);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should handle a new connection', () => {
|
|
||||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
|
||||||
expect(mockWs.on).toHaveBeenCalledWith('message', expect.any(Function));
|
|
||||||
expect(mockWs.on).toHaveBeenCalledWith('close', expect.any(Function));
|
|
||||||
expect(mockWs.on).toHaveBeenCalledWith('error', expect.any(Function));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a join_game message for a new game', () => {
|
it('should process a join_game message for an already connected client', () => {
|
||||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
const gameId = gameManager.createGame().id;
|
||||||
|
mockWs.data.query.gameId = gameId;
|
||||||
|
mockWs.data.query.playerId = 'player1';
|
||||||
|
mockWs.data.gameId = gameId;
|
||||||
|
mockWs.data.playerId = 'player1';
|
||||||
|
webSocketHandler.handleConnection(mockWs);
|
||||||
const joinGameMessage = JSON.stringify({
|
const joinGameMessage = JSON.stringify({
|
||||||
type: 'join_game',
|
type: 'join_game',
|
||||||
|
gameId: gameId,
|
||||||
playerId: 'player1',
|
playerId: 'player1',
|
||||||
});
|
});
|
||||||
triggerMessage(joinGameMessage);
|
triggerMessage(mockWs, joinGameMessage);
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
expect(mockWs.send).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('game_state'),
|
expect.stringContaining('<div id="game-board"'),
|
||||||
);
|
);
|
||||||
expect(mockWsData.gameId).toBeDefined();
|
|
||||||
expect(mockWsData.playerId).toBe('player1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle a join_game message for an existing game', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
gameManager.joinGame(game.id, 'player1');
|
|
||||||
|
|
||||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
|
||||||
const joinGameMessage = JSON.stringify({
|
|
||||||
type: 'join_game',
|
|
||||||
gameId: game.id,
|
|
||||||
playerId: 'player2',
|
|
||||||
});
|
|
||||||
triggerMessage(joinGameMessage);
|
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
expect(mockWs.send).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('game_state'),
|
expect.stringContaining('<div id="player-info"'),
|
||||||
);
|
);
|
||||||
expect(mockWsData.gameId).toBe(game.id);
|
|
||||||
expect(mockWsData.playerId).toBe('player2');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a make_move message', () => {
|
it('should handle a make_move message and broadcast HTML updates', () => {
|
||||||
const game = gameManager.createGame();
|
const game = gameManager.createGame();
|
||||||
gameManager.joinGame(game.id, 'player1');
|
game.addPlayer('player1');
|
||||||
gameManager.joinGame(game.id, 'player2');
|
game.addPlayer('player2');
|
||||||
|
game.currentPlayer = 'black';
|
||||||
|
|
||||||
game.status = 'playing';
|
mockWs.data.gameId = game.id;
|
||||||
|
mockWs.data.playerId = 'player1';
|
||||||
|
mockWs.data.query.gameId = game.id;
|
||||||
|
mockWs.data.query.playerId = 'player1';
|
||||||
|
webSocketHandler.handleConnection(mockWs);
|
||||||
|
|
||||||
mockWsData.gameId = game.id;
|
|
||||||
mockWsData.playerId = 'player1';
|
|
||||||
|
|
||||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
|
||||||
const makeMoveMessage = JSON.stringify({
|
const makeMoveMessage = JSON.stringify({
|
||||||
type: 'make_move',
|
type: 'make_move',
|
||||||
|
gameId: game.id,
|
||||||
|
playerId: 'player1',
|
||||||
row: 7,
|
row: 7,
|
||||||
col: 7,
|
col: 7,
|
||||||
});
|
});
|
||||||
triggerMessage(makeMoveMessage);
|
triggerMessage(mockWs, makeMoveMessage);
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
expect(mockWs.send).toHaveBeenCalledWith(
|
||||||
JSON.stringify({ type: 'move_result', success: true }),
|
expect.stringContaining('<div id="game-board"'),
|
||||||
);
|
);
|
||||||
|
expect(mockWs.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('<div id="player-info"'),
|
||||||
|
);
|
||||||
|
expect(mockWs.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('<div id="messages"'),
|
||||||
|
);
|
||||||
|
|
||||||
expect(game.board[7][7]).toBe('black');
|
expect(game.board[7][7]).toBe('black');
|
||||||
|
expect(game.currentPlayer).toBe('white');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send an error for an invalid move', () => {
|
it('should send an error for an invalid move', () => {
|
||||||
const game = gameManager.createGame();
|
const game = gameManager.createGame();
|
||||||
gameManager.joinGame(game.id, 'player1');
|
game.addPlayer('player1');
|
||||||
gameManager.joinGame(game.id, 'player2');
|
game.addPlayer('player2');
|
||||||
|
game.currentPlayer = 'black';
|
||||||
|
|
||||||
game.status = 'playing';
|
mockWs.data.gameId = game.id;
|
||||||
|
mockWs.data.playerId = 'player1';
|
||||||
mockWsData.gameId = game.id;
|
mockWs.data.query.gameId = game.id;
|
||||||
mockWsData.playerId = 'player1';
|
mockWs.data.query.playerId = 'player1';
|
||||||
|
webSocketHandler.handleConnection(mockWs);
|
||||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
|
||||||
|
|
||||||
const makeMoveMessage1 = JSON.stringify({
|
const makeMoveMessage1 = JSON.stringify({
|
||||||
type: 'make_move',
|
type: 'make_move',
|
||||||
|
gameId: game.id,
|
||||||
|
playerId: 'player1',
|
||||||
row: 7,
|
row: 7,
|
||||||
col: 7,
|
col: 7,
|
||||||
});
|
});
|
||||||
triggerMessage(makeMoveMessage1);
|
triggerMessage(mockWs, makeMoveMessage1);
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
mockWs.send.mockClear();
|
||||||
JSON.stringify({ type: 'move_result', success: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
game.currentPlayer = 'black';
|
triggerMessage(mockWs, makeMoveMessage1);
|
||||||
const makeMoveMessage2 = JSON.stringify({
|
|
||||||
type: 'make_move',
|
|
||||||
row: 7,
|
|
||||||
col: 7,
|
|
||||||
});
|
|
||||||
triggerMessage(makeMoveMessage2);
|
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
expect(mockWs.send).toHaveBeenCalledWith(
|
||||||
JSON.stringify({ type: 'error', error: 'Cell already occupied' }),
|
JSON.stringify({ type: 'error', error: 'Cell already occupied' }),
|
||||||
|
|
@ -143,26 +245,56 @@ describe('WebSocketHandler', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle ping/pong messages', () => {
|
it('should handle ping/pong messages', () => {
|
||||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
webSocketHandler.handleConnection(mockWs);
|
||||||
const pingMessage = JSON.stringify({ type: 'ping' });
|
const pingMessage = JSON.stringify({ type: 'ping' });
|
||||||
triggerMessage(pingMessage);
|
triggerMessage(mockWs, pingMessage);
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }));
|
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle player disconnection', () => {
|
it('should handle player disconnection and notify others', () => {
|
||||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
const game = gameManager.createGame();
|
||||||
|
const player1Ws = createNewMockWs();
|
||||||
|
const player2Ws = createNewMockWs();
|
||||||
|
|
||||||
mockWsData.gameId = 'test-game-id';
|
player1Ws.data.gameId = game.id;
|
||||||
mockWsData.playerId = 'test-player-id';
|
player1Ws.data.playerId = 'player1';
|
||||||
|
player1Ws.data.query.gameId = game.id;
|
||||||
|
player1Ws.data.query.playerId = 'player1';
|
||||||
|
|
||||||
triggerClose();
|
player2Ws.data.gameId = game.id;
|
||||||
|
player2Ws.data.playerId = 'player2';
|
||||||
|
player2Ws.data.query.gameId = game.id;
|
||||||
|
player2Ws.data.query.playerId = 'player2';
|
||||||
|
|
||||||
|
webSocketHandler.handleConnection(player1Ws);
|
||||||
|
webSocketHandler.handleConnection(player2Ws);
|
||||||
|
|
||||||
|
player1Ws.send.mockClear();
|
||||||
|
player2Ws.send.mockClear();
|
||||||
|
|
||||||
|
webSocketHandler.handleDisconnect(player2Ws);
|
||||||
|
|
||||||
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('<div id="player-info"'),
|
||||||
|
);
|
||||||
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('<div id="messages"'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
player1Ws.send.mock.calls
|
||||||
|
.flat()
|
||||||
|
.some((call) => (call as string).includes('player2 disconnected')),
|
||||||
|
).toBeTrue();
|
||||||
|
expect((webSocketHandler as any).connections.get(game.id)).not.toContain(
|
||||||
|
player2Ws,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send error for unknown message type', () => {
|
it('should send error for unknown message type', () => {
|
||||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
webSocketHandler.handleConnection(mockWs);
|
||||||
const unknownMessage = JSON.stringify({ type: 'unknown_type' });
|
const unknownMessage = JSON.stringify({ type: 'unknown_type' });
|
||||||
triggerMessage(unknownMessage);
|
triggerMessage(mockWs, unknownMessage);
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
expect(mockWs.send).toHaveBeenCalledWith(
|
||||||
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
|
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
|
||||||
|
|
@ -170,238 +302,96 @@ describe('WebSocketHandler', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send error for invalid JSON message', () => {
|
it('should send error for invalid JSON message', () => {
|
||||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
webSocketHandler.handleConnection(mockWs);
|
||||||
const invalidJsonMessage = 'not a json';
|
const invalidJsonMessage = 'not a json';
|
||||||
triggerMessage(invalidJsonMessage);
|
triggerMessage(mockWs, invalidJsonMessage);
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
expect(mockWs.send).toHaveBeenCalledWith(
|
||||||
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
|
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
it('should notify other players and remove a disconnected player', () => {
|
|
||||||
const gameManager = new GameManager();
|
|
||||||
const webSocketHandler = new WebSocketHandler(gameManager);
|
|
||||||
|
|
||||||
// Player 1
|
it('should broadcast game state to a specific client when targetWs is provided', () => {
|
||||||
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = {
|
const game = gameManager.createGame();
|
||||||
request: {},
|
game.addPlayer('player1');
|
||||||
};
|
game.addPlayer('player2');
|
||||||
const mockWs1: any = {
|
game.currentPlayer = 'black';
|
||||||
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
|
const player1Ws = createNewMockWs();
|
||||||
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = {
|
player1Ws.data.gameId = game.id;
|
||||||
request: {},
|
player1Ws.data.playerId = 'player1';
|
||||||
};
|
player1Ws.data.query.gameId = game.id;
|
||||||
const mockWs2: any = {
|
player1Ws.data.query.playerId = 'player1';
|
||||||
send: mock(() => {}),
|
webSocketHandler.handleConnection(player1Ws);
|
||||||
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) => {
|
const player2Ws = createNewMockWs();
|
||||||
if (ws._messageCallback) {
|
player2Ws.data.gameId = game.id;
|
||||||
ws._messageCallback(message);
|
player2Ws.data.playerId = 'player2';
|
||||||
}
|
player2Ws.data.query.gameId = game.id;
|
||||||
};
|
player2Ws.data.query.playerId = 'player2';
|
||||||
|
webSocketHandler.handleConnection(player2Ws);
|
||||||
|
|
||||||
const triggerCloseForWs = (ws: any) => {
|
player1Ws.send.mockClear();
|
||||||
if (ws._closeCallback) {
|
player2Ws.send.mockClear();
|
||||||
ws._closeCallback();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Player 1 joins, creates game
|
webSocketHandler.broadcastGameUpdate(game.id, game, null, null, player1Ws);
|
||||||
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
|
|
||||||
triggerMessageForWs(
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||||
mockWs1,
|
expect.stringContaining('<div id="game-board"'),
|
||||||
JSON.stringify({ type: 'join_game', playerId: 'player1' }),
|
|
||||||
);
|
);
|
||||||
mockWs1.data.gameId = mockWsData1.gameId;
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||||
mockWs1.data.playerId = 'player1';
|
expect.stringContaining('<div id="player-info"'),
|
||||||
|
|
||||||
// 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;
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||||
mockWs2.data.playerId = 'player2';
|
expect.stringContaining('<div id="messages"'),
|
||||||
|
|
||||||
// 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(
|
expect(player2Ws.send).not.toHaveBeenCalled();
|
||||||
mockWs2,
|
});
|
||||||
|
|
||||||
|
it('should broadcast game state to all clients if targetWs is not provided', () => {
|
||||||
|
const game = gameManager.createGame();
|
||||||
|
game.addPlayer('player1');
|
||||||
|
game.addPlayer('player2');
|
||||||
|
game.currentPlayer = 'black';
|
||||||
|
|
||||||
|
const player1Ws = createNewMockWs();
|
||||||
|
player1Ws.data.gameId = game.id;
|
||||||
|
player1Ws.data.playerId = 'player1';
|
||||||
|
player1Ws.data.query.gameId = game.id;
|
||||||
|
player1Ws.data.query.playerId = 'player1';
|
||||||
|
webSocketHandler.handleConnection(player1Ws);
|
||||||
|
|
||||||
|
const player2Ws = createNewMockWs();
|
||||||
|
player2Ws.data.gameId = game.id;
|
||||||
|
player2Ws.data.playerId = 'player2';
|
||||||
|
player2Ws.data.query.gameId = game.id;
|
||||||
|
player2Ws.data.query.playerId = 'player2';
|
||||||
|
webSocketHandler.handleConnection(player2Ws);
|
||||||
|
|
||||||
|
player1Ws.send.mockClear();
|
||||||
|
player2Ws.send.mockClear();
|
||||||
|
|
||||||
|
webSocketHandler.broadcastGameUpdate(game.id, game);
|
||||||
|
|
||||||
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('<div id="game-board"'),
|
||||||
);
|
);
|
||||||
});
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||||
it('should broadcast game state to other players when a new player joins', () => {
|
expect.stringContaining('<div id="player-info"'),
|
||||||
const gameManager = new GameManager();
|
);
|
||||||
const webSocketHandler = new WebSocketHandler(gameManager);
|
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('<div id="messages"'),
|
||||||
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = {
|
);
|
||||||
request: {},
|
|
||||||
};
|
expect(player2Ws.send).toHaveBeenCalledWith(
|
||||||
const mockWs1: any = {
|
expect.stringContaining('<div id="game-board"'),
|
||||||
send: mock(() => {}),
|
);
|
||||||
on: mock((event: string, callback: Function) => {
|
expect(player2Ws.send).toHaveBeenCalledWith(
|
||||||
if (event === 'message') mockWs1._messageCallback = callback;
|
expect.stringContaining('<div id="player-info"'),
|
||||||
}),
|
);
|
||||||
_messageCallback: null,
|
expect(player2Ws.send).toHaveBeenCalledWith(
|
||||||
data: mockWsData1,
|
expect.stringContaining('<div id="messages"'),
|
||||||
};
|
);
|
||||||
|
});
|
||||||
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = {
|
|
||||||
request: {},
|
|
||||||
};
|
|
||||||
const mockWs2: any = {
|
|
||||||
send: mock(() => {}),
|
|
||||||
on: mock((event: string, callback: Function) => {
|
|
||||||
if (event === 'message') mockWs2._messageCallback = callback;
|
|
||||||
}),
|
|
||||||
_messageCallback: null,
|
|
||||||
data: mockWsData2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerMessageForWs = (ws: any, message: string) => {
|
|
||||||
if (ws._messageCallback) {
|
|
||||||
ws._messageCallback(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Player 1 joins and creates a new game
|
|
||||||
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
|
|
||||||
const joinGameMessage1 = JSON.stringify({
|
|
||||||
type: 'join_game',
|
|
||||||
playerId: 'player1',
|
|
||||||
});
|
|
||||||
triggerMessageForWs(mockWs1, joinGameMessage1);
|
|
||||||
const player1GameId = mockWsData1.gameId;
|
|
||||||
|
|
||||||
// Player 2 joins the same game
|
|
||||||
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
|
|
||||||
const joinGameMessage2 = JSON.stringify({
|
|
||||||
type: 'join_game',
|
|
||||||
gameId: player1GameId,
|
|
||||||
playerId: 'player2',
|
|
||||||
});
|
|
||||||
triggerMessageForWs(mockWs2, joinGameMessage2);
|
|
||||||
|
|
||||||
// Check that Player 1 received the game_state update after Player 2 joined
|
|
||||||
// Player 1 should have received two messages: initial join and then game_state after P2 joins
|
|
||||||
expect(mockWs1.send).toHaveBeenCalledTimes(2);
|
|
||||||
const secondCallArgs = mockWs1.send.mock.calls[1][0];
|
|
||||||
const receivedMessage = JSON.parse(secondCallArgs);
|
|
||||||
|
|
||||||
expect(receivedMessage.type).toBe('game_state');
|
|
||||||
expect(receivedMessage.state.players.black).toBe('player1');
|
|
||||||
expect(receivedMessage.state.players.white).toBe('player2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should broadcast game state after a successful move', () => {
|
|
||||||
const gameManager = new GameManager();
|
|
||||||
const webSocketHandler = new WebSocketHandler(gameManager);
|
|
||||||
|
|
||||||
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = {
|
|
||||||
request: {},
|
|
||||||
};
|
|
||||||
const mockWs1: any = {
|
|
||||||
send: mock(() => {}),
|
|
||||||
on: mock((event: string, callback: Function) => {
|
|
||||||
if (event === 'message') mockWs1._messageCallback = callback;
|
|
||||||
}),
|
|
||||||
_messageCallback: null,
|
|
||||||
data: mockWsData1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = {
|
|
||||||
request: {},
|
|
||||||
};
|
|
||||||
const mockWs2: any = {
|
|
||||||
send: mock(() => {}),
|
|
||||||
on: mock((event: string, callback: Function) => {
|
|
||||||
if (event === 'message') mockWs2._messageCallback = callback;
|
|
||||||
}),
|
|
||||||
_messageCallback: null,
|
|
||||||
data: mockWsData2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerMessageForWs = (ws: any, message: string) => {
|
|
||||||
if (ws._messageCallback) {
|
|
||||||
ws._messageCallback(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Player 1 joins and creates a new game
|
|
||||||
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
|
|
||||||
const joinGameMessage1 = JSON.stringify({
|
|
||||||
type: 'join_game',
|
|
||||||
playerId: 'player1',
|
|
||||||
});
|
|
||||||
triggerMessageForWs(mockWs1, joinGameMessage1);
|
|
||||||
const player1GameId = mockWsData1.gameId;
|
|
||||||
mockWs1.data.gameId = player1GameId; // Manually set gameId for mockWs1
|
|
||||||
mockWs1.data.playerId = 'player1'; // Manually set playerId for mockWs1
|
|
||||||
|
|
||||||
// Player 2 joins the same game
|
|
||||||
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
|
|
||||||
const joinGameMessage2 = JSON.stringify({
|
|
||||||
type: 'join_game',
|
|
||||||
gameId: player1GameId,
|
|
||||||
playerId: 'player2',
|
|
||||||
});
|
|
||||||
triggerMessageForWs(mockWs2, joinGameMessage2);
|
|
||||||
mockWs2.data.gameId = player1GameId; // Manually set gameId for mockWs2
|
|
||||||
mockWs2.data.playerId = 'player2'; // Manually set playerId for mockWs2
|
|
||||||
|
|
||||||
// Clear previous calls for clean assertion
|
|
||||||
mockWs1.send.mockClear();
|
|
||||||
mockWs2.send.mockClear();
|
|
||||||
|
|
||||||
// Player 1 makes a move
|
|
||||||
const makeMoveMessage = JSON.stringify({
|
|
||||||
type: 'make_move',
|
|
||||||
row: 7,
|
|
||||||
col: 7,
|
|
||||||
});
|
|
||||||
triggerMessageForWs(mockWs1, makeMoveMessage);
|
|
||||||
|
|
||||||
// Expect Player 2 to receive the game state update
|
|
||||||
expect(mockWs2.send).toHaveBeenCalledTimes(1);
|
|
||||||
const receivedMessage = JSON.parse(mockWs2.send.mock.calls[0][0]);
|
|
||||||
expect(receivedMessage.type).toBe('game_state');
|
|
||||||
expect(receivedMessage.state.board[7][7]).toBe('black');
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,187 +1,107 @@
|
||||||
import { GameManager } from './GameManager';
|
|
||||||
import { GameInstance } from './GameInstance';
|
import { GameInstance } from './GameInstance';
|
||||||
|
import {
|
||||||
|
renderGameBoardHtml,
|
||||||
|
renderPlayerInfoHtml,
|
||||||
|
} from '../view/board-renderer';
|
||||||
|
|
||||||
interface WebSocketMessage {
|
interface MakeMoveMessage {
|
||||||
type: string;
|
gameId: string;
|
||||||
gameId?: string;
|
playerId: string;
|
||||||
playerId?: string;
|
row: number;
|
||||||
row?: number;
|
col: number;
|
||||||
col?: number;
|
|
||||||
state?: any; // GameState
|
|
||||||
success?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebSocketHandler {
|
export class WebSocketHandler {
|
||||||
private gameManager: GameManager;
|
private connections: Map<string, Array<any>>; // Use 'any' for the specific Elysia WS object for now
|
||||||
|
private games: Map<string, GameInstance>;
|
||||||
|
|
||||||
private connections: Map<string, Array<any>>; // Map of gameId to an array of connected websockets
|
constructor() {
|
||||||
constructor(gameManager: GameManager) {
|
|
||||||
this.gameManager = gameManager;
|
|
||||||
this.connections = new Map();
|
this.connections = new Map();
|
||||||
|
this.games = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleConnection(ws: any, req: any): void {
|
public handleConnection(ws: any, gameId: string, playerId: string): void {
|
||||||
console.log('WebSocket connected');
|
if (!this.connections.has(gameId)) {
|
||||||
|
this.connections.set(gameId, []);
|
||||||
|
}
|
||||||
|
ws.data.playerId = playerId;
|
||||||
|
ws.data.gameId = gameId;
|
||||||
|
this.connections.get(gameId)?.push(ws);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`WebSocket connected, registered for Game ${gameId} as Player ${playerId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleError(ws: any, error: Error): void {
|
public handleError(ws: any, error: Error): void {
|
||||||
console.error('WebSocket error:', error);
|
console.error('WebSocket error:', error);
|
||||||
// Optionally send an error message to the client
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.send(
|
this.sendMessage(
|
||||||
JSON.stringify({ type: 'error', error: 'Server-side WebSocket error' }),
|
ws.data.gameId,
|
||||||
|
'Error: server-side WebSocket error',
|
||||||
|
ws,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleMessage(ws: any, message: string): void {
|
public handleMessage(ws: any, message: any): void {
|
||||||
try {
|
const type: string = message.type;
|
||||||
const parsedMessage: WebSocketMessage = JSON.parse(message);
|
// Someday we might have other message types
|
||||||
console.log('Received message:', parsedMessage);
|
if (type === 'make_move') {
|
||||||
|
this.handleMakeMove(ws, message as MakeMoveMessage);
|
||||||
switch (parsedMessage.type) {
|
|
||||||
case 'join_game':
|
|
||||||
this.handleJoinGame(ws, parsedMessage);
|
|
||||||
break;
|
|
||||||
case 'make_move':
|
|
||||||
this.handleMakeMove(ws, parsedMessage);
|
|
||||||
break;
|
|
||||||
case 'ping':
|
|
||||||
ws.send(JSON.stringify({ type: 'pong' }));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse message:', message, error);
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleJoinGame(ws: any, message: WebSocketMessage): void {
|
private handleMakeMove(ws: any, message: MakeMoveMessage): void {
|
||||||
const { gameId, playerId } = message;
|
|
||||||
if (!playerId) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', error: 'playerId is required' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let game: GameInstance | null = null;
|
|
||||||
let isNewGame = false;
|
|
||||||
|
|
||||||
if (gameId) {
|
|
||||||
game = this.gameManager.getGame(gameId);
|
|
||||||
if (!game) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', error: 'Game not found' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create a new game if no gameId is provided
|
|
||||||
game = this.gameManager.createGame();
|
|
||||||
isNewGame = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (game && this.gameManager.joinGame(game.id, playerId)) {
|
|
||||||
ws.data.gameId = game.id; // Store gameId on the WebSocket object
|
|
||||||
ws.data.playerId = playerId; // Store playerId on the WebSocket object
|
|
||||||
|
|
||||||
if (!this.connections.has(game.id)) {
|
|
||||||
this.connections.set(game.id, []);
|
|
||||||
}
|
|
||||||
this.connections.get(game.id)?.push(ws);
|
|
||||||
|
|
||||||
const gameStateMessage = JSON.stringify({
|
|
||||||
type: 'game_state',
|
|
||||||
state: {
|
|
||||||
id: game.id,
|
|
||||||
board: game.board,
|
|
||||||
currentPlayer: game.currentPlayer,
|
|
||||||
status: game.status,
|
|
||||||
winner: game.winner,
|
|
||||||
players: game.players,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
ws.send(gameStateMessage);
|
|
||||||
// Notify other players if any
|
|
||||||
this.connections.get(game.id)?.forEach((playerWs: any) => {
|
|
||||||
if (playerWs !== ws) {
|
|
||||||
// Don't send back to the player who just joined
|
|
||||||
playerWs.send(gameStateMessage);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(`${playerId} joined game ${game.id}`);
|
|
||||||
} else {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', error: 'Failed to join game' }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMakeMove(ws: any, message: WebSocketMessage): void {
|
|
||||||
const { row, col } = message;
|
const { row, col } = message;
|
||||||
const gameId = ws.data.gameId;
|
const gameId = ws.data.gameId;
|
||||||
const playerId = ws.data.playerId;
|
const playerId = ws.data.playerId;
|
||||||
|
console.log(`Handling make_move message in game ${gameId} from player ${playerId}: ${{message}}`);
|
||||||
|
|
||||||
if (!gameId || !playerId) {
|
if (!gameId || !playerId || row === undefined || col === undefined) {
|
||||||
ws.send(JSON.stringify({ type: 'error', error: 'Not in a game' }));
|
this.sendMessage(
|
||||||
return;
|
gameId,
|
||||||
}
|
'Error: missing gameId, playerId, row, or col',
|
||||||
|
ws,
|
||||||
const game = this.gameManager.getGame(gameId);
|
|
||||||
if (!game) {
|
|
||||||
ws.send(JSON.stringify({ type: 'error', error: 'Game not found' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row === undefined || col === undefined) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({ type: 'error', error: 'Invalid move coordinates' }),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerColor =
|
const game = this.games.get(gameId);
|
||||||
game.players.black === playerId
|
if (!game) {
|
||||||
? 'black'
|
this.sendMessage(gameId, 'Error: game not found', ws);
|
||||||
: game.players.white === playerId
|
return;
|
||||||
? 'white'
|
}
|
||||||
: null;
|
|
||||||
|
const playerColor = Object.entries(game.players).find(
|
||||||
|
([_, id]) => id === playerId,
|
||||||
|
)?.[0] as ('black' | 'white') | undefined;
|
||||||
if (!playerColor) {
|
if (!playerColor) {
|
||||||
ws.send(
|
this.sendMessage(
|
||||||
JSON.stringify({
|
gameId,
|
||||||
type: 'error',
|
'Error: you are not a player in this game',
|
||||||
error: 'You are not a player in this game',
|
ws,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game.currentPlayer !== playerColor) {
|
if (game.currentPlayer !== playerColor) {
|
||||||
ws.send(JSON.stringify({ type: 'error', error: 'Not your turn' }));
|
this.sendMessage(gameId, 'Error: It\'s not your turn', ws);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = game.makeMove(playerId, row, col);
|
const result = game.makeMove(playerId, row, col);
|
||||||
ws.send(JSON.stringify({ type: 'move_result', success: result.success }));
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Broadcast updated game state to all players in the game
|
this.broadcastGameState(game.id);
|
||||||
this.broadcastGameState(gameId, game);
|
|
||||||
console.log(
|
console.log(
|
||||||
`Move made in game ${gameId} by ${playerId}: (${row}, ${col})`,
|
`Move made in game ${game.id} by ${playerId}: (${row}, ${col})`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ws.send(
|
this.sendMessage(gameId, result.error || 'Error: invalid move', ws);
|
||||||
JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
error: result.error || 'Invalid move',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ws.send(JSON.stringify({ type: 'error', error: e.message }));
|
this.sendMessage(gameId, 'Error: ' + e.message, ws);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,44 +110,92 @@ export class WebSocketHandler {
|
||||||
const playerId = ws.data.playerId;
|
const playerId = ws.data.playerId;
|
||||||
|
|
||||||
if (gameId && playerId) {
|
if (gameId && playerId) {
|
||||||
// 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(
|
this.connections.set(
|
||||||
gameId,
|
gameId,
|
||||||
connectionsInGame.filter((conn: any) => conn !== ws),
|
connectionsInGame.filter((conn) => 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify other players
|
|
||||||
if (this.connections.has(gameId)) {
|
if (this.connections.has(gameId)) {
|
||||||
const disconnectMessage = JSON.stringify({
|
// Notify remaining players about disconnect
|
||||||
type: 'player_disconnected',
|
this.sendMessage(gameId, 'message', `${playerId} disconnected.`);
|
||||||
playerId: playerId,
|
|
||||||
gameId: gameId,
|
|
||||||
});
|
|
||||||
this.connections.get(gameId)?.forEach((playerWs: any) => {
|
|
||||||
playerWs.send(disconnectMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
console.log(`${playerId} disconnected from game ${gameId}`);
|
console.log(`${playerId} disconnected from game ${gameId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to send updated game state to all participants in a game
|
public broadcastGameState(gameId: string): void {
|
||||||
// This would typically be called by GameManager when game state changes
|
const game = this.games.get(gameId);
|
||||||
public broadcastGameState(gameId: string, state: any): void {
|
if (!game) {
|
||||||
const message = JSON.stringify({ type: 'game_state', state });
|
console.warn('Attempted to broadcast state of game ${gameId}, which is not loaded.');
|
||||||
const connectionsInGame = this.connections.get(gameId);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (connectionsInGame) {
|
const connectionsToUpdate = this.connections.get(gameId);
|
||||||
connectionsInGame.forEach((ws: any) => {
|
if (connectionsToUpdate) {
|
||||||
ws.send(message);
|
connectionsToUpdate.forEach((ws) => {
|
||||||
|
if (!ws.data.playerId) {
|
||||||
|
console.warn('WebSocket without playerId in game for update', gameId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedBoardHtml = renderGameBoardHtml(game, ws.data.playerId);
|
||||||
|
ws.send(updatedBoardHtml);
|
||||||
|
const updatedPlayerInfoHtml = renderPlayerInfoHtml(
|
||||||
|
game.id,
|
||||||
|
ws.data.playerId,
|
||||||
|
);
|
||||||
|
ws.send(updatedPlayerInfoHtml);
|
||||||
|
|
||||||
|
if (game.status === 'finished') {
|
||||||
|
if (game.winner === 'draw') {
|
||||||
|
this.sendMessage(gameId, 'Game ended in draw.');
|
||||||
|
} else if (game.winner) {
|
||||||
|
this.sendMessage(gameId, `${game.winner.toUpperCase()} wins!`);
|
||||||
|
}
|
||||||
|
} else if (game.status === 'playing') {
|
||||||
|
const clientPlayerColor = Object.entries(game.players).find(
|
||||||
|
([_, id]) => id === ws.data.playerId,
|
||||||
|
)?.[0] as ('black' | 'white') | undefined;
|
||||||
|
if (game.currentPlayer && clientPlayerColor === game.currentPlayer) {
|
||||||
|
this.sendMessage(gameId, "It's your turn!", ws);
|
||||||
|
} else if (game.currentPlayer) {
|
||||||
|
this.sendMessage(gameId, `Waiting for ${game.currentPlayer}'s move.`, ws);
|
||||||
|
}
|
||||||
|
} else if (game.status === 'waiting') {
|
||||||
|
this.sendMessage(gameId, 'Waiting for another player...', ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`No connections to update for game ${gameId}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendMessage(
|
||||||
|
gameId: string,
|
||||||
|
message: string,
|
||||||
|
targetWs?: any,
|
||||||
|
): void {
|
||||||
|
const connections = targetWs ? [targetWs] : this.connections.get(gameId);
|
||||||
|
if (connections) {
|
||||||
|
connections.forEach((ws) => {
|
||||||
|
ws.send('<div id="messages">' + message + '</div>')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(`Broadcasting game state for ${gameId}:`, state);
|
}
|
||||||
|
|
||||||
|
public getGame(gameId: string): GameInstance | undefined {
|
||||||
|
return this.games.get(gameId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createGame(gameId?: string): GameInstance {
|
||||||
|
const game = new GameInstance(gameId);
|
||||||
|
this.games.set(game.id, game);
|
||||||
|
return game;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
140
src/index.ts
140
src/index.ts
|
|
@ -1,55 +1,143 @@
|
||||||
import { Elysia } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { staticPlugin } from '@elysiajs/static';
|
import { staticPlugin } from '@elysiajs/static';
|
||||||
|
import { cookie } from '@elysiajs/cookie';
|
||||||
import { WebSocketHandler } from './game/WebSocketHandler';
|
import { WebSocketHandler } from './game/WebSocketHandler';
|
||||||
import { GameManager } from './game/GameManager';
|
import { GameInstance } from './game/GameInstance';
|
||||||
import { GameInstance } from './game/GameInstance'; // Make sure GameInstance is accessible if GameInstance.addPlayer is used directly
|
|
||||||
|
|
||||||
// Initialize GameManager (server-side)
|
// Initialize WebSocketHandler
|
||||||
const gameManager = new GameManager();
|
const wsHandler = new WebSocketHandler();
|
||||||
|
|
||||||
// Initialize WebSocketHandler with the gameManager
|
|
||||||
const wsHandler = new WebSocketHandler(gameManager);
|
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(
|
.use(
|
||||||
staticPlugin({
|
staticPlugin({
|
||||||
assets: 'dist', // Serve static files from the dist directory
|
assets: 'dist',
|
||||||
prefix: '/dist', // Serve them under the /dist path
|
prefix: '/dist',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.use(
|
||||||
|
staticPlugin({
|
||||||
|
assets: '.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(cookie())
|
||||||
.ws('/ws', {
|
.ws('/ws', {
|
||||||
|
query: t.Object({
|
||||||
|
gameId: t.String(),
|
||||||
|
playerId: t.String(),
|
||||||
|
}),
|
||||||
open(ws) {
|
open(ws) {
|
||||||
// Call the handler's connection logic
|
const { gameId, playerId } = ws.data.query;
|
||||||
// Elysia's ws context directly provides the ws object
|
|
||||||
wsHandler.handleConnection(ws as any, {});
|
if (!gameId || !playerId) {
|
||||||
|
console.error(
|
||||||
|
'WebSocket connection missing gameId or playerId in query params.',
|
||||||
|
);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: 'Missing gameId or playerId.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsHandler.handleConnection(ws, gameId, playerId);
|
||||||
|
|
||||||
|
const game = wsHandler.getGame(gameId);
|
||||||
|
if (game) {
|
||||||
|
wsHandler.broadcastGameState(game.id);
|
||||||
|
let message = '';
|
||||||
|
if (game.getPlayerCount() === 2 && game.status === 'playing') {
|
||||||
|
message = `${game.currentPlayer}'s turn.`;
|
||||||
|
} else if (game.getPlayerCount() === 1 && game.status === 'waiting') {
|
||||||
|
message = `You are ${playerId}. Waiting for another player to join.`;
|
||||||
|
}
|
||||||
|
wsHandler.sendMessage(game.id, 'message', message, ws);
|
||||||
|
} else {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: 'Game not found after WebSocket connection.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
console.log(`WebSocket connected: Player ${playerId} for Game ${gameId}`);
|
||||||
},
|
},
|
||||||
message(ws, message) {
|
message(ws, message) {
|
||||||
let msgString: string;
|
let msgString: string;
|
||||||
if (message instanceof Buffer) {
|
if (message instanceof Buffer) {
|
||||||
msgString = message.toString();
|
msgString = message.toString();
|
||||||
} else if (typeof message === 'object') {
|
|
||||||
// If Elysia already parsed it to an object, stringify it
|
|
||||||
msgString = JSON.stringify(message);
|
|
||||||
} else {
|
} else {
|
||||||
|
// Assuming it's always a stringified JSON for 'make_move'
|
||||||
msgString = message as string;
|
msgString = message as string;
|
||||||
}
|
}
|
||||||
wsHandler.handleMessage(ws as any, msgString);
|
wsHandler.handleMessage(ws, msgString);
|
||||||
},
|
},
|
||||||
close(ws) {
|
close(ws) {
|
||||||
// Call the handler's disconnection logic
|
wsHandler.handleDisconnect(ws);
|
||||||
wsHandler.handleDisconnect(ws as any);
|
|
||||||
},
|
|
||||||
error(context: any) {
|
|
||||||
// Call the handler's error logic
|
|
||||||
wsHandler.handleError(context.ws as any, context.error);
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.get('/', () => Bun.file('index.html'));
|
.get('/', async ({ query, cookie, request }) => {
|
||||||
|
const htmlTemplate = await Bun.file('/home/sepia/gomoku/index.html').text();
|
||||||
|
const urlGameId = query.gameId as string | undefined;
|
||||||
|
|
||||||
|
let playerId: string;
|
||||||
|
const existingPlayerId = cookie.playerId?.value;
|
||||||
|
if (existingPlayerId) {
|
||||||
|
playerId = existingPlayerId;
|
||||||
|
console.log(`Using existing playerId from cookie: ${playerId}`);
|
||||||
|
} else {
|
||||||
|
playerId = `player-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
cookie.playerId.set({
|
||||||
|
value: playerId,
|
||||||
|
httpOnly: true,
|
||||||
|
path: '/',
|
||||||
|
maxAge: 30 * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
console.log(`Generated new playerId and set cookie: ${playerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let game: GameInstance;
|
||||||
|
if (urlGameId) {
|
||||||
|
let existingGame = wsHandler.getGame(urlGameId);
|
||||||
|
if (existingGame) {
|
||||||
|
game = existingGame;
|
||||||
|
console.log(`Found existing game: ${urlGameId}`);
|
||||||
|
} else {
|
||||||
|
game = wsHandler.createGame(urlGameId);
|
||||||
|
console.log(`Created new game with provided ID: ${urlGameId}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
game = wsHandler.createGame();
|
||||||
|
console.log(`Created new game without specific ID: ${game.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
game.addPlayer(playerId);
|
||||||
|
wsHandler.broadcastGameState(game.id);
|
||||||
|
|
||||||
|
let finalHtml = htmlTemplate
|
||||||
|
.replace(
|
||||||
|
'<meta name="gameId" content="" />',
|
||||||
|
`<meta name="gameId" content="${game.id}" />`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'<meta name="playerId" content="" />',
|
||||||
|
`<meta name="playerId" content="${playerId}" />`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
`<div id="game-link"></div>`,
|
||||||
|
`<div id="game-link">${request.url.split('?')[0]}?gameId=${game.id}</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(finalHtml, {
|
||||||
|
headers: { 'Content-Type': 'text/html' },
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(3000, () => {
|
app.listen(3000, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
|
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Elysia server started!');
|
|
||||||
|
|
|
||||||
55
src/view/board-renderer.ts
Normal file
55
src/view/board-renderer.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { GameInstance } from '../game/GameInstance';
|
||||||
|
|
||||||
|
export type GameStateType = Pick<
|
||||||
|
GameInstance,
|
||||||
|
'id' | 'board' | 'currentPlayer' | 'status' | 'winner' | 'players'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function renderGameBoardHtml(
|
||||||
|
gameState: GameStateType,
|
||||||
|
playerId: string,
|
||||||
|
): string {
|
||||||
|
let boardHtml = '<div id="game-board" class="game-board-grid">';
|
||||||
|
|
||||||
|
const currentPlayerColor =
|
||||||
|
Object.entries(gameState.players).find(
|
||||||
|
([_, id]) => id === playerId,
|
||||||
|
)?.[0] || null;
|
||||||
|
const isPlayersTurn =
|
||||||
|
gameState.status === 'playing' &&
|
||||||
|
gameState.currentPlayer === currentPlayerColor;
|
||||||
|
|
||||||
|
for (let row = 0; row < gameState.board.length; row++) {
|
||||||
|
for (let col = 0; col < gameState.board[row].length; col++) {
|
||||||
|
const stone = gameState.board[row][col];
|
||||||
|
const cellId = `cell-${row}-${col}`;
|
||||||
|
let stoneHtml = '';
|
||||||
|
if (stone) {
|
||||||
|
const color = stone === 'black' ? 'black' : 'white';
|
||||||
|
stoneHtml = `<div style="background-color: ${color};"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMX attributes for making a move
|
||||||
|
const wsAttrs = isPlayersTurn && !stone
|
||||||
|
? `ws-send="click"`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
boardHtml += `
|
||||||
|
<div
|
||||||
|
id="${cellId}"
|
||||||
|
class="board-cell"
|
||||||
|
data-row="${row}"
|
||||||
|
data-col="${col}"
|
||||||
|
${wsAttrs}
|
||||||
|
>
|
||||||
|
${stoneHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boardHtml += `</div>`;
|
||||||
|
return boardHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPlayerInfoHtml(gameId: string, playerId: string): string {
|
||||||
|
return `<div id="player-info">You are: ${playerId}<br/>Game ID: ${gameId}</div>`;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue