From 2195207297d354d12ecade8889e5ed38cdf6749c Mon Sep 17 00:00:00 2001 From: Maieul BOYER Date: Wed, 7 Jan 2026 20:04:38 +0100 Subject: [PATCH] feat(tournament): allow the creation of a tournament A tournament can be created (by the "owner") Any other players can join said tournament. The information is currently not displayed in the frontend, but does exists and is passed to the frontend using a socket.io event --- frontend/src/pages/pong/pong.css | 6 +- frontend/src/pages/pong/pong.html | 14 +- frontend/src/pages/pong/pong.ts | 357 +++++++++++++++++++++--------- frontend/src/pages/pong/socket.ts | 74 ++++--- nginx-dev/nginx.conf | 24 ++ src/@shared/src/utils/index.ts | 17 ++ src/pong/src/socket.ts | 74 +++++-- src/pong/src/state.ts | 108 ++++++++- src/pong/src/tour.ts | 54 +++++ 9 files changed, 558 insertions(+), 170 deletions(-) create mode 100644 src/pong/src/tour.ts diff --git a/frontend/src/pages/pong/pong.css b/frontend/src/pages/pong/pong.css index a913582..176eb9a 100644 --- a/frontend/src/pages/pong/pong.css +++ b/frontend/src/pages/pong/pong.css @@ -8,8 +8,8 @@ .btn-style { @apply - w-25 - h-8 + min-w-25 + min-h-8 border border-gray-500 rounded-3xl @@ -188,4 +188,4 @@ inline-flex items-center justify-center rounded-full w-8 h-8 bg-blue-500 border-10 border-blue-500 -} \ No newline at end of file +} diff --git a/frontend/src/pages/pong/pong.html b/frontend/src/pages/pong/pong.html index a2828d8..310c687 100644 --- a/frontend/src/pages/pong/pong.html +++ b/frontend/src/pages/pong/pong.html @@ -2,8 +2,13 @@
- ?👤 ?⏳ ?▮•▮ - + ?👤 + ?⏳ ?▮•▮ + +

Pong Box @@ -25,7 +30,8 @@ down: S
- You are red.
Your goal is to bounce the ball back to the adversary. + You are red.
Your goal is to bounce the ball back to the + adversary.
local games keys for the left paddle:
up: @@ -33,7 +39,7 @@ down: L
-

+
diff --git a/frontend/src/pages/pong/pong.ts b/frontend/src/pages/pong/pong.ts index 150598d..618c1f9 100644 --- a/frontend/src/pages/pong/pong.ts +++ b/frontend/src/pages/pong/pong.ts @@ -1,14 +1,20 @@ -import { addRoute, navigateTo, setTitle, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing"; -import authHtml from './pong.html?raw'; -import io from 'socket.io-client'; +import { + addRoute, + navigateTo, + setTitle, + type RouteHandlerParams, + type RouteHandlerReturn, +} from "@app/routing"; +import authHtml from "./pong.html?raw"; +import io from "socket.io-client"; import type { CSocket, GameMove, GameUpdate } from "./socket"; -import { showError, showInfo } from "@app/toast"; +import { showError, showInfo, showSuccess } from "@app/toast"; import { getUser, type User } from "@app/auth"; import { isNullish } from "@app/utils"; import client from "@app/api"; import "./pong.css"; -declare module 'ft_state' { +declare module "ft_state" { interface State { pongSock?: CSocket; } @@ -20,96 +26,176 @@ enum QueueState { Ready = "Ready-ing", Iddle = "Queue Up", In_local = "In Local", -}; +} enum ReadyState { readyUp = "ready ok", readyDown = "not ready", -}; +} + +enum TourBtnState { + Joined = "Joined", + Started = "Started", + AbleToJoin = "Join Tournament", + AbleToCreate = "Create Tournament", + AbleToStart = "Start Tournament", + AbeToProcreate = "He would be proud", +} document.addEventListener("ft:pageChange", (newUrl) => { - if (newUrl.detail.startsWith('/app/pong') || newUrl.detail.startsWith('/pong')) return; + if ( + newUrl.detail.startsWith("/app/pong") || + newUrl.detail.startsWith("/pong") + ) + return; if (window.__state.pongSock !== undefined) window.__state.pongSock.close(); window.__state.pongSock = undefined; }); export function getSocket(): CSocket { if (window.__state.pongSock === undefined) - window.__state.pongSock = io(window.location.host, { path: "/api/pong/socket.io/" }) as any as CSocket; + window.__state.pongSock = io(window.location.host, { + path: "/api/pong/socket.io/", + }) as any as CSocket; return window.__state.pongSock; } -function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn { - setTitle('Pong Game Page'); +function pongClient( + _url: string, + _args: RouteHandlerParams, +): RouteHandlerReturn { + setTitle("Pong Game Page"); return { - html: authHtml, postInsert: async (app) => { + html: authHtml, + postInsert: async (app) => { const DEFAULT_COLOR = "white"; const SELF_COLOR = "red"; const user = getUser(); let currentGame: GameUpdate | null = null; let opponent: User | null = null; - const rdy_btn = document.querySelector('#readyup-btn'); + const rdy_btn = + document.querySelector("#readyup-btn"); const batLeft = document.querySelector("#batleft"); - const batRight = document.querySelector("#batright"); + const batRight = + document.querySelector("#batright"); const ball = document.querySelector("#ball"); - const score = document.querySelector("#score-board"); - const playerL = document.querySelector('#player-left'); - const playerR = document.querySelector('#player-right'); - const queueBtn = document.querySelector("#QueueBtn"); - const LocalGameBtn = document.querySelector("#LocalBtn"); - const gameBoard = document.querySelector("#pongbox"); - const queue_infos = document.querySelector("#queue-info"); - const how_to_play_btn = document.querySelector("#play-info"); - const protips = document.querySelector("#protips-box"); + const score = + document.querySelector("#score-board"); + const playerL = + document.querySelector("#player-left"); + const playerR = + document.querySelector("#player-right"); + const queueBtn = + document.querySelector("#QueueBtn"); + const LocalGameBtn = + document.querySelector("#LocalBtn"); + const gameBoard = + document.querySelector("#pongbox"); + const queue_infos = + document.querySelector("#queue-info"); + const how_to_play_btn = + document.querySelector("#play-info"); + const protips = + document.querySelector("#protips-box"); + const tournamentBtn = + document.querySelector("#TourBtn"); let socket = getSocket(); - if (isNullish(user)) { // if no user (no loggin / other) : GTFO + if (isNullish(user)) { + // if no user (no loggin / other) : GTFO navigateTo("/app"); - return ; + return; } - if (!batLeft || !batRight || !ball || !score || !queueBtn || !playerL || !playerR || !gameBoard || !queue_infos || !LocalGameBtn || !rdy_btn) // sanity check - return showError('fatal error'); - if (!how_to_play_btn || !protips) - showError('missing protips'); // not a fatal error + if ( + !batLeft || + !batRight || + !ball || + !score || + !queueBtn || + !playerL || + !playerR || + !gameBoard || + !queue_infos || + !LocalGameBtn || + !rdy_btn || + !tournamentBtn + ) + // sanity check + return showError("fatal error"); + if (!how_to_play_btn || !protips) showError("missing protips"); // not a fatal error + + tournamentBtn.addEventListener("click", () => { + showInfo(`Button State: ${tournamentBtn.innerText}`); + + switch (tournamentBtn.innerText) { + case TourBtnState.AbleToStart: + //socket.emit('') + break; + case TourBtnState.AbleToJoin: + socket.emit("tourRegister"); + break; + case TourBtnState.AbleToCreate: + socket.emit("tourCreate"); + break; + case TourBtnState.AbeToProcreate: + showError("We are developpers, this is impossible !"); + break; + case TourBtnState.Joined: + socket.emit("tourUnregister"); + break; + case TourBtnState.Started: + showInfo("tournament Started"); + //socket.emit("tourStart"); + break; + } + }); // --- // keys handler // --- const keys: Record = {}; if (how_to_play_btn && protips) - how_to_play_btn.addEventListener("click", ()=>{ + how_to_play_btn.addEventListener("click", () => { protips.classList.toggle("hidden"); - how_to_play_btn.innerText = how_to_play_btn.innerText === '?' ? 'x' : '?'; + how_to_play_btn.innerText = + how_to_play_btn.innerText === "?" ? "x" : "?"; }); - document.addEventListener("keydown", (e) => {keys[e.key.toLowerCase()] = true;}); - document.addEventListener("keyup", (e) => {keys[e.key.toLowerCase()] = false;}); + document.addEventListener("keydown", (e) => { + keys[e.key.toLowerCase()] = true; + }); + document.addEventListener("keyup", (e) => { + keys[e.key.toLowerCase()] = false; + }); - setInterval(() => { // key sender - if (keys['escape'] === true && protips && how_to_play_btn) { + setInterval(() => { + // key sender + if (keys["escape"] === true && protips && how_to_play_btn) { protips.classList.add("hidden"); - how_to_play_btn.innerText = '?'; + how_to_play_btn.innerText = "?"; } - if (queueBtn.innerText !== QueueState.InGame)//we're in game ? continue | gtfo - return ; + if (queueBtn.innerText !== QueueState.InGame) + //we're in game ? continue | gtfo + return; if (currentGame === null) return; let packet: GameMove = { move: null, moveRight: null, - } + }; - if (queueBtn.innerText !== QueueState.InGame)//we're in game ? continue | gtfo - return ; + if (queueBtn.innerText !== QueueState.InGame) + //we're in game ? continue | gtfo + return; if (currentGame === null) return; - if ((keys['w'] !== keys['s'])) - packet.move = keys['w'] ? 'up' : 'down'; - if (currentGame.local && (keys['o'] !== keys['l'])) - packet.moveRight = keys['o'] ? 'up' : 'down'; - socket.emit('gameMove', packet); + if (keys["w"] !== keys["s"]) + packet.move = keys["w"] ? "up" : "down"; + if (currentGame.local && keys["o"] !== keys["l"]) + packet.moveRight = keys["o"] ? "up" : "down"; + socket.emit("gameMove", packet); }, 1000 / 60); // --- // keys end @@ -118,15 +204,28 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn // --- // position logic (client) // --- - const DEFAULT_POSITIONS : GameUpdate = { - gameId:"", - ball:{size:16, x:800/2, y:450/2}, - left:{id:"", paddle:{x:40, y:185, width:12, height:80}, score:0}, - right:{id:"", paddle:{x:748, y:185, width:12, height:80}, score:0}, - local:false + const DEFAULT_POSITIONS: GameUpdate = { + gameId: "", + ball: { size: 16, x: 800 / 2, y: 450 / 2 }, + left: { + id: "", + paddle: { x: 40, y: 185, width: 12, height: 80 }, + score: 0, + }, + right: { + id: "", + paddle: { x: 748, y: 185, width: 12, height: 80 }, + score: 0, + }, + local: false, }; - function resetBoard(batLeft : HTMLDivElement, batRight : HTMLDivElement, playerL : HTMLDivElement, playerR : HTMLDivElement) { + function resetBoard( + batLeft: HTMLDivElement, + batRight: HTMLDivElement, + playerL: HTMLDivElement, + playerR: HTMLDivElement, + ) { render(DEFAULT_POSITIONS); batLeft.style.backgroundColor = DEFAULT_COLOR; batRight.style.backgroundColor = DEFAULT_COLOR; @@ -154,115 +253,131 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn ball.style.height = `${state.ball.size * 2}px`; ball.style.width = `${state.ball.size * 2}px`; - score.innerText = `${state.left.score} | ${state.right.score}` - } - socket.on('gameUpdate', (state: GameUpdate) => { - render(state);}); + score.innerText = `${state.left.score} | ${state.right.score}`; + }; + socket.on("gameUpdate", (state: GameUpdate) => { + render(state); + }); // --- // position logic (client) end // --- // --- - // queue evt + // queue evt // --- // utils - function set_pretty(batU : HTMLDivElement, txtU : HTMLDivElement, txtO : HTMLDivElement, colorYou : string) { + function set_pretty( + batU: HTMLDivElement, + txtU: HTMLDivElement, + txtO: HTMLDivElement, + colorYou: string, + ) { batU.style.backgroundColor = colorYou; txtU.style.color = colorYou; txtU.innerText = isNullish(user) ? "you" : user.name; - txtO.innerText = isNullish(opponent) ? "the mechant" : opponent.name; + txtO.innerText = isNullish(opponent) + ? "the mechant" + : opponent.name; } - async function get_opponent(opponent_id : string) { - let t = await client.getUser({user:opponent_id}); + async function get_opponent(opponent_id: string) { + let t = await client.getUser({ user: opponent_id }); switch (t.kind) { - case "success" : + case "success": opponent = t.payload; - break ; - default : + break; + default: opponent = null; } } // btn setup - queueBtn.addEventListener("click", ()=>{ + queueBtn.addEventListener("click", () => { if (queueBtn.innerText !== QueueState.Iddle) { if (queueBtn.innerText === QueueState.InQueu) { socket.emit("dequeue"); queueBtn.innerText = QueueState.Iddle; } - return ; + return; } queueBtn.innerText = QueueState.InQueu; - socket.emit('enqueue'); + socket.emit("enqueue"); }); LocalGameBtn.addEventListener("click", () => { - if (queueBtn.innerText !== QueueState.Iddle || currentGame !== null) { - showError("cant launch a local game while in queue/in game"); - return ; + if ( + queueBtn.innerText !== QueueState.Iddle || + currentGame !== null + ) { + showError( + "cant launch a local game while in queue/in game", + ); + return; } socket.emit("localGame"); queueBtn.innerText = QueueState.In_local; LocalGameBtn.innerText = "playing"; }); - rdy_btn.addEventListener("click", ()=>{ + rdy_btn.addEventListener("click", () => { showInfo("rdy-evt"); switch (rdy_btn.innerText) { case ReadyState.readyDown: - socket.emit('readyUp'); + socket.emit("readyUp"); rdy_btn.innerText = ReadyState.readyUp; rdy_btn.classList.remove("text-red-600"); rdy_btn.classList.add("text-green-600"); - break ; + break; case ReadyState.readyUp: - socket.emit('readyDown'); + socket.emit("readyDown"); rdy_btn.innerText = ReadyState.readyDown; rdy_btn.classList.remove("text-green-600"); rdy_btn.classList.add("text-red-600"); - break ; + break; default: showError("error on ready btn"); } }); - socket.on('newGame', async (state) => { + socket.on("newGame", async (state) => { render(state); - - await get_opponent(state.left.id == user.id ? state.right.id : state.left.id); + + await get_opponent( + state.left.id == user.id ? state.right.id : state.left.id, + ); queueBtn.innerText = QueueState.InGame; - queueBtn.style.color = 'red'; + queueBtn.style.color = "red"; batLeft.style.backgroundColor = DEFAULT_COLOR; batRight.style.backgroundColor = DEFAULT_COLOR; if (state.left.id === user.id) { set_pretty(batLeft, playerL, playerR, SELF_COLOR); } else if (state.right.id === user.id) { set_pretty(batRight, playerR, playerL, SELF_COLOR); - } else - showError("couldn't find your id in game"); - rdy_btn.classList.remove('hidden'); + } else showError("couldn't find your id in game"); + rdy_btn.classList.remove("hidden"); rdy_btn.classList.add("text-red-600"); rdy_btn.innerText = ReadyState.readyDown; }); - socket.on('rdyEnd', () => { + socket.on("rdyEnd", () => { rdy_btn.classList.remove("text-green-600"); rdy_btn.classList.remove("text-red-600"); - rdy_btn.classList.add('hidden'); + rdy_btn.classList.add("hidden"); }); socket.on("gameEnd", (winner) => { - rdy_btn.classList.add('hidden'); + rdy_btn.classList.add("hidden"); queueBtn.innerHTML = QueueState.Iddle; - queueBtn.style.color = 'white'; + queueBtn.style.color = "white"; if (!isNullish(currentGame)) { - let new_div = document.createElement('div'); + let new_div = document.createElement("div"); let end_txt = ""; - if ((user.id === currentGame.left.id && winner === 'left') || - (user.id === currentGame.right.id && winner === 'right')) - end_txt = 'won! #yippe'; - else - end_txt = 'lost #sadge'; - new_div.innerText = 'you ' + end_txt; + if ( + (user.id === currentGame.left.id && + winner === "left") || + (user.id === currentGame.right.id && winner === "right") + ) + end_txt = "won! #yippe"; + else end_txt = "lost #sadge"; + new_div.innerText = "you " + end_txt; new_div.className = "pong-end-screen"; gameBoard.appendChild(new_div); setTimeout(() => { @@ -270,25 +385,61 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn }, 3 * 1000); if (currentGame.local) { - LocalGameBtn.innerText = "Local Game" + LocalGameBtn.innerText = "Local Game"; } } resetBoard(batLeft, batRight, playerL, playerR); - }) - socket.on('updateInformation', (e) => { + }); + socket.on("updateInformation", (e) => { queue_infos.innerText = `${e.totalUser}👤 ${e.inQueue}⏳ ${e.totalGames}▮•▮`; }); - socket.on('queueEvent', (e) => showInfo(`QueueEvent: ${e}`)); // MAYBE: play a sound? to notify user that smthing happend + socket.on("queueEvent", (e) => showInfo(`QueueEvent: ${e}`)); // MAYBE: play a sound? to notify user that smthing happend // --- // queue evt end // --- + socket.on("tournamentInfo", (s) => { + // no tournament => we can create it ! + if (s === null) { + tournamentBtn.innerText = TourBtnState.AbleToCreate; + // create tournament + return; + } + + let weIn = s.players.some((p) => p.id === user.id); + let imOwner = s.ownerId === user.id; + switch (s.state) { + case "ended": + tournamentBtn.innerText = TourBtnState.AbleToCreate; + break; + case "playing": + tournamentBtn.innerText = TourBtnState.Started; + break; + case "prestart": + if (imOwner) { + tournamentBtn.innerText = TourBtnState.AbleToStart; + } else { + tournamentBtn.innerText = weIn + ? TourBtnState.Joined + : TourBtnState.AbleToJoin; + } + break; + } + + console.log(s.players); + }); + + socket.on("tournamentRegister", ({ kind, msg }) => { + if (kind === "success") showSuccess(msg ?? "Success"); + if (kind === "failure") showError(msg ?? "An error Occured"); + }); + // init - rdy_btn.classList.add('hidden'); + rdy_btn.classList.add("hidden"); queueBtn.innerText = QueueState.Iddle; rdy_btn.innerText = ReadyState.readyUp; resetBoard(batLeft, batRight, playerL, playerR); - } - } -}; -addRoute('/pong', pongClient); + }, + }; +} +addRoute("/pong", pongClient); diff --git a/frontend/src/pages/pong/socket.ts b/frontend/src/pages/pong/socket.ts index 387f2c7..f0e1638 100644 --- a/frontend/src/pages/pong/socket.ts +++ b/frontend/src/pages/pong/socket.ts @@ -1,56 +1,80 @@ import { Socket } from 'socket.io-client'; export type UpdateInfo = { - inQueue: number, - totalUser: number, - totalGames : number -} + inQueue: number; + totalUser: number; + totalGames: number; +}; export type PaddleData = { - x: number, - y: number, + x: number; + y: number; - width: number, - height: number, + width: number; + height: number; }; export type GameUpdate = { gameId: string; - left: { id: string, paddle: PaddleData, score: number }; - right: { id: string, paddle: PaddleData, score: number }; + left: { id: string; paddle: PaddleData; score: number }; + right: { id: string; paddle: PaddleData; score: number }; - ball: { x: number, y: number, size: number }; - local: boolean, -} + ball: { x: number; y: number; size: number }; + local: boolean; +}; export type GameMove = { - move: 'up' | 'down' | null, + move: 'up' | 'down' | null; // only used in local games - moveRight: 'up' | 'down' | null, -} + moveRight: 'up' | 'down' | null; +}; + +export type TourInfo = { + ownerId: string; + state: 'prestart' | 'playing' | 'ended'; + players: { id: string; name: string; score: number }[]; + currentGameInfo: GameUpdate | null; +}; -// TODO: add new evt such as "local play", "ready-up" see: ./pong.ts export interface ClientToServer { enqueue: () => void; dequeue: () => void; readyUp: () => void; - readyDown:() => void; + readyDown: () => void; debugInfo: () => void; gameMove: (up: GameMove) => void; connectedToGame: (gameId: string) => void; - localGame: () => void, -}; + localGame: () => void; + + // TOURNAMENT + + tourRegister: () => void; + tourUnregister: () => void; + + tourCreate: () => void; +} export interface ServerToClient { forceDisconnect: (reason: string) => void; queueEvent: (msg: 'registered' | 'unregistered') => void; - rdyEnd:() => void, - updateInformation: (info: UpdateInfo) => void, - newGame: (initState: GameUpdate) => void, // <- consider this the gameProc eg not start of game but wait for client to "ready up" - gameUpdate: (state: GameUpdate) => void, + rdyEnd: () => void; + updateInformation: (info: UpdateInfo) => void; + newGame: (initState: GameUpdate) => void; + gameUpdate: (state: GameUpdate) => void; gameEnd: (winner: 'left' | 'right') => void; -}; + + // TOURNAMENT + tournamentRegister: (res: { + kind: 'success' | 'failure'; + msg?: string; + }) => void; + tournamentCreateMsg: (res: { + kind: 'success' | 'failure'; + msg?: string; + }) => void; + tournamentInfo: (info: TourInfo | null) => void; +} export type SSocket = Socket; export type CSocket = Socket; diff --git a/nginx-dev/nginx.conf b/nginx-dev/nginx.conf index abc21a6..2b6663a 100644 --- a/nginx-dev/nginx.conf +++ b/nginx-dev/nginx.conf @@ -31,6 +31,30 @@ http { proxy_ssl_verify off; proxy_pass https://localhost:8888; } + location /api/chat/socket.io/ { + proxy_ssl_verify off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_read_timeout 3600s; + proxy_pass https://localhost:8888; + } + location /api/pong/socket.io/ { + proxy_ssl_verify off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_read_timeout 3600s; + proxy_pass https://localhost:8888; + } + location /api/ttt/socket.io/ { + proxy_ssl_verify off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_read_timeout 3600s; + proxy_pass https://localhost:8888; + } location /assets { proxy_pass http://localhost:5173; proxy_ssl_verify off; diff --git a/src/@shared/src/utils/index.ts b/src/@shared/src/utils/index.ts index 2a5946c..30648ba 100644 --- a/src/@shared/src/utils/index.ts +++ b/src/@shared/src/utils/index.ts @@ -149,3 +149,20 @@ export function escape(s: string): string { c => '&#' + c.charCodeAt(0) + ';', ); } + +export function shuffle(array: T[]) { + let currentIndex = array.length; + + // While there remain elements to shuffle... + while (currentIndex != 0) { + // Pick a remaining element... + const randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], + array[currentIndex], + ]; + } +} diff --git a/src/pong/src/socket.ts b/src/pong/src/socket.ts index 1126c2a..be065b1 100644 --- a/src/pong/src/socket.ts +++ b/src/pong/src/socket.ts @@ -1,55 +1,81 @@ import { Socket } from 'socket.io'; export type UpdateInfo = { - inQueue: number, - totalUser: number, - totalGames : number -} + inQueue: number; + totalUser: number; + totalGames: number; +}; export type PaddleData = { - x: number, - y: number, + x: number; + y: number; - width: number, - height: number, + width: number; + height: number; }; export type GameUpdate = { gameId: string; - left: { id: string, paddle: PaddleData, score: number }; - right: { id: string, paddle: PaddleData, score: number }; + left: { id: string; paddle: PaddleData; score: number }; + right: { id: string; paddle: PaddleData; score: number }; - ball: { x: number, y: number, size: number }; - local: boolean, -} + ball: { x: number; y: number; size: number }; + local: boolean; +}; export type GameMove = { - move: 'up' | 'down' | null, + move: 'up' | 'down' | null; // only used in local games - moveRight: 'up' | 'down' | null, -} + moveRight: 'up' | 'down' | null; +}; + +export type TourInfo = { + ownerId: string; + state: 'prestart' | 'playing' | 'ended'; + players: { id: string; name: string; score: number }[]; + currentGameInfo: GameUpdate | null; +}; export interface ClientToServer { enqueue: () => void; dequeue: () => void; readyUp: () => void; - readyDown:() => void; + readyDown: () => void; debugInfo: () => void; gameMove: (up: GameMove) => void; connectedToGame: (gameId: string) => void; - localGame: () => void, -}; + localGame: () => void; + + // TOURNAMENT + + tourRegister: () => void; + tourUnregister: () => void; + + tourCreate: () => void; + // tourStart: () => void; +} export interface ServerToClient { forceDisconnect: (reason: string) => void; queueEvent: (msg: 'registered' | 'unregistered') => void; - rdyEnd:() => void, - updateInformation: (info: UpdateInfo) => void, - newGame: (initState: GameUpdate) => void, - gameUpdate: (state: GameUpdate) => void, + rdyEnd: () => void; + updateInformation: (info: UpdateInfo) => void; + newGame: (initState: GameUpdate) => void; + gameUpdate: (state: GameUpdate) => void; gameEnd: (winner: 'left' | 'right') => void; -}; + + // TOURNAMENT + tournamentRegister: (res: { + kind: 'success' | 'failure'; + msg?: string; + }) => void; + tournamentCreateMsg: (res: { + kind: 'success' | 'failure'; + msg?: string; + }) => void; + tournamentInfo: (info: TourInfo | null) => void; +} export type SSocket = Socket; export type CSocket = Socket; diff --git a/src/pong/src/state.ts b/src/pong/src/state.ts index 0987e72..9123031 100644 --- a/src/pong/src/state.ts +++ b/src/pong/src/state.ts @@ -2,9 +2,10 @@ import { UserId } from '@shared/database/mixin/user'; import { newUUID } from '@shared/utils/uuid'; import { FastifyInstance } from 'fastify'; import { Pong } from './game'; -import { GameMove, GameUpdate, SSocket } from './socket'; -import { isNullish } from '@shared/utils'; +import { GameMove, GameUpdate, SSocket, TourInfo } from './socket'; +import { isNullish, shuffle } from '@shared/utils'; import { PongGameId, PongGameOutcome } from '@shared/database/mixin/pong'; +import { Tournament } from './tour'; type PUser = { id: UserId; @@ -23,6 +24,7 @@ class StateI { private queue: Set = new Set(); private queueInterval: NodeJS.Timeout; private games: Map = new Map(); + private tournament: Tournament | null = null; public constructor(private fastify: FastifyInstance) { this.queueInterval = setInterval(() => this.queuerFunction()); @@ -39,8 +41,71 @@ class StateI { }; } + private registerForTournament(sock: SSocket, name: string | null) { + const user = this.users.get(sock.authUser.id); + if (isNullish(user)) return; + + if (isNullish(this.tournament)) { + sock.emit('tournamentRegister', { kind: 'failure', msg: 'No tournament exists' }); + return; + } + if (this.tournament.started) { + sock.emit('tournamentRegister', { kind: 'failure', msg: 'No tournament already started' }); + return; + } + const udb = this.fastify.db.getUser(user.id); + if (isNullish(udb)) { + sock.emit('tournamentRegister', { kind: 'failure', msg: 'User not found' }); + return; + } + + this.tournament.addUser(user.id, name ?? udb.name); + sock.emit('tournamentRegister', { kind: 'success', msg: 'Registered to Tournament' }); + return; + } + + private unregisterForTournament(sock: SSocket) { + const user = this.users.get(sock.authUser.id); + if (isNullish(user)) return; + + if (isNullish(this.tournament)) { + sock.emit('tournamentRegister', { kind: 'failure', msg: 'No tournament exists' }); + return; + } + if (this.tournament.started) { + sock.emit('tournamentRegister', { kind: 'failure', msg: 'No tournament already started' }); + return; + } + + this.tournament.removeUser(user.id); + sock.emit('tournamentRegister', { kind: 'success', msg: 'Unregistered to Tournament' }); + return; + } + + private createTournament(sock: SSocket) { + const user = this.users.get(sock.authUser.id); + if (isNullish(user)) return; + + if (this.tournament !== null) { + sock.emit('tournamentCreateMsg', { kind: 'failure', msg: 'A tournament already exists' }); + return ; + } + + this.tournament = new Tournament(user.id); + this.registerForTournament(sock, null); + } + + private tournamentStart(sock: SSocket) { + if (isNullish(this.tournament)) return; + const user = this.users.get(sock.authUser.id); + if (isNullish(user)) return; + + this.tournament.start(); + } + private queuerFunction(): void { const values = Array.from(this.queue.values()); + shuffle(values); while (values.length >= 2) { const id1 = values.pop(); const id2 = values.pop(); @@ -80,7 +145,7 @@ class StateI { this.gameUpdate(gameId, u1.socket); this.gameUpdate(gameId, u2.socket); } - if (g.checkWinner() !== null) {this.cleanupGame(gameId, g); } + if (g.checkWinner() !== null) { this.cleanupGame(gameId, g); } }, 1000 / StateI.UPDATE_INTERVAL_FRAMES); } } @@ -148,7 +213,7 @@ class StateI { socket, id: socket.authUser.id, windowId: socket.id, - updateInterval: setInterval(() => this.updateClient(socket), 3000), + updateInterval: setInterval(() => this.updateClient(socket), 100), currentGame: null, }); this.fastify.log.info('Registered new user'); @@ -162,6 +227,12 @@ class StateI { socket.on('gameMove', (e) => this.gameMove(socket, e)); socket.on('localGame', () => this.newLocalGame(socket)); + + // todo: allow passing nickname + socket.on('tourRegister', () => this.registerForTournament(socket, null)); + socket.on('tourUnregister', () => this.unregisterForTournament(socket)); + + socket.on('tourCreate', () => this.createTournament(socket)); } private updateClient(socket: SSocket): void { @@ -170,6 +241,21 @@ class StateI { totalUser: this.users.size, totalGames: this.games.size, }); + let tourInfo: TourInfo | null = null; + if (this.tournament !== null) { + tourInfo = { + ownerId: this.tournament.owner, + state: this.tournament.started ? 'playing' : 'prestart', + players: this.tournament.users.values().toArray(), + currentGameInfo: (() => { + if (this.tournament.currentGame === null) return null; + const game = this.games.get(this.tournament.currentGame); + if (isNullish(game)) return null; + return StateI.getGameUpdateData(this.tournament.currentGame, game); + })(), + }; + } + socket.emit('tournamentInfo', tourInfo); } private cleanupUser(socket: SSocket): void { @@ -203,20 +289,20 @@ class StateI { this.fastify.db.setPongGameOutcome(gameId, { id: game.userLeft, score: game.score[0] }, { id: game.userRight, score: game.score[1] }, outcome, game.local); this.fastify.log.info('SetGameOutcome !'); if (!game.local) { - const payload = { 'nextGame':chat_text }; + const payload = { 'nextGame': chat_text }; try { const resp = await fetch('http://app-chat/api/chat/broadcast', { - method:'POST', - headers:{ 'Content-type':'application/json' }, + method: 'POST', + headers: { 'Content-type': 'application/json' }, body: JSON.stringify(payload), }); - if (!resp.ok) { throw (resp); } + if (!resp.ok) { throw (resp); } else { this.fastify.log.info('game-end info to chat success'); } } // disable eslint for err catching // eslint-disable-next-line @typescript-eslint/no-explicit-any - catch (e : any) { + catch (e: any) { this.fastify.log.error(`game-end info to chat failed: ${e}`); } } @@ -243,7 +329,7 @@ class StateI { socket.emit('queueEvent', 'unregistered'); } - private readydownUser(socket: SSocket) : void { + private readydownUser(socket: SSocket): void { // do we know this user ? if (!this.users.has(socket.authUser.id)) return; const user = this.users.get(socket.authUser.id)!; @@ -254,7 +340,7 @@ class StateI { if (game.local === true) return; game.readydown(user.id); } - private readyupUser(socket: SSocket) : void { + private readyupUser(socket: SSocket): void { // do we know this user ? if (!this.users.has(socket.authUser.id)) return; const user = this.users.get(socket.authUser.id)!; diff --git a/src/pong/src/tour.ts b/src/pong/src/tour.ts new file mode 100644 index 0000000..d2656f7 --- /dev/null +++ b/src/pong/src/tour.ts @@ -0,0 +1,54 @@ +import { PongGameId } from '@shared/database/mixin/pong'; +import { UserId } from '@shared/database/mixin/user'; +import { shuffle } from '@shared/utils'; +import { newUUID } from '@shared/utils/uuid'; +import { Pong } from './game'; + +type TourUser = { + id: UserId; + score: number; + name: string; +}; + +export class Tournament { + public users: Map = new Map(); + public currentGame: PongGameId | null = null; + public games: Map = new Map(); + public started: boolean = false; + public gameUpdate: NodeJS.Timeout | undefined; + + constructor(public owner: UserId) { } + + public addUser(id: UserId, name: string) { + if (this.started) return; + this.users.set(id, { id, name, score: 0 }); + } + + public removeUser(id: UserId) { + if (this.started) return; + this.users.delete(id); + } + + public start() { + this.started = true; + const users = Array.from(this.users.keys()); + const comb: [UserId, UserId][] = []; + + for (let i = 0; i < users.length; i++) { + for (let j = i + 1; j < users.length; j++) { + comb.push([users[i], users[j]]); + } + } + + shuffle(comb); + comb.forEach(shuffle); + + comb.forEach(([u1, u2]) => { + const gameId = newUUID() as PongGameId; + const g = new Pong(u1, u2); + + this.games.set(gameId, g); + }); + this.currentGame = this.games.keys().next().value ?? null; + } +}