From ad935c0b56aa3e045734f959d074d9eb4f1409ae Mon Sep 17 00:00:00 2001 From: sepia Date: Mon, 28 Jul 2025 22:48:30 -0500 Subject: [PATCH] Big refactor --- src/game-server.ts | 140 +++++ src/messages.ts | 39 -- src/messages/draw.ts | 80 +++ src/messages/make-move.ts | 100 ++++ src/messages/messages.ts | 4 + src/messages/rematch.ts | 84 +++ src/messages/resign.ts | 48 ++ src/messages/takeback.ts | 88 ++++ src/messages/update-display-name.ts | 37 ++ src/player-connection.ts | 20 + src/view/board-renderer.tsx | 21 +- src/view/button-renderer.tsx | 183 +++++++ src/view/sound-renderer.tsx | 15 + src/view/title-renderer.tsx | 65 +++ src/web-socket-handler.tsx | 774 +--------------------------- 15 files changed, 889 insertions(+), 809 deletions(-) create mode 100755 src/game-server.ts delete mode 100755 src/messages.ts create mode 100755 src/messages/draw.ts create mode 100755 src/messages/make-move.ts create mode 100755 src/messages/messages.ts create mode 100755 src/messages/rematch.ts create mode 100755 src/messages/resign.ts create mode 100755 src/messages/takeback.ts create mode 100755 src/messages/update-display-name.ts create mode 100755 src/player-connection.ts create mode 100755 src/view/button-renderer.tsx create mode 100755 src/view/sound-renderer.tsx create mode 100755 src/view/title-renderer.tsx diff --git a/src/game-server.ts b/src/game-server.ts new file mode 100755 index 0000000..91271cf --- /dev/null +++ b/src/game-server.ts @@ -0,0 +1,140 @@ +import { WS } from '.'; +import { GomokuGame, PlayerColor } from './game/game-instance'; +import { handleDrawMessage, DrawMessage } from './messages/draw'; +import { handleMakeMove, MakeMoveMessage } from './messages/make-move'; +import { Message } from './messages/messages'; +import { handleRematchMessage, RematchMessage } from './messages/rematch'; +import { handleResignation, ResignationMessage } from './messages/resign'; +import { handleTakebackMessage, TakebackMessage } from './messages/takeback'; +import { + handleUpdateDisplayName, + UpdateDisplayNameMessage, +} from './messages/update-display-name'; +import { PlayerId, PlayerConnection } from './player-connection'; +import { broadcastBoard } from './view/board-renderer'; +import { broadcastButtons } from './view/button-renderer'; +import { broadcastTitle } from './view/title-renderer'; +import { WebSocketHandler } from './web-socket-handler'; + +export type GameId = string; + +export class GameServer { + id: GameId; + gomoku: GomokuGame; + connections: Map; + blackPlayerId?: PlayerId; + whitePlayerId?: PlayerId; + takebackRequesterId: PlayerId | null = null; + drawRequesterId: PlayerId | null = null; + rematchRequesterId: PlayerId | null = null; + + constructor( + id: GameId, + public 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'; + } + + broadcastBoard(this); + broadcastButtons(this); + broadcastTitle(this); + } + + public handleDisconnect(ws: WS) { + const { playerId } = ws.data.query; + this.connections.delete(playerId); + broadcastTitle(this); + } + + 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': { + handleMakeMove(this, conn, message as MakeMoveMessage); + break; + } + case 'resign': { + handleResignation(this, conn, message as ResignationMessage); + break; + } + case 'takeback': { + handleTakebackMessage(this, conn, message as TakebackMessage); + break; + } + case 'draw': { + handleDrawMessage(this, conn, message as DrawMessage); + break; + } + case 'rematch': { + handleRematchMessage(this, conn, message as RematchMessage); + break; + } + case 'update_display_name': { + handleUpdateDisplayName( + this, + conn, + message as UpdateDisplayNameMessage, + ); + break; + } + } + } + + public getPlayerColor(conn: PlayerConnection): PlayerColor | undefined { + if (this.blackPlayerId === conn.id) { + return 'black'; + } else if (this.whitePlayerId === conn.id) { + return 'white'; + } else { + return undefined; + } + } + + public getPlayerFromColor(color: PlayerColor) { + if (color === 'white' && this.whitePlayerId) { + return this.connections.get(this.whitePlayerId); + } else if (color === 'black' && this.blackPlayerId) { + return this.connections.get(this.blackPlayerId); + } else { + return null; + } + } +} diff --git a/src/messages.ts b/src/messages.ts deleted file mode 100755 index 545a241..0000000 --- a/src/messages.ts +++ /dev/null @@ -1,39 +0,0 @@ -export type ActionType = 'request' | 'accept' | 'decline' | 'cancel'; - -export interface Message { - type: - | 'make_move' - | 'resign' - | 'takeback' - | 'draw' - | 'rematch' - | 'redirect_to_game' - | 'update_display_name'; -} - -export interface UpdateDisplayNameMessage extends Message { - displayName: string; -} - -export interface MakeMoveMessage extends Message { - row: number; - col: number; -} - -export interface ResignationMessage extends Message {} - -export interface TakebackMessage extends Message { - action: ActionType; -} - -export interface DrawMessage extends Message { - action: ActionType; -} - -export interface RematchMessage extends Message { - action: ActionType; -} - -export interface RedirectToGameMessage extends Message { - gameId: string; -} diff --git a/src/messages/draw.ts b/src/messages/draw.ts new file mode 100755 index 0000000..e6e6674 --- /dev/null +++ b/src/messages/draw.ts @@ -0,0 +1,80 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastBoard } from '../view/board-renderer'; +import { broadcastButtons } from '../view/button-renderer'; +import { broadcastSound } from '../view/sound-renderer'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { ActionType, Message } from './messages'; + +export interface DrawMessage extends Message { + action: ActionType; +} + +export function handleDrawMessage( + server: GameServer, + conn: PlayerConnection, + message: DrawMessage, +): void { + if (server.gomoku.status !== 'playing') { + conn.sendMessage( + 'error', + 'You can only perform this action in an active game.', + ); + return; + } + switch (message.action) { + case 'request': + handleRequestDraw(server, conn); + break; + case 'accept': + if (!server.drawRequesterId) { + conn.sendMessage('error', 'No draw has been requested.'); + return; + } + handleAcceptDraw(server); + break; + case 'decline': + if (!server.drawRequesterId) { + conn.sendMessage('error', 'No draw has been requested.'); + return; + } + handleDeclineDraw(server); + break; + case 'cancel': + if (server.drawRequesterId !== conn.id) { + conn.sendMessage('error', 'You are not the one who requested a draw.'); + return; + } + handleCancelDrawRequest(server); + break; + } +} + +function handleRequestDraw(server: GameServer, conn: PlayerConnection): void { + if (server.takebackRequesterId) { + conn.sendMessage('error', 'A takeback has already been requested.'); + return; + } + + server.drawRequesterId = conn.id; + broadcastButtons(server); +} + +function handleAcceptDraw(server: GameServer): void { + server.gomoku.declareDraw(); + server.drawRequesterId = null; + broadcastBoard(server); + broadcastButtons(server); + broadcastTitle(server); + broadcastSound(server, 'draw'); +} + +function handleDeclineDraw(server: GameServer): void { + server.drawRequesterId = null; + broadcastButtons(server); +} + +function handleCancelDrawRequest(server: GameServer): void { + server.drawRequesterId = null; + broadcastButtons(server); +} diff --git a/src/messages/make-move.ts b/src/messages/make-move.ts new file mode 100755 index 0000000..0fadb31 --- /dev/null +++ b/src/messages/make-move.ts @@ -0,0 +1,100 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastBoard } from '../view/board-renderer'; +import { broadcastButtons } from '../view/button-renderer'; +import { broadcastSound, broadcastSoundToPlayer } from '../view/sound-renderer'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { Message } from './messages'; + +export interface MakeMoveMessage extends Message { + row: number; + col: number; +} + +export function handleMakeMove( + server: GameServer, + conn: PlayerConnection, + message: MakeMoveMessage, +): void { + const { row, col } = message; + + if (server.gomoku.status !== 'playing') { + conn.sendMessage( + 'error', + 'You cannot play a move while the game is not ongoing!', + ); + return; + } + + var playerColor; + if (server.blackPlayerId === conn.id) { + playerColor = 'black'; + } else if (server.whitePlayerId == conn.id) { + playerColor = 'white'; + } else { + conn.sendMessage( + 'error', + 'You are not a player in this game, you cannot make a move!', + ); + return; + } + if (server.gomoku.currentPlayerColor !== playerColor) { + conn.sendMessage('error', "It's not your turn"); + return; + } + + if (server.takebackRequesterId || server.drawRequesterId) { + server.takebackRequesterId = null; + server.drawRequesterId = null; + broadcastButtons(server); + } + + const stateBeforeMove = server.gomoku.status; + const result = server.gomoku.makeMove(playerColor, row, col); + if (result.success) { + broadcastBoard(server); + broadcastTitle(server); + // We only need to re-send buttons when the game state changes + if (stateBeforeMove != server.gomoku.status) { + broadcastButtons(server); + } + + // Broadcast sounds + if (server.gomoku.status === 'playing') { + broadcastSound(server, 'move'); + } else { + const whiteConn = server.whitePlayerId + ? server.connections.get(server.whitePlayerId) + : null; + const blackConn = server.blackPlayerId + ? server.connections.get(server.blackPlayerId) + : null; + switch (server.gomoku.winnerColor) { + case 'draw': + broadcastSound(server, 'draw'); + case 'white': + if (whiteConn) { + broadcastSoundToPlayer(whiteConn, 'victory'); + } + if (blackConn) { + broadcastSoundToPlayer(blackConn, 'defeat'); + } + break; + case 'black': + if (whiteConn) { + broadcastSoundToPlayer(whiteConn, 'defeat'); + } + if (blackConn) { + broadcastSoundToPlayer(blackConn, 'victory'); + } + break; + } + } + + console.log( + `Move made in game ${server.id} by ${conn.id}: (${row}, ${col})`, + ); + } else { + conn.sendMessage('error', result.error!); + } +} diff --git a/src/messages/messages.ts b/src/messages/messages.ts new file mode 100755 index 0000000..16474da --- /dev/null +++ b/src/messages/messages.ts @@ -0,0 +1,4 @@ +export interface Message { + type: string; +} +export type ActionType = 'request' | 'accept' | 'decline' | 'cancel'; diff --git a/src/messages/rematch.ts b/src/messages/rematch.ts new file mode 100755 index 0000000..c2b9352 --- /dev/null +++ b/src/messages/rematch.ts @@ -0,0 +1,84 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastButtons } from '../view/button-renderer'; +import { GameServer } from '../game-server'; +import { ActionType, Message } from './messages'; + +export interface RematchMessage extends Message { + action: ActionType; +} + +export function handleRematchMessage( + server: GameServer, + conn: PlayerConnection, + message: RematchMessage, +): void { + if (server.gomoku.status !== 'finished') { + conn.sendMessage( + 'error', + 'You can only perform this action in a finished game.', + ); + return; + } + switch (message.action) { + case 'request': + handleRequestRematch(server, conn); + break; + case 'accept': + if (!server.rematchRequesterId) { + conn.sendMessage('error', 'No rematch has been requested.'); + return; + } + handleAcceptRematch(server); + break; + case 'decline': + if (!server.rematchRequesterId) { + conn.sendMessage('error', 'No rematch has been requested.'); + return; + } + handleDeclineRematch(server); + break; + case 'cancel': + if (server.rematchRequesterId !== conn.id) { + conn.sendMessage( + 'error', + 'You are not the one who requested a rematch.', + ); + return; + } + handleCancelRematchRequest(server); + break; + } +} + +function handleRequestRematch( + server: GameServer, + conn: PlayerConnection, +): void { + server.rematchRequesterId = conn.id; + broadcastButtons(server); +} + +function handleAcceptRematch(server: GameServer): void { + const newGameId = server.webSocketHandler.createGame( + undefined, + server.whitePlayerId, + server.blackPlayerId, + ); + const redirectMessage = { + type: 'redirect_to_game', + gameId: newGameId, + }; + server.connections.forEach((c) => { + c.ws.send(redirectMessage); + }); +} + +function handleDeclineRematch(server: GameServer): void { + server.rematchRequesterId = null; + broadcastButtons(server); +} + +function handleCancelRematchRequest(server: GameServer): void { + server.rematchRequesterId = null; + broadcastButtons(server); +} diff --git a/src/messages/resign.ts b/src/messages/resign.ts new file mode 100755 index 0000000..902b933 --- /dev/null +++ b/src/messages/resign.ts @@ -0,0 +1,48 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastBoard } from '../view/board-renderer'; +import { broadcastButtons } from '../view/button-renderer'; +import { broadcastSoundToPlayer } from '../view/sound-renderer'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { Message } from './messages'; + +export interface ResignationMessage extends Message {} + +export function handleResignation( + server: GameServer, + conn: PlayerConnection, + message: ResignationMessage, +): void { + console.log( + `Handling resign message in game ${server.id} from player ${conn.id}: ${{ message }}`, + ); + + if (server.gomoku.status !== 'playing') { + conn.sendMessage('error', 'You can only resign from an active game.'); + return; + } + + const resigningPlayerColor = server.getPlayerColor(conn); + if (!resigningPlayerColor) { + conn.sendMessage('error', 'You are not a player in server game.'); + return; + } + + server.gomoku.resign(resigningPlayerColor); + broadcastBoard(server); + broadcastTitle(server); + broadcastButtons(server); + broadcastSoundToPlayer(conn, 'defeat'); + const otherPlayerId = + resigningPlayerColor === 'white' + ? server.blackPlayerId + : server.whitePlayerId; + if (otherPlayerId) { + const otherPlayer = server.connections.get(otherPlayerId); + if (otherPlayer) { + broadcastSoundToPlayer(otherPlayer, 'victory'); + } + } + + console.log(`Player ${conn.id} resigned from game ${server.id}`); +} diff --git a/src/messages/takeback.ts b/src/messages/takeback.ts new file mode 100755 index 0000000..06beb8d --- /dev/null +++ b/src/messages/takeback.ts @@ -0,0 +1,88 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastBoard } from '../view/board-renderer'; +import { broadcastButtons } from '../view/button-renderer'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { ActionType, Message } from './messages'; + +export interface TakebackMessage extends Message { + action: ActionType; +} + +export function handleTakebackMessage( + server: GameServer, + conn: PlayerConnection, + message: TakebackMessage, +): void { + if (server.gomoku.status !== 'playing') { + conn.sendMessage( + 'error', + 'You can only perform this action in an active game.', + ); + return; + } + switch (message.action) { + case 'request': + handleRequestTakeback(server, conn); + break; + case 'accept': + if (!server.takebackRequesterId) { + conn.sendMessage('error', 'No takeback has been requested.'); + return; + } + handleAcceptTakeback(server); + break; + case 'decline': + if (!server.takebackRequesterId) { + conn.sendMessage('error', 'No takeback has been requested.'); + return; + } + handleDeclineTakeback(server); + break; + case 'cancel': + if (server.takebackRequesterId !== conn.id) { + conn.sendMessage( + 'error', + 'You are not the one who requested a takeback.', + ); + return; + } + handleCancelTakebackRequest(server); + break; + } +} + +function handleRequestTakeback( + server: GameServer, + conn: PlayerConnection, +): void { + if (server.gomoku.history.length === 0) { + conn.sendMessage('error', 'There are no moves to take back.'); + return; + } + + if (server.drawRequesterId) { + conn.sendMessage('error', 'A draw has already been requested.'); + return; + } + server.takebackRequesterId = conn.id; + broadcastButtons(server); +} + +function handleAcceptTakeback(server: GameServer): void { + server.gomoku.undoMove(); + server.takebackRequesterId = null; + broadcastBoard(server); + broadcastButtons(server); + broadcastTitle(server); +} + +function handleDeclineTakeback(server: GameServer): void { + server.takebackRequesterId = null; + broadcastButtons(server); +} + +function handleCancelTakebackRequest(server: GameServer): void { + server.takebackRequesterId = null; + broadcastButtons(server); +} diff --git a/src/messages/update-display-name.ts b/src/messages/update-display-name.ts new file mode 100755 index 0000000..005a557 --- /dev/null +++ b/src/messages/update-display-name.ts @@ -0,0 +1,37 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { Message } from './messages'; + +export interface UpdateDisplayNameMessage extends Message { + displayName: string; +} + +export function handleUpdateDisplayName( + server: GameServer, + 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; + server.webSocketHandler.setPlayerName(conn.id, newDisplayName); + broadcastTitle(server); +} diff --git a/src/player-connection.ts b/src/player-connection.ts new file mode 100755 index 0000000..eb5627a --- /dev/null +++ b/src/player-connection.ts @@ -0,0 +1,20 @@ +import { WS } from '.'; + +export type PlayerId = string; + +export class PlayerConnection { + id: PlayerId; + 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 + } +} diff --git a/src/view/board-renderer.tsx b/src/view/board-renderer.tsx index 616c3d6..d48b90c 100755 --- a/src/view/board-renderer.tsx +++ b/src/view/board-renderer.tsx @@ -1,7 +1,26 @@ import { Html } from '@elysiajs/html'; import { GomokuGame } from '../game/game-instance'; +import { PlayerConnection } from '../player-connection'; +import { GameServer } from '../game-server'; -export function renderGameBoardHtml( +export function broadcastBoard(server: GameServer) { + server.connections.forEach((conn: PlayerConnection) => + broadcastBoardToPlayer(server, conn), + ); +} + +export function broadcastBoardToPlayer( + server: GameServer, + conn: PlayerConnection, +) { + const isToPlay = + server.gomoku.currentPlayerColor == server.getPlayerColor(conn); + const updatedBoardHtml = renderGameBoardHtml(server.gomoku, isToPlay); + conn.ws.send(updatedBoardHtml); + console.log(`Sent board for game ${server.id} to player ${conn.id}`); +} + +function renderGameBoardHtml( game: GomokuGame, isPlayerToPlay: boolean, ): string { diff --git a/src/view/button-renderer.tsx b/src/view/button-renderer.tsx new file mode 100755 index 0000000..1199ea5 --- /dev/null +++ b/src/view/button-renderer.tsx @@ -0,0 +1,183 @@ +import { Html } from '@elysiajs/html'; +import { PlayerConnection } from '../player-connection'; +import { GameServer } from '../game-server'; + +export function broadcastButtons(server: GameServer) { + server.connections.forEach((conn: PlayerConnection) => + broadcastButtonsToPlayer(server, conn), + ); +} + +export function broadcastButtonsToPlayer( + server: GameServer, + conn: PlayerConnection, +) { + const buttons: JSX.Element[] = []; + let title: string | undefined; + + if (server.gomoku.status == 'playing' && server.getPlayerColor(conn)) { + if (server.takebackRequesterId) { + if (server.takebackRequesterId === conn.id) { + title = 'You requested a takeback'; + buttons.push( + , + ); + } else { + title = 'Your opponent requests a takeback'; + buttons.push( + , + ); + buttons.push( + , + ); + } + } else if (server.drawRequesterId) { + if (server.drawRequesterId === conn.id) { + title = 'You requested a draw'; + buttons.push( + , + ); + } else { + title = 'Your opponent offers a draw'; + buttons.push( + , + ); + buttons.push( + , + ); + } + } else { + buttons.push( + , + ); + buttons.push( + , + ); + buttons.push( + , + ); + } + } else if (server.gomoku.status === 'finished') { + if (server.rematchRequesterId) { + if (server.rematchRequesterId === conn.id) { + title = 'You requested a rematch'; + buttons.push( + , + ); + } else { + title = 'Your opponent requests a rematch'; + buttons.push( + , + ); + buttons.push( + , + ); + } + } else { + buttons.push( + , + ); + } + } else if (server.gomoku.status === 'waiting') { + buttons.push( + , + ); + } + + conn.ws.send( +
+
{title}
+
{buttons}
+
, + ); + console.log(`Sent buttons for game ${server.id} to player ${conn.id}`); +} diff --git a/src/view/sound-renderer.tsx b/src/view/sound-renderer.tsx new file mode 100755 index 0000000..b52506d --- /dev/null +++ b/src/view/sound-renderer.tsx @@ -0,0 +1,15 @@ +import { PlayerConnection } from '../player-connection'; +import { GameServer } from '../game-server'; + +export function broadcastSound(server: GameServer, sound: string) { + server.connections.forEach((conn: PlayerConnection) => + broadcastSoundToPlayer(conn, sound), + ); +} + +export function broadcastSoundToPlayer(conn: PlayerConnection, sound: string) { + conn.ws.send({ + type: 'sound', + sound: sound, + }); +} diff --git a/src/view/title-renderer.tsx b/src/view/title-renderer.tsx new file mode 100755 index 0000000..1b4a5a4 --- /dev/null +++ b/src/view/title-renderer.tsx @@ -0,0 +1,65 @@ +import { Html } from '@elysiajs/html'; +import { PlayerConnection } from '../player-connection'; +import { GameServer } from '../game-server'; +import { PlayerColor } from '../game/game-instance'; + +export function broadcastTitle(server: GameServer) { + server.connections.forEach((conn: PlayerConnection) => + broadcastTitleToPlayer(server, conn), + ); +} + +export function broadcastTitleToPlayer( + server: GameServer, + conn: PlayerConnection, +) { + let message = ''; + switch (server.gomoku.status) { + case 'waiting': { + message = 'Waiting for players...'; + break; + } + case 'playing': { + const blackTag = playerTag(server, server.blackPlayerId!, 'black'); + const whiteTag = playerTag(server, server.whitePlayerId!, 'white'); + message = `${blackTag} vs ${whiteTag}`; + break; + } + case 'finished': { + switch (server.gomoku.winnerColor) { + case 'draw': { + message = 'Game ended in draw.'; + break; + } + case 'black': { + const name = server.connections.get(server.blackPlayerId!)?.name; + message = `${name} wins!`; + break; + } + case 'white': { + const name = server.connections.get(server.whitePlayerId!)?.name; + message = `${name} wins!`; + break; + } + } + break; + } + } + conn.ws.send(
{message}
); + console.log(`Sent title for game ${server.id} to player ${conn.id}`); +} + +function playerTag(server: GameServer, playerId: string, color: PlayerColor) { + const connectionIcon = server.connections.has(playerId) + ? '' + : `Disconnected`; + var turnClass = + server.gomoku.currentPlayerColor === color ? 'player-to-play' : ''; + const classes = `player-name ${'player-' + color} ${turnClass}`.trim(); + return ( + + {server.connections.get(playerId)?.name} + {connectionIcon} + + ); +} diff --git a/src/web-socket-handler.tsx b/src/web-socket-handler.tsx index 74fb277..46ff004 100755 --- a/src/web-socket-handler.tsx +++ b/src/web-socket-handler.tsx @@ -1,775 +1,11 @@ -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'; import { WS } from '.'; - -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 broadcastSound(sound: string) { - this.connections.forEach((conn: PlayerConnection) => - this.broadcastSoundToPlayer(conn, sound), - ); - } - - public broadcastSoundToPlayer(conn: PlayerConnection, sound: string) { - conn.ws.send({ - type: 'sound', - sound: sound, - }); - } - - 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[] = []; - let title: string | undefined; - - if (this.gomoku.status == 'playing' && this.getPlayerColor(conn)) { - if (this.takebackRequesterId) { - if (this.takebackRequesterId === conn.id) { - title = 'You requested a takeback'; - buttons.push( - , - ); - } else { - title = 'Your opponent requests a takeback'; - buttons.push( - , - ); - buttons.push( - , - ); - } - } else if (this.drawRequesterId) { - if (this.drawRequesterId === conn.id) { - title = 'You requested a draw'; - buttons.push( - , - ); - } else { - title = 'Your opponent offers a draw'; - 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) { - title = 'You requested a rematch'; - buttons.push( - , - ); - } else { - title = 'Your opponent requests a rematch'; - buttons.push( - , - ); - buttons.push( - , - ); - } - } else { - buttons.push( - , - ); - } - } else if (this.gomoku.status === 'waiting') { - buttons.push( - , - ); - } - - conn.ws.send( -
-
{title}
-
{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(); - } - - // Broadcast sounds - if (this.gomoku.status === 'playing') { - this.broadcastSound('move'); - } else { - const whiteConn = this.whitePlayerId - ? this.connections.get(this.whitePlayerId) - : null; - const blackConn = this.blackPlayerId - ? this.connections.get(this.blackPlayerId) - : null; - switch (this.gomoku.winnerColor) { - case 'draw': - this.broadcastSound('draw'); - case 'white': - if (whiteConn) { - this.broadcastSoundToPlayer(whiteConn, 'victory'); - } - if (blackConn) { - this.broadcastSoundToPlayer(blackConn, 'defeat'); - } - break; - case 'black': - if (whiteConn) { - this.broadcastSoundToPlayer(whiteConn, 'defeat'); - } - if (blackConn) { - this.broadcastSoundToPlayer(blackConn, 'victory'); - } - break; - } - } - - 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(); - this.broadcastSoundToPlayer(conn, 'defeat'); - const otherPlayerId = - resigningPlayerColor === 'white' - ? this.blackPlayerId - : this.whitePlayerId; - if (otherPlayerId) { - const otherPlayer = this.connections.get(otherPlayerId); - if (otherPlayer) { - this.broadcastSoundToPlayer(otherPlayer, 'victory'); - } - } - - 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(); - this.broadcastSound('draw'); - } - - 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(); - } -} +import { GameServer } from './game-server'; +import { Message } from './messages/messages'; export class WebSocketHandler { - private games: Map; - private playerNames: Map; + public games: Map; + public playerNames: Map; constructor() { this.games = new Map(); @@ -777,7 +13,7 @@ export class WebSocketHandler { } public handleConnection(ws: WS): void { - const { gameId, playerId } = ws.data.query; + const { gameId } = ws.data.query; if (this.games.has(gameId)) { const game = this.games.get(gameId)!; game.handleConnection(ws);