Refactor frontend to use HTMX, and do most rendering serverside
This commit is contained in:
parent
d1dbebcc39
commit
8eabbe3211
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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
@ -22,10 +31,13 @@
|
|||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
#game-board {
|
||||
margin-top: 20px;
|
||||
.game-board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(15, 1fr);
|
||||
width: 450px; /* 15 * 30px */
|
||||
height: 450px; /* 15 * 30px */
|
||||
border: 1px solid black;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.board-cell {
|
||||
width: 30px;
|
||||
|
@ -61,12 +73,111 @@
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container">
|
||||
<h1>Gomoku</h1>
|
||||
<div id="game-container" hx-ext="ws">
|
||||
<div id="player-info"></div>
|
||||
<div id="game-board"></div>
|
||||
<div id="messages"></div>
|
||||
<div id="game-board" class="game-board-grid"></div>
|
||||
<div id="messages" hx-swap-oob="beforeend"></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>
|
||||
</html>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"name": "gomoku",
|
||||
"version": "1.0.50",
|
||||
"dependencies": {
|
||||
"@elysiajs/cookie": "^0.8.0",
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"elysia": "latest",
|
||||
"uuid": "^11.1.0"
|
||||
|
|
|
@ -1,167 +1,22 @@
|
|||
// 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.');
|
||||
console.log('Gomoku client entry point -- HTMX mode.');
|
||||
|
||||
const WS_URL = process.env.WS_URL || 'ws://localhost:3000/ws';
|
||||
|
||||
// Function to get a query parameter from the URL
|
||||
function getQueryParam(name: string): string | null {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get(name);
|
||||
}
|
||||
// This will be handled by HTMX's ws-connect
|
||||
// However, we might still need a way to send messages from client-side JS if required by HTMX
|
||||
|
||||
// Get gameId from URL, if present
|
||||
const gameIdFromUrl = getQueryParam('gameId');
|
||||
|
||||
let playerId: string; // Declare playerId here, accessible throughout the module
|
||||
|
||||
// Initialize components
|
||||
const gameStateManager = new GameStateManager();
|
||||
const wsClient = new WebSocketClient(WS_URL);
|
||||
|
||||
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!');
|
||||
// Example of how to send a message via the established HTMX WebSocket.
|
||||
// This is a placeholder and might evolve as we refactor.
|
||||
(window as any).sendWebSocketMessage = (message: any) => {
|
||||
const gameContainer = document.getElementById('game-container');
|
||||
if (gameContainer) {
|
||||
const ws = (gameContainer as any)._htmx_ws;
|
||||
if (ws) {
|
||||
ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.error(`Move failed: ${msg.error}`);
|
||||
gameStateManager.rollbackGameState();
|
||||
gameBoardUI.updateBoard(gameStateManager.getGameState()); // Re-render after rollback
|
||||
console.error('HTMX WebSocket not found on game-container.');
|
||||
}
|
||||
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}`);
|
||||
} else {
|
||||
console.error('Game container not found.');
|
||||
}
|
||||
} 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 moveCount = 0;
|
||||
|
||||
constructor() {
|
||||
this.id = uuidv4();
|
||||
constructor(id?: string) {
|
||||
this.id = id || uuidv4();
|
||||
this.board = Array.from({ length: this.boardSize }, () =>
|
||||
Array(this.boardSize).fill(null),
|
||||
);
|
||||
|
|
|
@ -7,8 +7,9 @@ export class GameManager {
|
|||
this.games = new Map();
|
||||
}
|
||||
|
||||
createGame(): GameInstance {
|
||||
const game = new GameInstance();
|
||||
// Overload createGame to optionally accept a gameId
|
||||
createGame(gameId?: string): GameInstance {
|
||||
const game = new GameInstance(gameId); // Pass gameId to GameInstance constructor
|
||||
this.games.set(game.id, game);
|
||||
return game;
|
||||
}
|
||||
|
|
|
@ -3,139 +3,241 @@ import { WebSocketHandler } from './WebSocketHandler';
|
|||
import { GameManager } from './GameManager';
|
||||
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', () => {
|
||||
let gameManager: GameManager;
|
||||
let webSocketHandler: WebSocketHandler;
|
||||
let mockWs: any;
|
||||
let mockWsData: { request: {}; gameId?: string; playerId?: string };
|
||||
let mockWs: MockElysiaWS;
|
||||
|
||||
beforeEach(() => {
|
||||
gameManager = new GameManager();
|
||||
|
||||
mockWsData = { request: {} };
|
||||
|
||||
mockWs = {
|
||||
// Mock standard WebSocket methods
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs._messageCallback = callback;
|
||||
if (event === 'close') mockWs._closeCallback = callback;
|
||||
if (event === 'error') mockWs._errorCallback = callback;
|
||||
close: mock(() => {}),
|
||||
|
||||
// Mock custom 'on' method for attaching callbacks
|
||||
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,
|
||||
_closeCallback: null,
|
||||
_errorCallback: null,
|
||||
data: mockWsData,
|
||||
};
|
||||
|
||||
webSocketHandler = new WebSocketHandler(gameManager);
|
||||
data: { query: {} },
|
||||
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(() => {}),
|
||||
});
|
||||
|
||||
const triggerMessage = (message: string) => {
|
||||
if (mockWs._messageCallback) {
|
||||
mockWs._messageCallback(message);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerClose = () => {
|
||||
if (mockWs._closeCallback) {
|
||||
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 register a new connection', () => {
|
||||
mockWs.data.gameId = 'test-game';
|
||||
mockWs.data.playerId = 'player-alpha';
|
||||
mockWs.data.query.gameId = 'test-game';
|
||||
mockWs.data.query.playerId = 'player-alpha';
|
||||
webSocketHandler.handleConnection(mockWs);
|
||||
expect((webSocketHandler as any).connections.get('test-game')).toContain(
|
||||
mockWs,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a join_game message for a new game', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
it('should process a join_game message for an already connected client', () => {
|
||||
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({
|
||||
type: 'join_game',
|
||||
gameId: gameId,
|
||||
playerId: 'player1',
|
||||
});
|
||||
triggerMessage(joinGameMessage);
|
||||
triggerMessage(mockWs, joinGameMessage);
|
||||
|
||||
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.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();
|
||||
gameManager.joinGame(game.id, 'player1');
|
||||
gameManager.joinGame(game.id, 'player2');
|
||||
game.addPlayer('player1');
|
||||
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({
|
||||
type: 'make_move',
|
||||
gameId: game.id,
|
||||
playerId: 'player1',
|
||||
row: 7,
|
||||
col: 7,
|
||||
});
|
||||
triggerMessage(makeMoveMessage);
|
||||
triggerMessage(mockWs, makeMoveMessage);
|
||||
|
||||
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.currentPlayer).toBe('white');
|
||||
});
|
||||
|
||||
it('should send an error for an invalid move', () => {
|
||||
const game = gameManager.createGame();
|
||||
gameManager.joinGame(game.id, 'player1');
|
||||
gameManager.joinGame(game.id, 'player2');
|
||||
game.addPlayer('player1');
|
||||
game.addPlayer('player2');
|
||||
game.currentPlayer = 'black';
|
||||
|
||||
game.status = 'playing';
|
||||
|
||||
mockWsData.gameId = game.id;
|
||||
mockWsData.playerId = 'player1';
|
||||
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
mockWs.data.gameId = game.id;
|
||||
mockWs.data.playerId = 'player1';
|
||||
mockWs.data.query.gameId = game.id;
|
||||
mockWs.data.query.playerId = 'player1';
|
||||
webSocketHandler.handleConnection(mockWs);
|
||||
|
||||
const makeMoveMessage1 = JSON.stringify({
|
||||
type: 'make_move',
|
||||
gameId: game.id,
|
||||
playerId: 'player1',
|
||||
row: 7,
|
||||
col: 7,
|
||||
});
|
||||
triggerMessage(makeMoveMessage1);
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'move_result', success: true }),
|
||||
);
|
||||
triggerMessage(mockWs, makeMoveMessage1);
|
||||
mockWs.send.mockClear();
|
||||
|
||||
game.currentPlayer = 'black';
|
||||
const makeMoveMessage2 = JSON.stringify({
|
||||
type: 'make_move',
|
||||
row: 7,
|
||||
col: 7,
|
||||
});
|
||||
triggerMessage(makeMoveMessage2);
|
||||
triggerMessage(mockWs, makeMoveMessage1);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'error', error: 'Cell already occupied' }),
|
||||
|
@ -143,26 +245,56 @@ describe('WebSocketHandler', () => {
|
|||
});
|
||||
|
||||
it('should handle ping/pong messages', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
webSocketHandler.handleConnection(mockWs);
|
||||
const pingMessage = JSON.stringify({ type: 'ping' });
|
||||
triggerMessage(pingMessage);
|
||||
triggerMessage(mockWs, pingMessage);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }));
|
||||
});
|
||||
|
||||
it('should handle player disconnection', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
it('should handle player disconnection and notify others', () => {
|
||||
const game = gameManager.createGame();
|
||||
const player1Ws = createNewMockWs();
|
||||
const player2Ws = createNewMockWs();
|
||||
|
||||
mockWsData.gameId = 'test-game-id';
|
||||
mockWsData.playerId = 'test-player-id';
|
||||
player1Ws.data.gameId = game.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', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
webSocketHandler.handleConnection(mockWs);
|
||||
const unknownMessage = JSON.stringify({ type: 'unknown_type' });
|
||||
triggerMessage(unknownMessage);
|
||||
triggerMessage(mockWs, unknownMessage);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
|
||||
|
@ -170,238 +302,96 @@ describe('WebSocketHandler', () => {
|
|||
});
|
||||
|
||||
it('should send error for invalid JSON message', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
webSocketHandler.handleConnection(mockWs);
|
||||
const invalidJsonMessage = 'not a json';
|
||||
triggerMessage(invalidJsonMessage);
|
||||
triggerMessage(mockWs, invalidJsonMessage);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should broadcast game state to a specific client when targetWs is 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, null, null, player1Ws);
|
||||
|
||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<div id="game-board"'),
|
||||
);
|
||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<div id="player-info"'),
|
||||
);
|
||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<div id="messages"'),
|
||||
);
|
||||
|
||||
expect(player2Ws.send).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should notify other players and remove a disconnected player', () => {
|
||||
const gameManager = new GameManager();
|
||||
const webSocketHandler = new WebSocketHandler(gameManager);
|
||||
|
||||
// Player 1
|
||||
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = {
|
||||
request: {},
|
||||
};
|
||||
const mockWs1: any = {
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs1._messageCallback = callback;
|
||||
if (event === 'close') mockWs1._closeCallback = callback;
|
||||
}),
|
||||
_messageCallback: null,
|
||||
_closeCallback: null,
|
||||
data: mockWsData1,
|
||||
};
|
||||
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';
|
||||
|
||||
// Player 2
|
||||
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = {
|
||||
request: {},
|
||||
};
|
||||
const mockWs2: any = {
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs2._messageCallback = callback;
|
||||
if (event === 'close') mockWs2._closeCallback = callback;
|
||||
}),
|
||||
_messageCallback: null,
|
||||
_closeCallback: null,
|
||||
data: mockWsData2,
|
||||
};
|
||||
const 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 triggerMessageForWs = (ws: any, message: string) => {
|
||||
if (ws._messageCallback) {
|
||||
ws._messageCallback(message);
|
||||
}
|
||||
};
|
||||
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);
|
||||
|
||||
const triggerCloseForWs = (ws: any) => {
|
||||
if (ws._closeCallback) {
|
||||
ws._closeCallback();
|
||||
}
|
||||
};
|
||||
player1Ws.send.mockClear();
|
||||
player2Ws.send.mockClear();
|
||||
|
||||
// Player 1 joins, creates game
|
||||
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
|
||||
triggerMessageForWs(
|
||||
mockWs1,
|
||||
JSON.stringify({ type: 'join_game', playerId: 'player1' }),
|
||||
webSocketHandler.broadcastGameUpdate(game.id, game);
|
||||
|
||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<div id="game-board"'),
|
||||
);
|
||||
mockWs1.data.gameId = mockWsData1.gameId;
|
||||
mockWs1.data.playerId = 'player1';
|
||||
|
||||
// Player 2 joins same game
|
||||
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
|
||||
triggerMessageForWs(
|
||||
mockWs2,
|
||||
JSON.stringify({
|
||||
type: 'join_game',
|
||||
gameId: mockWsData1.gameId,
|
||||
playerId: 'player2',
|
||||
}),
|
||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<div id="player-info"'),
|
||||
);
|
||||
mockWs2.data.gameId = mockWsData1.gameId;
|
||||
mockWs2.data.playerId = 'player2';
|
||||
|
||||
// Player 2 disconnects
|
||||
mockWs1.send.mockClear(); // Clear P1's send history before P2 disconnects
|
||||
triggerCloseForWs(mockWs2);
|
||||
|
||||
// Expect Player 1 to receive player_disconnected message
|
||||
expect(mockWs1.send).toHaveBeenCalledTimes(1);
|
||||
const receivedMessage = JSON.parse(mockWs1.send.mock.calls[0][0]);
|
||||
expect(receivedMessage.type).toBe('player_disconnected');
|
||||
expect(receivedMessage.playerId).toBe('player2');
|
||||
expect(receivedMessage.gameId).toBe(mockWsData1.gameId);
|
||||
|
||||
// Verify connections map is updated (Player 2 removed)
|
||||
// @ts-ignore
|
||||
expect(webSocketHandler.connections.get(mockWsData1.gameId)).toContain(
|
||||
mockWs1,
|
||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<div id="messages"'),
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(webSocketHandler.connections.get(mockWsData1.gameId)).not.toContain(
|
||||
mockWs2,
|
||||
|
||||
expect(player2Ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<div id="game-board"'),
|
||||
);
|
||||
expect(player2Ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<div id="player-info"'),
|
||||
);
|
||||
expect(player2Ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('<div id="messages"'),
|
||||
);
|
||||
});
|
||||
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;
|
||||
|
||||
// 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 {
|
||||
renderGameBoardHtml,
|
||||
renderPlayerInfoHtml,
|
||||
} from '../view/board-renderer';
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
gameId?: string;
|
||||
playerId?: string;
|
||||
row?: number;
|
||||
col?: number;
|
||||
state?: any; // GameState
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
interface MakeMoveMessage {
|
||||
gameId: string;
|
||||
playerId: string;
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
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(gameManager: GameManager) {
|
||||
this.gameManager = gameManager;
|
||||
constructor() {
|
||||
this.connections = new Map();
|
||||
this.games = new Map();
|
||||
}
|
||||
|
||||
public handleConnection(ws: any, req: any): void {
|
||||
console.log('WebSocket connected');
|
||||
public handleConnection(ws: any, gameId: string, playerId: string): void {
|
||||
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 {
|
||||
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' }),
|
||||
this.sendMessage(
|
||||
ws.data.gameId,
|
||||
'Error: server-side WebSocket error',
|
||||
ws,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public handleMessage(ws: any, message: string): void {
|
||||
try {
|
||||
const parsedMessage: WebSocketMessage = JSON.parse(message);
|
||||
console.log('Received message:', parsedMessage);
|
||||
|
||||
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' }),
|
||||
);
|
||||
public handleMessage(ws: any, message: any): void {
|
||||
const type: string = message.type;
|
||||
// Someday we might have other message types
|
||||
if (type === 'make_move') {
|
||||
this.handleMakeMove(ws, message as MakeMoveMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private handleJoinGame(ws: any, message: WebSocketMessage): 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 {
|
||||
private handleMakeMove(ws: any, message: MakeMoveMessage): void {
|
||||
const { row, col } = message;
|
||||
const gameId = ws.data.gameId;
|
||||
const playerId = ws.data.playerId;
|
||||
console.log(`Handling make_move message in game ${gameId} from player ${playerId}: ${{message}}`);
|
||||
|
||||
if (!gameId || !playerId) {
|
||||
ws.send(JSON.stringify({ type: 'error', error: 'Not in a game' }));
|
||||
return;
|
||||
}
|
||||
|
||||
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' }),
|
||||
if (!gameId || !playerId || row === undefined || col === undefined) {
|
||||
this.sendMessage(
|
||||
gameId,
|
||||
'Error: missing gameId, playerId, row, or col',
|
||||
ws,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerColor =
|
||||
game.players.black === playerId
|
||||
? 'black'
|
||||
: game.players.white === playerId
|
||||
? 'white'
|
||||
: null;
|
||||
const game = this.games.get(gameId);
|
||||
if (!game) {
|
||||
this.sendMessage(gameId, 'Error: game not found', ws);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerColor = Object.entries(game.players).find(
|
||||
([_, id]) => id === playerId,
|
||||
)?.[0] as ('black' | 'white') | undefined;
|
||||
if (!playerColor) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
error: 'You are not a player in this game',
|
||||
}),
|
||||
this.sendMessage(
|
||||
gameId,
|
||||
'Error: you are not a player in this game',
|
||||
ws,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = game.makeMove(playerId, row, col);
|
||||
ws.send(JSON.stringify({ type: 'move_result', success: result.success }));
|
||||
if (result.success) {
|
||||
// Broadcast updated game state to all players in the game
|
||||
this.broadcastGameState(gameId, game);
|
||||
this.broadcastGameState(game.id);
|
||||
console.log(
|
||||
`Move made in game ${gameId} by ${playerId}: (${row}, ${col})`,
|
||||
`Move made in game ${game.id} by ${playerId}: (${row}, ${col})`,
|
||||
);
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
error: result.error || 'Invalid move',
|
||||
}),
|
||||
);
|
||||
this.sendMessage(gameId, result.error || 'Error: invalid move', ws);
|
||||
}
|
||||
} 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;
|
||||
|
||||
if (gameId && playerId) {
|
||||
// Remove disconnected player's websocket from connections
|
||||
const connectionsInGame = this.connections.get(gameId);
|
||||
if (connectionsInGame) {
|
||||
this.connections.set(
|
||||
gameId,
|
||||
connectionsInGame.filter((conn: any) => conn !== ws),
|
||||
connectionsInGame.filter((conn) => conn !== ws),
|
||||
);
|
||||
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)) {
|
||||
const disconnectMessage = JSON.stringify({
|
||||
type: 'player_disconnected',
|
||||
playerId: playerId,
|
||||
gameId: gameId,
|
||||
});
|
||||
this.connections.get(gameId)?.forEach((playerWs: any) => {
|
||||
playerWs.send(disconnectMessage);
|
||||
});
|
||||
// Notify remaining players about disconnect
|
||||
this.sendMessage(gameId, 'message', `${playerId} disconnected.`);
|
||||
}
|
||||
console.log(`${playerId} disconnected from game ${gameId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Method to send updated game state to all participants in a game
|
||||
// This would typically be called by GameManager when game state changes
|
||||
public broadcastGameState(gameId: string, state: any): void {
|
||||
const message = JSON.stringify({ type: 'game_state', state });
|
||||
const connectionsInGame = this.connections.get(gameId);
|
||||
public broadcastGameState(gameId: string): void {
|
||||
const game = this.games.get(gameId);
|
||||
if (!game) {
|
||||
console.warn('Attempted to broadcast state of game ${gameId}, which is not loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionsInGame) {
|
||||
connectionsInGame.forEach((ws: any) => {
|
||||
ws.send(message);
|
||||
const connectionsToUpdate = this.connections.get(gameId);
|
||||
if (connectionsToUpdate) {
|
||||
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 { cookie } from '@elysiajs/cookie';
|
||||
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
|
||||
import { GameInstance } from './game/GameInstance';
|
||||
|
||||
// Initialize GameManager (server-side)
|
||||
const gameManager = new GameManager();
|
||||
|
||||
// Initialize WebSocketHandler with the gameManager
|
||||
const wsHandler = new WebSocketHandler(gameManager);
|
||||
// Initialize WebSocketHandler
|
||||
const wsHandler = new WebSocketHandler();
|
||||
|
||||
const app = new Elysia()
|
||||
.use(
|
||||
staticPlugin({
|
||||
assets: 'dist', // Serve static files from the dist directory
|
||||
prefix: '/dist', // Serve them under the /dist path
|
||||
assets: 'dist',
|
||||
prefix: '/dist',
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
staticPlugin({
|
||||
assets: '.',
|
||||
}),
|
||||
)
|
||||
.use(cookie())
|
||||
.ws('/ws', {
|
||||
query: t.Object({
|
||||
gameId: t.String(),
|
||||
playerId: t.String(),
|
||||
}),
|
||||
open(ws) {
|
||||
// Call the handler's connection logic
|
||||
// Elysia's ws context directly provides the ws object
|
||||
wsHandler.handleConnection(ws as any, {});
|
||||
const { gameId, playerId } = ws.data.query;
|
||||
|
||||
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) {
|
||||
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 {
|
||||
// Assuming it's always a stringified JSON for 'make_move'
|
||||
msgString = message as string;
|
||||
}
|
||||
wsHandler.handleMessage(ws as any, msgString);
|
||||
wsHandler.handleMessage(ws, msgString);
|
||||
},
|
||||
close(ws) {
|
||||
// Call the handler's disconnection logic
|
||||
wsHandler.handleDisconnect(ws as any);
|
||||
},
|
||||
error(context: any) {
|
||||
// Call the handler's error logic
|
||||
wsHandler.handleError(context.ws as any, context.error);
|
||||
wsHandler.handleDisconnect(ws);
|
||||
},
|
||||
})
|
||||
.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, () => {
|
||||
console.log(
|
||||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
|
||||
);
|
||||
});
|
||||
|
||||
console.log('Elysia server started!');
|
||||
|
|
|
@ -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…
Reference in New Issue