Add draw requests and rematches

This commit is contained in:
sepia 2025-07-22 20:44:06 -05:00
parent 1a221bf680
commit 2f46d86947
6 changed files with 297 additions and 7 deletions

View File

@ -35,5 +35,6 @@
<script src="scripts/display-ws-connection.js"></script> <script src="scripts/display-ws-connection.js"></script>
<script src="scripts/send-ws-messages.js"></script> <script src="scripts/send-ws-messages.js"></script>
<script src="scripts/copy-game-link.js"></script> <script src="scripts/copy-game-link.js"></script>
<script src="scripts/handle-redirects.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,6 @@
document.addEventListener('htmx:wsAfterMessage', function (e) {
const message = JSON.parse(e.detail.message);
if (message.type === 'redirect_to_game') {
window.location.href = '/?gameId=' + message.gameId;
}
});

View File

@ -27,5 +27,29 @@ document.addEventListener('htmx:wsConfigSend', function (e) {
e.detail.parameters = { e.detail.parameters = {
type: 'decline_takeback', type: 'decline_takeback',
}; };
} else if (e.target.id == 'draw-button') {
e.detail.parameters = {
type: 'request_draw',
};
} else if (e.target.id == 'accept-draw-button') {
e.detail.parameters = {
type: 'accept_draw',
};
} else if (e.target.id == 'decline-draw-button') {
e.detail.parameters = {
type: 'decline_draw',
};
} else if (e.target.id == 'rematch-button') {
e.detail.parameters = {
type: 'request_rematch',
};
} else if (e.target.id == 'accept-rematch-button') {
e.detail.parameters = {
type: 'accept_rematch',
};
} else if (e.target.id == 'decline-rematch-button') {
e.detail.parameters = {
type: 'decline_rematch',
};
} }
}); });

View File

@ -100,6 +100,12 @@ export class GomokuGame {
} }
} }
public declareDraw() {
this.status = 'finished';
this.winnerColor = 'draw';
this.currentPlayerColor = 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

@ -4,7 +4,14 @@ export interface Message {
| 'resign' | 'resign'
| 'request_takeback' | 'request_takeback'
| 'accept_takeback' | 'accept_takeback'
| 'decline_takeback'; | 'decline_takeback'
| 'request_draw'
| 'accept_draw'
| 'decline_draw'
| 'request_rematch'
| 'accept_rematch'
| 'decline_rematch'
| 'redirect_to_game';
} }
export interface MakeMoveMessage extends Message { export interface MakeMoveMessage extends Message {
@ -19,3 +26,19 @@ export interface RequestTakebackMessage extends Message {}
export interface AcceptTakebackMessage extends Message {} export interface AcceptTakebackMessage extends Message {}
export interface DeclineTakebackMessage extends Message {} export interface DeclineTakebackMessage extends Message {}
export interface RequestDrawMessage extends Message {}
export interface AcceptDrawMessage extends Message {}
export interface DeclineDrawMessage extends Message {}
export interface RequestRematchMessage extends Message {}
export interface AcceptRematchMessage extends Message {}
export interface DeclineRematchMessage extends Message {}
export interface RedirectToGameMessage extends Message {
gameId: string;
}

View File

@ -9,6 +9,13 @@ import {
RequestTakebackMessage, RequestTakebackMessage,
AcceptTakebackMessage, AcceptTakebackMessage,
DeclineTakebackMessage, DeclineTakebackMessage,
RequestDrawMessage,
AcceptDrawMessage,
DeclineDrawMessage,
RequestRematchMessage,
AcceptRematchMessage,
DeclineRematchMessage,
RedirectToGameMessage,
} from './messages'; } from './messages';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -37,8 +44,13 @@ class GameServer {
blackPlayerId?: string; blackPlayerId?: string;
whitePlayerId?: string; whitePlayerId?: string;
takebackRequesterId: string | null = null; takebackRequesterId: string | null = null;
drawRequesterId: string | null = null;
rematchRequesterId: string | null = null;
constructor(id: string) { constructor(
id: string,
private webSocketHandler: WebSocketHandler,
) {
this.id = id; this.id = id;
this.gomoku = new GomokuGame(); this.gomoku = new GomokuGame();
this.connections = new Map(); this.connections = new Map();
@ -146,10 +158,29 @@ class GameServer {
buttonsHtml = ( buttonsHtml = (
<div> <div>
<button id="accept-takeback-button" ws-send="click"> <button id="accept-takeback-button" ws-send="click">
Accept Accept Takeback
</button> </button>
<button id="decline-takeback-button" ws-send="click"> <button id="decline-takeback-button" ws-send="click">
Decline Decline Takeback
</button>
</div>
);
}
} else if (this.drawRequesterId) {
if (this.drawRequesterId === conn.id) {
buttonsHtml = (
<button id="draw-button" disabled>
Draw Requested
</button>
);
} else {
buttonsHtml = (
<div>
<button id="accept-draw-button" ws-send="click">
Accept Draw
</button>
<button id="decline-draw-button" ws-send="click">
Decline Draw
</button> </button>
</div> </div>
); );
@ -163,9 +194,39 @@ class GameServer {
<button id="takeback-button" ws-send="click"> <button id="takeback-button" ws-send="click">
Takeback Takeback
</button> </button>
<button id="draw-button" ws-send="click">
Draw
</button>
</div> </div>
); );
} }
} else if (this.gomoku.status === 'finished') {
if (this.rematchRequesterId) {
if (this.rematchRequesterId === conn.id) {
buttonsHtml = (
<button id="rematch-button" disabled>
Rematch Requested
</button>
);
} else {
buttonsHtml = (
<div>
<button id="accept-rematch-button" ws-send="click">
Accept Rematch
</button>
<button id="decline-rematch-button" ws-send="click">
Decline Rematch
</button>
</div>
);
}
} else {
buttonsHtml = (
<button id="rematch-button" ws-send="click">
Rematch
</button>
);
}
} }
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}`);
@ -219,6 +280,30 @@ class GameServer {
this.handleDeclineTakeback(conn, message as DeclineTakebackMessage); this.handleDeclineTakeback(conn, message as DeclineTakebackMessage);
break; break;
} }
case 'request_draw': {
this.handleRequestDraw(conn, message as RequestDrawMessage);
break;
}
case 'accept_draw': {
this.handleAcceptDraw(conn, message as AcceptDrawMessage);
break;
}
case 'decline_draw': {
this.handleDeclineDraw(conn, message as DeclineDrawMessage);
break;
}
case 'request_rematch': {
this.handleRequestRematch(conn, message as RequestRematchMessage);
break;
}
case 'accept_rematch': {
this.handleAcceptRematch(conn, message as AcceptRematchMessage);
break;
}
case 'decline_rematch': {
this.handleDeclineRematch(conn, message as DeclineRematchMessage);
break;
}
} }
} }
@ -255,8 +340,9 @@ class GameServer {
return; return;
} }
if (this.takebackRequesterId) { if (this.takebackRequesterId || this.drawRequesterId) {
this.takebackRequesterId = null; this.takebackRequesterId = null;
this.drawRequesterId = null;
this.broadcastButtons(); this.broadcastButtons();
} }
@ -321,6 +407,10 @@ class GameServer {
return; return;
} }
if (this.drawRequesterId) {
conn.sendMessage('error', 'A draw has already been requested.');
return;
}
this.takebackRequesterId = conn.id; this.takebackRequesterId = conn.id;
this.broadcastButtons(); this.broadcastButtons();
} }
@ -369,6 +459,139 @@ class GameServer {
this.takebackRequesterId = null; this.takebackRequesterId = null;
this.broadcastButtons(); this.broadcastButtons();
} }
private handleRequestDraw(
conn: PlayerConnection,
message: RequestDrawMessage,
): void {
if (this.gomoku.status !== 'playing') {
conn.sendMessage(
'error',
'You can only request a draw in an active game.',
);
return;
}
if (this.takebackRequesterId) {
conn.sendMessage('error', 'A takeback has already been requested.');
return;
}
this.drawRequesterId = conn.id;
this.broadcastButtons();
}
private handleAcceptDraw(
conn: PlayerConnection,
message: AcceptDrawMessage,
): void {
if (this.gomoku.status !== 'playing') {
conn.sendMessage(
'error',
'You can only accept a draw in an active game.',
);
return;
}
if (!this.drawRequesterId) {
conn.sendMessage('error', 'No draw has been requested.');
return;
}
this.gomoku.declareDraw();
this.drawRequesterId = null;
this.broadcastBoard();
this.broadcastButtons();
this.broadcastTitle();
}
private handleDeclineDraw(
conn: PlayerConnection,
message: DeclineDrawMessage,
): void {
if (this.gomoku.status !== 'playing') {
conn.sendMessage(
'error',
'You can only decline a draw in an active game.',
);
return;
}
if (!this.drawRequesterId) {
conn.sendMessage('error', 'No draw has been requested.');
return;
}
this.drawRequesterId = null;
this.broadcastButtons();
}
private handleRequestRematch(
conn: PlayerConnection,
message: RequestRematchMessage,
): void {
if (this.gomoku.status !== 'finished') {
conn.sendMessage(
'error',
'You can only request a rematch in a finished game.',
);
return;
}
this.rematchRequesterId = conn.id;
this.broadcastButtons();
}
private handleAcceptRematch(
conn: PlayerConnection,
message: AcceptRematchMessage,
): void {
if (this.gomoku.status !== 'finished') {
conn.sendMessage(
'error',
'You can only accept a rematch in a finished game.',
);
return;
}
if (!this.rematchRequesterId) {
conn.sendMessage('error', 'No rematch has been requested.');
return;
}
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(
conn: PlayerConnection,
message: DeclineRematchMessage,
): void {
if (this.gomoku.status !== 'finished') {
conn.sendMessage(
'error',
'You can only decline a rematch in a finished game.',
);
return;
}
if (!this.rematchRequesterId) {
conn.sendMessage('error', 'No rematch has been requested.');
return;
}
this.rematchRequesterId = null;
this.broadcastButtons();
}
} }
export class WebSocketHandler { export class WebSocketHandler {
@ -430,9 +653,16 @@ export class WebSocketHandler {
gameServer.handleMessage(ws, message); gameServer.handleMessage(ws, message);
} }
public createGame(id?: string): string { public createGame(
id?: string,
blackPlayerId?: string,
whitePlayerId?: string,
): string {
const realId = id ? id : uuidv4(); const realId = id ? id : uuidv4();
this.games.set(realId, new GameServer(realId)); this.games.set(realId, new GameServer(realId, this));
const game = this.games.get(realId)!;
game.blackPlayerId = blackPlayerId;
game.whitePlayerId = whitePlayerId;
return realId; return realId;
} }