diff --git a/docker-compose.yml b/docker-compose.yml index fb993e0..33f54fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,25 @@ services: gelf-address: "udp://127.0.0.1:12201" tag: "{{.Name}}" + ############### + # TIC-TAC-TOE # + ############### + # tic-tac-toe: + # build: + # context: ./src/ + # args: + # - SERVICE=tic-tac-toe + # - EXTRA_FILES=tic-tac-toe/extra + # container_name: tic-tac-toe + # restart: unless-stopped + # networks: + # - transcendance-network + # volumes: + # - sqlite-volume:/volumes/database + # - static-volume:/volumes/static + # environment: + # - JWT_SECRET=KRUGKIDROVUWG2ZAMJZG653OEBTG66BANJ2W24DTEBXXMZLSEB2GQZJANRQXU6JA + # - DATABASE_DIR=/volumes/database ############### # CHAT # diff --git a/frontend/index.html b/frontend/index.html index 5163217..7ffbbff 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -28,6 +28,7 @@ πŸ‘€ Login πŸ‘€ Signin πŸ‘€ Chat + β­• Tic-Tac-Toe βš™οΈ Settings πŸšͺ Logout diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 125b99d..dfab156 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -3,6 +3,7 @@ import './root/root.ts' import './chat/chat.ts' import './login/login.ts' import './signin/signin.ts' +import './ttt/ttt.ts' import './profile/profile.ts' // ---- Initial load ---- diff --git a/frontend/src/pages/ttt/README.md b/frontend/src/pages/ttt/README.md new file mode 100644 index 0000000..d8169ae --- /dev/null +++ b/frontend/src/pages/ttt/README.md @@ -0,0 +1,29 @@ +Add another game with user history and matchmaking. +The goal of this major module, is to introduce a new game, distinct from Pong, and +incorporate features such as user history tracking and matchmaking. Key features +and objectives include: +β—¦ Develop a new, engaging game to diversify the platform’s offerings and enter- +tain users. +β—¦ Implement user history tracking to record and display individual users’ game- +play statistics. +β—¦ Create a matchmaking system to allow users to find opponents and participate +in fair and balanced matches. +β—¦ Ensure that user game history and matchmaking data are stored securely and +remain up-to-date. +β—¦ Optimize the performance and responsiveness of the new game to provide an +enjoyable user experience. Regularly update and maintain the game to fix +bugs, add new features, and enhance gameplay. +This major module aims to expand your platform by introducing a new game, +enhancing user engagement with gameplay history, and facilitating matchmaking +for an enjoyable gaming experience. + +# TO-DO +For now I am prohibited from working on the backend as per Maieul's request. +[ ] - Implement other game + [X] - (Done on Dec. 7 2025) Task for December 08 or 09: Tic-tac-toe should lock up once it's finished (i.e., draw or win) and print who won, how many turns and which row/col/diag the win happened in + +[ ] - Implement user history +[ ] - Implement matchmaking +[ ] - +[ ] - +[ ] - \ No newline at end of file diff --git a/frontend/src/pages/ttt/ttt.html b/frontend/src/pages/ttt/ttt.html new file mode 100644 index 0000000..7773eed --- /dev/null +++ b/frontend/src/pages/ttt/ttt.html @@ -0,0 +1,16 @@ +
+
+
+
+
+
+
+
+
+
+
+
+ +
diff --git a/frontend/src/pages/ttt/ttt.ts b/frontend/src/pages/ttt/ttt.ts new file mode 100644 index 0000000..e895914 --- /dev/null +++ b/frontend/src/pages/ttt/ttt.ts @@ -0,0 +1,191 @@ +import { addRoute, setTitle, type RouteHandlerReturn } from "@app/routing"; +import tttPage from "./ttt.html?raw"; +import { showError, showInfo, showSuccess } from "@app/toast"; + +// Represents the possible states of a cell on the board. +// `null` means that the cell is empty. +type CellState = 'O' | 'X' | null + +// Encapsulates the game logic. +class TTC { + + private isGameOver: boolean; + + private board: [ + CellState, CellState, CellState, + CellState, CellState, CellState, + CellState, CellState, CellState]; + private currentPlayer: 'O' | 'X'; + + constructor() { + this.board = [null,null,null,null,null,null,null,null,null]; + this.isGameOver = false; + this.currentPlayer = 'X'; + } + + private changePlayer() + { + if (this.currentPlayer === 'X') + this.currentPlayer = 'O'; + else + this.currentPlayer = 'X'; + } + + // Analyzes the current board to determine if the game has ended. + private 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; + } + + 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]; + 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 'ongoing'; + } + + public reset(): void { + this.board = [null,null,null,null,null,null,null,null,null]; + this.currentPlayer = 'X'; + this.isGameOver = false; + }; + + // 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) { + return 'invalidMove'; + } + if (idx < 0 || idx >= this.board.length) { + return 'invalidMove'; + } + if (this.board[idx] !== null) { + return 'invalidMove'; + } + this.board[idx] = this.currentPlayer; + this.changePlayer(); + + const result = this.checkState(); + + if (result !== 'ongoing') { + this.isGameOver = true; + } + + return result; + } + + public getBoard(): [ + CellState, CellState, CellState, + CellState, CellState, CellState, + CellState, CellState, CellState] + { + return this.board; + } +} + +// Route handler for the Tic-Tac-Toe page. +// Instantiates the game logic and binds UI events. +async function handleTTT(): Promise +{ + // Create a fresh instance for every page load. + let board = new TTC(); + + return { + html: tttPage, + postInsert: async (app) => { + if (!app) { + return; + } + + const cells = app.querySelectorAll(".ttt-grid-cell"); + const restartBtn = app.querySelector("#ttt-restart-btn"); + + const updateUI = () => { + const board_state = board.getBoard(); + board_state.forEach((cell_state, cell_idx) => { + cells[cell_idx].innerText = cell_state !== null ? cell_state : " "; + }); + }; + + console.log(cells); + + cells?.forEach(function (c, idx) { + c.addEventListener('click', () => { + const result = board.makeMove(idx); + switch(result) + { + case ('draw'): { + showInfo('Game is a draw'); + break; + } + case ('invalidMove'): { + showError('Move is invalid'); + break; + } + + case ('winX'): { + showSuccess('X won'); + app?.querySelector('.ttt-grid')?.classList.add('pointer-events-none'); + break; + } + case ('winO'): { + showSuccess('O won'); + app?.querySelector('.ttt-grid')?.classList.add('pointer-events-none'); + break; + } + } + + // Sync UI with Game State + const board_state = board.getBoard(); + board_state.forEach( function (cell_state, cell_idx) { + cells[cell_idx].innerText = cell_state !== null ? cell_state : " "; + }); + + updateUI(); + }); + }); + restartBtn?.addEventListener('click', () => { + board.reset(); + // Remove pointer-events-none to re-enable the board if it was disabled + app?.querySelector('.ttt-grid')?.classList.remove('pointer-events-none'); + updateUI(); + showInfo('Game Restarted'); + }); + } + } +} + + +addRoute('/ttt', handleTTT) \ No newline at end of file diff --git a/src/tic-tac-toe/.dockerignore b/src/tic-tac-toe/.dockerignore new file mode 100644 index 0000000..246d599 --- /dev/null +++ b/src/tic-tac-toe/.dockerignore @@ -0,0 +1,2 @@ +/dist +/node_modules \ No newline at end of file diff --git a/src/tic-tac-toe/README.md b/src/tic-tac-toe/README.md new file mode 100644 index 0000000..e8ef203 --- /dev/null +++ b/src/tic-tac-toe/README.md @@ -0,0 +1,18 @@ +# Directory layout +```plaintext +./src/tic-tac-toe +β”œβ”€β”€ entrypoint.sh +β”œβ”€β”€ package.json +β”œβ”€β”€ README.md +β”œβ”€β”€ src +β”‚ β”œβ”€β”€ app.ts # The microservice app file, where the major part of the backend code lives. +β”‚ └── run.ts # Equivalent of server.ts, it is the entrypoint for our service. +β”œβ”€β”€ tsconfig.json +└── vite.config.js +``` + +# Anatomy of a microservice + +# Backend + +# Frontend \ No newline at end of file diff --git a/src/tic-tac-toe/entrypoint.sh b/src/tic-tac-toe/entrypoint.sh new file mode 100644 index 0000000..91a963d --- /dev/null +++ b/src/tic-tac-toe/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -e +set -x + +# run the CMD [ ... ] from the dockerfile +exec "$@" diff --git a/src/tic-tac-toe/package.json b/src/tic-tac-toe/package.json new file mode 100644 index 0000000..2ae85bd --- /dev/null +++ b/src/tic-tac-toe/package.json @@ -0,0 +1,17 @@ +{ + "name": "tic-tac-toe", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "npm run build && node dist/run.js", + "build": "vite build", + "build:prod": "vite build --outDir=/dist --minify=true --sourcemap=false", + "build:openapi": "VITE_ENTRYPOINT=src/openapi.ts vite build && node dist/openapi.cjs >openapi.json", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.24.0" +} diff --git a/src/tic-tac-toe/src/app.ts b/src/tic-tac-toe/src/app.ts new file mode 100644 index 0000000..f3aef85 --- /dev/null +++ b/src/tic-tac-toe/src/app.ts @@ -0,0 +1,198 @@ +// import fastify, { FastifyInstance, FastifyPluginAsync } from 'fastify'; +// // TODO: Import Fastify formbody +// // TODO: Import Fastify multipart +// // TODO: Import shared database +// // TODO: Import shared auth +// // TODO: Import shared swagger +// // TODO: Import shared utils +// // TODO: Import socketio + +// // @brief ??? +// declare const __SERVICE_NAME: string; + +// // TODO: Import the plugins defined for this microservice +// // TODO: Import the routes defined for this microservice + +// // @brief The microservice app (as a plugin for Fastify), kinda like a main function I guess ??? +// // @param fastify +// // @param opts +// export const app: FastifyPluginAsync = async (fastify, opts): Promise => { +// // Register all the fastify plugins that this app will use + +// // Once it is done: +// fastify.ready((err) => { +// if (err) { +// throw err; +// } +// // TODO: Supposedly, something should be there I guess +// }); +// }; +// // Export it as the default for this file. +// export default app; + +// // TODO: Understand what is this for in /src/chat/src/app.ts +// // declare module 'fastify' { +// // interface FastifyInstance { +// // io: Server<{ +// // hello: (message: string) => string; +// // MsgObjectServer: (data: { message: ClientMessage }) => void; +// // message: (msg: string) => void; +// // testend: (sock_id_client: string) => void; +// // }>; +// // } +// // } + +// // TODO: Same for this, also in /src/chat/src/app.ts +// // async function onReady(fastify: FastifyInstance) { +// // function connectedUser(io?: Server, target?: string): number { +// // let count = 0; +// // const seen = new Set(); +// // // <- only log/count unique usernames + +// // for (const [socketId, username] of clientChat) { +// // // Basic sanity checks +// // if (typeof socketId !== 'string' || socketId.length === 0) { +// // clientChat.delete(socketId); +// // continue; +// // } +// // if (typeof username !== 'string' || username.length === 0) { +// // clientChat.delete(socketId); +// // continue; +// // } + +// // // If we have the io instance, attempt to validate the socket is still connected +// // if (io && typeof io.sockets?.sockets?.get === 'function') { +// // const s = io.sockets.sockets.get(socketId) as +// // | Socket +// // | undefined; +// // // If socket not found or disconnected, remove from map and skip +// // if (!s || s.disconnected) { +// // clientChat.delete(socketId); +// // continue; +// // } + +// // // Skip duplicates (DO NOT delete them β€” just don't count) +// // if (seen.has(username)) { +// // continue; +// // } +// // // socket exists and is connected +// // seen.add(username); +// // count++; +// // // console.log(color.green,"count: ", count); +// // console.log(color.yellow, 'Client:', color.reset, username); + +// // const targetSocketId = target; +// // io.to(targetSocketId!).emit('listObj', username); + +// // console.log( +// // color.yellow, +// // 'Chat Socket ID:', +// // color.reset, +// // socketId, +// // ); +// // continue; +// // } + +// // // If no io provided, assume entries in the map are valid and count them. +// // count++; +// // console.log( +// // color.red, +// // 'Client (unverified):', +// // color.reset, +// // username, +// // ); +// // console.log( +// // color.red, +// // 'Chat Socket ID (unverified):', +// // color.reset, +// // socketId, +// // ); +// // } + +// // return count; +// // } + +// // function broadcast(data: ClientMessage, sender?: string) { +// // fastify.io.fetchSockets().then((sockets) => { +// // for (const s of sockets) { +// // if (s.id !== sender) { +// // // Send REAL JSON object +// // const clientName = clientChat.get(s.id) || null; +// // if (clientName !== null) { +// // s.emit('MsgObjectServer', { message: data }); +// // } +// // console.log(' Target window socket ID:', s.id); +// // console.log(' Target window ID:', [...s.rooms]); +// // console.log(' Sender window ID:', sender ? sender : 'none'); +// // } +// // } +// // }); +// // } + +// // fastify.io.on('connection', (socket: Socket) => { +// // socket.on('message', (message: string) => { +// // console.info( +// // color.blue, +// // 'Socket connected!', +// // color.reset, +// // socket.id, +// // ); +// // console.log( +// // color.blue, +// // 'Received message from client', +// // color.reset, +// // message, +// // ); + +// // const obj: ClientMessage = JSON.parse(message) as ClientMessage; +// // clientChat.set(socket.id, obj.user); +// // console.log( +// // color.green, +// // 'Message from client', +// // color.reset, +// // `Sender: login name: "${obj.user}" - windowID "${obj.SenderWindowID}" - text message: "${obj.text}"`, +// // ); +// // // Send object directly β€” DO NOT wrap it in a string +// // broadcast(obj, obj.SenderWindowID); +// // console.log( +// // color.red, +// // 'connected in the Chat :', +// // connectedUser(fastify.io), +// // color.reset, +// // ); +// // }); + +// // socket.on('testend', (sock_id_cl: string) => { +// // console.log('testend received from client socket id:', sock_id_cl); +// // }); + +// // socket.on('list', () => { +// // console.log(color.red, 'list activated', color.reset, socket.id); +// // connectedUser(fastify.io, socket.id); +// // }); + +// // socket.on('disconnecting', (reason) => { +// // const clientName = clientChat.get(socket.id) || null; +// // console.log( +// // color.green, +// // `Client disconnecting: ${clientName} (${socket.id}) reason:`, +// // reason, +// // ); +// // if (reason === 'transport error') return; + +// // if (clientName !== null) { +// // const obj = { +// // type: 'chat', +// // user: clientName, +// // token: '', +// // text: 'LEFT the chat', +// // timestamp: Date.now(), +// // SenderWindowID: socket.id, +// // }; + +// // broadcast(obj, obj.SenderWindowID); +// // // clientChat.delete(obj.user); +// // } +// // }); +// // }); +// // } \ No newline at end of file diff --git a/src/tic-tac-toe/src/run.ts b/src/tic-tac-toe/src/run.ts new file mode 100644 index 0000000..d49db36 --- /dev/null +++ b/src/tic-tac-toe/src/run.ts @@ -0,0 +1,31 @@ +// @file run.ts +// @brief The entrypoint to the service. + +// Entry point of the microservice, ran by the Dockerfile. + +import fastify, { FastifyInstance } from 'fastify'; +import app from './app'; +// TODO: Import the microservice app + +// @brief Entrypoint for the microservice's backend. +const start = async () => { + // TODO: Thingies to send to log service (if I understood that correctly from /src/chat/src/run.ts) + + // TODO: Add the logging thingy to the call to fastify() + const fastInst: FastifyInstance = fastify(); + try { + process.on('SIGTERM', () => { + fastInst.log.info('Requested to shutdown'); + process.exit(143); + }); + // TODO: Uncomment when app.ts will be import-able. + await fastInst.register(app); + await fastInst.listen({ port: 80, host: '0.0.0.0' }); + } + catch (err) { + fastInst.log.error(err); + process.exit(1); + }; +}; + +start(); \ No newline at end of file diff --git a/src/tic-tac-toe/tsconfig.json b/src/tic-tac-toe/tsconfig.json new file mode 100644 index 0000000..cd65905 --- /dev/null +++ b/src/tic-tac-toe/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": {}, + "include": ["src/**/*.ts"] +} \ No newline at end of file diff --git a/src/tic-tac-toe/vite.config.js b/src/tic-tac-toe/vite.config.js new file mode 100644 index 0000000..a4b829c --- /dev/null +++ b/src/tic-tac-toe/vite.config.js @@ -0,0 +1,53 @@ +import { defineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import nodeExternals from 'rollup-plugin-node-externals'; +import path from 'node:path'; +import fs from 'node:fs'; + +function collectDeps(...pkgJsonPaths) { + const allDeps = new Set(); + for (const pkgPath of pkgJsonPaths) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + for (const dep of Object.keys(pkg.dependencies || {})) { + allDeps.add(dep); + } + for (const peer of Object.keys(pkg.peerDependencies || {})) { + allDeps.add(peer); + } + } + return Array.from(allDeps); +}; + +const externals = collectDeps( + './package.json', + '../@shared/package.json', +); + +export default defineConfig({ + root: __dirname, + define: { + __SERVICE_NAME: '"tic-tac-toe"', + }, + // service root + plugins: [tsconfigPaths(), nodeExternals()], + build: { + ssr: true, + outDir: 'dist', + emptyOutDir: true, + lib: { + entry: path.resolve(__dirname, process.env.VITE_ENTRYPOINT ?? 'src/run.ts'), + // adjust main entry + formats: ['cjs'], + // CommonJS for Node.js + fileName: () => 'index.js', + }, + rollupOptions: { + external: externals, + }, + target: 'node22', + // or whatever Node version you use + sourcemap: true, + minify: false, + // for easier debugging + }, +});