diff --git a/frontend/src/pages/ttt/socket.ts b/frontend/src/pages/ttt/socket.ts new file mode 100644 index 0000000..2450d9c --- /dev/null +++ b/frontend/src/pages/ttt/socket.ts @@ -0,0 +1,41 @@ +import { type Socket } from 'socket.io-client'; + +export type UpdateInfo = { + inQueue: number, + totalUser: number, +} +export type CellState = 'X' | 'O' | null; + +export type GameUpdate = { + gameId: string; + + playerX: string; + playerO: string; + + boardState: CellState[]; + currentPlayer: 'X' | 'O'; + gameState: 'winX' | 'winO' | 'concededX' | 'concededO' | 'draw' | 'ongoing'; +} + +export type GameMove = { + index: number; +} +export type GameMoveResponse = 'success' | 'invalidMove' | 'unknownError'; + +export interface ClientToServer { + enqueue: () => void; + dequeue: () => void; + debugInfo: () => void; + gameMove: (up: GameMove) => GameMoveResponse; +}; + +export interface ServerToClient { + forceDisconnect: (reason: string) => void; + queueEvent: (msg: 'registered' | 'unregistered') => void; + updateInformation: (info: UpdateInfo) => void, + newGame: (gameId: string) => void, + gameBoard: (state: GameUpdate) => void, +}; + +export type SSocket = Socket; +export type CSocket = Socket; diff --git a/frontend/src/pages/ttt/ttt.ts b/frontend/src/pages/ttt/ttt.ts index 75c0e37..bd31fc1 100644 --- a/frontend/src/pages/ttt/ttt.ts +++ b/frontend/src/pages/ttt/ttt.ts @@ -1,38 +1,32 @@ import { addRoute, type RouteHandlerReturn } from "@app/routing"; import tttPage from "./ttt.html?raw"; import { showError, showInfo, showSuccess } from "@app/toast"; -import { io, Socket } from "socket.io-client"; +import { io } from "socket.io-client"; +import type { CSocket as Socket } from "./socket"; -// get the name of the machine used to connect -const machineHostName = window.location.hostname; -console.log( - "connect to login at https://" + machineHostName + ":8888/app/login", -); -export let __socket: Socket | undefined = undefined; +declare module 'ft_state' { + interface State { + tttSock?: Socket; + } +} + document.addEventListener("ft:pageChange", () => { - if (__socket !== undefined) __socket.close(); - __socket = undefined; - console.log("Page changed"); + if (window.__state.tttSock !== undefined) window.__state.tttSock.close(); + window.__state.tttSock = undefined; }); export function getSocket(): Socket { - let addressHost = `wss://${machineHostName}:8888`; - // let addressHost = `wss://localhost:8888`; - if (__socket === undefined) - __socket = io(addressHost, { - path: "/api/ttt/socket.io/", - secure: true, - transports: ["websocket"], - }); - return __socket; + if (window.__state.tttSock === undefined) + window.__state.tttSock = io(window.location.host, { path: "/api/ttt/socket.io/" }) as any as Socket; + return window.__state.tttSock; } // Route handler for the Tic-Tac-Toe page. // Instantiates the game logic and binds UI events. async function handleTTT(): Promise { const socket: Socket = getSocket(); - + void socket; return { html: tttPage, postInsert: async (app) => { @@ -40,10 +34,12 @@ async function handleTTT(): Promise { return; } - const cells = - app.querySelectorAll(".ttt-grid-cell"); - const restartBtn = - app.querySelector("#ttt-restart-btn"); + socket.on('updateInformation', (e) => showInfo(`UpdateInformation: t=${e.totalUser};q=${e.inQueue}`)); + socket.on('queueEvent', (e) => showInfo(`QueueEvent: ${e}`)); + socket.on('newGame', (e) => showInfo(`newGame: ${e}`)); + socket.emit('enqueue'); + + const cells = app.querySelectorAll(".ttt-grid-cell"); const grid = app.querySelector(".ttt-grid"); // Not sure about this one const updateUI = (boardState: (string | null)[]) => { @@ -52,41 +48,36 @@ async function handleTTT(): Promise { }); }; - socket.on("gameState", (data) => { - updateUI(data.board); + socket.on('gameBoard', (u) => { + updateUI(u.boardState); - if (data.lastResult && data.lastResult !== "ongoing") { + if (u.gameState && u.gameState !== "ongoing") { grid?.classList.add("pointer-events-none"); - if (data.lastResult === "winX") { + if (u.gameState === "winX") { showSuccess("X won !"); } - if (data.lastResult === "winO") { + if (u.gameState === "winO") { showSuccess("O won !"); } - if (data.lastResult === "draw") { + if (u.gameState === "draw") { showInfo("Draw !"); } + if (u.gameState === 'concededX' ) + { + showInfo("concededX"); + } + if (u.gameState === 'concededO' ) + { + showInfo("concededO"); + } } - - if (data.reset) { - grid?.classList.remove("pointer-events-none"); - showInfo("Game Restarted"); - } - }); - - socket.on("error", (msg) => { - showError(msg); }); cells?.forEach(function(c, idx) { c.addEventListener("click", () => { - socket.emit("makeMove", idx); + socket.emit("gameMove", { index: idx }); }); }); - - restartBtn?.addEventListener("click", () => { - socket.emit("resetGame"); - }); }, }; } diff --git a/src/@shared/src/database/mixin/tictactoe.ts b/src/@shared/src/database/mixin/tictactoe.ts index 18e5305..0f0950c 100644 --- a/src/@shared/src/database/mixin/tictactoe.ts +++ b/src/@shared/src/database/mixin/tictactoe.ts @@ -1,10 +1,11 @@ import type { Database } from './_base'; +import { UserId } from './user'; // import { UserId } from './user'; // describe every function in the object export interface ITicTacToeDb extends Database { - setGameOutcome(this: ITicTacToeDb, id: GameId): void, -// asyncFunction(id: TemplateId): Promise, + setGameOutcome(this: ITicTacToeDb, id: GameId, player1: UserId, player2: UserId, outcome: string): void, + // asyncFunction(id: TemplateId): Promise, }; export const TicTacToeImpl: Omit = { @@ -25,13 +26,13 @@ export const TicTacToeImpl: Omit = { * * @returns what does the function return ? */ -// async asyncFunction(this: ITemplateDb, id: TemplateId): Promise { -// void id; -// return undefined; -// }, + // async asyncFunction(this: ITemplateDb, id: TemplateId): Promise { + // void id; + // return undefined; + // }, }; -export type GameId = number & { readonly __brand: unique symbol }; +export type GameId = string & { readonly __brand: unique symbol }; export type TicTacToeData = { readonly id: GameId; diff --git a/src/tic-tac-toe/src/app.ts b/src/tic-tac-toe/src/app.ts index 913255a..b0d0a30 100644 --- a/src/tic-tac-toe/src/app.ts +++ b/src/tic-tac-toe/src/app.ts @@ -1,4 +1,4 @@ -import { TTC } from './game'; +// import { TTC } from './game'; import { FastifyInstance, FastifyPluginAsync } from 'fastify'; import fastifyFormBody from '@fastify/formbody'; import fastifyMultipart from '@fastify/multipart'; @@ -7,6 +7,8 @@ import * as auth from '@shared/auth'; import * as swagger from '@shared/swagger'; import * as utils from '@shared/utils'; import { Server } from 'socket.io'; +import { State, createState } from './state'; +import { ClientToServer, ServerToClient } from './socket'; declare const __SERVICE_NAME: string; @@ -36,11 +38,10 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise => { void fastify.register(fastifyFormBody, {}); void fastify.register(fastifyMultipart, {}); - const game = new TTC(); - fastify.ready((err) => { if (err) throw err; - onReady(fastify, game); + onReady(fastify); + createState(fastify); }); }; export default app; @@ -48,53 +49,13 @@ export default app; // When using .decorate you have to specify added properties for Typescript declare module 'fastify' { interface FastifyInstance { - io: Server<{ - hello: (message: string) => string; - // idk you put something - // eslint-disable-next-line @typescript-eslint/no-explicit-any - gameState: any; - makeMove: (idx: number) => void; - resetGame: () => void; - error: string, - }>; + io: Server; } } -async function onReady(fastify: FastifyInstance, game: TTC) { +async function onReady(fastify: FastifyInstance) { fastify.io.on('connection', (socket) => { fastify.log.info(`Client connected: ${socket.id}`); - - socket.emit('gameState', { - board: game.board, - turn: game.currentPlayer, - gameOver: game.isGameOver, - }); - - socket.on('makeMove', (idx: number) => { - const result = game.makeMove(idx); - - if (result === 'invalidMove') { - socket.emit('error', 'Invalid Move'); - } - else { - if (result !== 'ongoing') { - fastify.db.setGameOutcome('011001', 'player1', 'player2', result); - } - fastify.io.emit('gameState', { - board: game.board, - turn: game.currentPlayer, - lastResult: result, - }); - } - }); - - socket.on('resetGame', () => { - game.reset(); - fastify.io.emit('gameState', { - board: game.board, - turn: game.currentPlayer, - reset: true, - }); - }); + State.registerUser(socket); }); -} \ No newline at end of file +} diff --git a/src/tic-tac-toe/src/game.ts b/src/tic-tac-toe/src/game.ts index 721ec91..e5cd1de 100644 --- a/src/tic-tac-toe/src/game.ts +++ b/src/tic-tac-toe/src/game.ts @@ -1,5 +1,7 @@ // import type { TicTacToeData } from '@shared/database/mixin/tictactoe'; +import { UserId } from '@shared/database/mixin/user'; + // Represents the possible states of a cell on the board. // `null` means that the cell is empty. type CellState = 'O' | 'X' | null @@ -9,31 +11,35 @@ export class TTC { public board: CellState[] = Array(9).fill(null); private currentPlayer: 'O' | 'X' = 'X'; + public gameUpdate: NodeJS.Timeout | null = null; + private changePlayer() { this.currentPlayer = this.currentPlayer === 'X' ? 'O' : 'X'; } + public constructor(public readonly playerX: UserId, public readonly playerO: UserId) { } + // Analyzes the current board to determine if the game has ended. - private checkState(): 'winX' | 'winO' | 'draw' | 'ongoing' { + 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];} + 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; }; const checkCol = (col: number): ('X' | 'O' | null) => { if (this.board[col] === null) return null; - if (this.board[col] === this.board[col + 3] && this.board[col + 3] === this.board[col + 6]) {return this.board[col];} + 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[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];} + if (this.board[2] === this.board[4] && this.board[4] === this.board[6]) { return this.board[4]; } return null; }; @@ -46,7 +52,7 @@ export class TTC { if (col !== null) return `win${col}`; if (diag !== null) return `win${diag}`; - if (this.board.filter(c => c === null).length === 0) {return 'draw';} + if (this.board.filter(c => c === null).length === 0) { return 'draw'; } return 'ongoing'; } @@ -59,8 +65,14 @@ export class TTC { // Attempts to place the current player's mark on the specified cell. // @param idx - The index of the board (0-8) to place the mark. // @returns The resulting game state, or `invalidMove` if the move is illegal. - public makeMove(idx: number): 'winX' | 'winO' | 'draw' | 'ongoing' | 'invalidMove' { - if (this.isGameOver) { + public makeMove(playerId: UserId, idx: number): 'success' | 'invalidMove' { + const player = playerId == this.playerX ? 'X' : ( + playerId == this.playerO ? 'O' : null + ); + + if (player === null) return 'invalidMove'; + + if (player !== this.currentPlayer || this.isGameOver) { return 'invalidMove'; } if (idx < 0 || idx >= this.board.length) { @@ -77,7 +89,9 @@ export class TTC { if (result !== 'ongoing') { this.isGameOver = true; } + return 'success'; - return result; } + + getCurrentState(): 'X' | 'O' { return this.currentPlayer; }; } diff --git a/src/tic-tac-toe/src/socket.ts b/src/tic-tac-toe/src/socket.ts new file mode 100644 index 0000000..5e21511 --- /dev/null +++ b/src/tic-tac-toe/src/socket.ts @@ -0,0 +1,41 @@ +import { Socket } from 'socket.io'; + +export type UpdateInfo = { + inQueue: number, + totalUser: number, +} +export type CellState = 'X' | 'O' | null; + +export type GameUpdate = { + gameId: string; + + playerX: string; + playerO: string; + + boardState: CellState[]; + currentPlayer: 'X' | 'O'; + gameState: 'winX' | 'winO' | 'concededX' | 'concededO' | 'draw' | 'ongoing'; +} + +export type GameMove = { + index: number; +} +export type GameMoveResponse = 'success' | 'invalidMove' | 'unknownError'; + +export interface ClientToServer { + enqueue: () => void; + dequeue: () => void; + debugInfo: () => void; + gameMove: (up: GameMove) => GameMoveResponse; +}; + +export interface ServerToClient { + forceDisconnect: (reason: string) => void; + queueEvent: (msg: 'registered' | 'unregistered') => void; + updateInformation: (info: UpdateInfo) => void, + newGame: (gameId: string) => void, + gameBoard: (state: GameUpdate) => void, +}; + +export type SSocket = Socket; +export type CSocket = Socket; diff --git a/src/tic-tac-toe/src/state.ts b/src/tic-tac-toe/src/state.ts new file mode 100644 index 0000000..6141ead --- /dev/null +++ b/src/tic-tac-toe/src/state.ts @@ -0,0 +1,175 @@ +import { UserId } from '@shared/database/mixin/user'; +import { FastifyInstance } from 'fastify'; +import { GameMove, GameMoveResponse, SSocket } from './socket'; +import { isNullish } from '@shared/utils'; +import { newUUID } from '@shared/utils/uuid'; +import { GameId } from '@shared/database/mixin/tictactoe'; +import { TTC } from './game'; + +type TTTUser = { + socket: SSocket, + userId: UserId, + windowId: string, + updateInterval: NodeJS.Timeout, + currentGame: GameId | null, +} + +export class StateI { + private users: Map = new Map(); + + private queue: Set = new Set(); + + private queueInterval: NodeJS.Timeout; + + private games: Map = new Map(); + + constructor(private fastify: FastifyInstance) { + this.queueInterval = setInterval(() => this.queuerFunction()); + void this.queueInterval; + } + + private queuerFunction(): void { + const values = Array.from(this.queue.values()); + while (values.length >= 2) { + const id1 = values.pop(); + const id2 = values.pop(); + + if (isNullish(id1) || isNullish(id2)) { + continue; + } + + const u1 = this.users.get(id1); + const u2 = this.users.get(id2); + + if (isNullish(u1) || isNullish(u2)) { + continue; + } + this.queue.delete(id1); + this.queue.delete(id2); + + const gameId = newUUID() as unknown as GameId; + u1.socket.emit('newGame', gameId); + u2.socket.emit('newGame', gameId); + + this.games.set(gameId, new TTC(u1.userId, u2.userId)); + + u1.currentGame = gameId; + u2.currentGame = gameId; + + this.games.get(gameId)!.gameUpdate = setInterval(() => { + this.gameUpdate(gameId, u1.socket); + this.gameUpdate(gameId, u2.socket); + }, 100); + } + } + + public registerUser(socket: SSocket): void { + this.fastify.log.info('Registering new user'); + if (this.users.has(socket.authUser.id)) { + socket.emit('forceDisconnect', 'Already Connected'); + socket.disconnect(); + return; + } + this.users.set(socket.authUser.id, { + socket, + userId: socket.authUser.id, + windowId: socket.id, + updateInterval: setInterval(() => this.updateClient(socket), 3000), + currentGame: null, + }); + this.fastify.log.info('Registered new user'); + + socket.on('disconnect', () => this.cleanupUser(socket)); + socket.on('enqueue', () => this.enqueueUser(socket)); + socket.on('dequeue', () => this.dequeueUser(socket)); + socket.on('debugInfo', () => this.debugSocket(socket)); + + socket.on('gameMove', (e) => this.gameMove(socket, e)); + } + + private updateClient(socket: SSocket): void { + socket.emit('updateInformation', { + inQueue: this.queue.size, + totalUser: this.users.size, + }); + } + + private cleanupUser(socket: SSocket): void { + if (!this.users.has(socket.authUser.id)) return; + + clearInterval(this.users.get(socket.authUser.id)?.updateInterval); + this.users.delete(socket.authUser.id); + this.queue.delete(socket.authUser.id); + } + + private enqueueUser(socket: SSocket): void { + if (!this.users.has(socket.authUser.id)) return; + + if (this.queue.has(socket.authUser.id)) return; + + if (this.users.get(socket.authUser.id)?.currentGame !== null) return; + + this.queue.add(socket.authUser.id); + socket.emit('queueEvent', 'registered'); + } + + private dequeueUser(socket: SSocket): void { + if (!this.users.has(socket.authUser.id)) return; + + if (!this.queue.has(socket.authUser.id)) return; + + this.queue.delete(socket.authUser.id); + socket.emit('queueEvent', 'unregistered'); + } + + private debugSocket(socket: SSocket): void { + console.log(({ + message: `socket debug for ${socket.id}`, + userid: socket.authUser.id, + queue: this.queue, + users: this.users, + })); + } + + private gameUpdate(gameId: GameId, socket: SSocket): void { + if (!this.users.has(socket.authUser.id)) return; + const user = this.users.get(socket.authUser.id)!; + + if (user.currentGame !== gameId || !this.games.has(user.currentGame)) return; + + const games = this.games.get(gameId)!; + + socket.emit('gameBoard', { + boardState: games.board, + currentPlayer: games.getCurrentState(), + playerX: games.playerX, + playerO: games.playerO, + gameState: games.checkState(), + gameId: gameId, + }); + } + + private gameMove(socket: SSocket, update: GameMove): GameMoveResponse { + if (!this.users.has(socket.authUser.id)) return 'unknownError'; + 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!)!; + + return game.makeMove(socket.authUser.id, update.index); + } +} + +// this value will be overriten +export let State: StateI = undefined as unknown as StateI; + +export function createState(fastify: FastifyInstance) { + if (State !== undefined) { + throw new Error('State was already created !!!!'); + } + State = new StateI(fastify); +} + + +export default State; +