feat(sprint1-step3b): backend save system + anti-cheat + données rattrapées

- 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)
This commit is contained in:
2026-03-20 13:40:16 +01:00
parent 9f0ccda99b
commit a52746ed0c
20 changed files with 1167 additions and 152 deletions

View File

@@ -0,0 +1,14 @@
-- Migration 002 — Game saves with anti-cheat metadata
-- Safe: CREATE TABLE IF NOT EXISTS, no data loss
-- Run: mysql -u <user> -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
);

View File

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

View File

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

View File

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

View File

@@ -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);
/* ************************************************************************* */

View File

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

View File

@@ -1,6 +1,6 @@
<!--Head-->
<h3>Xmass Clicker</h3>
<h3>Clickerz — Tetard Universe</h3>
<br>
### 📄 About :

View File

@@ -10,7 +10,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Xmass Click votre nouveau Clicker préféré !"
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
/>
<meta name="robots" content="index, follow" />
<meta
@@ -21,42 +21,25 @@
name="bingbot"
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
/>
<link rel="canonical" href="https://xmass.click" />
<meta property="og:url" content="https://xmass.click" />
<meta property="og:site_name" content="Xmass Click" />
<link rel="canonical" href="https://clickerz.tetardtek.com" />
<meta property="og:url" content="https://clickerz.tetardtek.com" />
<meta property="og:site_name" content="Clickerz" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Xmass Click" />
<meta property="og:title" content="Clickerz — Tetard Universe" />
<meta
property="og:description"
content="Xmass Click votre nouveau Clicker préféré !"
/>
<meta
property="og:image"
content="https://xmass.click/webp/share-cover.webp"
/>
<meta
property="og:image:secure_url"
content="https://xmass.click/webp/share-cover.webp"
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
/>
<meta property="og:image:width" content="584" />
<meta property="og:image:height" content="384" />
<meta property="fb:pages" content />
<meta property="fb:admins" content />
<meta property="fb:app_id" content />
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content />
<meta name="twitter:creator" content />
<meta name="twitter:title" content="Xmass Click" />
<meta name="twitter:title" content="Clickerz — Tetard Universe" />
<meta
name="twitter:description"
content="Xmass Click votre nouveau Clicker préféré !"
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
/>
<meta
name="twitter:image"
content="https://xmass.click/webp/share-cover.webp"
/>
<title>Name</title>
<title>Clickerz — Tetard Universe</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,4 +1,4 @@
Sitemap: https://xmass.click/sitemap.xml
Sitemap: https://clickerz.tetardtek.com/sitemap.xml
User-agent: AlphaSeoBot
User-agent: AlphaSeoBot-SA

View File

@@ -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">
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
<url>
<loc>https://www.xmass.click/</loc>
<lastmod>2023-08-15T19:22:16+00:00</lastmod>
<loc>https://clickerz.tetardtek.com/</loc>
<lastmod>2026-03-20T12:00:00+00:00</lastmod>
<priority>1.00</priority>
</url>
<url>
<loc>https://www.xmass.click/boutique</loc>
<lastmod>2023-08-15T19:22:16+00:00</lastmod>
<loc>https://clickerz.tetardtek.com/boutique</loc>
<lastmod>2026-03-20T12:00:00+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://www.xmass.click/achievements</loc>
<lastmod>2023-08-15T19:22:16+00:00</lastmod>
<loc>https://clickerz.tetardtek.com/achievements</loc>
<lastmod>2026-03-20T12:00:00+00:00</lastmod>
<priority>0.80</priority>
</url>
</urlset>
</urlset>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string | null>(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 };
}

View File

@@ -3,32 +3,32 @@ function Cookie() {
return (
<div className="container">
<div className="item">
<h2>Quest-ce quun cookie ?</h2>
<h2>Qu'est-ce qu'un cookie ?</h2>
<p>
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 dun site. Dautres cookies
sont dutilité pratique pour le visiteur : ils sauvegardent de manière
sécurisée votre nom dutilisateur ou vos préférences linguistiques par
exemple. Les cookies signifient tout simplement quà chaque fois que
vous visitez un site web, vous navez 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 quchaque fois que
vous visitez un site web, vous n'avez pas besoin de saisir à nouveau
les mêmes informations.
</p>
</div>
<div className="item">
<h2>Pourquoi Xmass Clicker utilise des cookies ?</h2>
<h2>Pourquoi Clickerz utilise des cookies ?</h2>
<p>
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 dune éventuelle perte dinformations ou de
toute forme de traitement illicite. Pour davantage dinformations,
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é.
</p>
</div>
@@ -37,7 +37,7 @@ function Cookie() {
<p>
Vous pouvez paramétrer votre navigateur Internet pour désactiver les
cookies. Notez toutefois que si vous désactivez les cookies, votre nom
dutilisateur 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.
</p>
</div>
@@ -49,9 +49,9 @@ function Cookie() {
2. Appuyez sur la touche « Alt » <br />
3. Dans le menu en haut de la page cliquez sur « Outils » puis «
Options » <br />
4. Sélectionnez longlet « Vie privée » <br />
4. Sélectionnez l'onglet « Vie privée » <br />
5. Dans le menu déroulant à droite de « Règles de conservation »,
cliquez sur « utiliser les paramètres personnalisés pour lhistorique
cliquez sur « utiliser les paramètres personnalisés pour l'historique
» <br />
6. Un peu plus bas, décochez « Accepter les cookies » <br />
7. Sauvegardez vos préférences en cliquant sur « OK »
@@ -63,7 +63,7 @@ function Cookie() {
<p>
1. Ouvrez Internet Explorer <br />
2. Dans le menu « Outils », sélectionnez « Options Internet » <br />
3. Cliquez sur longlet « Confidentialité » <br />
3. Cliquez sur l'onglet « Confidentialité » <br />
4. Cliquez sur « Avancé » et décochez « Accepter » <br />
5. Sauvegardez vos préférences en cliquant sur « OK »
</p>
@@ -75,7 +75,7 @@ function Cookie() {
1. Ouvrez Safari <br />
2. Dans la barre de menu en haut, cliquez sur « Safari », puis «
Préférences » <br />
3. Sélectionnez licône « Sécurité » <br />
3. Sélectionnez l'icône « Sécurité » <br />
4. À côté de « Accepter les cookies », cochez « Jamais » <br />
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() {
<h2>Google Chrome :</h2>
<p>
1. Ouvrez Google Chrome <br />
2. Cliquez sur licône doutils dans la barre de menu <br />
2. Cliquez sur l'icône d'outils dans la barre de menu <br />
3. Sélectionnez « Options » <br />
4. Cliquez sur longlet « Options avancées » <br />
4. Cliquez sur l'onglet « Options avancées » <br />
5. Dans le menu déroulant « Paramètres des cookies », sélectionnez «
Bloquer tous les cookies »
</p>

View File

@@ -4,20 +4,18 @@ function Legal() {
<div className="mentionslegales">
<h2>Éditeur :</h2>
<p>
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.
</p>
<h2>Coordonnées :</h2>
<p>
Téléphone : 04 22 52 10 10 <br />
E-mail : pere-noel@laposte.net <br />
Adresse : 250 avenue des Nuages, 1000 Pôle Nord <br />
E-mail : contact@tetardtek.com <br />
Site : https://tetardtek.com <br />
</p>
<h2>Responsabilité :</h2>
<p>
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.
</p>
@@ -25,26 +23,26 @@ function Legal() {
<h2>Propriété Intellectuelle :</h2>
<p>
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.
</p>
<h2>Protection des Données Personnelles :</h2>
<p>
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.
</p>
<h2>Conditions Générales d'Utilisation :</h2>
<p>
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.
</p>
<h2>Loi Applicable :</h2>
<p>
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.
</p>
</div>
);

View File

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

167
docs/SPRINT1.md Normal file
View File

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