From 43e6ec24f5f372410635b7a2d36a15f529c5bdb7 Mon Sep 17 00:00:00 2001 From: Maieul BOYER Date: Wed, 7 Jan 2026 20:44:23 +0100 Subject: [PATCH] feat(ttt): added concede detection and move timeout --- frontend/src/pages/ttt/socket.ts | 5 +- frontend/src/pages/ttt/ttt.ts | 27 ++++------ src/tic-tac-toe/src/game.ts | 89 ++++++++++++++++++++++---------- src/tic-tac-toe/src/socket.ts | 3 +- src/tic-tac-toe/src/state.ts | 52 ++++++++++++------- 5 files changed, 109 insertions(+), 67 deletions(-) diff --git a/frontend/src/pages/ttt/socket.ts b/frontend/src/pages/ttt/socket.ts index 59e8eb1..30ab897 100644 --- a/frontend/src/pages/ttt/socket.ts +++ b/frontend/src/pages/ttt/socket.ts @@ -1,4 +1,4 @@ -import { type Socket } from 'socket.io-client'; +import { Socket } from 'socket.io-client'; export type UpdateInfo = { inQueue: number, @@ -14,7 +14,7 @@ export type GameUpdate = { boardState: CellState[]; currentPlayer: 'X' | 'O'; - gameState: 'winX' | 'winO' | 'concededX' | 'concededO' | 'draw' | 'ongoing'; + gameState: 'winX' | 'winO' | 'draw' | 'ongoing' | 'other'; } export type GameMove = { @@ -26,6 +26,7 @@ export interface ClientToServer { dequeue: () => void; debugInfo: () => void; gameMove: (up: GameMove) => void; + keepalive: () => void; connectedToGame: (gameId: string) => void; }; diff --git a/frontend/src/pages/ttt/ttt.ts b/frontend/src/pages/ttt/ttt.ts index 9863b1e..6233713 100644 --- a/frontend/src/pages/ttt/ttt.ts +++ b/frontend/src/pages/ttt/ttt.ts @@ -10,17 +10,22 @@ import { updateUser } from "@app/auth"; declare module 'ft_state' { interface State { tttSock?: Socket; + keepAliveInterval?: ReturnType; } } document.addEventListener("ft:pageChange", () => { if (window.__state.tttSock !== undefined) window.__state.tttSock.close(); + if (window.__state.keepAliveInterval !== undefined) clearInterval(window.__state.keepAliveInterval); window.__state.tttSock = undefined; + window.__state.keepAliveInterval = undefined; }); export function getSocket(): Socket { if (window.__state.tttSock === undefined) window.__state.tttSock = io(window.location.host, { path: "/api/ttt/socket.io/" }) as any as Socket; + if (window.__state.keepAliveInterval === undefined) + window.__state.keepAliveInterval = setInterval(() => window.__state.tttSock?.emit('keepalive'), 100); return window.__state.tttSock; } @@ -63,22 +68,13 @@ async function handleTTT(): Promise { if (type === 'draw') { showWarn('It\'s a draw !') } - // if the other player conceded, switch the side so you won - if (type === 'conceded') { - player = player === 'X' ? 'O' : 'X'; - const youWin = (curGame?.playerX === user.id); - if (youWin) - showSuccess('The other player Conceded !'); - else - showError('You Conceded :('); - } if (type === 'win') { let youWin: boolean; - switch(player) { + switch (player) { case 'X': youWin = (curGame?.playerX === user.id); - break ; + break; case 'O': youWin = (curGame?.playerO === user.id); break; @@ -98,6 +94,7 @@ async function handleTTT(): Promise { showInfo('Game is finished, enqueuing directly') }) + socket.on('gameBoard', (u) => { if (curGame === null) { return showError('Got game State, but no in a game ?'); @@ -114,11 +111,7 @@ async function handleTTT(): Promise { case 'winO': makeEnd('win', u.gameState[3] as 'X' | 'O'); break; - case 'concededX': - case 'concededO': - makeEnd('conceded', u.gameState[8] as 'X' | 'O'); - break; - case 'draw': + default: makeEnd('draw', 'X'); break; } @@ -129,7 +122,7 @@ async function handleTTT(): Promise { cells?.forEach(function(c, idx) { c.addEventListener("click", () => { if (socket) { - socket.emit("gameMove", { index: idx }); + socket.emit("gameMove", { index: idx }); } }); }); diff --git a/src/tic-tac-toe/src/game.ts b/src/tic-tac-toe/src/game.ts index e5cd1de..4764dd3 100644 --- a/src/tic-tac-toe/src/game.ts +++ b/src/tic-tac-toe/src/game.ts @@ -1,5 +1,6 @@ // import type { TicTacToeData } from '@shared/database/mixin/tictactoe'; +import { TicTacToeOutcome } from '@shared/database/mixin/tictactoe'; import { UserId } from '@shared/database/mixin/user'; // Represents the possible states of a cell on the board. @@ -7,53 +8,82 @@ import { UserId } from '@shared/database/mixin/user'; type CellState = 'O' | 'X' | null export class TTC { + // 30s + public static readonly TIMEOUT_INMOVE: number = 30 * 1000; + // 1.5s + public static readonly TIMEOUT_KEEPALIVE: number = 1.5 * 1000; + private isGameOver: boolean = false; public board: CellState[] = Array(9).fill(null); private currentPlayer: 'O' | 'X' = 'X'; public gameUpdate: NodeJS.Timeout | null = null; + public lastMoveTime: number = -1; + public lastSeenX: number = -1; + public lastSeenO: number = -1; + private changePlayer() { this.currentPlayer = this.currentPlayer === 'X' ? 'O' : 'X'; + this.lastMoveTime = Date.now(); } public constructor(public readonly playerX: UserId, public readonly playerO: UserId) { } + + private checkWinnerCache: TicTacToeOutcome | null = null; // Analyzes the current board to determine if the game has ended. - public checkState(): 'winX' | 'winO' | 'draw' | 'ongoing' { - const checkRow = (row: number): ('X' | 'O' | null) => { - if (this.board[row * 3] === null) { return null; } - if (this.board[row * 3] === this.board[row * 3 + 1] && this.board[row * 3 + 1] === this.board[row * 3 + 2]) { return this.board[row * 3]; } - return null; - }; + public checkWinner(): TicTacToeOutcome | null { + const real_func = (): (TicTacToeOutcome | null) => { + if (this.lastMoveTime !== -1 && Date.now() - this.lastMoveTime > TTC.TIMEOUT_INMOVE) { + if (this.currentPlayer === 'X') { return 'winO'; } + else { return 'winX'; } + } - const checkCol = (col: number): ('X' | 'O' | null) => { - if (this.board[col] === null) return null; + if (this.lastSeenX !== -1 && Date.now() - this.lastSeenX > TTC.TIMEOUT_KEEPALIVE) { return 'winO'; } + if (this.lastSeenO !== -1 && Date.now() - this.lastSeenO > TTC.TIMEOUT_KEEPALIVE) { return 'winX'; } - if (this.board[col] === this.board[col + 3] && this.board[col + 3] === this.board[col + 6]) { return this.board[col]; } - return null; - }; - const checkDiag = (): ('X' | 'O' | null) => { - if (this.board[4] === null) return null; + const checkRow = (row: number): ('X' | 'O' | null) => { + if (this.board[row * 3] === null) { return null; } + if (this.board[row * 3] === this.board[row * 3 + 1] && this.board[row * 3 + 1] === this.board[row * 3 + 2]) { return this.board[row * 3]; } + return null; + }; - if (this.board[0] === this.board[4] && this.board[4] === this.board[8]) { return this.board[4]; } + const checkCol = (col: number): ('X' | 'O' | null) => { + if (this.board[col] === null) return null; - if (this.board[2] === this.board[4] && this.board[4] === this.board[6]) { return this.board[4]; } + if (this.board[col] === this.board[col + 3] && this.board[col + 3] === this.board[col + 6]) { return this.board[col]; } + return null; + }; + + const checkDiag = (): ('X' | 'O' | null) => { + if (this.board[4] === null) return null; + + if (this.board[0] === this.board[4] && this.board[4] === this.board[8]) { return this.board[4]; } + + if (this.board[2] === this.board[4] && this.board[4] === this.board[6]) { return this.board[4]; } + return null; + }; + + + const row = (checkRow(0) ?? checkRow(1)) ?? checkRow(2); + const col = (checkCol(0) ?? checkCol(1)) ?? checkCol(2); + const diag = checkDiag(); + + if (row !== null) return `win${row}`; + if (col !== null) return `win${col}`; + if (diag !== null) return `win${diag}`; + + if (this.board.filter(c => c === null).length === 0) { return 'draw'; } return null; }; - const row = (checkRow(0) ?? checkRow(1)) ?? checkRow(2); - const col = (checkCol(0) ?? checkCol(1)) ?? checkCol(2); - const diag = checkDiag(); - - if (row !== null) return `win${row}`; - if (col !== null) return `win${col}`; - if (diag !== null) return `win${diag}`; - - if (this.board.filter(c => c === null).length === 0) { return 'draw'; } - return 'ongoing'; + if (this.checkWinnerCache === null) { + this.checkWinnerCache = real_func(); + } + return this.checkWinnerCache; } public reset(): void { @@ -84,14 +114,19 @@ export class TTC { this.board[idx] = this.currentPlayer; this.changePlayer(); - const result = this.checkState(); + const result = this.checkWinner(); - if (result !== 'ongoing') { + if (result !== null) { this.isGameOver = true; } return 'success'; } + public updateKeepAlive(user: UserId) { + if (user === this.playerX) { this.lastSeenX = Date.now(); } + if (user === this.playerO) { this.lastSeenO = Date.now(); } + } + getCurrentState(): 'X' | 'O' { return this.currentPlayer; }; } diff --git a/src/tic-tac-toe/src/socket.ts b/src/tic-tac-toe/src/socket.ts index d549691..45ce2ea 100644 --- a/src/tic-tac-toe/src/socket.ts +++ b/src/tic-tac-toe/src/socket.ts @@ -14,7 +14,7 @@ export type GameUpdate = { boardState: CellState[]; currentPlayer: 'X' | 'O'; - gameState: 'winX' | 'winO' | 'draw' | 'ongoing'; + gameState: 'winX' | 'winO' | 'draw' | 'ongoing' | 'other'; } export type GameMove = { @@ -26,6 +26,7 @@ export interface ClientToServer { dequeue: () => void; debugInfo: () => void; gameMove: (up: GameMove) => void; + keepalive: () => void; connectedToGame: (gameId: string) => void; }; diff --git a/src/tic-tac-toe/src/state.ts b/src/tic-tac-toe/src/state.ts index f35769d..7ab2026 100644 --- a/src/tic-tac-toe/src/state.ts +++ b/src/tic-tac-toe/src/state.ts @@ -1,17 +1,17 @@ import { UserId } from '@shared/database/mixin/user'; import { FastifyInstance } from 'fastify'; -import { GameMove, SSocket } from './socket'; +import { GameMove, GameUpdate, SSocket } from './socket'; import { isNullish } from '@shared/utils'; import { newUUID } from '@shared/utils/uuid'; -import { GameId } from '@shared/database/mixin/tictactoe'; +import { TTTGameId } from '@shared/database/mixin/tictactoe'; import { TTC } from './game'; type TTTUser = { - socket: SSocket, - userId: UserId, - windowId: string, - updateInterval: NodeJS.Timeout, - currentGame: GameId | null, + socket: SSocket, + userId: UserId, + windowId: string, + updateInterval: NodeJS.Timeout, + currentGame: TTTGameId | null, } export class StateI { @@ -21,7 +21,7 @@ export class StateI { private queueInterval: NodeJS.Timeout; - private games: Map = new Map(); + private games: Map = new Map(); constructor(private fastify: FastifyInstance) { this.queueInterval = setInterval(() => this.queuerFunction()); @@ -50,6 +50,7 @@ export class StateI { socket.on('debugInfo', () => this.debugSocket(socket)); socket.on('gameMove', (e) => this.gameMove(socket, e)); + socket.on('keepalive', () => this.keepAlive(socket)); if (socket) { console.log('Socket:', socket.id); } @@ -75,14 +76,14 @@ export class StateI { this.queue.delete(id1); this.queue.delete(id2); - const gameId = newUUID() as unknown as GameId; + const gameId = newUUID() as TTTGameId; const g = new TTC(u1.userId, u2.userId); - const iState = { + const iState: GameUpdate = { boardState: g.board, currentPlayer: g.getCurrentState(), playerX: g.playerX, playerO: g.playerO, - gameState: g.checkState(), + gameState: g.checkWinner() ?? 'ongoing', gameId: gameId, }; @@ -96,9 +97,8 @@ export class StateI { g.gameUpdate = setInterval(() => { this.gameUpdate(gameId, u1.socket); this.gameUpdate(gameId, u2.socket); - if (g.checkState() !== 'ongoing') { + if (g.checkWinner() !== null) { this.cleanupGame(gameId, g); - this.fastify.db.setTTTGameOutcome(gameId, u1.userId, u2.userId, g.checkState()); } }, 100); } @@ -111,6 +111,16 @@ export class StateI { }); } + private keepAlive(socket: SSocket): void { + const user = this.users.get(socket.authUser.id); + if (isNullish(user)) { return; } + if (isNullish(user.currentGame)) { return; } + const game = this.games.get(user.currentGame); + if (isNullish(game)) { return; } + + game.updateKeepAlive(user.userId); + } + private cleanupUser(socket: SSocket): void { if (!this.users.has(socket.authUser.id)) return; @@ -119,7 +129,7 @@ export class StateI { this.queue.delete(socket.authUser.id); } - private cleanupGame(gameId: GameId, game: TTC): void { + private cleanupGame(gameId: TTTGameId, game: TTC): void { clearInterval(game.gameUpdate ?? undefined); this.games.delete(gameId); let player: TTTUser | undefined; @@ -131,6 +141,7 @@ export class StateI { player.currentGame = null; player.socket.emit('gameEnd'); } + this.fastify.db.setTTTGameOutcome(gameId, game.playerX, game.playerO, game.checkWinner() ?? 'draw'); // do something here with the game result before deleting the game at the end } @@ -163,7 +174,7 @@ export class StateI { })); } - private gameUpdate(gameId: GameId, socket: SSocket): void { + private gameUpdate(gameId: TTTGameId, socket: SSocket): void { if (!this.users.has(socket.authUser.id)) return; const user = this.users.get(socket.authUser.id)!; @@ -176,19 +187,20 @@ export class StateI { currentPlayer: games.getCurrentState(), playerX: games.playerX, playerO: games.playerO, - gameState: games.checkState(), + gameState: games.checkWinner() ?? 'ongoing', gameId: gameId, }); } private gameMove(socket: SSocket, update: GameMove) { - if (!this.users.has(socket.authUser.id)) return 'unknownError'; + if (!this.users.has(socket.authUser.id)) return; const user = this.users.get(socket.authUser.id)!; - if (user.currentGame !== null && !this.games.has(user.currentGame)) return 'unknownError'; - const game = this.games.get(user.currentGame!)!; + if (user.currentGame === null) return; + if (!this.games.has(user.currentGame)) return; + const game = this.games.get(user.currentGame)!; - game.makeMove(socket.authUser.id, update.index); + game?.makeMove(socket.authUser.id, update.index); } }