Merge pull request #56 from Maix0/Alex/tic-tac-toe_frontend

Alex/tic tac toe frontend
This commit is contained in:
Maix0 2025-12-12 14:19:42 +01:00 committed by GitHub
commit 7ebd129faa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 588 additions and 0 deletions

View file

@ -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 #

View file

@ -28,6 +28,7 @@
<a href="/login" class="hover:bg-gray-700 rounded-md px-3 py-2">👤 Login</a>
<a href="/signin" class="hover:bg-gray-700 rounded-md px-3 py-2">👤 Signin</a>
<a href="/chat" class="hover:bg-gray-700 rounded-md px-3 py-2">👤 Chat</a>
<a href="/ttt" class="hover:bg-gray-700 rounded-md px-3 py-2">⭕ Tic-Tac-Toe</a>
<a href="/contact" class="hover:bg-gray-700 rounded-md px-3 py-2">⚙️ Settings</a>
<a href="/logout" class="hover:bg-gray-700 rounded-md px-3 py-2">🚪 Logout</a>
</nav>

View file

@ -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 ----

View file

@ -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 platforms 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
[ ] -
[ ] -
[ ] -

View file

@ -0,0 +1,16 @@
<div class="bg-gray-100 p-8">
<div class="grid grid-cols-3 gap-4 max-w-2xl mx-auto">
<div class="bg-blue-500 h-32 flex items-center justify-center text-white text-xl font-bold hover:bg-red-700 ttt-grid-cell"> </div>
<div class="bg-blue-500 h-32 flex items-center justify-center text-white text-xl font-bold hover:bg-red-700 ttt-grid-cell"> </div>
<div class="bg-blue-500 h-32 flex items-center justify-center text-white text-xl font-bold hover:bg-red-700 ttt-grid-cell"> </div>
<div class="bg-blue-500 h-32 flex items-center justify-center text-white text-xl font-bold hover:bg-red-700 ttt-grid-cell"> </div>
<div class="bg-blue-500 h-32 flex items-center justify-center text-white text-xl font-bold hover:bg-red-700 ttt-grid-cell"> </div>
<div class="bg-blue-500 h-32 flex items-center justify-center text-white text-xl font-bold hover:bg-red-700 ttt-grid-cell"> </div>
<div class="bg-blue-500 h-32 flex items-center justify-center text-white text-xl font-bold hover:bg-red-700 ttt-grid-cell"> </div>
<div class="bg-blue-500 h-32 flex items-center justify-center text-white text-xl font-bold hover:bg-red-700 ttt-grid-cell"> </div>
<div class="bg-blue-500 h-32 flex items-center justify-center text-white text-xl font-bold hover:bg-red-700 ttt-grid-cell"> </div>
</div>
<button id="ttt-restart-btn" class="mt-8 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Restart Game
</button>
</div>

View file

@ -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<RouteHandlerReturn>
{
// 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<HTMLDivElement>(".ttt-grid-cell");
const restartBtn = app.querySelector<HTMLButtonElement>("#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)

View file

@ -0,0 +1,2 @@
/dist
/node_modules

18
src/tic-tac-toe/README.md Normal file
View file

@ -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

View file

@ -0,0 +1,7 @@
#!/bin/sh
set -e
set -x
# run the CMD [ ... ] from the dockerfile
exec "$@"

View file

@ -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"
}

198
src/tic-tac-toe/src/app.ts Normal file
View file

@ -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<void> => {
// // 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<string>();
// // // <- 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);
// // }
// // });
// // });
// // }

View file

@ -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();

View file

@ -0,0 +1,5 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {},
"include": ["src/**/*.ts"]
}

View file

@ -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
},
});