Add takeback button

This commit is contained in:
sepia 2025-07-22 19:06:12 -05:00
parent 3093754bd4
commit 1a221bf680
6 changed files with 217 additions and 26 deletions

View File

@ -13,7 +13,19 @@ document.addEventListener('htmx:wsConfigSend', function (e) {
}; };
} else if (e.target.id == 'resign-button') { } else if (e.target.id == 'resign-button') {
e.detail.parameters = { e.detail.parameters = {
type: 'resign' type: 'resign',
};
} else if (e.target.id == 'takeback-button') {
e.detail.parameters = {
type: 'request_takeback',
};
} else if (e.target.id == 'accept-takeback-button') {
e.detail.parameters = {
type: 'accept_takeback',
};
} else if (e.target.id == 'decline-takeback-button') {
e.detail.parameters = {
type: 'decline_takeback',
}; };
} }
}); });

View File

@ -271,4 +271,3 @@ button:hover {
#copy-link-button.copied-state { #copy-link-button.copied-state {
background-color: var(--color-success); background-color: var(--color-success);
} }

View File

@ -7,7 +7,7 @@ export class GomokuGame {
public currentPlayerColor: null | PlayerColor; public currentPlayerColor: null | PlayerColor;
public status: GameStatus; public status: GameStatus;
public winnerColor: null | PlayerColor | 'draw'; public winnerColor: null | PlayerColor | 'draw';
public history: { row: number, col: number }[]; public history: { row: number; col: number }[];
private readonly boardSize = 15; private readonly boardSize = 15;
private moveCount = 0; private moveCount = 0;
@ -82,6 +82,24 @@ export class GomokuGame {
this.currentPlayerColor = null; this.currentPlayerColor = null;
} }
public undoMove() {
if (this.history.length === 0) {
return;
}
const lastMove = this.history.pop();
if (lastMove) {
this.board[lastMove.row][lastMove.col] = null;
this.moveCount--;
this.currentPlayerColor =
this.currentPlayerColor === 'black' ? 'white' : 'black';
if (this.status === 'finished') {
this.status = 'playing';
this.winnerColor = null;
}
}
}
private checkWin(row: number, col: number, color: PlayerColor): boolean { private checkWin(row: number, col: number, color: PlayerColor): boolean {
const directions = [ const directions = [
[1, 0], // vertical [1, 0], // vertical

View File

@ -1,5 +1,10 @@
export interface Message { export interface Message {
type: 'make_move' | 'resign'; type:
| 'make_move'
| 'resign'
| 'request_takeback'
| 'accept_takeback'
| 'decline_takeback';
} }
export interface MakeMoveMessage extends Message { export interface MakeMoveMessage extends Message {
@ -9,3 +14,8 @@ export interface MakeMoveMessage extends Message {
export interface ResignationMessage extends Message {} export interface ResignationMessage extends Message {}
export interface RequestTakebackMessage extends Message {}
export interface AcceptTakebackMessage extends Message {}
export interface DeclineTakebackMessage extends Message {}

View File

@ -2,7 +2,14 @@ import { ElysiaWS } from 'elysia/dist/ws';
import { Html } from '@elysiajs/html'; import { Html } from '@elysiajs/html';
import { GomokuGame as GomokuGame, PlayerColor } from './game/game-instance'; import { GomokuGame as GomokuGame, PlayerColor } from './game/game-instance';
import { renderGameBoardHtml } from './view/board-renderer'; import { renderGameBoardHtml } from './view/board-renderer';
import { Message, MakeMoveMessage, ResignationMessage } from './messages'; import {
Message,
MakeMoveMessage,
ResignationMessage,
RequestTakebackMessage,
AcceptTakebackMessage,
DeclineTakebackMessage,
} from './messages';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>; type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>;
@ -29,6 +36,7 @@ class GameServer {
connections: Map<string, PlayerConnection>; connections: Map<string, PlayerConnection>;
blackPlayerId?: string; blackPlayerId?: string;
whitePlayerId?: string; whitePlayerId?: string;
takebackRequesterId: string | null = null;
constructor(id: string) { constructor(id: string) {
this.id = id; this.id = id;
@ -50,7 +58,7 @@ class GameServer {
if (this.whitePlayerId && this.blackPlayerId) { if (this.whitePlayerId && this.blackPlayerId) {
this.gomoku.status = 'playing'; this.gomoku.status = 'playing';
} }
this.broadcastBoard(); this.broadcastBoard();
this.broadcastButtons(); this.broadcastButtons();
this.broadcastTitle(); this.broadcastTitle();
@ -62,19 +70,26 @@ class GameServer {
} }
public broadcastBoard() { public broadcastBoard() {
this.connections.forEach((conn: PlayerConnection) => this.broadcastBoardToPlayer(conn)); this.connections.forEach((conn: PlayerConnection) =>
this.broadcastBoardToPlayer(conn),
);
} }
public broadcastTitle() { public broadcastTitle() {
this.connections.forEach((conn: PlayerConnection) => this.broadcastTitleToPlayer(conn)); this.connections.forEach((conn: PlayerConnection) =>
this.broadcastTitleToPlayer(conn),
);
} }
public broadcastButtons() { public broadcastButtons() {
this.connections.forEach((conn: PlayerConnection) => this.broadcastButtonsToPlayer(conn)); this.connections.forEach((conn: PlayerConnection) =>
this.broadcastButtonsToPlayer(conn),
);
} }
public broadcastBoardToPlayer(conn: PlayerConnection) { public broadcastBoardToPlayer(conn: PlayerConnection) {
const isToPlay = this.gomoku.currentPlayerColor == this.getPlayerColor(conn); const isToPlay =
this.gomoku.currentPlayerColor == this.getPlayerColor(conn);
const updatedBoardHtml = renderGameBoardHtml(this.gomoku, isToPlay); const updatedBoardHtml = renderGameBoardHtml(this.gomoku, isToPlay);
conn.ws.send(updatedBoardHtml); conn.ws.send(updatedBoardHtml);
console.log(`Sent board for game ${this.id} to player ${conn.id}`); console.log(`Sent board for game ${this.id} to player ${conn.id}`);
@ -120,23 +135,63 @@ class GameServer {
public broadcastButtonsToPlayer(conn: PlayerConnection) { public broadcastButtonsToPlayer(conn: PlayerConnection) {
let buttonsHtml; let buttonsHtml;
if (this.gomoku.status == 'playing' && this.getPlayerColor(conn)) { if (this.gomoku.status == 'playing' && this.getPlayerColor(conn)) {
buttonsHtml = <button id="resign-button" ws-send="click">Resign</button>; if (this.takebackRequesterId) {
if (this.takebackRequesterId === conn.id) {
buttonsHtml = (
<button id="takeback-button" disabled>
Takeback Requested
</button>
);
} else {
buttonsHtml = (
<div>
<button id="accept-takeback-button" ws-send="click">
Accept
</button>
<button id="decline-takeback-button" ws-send="click">
Decline
</button>
</div>
);
}
} else {
buttonsHtml = (
<div>
<button id="resign-button" ws-send="click">
Resign
</button>
<button id="takeback-button" ws-send="click">
Takeback
</button>
</div>
);
}
} }
conn.ws.send(<div id="button-box">{buttonsHtml}</div>); conn.ws.send(<div id="button-box">{buttonsHtml}</div>);
console.log(`Sent buttons for game ${this.id} to player ${conn.id}`); console.log(`Sent buttons for game ${this.id} to player ${conn.id}`);
} }
private playerTag(playerId: string, color: PlayerColor) { private playerTag(playerId: string, color: PlayerColor) {
const connectionIcon = this.connections.has(playerId) ? '' : `<img src="/icons/disconnected.svg" alt="Disconnected" class="icon" />`; const connectionIcon = this.connections.has(playerId)
var turnClass = (this.gomoku.currentPlayerColor === color) ? 'player-to-play' : '' ? ''
: `<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(); const classes = `player-name ${'player-' + color} ${turnClass}`.trim();
return (<span class={classes}>{playerId}{connectionIcon}</span>); return (
<span class={classes}>
{playerId}
{connectionIcon}
</span>
);
} }
public handleMessage(ws: WS, message: Message): void { public handleMessage(ws: WS, message: Message): void {
const conn = this.connections.get(ws.data.query.playerId); const conn = this.connections.get(ws.data.query.playerId);
if (!conn) { 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`); 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; return;
} }
@ -152,6 +207,18 @@ class GameServer {
this.handleResignation(conn, message as ResignationMessage); this.handleResignation(conn, message as ResignationMessage);
break; break;
} }
case 'request_takeback': {
this.handleRequestTakeback(conn, message as RequestTakebackMessage);
break;
}
case 'accept_takeback': {
this.handleAcceptTakeback(conn, message as AcceptTakebackMessage);
break;
}
case 'decline_takeback': {
this.handleDeclineTakeback(conn, message as DeclineTakebackMessage);
break;
}
} }
} }
@ -165,7 +232,10 @@ class GameServer {
} }
} }
private handleMakeMove(conn: PlayerConnection, message: MakeMoveMessage): void { private handleMakeMove(
conn: PlayerConnection,
message: MakeMoveMessage,
): void {
const { row, col } = message; const { row, col } = message;
var playerColor; var playerColor;
@ -174,7 +244,10 @@ class GameServer {
} else if (this.whitePlayerId == conn.id) { } else if (this.whitePlayerId == conn.id) {
playerColor = 'white'; playerColor = 'white';
} else { } else {
conn.sendMessage('error', 'You are not a player in this game, you cannot make a move!'); conn.sendMessage(
'error',
'You are not a player in this game, you cannot make a move!',
);
return; return;
} }
if (this.gomoku.currentPlayerColor !== playerColor) { if (this.gomoku.currentPlayerColor !== playerColor) {
@ -182,6 +255,11 @@ class GameServer {
return; return;
} }
if (this.takebackRequesterId) {
this.takebackRequesterId = null;
this.broadcastButtons();
}
const stateBeforeMove = this.gomoku.status; const stateBeforeMove = this.gomoku.status;
const result = this.gomoku.makeMove(playerColor, row, col); const result = this.gomoku.makeMove(playerColor, row, col);
if (result.success) { if (result.success) {
@ -199,7 +277,10 @@ class GameServer {
} }
} }
private handleResignation(conn: PlayerConnection, message: ResignationMessage): void { private handleResignation(
conn: PlayerConnection,
message: ResignationMessage,
): void {
console.log( console.log(
`Handling resign message in game ${this.id} from player ${conn.id}: ${{ message }}`, `Handling resign message in game ${this.id} from player ${conn.id}: ${{ message }}`,
); );
@ -222,6 +303,72 @@ class GameServer {
console.log(`Player ${conn.id} resigned from game ${this.id}`); console.log(`Player ${conn.id} resigned from game ${this.id}`);
} }
private handleRequestTakeback(
conn: PlayerConnection,
message: RequestTakebackMessage,
): void {
if (this.gomoku.status !== 'playing') {
conn.sendMessage(
'error',
'You can only request a takeback in an active game.',
);
return;
}
if (this.gomoku.history.length === 0) {
conn.sendMessage('error', 'There are no moves to take back.');
return;
}
this.takebackRequesterId = conn.id;
this.broadcastButtons();
}
private handleAcceptTakeback(
conn: PlayerConnection,
message: AcceptTakebackMessage,
): void {
if (this.gomoku.status !== 'playing') {
conn.sendMessage(
'error',
'You can only accept a takeback in an active game.',
);
return;
}
if (!this.takebackRequesterId) {
conn.sendMessage('error', 'No takeback has been requested.');
return;
}
this.gomoku.undoMove();
this.takebackRequesterId = null;
this.broadcastBoard();
this.broadcastButtons();
this.broadcastTitle();
}
private handleDeclineTakeback(
conn: PlayerConnection,
message: DeclineTakebackMessage,
): void {
if (this.gomoku.status !== 'playing') {
conn.sendMessage(
'error',
'You can only decline a takeback in an active game.',
);
return;
}
if (!this.takebackRequesterId) {
conn.sendMessage('error', 'No takeback has been requested.');
return;
}
this.takebackRequesterId = null;
this.broadcastButtons();
}
} }
export class WebSocketHandler { export class WebSocketHandler {
@ -246,7 +393,9 @@ export class WebSocketHandler {
const game = this.games.get(gameId); const game = this.games.get(gameId);
if (!game) { if (!game) {
console.error(`Attempted to disconnect player ${playerId} from game ${gameId}, which does not exist!`); console.error(
`Attempted to disconnect player ${playerId} from game ${gameId}, which does not exist!`,
);
return; return;
} }
@ -262,15 +411,19 @@ export class WebSocketHandler {
public handleMessage(ws: WS, messageUnparsed: any) { public handleMessage(ws: WS, messageUnparsed: any) {
let message = messageUnparsed as Message; let message = messageUnparsed as Message;
if (!message) { if (!message) {
console.log(`Received malformed message ${messageUnparsed} from player ${ws.data.query.playerId}.`); console.log(
ws.send("Error: malformed message!"); `Received malformed message ${messageUnparsed} from player ${ws.data.query.playerId}.`,
);
ws.send('Error: malformed message!');
return; return;
} }
const { gameId } = ws.data.query; const { gameId } = ws.data.query;
const gameServer = this.games.get(gameId); const gameServer = this.games.get(gameId);
if (!gameServer) { if (!gameServer) {
console.error(`A WebSocket connection was left open for the non-existent game ${gameId}`); console.error(
`A WebSocket connection was left open for the non-existent game ${gameId}`,
);
return; return;
} }
@ -287,4 +440,3 @@ export class WebSocketHandler {
return this.games.has(id); return this.games.has(id);
} }
} }

View File

@ -9,7 +9,7 @@
"target": "ES2021", "target": "ES2021",
"types": ["bun-types"], "types": ["bun-types"],
"jsx": "react", "jsx": "react",
"jsxFactory": "Html.createElement", "jsxFactory": "Html.createElement",
"jsxFragmentFactory": "Html.Fragment" "jsxFragmentFactory": "Html.Fragment"
} }
} }