gomoku/src/web-socket-handler.tsx

802 lines
22 KiB
TypeScript

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<string, PlayerConnection>;
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(<div id="title-box">{message}</div>);
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(
<button
id="cancel-takeback-request-button"
class="decline-button"
ws-send="click"
>
<svg class="icon" alt="Cancel">
<use href="/icons/decline.svg"></use>
</svg>
Cancel Takeback Request
</button>,
);
} else {
buttons.push(
<button
id="accept-takeback-button"
class="accept-button"
ws-send="click"
>
<svg class="icon" alt="Accept">
<use href="/icons/accept.svg"></use>
</svg>
Accept Takeback
</button>,
);
buttons.push(
<button
id="decline-takeback-button"
class="decline-button"
ws-send="click"
>
<svg class="icon" alt="Decline">
<use href="/icons/decline.svg"></use>
</svg>
Decline Takeback
</button>,
);
}
} else if (this.drawRequesterId) {
if (this.drawRequesterId === conn.id) {
buttons.push(
<button
id="cancel-draw-request-button"
class="decline-button"
ws-send="click"
>
<svg class="icon" alt="Cancel">
<use href="/icons/decline.svg"></use>
</svg>
Cancel Draw Request
</button>,
);
} else {
buttons.push(
<button
id="accept-draw-button"
class="accept-button"
ws-send="click"
>
<svg class="icon" alt="Accept">
<use href="/icons/accept.svg"></use>
</svg>
Accept Draw
</button>,
);
buttons.push(
<button
id="decline-draw-button"
class="decline-button"
ws-send="click"
>
<svg class="icon" alt="Decline">
<use href="/icons/decline.svg"></use>
</svg>
Decline Draw
</button>,
);
}
} else {
buttons.push(
<button id="resign-button" ws-send="click">
<svg class="icon" alt="Resign">
<use href="/icons/resign.svg"></use>
</svg>
Resign
</button>,
);
buttons.push(
<button id="takeback-button" ws-send="click">
<svg class="icon" alt="Takeback">
<use href="/icons/undo.svg"></use>
</svg>
Takeback
</button>,
);
buttons.push(
<button id="draw-button" ws-send="click">
<svg class="icon" alt="Draw">
<use href="/icons/draw.svg"></use>
</svg>
Draw
</button>,
);
}
} else if (this.gomoku.status === 'finished') {
if (this.rematchRequesterId) {
if (this.rematchRequesterId === conn.id) {
buttons.push(
<button
id="cancel-rematch-request-button"
class="decline-button"
ws-send="click"
>
<svg class="icon" alt="Cancel">
<use href="/icons/decline.svg"></use>
</svg>
Cancel Rematch Request
</button>,
);
} else {
buttons.push(
<button
id="accept-rematch-button"
class="accept-button"
ws-send="click"
>
<svg class="icon" alt="Accept">
<use href="/icons/accept.svg"></use>
</svg>
Accept Rematch
</button>,
);
buttons.push(
<button
id="decline-rematch-button"
class="decline-button"
ws-send="click"
>
<svg class="icon" alt="Decline">
<use href="/icons/decline.svg"></use>
</svg>
Decline Rematch
</button>,
);
}
} else {
buttons.push(
<button id="rematch-button" ws-send="click">
<svg class="icon" alt="Rematch">
<use href="/icons/rotate-right.svg"></use>
</svg>
Rematch
</button>,
);
}
} else if (this.gomoku.status === 'waiting') {
buttons.push(
<button id="copy-link-button" onclick="copyGameLink()">
<svg class="icon" alt="Copy">
<use href="/icons/clipboard-copy.svg"></use>
</svg>
<span id="copy-link-text">Click to copy game link!</span>
</button>,
);
}
conn.ws.send(<div id="button-box">{buttons}</div>);
console.log(`Sent buttons for game ${this.id} to player ${conn.id}`);
}
private playerTag(playerId: string, color: PlayerColor) {
const connectionIcon = this.connections.has(playerId)
? ''
: `<img src="/icons/disconnected.svg" alt="Disconnected" class="icon" />`;
var turnClass =
this.gomoku.currentPlayerColor === color ? 'player-to-play' : '';
const classes = `player-name ${'player-' + color} ${turnClass}`.trim();
return (
<span class={classes}>
{this.connections.get(playerId)?.name}
{connectionIcon}
</span>
);
}
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<String, GameServer>;
private playerNames: Map<string, string>;
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);
}
}