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:
14
Backend/database/migrations/002_game_saves.sql
Normal file
14
Backend/database/migrations/002_game_saves.sql
Normal 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
|
||||||
|
);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
DROP TABLE IF EXISTS game_saves;
|
||||||
DROP TABLE IF EXISTS users;
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
@@ -10,3 +11,14 @@ CREATE TABLE users (
|
|||||||
lastname VARCHAR(50) NULL,
|
lastname VARCHAR(50) NULL,
|
||||||
super_oauth_id VARCHAR(36) NULL UNIQUE
|
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
|
||||||
|
);
|
||||||
|
|||||||
141
Backend/src/controllers/saveControllers.js
Normal file
141
Backend/src/controllers/saveControllers.js
Normal 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 };
|
||||||
43
Backend/src/models/GameSaveManager.js
Normal file
43
Backend/src/models/GameSaveManager.js
Normal 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;
|
||||||
@@ -9,6 +9,7 @@ const router = express.Router();
|
|||||||
// Import Controllers
|
// Import Controllers
|
||||||
const userControllers = require("./controllers/userControllers");
|
const userControllers = require("./controllers/userControllers");
|
||||||
const authControllers = require("./controllers/authControllers");
|
const authControllers = require("./controllers/authControllers");
|
||||||
|
const saveControllers = require("./controllers/saveControllers");
|
||||||
const verifyToken = require("./middlewares/verifyToken");
|
const verifyToken = require("./middlewares/verifyToken");
|
||||||
const verifyOAuth = require("./middlewares/verifyOAuth");
|
const verifyOAuth = require("./middlewares/verifyOAuth");
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ router.post("/login", userControllers.login);
|
|||||||
// Sync game state — SuperOAuth uniquement
|
// Sync game state — SuperOAuth uniquement
|
||||||
router.patch("/users/:id/coins", verifyOAuth, verifySelf, userControllers.updateCoins);
|
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);
|
||||||
|
|
||||||
|
|
||||||
/* ************************************************************************* */
|
/* ************************************************************************* */
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
// Import the manager modules responsible for handling data operations on the tables
|
// Import the manager modules responsible for handling data operations on the tables
|
||||||
const UserManager = require("./models/UserManager");
|
const UserManager = require("./models/UserManager");
|
||||||
|
const GameSaveManager = require("./models/GameSaveManager");
|
||||||
|
|
||||||
const managers = [
|
const managers = [
|
||||||
UserManager,
|
UserManager,
|
||||||
// Add other managers here
|
GameSaveManager,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create an empty object to hold data managers for different tables
|
// Create an empty object to hold data managers for different tables
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!--Head-->
|
<!--Head-->
|
||||||
|
|
||||||
<h3>Xmass Clicker</h3>
|
<h3>Clickerz — Tetard Universe</h3>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
### 📄 About :
|
### 📄 About :
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
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 name="robots" content="index, follow" />
|
||||||
<meta
|
<meta
|
||||||
@@ -21,42 +21,25 @@
|
|||||||
name="bingbot"
|
name="bingbot"
|
||||||
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
|
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
|
||||||
/>
|
/>
|
||||||
<link rel="canonical" href="https://xmass.click" />
|
<link rel="canonical" href="https://clickerz.tetardtek.com" />
|
||||||
<meta property="og:url" content="https://xmass.click" />
|
<meta property="og:url" content="https://clickerz.tetardtek.com" />
|
||||||
<meta property="og:site_name" content="Xmass Click" />
|
<meta property="og:site_name" content="Clickerz" />
|
||||||
<meta property="og:locale" content="fr_FR" />
|
<meta property="og:locale" content="fr_FR" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="Xmass Click" />
|
<meta property="og:title" content="Clickerz — Tetard Universe" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og: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
|
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
<meta property="og:image:width" content="584" />
|
<meta property="og:image:width" content="584" />
|
||||||
<meta property="og:image:height" content="384" />
|
<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:card" content="summary" />
|
||||||
<meta name="twitter:site" content />
|
<meta name="twitter:title" content="Clickerz — Tetard Universe" />
|
||||||
<meta name="twitter:creator" content />
|
|
||||||
<meta name="twitter:title" content="Xmass Click" />
|
|
||||||
<meta
|
<meta
|
||||||
name="twitter:description"
|
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
|
<title>Clickerz — Tetard Universe</title>
|
||||||
name="twitter:image"
|
|
||||||
content="https://xmass.click/webp/share-cover.webp"
|
|
||||||
/>
|
|
||||||
<title>Name</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Sitemap: https://xmass.click/sitemap.xml
|
Sitemap: https://clickerz.tetardtek.com/sitemap.xml
|
||||||
|
|
||||||
User-agent: AlphaSeoBot
|
User-agent: AlphaSeoBot
|
||||||
User-agent: AlphaSeoBot-SA
|
User-agent: AlphaSeoBot-SA
|
||||||
|
|||||||
@@ -4,22 +4,20 @@
|
|||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||||
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
|
|
||||||
|
|
||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.xmass.click/</loc>
|
<loc>https://clickerz.tetardtek.com/</loc>
|
||||||
<lastmod>2023-08-15T19:22:16+00:00</lastmod>
|
<lastmod>2026-03-20T12:00:00+00:00</lastmod>
|
||||||
<priority>1.00</priority>
|
<priority>1.00</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.xmass.click/boutique</loc>
|
<loc>https://clickerz.tetardtek.com/boutique</loc>
|
||||||
<lastmod>2023-08-15T19:22:16+00:00</lastmod>
|
<lastmod>2026-03-20T12:00:00+00:00</lastmod>
|
||||||
<priority>0.80</priority>
|
<priority>0.80</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.xmass.click/achievements</loc>
|
<loc>https://clickerz.tetardtek.com/achievements</loc>
|
||||||
<lastmod>2023-08-15T19:22:16+00:00</lastmod>
|
<lastmod>2026-03-20T12:00:00+00:00</lastmod>
|
||||||
<priority>0.80</priority>
|
<priority>0.80</priority>
|
||||||
</url>
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
@@ -7,13 +7,19 @@ import {
|
|||||||
buyGenerator,
|
buyGenerator,
|
||||||
applyPrestige,
|
applyPrestige,
|
||||||
canPrestige,
|
canPrestige,
|
||||||
|
computePrestigeDna,
|
||||||
|
canBuyEvolutionNode,
|
||||||
|
buyEvolutionNode,
|
||||||
|
getClickMultiplierFromTree,
|
||||||
|
getProductionMultiplierFromTree,
|
||||||
|
getStartBonusFromTree,
|
||||||
DEFAULT_STATE,
|
DEFAULT_STATE,
|
||||||
DEFAULT_GENERATORS,
|
DEFAULT_GENERATORS,
|
||||||
|
DEFAULT_EVOLUTION_TREE,
|
||||||
} from "../core/economy";
|
} from "../core/economy";
|
||||||
|
|
||||||
// PrestigePanel visibility guard — canPrestige drives render condition
|
// --- PrestigePanel visibility ---
|
||||||
// Ces tests valident l'invariant : le panneau prestige ne doit jamais être
|
|
||||||
// visible (canPrestige = false) si les ressources sont inférieures au seuil.
|
|
||||||
describe("PrestigePanel visibility (canPrestige guard)", () => {
|
describe("PrestigePanel visibility (canPrestige guard)", () => {
|
||||||
it("canPrestige = false pour resources = 0 → panneau non visible", () => {
|
it("canPrestige = false pour resources = 0 → panneau non visible", () => {
|
||||||
expect(canPrestige({ ...DEFAULT_STATE, resources: 0 })).toBe(false);
|
expect(canPrestige({ ...DEFAULT_STATE, resources: 0 })).toBe(false);
|
||||||
@@ -28,6 +34,8 @@ describe("PrestigePanel visibility (canPrestige guard)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Prestige reset ---
|
||||||
|
|
||||||
describe("applyPrestige — post-prestige state", () => {
|
describe("applyPrestige — post-prestige state", () => {
|
||||||
const prestigeState = {
|
const prestigeState = {
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
@@ -35,9 +43,10 @@ describe("applyPrestige — post-prestige state", () => {
|
|||||||
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 3 })),
|
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 3 })),
|
||||||
prestigeCount: 0,
|
prestigeCount: 0,
|
||||||
prestigeMultiplier: 1,
|
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);
|
expect(applyPrestige(prestigeState).resources).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,8 +62,43 @@ describe("applyPrestige — post-prestige state", () => {
|
|||||||
it("prestigeCount incrémenté à 1 après premier prestige", () => {
|
it("prestigeCount incrémenté à 1 après premier prestige", () => {
|
||||||
expect(applyPrestige(prestigeState).prestigeCount).toBe(1);
|
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", () => {
|
describe("generatorCost", () => {
|
||||||
it("retourne baseCost quand owned = 0", () => {
|
it("retourne baseCost quand owned = 0", () => {
|
||||||
const gen = { ...DEFAULT_GENERATORS[0], owned: 0 };
|
const gen = { ...DEFAULT_GENERATORS[0], owned: 0 };
|
||||||
@@ -67,6 +111,8 @@ describe("generatorCost", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Production ---
|
||||||
|
|
||||||
describe("totalProductionPerSecond", () => {
|
describe("totalProductionPerSecond", () => {
|
||||||
it("retourne 0 si aucun générateur acheté", () => {
|
it("retourne 0 si aucun générateur acheté", () => {
|
||||||
expect(totalProductionPerSecond(DEFAULT_STATE)).toBe(0);
|
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 };
|
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);
|
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)", () => {
|
describe("computeIdleGains (lazy calculation)", () => {
|
||||||
it("calcule les gains proportionnellement au temps écoulé", () => {
|
it("calcule les gains proportionnellement au temps écoulé", () => {
|
||||||
const state = {
|
const state = {
|
||||||
@@ -108,29 +167,54 @@ describe("computeIdleGains (lazy calculation)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Click ---
|
||||||
|
|
||||||
describe("applyClick", () => {
|
describe("applyClick", () => {
|
||||||
it("augmente les ressources du clickMultiplier × prestigeMultiplier", () => {
|
it("augmente les ressources du clickMultiplier × prestigeMultiplier", () => {
|
||||||
const state = { ...DEFAULT_STATE, clickMultiplier: 3, prestigeMultiplier: 2 };
|
const state = { ...DEFAULT_STATE, clickMultiplier: 3, prestigeMultiplier: 2 };
|
||||||
const result = applyClick(state);
|
const result = applyClick(state);
|
||||||
expect(result.resources).toBe(6);
|
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", () => {
|
describe("buyGenerator", () => {
|
||||||
it("retourne null si fonds insuffisants", () => {
|
it("retourne null si fonds insuffisants", () => {
|
||||||
const result = buyGenerator(DEFAULT_STATE, "manic");
|
const result = buyGenerator(DEFAULT_STATE, "nid");
|
||||||
expect(result).toBeNull(); // 0 ressources, coût = 15
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("achète correctement et déduit le coût", () => {
|
it("achète correctement et déduit le coût", () => {
|
||||||
const state = { ...DEFAULT_STATE, resources: 100 };
|
const state = { ...DEFAULT_STATE, resources: 100 };
|
||||||
const result = buyGenerator(state, "manic");
|
const result = buyGenerator(state, "nid");
|
||||||
expect(result).not.toBeNull();
|
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);
|
expect(result!.resources).toBe(100 - DEFAULT_GENERATORS[0].baseCost);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Prestige legacy ---
|
||||||
|
|
||||||
describe("prestige", () => {
|
describe("prestige", () => {
|
||||||
it("canPrestige retourne false si < 1 000 000 ressources", () => {
|
it("canPrestige retourne false si < 1 000 000 ressources", () => {
|
||||||
expect(canPrestige({ ...DEFAULT_STATE, resources: 999_999 })).toBe(false);
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
123
Frontend/src/__tests__/save-validation.test.ts
Normal file
123
Frontend/src/__tests__/save-validation.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,15 +9,94 @@ export interface Generator {
|
|||||||
owned: number;
|
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 {
|
export interface GameState {
|
||||||
resources: number;
|
resources: number;
|
||||||
clickMultiplier: number;
|
clickMultiplier: number;
|
||||||
generators: Generator[];
|
generators: Generator[];
|
||||||
lastTick: number; // timestamp ms — lazy calc reference
|
lastTick: number; // timestamp ms — lazy calc reference
|
||||||
prestigeCount: number;
|
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
|
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned
|
||||||
export function generatorCost(gen: Generator): number {
|
export function generatorCost(gen: Generator): number {
|
||||||
return Math.floor(gen.baseCost * Math.pow(1.15, gen.owned));
|
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,
|
(sum, gen) => sum + gen.baseProduction * gen.owned,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
return base * state.prestigeMultiplier;
|
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
||||||
|
return base * state.prestigeMultiplier * treeMultiplier;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy calculation : ressources accumulées depuis lastTick
|
// Lazy calculation : ressources accumulées depuis lastTick
|
||||||
@@ -44,15 +124,19 @@ export function applyIdleGains(state: GameState, now: number): GameState {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
resources: state.resources + gains,
|
resources: state.resources + gains,
|
||||||
|
lifetimeTadpoles: state.lifetimeTadpoles + gains,
|
||||||
lastTick: now,
|
lastTick: now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clic manuel
|
// Clic manuel
|
||||||
export function applyClick(state: GameState): GameState {
|
export function applyClick(state: GameState): GameState {
|
||||||
|
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
|
||||||
|
const gain = state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
|
||||||
return {
|
return {
|
||||||
...state,
|
...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 {
|
export function canPrestige(state: GameState): boolean {
|
||||||
return state.resources >= 1_000_000;
|
return state.resources >= 1_000_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyPrestige(state: GameState): GameState {
|
export function applyPrestige(state: GameState): GameState {
|
||||||
const newPrestigeCount = state.prestigeCount + 1;
|
const newPrestigeCount = state.prestigeCount + 1;
|
||||||
|
const dnaGained = computePrestigeDna(state.lifetimeTadpoles);
|
||||||
|
const startBonus = getStartBonusFromTree(state.evolutionTree);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
resources: 0,
|
resources: startBonus,
|
||||||
generators: state.generators.map((g) => ({ ...g, owned: 0 })),
|
generators: state.generators.map((g) => ({ ...g, owned: 0 })),
|
||||||
prestigeCount: newPrestigeCount,
|
prestigeCount: newPrestigeCount,
|
||||||
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
|
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
|
||||||
|
ancestralDna: state.ancestralDna + dnaGained,
|
||||||
|
lifetimeTadpoles: 0,
|
||||||
lastTick: Date.now(),
|
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[] = [
|
export const DEFAULT_GENERATORS: Generator[] = [
|
||||||
{ id: "manic", name: "Manic", baseCost: 10, baseProduction: 0.1, owned: 0 },
|
{ id: "nid", name: "Nid", baseCost: 10, baseProduction: 0.1, owned: 0 },
|
||||||
{ id: "coffee", name: "Tasse à café", baseCost: 100, baseProduction: 0.5, owned: 0 },
|
{ id: "mare", name: "Mare", baseCost: 100, baseProduction: 0.5, owned: 0 },
|
||||||
{ id: "sugar", name: "Sucre", baseCost: 1_000, baseProduction: 3, owned: 0 },
|
{ id: "marecage", name: "Marécage", baseCost: 1_000, baseProduction: 3, owned: 0 },
|
||||||
{ id: "factory", name: "Usine", baseCost: 10_000, baseProduction: 20, owned: 0 },
|
{ id: "etang", name: "Étang Ancien", baseCost: 10_000, baseProduction: 20, owned: 0 },
|
||||||
{ id: "portal", name: "Portail", baseCost: 100_000, baseProduction: 150, owned: 0 },
|
{ id: "lac", name: "Lac Mystique", baseCost: 100_000, baseProduction: 150, owned: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_STATE: GameState = {
|
export const DEFAULT_STATE: GameState = {
|
||||||
@@ -108,4 +198,7 @@ export const DEFAULT_STATE: GameState = {
|
|||||||
lastTick: Date.now(),
|
lastTick: Date.now(),
|
||||||
prestigeCount: 0,
|
prestigeCount: 0,
|
||||||
prestigeMultiplier: 1,
|
prestigeMultiplier: 1,
|
||||||
|
ancestralDna: 0,
|
||||||
|
evolutionTree: DEFAULT_EVOLUTION_TREE,
|
||||||
|
lifetimeTadpoles: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 18,
|
"id": 18,
|
||||||
"name": "un pull de noël",
|
"name": "Peau de grenouille rare",
|
||||||
"founded": false,
|
"founded": false,
|
||||||
"image": "https://i.goopics.net/uwjwn1.jpg"
|
"image": "https://i.goopics.net/uwjwn1.jpg"
|
||||||
},
|
},
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 30,
|
"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,
|
"founded": false,
|
||||||
"image": "https://i.goopics.net/dakyj9.png"
|
"image": "https://i.goopics.net/dakyj9.png"
|
||||||
},
|
},
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 36,
|
"id": 36,
|
||||||
"name": "Chapeau de Noël clignotant",
|
"name": "Couronne de nénuphars lumineuse",
|
||||||
"founded": false,
|
"founded": false,
|
||||||
"image": "https://i.goopics.net/d4su7e.png"
|
"image": "https://i.goopics.net/d4su7e.png"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,89 +1,89 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "Manic",
|
"name": "Griffes de Grenouille",
|
||||||
"price": 15,
|
"price": 15,
|
||||||
"incrementValue": 1,
|
"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": "/",
|
"link": "/",
|
||||||
"image": "./svg/Hand.svg",
|
"image": "./svg/Hand.svg",
|
||||||
"buyed": false,
|
"buyed": false,
|
||||||
"type": "actif"
|
"type": "actif"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Tasse à café",
|
"name": "Algues Nutritives",
|
||||||
"price": 15,
|
"price": 15,
|
||||||
"incrementValue": 1,
|
"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": "/",
|
"link": "/",
|
||||||
"image": "./svg/Tasse.svg",
|
"image": "./svg/Tasse.svg",
|
||||||
"buyed": false,
|
"buyed": false,
|
||||||
"type": "passif"
|
"type": "passif"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Mr Bonhomme",
|
"name": "Crapaud Gardien",
|
||||||
"price": 150,
|
"price": 150,
|
||||||
"incrementValue": 10,
|
"incrementValue": 10,
|
||||||
"description": "Un assistant idéal pour le click",
|
"description": "Un ancien du marais qui veille sur les pontes. +10 par clic.",
|
||||||
"link": "/",
|
"link": "/",
|
||||||
"image": "./svg/Bonhome.svg",
|
"image": "./svg/Bonhome.svg",
|
||||||
"buyed": false,
|
"buyed": false,
|
||||||
"type": "actif"
|
"type": "actif"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Bonnet",
|
"name": "Nénuphar Géant",
|
||||||
"price": 150,
|
"price": 150,
|
||||||
"incrementValue": 10,
|
"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": "/",
|
"link": "/",
|
||||||
"image": "./svg/Bonnet.svg",
|
"image": "./svg/Bonnet.svg",
|
||||||
"buyed": false,
|
"buyed": false,
|
||||||
"type": "passif"
|
"type": "passif"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Cookie",
|
"name": "Oeuf Doré",
|
||||||
"price": 1500,
|
"price": 1500,
|
||||||
"incrementValue": 100,
|
"incrementValue": 100,
|
||||||
"description": "Fait avec amour",
|
"description": "Un oeuf rare qui éclot en masse. +100 par clic.",
|
||||||
"link": "/",
|
"link": "/",
|
||||||
"image": "./svg/Cookie.svg",
|
"image": "./svg/Cookie.svg",
|
||||||
"buyed": false,
|
"buyed": false,
|
||||||
"type": "actif"
|
"type": "actif"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Canne en sucre",
|
"name": "Mousse Lumineuse",
|
||||||
"price": 1500,
|
"price": 1500,
|
||||||
"incrementValue": 100,
|
"incrementValue": 100,
|
||||||
"description": "Le sucre c'est connu, ca reboost",
|
"description": "La mousse phosphorescente accélère la croissance. +100 têtards/s.",
|
||||||
"link": "/",
|
"link": "/",
|
||||||
"image": "./svg/Canne.svg",
|
"image": "./svg/Canne.svg",
|
||||||
"buyed": false,
|
"buyed": false,
|
||||||
"type": "passif"
|
"type": "passif"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Couronne d'hiver",
|
"name": "Couronne de Roseaux",
|
||||||
"price": 15000,
|
"price": 15000,
|
||||||
"incrementValue": 1000,
|
"incrementValue": 1000,
|
||||||
"description": "Un bisous ou rien du tout !",
|
"description": "Le symbole du Gardien suprême du Marais. +1000 par clic.",
|
||||||
"link": "/",
|
"link": "/",
|
||||||
"image": "./svg/Courone.svg",
|
"image": "./svg/Courone.svg",
|
||||||
"buyed": false,
|
"buyed": false,
|
||||||
"type": "actif"
|
"type": "actif"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Mr pain d'épice",
|
"name": "Esprit du Marais",
|
||||||
"price": 15000,
|
"price": 15000,
|
||||||
"incrementValue": 1000,
|
"incrementValue": 1000,
|
||||||
"description": "Le meilleur c'est la tête",
|
"description": "L'esprit ancestral bénit les eaux. +1000 têtards/s.",
|
||||||
"link": "/",
|
"link": "/",
|
||||||
"image": "./svg/PainDep.svg",
|
"image": "./svg/PainDep.svg",
|
||||||
"buyed": false,
|
"buyed": false,
|
||||||
"type": "passif"
|
"type": "passif"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Bière",
|
"name": "Nectar de Lotus",
|
||||||
"price": 8000,
|
"price": 8000,
|
||||||
"incrementValue": 1000,
|
"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": "/",
|
"link": "/",
|
||||||
"image": "./svg/Beer.svg",
|
"image": "./svg/Beer.svg",
|
||||||
"buyed": false,
|
"buyed": false,
|
||||||
|
|||||||
115
Frontend/src/hooks/useSaveSync.ts
Normal file
115
Frontend/src/hooks/useSaveSync.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -3,32 +3,32 @@ function Cookie() {
|
|||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="item">
|
<div className="item">
|
||||||
<h2>Qu’est-ce qu’un cookie ?</h2>
|
<h2>Qu'est-ce qu'un cookie ?</h2>
|
||||||
<p>
|
<p>
|
||||||
Un cookie est un petit fichier texte sauvegardé sur votre ordinateur
|
Un cookie est un petit fichier texte sauvegardé sur votre ordinateur
|
||||||
lorsque vous visitez un site web. Ce fichier texte enregistre des
|
lorsque vous visitez un site web. Ce fichier texte enregistre des
|
||||||
informations qui peuvent être lues par un site web lorsque vous le
|
informations qui peuvent être lues par un site web lorsque vous le
|
||||||
visitez de nouveau plus tard. Certains de ces cookies sont nécessaires
|
visitez de nouveau plus tard. Certains de ces cookies sont nécessaires
|
||||||
pour accéder à certaines fonctionnalités d’un site. D’autres cookies
|
pour accéder à certaines fonctionnalités d'un site. D'autres cookies
|
||||||
sont d’utilité pratique pour le visiteur : ils sauvegardent de manière
|
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
|
sécurisée votre nom d'utilisateur ou vos préférences linguistiques par
|
||||||
exemple. Les cookies signifient tout simplement qu’à chaque fois que
|
exemple. Les cookies signifient tout simplement qu'à chaque fois que
|
||||||
vous visitez un site web, vous n’avez pas besoin de saisir à nouveau
|
vous visitez un site web, vous n'avez pas besoin de saisir à nouveau
|
||||||
les mêmes informations.
|
les mêmes informations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="item">
|
<div className="item">
|
||||||
<h2>Pourquoi Xmass Clicker utilise des cookies ?</h2>
|
<h2>Pourquoi Clickerz utilise des cookies ?</h2>
|
||||||
<p>
|
<p>
|
||||||
Nous utilisons des cookies pour vous fournir une expérience
|
Nous utilisons des cookies pour vous fournir une expérience
|
||||||
utilisateur optimale et adaptée à vos préférences personnelles. En
|
utilisateur optimale et adaptée à vos préférences personnelles.
|
||||||
utilisant les cookies, Les cookies sont également utilisés pour
|
Les cookies sont également utilisés pour optimiser la performance
|
||||||
optimiser la performance du site. Xmass Clicker a pris toutes les
|
du site. Clickerz a pris toutes les mesures organisationnelles et
|
||||||
mesures organisationnelles et techniques pour protéger vos données
|
techniques pour protéger vos données personnelles ainsi que d'une
|
||||||
personnelles ainsi que d’une éventuelle perte d’informations ou de
|
éventuelle perte d'informations ou de toute forme de traitement
|
||||||
toute forme de traitement illicite. Pour davantage d’informations,
|
illicite. Pour davantage d'informations, consultez notre Politique
|
||||||
consultez notre Politique de confidentialité.
|
de confidentialité.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ function Cookie() {
|
|||||||
<p>
|
<p>
|
||||||
Vous pouvez paramétrer votre navigateur Internet pour désactiver les
|
Vous pouvez paramétrer votre navigateur Internet pour désactiver les
|
||||||
cookies. Notez toutefois que si vous désactivez les cookies, votre nom
|
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.
|
sur aucun site web.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,9 +49,9 @@ function Cookie() {
|
|||||||
2. Appuyez sur la touche « Alt » <br />
|
2. Appuyez sur la touche « Alt » <br />
|
||||||
3. Dans le menu en haut de la page cliquez sur « Outils » puis «
|
3. Dans le menu en haut de la page cliquez sur « Outils » puis «
|
||||||
Options » <br />
|
Options » <br />
|
||||||
4. Sélectionnez l’onglet « 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 »,
|
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
|
||||||
» <br />
|
» <br />
|
||||||
6. Un peu plus bas, décochez « Accepter les cookies » <br />
|
6. Un peu plus bas, décochez « Accepter les cookies » <br />
|
||||||
7. Sauvegardez vos préférences en cliquant sur « OK »
|
7. Sauvegardez vos préférences en cliquant sur « OK »
|
||||||
@@ -63,7 +63,7 @@ function Cookie() {
|
|||||||
<p>
|
<p>
|
||||||
1. Ouvrez Internet Explorer <br />
|
1. Ouvrez Internet Explorer <br />
|
||||||
2. Dans le menu « Outils », sélectionnez « Options Internet » <br />
|
2. Dans le menu « Outils », sélectionnez « Options Internet » <br />
|
||||||
3. Cliquez sur l’onglet « Confidentialité » <br />
|
3. Cliquez sur l'onglet « Confidentialité » <br />
|
||||||
4. Cliquez sur « Avancé » et décochez « Accepter » <br />
|
4. Cliquez sur « Avancé » et décochez « Accepter » <br />
|
||||||
5. Sauvegardez vos préférences en cliquant sur « OK »
|
5. Sauvegardez vos préférences en cliquant sur « OK »
|
||||||
</p>
|
</p>
|
||||||
@@ -75,7 +75,7 @@ function Cookie() {
|
|||||||
1. Ouvrez Safari <br />
|
1. Ouvrez Safari <br />
|
||||||
2. Dans la barre de menu en haut, cliquez sur « Safari », puis «
|
2. Dans la barre de menu en haut, cliquez sur « Safari », puis «
|
||||||
Préférences » <br />
|
Préférences » <br />
|
||||||
3. Sélectionnez l’icône « Sécurité » <br />
|
3. Sélectionnez l'icône « Sécurité » <br />
|
||||||
4. À côté de « Accepter les cookies », cochez « Jamais » <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
|
5. Si vous souhaitez voir les cookies qui sont déjà sauvegardés sur
|
||||||
votre ordinateur, cliquez sur « Afficher les cookies »
|
votre ordinateur, cliquez sur « Afficher les cookies »
|
||||||
@@ -86,9 +86,9 @@ function Cookie() {
|
|||||||
<h2>Google Chrome :</h2>
|
<h2>Google Chrome :</h2>
|
||||||
<p>
|
<p>
|
||||||
1. Ouvrez Google Chrome <br />
|
1. Ouvrez Google Chrome <br />
|
||||||
2. Cliquez sur l’icône d’outils 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 />
|
3. Sélectionnez « Options » <br />
|
||||||
4. Cliquez sur l’onglet « 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 «
|
5. Dans le menu déroulant « Paramètres des cookies », sélectionnez «
|
||||||
Bloquer tous les cookies »
|
Bloquer tous les cookies »
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,20 +4,18 @@ function Legal() {
|
|||||||
<div className="mentionslegales">
|
<div className="mentionslegales">
|
||||||
<h2>Éditeur :</h2>
|
<h2>Éditeur :</h2>
|
||||||
<p>
|
<p>
|
||||||
Xmass'Click est un projet réalisé dans le cadre d'un hackathon sur 2
|
Clickerz est un projet indépendant faisant partie du Tetard Universe.
|
||||||
jours.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Coordonnées :</h2>
|
<h2>Coordonnées :</h2>
|
||||||
<p>
|
<p>
|
||||||
Téléphone : 04 22 52 10 10 <br />
|
E-mail : contact@tetardtek.com <br />
|
||||||
E-mail : pere-noel@laposte.net <br />
|
Site : https://tetardtek.com <br />
|
||||||
Adresse : 250 avenue des Nuages, 1000 Pôle Nord <br />
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Responsabilité :</h2>
|
<h2>Responsabilité :</h2>
|
||||||
<p>
|
<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
|
Les informations fournies sont à titre informatif et peuvent être
|
||||||
sujettes à des erreurs.
|
sujettes à des erreurs.
|
||||||
</p>
|
</p>
|
||||||
@@ -25,26 +23,26 @@ function Legal() {
|
|||||||
<h2>Propriété Intellectuelle :</h2>
|
<h2>Propriété Intellectuelle :</h2>
|
||||||
<p>
|
<p>
|
||||||
Tout le contenu du site (textes, images, etc.) reste la propriété de
|
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.
|
préalable.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Protection des Données Personnelles :</h2>
|
<h2>Protection des Données Personnelles :</h2>
|
||||||
<p>
|
<p>
|
||||||
Xmass'Click ne collecte pas de données personnelles. Aucune information
|
Clickerz utilise un système d'authentification via SuperOAuth.
|
||||||
personnelle n'est stockée lors de l'utilisation du site.
|
Les données de jeu sont sauvegardées côté serveur.
|
||||||
|
Aucune donnée personnelle n'est partagée avec des tiers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Conditions Générales d'Utilisation :</h2>
|
<h2>Conditions Générales d'Utilisation :</h2>
|
||||||
<p>
|
<p>
|
||||||
Aucune condition générale d'utilisation n'est applicable. L'utilisation
|
L'utilisation du site Clickerz se fait à titre gratuit et sans engagement.
|
||||||
du site Xmass'Click se fait à titre gratuit et sans engagement.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Loi Applicable :</h2>
|
<h2>Loi Applicable :</h2>
|
||||||
<p>
|
<p>
|
||||||
Le présent site est régi par la loi du Pôle Nord. En cas de litige, les
|
Le présent site est régi par la loi française. En cas de litige, les
|
||||||
tribunaux du Père Noël seront compétents.
|
tribunaux compétents seront ceux du ressort du siège social de l'éditeur.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
192
docs/GDD.md
192
docs/GDD.md
@@ -1,76 +1,188 @@
|
|||||||
# Clickerz — GDD Minimal
|
# Clickerz — GDD (Game Design Document)
|
||||||
|
|
||||||
> Sprint 1 — Step 1 output
|
> Tetard Universe — Clicker/idle hybride
|
||||||
> Date : 2026-03-17
|
> 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
|
## Stack technique
|
||||||
|
|
||||||
**React + TypeScript + Vite** — vanilla, sans moteur de jeu
|
| Couche | Techno | Justification |
|
||||||
|
|--------|--------|---------------|
|
||||||
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.
|
| 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
|
## Mécaniques core
|
||||||
|
|
||||||
**Ressource principale** : Cookies (ou ressource thématique à nommer)
|
### Ressource principale : Têtards
|
||||||
|
|
||||||
**Sources de production** :
|
**Sources de production :**
|
||||||
- Clic manuel : +1 ressource/clic (multiplicateur upgradable)
|
- Ponte (clic manuel) : +1 × clickMultiplier × prestigeMultiplier
|
||||||
- Générateurs idle : 5 tiers, coût `base × 1.15^n`, production `/s` cumulative
|
- 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é) :
|
### Générateurs — progression tier
|
||||||
| Tier | Coût base | Production/s |
|
|
||||||
|------|-----------|-------------|
|
| Tier | Nom | Coût base | Production/s | Ratio scaling |
|
||||||
| 1 | 10 | 0.1 |
|
|------|-----|-----------|-------------|---------------|
|
||||||
| 2 | 100 | 0.5 |
|
| 1 | Nid | 10 | 0.1 | 1.15 |
|
||||||
| 3 | 1 000 | 3 |
|
| 2 | Mare | 100 | 0.5 | 1.15 |
|
||||||
| 4 | 10 000 | 20 |
|
| 3 | Marécage | 1 000 | 3 | 1.15 |
|
||||||
| 5 | 100 000 | 150 |
|
| 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)
|
│ BOUCLE 1 — La Run (secondes → minutes) │
|
||||||
- Reset : ressources + générateurs à 0, upgrades prestige conservés
|
│ │
|
||||||
- Courbe : reset 1 = ×1.1, reset 10 = ×2.0, reset 50 = ×6.0
|
│ 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
|
## Monétisation
|
||||||
|
|
||||||
**Cosmétiques only** — pas de pay-to-win
|
**Cosmétiques only** — pas de pay-to-win
|
||||||
|
- Thèmes visuels (couleurs, icônes marais)
|
||||||
- Thèmes visuels (couleurs, icônes)
|
- Titres / badges de génération
|
||||||
- Titres / badges de prestige
|
- Effets de ponte (particules)
|
||||||
- Effets de clic (particules)
|
|
||||||
- Raison : 0 compliance fiscale, 0 déséquilibre économie, communauté saine
|
- 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
|
## 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
|
- Intégration Twitch
|
||||||
- Multijoueur
|
- 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
167
docs/SPRINT1.md
Normal 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 |
|
||||||
Reference in New Issue
Block a user