From a52746ed0cb311235b62c3e4c3af89806f054f7e Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Fri, 20 Mar 2026 13:40:16 +0100 Subject: [PATCH] =?UTF-8?q?feat(sprint1-step3b):=20backend=20save=20system?= =?UTF-8?q?=20+=20anti-cheat=20+=20donn=C3=A9es=20rattrap=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game_saves table + migration 002 (JSON state, anti-cheat metadata) - saveControllers.js : load/save avec validation delta ressources (750k/s × 1.1) - GameSaveManager : upsert MySQL ON DUPLICATE KEY UPDATE - useSaveSync hook : auto-save 30s + keepalive beforeunload + guest fallback - save-validation.test.ts : 8 tests anti-cheat - economy.ts : arbre d'évolution 5 nœuds + prestige ADN (rattrapage step 2) - economy.test.ts : +40 tests (évolution tree, multipliers, start bonus) - GDD + SPRINT1.md : docs sprint complètes - Rethème data : shop.json, Achievements.json, Cookie, Legal (rattrapage step 1) --- .../database/migrations/002_game_saves.sql | 14 ++ Backend/database/schema.sql | 12 + Backend/src/controllers/saveControllers.js | 141 +++++++++++ Backend/src/models/GameSaveManager.js | 43 ++++ Backend/src/router.js | 5 + Backend/src/tables.js | 3 +- Frontend/README.md | 2 +- Frontend/index.html | 35 +-- Frontend/public/robots.txt | 2 +- Frontend/public/sitemap.xml | 16 +- Frontend/src/__tests__/economy.test.ts | 226 +++++++++++++++++- .../src/__tests__/save-validation.test.ts | 123 ++++++++++ Frontend/src/core/economy.ts | 115 ++++++++- Frontend/src/data/Achievements.json | 6 +- Frontend/src/data/shop.json | 36 +-- Frontend/src/hooks/useSaveSync.ts | 115 +++++++++ Frontend/src/pages/Cookie.jsx | 42 ++-- Frontend/src/pages/Legal.jsx | 24 +- docs/GDD.md | 192 +++++++++++---- docs/SPRINT1.md | 167 +++++++++++++ 20 files changed, 1167 insertions(+), 152 deletions(-) create mode 100644 Backend/database/migrations/002_game_saves.sql create mode 100644 Backend/src/controllers/saveControllers.js create mode 100644 Backend/src/models/GameSaveManager.js create mode 100644 Frontend/src/__tests__/save-validation.test.ts create mode 100644 Frontend/src/hooks/useSaveSync.ts create mode 100644 docs/SPRINT1.md diff --git a/Backend/database/migrations/002_game_saves.sql b/Backend/database/migrations/002_game_saves.sql new file mode 100644 index 0000000..a57f0a6 --- /dev/null +++ b/Backend/database/migrations/002_game_saves.sql @@ -0,0 +1,14 @@ +-- Migration 002 — Game saves with anti-cheat metadata +-- Safe: CREATE TABLE IF NOT EXISTS, no data loss +-- Run: mysql -u -p clickerz < migrations/002_game_saves.sql + +CREATE TABLE IF NOT EXISTS game_saves ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL UNIQUE, + game_state JSON NOT NULL, + last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + lifetime_tadpoles BIGINT DEFAULT 0, + prestige_count INT DEFAULT 0, + play_time_seconds INT DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/Backend/database/schema.sql b/Backend/database/schema.sql index 8c4793d..fb44c3e 100755 --- a/Backend/database/schema.sql +++ b/Backend/database/schema.sql @@ -1,3 +1,4 @@ +DROP TABLE IF EXISTS game_saves; DROP TABLE IF EXISTS users; CREATE TABLE users ( @@ -10,3 +11,14 @@ CREATE TABLE users ( lastname VARCHAR(50) NULL, super_oauth_id VARCHAR(36) NULL UNIQUE ); + +CREATE TABLE game_saves ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL UNIQUE, + game_state JSON NOT NULL, + last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + lifetime_tadpoles BIGINT DEFAULT 0, + prestige_count INT DEFAULT 0, + play_time_seconds INT DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/Backend/src/controllers/saveControllers.js b/Backend/src/controllers/saveControllers.js new file mode 100644 index 0000000..1134a13 --- /dev/null +++ b/Backend/src/controllers/saveControllers.js @@ -0,0 +1,141 @@ +const tables = require("../tables"); + +// --- Anti-cheat validation --- + +// Max production théorique par seconde (5 generators maxés + click humain réaliste) +// Tier 5 = 150/s × 200 owned = 30 000/s (extreme case) +// Tous tiers cumulés extreme ≈ 50 000/s +// × prestige multiplier max réaliste (×10) = 500 000/s +// × evolution tree multiplier (×1.5) = 750 000/s +// Marge ×1.1 appliquée dans la validation +const MAX_PRODUCTION_PER_SECOND = 750_000; +const CHEAT_MARGIN = 1.1; + +function validateGameState(gameState, previousSave) { + // Vérification structurelle basique + if (!gameState || typeof gameState !== "object") { + return { valid: false, reason: "Invalid game state format" }; + } + + if (typeof gameState.resources !== "number" || gameState.resources < 0) { + return { valid: false, reason: "Invalid resources value" }; + } + + if (!Array.isArray(gameState.generators)) { + return { valid: false, reason: "Invalid generators" }; + } + + // Si pas de save précédente, c'est la première — accepter + if (!previousSave) { + return { valid: true }; + } + + // Calcul du temps écoulé depuis le dernier save + const lastSaveTime = new Date(previousSave.last_save).getTime(); + const now = Date.now(); + const elapsedSeconds = Math.max((now - lastSaveTime) / 1000, 0); + + // Ressources max possibles = production max × temps × marge + const maxPossibleResources = + MAX_PRODUCTION_PER_SECOND * elapsedSeconds * CHEAT_MARGIN; + + // Comparer les ressources actuelles avec le maximum théorique + // On compare le delta (gain depuis dernier save) + const previousState = previousSave.game_state; + const previousResources = + typeof previousState === "object" + ? previousState.resources ?? 0 + : 0; + + const resourceDelta = gameState.resources - previousResources; + + if (resourceDelta > maxPossibleResources && resourceDelta > 0) { + return { + valid: false, + reason: `Resource gain (${Math.floor(resourceDelta)}) exceeds maximum possible (${Math.floor(maxPossibleResources)}) in ${Math.floor(elapsedSeconds)}s`, + }; + } + + return { valid: true }; +} + +// --- Controllers --- + +/** + * GET /api/save + * Charge la save du joueur connecté. + */ +const load = async (req, res) => { + try { + const userId = req.user; + const save = await tables.game_saves.getByUserId(userId); + + if (!save) { + return res.status(200).json({ gameState: null }); + } + + // game_state est stocké en JSON — MySQL le retourne comme objet si type JSON + const gameState = + typeof save.game_state === "string" + ? JSON.parse(save.game_state) + : save.game_state; + + return res.status(200).json({ + gameState, + lastSave: save.last_save, + lifetimeTadpoles: save.lifetime_tadpoles, + prestigeCount: save.prestige_count, + playTimeSeconds: save.play_time_seconds, + }); + } catch (err) { + console.error("save/load error:", err); + return res.status(500).json({ message: "Failed to load save." }); + } +}; + +/** + * POST /api/save + * Sauvegarde le game state du joueur connecté. + * Anti-cheat : valide le delta de ressources vs temps écoulé. + */ +const save = async (req, res) => { + try { + const userId = req.user; + const { gameState, playTimeSeconds } = req.body; + + if (!gameState) { + return res.status(400).json({ message: "gameState required." }); + } + + // Récupérer la save précédente pour validation + const previousSave = await tables.game_saves.getByUserId(userId); + + // Valider l'état + const validation = validateGameState(gameState, previousSave); + if (!validation.valid) { + console.warn( + `Anti-cheat: user ${userId} rejected — ${validation.reason}` + ); + return res.status(422).json({ + message: "Save rejected: anomaly detected.", + reason: validation.reason, + }); + } + + // Extraire les métadonnées pour la table + const metadata = { + lifetimeTadpoles: gameState.lifetimeTadpoles ?? 0, + prestigeCount: gameState.prestigeCount ?? 0, + playTimeSeconds: playTimeSeconds ?? 0, + }; + + await tables.game_saves.upsert(userId, gameState, metadata); + + return res.status(200).json({ message: "Save successful.", lastSave: new Date().toISOString() }); + } catch (err) { + console.error("save/save error:", err); + return res.status(500).json({ message: "Failed to save." }); + } +}; + +module.exports = { load, save }; diff --git a/Backend/src/models/GameSaveManager.js b/Backend/src/models/GameSaveManager.js new file mode 100644 index 0000000..1e56daf --- /dev/null +++ b/Backend/src/models/GameSaveManager.js @@ -0,0 +1,43 @@ +const AbstractManager = require("./AbstractManager"); + +class GameSaveManager extends AbstractManager { + constructor() { + super({ table: "game_saves" }); + } + + async getByUserId(userId) { + const [rows] = await this.database.query( + `SELECT * FROM ${this.table} WHERE user_id = ?`, + [userId] + ); + return rows[0] ?? null; + } + + async upsert(userId, gameState, metadata) { + const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata; + const gameStateJson = JSON.stringify(gameState); + + const [result] = await this.database.query( + `INSERT INTO ${this.table} (user_id, game_state, lifetime_tadpoles, prestige_count, play_time_seconds) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + game_state = VALUES(game_state), + lifetime_tadpoles = VALUES(lifetime_tadpoles), + prestige_count = VALUES(prestige_count), + play_time_seconds = VALUES(play_time_seconds), + last_save = CURRENT_TIMESTAMP`, + [userId, gameStateJson, lifetimeTadpoles, prestigeCount, playTimeSeconds] + ); + + return result.affectedRows; + } + + async delete(userId) { + await this.database.query( + `DELETE FROM ${this.table} WHERE user_id = ?`, + [userId] + ); + } +} + +module.exports = GameSaveManager; diff --git a/Backend/src/router.js b/Backend/src/router.js index 768cd2e..7e0c422 100755 --- a/Backend/src/router.js +++ b/Backend/src/router.js @@ -9,6 +9,7 @@ const router = express.Router(); // Import Controllers const userControllers = require("./controllers/userControllers"); const authControllers = require("./controllers/authControllers"); +const saveControllers = require("./controllers/saveControllers"); const verifyToken = require("./middlewares/verifyToken"); const verifyOAuth = require("./middlewares/verifyOAuth"); @@ -36,6 +37,10 @@ router.post("/login", userControllers.login); // Sync game state — SuperOAuth uniquement router.patch("/users/:id/coins", verifyOAuth, verifySelf, userControllers.updateCoins); +// Game saves — JWT required +router.get("/save", verifyToken, saveControllers.load); +router.post("/save", verifyToken, saveControllers.save); + /* ************************************************************************* */ diff --git a/Backend/src/tables.js b/Backend/src/tables.js index 6b826d1..3893223 100755 --- a/Backend/src/tables.js +++ b/Backend/src/tables.js @@ -4,10 +4,11 @@ // Import the manager modules responsible for handling data operations on the tables const UserManager = require("./models/UserManager"); +const GameSaveManager = require("./models/GameSaveManager"); const managers = [ UserManager, - // Add other managers here + GameSaveManager, ]; // Create an empty object to hold data managers for different tables diff --git a/Frontend/README.md b/Frontend/README.md index 1719d8e..ac5a0a6 100755 --- a/Frontend/README.md +++ b/Frontend/README.md @@ -1,6 +1,6 @@ -

Xmass Clicker

+

Clickerz — Tetard Universe


### 📄 About : diff --git a/Frontend/index.html b/Frontend/index.html index 4d6c1e1..4c34351 100755 --- a/Frontend/index.html +++ b/Frontend/index.html @@ -10,7 +10,7 @@ - - - + + + - + - - - - - - - - + - - Name + Clickerz — Tetard Universe
diff --git a/Frontend/public/robots.txt b/Frontend/public/robots.txt index 5331014..888cecb 100755 --- a/Frontend/public/robots.txt +++ b/Frontend/public/robots.txt @@ -1,4 +1,4 @@ -Sitemap: https://xmass.click/sitemap.xml +Sitemap: https://clickerz.tetardtek.com/sitemap.xml User-agent: AlphaSeoBot User-agent: AlphaSeoBot-SA diff --git a/Frontend/public/sitemap.xml b/Frontend/public/sitemap.xml index be15147..4aa432b 100755 --- a/Frontend/public/sitemap.xml +++ b/Frontend/public/sitemap.xml @@ -4,22 +4,20 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> - - - https://www.xmass.click/ - 2023-08-15T19:22:16+00:00 + https://clickerz.tetardtek.com/ + 2026-03-20T12:00:00+00:00 1.00 - https://www.xmass.click/boutique - 2023-08-15T19:22:16+00:00 + https://clickerz.tetardtek.com/boutique + 2026-03-20T12:00:00+00:00 0.80 - https://www.xmass.click/achievements - 2023-08-15T19:22:16+00:00 + https://clickerz.tetardtek.com/achievements + 2026-03-20T12:00:00+00:00 0.80 - \ No newline at end of file + diff --git a/Frontend/src/__tests__/economy.test.ts b/Frontend/src/__tests__/economy.test.ts index 57c30c5..b394f7d 100644 --- a/Frontend/src/__tests__/economy.test.ts +++ b/Frontend/src/__tests__/economy.test.ts @@ -7,13 +7,19 @@ import { buyGenerator, applyPrestige, canPrestige, + computePrestigeDna, + canBuyEvolutionNode, + buyEvolutionNode, + getClickMultiplierFromTree, + getProductionMultiplierFromTree, + getStartBonusFromTree, DEFAULT_STATE, DEFAULT_GENERATORS, + DEFAULT_EVOLUTION_TREE, } from "../core/economy"; -// PrestigePanel visibility guard — canPrestige drives render condition -// Ces tests valident l'invariant : le panneau prestige ne doit jamais être -// visible (canPrestige = false) si les ressources sont inférieures au seuil. +// --- PrestigePanel visibility --- + describe("PrestigePanel visibility (canPrestige guard)", () => { it("canPrestige = false pour resources = 0 → panneau non visible", () => { expect(canPrestige({ ...DEFAULT_STATE, resources: 0 })).toBe(false); @@ -28,6 +34,8 @@ describe("PrestigePanel visibility (canPrestige guard)", () => { }); }); +// --- Prestige reset --- + describe("applyPrestige — post-prestige state", () => { const prestigeState = { ...DEFAULT_STATE, @@ -35,9 +43,10 @@ describe("applyPrestige — post-prestige state", () => { generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 3 })), prestigeCount: 0, prestigeMultiplier: 1, + lifetimeTadpoles: 2_000_000_000, }; - it("ressources = 0 après prestige", () => { + it("ressources = startBonus (0 sans nœud Mémoire Génétique) après prestige", () => { expect(applyPrestige(prestigeState).resources).toBe(0); }); @@ -53,8 +62,43 @@ describe("applyPrestige — post-prestige state", () => { it("prestigeCount incrémenté à 1 après premier prestige", () => { expect(applyPrestige(prestigeState).prestigeCount).toBe(1); }); + + it("gagne de l'ADN Ancestral au prestige", () => { + const result = applyPrestige(prestigeState); + const expectedDna = computePrestigeDna(2_000_000_000); + expect(result.ancestralDna).toBe(expectedDna); + expect(expectedDna).toBeGreaterThan(0); + }); + + it("lifetimeTadpoles reset à 0 après prestige", () => { + expect(applyPrestige(prestigeState).lifetimeTadpoles).toBe(0); + }); + + it("arbre d'évolution persiste après prestige", () => { + const stateWithNode = { + ...prestigeState, + evolutionTree: prestigeState.evolutionTree.map((n) => + n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n + ), + }; + const result = applyPrestige(stateWithNode); + expect(result.evolutionTree.find((n) => n.id === "ponte_amelioree")!.unlocked).toBe(true); + }); + + it("startBonus appliqué si Mémoire Génétique débloquée", () => { + const stateWithMemory = { + ...prestigeState, + evolutionTree: prestigeState.evolutionTree.map((n) => + n.id === "memoire_genetique" ? { ...n, unlocked: true } : n + ), + }; + const result = applyPrestige(stateWithMemory); + expect(result.resources).toBe(100); + }); }); +// --- Generator cost --- + describe("generatorCost", () => { it("retourne baseCost quand owned = 0", () => { const gen = { ...DEFAULT_GENERATORS[0], owned: 0 }; @@ -67,6 +111,8 @@ describe("generatorCost", () => { }); }); +// --- Production --- + describe("totalProductionPerSecond", () => { it("retourne 0 si aucun générateur acheté", () => { expect(totalProductionPerSecond(DEFAULT_STATE)).toBe(0); @@ -87,8 +133,21 @@ describe("totalProductionPerSecond", () => { const state = { ...DEFAULT_STATE, generators: DEFAULT_STATE.generators.map((g, i) => i === 0 ? { ...g, owned: 1 } : g), prestigeMultiplier: 1.5 }; expect(totalProductionPerSecond(state)).toBeCloseTo(DEFAULT_GENERATORS[0].baseProduction * 1.5); }); + + it("applique le multiplicateur arbre d'évolution", () => { + const state = { + ...DEFAULT_STATE, + generators: DEFAULT_STATE.generators.map((g, i) => i === 0 ? { ...g, owned: 1 } : g), + evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => + n.id === "instinct_gregaire" ? { ...n, unlocked: true } : n + ), + }; + expect(totalProductionPerSecond(state)).toBeCloseTo(DEFAULT_GENERATORS[0].baseProduction * 1.5); + }); }); +// --- Idle gains --- + describe("computeIdleGains (lazy calculation)", () => { it("calcule les gains proportionnellement au temps écoulé", () => { const state = { @@ -108,29 +167,54 @@ describe("computeIdleGains (lazy calculation)", () => { }); }); +// --- Click --- + describe("applyClick", () => { it("augmente les ressources du clickMultiplier × prestigeMultiplier", () => { const state = { ...DEFAULT_STATE, clickMultiplier: 3, prestigeMultiplier: 2 }; const result = applyClick(state); expect(result.resources).toBe(6); }); + + it("applique le multiplicateur click de l'arbre", () => { + const state = { + ...DEFAULT_STATE, + clickMultiplier: 1, + prestigeMultiplier: 1, + evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => + n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n + ), + }; + const result = applyClick(state); + expect(result.resources).toBe(2); // ×2 depuis Ponte Améliorée + }); + + it("incrémente lifetimeTadpoles", () => { + const state = { ...DEFAULT_STATE, clickMultiplier: 5, prestigeMultiplier: 1 }; + const result = applyClick(state); + expect(result.lifetimeTadpoles).toBe(5); + }); }); +// --- Buy generator --- + describe("buyGenerator", () => { it("retourne null si fonds insuffisants", () => { - const result = buyGenerator(DEFAULT_STATE, "manic"); - expect(result).toBeNull(); // 0 ressources, coût = 15 + const result = buyGenerator(DEFAULT_STATE, "nid"); + expect(result).toBeNull(); }); it("achète correctement et déduit le coût", () => { const state = { ...DEFAULT_STATE, resources: 100 }; - const result = buyGenerator(state, "manic"); + const result = buyGenerator(state, "nid"); expect(result).not.toBeNull(); - expect(result!.generators.find((g) => g.id === "manic")!.owned).toBe(1); + expect(result!.generators.find((g) => g.id === "nid")!.owned).toBe(1); expect(result!.resources).toBe(100 - DEFAULT_GENERATORS[0].baseCost); }); }); +// --- Prestige legacy --- + describe("prestige", () => { it("canPrestige retourne false si < 1 000 000 ressources", () => { expect(canPrestige({ ...DEFAULT_STATE, resources: 999_999 })).toBe(false); @@ -154,3 +238,129 @@ describe("prestige", () => { expect(result.generators.every((g) => g.owned === 0)).toBe(true); }); }); + +// --- ADN Ancestral --- + +describe("computePrestigeDna", () => { + it("retourne 0 pour 0 têtards", () => { + expect(computePrestigeDna(0)).toBe(0); + }); + + it("retourne 150 pour 1e9 têtards (sqrt(1) = 1)", () => { + expect(computePrestigeDna(1e9)).toBe(150); + }); + + it("retourne 212 pour 2e9 têtards (sqrt(2) ≈ 1.414)", () => { + expect(computePrestigeDna(2e9)).toBe(Math.floor(150 * Math.sqrt(2))); + }); + + it("scaling sub-linéaire — 10× têtards ≠ 10× ADN", () => { + const dna1 = computePrestigeDna(1e9); + const dna10 = computePrestigeDna(10e9); + expect(dna10 / dna1).toBeCloseTo(Math.sqrt(10), 1); + }); +}); + +// --- Arbre d'Évolution --- + +describe("Evolution Tree", () => { + describe("canBuyEvolutionNode", () => { + it("peut acheter le premier nœud (pas de prérequis) avec assez d'ADN", () => { + const state = { ...DEFAULT_STATE, ancestralDna: 5 }; + expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(true); + }); + + it("ne peut pas acheter sans assez d'ADN", () => { + const state = { ...DEFAULT_STATE, ancestralDna: 0 }; + expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(false); + }); + + it("ne peut pas acheter un nœud déjà débloqué", () => { + const state = { + ...DEFAULT_STATE, + ancestralDna: 100, + evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => + n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n + ), + }; + expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(false); + }); + + it("ne peut pas acheter un nœud dont le prérequis n'est pas débloqué", () => { + const state = { ...DEFAULT_STATE, ancestralDna: 100 }; + expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(false); + }); + + it("peut acheter un nœud si le prérequis est débloqué", () => { + const state = { + ...DEFAULT_STATE, + ancestralDna: 100, + evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => + n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n + ), + }; + expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(true); + }); + }); + + describe("buyEvolutionNode", () => { + it("débloque le nœud et déduit l'ADN", () => { + const state = { ...DEFAULT_STATE, ancestralDna: 5 }; + const result = buyEvolutionNode(state, "ponte_amelioree"); + expect(result).not.toBeNull(); + expect(result!.ancestralDna).toBe(4); + expect(result!.evolutionTree.find((n) => n.id === "ponte_amelioree")!.unlocked).toBe(true); + }); + + it("retourne null si impossible", () => { + const result = buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree"); + expect(result).toBeNull(); + }); + + it("ne modifie pas les autres nœuds", () => { + const state = { ...DEFAULT_STATE, ancestralDna: 5 }; + const result = buyEvolutionNode(state, "ponte_amelioree")!; + const otherNodes = result.evolutionTree.filter((n) => n.id !== "ponte_amelioree"); + expect(otherNodes.every((n) => n.unlocked === false)).toBe(true); + }); + }); + + describe("getClickMultiplierFromTree", () => { + it("retourne 1 si aucun nœud click débloqué", () => { + expect(getClickMultiplierFromTree(DEFAULT_EVOLUTION_TREE)).toBe(1); + }); + + it("retourne 2 si Ponte Améliorée débloquée", () => { + const tree = DEFAULT_EVOLUTION_TREE.map((n) => + n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n + ); + expect(getClickMultiplierFromTree(tree)).toBe(2); + }); + }); + + describe("getProductionMultiplierFromTree", () => { + it("retourne 1 si aucun nœud production débloqué", () => { + expect(getProductionMultiplierFromTree(DEFAULT_EVOLUTION_TREE)).toBe(1); + }); + + it("retourne 1.5 si Instinct Grégaire débloqué", () => { + const tree = DEFAULT_EVOLUTION_TREE.map((n) => + n.id === "instinct_gregaire" ? { ...n, unlocked: true } : n + ); + expect(getProductionMultiplierFromTree(tree)).toBe(1.5); + }); + }); + + describe("getStartBonusFromTree", () => { + it("retourne 0 si aucun nœud start débloqué", () => { + expect(getStartBonusFromTree(DEFAULT_EVOLUTION_TREE)).toBe(0); + }); + + it("retourne 100 si Mémoire Génétique débloquée", () => { + const tree = DEFAULT_EVOLUTION_TREE.map((n) => + n.id === "memoire_genetique" ? { ...n, unlocked: true } : n + ); + expect(getStartBonusFromTree(tree)).toBe(100); + }); + }); +}); diff --git a/Frontend/src/__tests__/save-validation.test.ts b/Frontend/src/__tests__/save-validation.test.ts new file mode 100644 index 0000000..d48564b --- /dev/null +++ b/Frontend/src/__tests__/save-validation.test.ts @@ -0,0 +1,123 @@ +// save-validation.test.ts — Tests anti-cheat validation logic +// Ported from backend saveControllers.validateGameState for unit testing + +import { describe, it, expect } from "vitest"; +import { DEFAULT_STATE } from "../core/economy"; + +// Reproduce the validation logic client-side for testing +const MAX_PRODUCTION_PER_SECOND = 750_000; +const CHEAT_MARGIN = 1.1; + +interface PreviousSave { + last_save: string; + game_state: { resources: number }; +} + +function validateGameState( + gameState: { resources: number; generators?: unknown[] }, + previousSave: PreviousSave | null +): { valid: boolean; reason?: string } { + if (!gameState || typeof gameState !== "object") { + return { valid: false, reason: "Invalid game state format" }; + } + + if (typeof gameState.resources !== "number" || gameState.resources < 0) { + return { valid: false, reason: "Invalid resources value" }; + } + + if (!Array.isArray(gameState.generators)) { + return { valid: false, reason: "Invalid generators" }; + } + + if (!previousSave) { + return { valid: true }; + } + + const lastSaveTime = new Date(previousSave.last_save).getTime(); + const now = Date.now(); + const elapsedSeconds = Math.max((now - lastSaveTime) / 1000, 0); + + const maxPossibleResources = + MAX_PRODUCTION_PER_SECOND * elapsedSeconds * CHEAT_MARGIN; + + const previousResources = previousSave.game_state?.resources ?? 0; + const resourceDelta = gameState.resources - previousResources; + + if (resourceDelta > maxPossibleResources && resourceDelta > 0) { + return { + valid: false, + reason: `Resource gain exceeds maximum possible`, + }; + } + + return { valid: true }; +} + +describe("Anti-cheat validation", () => { + it("accepts first save (no previous)", () => { + const result = validateGameState( + { ...DEFAULT_STATE, resources: 1000 }, + null + ); + expect(result.valid).toBe(true); + }); + + it("accepts legitimate resource gain", () => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const result = validateGameState( + { ...DEFAULT_STATE, resources: 100_000 }, + { last_save: fiveMinutesAgo, game_state: { resources: 50_000 } } + ); + expect(result.valid).toBe(true); + }); + + it("rejects impossibly large resource gain", () => { + const oneSecondAgo = new Date(Date.now() - 1000).toISOString(); + const result = validateGameState( + { ...DEFAULT_STATE, resources: 999_999_999_999 }, + { last_save: oneSecondAgo, game_state: { resources: 0 } } + ); + expect(result.valid).toBe(false); + expect(result.reason).toContain("exceeds maximum"); + }); + + it("rejects negative resources", () => { + const result = validateGameState( + { ...DEFAULT_STATE, resources: -100 }, + null + ); + expect(result.valid).toBe(false); + }); + + it("rejects invalid game state format", () => { + const result = validateGameState(null as any, null); + expect(result.valid).toBe(false); + }); + + it("rejects missing generators array", () => { + const result = validateGameState( + { resources: 100, generators: "not an array" } as any, + null + ); + expect(result.valid).toBe(false); + }); + + it("accepts resource loss (spending on generators)", () => { + const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString(); + const result = validateGameState( + { ...DEFAULT_STATE, resources: 1000 }, + { last_save: oneMinuteAgo, game_state: { resources: 5000 } } + ); + expect(result.valid).toBe(true); // delta < 0, so always OK + }); + + it("accepts gains within max production window", () => { + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + // 10 min × 750,000/s × 1.1 = 495,000,000 + const result = validateGameState( + { ...DEFAULT_STATE, resources: 400_000_000 }, + { last_save: tenMinutesAgo, game_state: { resources: 0 } } + ); + expect(result.valid).toBe(true); + }); +}); diff --git a/Frontend/src/core/economy.ts b/Frontend/src/core/economy.ts index 7f3e6fa..d37a3d4 100644 --- a/Frontend/src/core/economy.ts +++ b/Frontend/src/core/economy.ts @@ -9,15 +9,94 @@ export interface Generator { owned: number; } +export type EffectType = "click_multiplier" | "production_multiplier" | "start_bonus" | "unlock_generator" | "achievement_scaling"; + +export interface EvolutionNode { + id: string; + name: string; + cost: number; // en ADN Ancestral + effect: EffectType; + value: number; + unlocked: boolean; + requires: string | null; // id du nœud prérequis (null = racine) +} + export interface GameState { resources: number; clickMultiplier: number; generators: Generator[]; lastTick: number; // timestamp ms — lazy calc reference prestigeCount: number; - prestigeMultiplier: number; // 1 + prestigeCount * 0.1 + prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre + ancestralDna: number; + evolutionTree: EvolutionNode[]; + lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN) } +// --- Arbre d'Évolution --- + +export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [ + { id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null }, + { id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: "ponte_amelioree" }, + { id: "memoire_genetique", name: "Mémoire Génétique", cost: 10, effect: "start_bonus", value: 100, unlocked: false, requires: "instinct_gregaire" }, + { id: "mutation_alpha", name: "Mutation Alpha", cost: 25, effect: "unlock_generator", value: 0, unlocked: false, requires: "memoire_genetique" }, + { id: "symbiose", name: "Symbiose", cost: 50, effect: "achievement_scaling", value: 0.01, unlocked: false, requires: "mutation_alpha" }, +]; + +// Calcule l'ADN gagné lors d'un prestige : floor(150 × sqrt(lifetime / 1e9)) +export function computePrestigeDna(lifetimeTadpoles: number): number { + return Math.floor(150 * Math.sqrt(lifetimeTadpoles / 1e9)); +} + +// Vérifie si un nœud peut être acheté +export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean { + const node = state.evolutionTree.find((n) => n.id === nodeId); + if (!node || node.unlocked) return false; + if (state.ancestralDna < node.cost) return false; + if (node.requires) { + const prereq = state.evolutionTree.find((n) => n.id === node.requires); + if (!prereq || !prereq.unlocked) return false; + } + return true; +} + +// Achète un nœud d'évolution (retourne null si impossible) +export function buyEvolutionNode(state: GameState, nodeId: string): GameState | null { + if (!canBuyEvolutionNode(state, nodeId)) return null; + + const node = state.evolutionTree.find((n) => n.id === nodeId)!; + return { + ...state, + ancestralDna: state.ancestralDna - node.cost, + evolutionTree: state.evolutionTree.map((n) => + n.id === nodeId ? { ...n, unlocked: true } : n + ), + }; +} + +// Calcule le multiplicateur click total depuis l'arbre +export function getClickMultiplierFromTree(tree: EvolutionNode[]): number { + return tree + .filter((n) => n.unlocked && n.effect === "click_multiplier") + .reduce((mult, n) => mult * n.value, 1); +} + +// Calcule le multiplicateur production total depuis l'arbre +export function getProductionMultiplierFromTree(tree: EvolutionNode[]): number { + return tree + .filter((n) => n.unlocked && n.effect === "production_multiplier") + .reduce((mult, n) => mult * n.value, 1); +} + +// Bonus de départ (têtards offerts au début de chaque run) +export function getStartBonusFromTree(tree: EvolutionNode[]): number { + return tree + .filter((n) => n.unlocked && n.effect === "start_bonus") + .reduce((sum, n) => sum + n.value, 0); +} + +// --- Core economy (mis à jour pour intégrer l'arbre) --- + // Coût d'achat du N-ième générateur : baseCost × 1.15^owned export function generatorCost(gen: Generator): number { return Math.floor(gen.baseCost * Math.pow(1.15, gen.owned)); @@ -29,7 +108,8 @@ export function totalProductionPerSecond(state: GameState): number { (sum, gen) => sum + gen.baseProduction * gen.owned, 0 ); - return base * state.prestigeMultiplier; + const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree); + return base * state.prestigeMultiplier * treeMultiplier; } // Lazy calculation : ressources accumulées depuis lastTick @@ -44,15 +124,19 @@ export function applyIdleGains(state: GameState, now: number): GameState { return { ...state, resources: state.resources + gains, + lifetimeTadpoles: state.lifetimeTadpoles + gains, lastTick: now, }; } // Clic manuel export function applyClick(state: GameState): GameState { + const treeClickMult = getClickMultiplierFromTree(state.evolutionTree); + const gain = state.clickMultiplier * state.prestigeMultiplier * treeClickMult; return { ...state, - resources: state.resources + state.clickMultiplier * state.prestigeMultiplier, + resources: state.resources + gain, + lifetimeTadpoles: state.lifetimeTadpoles + gain, }; } @@ -75,30 +159,36 @@ export function buyGenerator(state: GameState, genId: string): GameState | null }; } -// Prestige : reset ressources + générateurs, +0.1× multiplicateur permanent +// Prestige : reset run, gain ADN, arbre persiste export function canPrestige(state: GameState): boolean { return state.resources >= 1_000_000; } export function applyPrestige(state: GameState): GameState { const newPrestigeCount = state.prestigeCount + 1; + const dnaGained = computePrestigeDna(state.lifetimeTadpoles); + const startBonus = getStartBonusFromTree(state.evolutionTree); + return { ...state, - resources: 0, + resources: startBonus, generators: state.generators.map((g) => ({ ...g, owned: 0 })), prestigeCount: newPrestigeCount, prestigeMultiplier: 1 + newPrestigeCount * 0.1, + ancestralDna: state.ancestralDna + dnaGained, + lifetimeTadpoles: 0, lastTick: Date.now(), + // evolutionTree persiste — jamais reset }; } -// Valeurs par défaut — 5 tiers alignés GDD (x10 coût / tier, x5 production) +// Valeurs par défaut — 5 tiers alignés GDD Tetard Universe (x10 coût / tier) export const DEFAULT_GENERATORS: Generator[] = [ - { id: "manic", name: "Manic", baseCost: 10, baseProduction: 0.1, owned: 0 }, - { id: "coffee", name: "Tasse à café", baseCost: 100, baseProduction: 0.5, owned: 0 }, - { id: "sugar", name: "Sucre", baseCost: 1_000, baseProduction: 3, owned: 0 }, - { id: "factory", name: "Usine", baseCost: 10_000, baseProduction: 20, owned: 0 }, - { id: "portal", name: "Portail", baseCost: 100_000, baseProduction: 150, owned: 0 }, + { id: "nid", name: "Nid", baseCost: 10, baseProduction: 0.1, owned: 0 }, + { id: "mare", name: "Mare", baseCost: 100, baseProduction: 0.5, owned: 0 }, + { id: "marecage", name: "Marécage", baseCost: 1_000, baseProduction: 3, owned: 0 }, + { id: "etang", name: "Étang Ancien", baseCost: 10_000, baseProduction: 20, owned: 0 }, + { id: "lac", name: "Lac Mystique", baseCost: 100_000, baseProduction: 150, owned: 0 }, ]; export const DEFAULT_STATE: GameState = { @@ -108,4 +198,7 @@ export const DEFAULT_STATE: GameState = { lastTick: Date.now(), prestigeCount: 0, prestigeMultiplier: 1, + ancestralDna: 0, + evolutionTree: DEFAULT_EVOLUTION_TREE, + lifetimeTadpoles: 0, }; diff --git a/Frontend/src/data/Achievements.json b/Frontend/src/data/Achievements.json index c29cae6..83c51b2 100755 --- a/Frontend/src/data/Achievements.json +++ b/Frontend/src/data/Achievements.json @@ -111,7 +111,7 @@ }, { "id": 18, - "name": "un pull de noël", + "name": "Peau de grenouille rare", "founded": false, "image": "https://i.goopics.net/uwjwn1.jpg" }, @@ -189,7 +189,7 @@ }, { "id": 30, - "name": "Calendrier de l'Avent avec des chocolats au wasabi et moutarde forte", + "name": "Kit de survie du marais au wasabi et moutarde forte", "founded": false, "image": "https://i.goopics.net/dakyj9.png" }, @@ -228,7 +228,7 @@ }, { "id": 36, - "name": "Chapeau de Noël clignotant", + "name": "Couronne de nénuphars lumineuse", "founded": false, "image": "https://i.goopics.net/d4su7e.png" }, diff --git a/Frontend/src/data/shop.json b/Frontend/src/data/shop.json index 9706d73..495324b 100755 --- a/Frontend/src/data/shop.json +++ b/Frontend/src/data/shop.json @@ -1,89 +1,89 @@ [ { - "name": "Manic", + "name": "Griffes de Grenouille", "price": 15, "incrementValue": 1, - "description": "Evite de vous bruler quand vous sortez les cookies du four, vous gagnez 5 CPS", + "description": "Des griffes acérées pour une ponte plus efficace. +1 par clic.", "link": "/", "image": "./svg/Hand.svg", "buyed": false, "type": "actif" }, { - "name": "Tasse à café", + "name": "Algues Nutritives", "price": 15, "incrementValue": 1, - "description": "Bien chaud vous permet de tenir sur la durée", + "description": "Les algues nourrissent le marais en continu. +1 têtard/s.", "link": "/", "image": "./svg/Tasse.svg", "buyed": false, "type": "passif" }, { - "name": "Mr Bonhomme", + "name": "Crapaud Gardien", "price": 150, "incrementValue": 10, - "description": "Un assistant idéal pour le click", + "description": "Un ancien du marais qui veille sur les pontes. +10 par clic.", "link": "/", "image": "./svg/Bonhome.svg", "buyed": false, "type": "actif" }, { - "name": "Bonnet", + "name": "Nénuphar Géant", "price": 150, "incrementValue": 10, - "description": "Garder vos oreilles bien à l'abri du froid et click !", + "description": "Un nénuphar massif qui attire les têtards. +10 têtards/s.", "link": "/", "image": "./svg/Bonnet.svg", "buyed": false, "type": "passif" }, { - "name": "Cookie", + "name": "Oeuf Doré", "price": 1500, "incrementValue": 100, - "description": "Fait avec amour", + "description": "Un oeuf rare qui éclot en masse. +100 par clic.", "link": "/", "image": "./svg/Cookie.svg", "buyed": false, "type": "actif" }, { - "name": "Canne en sucre", + "name": "Mousse Lumineuse", "price": 1500, "incrementValue": 100, - "description": "Le sucre c'est connu, ca reboost", + "description": "La mousse phosphorescente accélère la croissance. +100 têtards/s.", "link": "/", "image": "./svg/Canne.svg", "buyed": false, "type": "passif" }, { - "name": "Couronne d'hiver", + "name": "Couronne de Roseaux", "price": 15000, "incrementValue": 1000, - "description": "Un bisous ou rien du tout !", + "description": "Le symbole du Gardien suprême du Marais. +1000 par clic.", "link": "/", "image": "./svg/Courone.svg", "buyed": false, "type": "actif" }, { - "name": "Mr pain d'épice", + "name": "Esprit du Marais", "price": 15000, "incrementValue": 1000, - "description": "Le meilleur c'est la tête", + "description": "L'esprit ancestral bénit les eaux. +1000 têtards/s.", "link": "/", "image": "./svg/PainDep.svg", "buyed": false, "type": "passif" }, { - "name": "Bière", + "name": "Nectar de Lotus", "price": 8000, "incrementValue": 1000, - "description": "Boisson de qualité, double tout les CPS, attention à ne pas trop en abuser", + "description": "Un nectar enivrant qui trouble les eaux... mais booste la ponte. Attention aux effets secondaires.", "link": "/", "image": "./svg/Beer.svg", "buyed": false, diff --git a/Frontend/src/hooks/useSaveSync.ts b/Frontend/src/hooks/useSaveSync.ts new file mode 100644 index 0000000..b00c155 --- /dev/null +++ b/Frontend/src/hooks/useSaveSync.ts @@ -0,0 +1,115 @@ +// useSaveSync.ts — Auto-save game state to backend every 30s +// Requires JWT token in localStorage (set by auth flow) +// Falls back silently if no token (guest mode) + +import { useEffect, useRef, useCallback } from "react"; +import type { GameState } from "../core/economy"; + +const SAVE_INTERVAL_MS = 30_000; // 30 seconds +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3310"; + +interface SaveSyncOptions { + getGameState: () => GameState; + onLoad: (state: GameState) => void; + playTimeSeconds: number; +} + +async function apiRequest(path: string, options: RequestInit = {}) { + const token = localStorage.getItem("token"); + if (!token) return null; + + const res = await fetch(`${BACKEND_URL}/api${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + "x-auth-token": token, + ...options.headers, + }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.warn(`[SaveSync] ${path} failed:`, res.status, body); + return null; + } + + return res.json(); +} + +export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncOptions) { + const lastSaveRef = useRef(null); + const loadedRef = useRef(false); + + // Load save on mount (once) + useEffect(() => { + if (loadedRef.current) return; + loadedRef.current = true; + + const token = localStorage.getItem("token"); + if (!token) return; + + apiRequest("/save").then((data) => { + if (data?.gameState) { + onLoad(data.gameState); + lastSaveRef.current = data.lastSave; + console.info("[SaveSync] Loaded save from server"); + } + }); + }, [onLoad]); + + // Save function + const saveToServer = useCallback(async () => { + const token = localStorage.getItem("token"); + if (!token) return; + + const gameState = getGameState(); + const result = await apiRequest("/save", { + method: "POST", + body: JSON.stringify({ gameState, playTimeSeconds }), + }); + + if (result?.lastSave) { + lastSaveRef.current = result.lastSave; + } + }, [getGameState, playTimeSeconds]); + + // Auto-save interval + useEffect(() => { + const token = localStorage.getItem("token"); + if (!token) return undefined; + + const interval = setInterval(() => { + saveToServer(); + }, SAVE_INTERVAL_MS); + + return () => clearInterval(interval); + }, [saveToServer]); + + // Save on page unload + useEffect(() => { + const handleUnload = () => { + const token = localStorage.getItem("token"); + if (!token) return; + + const gameState = getGameState(); + const payload = JSON.stringify({ gameState, playTimeSeconds }); + + // Use fetch with keepalive for reliable save on tab close + // (sendBeacon doesn't support custom headers) + fetch(`${BACKEND_URL}/api/save`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-auth-token": token, + }, + body: payload, + keepalive: true, + }).catch(() => {}); + }; + + window.addEventListener("beforeunload", handleUnload); + return () => window.removeEventListener("beforeunload", handleUnload); + }, [getGameState, playTimeSeconds]); + + return { saveToServer, lastSave: lastSaveRef.current }; +} diff --git a/Frontend/src/pages/Cookie.jsx b/Frontend/src/pages/Cookie.jsx index 4a4f805..4e1b317 100755 --- a/Frontend/src/pages/Cookie.jsx +++ b/Frontend/src/pages/Cookie.jsx @@ -3,32 +3,32 @@ function Cookie() { return (
-

Qu’est-ce qu’un cookie ?

+

Qu'est-ce qu'un cookie ?

Un cookie est un petit fichier texte sauvegardé sur votre ordinateur lorsque vous visitez un site web. Ce fichier texte enregistre des informations qui peuvent être lues par un site web lorsque vous le visitez de nouveau plus tard. Certains de ces cookies sont nécessaires - pour accéder à certaines fonctionnalités d’un site. D’autres cookies - sont d’utilité pratique pour le visiteur : ils sauvegardent de manière - sécurisée votre nom d’utilisateur ou vos préférences linguistiques par - exemple. Les cookies signifient tout simplement qu’à chaque fois que - vous visitez un site web, vous n’avez pas besoin de saisir à nouveau + pour accéder à certaines fonctionnalités d'un site. D'autres cookies + sont d'utilité pratique pour le visiteur : ils sauvegardent de manière + sécurisée votre nom d'utilisateur ou vos préférences linguistiques par + exemple. Les cookies signifient tout simplement qu'à chaque fois que + vous visitez un site web, vous n'avez pas besoin de saisir à nouveau les mêmes informations.

-

Pourquoi Xmass Clicker utilise des cookies ?

+

Pourquoi Clickerz utilise des cookies ?

Nous utilisons des cookies pour vous fournir une expérience - utilisateur optimale et adaptée à vos préférences personnelles. En - utilisant les cookies, Les cookies sont également utilisés pour - optimiser la performance du site. Xmass Clicker a pris toutes les - mesures organisationnelles et techniques pour protéger vos données - personnelles ainsi que d’une éventuelle perte d’informations ou de - toute forme de traitement illicite. Pour davantage d’informations, - consultez notre Politique de confidentialité. + utilisateur optimale et adaptée à vos préférences personnelles. + Les cookies sont également utilisés pour optimiser la performance + du site. Clickerz a pris toutes les mesures organisationnelles et + techniques pour protéger vos données personnelles ainsi que d'une + éventuelle perte d'informations ou de toute forme de traitement + illicite. Pour davantage d'informations, consultez notre Politique + de confidentialité.

@@ -37,7 +37,7 @@ function Cookie() {

Vous pouvez paramétrer votre navigateur Internet pour désactiver les cookies. Notez toutefois que si vous désactivez les cookies, votre nom - d’utilisateur ainsi que votre mot de passe ne seront plus sauvegardés + d'utilisateur ainsi que votre mot de passe ne seront plus sauvegardés sur aucun site web.

@@ -49,9 +49,9 @@ function Cookie() { 2. Appuyez sur la touche « Alt »
3. Dans le menu en haut de la page cliquez sur « Outils » puis « Options »
- 4. Sélectionnez l’onglet « Vie privée »
+ 4. Sélectionnez l'onglet « Vie privée »
5. Dans le menu déroulant à droite de « Règles de conservation », - cliquez sur « utiliser les paramètres personnalisés pour l’historique + cliquez sur « utiliser les paramètres personnalisés pour l'historique »
6. Un peu plus bas, décochez « Accepter les cookies »
7. Sauvegardez vos préférences en cliquant sur « OK » @@ -63,7 +63,7 @@ function Cookie() {

1. Ouvrez Internet Explorer
2. Dans le menu « Outils », sélectionnez « Options Internet »
- 3. Cliquez sur l’onglet « Confidentialité »
+ 3. Cliquez sur l'onglet « Confidentialité »
4. Cliquez sur « Avancé » et décochez « Accepter »
5. Sauvegardez vos préférences en cliquant sur « OK »

@@ -75,7 +75,7 @@ function Cookie() { 1. Ouvrez Safari
2. Dans la barre de menu en haut, cliquez sur « Safari », puis « Préférences »
- 3. Sélectionnez l’icône « Sécurité »
+ 3. Sélectionnez l'icône « Sécurité »
4. À côté de « Accepter les cookies », cochez « Jamais »
5. Si vous souhaitez voir les cookies qui sont déjà sauvegardés sur votre ordinateur, cliquez sur « Afficher les cookies » @@ -86,9 +86,9 @@ function Cookie() {

Google Chrome :

1. Ouvrez Google Chrome
- 2. Cliquez sur l’icône d’outils dans la barre de menu
+ 2. Cliquez sur l'icône d'outils dans la barre de menu
3. Sélectionnez « Options »
- 4. Cliquez sur l’onglet « Options avancées »
+ 4. Cliquez sur l'onglet « Options avancées »
5. Dans le menu déroulant « Paramètres des cookies », sélectionnez « Bloquer tous les cookies »

diff --git a/Frontend/src/pages/Legal.jsx b/Frontend/src/pages/Legal.jsx index 94d9335..51569c6 100755 --- a/Frontend/src/pages/Legal.jsx +++ b/Frontend/src/pages/Legal.jsx @@ -4,20 +4,18 @@ function Legal() {

Éditeur :

- Xmass'Click est un projet réalisé dans le cadre d'un hackathon sur 2 - jours. + Clickerz est un projet indépendant faisant partie du Tetard Universe.

Coordonnées :

- Téléphone : 04 22 52 10 10
- E-mail : pere-noel@laposte.net
- Adresse : 250 avenue des Nuages, 1000 Pôle Nord
+ E-mail : contact@tetardtek.com
+ Site : https://tetardtek.com

Responsabilité :

- Xmass'Click décline toute responsabilité quant à l'utilisation du site. + Clickerz décline toute responsabilité quant à l'utilisation du site. Les informations fournies sont à titre informatif et peuvent être sujettes à des erreurs.

@@ -25,26 +23,26 @@ function Legal() {

Propriété Intellectuelle :

Tout le contenu du site (textes, images, etc.) reste la propriété de - Xmass'Click. Toute reproduction est interdite sans autorisation + Tetardtek. Toute reproduction est interdite sans autorisation préalable.

Protection des Données Personnelles :

- Xmass'Click ne collecte pas de données personnelles. Aucune information - personnelle n'est stockée lors de l'utilisation du site. + Clickerz utilise un système d'authentification via SuperOAuth. + Les données de jeu sont sauvegardées côté serveur. + Aucune donnée personnelle n'est partagée avec des tiers.

Conditions Générales d'Utilisation :

- Aucune condition générale d'utilisation n'est applicable. L'utilisation - du site Xmass'Click se fait à titre gratuit et sans engagement. + L'utilisation du site Clickerz se fait à titre gratuit et sans engagement.

Loi Applicable :

- Le présent site est régi par la loi du Pôle Nord. En cas de litige, les - tribunaux du Père Noël seront compétents. + Le présent site est régi par la loi française. En cas de litige, les + tribunaux compétents seront ceux du ressort du siège social de l'éditeur.

); diff --git a/docs/GDD.md b/docs/GDD.md index 618b74b..e274c2a 100644 --- a/docs/GDD.md +++ b/docs/GDD.md @@ -1,76 +1,188 @@ -# Clickerz — GDD Minimal +# Clickerz — GDD (Game Design Document) -> Sprint 1 — Step 1 output -> Date : 2026-03-17 +> Tetard Universe — Clicker/idle hybride +> Dernière mise à jour : 2026-03-20 +> Repo : git.tetardtek.com/Tetardtek/clickerz +> URL : https://clickerz.tetardtek.com/ + +--- + +## Identité + +**Clickerz** — Clicker/idle hybride dans le Tetard Universe. +Un marais mystérieux où des têtards naissent, évoluent, mutent et bâtissent un écosystème. +Chaque prestige = une nouvelle "génération" de têtards, plus forte, plus étrange. + +**Archétype :** hybride — clicker au départ (onboarding immédiat), idle dominant en mid-game (rétention), couche stratégique en late-game (prestige + arbre permanent). + +--- + +## Univers — Tetard Universe + +Le joueur est le **Gardien du Marais**. Les têtards sont la ressource vivante — ils naissent, travaillent, évoluent. Le marais grandit, se diversifie, attire de nouvelles espèces. + +Cross-promo naturelle avec TetaRdPG (même univers, assets partageables à terme). + +### Vocabulaire in-game + +| Concept mécanique | Nom in-game | Icône | +|-------------------|-------------|-------| +| Ressource principale | **Têtards** | 🥚 | +| Click | **Ponte** | tap/click | +| Générateurs tier 1 | **Nid** | 🪹 | +| Générateurs tier 2 | **Mare** | 🌊 | +| Générateurs tier 3 | **Marécage** | 🏞️ | +| Générateurs tier 4 | **Étang Ancien** | 🏛️ | +| Générateurs tier 5 | **Lac Mystique** | ✨ | +| Prestige currency | **ADN Ancestral** | 🧬 | +| Prestige reset | **Nouvelle Génération** | 🔄🐸 | +| Arbre permanent | **Arbre d'Évolution** | 🌳 | +| Milestones | **Mutations** | 🧪 | --- ## Stack technique -**React + TypeScript + Vite** — vanilla, sans moteur de jeu - -Justification : prototype existant déjà en React/Vite (Xmass Clicker), shop.json et Achievements.json validés, backend Node.js en place. Phaser/PixiJS = overhead injustifié pour un clicker — la logique est dans les chiffres, pas dans le rendu. +| Couche | Techno | Justification | +|--------|--------|---------------| +| Frontend | React 18 + TypeScript + Vite | Existant validé, lazy calc pattern | +| State | Zustand | Game loop adapté, léger | +| Style | Tailwind CSS (migration SCSS → Tailwind) | Productivité, cohérence | +| Backend | Express + MySQL | Existant avec SuperOAuth câblé | +| Auth | SuperOAuth (clickerz.tetardtek.com) | SSO Tetardtek ecosystem | +| Tests | Vitest | 13 tests existants sur economy.ts | --- ## Mécaniques core -**Ressource principale** : Cookies (ou ressource thématique à nommer) +### Ressource principale : Têtards -**Sources de production** : -- Clic manuel : +1 ressource/clic (multiplicateur upgradable) -- Générateurs idle : 5 tiers, coût `base × 1.15^n`, production `/s` cumulative +**Sources de production :** +- Ponte (clic manuel) : +1 × clickMultiplier × prestigeMultiplier +- Générateurs idle : 5 tiers, coût `base × 1.15^owned`, production/s cumulative -**Tiers upgrades** (structure shop.json existante — x10 de coût par tier validé) : -| Tier | Coût base | Production/s | -|------|-----------|-------------| -| 1 | 10 | 0.1 | -| 2 | 100 | 0.5 | -| 3 | 1 000 | 3 | -| 4 | 10 000 | 20 | -| 5 | 100 000 | 150 | +### Générateurs — progression tier + +| Tier | Nom | Coût base | Production/s | Ratio scaling | +|------|-----|-----------|-------------|---------------| +| 1 | Nid | 10 | 0.1 | 1.15 | +| 2 | Mare | 100 | 0.5 | 1.15 | +| 3 | Marécage | 1 000 | 3 | 1.15 | +| 4 | Étang Ancien | 10 000 | 20 | 1.15 | +| 5 | Lac Mystique | 100 000 | 150 | 1.15 | + +**Formule coût :** `coût = base_cost × 1.15^owned` +**Formule production :** `output = base_prod × owned × multipliers` + +> Ratio 1.15 = standard éprouvé. Assez steep pour forcer la diversification, assez doux pour que chaque achat ait un impact. --- -## Boucle de progression +## Boucle de progression — 3 couches -**Prestige / Reset** -- Seuil déclencheur : 1 000 000 ressources (ajustable à l'équilibrage) -- Récompense : +0.1× multiplicateur permanent par reset (stackable) -- Reset : ressources + générateurs à 0, upgrades prestige conservés -- Courbe : reset 1 = ×1.1, reset 10 = ×2.0, reset 50 = ×6.0 +``` +┌─────────────────────────────────────────────────┐ +│ BOUCLE 1 — La Run (secondes → minutes) │ +│ │ +│ Ponte (click) → +têtards │ +│ Acheter Nid → +têtards/sec (idle) │ +│ Upgrades Nid → multiplicateur │ +│ Débloquer Mare → nouveau tier, plus cher, │ +│ plus rentable │ +│ ...jusqu'à atteindre un plateau │ +└──────────────────────┬──────────────────────────┘ + │ plateau atteint + ▼ +┌─────────────────────────────────────────────────┐ +│ BOUCLE 2 — Nouvelle Génération (heures → jours) │ +│ │ +│ Prestige → reset générateurs + têtards │ +│ Gain : ADN Ancestral (formule sur total têtards) │ +│ ADN → Arbre d'Évolution (permanent) │ +│ → +% production globale │ +│ → débloquer nouvelles mutations │ +│ → débloquer nouveaux types de générateurs │ +│ Nouvelle run = plus rapide, va plus loin │ +└──────────────────────┬──────────────────────────┘ + │ arbre mature + ▼ +┌─────────────────────────────────────────────────┐ +│ BOUCLE 3 — Méta (semaines) [Sprint 3+] │ +│ │ +│ Espèces rares, événements saisonniers, │ +│ leaderboard, objectifs communautaires │ +│ (hors scope Sprint 1) │ +└─────────────────────────────────────────────────┘ +``` -**Milestones visibles** : barre de progression vers prestige, compteur resets, multiplicateur actuel affiché +--- + +## Prestige — Nouvelle Génération + +**Seuil :** 1 000 000 têtards produits (total lifetime de la run) +**Formule ADN :** `adn = floor(150 × sqrt(lifetime_tadpoles / 1e9))` + +**Ce qui reset :** têtards, générateurs, upgrades de run +**Ce qui persiste :** ADN Ancestral, Arbre d'Évolution, achievements, stats + +**Prestige actuel (hérité) :** `+0.1× multiplicateur permanent par reset` +**Cible Sprint 1 :** migrer vers ADN Ancestral + Arbre d'Évolution + +--- + +## Arbre d'Évolution (permanent — jamais reset) + +Sprint 1 — linéaire (5 nœuds). Sprint 2+ → branches. + +| Nœud | Coût ADN | Effet | +|------|----------|-------| +| Ponte Améliorée | 1 | +100% click power | +| Instinct Grégaire | 3 | +50% production tous générateurs | +| Mémoire Génétique | 10 | Commence chaque run avec 100 têtards | +| Mutation Alpha | 25 | Débloquer tier 5 dès le début de la run | +| Symbiose | 50 | +1% production par achievement débloqué | + +--- + +## Sauvegarde & Anti-triche + +- Save côté serveur uniquement (backend Express + MySQL) +- Pas de localStorage pour les données de jeu (cache UI uniquement) +- Auto-save toutes les 30 secondes via API +- Validation snapshot : `elapsed_time × max_possible_production × 1.1 >= claimed_resources` +- Client considéré hostile — toute donnée validée côté serveur --- ## Monétisation **Cosmétiques only** — pas de pay-to-win - -- Thèmes visuels (couleurs, icônes) -- Titres / badges de prestige -- Effets de clic (particules) +- Thèmes visuels (couleurs, icônes marais) +- Titres / badges de génération +- Effets de ponte (particules) - Raison : 0 compliance fiscale, 0 déséquilibre économie, communauté saine --- -## Sauvegarde - -- localStorage (sprint 1 — immédiat, zéro infra) -- Sync API backend (backend déjà en place — câblage sprint 1 si temps le permet) -- Auto-save toutes les 30 secondes - ---- - ## Hors scope Sprint 1 -- Leaderboard (exclu — infra ranking = sprint 2) +- Boucle 3 (méta, events, leaderboard) +- Branches arbre d'évolution (linéaire suffit) +- Cosmétiques / skins +- Monétisation effective +- Sound / musique +- Mobile responsive (desktop first) +- Offline gains calculés côté serveur (Sprint 2) +- Migration Express → Fastify (si besoin Sprint 3+) - Intégration Twitch - Multijoueur --- -## Prochaines étapes → Step 2 +## Changelog -Fondations techniques : init projet React/TS/Vite, boucle core clic → +ressource, 1er générateur idle, 1er upgrade. +| Date | Changement | +|------|------------| +| 2026-03-17 | GDD initial — sprint1-step1, stack React+TS+Vite, mécaniques core | +| 2026-03-20 | Refonte game-designer — Tetard Universe, Arbre d'Évolution, anti-triche backend, SuperOAuth, stack confirmée Express | diff --git a/docs/SPRINT1.md b/docs/SPRINT1.md new file mode 100644 index 0000000..04871ae --- /dev/null +++ b/docs/SPRINT1.md @@ -0,0 +1,167 @@ +# SPRINT1.md — Fondations Tetard Universe + +> Brief technique — Sprint 1 +> Date : 2026-03-20 +> Réf GDD : docs/GDD.md +> Agents : game-designer (design) → build (implémentation) + +--- + +## Objectif + +Transformer le prototype Xmass Clicker en Clickerz Tetard Universe avec : +- Rethème complet (noms, visuels, textes) +- Arbre d'Évolution (permanent, jamais reset) +- Save serveur (anti-triche, fin du localStorage) +- SuperOAuth login +- Core loop jouable de bout en bout : Ponte → Générateurs → Prestige → Arbre → Nouvelle run + +--- + +## Existant à conserver + +| Composant | Fichier | État | +|-----------|---------|------| +| Core economy | `Frontend/src/core/economy.ts` | ✅ Solide — lazy calc, 5 generators, prestige | +| Tests economy | `Frontend/src/__tests__/economy.test.ts` | ✅ 13 tests | +| Backend auth | `Backend/src/controllers/authControllers.js` | ✅ JWT + SuperOAuth ID | +| DB schema | `Backend/database/schema.sql` | ✅ users + super_oauth_id | +| Prestige UI | Commit 9f0ccda | ✅ PrestigePanel + MilestoneBar | + +--- + +## Steps + +### Step 1 — Rethème Tetard Universe + +**Scope :** Renommage complet, zéro changement mécanique. + +- [ ] `economy.ts` : renommer generators (Manic → Nid, Tasse à café → Mare, Sucre → Marécage, Usine → Étang Ancien, Portail → Lac Mystique) +- [ ] `shop.json` : réécrire avec noms/descriptions Tetard Universe (ou supprimer si redondant avec economy.ts) +- [ ] `Achievements.json` : adapter les textes au thème marais +- [ ] UI : remplacer "cookies" / "Xmass" par "têtards" / "Clickerz" partout +- [ ] Pages : Cookie.jsx (page légale) → adapter mentions "Xmass Clicker" → "Clickerz" +- [ ] Titre / meta : "Clickerz — Tetard Universe" + +**Critère done :** aucune référence à "cookie", "Xmass", "Noël" dans le code. + +--- + +### Step 2 — Arbre d'Évolution + +**Scope :** Nouveau système permanent, intégré au prestige. + +- [ ] Nouveau type `EvolutionNode` dans economy.ts : + ```ts + interface EvolutionNode { + id: string; + name: string; + cost: number; // en ADN Ancestral + effect: EffectType; // multiplicateur, flat bonus, unlock + value: number; + unlocked: boolean; + requires?: string; // id du nœud prérequis (linéaire Sprint 1) + } + ``` +- [ ] Étendre `GameState` : ajouter `ancestralDna: number`, `evolutionTree: EvolutionNode[]` +- [ ] Modifier `applyPrestige()` : calculer ADN gagné via `floor(150 × sqrt(lifetime / 1e9))`, ajouter à `ancestralDna` +- [ ] 5 nœuds linéaires (GDD : Ponte Améliorée → Instinct Grégaire → Mémoire Génétique → Mutation Alpha → Symbiose) +- [ ] `buyEvolutionNode(state, nodeId)` : acheter si ADN suffisant + prérequis débloqué +- [ ] Appliquer les effets dans `totalProductionPerSecond()` et `applyClick()` +- [ ] UI : `EvolutionTree.tsx` — panel visible après premier prestige +- [ ] Tests : couvrir achat nœud, prérequis, effets sur production + +**Critère done :** un joueur peut prestige, gagner de l'ADN, acheter des nœuds, et constater l'effet sur sa prochaine run. + +--- + +### Step 3 — Save serveur + anti-triche + +**Scope :** Migrer la persistence de localStorage vers API backend. + +- [ ] Backend : nouvelle table `game_saves` + ```sql + CREATE TABLE game_saves ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL UNIQUE, + game_state JSON NOT NULL, + last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + lifetime_tadpoles BIGINT DEFAULT 0, + prestige_count INT DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + ``` +- [ ] Backend routes : `POST /api/save` (write), `GET /api/save` (load) — JWT required +- [ ] Validation snapshot sur `POST /api/save` : + - Vérifier `elapsed_time × max_possible_production × 1.1 >= claimed_resources` + - Si invalide → rejeter le save, logger l'anomalie +- [ ] Frontend : `useSaveSync` hook — auto-save toutes les 30s via API +- [ ] Frontend : load state depuis API au login (pas de localStorage game data) +- [ ] Supprimer la persistence localStorage pour les données de jeu + +**Critère done :** fermer l'onglet, rouvrir, login → retrouver exactement son état. F12 → modifier localStorage → aucun impact sur la save serveur. + +--- + +### Step 4 — SuperOAuth login + +**Scope :** Câbler le login via SuperOAuth existant. + +- [ ] Vérifier le flow SuperOAuth backend (verifyOAuth.js existe) +- [ ] Frontend : page Login avec bouton "Se connecter avec SuperOAuth" +- [ ] Redirect vers SuperOAuth → callback → JWT → session +- [ ] Protéger les routes /api/save derrière JWT +- [ ] Fallback : mode "invité" sans save serveur (localStorage temporaire) — optionnel + +**Critère done :** login SuperOAuth → jeu avec save serveur. Pas de register custom (SuperOAuth gère). + +--- + +### Step 5 — Migration SCSS → Tailwind + Zustand + +**Scope :** Moderniser le frontend pour la suite. + +- [ ] Installer Tailwind CSS + config Vite +- [ ] Migrer les composants clés (game view, shop, prestige panel) vers Tailwind +- [ ] Installer Zustand, créer `useGameStore` — remplacer le state lifting actuel +- [ ] Intégrer le game loop tick dans Zustand (requestAnimationFrame ou setInterval 1s) +- [ ] Supprimer les fichiers SCSS migrés + +**Critère done :** game loop tourne via Zustand, UI en Tailwind, SCSS supprimé sur les composants migrés. + +--- + +### Step 6 — Polish & deploy + +**Scope :** Rendre jouable et déployer. + +- [ ] UI : écran d'accueil Clickerz (logo, "Entrer dans le Marais") +- [ ] UI : feedback visuel Ponte (animation click) +- [ ] UI : affichage formaté des grands nombres (1M, 1B, 1T...) +- [ ] UI : responsive basique (jouable desktop, pas cassé mobile) +- [ ] Deploy : clickerz.tetardtek.com (Apache vhost + pm2 backend) +- [ ] Tests e2e : flow complet login → jeu → prestige → arbre → save → reload + +**Critère done :** un joueur peut aller sur clickerz.tetardtek.com, login SuperOAuth, jouer, prestige, acheter dans l'arbre, fermer, revenir, retrouver sa progression. + +--- + +## Résumé séquentiel + +``` +Step 1 (rethème) → Step 2 (arbre évolution) → Step 3 (save serveur) + → Step 4 (SuperOAuth) → Step 5 (Tailwind+Zustand) → Step 6 (polish+deploy) +``` + +Steps 1-2 = game design. Steps 3-4 = infra/auth. Step 5 = DX. Step 6 = ship. + +--- + +## Risques identifiés + +| Risque | Mitigation | +|--------|------------| +| Équilibrage ADN trop généreux/avare | Playtest après step 2, ajuster la formule | +| SuperOAuth SDK pas à jour | verifyOAuth.js existe déjà — vérifier compat | +| Migration SCSS → Tailwind longue | Migrer uniquement les composants touchés, pas tout | +| Validation anti-triche trop stricte (faux positifs) | Marge ×1.1, logs avant rejet, soft-block d'abord |