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') {
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 {
background-color: var(--color-success);
}

View File

@ -7,7 +7,7 @@ export class GomokuGame {
public currentPlayerColor: null | PlayerColor;
public status: GameStatus;
public winnerColor: null | PlayerColor | 'draw';
public history: { row: number, col: number }[];
public history: { row: number; col: number }[];
private readonly boardSize = 15;
private moveCount = 0;
@ -82,6 +82,24 @@ export class GomokuGame {
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 {
const directions = [
[1, 0], // vertical

View File

@ -1,5 +1,10 @@
export interface Message {
type: 'make_move' | 'resign';
type:
| 'make_move'
| 'resign'
| 'request_takeback'
| 'accept_takeback'
| 'decline_takeback';
}
export interface MakeMoveMessage extends Message {
@ -9,3 +14,8 @@ export interface MakeMoveMessage 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 { GomokuGame as GomokuGame, PlayerColor } from './game/game-instance';
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';
type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>;
@ -29,6 +36,7 @@ class GameServer {
connections: Map<string, PlayerConnection>;
blackPlayerId?: string;
whitePlayerId?: string;
takebackRequesterId: string | null = null;
constructor(id: string) {
this.id = id;
@ -62,19 +70,26 @@ class GameServer {
}
public broadcastBoard() {
this.connections.forEach((conn: PlayerConnection) => this.broadcastBoardToPlayer(conn));
this.connections.forEach((conn: PlayerConnection) =>
this.broadcastBoardToPlayer(conn),
);
}
public broadcastTitle() {
this.connections.forEach((conn: PlayerConnection) => this.broadcastTitleToPlayer(conn));
this.connections.forEach((conn: PlayerConnection) =>
this.broadcastTitleToPlayer(conn),
);
}
public broadcastButtons() {
this.connections.forEach((conn: PlayerConnection) => this.broadcastButtonsToPlayer(conn));
this.connections.forEach((conn: PlayerConnection) =>
this.broadcastButtonsToPlayer(conn),
);
}
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);
conn.ws.send(updatedBoardHtml);
console.log(`Sent board for game ${this.id} to player ${conn.id}`);
@ -120,23 +135,63 @@ class GameServer {
public broadcastButtonsToPlayer(conn: PlayerConnection) {
let buttonsHtml;
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>);
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 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}>{playerId}{connectionIcon}</span>);
return (
<span class={classes}>
{playerId}
{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`);
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;
}
@ -152,6 +207,18 @@ class GameServer {
this.handleResignation(conn, message as ResignationMessage);
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;
var playerColor;
@ -174,7 +244,10 @@ class GameServer {
} 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!');
conn.sendMessage(
'error',
'You are not a player in this game, you cannot make a move!',
);
return;
}
if (this.gomoku.currentPlayerColor !== playerColor) {
@ -182,6 +255,11 @@ class GameServer {
return;
}
if (this.takebackRequesterId) {
this.takebackRequesterId = null;
this.broadcastButtons();
}
const stateBeforeMove = this.gomoku.status;
const result = this.gomoku.makeMove(playerColor, row, col);
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(
`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}`);
}
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 {
@ -246,7 +393,9 @@ export class WebSocketHandler {
const game = this.games.get(gameId);
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;
}
@ -262,15 +411,19 @@ export class WebSocketHandler {
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!");
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}`);
console.error(
`A WebSocket connection was left open for the non-existent game ${gameId}`,
);
return;
}
@ -287,4 +440,3 @@ export class WebSocketHandler {
return this.games.has(id);
}
}