From ed33ce904c19e6f3e840dbf588c56fdcdc48a373 Mon Sep 17 00:00:00 2001 From: amine-aitmokhtar Date: Sat, 29 Nov 2025 15:03:33 +0100 Subject: [PATCH] feat(db): implement persistence layer with postgresql Added Docker Compose configuration. Created direct save logic on game restart Added .env in gitignore to secure secrets --- .gitignore | 1 + docker-compose.yml | 18 ++++ game/server/db/db.js | 55 ++++++++++++ game/server/package-lock.json | 160 ++++++++++++++++++++++++++++++++++ game/server/package.json | 2 + game/server/server.js | 140 +++++++++++++++++------------ 6 files changed, 321 insertions(+), 55 deletions(-) create mode 100644 docker-compose.yml create mode 100644 game/server/db/db.js diff --git a/.gitignore b/.gitignore index c2658d7..4e23707 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +.env \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0de1a99 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: jump_dash_db + restart: always + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME} + ports: + - "${DB_PORT}:5432" + volumes: + - db_data:/var/lib/postgresql/data + +volumes: + db_data: \ No newline at end of file diff --git a/game/server/db/db.js b/game/server/db/db.js new file mode 100644 index 0000000..f6e768e --- /dev/null +++ b/game/server/db/db.js @@ -0,0 +1,55 @@ +const path = require('path'); +// Chargement des variables d'environnement depuis la racine +require('dotenv').config({ path: path.resolve(__dirname, '../../../.env') }); + +const { Pool } = require('pg'); + +// Configuration de la connexion PostgreSQL +const pool = new Pool({ + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432, + host: 'localhost', +}); + +// Initialisation de la table au demarrage +async function initDB() { + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS game_runs ( + id SERIAL PRIMARY KEY, + sensor_id VARCHAR(50) NOT NULL, + final_score INTEGER NOT NULL, + total_deaths INTEGER NOT NULL, + played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + console.log("Connexion BDD etablie et table verifiee."); + } catch (err) { + console.error("Erreur critique BDD :", err.message); + } +} + +// Fonction de sauvegarde directe (Ticket 1) +// Cette fonction insere la donnee immediatement sans attendre +async function saveGameRun(sensorId, score, deaths) { + if (score > 0) { + console.log(`[Direct Write] Sauvegarde en cours : Score ${score}`); + + const client = await pool.connect(); + try { + await client.query( + 'INSERT INTO game_runs(sensor_id, final_score, total_deaths, played_at) VALUES($1, $2, $3, $4)', + [sensorId, score, deaths, new Date()] + ); + console.log("Donnees persistees avec succes."); + } catch (err) { + console.error("Erreur d'insertion SQL :", err.message); + } finally { + client.release(); + } + } +} + +module.exports = { initDB, saveGameRun }; \ No newline at end of file diff --git a/game/server/package-lock.json b/game/server/package-lock.json index 68dd97b..9db677b 100644 --- a/game/server/package-lock.json +++ b/game/server/package-lock.json @@ -5,7 +5,9 @@ "packages": { "": { "dependencies": { + "dotenv": "^17.2.3", "express": "^5.1.0", + "pg": "^8.16.3", "serialport": "^13.0.0", "ws": "^8.18.3" } @@ -386,6 +388,18 @@ "node": ">= 0.8" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -814,6 +828,134 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1089,6 +1231,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1165,6 +1316,15 @@ "optional": true } } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/game/server/package.json b/game/server/package.json index 9a920c4..56c9042 100644 --- a/game/server/package.json +++ b/game/server/package.json @@ -1,6 +1,8 @@ { "dependencies": { + "dotenv": "^17.2.3", "express": "^5.1.0", + "pg": "^8.16.3", "serialport": "^13.0.0", "ws": "^8.18.3" } diff --git a/game/server/server.js b/game/server/server.js index 815b65d..bc0cbde 100644 --- a/game/server/server.js +++ b/game/server/server.js @@ -1,76 +1,57 @@ +require('dotenv').config({ path: '../../.env' }); + const express = require('express'); const { SerialPort, ReadlineParser } = require('serialport'); const WebSocket = require('ws'); +const { initDB, bufferGameRun } = require('./db/db'); const app = express(); const port = 3000; +// --- Configuration Constants --- +const SENSOR_ID = "ttyACM0"; // Identifiant du capteur pour la traçabilité BDD +const MIN_SCORE_TO_SAVE = 2; // Score minimum pour enregistrer une partie + +// --- Session Variables --- +let currentSessionScore = 0; +let currentSessionDeaths = 0; -// ---- Express Serve Static Files ---- +// Initialisation de la base de données au démarrage +initDB(); + +// --- HTTP Server --- const server = app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); }); -// ---- WebSocket Server ---- +// --- WebSocket Server --- const wss = new WebSocket.Server({ server }); -wss.on("connection", ws => { - console.log("Client connected"); - - ws.on("message", msg => { - try { - const data = JSON.parse(msg); - - if(data.type == "input") { - console.log("User input: ", data.action); - sendToArduino(String(data.action).toUpperCase()) - } - - if (data.type === "score") { - console.log("Score update:", data.score); - // send to Arduino - sendToArduino(`S${data.score}`); - } - - if (data.type === "death") { - console.log("Death update:", data.deaths); - - // send to Arduino - sendToArduino(`D${data.deaths}`); - } - } catch (e) { - console.error("Invalid message", e); - } - }); -}); +// --- Serial Port Configuration (Repeater Architecture) --- - -// ---- SerialPort Setup ---- - -// A. Le Vrai Port (Hardware) +// 1. Port Physique (Hardware) - Connexion directe Arduino const arduinoPort = new SerialPort({ path: "/dev/ttyACM0", baudRate: 9600, + autoOpen: true }); -// B. Le Port Pont (Pour le Monitor +// 2. Port Virtuel (Bridge) - Pour le monitoring const bridgePort = new SerialPort({ path: "/dev/ttyBRIDGE", baudRate: 9600, + autoOpen: true }); const parser = arduinoPort.pipe(new ReadlineParser()); -// When Arduino sends a line: +// --- Serial Data Reception (Arduino -> Server) --- parser.on("data", line => { - - const cleanLine = line.trim(); - console.log("Serial:", cleanLine); - - // 1. Pour le JEU (WebSocket) - // On utilise 'includes' pour être plus robuste si socat colle des caractères + const cleanLine = line.trim(); + + // Traitement pour le jeu : détection du saut + // Utilisation de includes() pour gérer la fragmentation potentielle des données if (cleanLine.includes("JUMP")) { - // broadcast jump to all browsers wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify({ action: "jump" })); @@ -78,28 +59,77 @@ parser.on("data", line => { }); } - // 2. Pour le MONITEUR (Le Répéteur) - // On copie ce qu'on reçoit vers le tuyau virtuel + // Duplication du trafic vers le port virtuel pour le moniteur if (bridgePort.isOpen) { bridgePort.write(cleanLine + "\n"); } }); -// --- Utility Functions --- +// --- Serial Data Transmission (Server -> Arduino) --- function sendToArduino(message) { const msg = message + "\n"; - - // Écriture sur le vrai Arduino (pour l'affichage) + + // Envoi au matériel réel (affichage score/morts) if (arduinoPort.isOpen) { - arduinoPort.write(message + "\n", err => { - if (err) return console.error("Serial write error:", err); + arduinoPort.write(msg, err => { + if (err) console.error("Arduino Write Error:", err); }); } - - // Écriture sur le Pont (pour que le Monitor voie aussi les scores) + + // Copie vers le moniteur via le pont virtuel if (bridgePort.isOpen) { bridgePort.write(msg, err => { - if (err) return console.error("Bridge write error:", err); + if (err) console.error("Bridge Write Error:", err); }); } -} \ No newline at end of file +} + +// --- WebSocket Management --- +wss.on("connection", ws => { + console.log("Client connected"); + + // Réinitialisation de la session en cas de déconnexion du client + ws.on("close", () => { + currentSessionScore = 0; + }); + + ws.on("message", msg => { + try { + const data = JSON.parse(msg); + + // Gestion des inputs clavier/touch + if(data.type == "input") { + sendToArduino(String(data.action).toUpperCase()); + } + + // Gestion du Score + if (data.type === "score") { + // Détection de fin de partie (Signal Restart reçu : score 0) + if (data.score === 0) { + // Sauvegarde en BDD si le score précédent était significatif + if (currentSessionScore >= MIN_SCORE_TO_SAVE) { + console.log(`End of session. Score: ${currentSessionScore}. Saving to DB.`); + bufferGameRun(SENSOR_ID, currentSessionScore, currentSessionDeaths); + } + // Reset des compteurs pour la nouvelle partie + currentSessionScore = 0; + currentSessionDeaths = 0; + } else { + // Mise à jour du score courant + currentSessionScore = data.score; + } + + sendToArduino(`S${data.score}`); + } + + // Gestion des Morts + if (data.type === "death") { + currentSessionDeaths = data.deaths; + sendToArduino(`D${data.deaths}`); + } + + } catch (e) { + console.error("Invalid message:", e); + } + }); +}); \ No newline at end of file -- GitLab