import { ElysiaWS } from 'elysia/dist/ws'; import { Html } from '@elysiajs/html'; import { GomokuGame as GomokuGame, PlayerColor } from './game/game-instance'; import { renderGameBoardHtml } from './view/board-renderer'; import { Message, MakeMoveMessage, ResignationMessage, TakebackMessage, DrawMessage, RematchMessage, RedirectToGameMessage, UpdateDisplayNameMessage, } from './messages'; import { v4 as uuidv4 } from 'uuid'; type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>; class PlayerConnection { id: string; name: string; ws: WS; constructor(id: string, name: string, ws: WS) { this.id = id; this.name = name; this.ws = ws; } public sendMessage(severity: 'info' | 'error', message: string) { console.log(`Sending message ${message} to player ${this.id}`); // TODO } } class GameServer { id: string; gomoku: GomokuGame; connections: Map; blackPlayerId?: string; whitePlayerId?: string; takebackRequesterId: string | null = null; drawRequesterId: string | null = null; rematchRequesterId: string | null = null; constructor( id: string, private webSocketHandler: WebSocketHandler, ) { this.id = id; this.gomoku = new GomokuGame(); this.connections = new Map(); } public handleConnection(ws: WS) { const { playerId } = ws.data.query; if (this.connections.has(playerId)) { const existingConn = this.connections.get(playerId)!; existingConn.ws = ws; // Update with new WebSocket console.log( `Updated connection for player ${playerId} in game ${this.id}, replacing old WS.`, ); } else { const playerName = this.webSocketHandler.getPlayerName(playerId); // Retrieve name or use ID const conn = new PlayerConnection(playerId, playerName, ws); this.connections.set(playerId, conn); console.log( `Created new connection with player ${conn.id} in game ${this.id}`, ); if (!this.blackPlayerId) { this.blackPlayerId = conn.id; } else if (!this.whitePlayerId) { this.whitePlayerId = conn.id; } } if (this.whitePlayerId && this.blackPlayerId) { this.gomoku.status = 'playing'; } this.broadcastBoard(); this.broadcastButtons(); this.broadcastTitle(); } public handleDisconnect(ws: WS) { const { playerId } = ws.data.query; this.connections.delete(playerId); this.broadcastTitle(); } public broadcastBoard() { this.connections.forEach((conn: PlayerConnection) => this.broadcastBoardToPlayer(conn), ); } public broadcastTitle() { this.connections.forEach((conn: PlayerConnection) => this.broadcastTitleToPlayer(conn), ); } public broadcastButtons() { this.connections.forEach((conn: PlayerConnection) => this.broadcastButtonsToPlayer(conn), ); } public broadcastBoardToPlayer(conn: PlayerConnection) { const isToPlay = this.gomoku.currentPlayerColor == this.getPlayerColor(conn); const updatedBoardHtml = renderGameBoardHtml(this.gomoku, isToPlay); conn.ws.send(updatedBoardHtml); console.log(`Sent board for game ${this.id} to player ${conn.id}`); } public broadcastTitleToPlayer(conn: PlayerConnection) { let message = ''; switch (this.gomoku.status) { case 'waiting': { message = 'Waiting for players...'; break; } case 'playing': { const blackTag = this.playerTag(this.blackPlayerId!, 'black'); const whiteTag = this.playerTag(this.whitePlayerId!, 'white'); message = `${blackTag} vs ${whiteTag}`; break; } case 'finished': { switch (this.gomoku.winnerColor) { case 'draw': { message = 'Game ended in draw.'; break; } case 'black': { const name = this.connections.get(this.blackPlayerId!)?.name; message = `${name} wins!`; break; } case 'white': { const name = this.connections.get(this.whitePlayerId!)?.name; message = `${name} wins!`; break; } } break; } } conn.ws.send(
{message}
); console.log(`Sent title for game ${this.id} to player ${conn.id}`); } public broadcastButtonsToPlayer(conn: PlayerConnection) { const buttons: JSX.Element[] = []; if (this.gomoku.status == 'playing' && this.getPlayerColor(conn)) { if (this.takebackRequesterId) { if (this.takebackRequesterId === conn.id) { buttons.push( , ); } else { buttons.push( , ); buttons.push( , ); } } else if (this.drawRequesterId) { if (this.drawRequesterId === conn.id) { buttons.push( , ); } else { buttons.push( , ); buttons.push( , ); } } else { buttons.push( , ); buttons.push( , ); buttons.push( , ); } } else if (this.gomoku.status === 'finished') { if (this.rematchRequesterId) { if (this.rematchRequesterId === conn.id) { buttons.push( , ); } else { buttons.push( , ); buttons.push( , ); } } else { buttons.push( , ); } } else if (this.gomoku.status === 'waiting') { buttons.push( , ); } conn.ws.send(
{buttons}
); console.log(`Sent buttons for game ${this.id} to player ${conn.id}`); } private playerTag(playerId: string, color: PlayerColor) { const connectionIcon = this.connections.has(playerId) ? '' : `Disconnected`; var turnClass = this.gomoku.currentPlayerColor === color ? 'player-to-play' : ''; const classes = `player-name ${'player-' + color} ${turnClass}`.trim(); return ( {this.connections.get(playerId)?.name} {connectionIcon} ); } public handleMessage(ws: WS, message: Message): void { const conn = this.connections.get(ws.data.query.playerId); if (!conn) { console.error( `Failed to handle message from player ${ws.data.query.playerId}, because they are not in the game ${this.id}, which it was routed to`, ); return; } console.log( `Handling ${message.type} message in game ${this.id} from player ${conn.id}: ${JSON.stringify(message)}`, ); switch (message.type) { case 'make_move': { this.handleMakeMove(conn, message as MakeMoveMessage); break; } case 'resign': { this.handleResignation(conn, message as ResignationMessage); break; } case 'takeback': { this.handleTakebackMessage(conn, message as TakebackMessage); break; } case 'draw': { this.handleDrawMessage(conn, message as DrawMessage); break; } case 'rematch': { this.handleRematchMessage(conn, message as RematchMessage); break; } case 'update_display_name': { this.handleUpdateDisplayName(conn, message as UpdateDisplayNameMessage); break; } } } private getPlayerColor(conn: PlayerConnection): PlayerColor | undefined { if (this.blackPlayerId === conn.id) { return 'black'; } else if (this.whitePlayerId === conn.id) { return 'white'; } else { return undefined; } } private handleMakeMove( conn: PlayerConnection, message: MakeMoveMessage, ): void { const { row, col } = message; var playerColor; if (this.blackPlayerId === conn.id) { playerColor = 'black'; } else if (this.whitePlayerId == conn.id) { playerColor = 'white'; } else { conn.sendMessage( 'error', 'You are not a player in this game, you cannot make a move!', ); return; } if (this.gomoku.currentPlayerColor !== playerColor) { conn.sendMessage('error', "It's not your turn"); return; } if (this.takebackRequesterId || this.drawRequesterId) { this.takebackRequesterId = null; this.drawRequesterId = null; this.broadcastButtons(); } const stateBeforeMove = this.gomoku.status; const result = this.gomoku.makeMove(playerColor, row, col); if (result.success) { this.broadcastBoard(); this.broadcastTitle(); // We only need to re-send buttons when the game state changes if (stateBeforeMove != this.gomoku.status) { this.broadcastButtons(); } console.log( `Move made in game ${this.id} by ${conn.id}: (${row}, ${col})`, ); } else { conn.sendMessage('error', result.error!); } } private handleResignation( conn: PlayerConnection, message: ResignationMessage, ): void { console.log( `Handling resign message in game ${this.id} from player ${conn.id}: ${{ message }}`, ); if (this.gomoku.status !== 'playing') { conn.sendMessage('error', 'You can only resign from an active game.'); return; } const resigningPlayerColor = this.getPlayerColor(conn); if (!resigningPlayerColor) { conn.sendMessage('error', 'You are not a player in this game.'); return; } this.gomoku.resign(resigningPlayerColor); this.broadcastBoard(); this.broadcastTitle(); this.broadcastButtons(); console.log(`Player ${conn.id} resigned from game ${this.id}`); } private handleTakebackMessage( conn: PlayerConnection, message: TakebackMessage, ): void { if (this.gomoku.status !== 'playing') { conn.sendMessage( 'error', 'You can only perform this action in an active game.', ); return; } switch (message.action) { case 'request': this.handleRequestTakeback(conn); break; case 'accept': if (!this.takebackRequesterId) { conn.sendMessage('error', 'No takeback has been requested.'); return; } this.handleAcceptTakeback(); break; case 'decline': if (!this.takebackRequesterId) { conn.sendMessage('error', 'No takeback has been requested.'); return; } this.handleDeclineTakeback(); break; case 'cancel': if (this.takebackRequesterId !== conn.id) { conn.sendMessage( 'error', 'You are not the one who requested a takeback.', ); return; } this.handleCancelTakebackRequest(); break; } } private handleDrawMessage( conn: PlayerConnection, message: DrawMessage, ): void { if (this.gomoku.status !== 'playing') { conn.sendMessage( 'error', 'You can only perform this action in an active game.', ); return; } switch (message.action) { case 'request': this.handleRequestDraw(conn); break; case 'accept': if (!this.drawRequesterId) { conn.sendMessage('error', 'No draw has been requested.'); return; } this.handleAcceptDraw(); break; case 'decline': if (!this.drawRequesterId) { conn.sendMessage('error', 'No draw has been requested.'); return; } this.handleDeclineDraw(); break; case 'cancel': if (this.drawRequesterId !== conn.id) { conn.sendMessage( 'error', 'You are not the one who requested a draw.', ); return; } this.handleCancelDrawRequest(); break; } } private handleRematchMessage( conn: PlayerConnection, message: RematchMessage, ): void { if (this.gomoku.status !== 'finished') { conn.sendMessage( 'error', 'You can only perform this action in a finished game.', ); return; } switch (message.action) { case 'request': this.handleRequestRematch(conn); break; case 'accept': if (!this.rematchRequesterId) { conn.sendMessage('error', 'No rematch has been requested.'); return; } this.handleAcceptRematch(); break; case 'decline': if (!this.rematchRequesterId) { conn.sendMessage('error', 'No rematch has been requested.'); return; } this.handleDeclineRematch(); break; case 'cancel': if (this.rematchRequesterId !== conn.id) { conn.sendMessage( 'error', 'You are not the one who requested a rematch.', ); return; } this.handleCancelRematchRequest(); break; } } private handleRequestTakeback(conn: PlayerConnection): void { if (this.gomoku.history.length === 0) { conn.sendMessage('error', 'There are no moves to take back.'); return; } if (this.drawRequesterId) { conn.sendMessage('error', 'A draw has already been requested.'); return; } this.takebackRequesterId = conn.id; this.broadcastButtons(); } private handleAcceptTakeback(): void { this.gomoku.undoMove(); this.takebackRequesterId = null; this.broadcastBoard(); this.broadcastButtons(); this.broadcastTitle(); } private handleDeclineTakeback(): void { this.takebackRequesterId = null; this.broadcastButtons(); } private handleRequestDraw(conn: PlayerConnection): void { if (this.takebackRequesterId) { conn.sendMessage('error', 'A takeback has already been requested.'); return; } this.drawRequesterId = conn.id; this.broadcastButtons(); } private handleAcceptDraw(): void { this.gomoku.declareDraw(); this.drawRequesterId = null; this.broadcastBoard(); this.broadcastButtons(); this.broadcastTitle(); } private handleDeclineDraw(): void { this.drawRequesterId = null; this.broadcastButtons(); } private handleRequestRematch(conn: PlayerConnection): void { this.rematchRequesterId = conn.id; this.broadcastButtons(); } private handleAcceptRematch(): void { const newGameId = this.webSocketHandler.createGame( undefined, this.whitePlayerId, this.blackPlayerId, ); const redirectMessage: RedirectToGameMessage = { type: 'redirect_to_game', gameId: newGameId, }; this.connections.forEach((c) => { c.ws.send(redirectMessage); }); } private handleDeclineRematch(): void { this.rematchRequesterId = null; this.broadcastButtons(); } private handleCancelTakebackRequest(): void { this.takebackRequesterId = null; this.broadcastButtons(); } private handleCancelDrawRequest(): void { this.drawRequesterId = null; this.broadcastButtons(); } private handleCancelRematchRequest(): void { this.rematchRequesterId = null; this.broadcastButtons(); } private handleUpdateDisplayName( conn: PlayerConnection, message: UpdateDisplayNameMessage, ): void { const newDisplayName = message.displayName.trim(); if (!newDisplayName) { conn.sendMessage('error', 'Display name cannot be empty.'); return; } if (newDisplayName.length > 20) { conn.sendMessage( 'error', 'Display name cannot be longer than 20 characters.', ); return; } if (newDisplayName === conn.name) { return; // No change, do nothing } conn.name = newDisplayName; this.webSocketHandler.setPlayerName(conn.id, newDisplayName); this.broadcastTitle(); } } export class WebSocketHandler { private games: Map; private playerNames: Map; constructor() { this.games = new Map(); this.playerNames = new Map(); } public handleConnection(ws: WS): void { const { gameId, playerId } = ws.data.query; if (this.games.has(gameId)) { const game = this.games.get(gameId)!; game.handleConnection(ws); } else { ws.send('Error: game not found'); ws.close(); } } public handleDisconnect(ws: WS): void { const { gameId, playerId } = ws.data.query; const game = this.games.get(gameId); if (!game) { console.error( `Attempted to disconnect player ${playerId} from game ${gameId}, which does not exist!`, ); return; } game.handleDisconnect(ws); console.log(`${playerId} disconnected from game ${gameId}`); if (game.connections.size == 0) { this.games.delete(gameId); console.log(`Game ${gameId} has been deleted (empty).`); } } public getPlayerName(playerId: string): string { const name = this.playerNames.get(playerId); return name ? name : 'New Player'; } public setPlayerName(playerId: string, displayName: string) { this.playerNames.set(playerId, displayName); } public handleMessage(ws: WS, messageUnparsed: any) { let message = messageUnparsed as Message; if (!message) { console.log( `Received malformed message ${messageUnparsed} from player ${ws.data.query.playerId}.`, ); ws.send('Error: malformed message!'); return; } const { gameId } = ws.data.query; const gameServer = this.games.get(gameId); if (!gameServer) { console.error( `A WebSocket connection was left open for the non-existent game ${gameId}`, ); return; } gameServer.handleMessage(ws, message); } public createGame( id?: string, blackPlayerId?: string, whitePlayerId?: string, ): string { const realId = id ? id : uuidv4(); this.games.set(realId, new GameServer(realId, this)); const game = this.games.get(realId)!; game.blackPlayerId = blackPlayerId; game.whitePlayerId = whitePlayerId; return realId; } public hasGame(id: string): boolean { return this.games.has(id); } }