Compare commits

...

8 Commits

Author SHA1 Message Date
ed8cf87d4e feat: Sprint 3 — Prestige Loop endless
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 35s
- Migration saves: saveVersion pattern + migrateSave lazy (v1→v2)
- Formule ADN rebalancée: log10 + clamp min 1 + cap bonus ×4
- Prestige Experience: modal fullscreen, preview ADN, stats run, best run
- Arbre V2: 25 nœuds, 3 capstones, post-capstones repeatables (scaling par tranche)
- Convergence évolutif Alpha→Omega (tier system)
- Reset arbre: 1 gratuit/prestige, payant linéaire au-delà
- Milestones prestige: 8 paliers (1→100), cosmétiques exclusifs, bonus gameplay
- balance.ts: constantes centralisées pour playtest
- 136 tests green, 0 regression
2026-03-28 18:24:24 +01:00
f80f071c24 feat: 9 SVG placeholders cosmétiques (32x32, 1 couleur/slot)
crown, cap-swamp, glasses-savant, mask-frog, cape-algae,
armor-scales, flame-tail, ribbon, particles-gold.
Placeholders à remplacer par l'art final plus tard.
2026-03-28 12:43:07 +01:00
2c924c1e4a fix: câbler tous les effets arbre + cleanup dette Sprint 2
- double_click_chance + crit_click_chance câblés dans applyClick (RNG)
- auto_click câblé dans le tick (auto-pontes/s)
- unlock_generator (Résilience) → 1 Lac Mystique gratuit au prestige
- ponte_critique requires double_ponte (fix branche morte)
- achievement_scaling retiré (nœud absent), full_tree + symbiose fixés
- Particule feedback coloré (crit=ambre, double=violet)
- 99 tests (tous passent)
2026-03-28 12:41:12 +01:00
2a242e97cc feat: cosmétiques V1 — 5 slots SVG, récompenses achievements + prestige
10 cosmétiques (2/slot), unlock auto sur achievements et prestige tiers.
TadpoleSprite composant SVG stack (base + overlays équipés).
CosmeticsPanel dans la sidebar — inventaire, équiper/retirer par slot.
GameState étendu (cosmeticInventory + cosmeticEquipped), backfill saves.
17 nouveaux tests cosmétiques (92 total, tous passent).
2026-03-28 12:09:26 +01:00
ae50908bc9 feat: arbre d'évolution 3 voies — ponte/marais/adaptation
18 nœuds (6/branche), nœuds exclusifs (pick one), reset gratuit.
Nouveaux effets : double_click, auto_click, crit, generator_boost,
cost_reduction, prestige_dna_bonus, offline_boost, threshold_reduction.
UI 3 colonnes colorées, seuil prestige dynamique, coût réduit.
75 tests (tous passent).
2026-03-28 11:52:51 +01:00
3ba10dad5f feat: offline gains — courbe inversée 2h, cap 25%, écran résumé
offlineEfficiency() : 100% (0-15min) → 25% (1h) → 0% (2h).
computeOfflineGains() intègre la courbe par tranches de 1min.
GameState.lastOnline ajouté, store hydrate avec offline report.
OfflineReport.tsx affiché au retour si absence > 60s.
13 nouveaux tests (66 total, tous passent).
2026-03-28 11:44:59 +01:00
90761b3e13 feat: Sprint 2 brief — offline gains, arbre 3 voies, cosmétiques V1
SPRINT2.md posé avec 3 steps : courbe inversée offline (2h cap 25%),
arbre d'évolution 3 branches (ponte/marais/adaptation, reset gratuit),
cosmétiques par récompenses (5 slots SVG, achievements + prestige).
GDD mis à jour avec Sprint 2 scope et hors-scope.
2026-03-28 11:32:53 +01:00
b58d39e707 feat: migrate SCSS → Tailwind CSS + remove sass dependency
12 SCSS files (1167 lines) replaced by centralized index.css with
Tailwind v4 @theme tokens, @layer components, and utility classes.
Game panel design system (gp-*) preserved as CSS components.
Inline styles in Settings/Login/MilestoneBar converted to Tailwind utilities.
sass removed from dependencies. Build clean, 53 tests pass.
2026-03-28 11:19:45 +01:00
69 changed files with 4271 additions and 1425 deletions

View File

@@ -0,0 +1,4 @@
-- Migration 003: Add save_version column for Sprint 3 migration system
-- Safe to run on existing data — defaults to 1 (Sprint 2 saves)
ALTER TABLE game_saves ADD COLUMN save_version INT DEFAULT 1 AFTER game_state;

View File

@@ -16,6 +16,7 @@ CREATE TABLE game_saves (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE, user_id INT NOT NULL UNIQUE,
game_state JSON NOT NULL, game_state JSON NOT NULL,
save_version INT DEFAULT 1,
last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
lifetime_tadpoles BIGINT DEFAULT 0, lifetime_tadpoles BIGINT DEFAULT 0,
prestige_count INT DEFAULT 0, prestige_count INT DEFAULT 0,

View File

@@ -1,4 +1,5 @@
const tables = require("../tables"); const tables = require("../tables");
const { migrateSave } = require("../services/migrateSave");
// --- Anti-cheat validation --- // --- Anti-cheat validation ---
@@ -75,11 +76,14 @@ const load = async (req, res) => {
} }
// game_state est stocké en JSON — MySQL le retourne comme objet si type JSON // game_state est stocké en JSON — MySQL le retourne comme objet si type JSON
const gameState = const rawState =
typeof save.game_state === "string" typeof save.game_state === "string"
? JSON.parse(save.game_state) ? JSON.parse(save.game_state)
: save.game_state; : save.game_state;
// Migrate on load — lazy migration, never touch DB rows directly
const gameState = migrateSave(rawState);
return res.status(200).json({ return res.status(200).json({
gameState, gameState,
lastSave: save.last_save, lastSave: save.last_save,

View File

@@ -15,18 +15,20 @@ class GameSaveManager extends AbstractManager {
async upsert(userId, gameState, metadata) { async upsert(userId, gameState, metadata) {
const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata; const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata;
const saveVersion = gameState.saveVersion ?? 1;
const gameStateJson = JSON.stringify(gameState); const gameStateJson = JSON.stringify(gameState);
const [result] = await this.database.query( const [result] = await this.database.query(
`INSERT INTO ${this.table} (user_id, game_state, lifetime_tadpoles, prestige_count, play_time_seconds) `INSERT INTO ${this.table} (user_id, game_state, save_version, lifetime_tadpoles, prestige_count, play_time_seconds)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
game_state = VALUES(game_state), game_state = VALUES(game_state),
save_version = VALUES(save_version),
lifetime_tadpoles = VALUES(lifetime_tadpoles), lifetime_tadpoles = VALUES(lifetime_tadpoles),
prestige_count = VALUES(prestige_count), prestige_count = VALUES(prestige_count),
play_time_seconds = VALUES(play_time_seconds), play_time_seconds = VALUES(play_time_seconds),
last_save = CURRENT_TIMESTAMP`, last_save = CURRENT_TIMESTAMP`,
[userId, gameStateJson, lifetimeTadpoles, prestigeCount, playTimeSeconds] [userId, gameStateJson, saveVersion, lifetimeTadpoles, prestigeCount, playTimeSeconds]
); );
return result.affectedRows; return result.affectedRows;

View File

@@ -0,0 +1,65 @@
// migrateSave.js — Backend save migration (mirrors Frontend/src/core/migrateSave.ts)
// Applied on load — lazy migration, never touch DB directly.
const CURRENT_SAVE_VERSION = 2;
// Default evolution tree (Sprint 2 — 18 nodes)
// Used to merge new nodes into old saves
const DEFAULT_TREE_IDS = [
"ponte_amelioree", "double_ponte", "ponte_frenetique", "auto_ponte",
"ponte_critique", "maitre_pondeur",
"instinct_gregaire", "symbiose_algale", "courant_profond", "maree_haute",
"ecosysteme_mature", "marais_eternel",
"memoire_genetique", "adn_renforce", "eveil_rapide", "resilience",
"heritage", "transcendance",
];
/**
* Migrate a raw game state to the current version.
* Backend only needs structural migration for anti-cheat validation —
* the full tree/generator merge happens on the frontend.
*/
function migrateSave(raw) {
if (!raw || typeof raw !== "object") return raw;
const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1;
let state = { ...raw };
if (version < 2) {
state = migrateV1toV2(state);
}
return state;
}
function migrateV1toV2(state) {
state.saveVersion = 2;
// RunStats
if (!state.runStats) {
state.runStats = {
startedAt: state.lastTick || Date.now(),
tadpolesProduced: 0,
bestRun: null,
};
}
// Tree reset fields
if (typeof state.freeResetAvailable !== "boolean") {
state.freeResetAvailable = true;
}
if (typeof state.extraResetsUsed !== "number") {
state.extraResetsUsed = 0;
}
// Backfill cosmetics
if (!state.lastOnline) state.lastOnline = state.lastTick;
if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = [];
if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") {
state.cosmeticEquipped = {};
}
return state;
}
module.exports = { migrateSave, CURRENT_SAVE_VERSION };

View File

@@ -14,7 +14,6 @@
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-lottie-player": "^1.5.5", "react-lottie-player": "^1.5.5",
"react-router-dom": "^6.19.0", "react-router-dom": "^6.19.0",
"sass": "^1.69.5",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
@@ -1033,9 +1032,11 @@
"version": "2.5.6", "version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3", "detect-libc": "^2.0.3",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@@ -1072,11 +1073,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1092,11 +1095,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1112,11 +1117,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1132,11 +1139,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1152,11 +1161,13 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1172,11 +1183,13 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1192,11 +1205,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1212,11 +1227,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1232,11 +1249,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1252,11 +1271,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1272,11 +1293,13 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1292,11 +1315,13 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -1312,11 +1337,13 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
}, },
@@ -2579,6 +2606,8 @@
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
}, },
@@ -3600,7 +3629,9 @@
"version": "5.1.5", "version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.0", "version": "3.3.0",
@@ -3757,7 +3788,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"devOptional": true, "dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3793,7 +3824,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"devOptional": true, "dev": true,
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
}, },
@@ -4442,8 +4473,10 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true "optional": true,
"peer": true
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.13", "version": "2.0.13",
@@ -4685,7 +4718,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -4885,6 +4918,8 @@
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">= 14.18.0" "node": ">= 14.18.0"
}, },
@@ -5114,6 +5149,8 @@
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^5.1.5", "immutable": "^5.1.5",

View File

@@ -17,7 +17,6 @@
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-lottie-player": "^1.5.5", "react-lottie-player": "^1.5.5",
"react-router-dom": "^6.19.0", "react-router-dom": "^6.19.0",
"sass": "^1.69.5",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><g fill="#6b7280" opacity="0.5"><ellipse cx="13" cy="16" rx="3" ry="2"/><ellipse cx="19" cy="16" rx="3" ry="2"/><ellipse cx="16" cy="20" rx="3" ry="2"/><ellipse cx="13" cy="24" rx="3" ry="2"/><ellipse cx="19" cy="24" rx="3" ry="2"/></g></svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><ellipse cx="16" cy="8" rx="10" ry="4" fill="#34d399"/><rect x="6" y="7" width="20" height="3" rx="1" fill="#059669"/></svg>

After

Width:  |  Height:  |  Size: 185 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M10,14 Q6,20 8,28 L16,26 L24,28 Q26,20 22,14Z" fill="#059669" opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 153 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><polygon points="8,10 11,4 16,8 21,4 24,10" fill="#fbbf24"/><rect x="8" y="10" width="16" height="3" rx="1" fill="#f59e0b"/></svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M14,22 Q12,18 16,16 Q20,18 18,22 Q20,20 18,26 Q16,28 14,26 Q12,24 14,22Z" fill="#f59e0b" opacity="0.8"/></svg>

After

Width:  |  Height:  |  Size: 180 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="11" cy="13" r="3" fill="none" stroke="#a78bfa" stroke-width="1.5"/><circle cx="21" cy="13" r="3" fill="none" stroke="#a78bfa" stroke-width="1.5"/><line x1="14" y1="13" x2="18" y2="13" stroke="#a78bfa" stroke-width="1"/></svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M8,12 Q16,18 24,12 Q24,16 16,17 Q8,16 8,12Z" fill="#34d399" opacity="0.7"/></svg>

After

Width:  |  Height:  |  Size: 151 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><g fill="#fbbf24" opacity="0.6"><circle cx="6" cy="8" r="1.2"/><circle cx="26" cy="6" r="1"/><circle cx="4" cy="20" r="0.8"/><circle cx="28" cy="22" r="1.2"/><circle cx="10" cy="28" r="1"/><circle cx="22" cy="30" r="0.8"/><circle cx="16" cy="2" r="1"/><circle cx="2" cy="14" r="0.8"/><circle cx="30" cy="14" r="1"/></g></svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M12,22 L16,20 L20,22 L18,26 L16,24 L14,26Z" fill="#ec4899"/><circle cx="16" cy="21" r="2" fill="#f472b6"/></svg>

After

Width:  |  Height:  |  Size: 182 B

View File

@@ -5,10 +5,7 @@ import Navbar from "./components/navbar";
import Footer from "./components/footer"; import Footer from "./components/footer";
import { GameTick } from "./components/GameTick"; import { GameTick } from "./components/GameTick";
import { GameSync } from "./components/GameSync"; import { GameSync } from "./components/GameSync";
import { OfflineReport } from "./components/OfflineReport";
import "./scss/root.scss";
import "./scss/zones.scss";
import "./scss/components/footer.scss";
import navData from "./data/NavBarData.json"; import navData from "./data/NavBarData.json";
@@ -19,6 +16,7 @@ function App() {
<> <>
<GameTick /> <GameTick />
<GameSync /> <GameSync />
<OfflineReport />
<Navbar <Navbar
navData={navData} navData={navData}
toggleRain={toggleRain} toggleRain={toggleRain}

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { postCapstoneCost, treeResetCost } from "../core/balance";
describe("postCapstoneCost", () => {
it("first purchase = base cost (no multiplier)", () => {
expect(postCapstoneCost(500, 0)).toBe(500);
});
it("applies ×1.5 for purchases 1-5", () => {
expect(postCapstoneCost(500, 1)).toBe(750);
expect(postCapstoneCost(500, 2)).toBe(Math.floor(500 * 1.5 * 1.5));
});
it("uses ×1.8 tier for purchases 5-9", () => {
const at5 = postCapstoneCost(500, 5);
const at6 = postCapstoneCost(500, 6);
// Ratio should be ~1.8 (floor rounding tolerance ±1)
expect(at6 / at5).toBeCloseTo(1.8, 1);
});
it("uses ×2.0 tier for purchases 10+", () => {
const at10 = postCapstoneCost(500, 10);
const at11 = postCapstoneCost(500, 11);
expect(at11 / at10).toBeCloseTo(2.0, 1);
});
it("cost always increases", () => {
let prev = 0;
for (let i = 0; i < 15; i++) {
const cost = postCapstoneCost(500, i);
expect(cost).toBeGreaterThan(prev);
prev = cost;
}
});
});
describe("treeResetCost", () => {
it("free reset costs 0", () => {
expect(treeResetCost(true, 0)).toBe(0);
expect(treeResetCost(true, 5)).toBe(0);
});
it("first paid reset costs 5 ADN", () => {
expect(treeResetCost(false, 0)).toBe(5);
});
it("scales linearly", () => {
expect(treeResetCost(false, 0)).toBe(5);
expect(treeResetCost(false, 1)).toBe(10);
expect(treeResetCost(false, 2)).toBe(15);
expect(treeResetCost(false, 3)).toBe(20);
});
});

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import {
COSMETICS,
shouldUnlockCosmetic,
computeNewUnlocks,
equipCosmetic,
unequipSlot,
addToInventory,
DEFAULT_COSMETIC_STATE,
} from "../core/cosmetics";
import { DEFAULT_STATE } from "../core/economy";
describe("Cosmetics system", () => {
describe("COSMETICS catalog", () => {
it("a 10 cosmétiques", () => {
expect(COSMETICS.length).toBe(10);
});
it("2 cosmétiques par slot", () => {
const slots = ["hat", "eyes", "body", "tail", "accessory"];
for (const slot of slots) {
expect(COSMETICS.filter((c) => c.slot === slot).length).toBe(2);
}
});
it("ids uniques", () => {
const ids = COSMETICS.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("shouldUnlockCosmetic", () => {
it("unlock prestige_3 si prestigeCount >= 3", () => {
const cos = COSMETICS.find((c) => c.sourceId === "prestige_3")!;
const state = { ...DEFAULT_STATE, prestigeCount: 3 };
expect(shouldUnlockCosmetic(cos, state)).toBe(true);
});
it("pas d'unlock prestige_10 si prestigeCount < 10", () => {
const cos = COSMETICS.find((c) => c.sourceId === "prestige_10")!;
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
expect(shouldUnlockCosmetic(cos, state)).toBe(false);
});
it("unlock achievement 'first_prestige' si prestigeCount >= 1", () => {
const cos = COSMETICS.find((c) => c.sourceId === "first_prestige")!;
const state = { ...DEFAULT_STATE, prestigeCount: 1 };
expect(shouldUnlockCosmetic(cos, state)).toBe(true);
});
it("pas d'unlock achievement si condition non remplie", () => {
const cos = COSMETICS.find((c) => c.sourceId === "empire")!;
expect(shouldUnlockCosmetic(cos, DEFAULT_STATE)).toBe(false);
});
});
describe("computeNewUnlocks", () => {
it("retourne les cosmétiques nouvellement débloqués", () => {
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
const newUnlocks = computeNewUnlocks(state, DEFAULT_COSMETIC_STATE);
// prestige_3 (particles_gold) + prestige_5 (glasses_savant) + first_prestige (ribbon) + veteran (aura_swamp)
expect(newUnlocks).toContain("particles_gold");
expect(newUnlocks).toContain("glasses_savant");
expect(newUnlocks).toContain("ribbon");
});
it("ne retourne pas les cosmétiques déjà dans l'inventaire", () => {
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
const cosState = { ...DEFAULT_COSMETIC_STATE, inventory: ["particles_gold"] };
const newUnlocks = computeNewUnlocks(state, cosState);
expect(newUnlocks).not.toContain("particles_gold");
});
it("retourne vide si rien à débloquer", () => {
expect(computeNewUnlocks(DEFAULT_STATE, DEFAULT_COSMETIC_STATE)).toEqual([]);
});
});
describe("equipCosmetic", () => {
it("équipe un cosmétique dans le bon slot", () => {
const cosState = { inventory: ["ribbon"], equipped: {} };
const result = equipCosmetic(cosState, "ribbon");
expect(result.equipped.tail).toBe("ribbon");
});
it("ne fait rien si cosmétique pas dans l'inventaire", () => {
const result = equipCosmetic(DEFAULT_COSMETIC_STATE, "ribbon");
expect(result.equipped).toEqual({});
});
it("remplace le cosmétique déjà équipé dans le même slot", () => {
const cosState = { inventory: ["ribbon", "flame_tail"], equipped: { tail: "ribbon" } };
const result = equipCosmetic(cosState, "flame_tail");
expect(result.equipped.tail).toBe("flame_tail");
});
});
describe("unequipSlot", () => {
it("retire le cosmétique du slot", () => {
const cosState = { inventory: ["ribbon"], equipped: { tail: "ribbon" } };
const result = unequipSlot(cosState, "tail");
expect(result.equipped.tail).toBeUndefined();
});
it("ne touche pas les autres slots", () => {
const cosState = {
inventory: ["ribbon", "crown"],
equipped: { tail: "ribbon", hat: "crown" },
};
const result = unequipSlot(cosState, "tail");
expect(result.equipped.hat).toBe("crown");
});
});
describe("addToInventory", () => {
it("ajoute des ids", () => {
const result = addToInventory(DEFAULT_COSMETIC_STATE, ["ribbon", "crown"]);
expect(result.inventory).toEqual(["ribbon", "crown"]);
});
it("pas de doublons", () => {
const cosState = { ...DEFAULT_COSMETIC_STATE, inventory: ["ribbon"] };
const result = addToInventory(cosState, ["ribbon", "crown"]);
expect(result.inventory).toEqual(["ribbon", "crown"]);
});
});
});

View File

@@ -7,12 +7,19 @@ import {
buyGenerator, buyGenerator,
applyPrestige, applyPrestige,
canPrestige, canPrestige,
getPrestigeThreshold,
computePrestigeDna, computePrestigeDna,
canBuyEvolutionNode, canBuyEvolutionNode,
buyEvolutionNode, buyEvolutionNode,
resetEvolutionTree,
getClickMultiplierFromTree, getClickMultiplierFromTree,
getProductionMultiplierFromTree, getProductionMultiplierFromTree,
getStartBonusFromTree, getStartBonusFromTree,
getPrestigeDnaBonus,
getCostReduction,
getAutoClicksPerSecond,
offlineEfficiency,
computeOfflineGains,
DEFAULT_STATE, DEFAULT_STATE,
DEFAULT_GENERATORS, DEFAULT_GENERATORS,
DEFAULT_EVOLUTION_TREE, DEFAULT_EVOLUTION_TREE,
@@ -167,13 +174,15 @@ describe("computeIdleGains (lazy calculation)", () => {
}); });
}); });
// --- Click --- // --- Click (avec double + crit) ---
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, 0.99); // rng high → no double, no crit
expect(result.resources).toBe(6); expect(result.state.resources).toBe(6);
expect(result.isDouble).toBe(false);
expect(result.isCrit).toBe(false);
}); });
it("applique le multiplicateur click de l'arbre", () => { it("applique le multiplicateur click de l'arbre", () => {
@@ -185,14 +194,58 @@ describe("applyClick", () => {
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
), ),
}; };
const result = applyClick(state); const result = applyClick(state, 0.99);
expect(result.resources).toBe(2); // ×2 depuis Ponte Améliorée expect(result.state.resources).toBe(2);
}); });
it("incrémente lifetimeTadpoles", () => { it("incrémente lifetimeTadpoles", () => {
const state = { ...DEFAULT_STATE, clickMultiplier: 5, prestigeMultiplier: 1 }; const state = { ...DEFAULT_STATE, clickMultiplier: 5, prestigeMultiplier: 1 };
const result = applyClick(state); const result = applyClick(state, 0.99);
expect(result.lifetimeTadpoles).toBe(5); expect(result.state.lifetimeTadpoles).toBe(5);
});
it("double ponte x2 quand rng < doubleClickChance", () => {
const state = {
...DEFAULT_STATE,
clickMultiplier: 1,
prestigeMultiplier: 1,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "double_ponte" ? { ...n, unlocked: true } : n
),
};
// double_ponte = 10% chance, rng=0.05 < 0.10 → double
const result = applyClick(state, 0.05);
expect(result.isDouble).toBe(true);
expect(result.gain).toBe(2);
});
it("pas de double ponte quand rng > doubleClickChance", () => {
const state = {
...DEFAULT_STATE,
clickMultiplier: 1,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "double_ponte" ? { ...n, unlocked: true } : n
),
};
const result = applyClick(state, 0.50);
expect(result.isDouble).toBe(false);
expect(result.gain).toBe(1);
});
it("crit x10 quand critRng < critClickChance", () => {
const state = {
...DEFAULT_STATE,
clickMultiplier: 1,
prestigeMultiplier: 1,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "ponte_critique" ? { ...n, unlocked: true } : n
),
};
// ponte_critique = 5% chance, need critRng = (rng * 7.13) % 1 < 0.05
// rng = 0.007 → critRng = 0.04991 < 0.05 → crit!
const result = applyClick(state, 0.007);
expect(result.isCrit).toBe(true);
expect(result.gain).toBe(10);
}); });
}); });
@@ -246,49 +299,68 @@ describe("computePrestigeDna", () => {
expect(computePrestigeDna(0)).toBe(0); expect(computePrestigeDna(0)).toBe(0);
}); });
it("retourne 150 pour 1e9 têtards (sqrt(1) = 1)", () => { it("retourne 0 sous le seuil de 1M", () => {
expect(computePrestigeDna(1e9)).toBe(150); expect(computePrestigeDna(999_999)).toBe(0);
}); });
it("retourne 212 pour 2e9 têtards (sqrt(2) ≈ 1.414)", () => { it("retourne 1 (clamp) à exactement 1M têtards", () => {
expect(computePrestigeDna(2e9)).toBe(Math.floor(150 * Math.sqrt(2))); expect(computePrestigeDna(1e6)).toBe(1);
}); });
it("scaling sub-linéaire — 10× têtards ≠ 10× ADN", () => { it("retourne 50 pour 10M têtards (log10(10) = 1)", () => {
const dna1 = computePrestigeDna(1e9); expect(computePrestigeDna(10e6)).toBe(50);
const dna10 = computePrestigeDna(10e9); });
expect(dna10 / dna1).toBeCloseTo(Math.sqrt(10), 1);
it("scaling log — 10M→100M donne 2× ADN (log10(100) = 2)", () => {
const dna10m = computePrestigeDna(10e6);
const dna100m = computePrestigeDna(100e6);
expect(dna100m / dna10m).toBeCloseTo(2, 1);
});
it("prestige bonus augmente le gain (+5% par prestige)", () => {
const base = computePrestigeDna(10e6, 0);
const with10 = computePrestigeDna(10e6, 10);
expect(with10).toBe(Math.max(1, Math.floor(50 * 1 * 1.5))); // 75
expect(with10).toBeGreaterThan(base);
});
it("prestige bonus cappé à ×4 (80+ prestiges)", () => {
const at80 = computePrestigeDna(10e6, 80);
const at100 = computePrestigeDna(10e6, 100);
expect(at80).toBe(at100); // cap atteint
}); });
}); });
// --- Arbre d'Évolution --- // --- Arbre d'Évolution 3 voies ---
describe("Evolution Tree (3 branches)", () => {
it("arbre V2 : 3 branches + cross (~30 nœuds)", () => {
const ponte = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "ponte");
const marais = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "marais");
const adaptation = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "adaptation");
const cross = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "cross");
expect(ponte.length).toBe(8);
expect(marais.length).toBe(8);
expect(adaptation.length).toBe(8);
expect(cross.length).toBe(1);
expect(DEFAULT_EVOLUTION_TREE.length).toBe(25);
});
describe("Evolution Tree", () => {
describe("canBuyEvolutionNode", () => { describe("canBuyEvolutionNode", () => {
it("peut acheter le premier nœud (pas de prérequis) avec assez d'ADN", () => { it("peut acheter un nœud racine avec assez d'ADN", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 5 }; const state = { ...DEFAULT_STATE, ancestralDna: 5 };
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(true); expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(true);
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(true);
expect(canBuyEvolutionNode(state, "memoire_genetique")).toBe(true);
}); });
it("ne peut pas acheter sans assez d'ADN", () => { it("ne peut pas acheter sans assez d'ADN", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 0 }; expect(canBuyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBe(false);
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é", () => { it("ne peut pas acheter un nœud dont le prérequis n'est pas débloqué", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 100 }; const state = { ...DEFAULT_STATE, ancestralDna: 100 };
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(false); expect(canBuyEvolutionNode(state, "double_ponte")).toBe(false);
}); });
it("peut acheter un nœud si le prérequis est débloqué", () => { it("peut acheter un nœud si le prérequis est débloqué", () => {
@@ -299,7 +371,32 @@ describe("Evolution Tree", () => {
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
), ),
}; };
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(true); expect(canBuyEvolutionNode(state, "double_ponte")).toBe(true);
});
it("ne peut pas acheter un nœud exclusif si l'alternative est débloquée", () => {
const state = {
...DEFAULT_STATE,
ancestralDna: 100,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "double_ponte" || n.id === "ponte_amelioree" ? { ...n, unlocked: true } :
n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n
),
};
// concentration exclusive_with ponte_frenetique → locked
expect(canBuyEvolutionNode(state, "concentration")).toBe(false);
});
it("peut acheter un nœud exclusif si l'alternative n'est pas débloquée", () => {
const state = {
...DEFAULT_STATE,
ancestralDna: 100,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "double_ponte" || n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
),
};
expect(canBuyEvolutionNode(state, "concentration")).toBe(true);
expect(canBuyEvolutionNode(state, "ponte_frenetique")).toBe(true);
}); });
}); });
@@ -313,15 +410,32 @@ describe("Evolution Tree", () => {
}); });
it("retourne null si impossible", () => { it("retourne null si impossible", () => {
const result = buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree"); expect(buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBeNull();
expect(result).toBeNull(); });
});
describe("resetEvolutionTree", () => {
it("rembourse tout l'ADN dépensé et relock tous les nœuds", () => {
const state = {
...DEFAULT_STATE,
ancestralDna: 50,
prestigeCount: 1,
freeResetAvailable: true,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "ponte_amelioree" || n.id === "instinct_gregaire"
? { ...n, unlocked: true }
: n
),
};
// ponte_amelioree (1) + instinct_gregaire (3) = 4 ADN spent, free reset
const result = resetEvolutionTree(state);
expect(result.ancestralDna).toBe(54);
expect(result.evolutionTree.every((n) => !n.unlocked)).toBe(true);
}); });
it("ne modifie pas les autres nœuds", () => { it("ne change rien si aucun nœud débloqué", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 5 }; const result = resetEvolutionTree({ ...DEFAULT_STATE, ancestralDna: 10 });
const result = buyEvolutionNode(state, "ponte_amelioree")!; expect(result.ancestralDna).toBe(10);
const otherNodes = result.evolutionTree.filter((n) => n.id !== "ponte_amelioree");
expect(otherNodes.every((n) => n.unlocked === false)).toBe(true);
}); });
}); });
@@ -336,6 +450,13 @@ describe("Evolution Tree", () => {
); );
expect(getClickMultiplierFromTree(tree)).toBe(2); expect(getClickMultiplierFromTree(tree)).toBe(2);
}); });
it("multiplie si plusieurs nœuds click débloqués (2 × 3 = 6)", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "ponte_amelioree" || n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n
);
expect(getClickMultiplierFromTree(tree)).toBe(6);
});
}); });
describe("getProductionMultiplierFromTree", () => { describe("getProductionMultiplierFromTree", () => {
@@ -363,4 +484,168 @@ describe("Evolution Tree", () => {
expect(getStartBonusFromTree(tree)).toBe(100); expect(getStartBonusFromTree(tree)).toBe(100);
}); });
}); });
describe("prestige_dna_bonus", () => {
it("ADN Renforcé + Héritage = +75% ADN", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "adn_renforce" || n.id === "heritage" ? { ...n, unlocked: true } : n
);
expect(getPrestigeDnaBonus(tree)).toBeCloseTo(0.75);
});
});
describe("cost_reduction", () => {
it("Marée Haute = -20% coût générateurs", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "maree_haute" ? { ...n, unlocked: true } : n
);
expect(getCostReduction(tree)).toBeCloseTo(0.20);
});
it("coût réduit appliqué via generatorCost", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "maree_haute" ? { ...n, unlocked: true } : n
);
const gen = { ...DEFAULT_GENERATORS[0], owned: 0 };
const baseCost = generatorCost(gen);
const reducedCost = generatorCost(gen, tree);
expect(reducedCost).toBe(Math.floor(baseCost * 0.8));
});
});
describe("prestige reset generators", () => {
it("prestige remet les générateurs à 0", () => {
const state = {
...DEFAULT_STATE,
resources: 2_000_000,
lifetimeTadpoles: 2_000_000,
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })),
};
const result = applyPrestige(state);
expect(result.generators.every((g) => g.owned === 0)).toBe(true);
});
});
describe("auto_click (getAutoClicksPerSecond)", () => {
it("retourne 0 si capstone ponte non débloqué", () => {
expect(getAutoClicksPerSecond(DEFAULT_EVOLUTION_TREE)).toBe(0);
});
it("retourne 1 si capstone Ponte Automatique débloqué", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "ponte_auto" ? { ...n, unlocked: true } : n
);
expect(getAutoClicksPerSecond(tree)).toBe(1);
});
});
describe("prestige threshold reduction", () => {
it("Transcendance réduit le seuil de 50%", () => {
const state = {
...DEFAULT_STATE,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "transcendance" ? { ...n, unlocked: true } : n
),
};
expect(getPrestigeThreshold(state)).toBe(500_000);
});
it("canPrestige utilise le seuil réduit", () => {
const state = {
...DEFAULT_STATE,
resources: 600_000,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "transcendance" ? { ...n, unlocked: true } : n
),
};
expect(canPrestige(state)).toBe(true);
});
});
});
// --- Offline gains (courbe inversée) ---
describe("offlineEfficiency", () => {
it("retourne 1.0 pour absence < 60s (pas offline)", () => {
expect(offlineEfficiency(30_000)).toBe(1);
});
it("retourne 1.0 pour absence de 10min (phase 100%)", () => {
expect(offlineEfficiency(10 * 60_000)).toBe(1);
});
it("retourne 1.0 à exactement 15min", () => {
expect(offlineEfficiency(15 * 60_000)).toBe(1);
});
it("retourne ~0.625 à 30min (milieu decay 1.0→0.25)", () => {
const eff = offlineEfficiency(30 * 60_000);
// 30min = 15min dans la phase decay (15min-1h = 45min total)
// t = 15/45 = 0.333 → eff = 1 - 0.333 * 0.75 = 0.75
expect(eff).toBeCloseTo(0.75, 1);
});
it("retourne 0.25 à exactement 1h (fin du decay)", () => {
expect(offlineEfficiency(60 * 60_000)).toBeCloseTo(0.25);
});
it("retourne ~0.125 à 1h30 (milieu 0.25→0)", () => {
const eff = offlineEfficiency(90 * 60_000);
expect(eff).toBeCloseTo(0.125, 1);
});
it("retourne 0 à exactement 2h", () => {
expect(offlineEfficiency(2 * 60 * 60_000)).toBe(0);
});
it("retourne 0 après 2h (cap)", () => {
expect(offlineEfficiency(5 * 60 * 60_000)).toBe(0);
});
it("courbe monotone décroissante", () => {
const points = [0, 10, 15, 30, 45, 60, 90, 120, 180].map(
(min) => offlineEfficiency(min * 60_000)
);
for (let i = 1; i < points.length; i++) {
expect(points[i]).toBeLessThanOrEqual(points[i - 1]);
}
});
});
describe("computeOfflineGains", () => {
const stateWithProd = {
...DEFAULT_STATE,
generators: DEFAULT_STATE.generators.map((g, i) =>
i === 0 ? { ...g, owned: 10 } : g
),
lastTick: 0,
lastOnline: 0,
};
const pps = DEFAULT_GENERATORS[0].baseProduction * 10; // 1/s
it("gains normaux si absence < 60s", () => {
const gains = computeOfflineGains(stateWithProd, 30_000);
// < threshold → computeIdleGains classique
expect(gains).toBeCloseTo(pps * 30);
});
it("gains < idle pur pour absence de 1h", () => {
const gains = computeOfflineGains(stateWithProd, 60 * 60_000);
const fullIdleGains = pps * 3600;
expect(gains).toBeLessThan(fullIdleGains);
expect(gains).toBeGreaterThan(0);
});
it("gains = 0 pour absence > 2h si prod constante", () => {
// > 2h : tout tombe à 0%, mais les premières 2h produisent encore
const gains = computeOfflineGains(stateWithProd, 3 * 60 * 60_000);
const gainsAt2h = computeOfflineGains(stateWithProd, 2 * 60 * 60_000);
// gains at 3h should equal gains at 2h (nothing added after 2h)
expect(gains).toBeCloseTo(gainsAt2h, 0);
});
it("retourne 0 si aucune production", () => {
const gains = computeOfflineGains({ ...DEFAULT_STATE, lastTick: 0 }, 60 * 60_000);
expect(gains).toBe(0);
});
}); });

View File

@@ -0,0 +1,123 @@
import { describe, it, expect } from "vitest";
import { migrateSave } from "../core/migrateSave";
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "../core/economy";
import { CURRENT_SAVE_VERSION } from "../core/balance";
// Minimal Sprint 2 save (v1 — no saveVersion field)
function makeV1Save(overrides: Record<string, unknown> = {}) {
return {
resources: 1234,
clickMultiplier: 1,
generators: DEFAULT_GENERATORS.map((g) => ({ ...g, owned: 5 })),
lastTick: Date.now() - 60_000,
lastOnline: Date.now() - 60_000,
prestigeCount: 3,
prestigeMultiplier: 1.3,
ancestralDna: 42,
evolutionTree: DEFAULT_EVOLUTION_TREE.slice(0, 18).map((n, i) => ({
...n,
unlocked: i < 2, // first 2 nodes unlocked
})),
lifetimeTadpoles: 5_000_000,
cosmeticInventory: ["hat_lily"],
cosmeticEquipped: { hat: "hat_lily" },
...overrides,
};
}
describe("migrateSave", () => {
describe("v1 → v2", () => {
it("sets saveVersion to current", () => {
const result = migrateSave(makeV1Save());
expect(result.saveVersion).toBe(CURRENT_SAVE_VERSION);
});
it("adds runStats with defaults", () => {
const result = migrateSave(makeV1Save());
expect(result.runStats).toBeDefined();
expect(result.runStats.tadpolesProduced).toBe(0);
expect(result.runStats.bestRun).toBeNull();
});
it("adds freeResetAvailable and extraResetsUsed", () => {
const result = migrateSave(makeV1Save());
expect(result.freeResetAvailable).toBe(true);
expect(result.extraResetsUsed).toBe(0);
});
it("preserves unlocked state of existing tree nodes", () => {
const result = migrateSave(makeV1Save());
const node0 = result.evolutionTree.find((n) => n.id === "ponte_amelioree");
const node2 = result.evolutionTree.find((n) => n.id === "ponte_frenetique");
expect(node0?.unlocked).toBe(true);
expect(node2?.unlocked).toBe(false);
});
it("preserves generator owned counts", () => {
const result = migrateSave(makeV1Save());
expect(result.generators[0].owned).toBe(5);
});
it("preserves resources, ancestralDna, prestigeCount", () => {
const result = migrateSave(makeV1Save());
expect(result.resources).toBe(1234);
expect(result.ancestralDna).toBe(42);
expect(result.prestigeCount).toBe(3);
});
it("preserves cosmetics", () => {
const result = migrateSave(makeV1Save());
expect(result.cosmeticInventory).toContain("hat_lily");
expect(result.cosmeticEquipped.hat).toBe("hat_lily");
});
});
describe("backfill missing fields", () => {
it("backfills lastOnline from lastTick", () => {
const save = makeV1Save();
delete (save as Record<string, unknown>).lastOnline;
const result = migrateSave(save);
expect(result.lastOnline).toBe(save.lastTick);
});
it("backfills empty cosmeticInventory", () => {
const save = makeV1Save();
delete (save as Record<string, unknown>).cosmeticInventory;
const result = migrateSave(save);
expect(result.cosmeticInventory).toEqual([]);
});
it("backfills empty cosmeticEquipped", () => {
const save = makeV1Save();
delete (save as Record<string, unknown>).cosmeticEquipped;
const result = migrateSave(save);
expect(result.cosmeticEquipped).toEqual({});
});
});
describe("v2 passthrough", () => {
it("does not re-migrate a v2 save", () => {
const v2 = migrateSave(makeV1Save());
const result = migrateSave(v2 as unknown as Record<string, unknown>);
expect(result.saveVersion).toBe(CURRENT_SAVE_VERSION);
expect(result).toEqual(v2);
});
});
describe("edge cases", () => {
it("handles save with no evolutionTree (corrupted)", () => {
const save = makeV1Save();
delete (save as Record<string, unknown>).evolutionTree;
const result = migrateSave(save);
expect(result.evolutionTree.length).toBe(DEFAULT_EVOLUTION_TREE.length);
});
it("handles save with no generators (corrupted)", () => {
const save = makeV1Save();
delete (save as Record<string, unknown>).generators;
const result = migrateSave(save);
expect(result.generators.length).toBe(DEFAULT_GENERATORS.length);
expect(result.generators[0].owned).toBe(0);
});
});
});

View File

@@ -0,0 +1,101 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_STATE,
getClaimableMilestones,
getNextMilestone,
claimMilestone,
getMilestoneStartNid,
getMilestoneOfflineBonus,
} from "../core/economy";
describe("Prestige Milestones", () => {
it("no claimable milestones at 0 prestiges", () => {
expect(getClaimableMilestones(DEFAULT_STATE)).toEqual([]);
});
it("milestone_1 claimable at 1 prestige", () => {
const state = { ...DEFAULT_STATE, prestigeCount: 1 };
const claimable = getClaimableMilestones(state);
expect(claimable.length).toBe(1);
expect(claimable[0].id).toBe("milestone_1");
});
it("multiple milestones claimable at 5 prestiges", () => {
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
const claimable = getClaimableMilestones(state);
expect(claimable.length).toBe(3); // 1, 3, 5
});
it("already claimed milestones not returned", () => {
const state = {
...DEFAULT_STATE,
prestigeCount: 5,
claimedMilestones: ["milestone_1", "milestone_3"],
};
const claimable = getClaimableMilestones(state);
expect(claimable.length).toBe(1);
expect(claimable[0].id).toBe("milestone_5");
});
it("getNextMilestone returns first unachieved", () => {
const state = { ...DEFAULT_STATE, prestigeCount: 2 };
const next = getNextMilestone(state);
expect(next?.id).toBe("milestone_3");
expect(next?.threshold).toBe(3);
});
it("getNextMilestone returns null when all achieved", () => {
const state = { ...DEFAULT_STATE, prestigeCount: 200 };
expect(getNextMilestone(state)).toBeNull();
});
describe("claimMilestone", () => {
it("claims successfully and adds to claimedMilestones", () => {
const state = { ...DEFAULT_STATE, prestigeCount: 1 };
const result = claimMilestone(state, "milestone_1");
expect(result).not.toBeNull();
expect(result!.claimedMilestones).toContain("milestone_1");
});
it("cosmetic reward adds to inventory", () => {
const state = { ...DEFAULT_STATE, prestigeCount: 1 };
const result = claimMilestone(state, "milestone_1");
expect(result!.cosmeticInventory).toContain("ribbon");
});
it("cannot claim milestone not yet reached", () => {
const result = claimMilestone(DEFAULT_STATE, "milestone_1");
expect(result).toBeNull();
});
it("cannot claim already claimed milestone", () => {
const state = {
...DEFAULT_STATE,
prestigeCount: 1,
claimedMilestones: ["milestone_1"],
};
const result = claimMilestone(state, "milestone_1");
expect(result).toBeNull();
});
});
describe("milestone bonuses", () => {
it("getMilestoneStartNid returns 0 without milestone_5", () => {
expect(getMilestoneStartNid(DEFAULT_STATE)).toBe(0);
});
it("getMilestoneStartNid returns 1 with milestone_5 claimed", () => {
const state = { ...DEFAULT_STATE, claimedMilestones: ["milestone_5"] };
expect(getMilestoneStartNid(state)).toBe(1);
});
it("getMilestoneOfflineBonus returns 0 without milestone_15", () => {
expect(getMilestoneOfflineBonus(DEFAULT_STATE)).toBe(0);
});
it("getMilestoneOfflineBonus returns 0.05 with milestone_15 claimed", () => {
const state = { ...DEFAULT_STATE, claimedMilestones: ["milestone_15"] };
expect(getMilestoneOfflineBonus(state)).toBe(0.05);
});
});
});

View File

@@ -0,0 +1,68 @@
// CosmeticsPanel.tsx — Inventaire cosmétique dans la sidebar
import { useGameStore } from "../store/useGameStore";
import { COSMETICS, type CosmeticSlot } from "../core/cosmetics";
const SLOT_LABELS: Record<CosmeticSlot, string> = {
hat: "Tête",
eyes: "Yeux",
body: "Corps",
tail: "Queue",
accessory: "Aura",
};
const SLOT_ORDER: CosmeticSlot[] = ["hat", "eyes", "body", "tail", "accessory"];
export function CosmeticsPanel() {
const inventory = useGameStore((s) => s.state.cosmeticInventory);
const equipped = useGameStore((s) => s.state.cosmeticEquipped);
const equip = useGameStore((s) => s.equipCosmetic);
const unequip = useGameStore((s) => s.unequipCosmetic);
if (inventory.length === 0) return null;
const ownedCosmetics = COSMETICS.filter((c) => inventory.includes(c.id));
return (
<div className="gp">
<div className="flex justify-between items-center">
<span className="gp-title">Cosmétiques</span>
<span className="gp-label">{inventory.length}/{COSMETICS.length}</span>
</div>
{SLOT_ORDER.map((slot) => {
const slotCosmetics = ownedCosmetics.filter((c) => c.slot === slot);
if (slotCosmetics.length === 0) return null;
const equippedId = equipped[slot];
return (
<div key={slot} className="flex flex-col gap-0.5">
<span className="gp-zone-label">{SLOT_LABELS[slot]}</span>
{slotCosmetics.map((cos) => {
const isEquipped = equippedId === cos.id;
return (
<div
key={cos.id}
className={`gp-row ${isEquipped ? "gp-row--unlocked" : "gp-row--active"}`}
>
<div className="flex flex-col min-w-0">
<span className="gp-value text-[0.7rem]!">{cos.name}</span>
<span className="gp-label">{cos.description}</span>
</div>
<button
onClick={() => isEquipped ? unequip(slot) : equip(cos.id)}
className={`gp-btn ${isEquipped ? "gp-btn--disabled" : "gp-btn--buy"}`}
>
{isEquipped ? "Retirer" : "Équiper"}
</button>
</div>
);
})}
</div>
);
})}
</div>
);
}

View File

@@ -1,75 +1,265 @@
// EvolutionTree.tsx — Arbre d'Évolution permanent (jamais reset) // EvolutionTree.tsx — Arbre d'Évolution V2 (Sprint 3)
// 3 branches + capstones + post-capstone repeatables + Convergence évolutif
import { useGameStore } from "../store/useGameStore"; import { useGameStore } from "../store/useGameStore";
import { canBuyEvolutionNode } from "../core/economy"; import {
import type { EvolutionNode } from "../core/economy"; canBuyEvolutionNode,
getSpentDna,
getTreeResetCost,
canResetTree,
getRepeatableCost,
canUpgradeConvergence,
} from "../core/economy";
import type { EvolutionNode, Branch } from "../core/economy";
import { formatNumber } from "../utils/formatNumber";
const EFFECT_LABELS: Record<string, (v: number) => string> = { const EFFECT_LABELS: Record<string, (v: number, n?: EvolutionNode) => string> = {
click_multiplier: (v) => `x${v} ponte`, click_multiplier: (v) => `x${v} ponte`,
production_multiplier: (v) => `x${v} production`, production_multiplier: (v) => `x${v} production`,
start_bonus: (v) => `+${v} têtards au départ`, start_bonus: (v) => `+${v} tetards au depart`,
unlock_generator: () => `Lac Mystique dès le début`, unlock_generator: () => `Lac Mystique des le debut`,
achievement_scaling: (v) => `+${(v * 100).toFixed(0)}% prod/succès`, double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
auto_click: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `${v} auto-ponte/s`,
auto_click_scaling: (v) => `${v} auto-ponte/s (scale)`,
crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`,
generator_boost: (v) => `x${v} Nid`,
generator_synergy: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% par type`,
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% cout generateurs`,
prestige_dna_bonus: (v) => `+${(v * 100).toFixed(0)}% ADN prestige`,
offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`,
offline_cap_boost: (v) => `Offline cap → ${(v * 100).toFixed(0)}%, duree 8h`,
prestige_threshold_reduction: (v) => `Prestige a ${((1 - v) * 100).toFixed(0)}% du seuil`,
all_effects_boost: (v) => `+${(v * 100).toFixed(0)}% tous effets`,
post_capstone_discount: (v) => `-${(v * 100).toFixed(0)}% cout post-capstones`,
};
const BRANCH_CONFIG: Record<Branch | "cross", { label: string; color: string; accent: string }> = {
ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" },
marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" },
adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" },
cross: { label: "Convergence", color: "border-purple-500/30", accent: "gp-accent-purple" },
}; };
function NodeRow({ function NodeRow({
node, node,
canBuy, canBuy,
isExcluded,
onBuy, onBuy,
}: { }: {
node: EvolutionNode; node: EvolutionNode;
canBuy: boolean; canBuy: boolean;
isExcluded: boolean;
onBuy: () => void; onBuy: () => void;
}) { }) {
const isCapstone = node.capstone;
const isRepeatable = node.repeatable;
const purchased = node.purchased ?? 0;
const rowClass = node.unlocked const rowClass = node.unlocked
? "gp-row gp-row--unlocked" ? isCapstone
? "gp-row gp-row--unlocked border-amber-400/40!"
: "gp-row gp-row--unlocked"
: isExcluded
? "gp-row gp-row--locked opacity-30!"
: canBuy : canBuy
? "gp-row gp-row--evolution" ? isCapstone
? "gp-row gp-row--evolution border-amber-400/30!"
: "gp-row gp-row--evolution"
: "gp-row gp-row--locked"; : "gp-row gp-row--locked";
const cost = isRepeatable && node.unlocked
? getRepeatableCost(node)
: isRepeatable
? node.cost
: node.cost;
return ( return (
<div className={rowClass}> <div className={rowClass}>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}> <div className="flex flex-col min-w-0">
<span className="gp-value">{node.name}</span> <div className="flex items-center gap-1">
<span className="gp-label">{EFFECT_LABELS[node.effect](node.value)}</span> {isCapstone && <span className="text-amber-400 text-[0.6rem]"></span>}
<span className="gp-value text-[0.7rem]!">{node.name}</span>
{isRepeatable && node.unlocked && (
<span className="gp-label text-[0.55rem]!">x{purchased}</span>
)}
{node.exclusive_with && !node.unlocked && !isExcluded && (
<span className="gp-label text-[0.55rem]!">OU</span>
)}
</div>
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
</div> </div>
{node.unlocked ? ( {node.unlocked && !isRepeatable ? (
<span className="gp-label gp-accent-green">OK</span> <span className="gp-label gp-accent-green">OK</span>
) : node.unlocked && isRepeatable ? (
<button
disabled={!canBuy}
onClick={onBuy}
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
>
{formatNumber(cost)}
</button>
) : isExcluded ? (
<span className="gp-label text-[0.55rem]!">verrouille</span>
) : ( ) : (
<button <button
disabled={!canBuy} disabled={!canBuy}
onClick={onBuy} onClick={onBuy}
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`} className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
style={canBuy ? { background: "#d97706" } : {}}
> >
{node.cost} ADN {formatNumber(cost)}
</button> </button>
)} )}
</div> </div>
); );
} }
function BranchColumn({ branch }: { branch: Branch }) {
const state = useGameStore((s) => s.state);
const buyNode = useGameStore((s) => s.buyNode);
const nodes = state.evolutionTree.filter((n) => n.branch === branch);
const config = BRANCH_CONFIG[branch];
return (
<div className={`gp flex-1 min-w-0 border-t-2 ${config.color}`}>
<span className={`gp-title text-center ${config.accent}`}>{config.label}</span>
{nodes.map((node) => {
const isExcluded = node.exclusive_with
? state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false
: false;
return (
<NodeRow
key={node.id}
node={node}
canBuy={canBuyEvolutionNode(state, node.id)}
isExcluded={isExcluded}
onBuy={() => buyNode(node.id)}
/>
);
})}
</div>
);
}
function ConvergenceSection() {
const state = useGameStore((s) => s.state);
const buyNode = useGameStore((s) => s.buyNode);
const upgradeConv = useGameStore((s) => s.upgradeConvergenceNode);
const conv = state.evolutionTree.find((n) => n.id === "convergence");
if (!conv) return null;
const canBuy = canBuyEvolutionNode(state, "convergence");
const canUpgrade = canUpgradeConvergence(state);
const tier = conv.tier ?? 1;
const maxTier = conv.maxTier ?? 2;
const tierName = tier >= 2 ? "Omega" : "Alpha";
return (
<div className="gp border-t-2 border-purple-500/30">
<span className="gp-title text-center gp-accent-purple">
Convergence {conv.unlocked ? tierName : ""}
</span>
{conv.unlocked ? (
<div className="flex flex-col gap-1">
<div className="gp-row gp-row--unlocked border-purple-400/30!">
<div className="flex flex-col">
<span className="gp-value text-[0.7rem]!">
{tier >= 2 ? "Omega" : "Alpha"} (tier {tier}/{maxTier})
</span>
<span className="gp-label">
{tier >= 2
? "+10% tous effets + -20% cout post-capstones"
: "+10% a tous les effets de l'arbre"
}
</span>
</div>
<span className="gp-label gp-accent-green">OK</span>
</div>
{tier < maxTier && (
<button
disabled={!canUpgrade}
onClick={upgradeConv}
className={`gp-btn ${canUpgrade ? "gp-btn--buy" : "gp-btn--disabled"} w-full`}
>
{canUpgrade
? `Evoluer → Omega (${conv.tierUpgradeCost} ADN)`
: `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`
}
</button>
)}
</div>
) : (
<div className="gp-row gp-row--locked">
<div className="flex flex-col">
<span className="gp-value text-[0.7rem]!">Convergence Alpha</span>
<span className="gp-label">+10% a tous les effets de l'arbre</span>
<span className="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
</div>
<button
disabled={!canBuy}
onClick={() => buyNode("convergence")}
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
>
{conv.cost}
</button>
</div>
)}
</div>
);
}
export function EvolutionTree() { export function EvolutionTree() {
const state = useGameStore((s) => s.state); const state = useGameStore((s) => s.state);
const buyNode = useGameStore((s) => s.buyNode); const resetTree = useGameStore((s) => s.resetTree);
const { evolutionTree, prestigeCount } = state; const { prestigeCount, ancestralDna, evolutionTree } = state;
if (prestigeCount < 1) return null; if (prestigeCount < 1) return null;
const spentDna = getSpentDna(evolutionTree);
const hasUnlocked = spentDna > 0;
const resetCost = getTreeResetCost(state);
const canReset = canResetTree(state);
const handleReset = () => {
if (!canReset) return;
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : " (gratuit)";
const confirmed = window.confirm(
`Reinitialiser l'Arbre d'Evolution ?\n\n` +
`Tu recuperes ${spentDna} ADN Ancestral.${costLabel}\n` +
`Tous les noeuds seront verrouilles.\n\n` +
`Confirmer ?`
);
if (confirmed) resetTree();
};
return ( return (
<div className="gp"> <div className="flex flex-col gap-2">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div className="flex justify-between items-center px-1">
<span className="gp-title">Évolution</span> <span className="gp-title">Evolution</span>
<span className="gp-value gp-accent-amber">{state.ancestralDna} ADN</span> <div className="flex items-center gap-2">
<span className="gp-value gp-accent-amber">{formatNumber(ancestralDna)} ADN</span>
{hasUnlocked && (
<button
onClick={handleReset}
disabled={!canReset}
className={`gp-btn text-[0.55rem]! ${
canReset
? "gp-btn--disabled hover:bg-red-500/20! hover:text-red-400!"
: "gp-btn--disabled"
}`}
title={`Recuperer ${spentDna} ADN${resetCost > 0 ? ` (coute ${resetCost})` : " (gratuit)"}`}
>
Reset{resetCost > 0 ? ` (${resetCost})` : ""}
</button>
)}
</div>
</div> </div>
{evolutionTree.map((node) => ( <div className="flex gap-1.5">
<NodeRow <BranchColumn branch="ponte" />
key={node.id} <BranchColumn branch="marais" />
node={node} <BranchColumn branch="adaptation" />
canBuy={canBuyEvolutionNode(state, node.id)} </div>
onBuy={() => buyNode(node.id)} <ConvergenceSection />
/>
))}
</div> </div>
); );
} }

View File

@@ -8,11 +8,11 @@ export function GeneratorShop() {
const resources = useGameStore((s) => s.state.resources); const resources = useGameStore((s) => s.state.resources);
const productionPerSecond = useGameStore((s) => s.productionPerSecond); const productionPerSecond = useGameStore((s) => s.productionPerSecond);
const buy = useGameStore((s) => s.buy); const buy = useGameStore((s) => s.buy);
const generatorCost = useGameStore((s) => s.generatorCost); const generatorCost = useGameStore((s) => s.generatorCostWithTree);
return ( return (
<div className="gp"> <div className="gp">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div className="flex justify-between items-center">
<span className="gp-title" title="Achète des générateurs pour produire des têtards automatiquement">Générateurs</span> <span className="gp-title" title="Achète des générateurs pour produire des têtards automatiquement">Générateurs</span>
<span className="gp-value gp-accent-green" title="Production totale par seconde">{formatNumber(productionPerSecond)}/s</span> <span className="gp-value gp-accent-green" title="Production totale par seconde">{formatNumber(productionPerSecond)}/s</span>
</div> </div>
@@ -26,8 +26,8 @@ export function GeneratorShop() {
key={gen.id} key={gen.id}
className={`gp-row ${canAfford ? "gp-row--active" : "gp-row--locked"}`} className={`gp-row ${canAfford ? "gp-row--active" : "gp-row--locked"}`}
> >
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}> <div className="flex flex-col min-w-0">
<div style={{ display: "flex", alignItems: "center", gap: "0.3rem" }}> <div className="flex items-center gap-1">
<span className="gp-value">{gen.name}</span> <span className="gp-value">{gen.name}</span>
{gen.owned > 0 && ( {gen.owned > 0 && (
<span className="gp-label gp-accent-green">x{gen.owned}</span> <span className="gp-label gp-accent-green">x{gen.owned}</span>

View File

@@ -1,35 +1,33 @@
// MilestoneBar.tsx — Progression vers le prochain prestige // MilestoneBar.tsx — Progression vers le prochain prestige
import { useGameStore } from "../store/useGameStore"; import { useGameStore } from "../store/useGameStore";
import { formatNumber } from "../utils/formatNumber"; import { formatNumber, } from "../utils/formatNumber";
import { getPrestigeThreshold } from "../core/economy";
const PRESTIGE_THRESHOLD = 1_000_000;
export function MilestoneBar() { export function MilestoneBar() {
const resources = useGameStore((s) => s.state.resources); const state = useGameStore((s) => s.state);
const resources = state.resources;
const threshold = getPrestigeThreshold(state);
const progress = Math.min(resources / PRESTIGE_THRESHOLD, 1); const progress = Math.min(resources / threshold, 1);
const progressPercent = (progress * 100).toFixed(1); const progressPercent = (progress * 100).toFixed(1);
const remaining = Math.max(PRESTIGE_THRESHOLD - resources, 0); const remaining = Math.max(threshold - resources, 0);
return ( return (
<div className="gp" style={{ gap: "0.25rem" }}> <div className="gp gap-1">
<div style={{ display: "flex", justifyContent: "space-between" }}> <div className="flex justify-between">
<span className="gp-label">Prochaine Génération</span> <span className="gp-label">Prochaine Génération</span>
<span className="gp-label"> <span className="gp-label">
{formatNumber(resources)} / {formatNumber(PRESTIGE_THRESHOLD)} {formatNumber(resources)} / {formatNumber(threshold)}
</span> </span>
</div> </div>
<div className="gp-progress"> <div className="gp-progress">
<div <div
className="gp-progress-fill" className="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400"
style={{ style={{ width: `${progressPercent}%` }}
width: `${progressPercent}%`,
background: "linear-gradient(90deg, #7c3aed, #a78bfa)",
}}
/> />
</div> </div>
<span className="gp-label" style={{ textAlign: "right" }}> <span className="gp-label text-right">
{remaining > 0 {remaining > 0
? `${formatNumber(remaining)} restants` ? `${formatNumber(remaining)} restants`
: "Nouvelle Génération disponible !"} : "Nouvelle Génération disponible !"}

View File

@@ -0,0 +1,89 @@
// MilestonesPanel.tsx — Paliers de prestige (Sprint 3)
// Progress bar vers le prochain milestone, claim button, preview reward
import { useGameStore } from "../store/useGameStore";
import { getClaimableMilestones, getNextMilestone } from "../core/economy";
import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones";
export function MilestonesPanel() {
const state = useGameStore((s) => s.state);
const claim = useGameStore((s) => s.claimMilestone);
if (state.prestigeCount < 1) return null;
const claimable = getClaimableMilestones(state);
const nextMilestone = getNextMilestone(state);
const totalClaimed = state.claimedMilestones.length;
return (
<div className="gp">
<div className="flex justify-between items-center">
<span className="gp-title">Milestones</span>
<span className="gp-label">{totalClaimed}/{PRESTIGE_MILESTONES.length}</span>
</div>
{/* Claimable milestones */}
{claimable.length > 0 && (
<div className="flex flex-col gap-1.5">
{claimable.map((m) => (
<div key={m.id} className="gp-row gp-row--evolution border-purple-400/30!">
<div className="flex flex-col min-w-0">
<span className="gp-value text-[0.7rem]!">{m.name}</span>
<span className="gp-label">{m.reward.label}</span>
</div>
<button
onClick={() => claim(m.id)}
className="gp-btn gp-btn--buy"
>
Claim
</button>
</div>
))}
</div>
)}
{/* Progress vers le prochain milestone */}
{nextMilestone && (
<div className="flex flex-col gap-1">
<div className="flex justify-between">
<span className="gp-label">Prochain : {nextMilestone.name}</span>
<span className="gp-label">
{state.prestigeCount}/{nextMilestone.threshold}
</span>
</div>
<div className="gp-progress">
<div
className="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400"
style={{
width: `${Math.min((state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}%`,
}}
/>
</div>
<span className="gp-label">{nextMilestone.reward.label}</span>
</div>
)}
{/* Tous les milestones réclamés */}
{!nextMilestone && claimable.length === 0 && (
<span className="gp-label text-center gp-accent-purple">
Tous les milestones reclames !
</span>
)}
{/* Liste compacte des milestones passés */}
{totalClaimed > 0 && claimable.length === 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{PRESTIGE_MILESTONES.filter((m) => state.claimedMilestones.includes(m.id)).map((m) => (
<span
key={m.id}
className="gp-label text-[0.55rem]! px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20"
title={`${m.name}${m.description}`}
>
{m.threshold}
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
// OfflineReport.tsx — Écran "Pendant ton absence..." affiché au retour offline
import { useGameStore } from "../store/useGameStore";
import { formatNumber } from "../utils/formatNumber";
function formatDuration(ms: number): string {
const minutes = Math.floor(ms / 60_000);
if (minutes < 60) return `${minutes}min`;
const hours = Math.floor(minutes / 60);
const remainMinutes = minutes % 60;
return remainMinutes > 0 ? `${hours}h${remainMinutes}min` : `${hours}h`;
}
export function OfflineReport() {
const report = useGameStore((s) => s.offlineReport);
const dismiss = useGameStore((s) => s.dismissOfflineReport);
if (!report || !report.wasOffline) return null;
const effPercent = Math.round(report.efficiency * 100);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="gp max-w-sm w-full mx-4 text-center">
<span className="gp-title text-lg!">Pendant ton absence...</span>
<div className="flex flex-col gap-3 mt-2">
<div className="flex justify-between">
<span className="gp-label">Durée</span>
<span className="gp-value">{formatDuration(report.duration)}</span>
</div>
<div className="flex justify-between">
<span className="gp-label">Efficacité marais</span>
<span className={`gp-value ${effPercent > 50 ? "gp-accent-green" : "gp-accent-amber"}`}>
{effPercent}%
</span>
</div>
<div className="gp-sep" />
<div className="flex justify-between items-center">
<span className="gp-label">Têtards récoltés</span>
<span className="gp-value gp-accent-green text-lg!">
+{formatNumber(report.gains)}
</span>
</div>
{report.efficiency < 0.5 && (
<p className="gp-label text-center">
Le marais s'endort sans toi... Joue activement pour maximiser ta production !
</p>
)}
</div>
<button
onClick={dismiss}
className="gp-btn gp-btn--buy w-full mt-3 py-2!"
>
Retour au marais
</button>
</div>
</div>
);
}

View File

@@ -1,41 +1,33 @@
// PrestigePanel.tsx — Nouvelle Génération (prestige) // PrestigePanel.tsx — Nouvelle Génération (prestige)
import { useGameStore } from "../store/useGameStore"; import { useGameStore } from "../store/useGameStore";
import { computePrestigeDna } from "../core/economy"; import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from "../core/economy";
import { formatNumber } from "../utils/formatNumber";
export function PrestigePanel() { export function PrestigePanel() {
const { lifetimeTadpoles } = useGameStore((s) => s.state); const state = useGameStore((s) => s.state);
const canPrestige = useGameStore((s) => s.canPrestige); const canPrestige = useGameStore((s) => s.canPrestige);
const prestige = useGameStore((s) => s.prestige); const openPrestigeScreen = useGameStore((s) => s.openPrestigeScreen);
const dnaPreview = computePrestigeDna(lifetimeTadpoles); const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const handlePrestige = () => { const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
const confirmed = window.confirm( const threshold = getPrestigeThreshold(state);
`Nouvelle Génération\n\n` +
`Reset : têtards et générateurs à zéro.\n` +
`Récompense : +${dnaPreview} ADN Ancestral\n` +
` +0.1x multiplicateur permanent\n\n` +
`L'Arbre d'Évolution persiste.\n\n` +
`Confirmer ?`
);
if (confirmed) prestige();
};
return ( return (
<div className="gp"> <div className="gp">
<span className="gp-title" title="Recommence à zéro en échange d'un bonus permanent — tes têtards et générateurs sont réinitialisés mais tu gagnes de l'ADN et un multiplicateur">Prestige</span> <span className="gp-title" title="Recommence à zéro en échange d'un bonus permanent — tes têtards et générateurs sont réinitialisés mais tu gagnes de l'ADN et un multiplicateur">Prestige</span>
{canPrestige ? ( {canPrestige ? (
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}> <div className="flex flex-col gap-1.5">
<span className="gp-value gp-accent-purple"> <span className="gp-value gp-accent-purple">
+{dnaPreview} ADN · +0.1x mult +{dnaPreview} ADN · +0.1x mult
</span> </span>
<button onClick={handlePrestige} className="gp-btn gp-btn--prestige"> <button onClick={openPrestigeScreen} className="gp-btn gp-btn--prestige">
Nouvelle Génération Nouvelle Generation
</button> </button>
</div> </div>
) : ( ) : (
<span className="gp-label">Atteins 1M têtards pour prestige</span> <span className="gp-label">Atteins {formatNumber(threshold)} tetards pour prestige</span>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,182 @@
// PrestigeScreen.tsx — Écran de prestige fullscreen (Sprint 3)
// Preview ADN, stats de run, comparaison meilleure run, confirmation
import { useGameStore } from "../store/useGameStore";
import {
computePrestigeDna,
getPrestigeDnaBonus,
getPrestigeThreshold,
} from "../core/economy";
import { formatNumber } from "../utils/formatNumber";
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
export function PrestigeScreen() {
const show = useGameStore((s) => s.showPrestigeScreen);
const close = useGameStore((s) => s.closePrestigeScreen);
const prestige = useGameStore((s) => s.prestige);
const state = useGameStore((s) => s.state);
if (!show) return null;
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
const threshold = getPrestigeThreshold(state);
const canPrestige = state.lifetimeTadpoles >= threshold;
// Run stats
const now = Date.now();
const runDuration = now - state.runStats.startedAt;
const bestRun = state.runStats.bestRun;
// Comparison with best run
const isBestAdn = !bestRun || dnaPreview > bestRun.adn;
const isBestTadpoles = !bestRun || state.lifetimeTadpoles > bestRun.tadpoles;
const handlePrestige = () => {
if (canPrestige) prestige();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm">
<div className="gp max-w-md w-full mx-4">
{/* Header */}
<div className="text-center">
<span className="gp-title text-lg!">Nouvelle Generation</span>
<p className="gp-label mt-1">
Generation #{state.prestigeCount + 1}
</p>
</div>
<div className="gp-sep" />
{/* ADN Preview */}
<div className="flex flex-col items-center gap-1 py-2">
<span className="gp-label">ADN Ancestral</span>
<span className="text-3xl font-extrabold" style={{ color: "#a78bfa", fontFamily: "var(--font)" }}>
+{formatNumber(dnaPreview)}
</span>
{dnaBonus > 0 && (
<span className="gp-label">
(base {formatNumber(baseDna)} + {Math.round(dnaBonus * 100)}% arbre)
</span>
)}
<span className="gp-label mt-1">
Total apres : {formatNumber(state.ancestralDna + dnaPreview)} ADN
</span>
</div>
<div className="gp-sep" />
{/* Run Stats */}
<div className="flex flex-col gap-2">
<span className="gp-zone-label">Stats de la run</span>
<div className="flex justify-between">
<span className="gp-label">Duree</span>
<span className="gp-value">{formatDuration(runDuration)}</span>
</div>
<div className="flex justify-between">
<span className="gp-label">Tetards produits</span>
<span className={`gp-value ${isBestTadpoles ? "gp-accent-green" : ""}`}>
{formatNumber(state.lifetimeTadpoles)}
{isBestTadpoles && bestRun && " ★"}
</span>
</div>
<div className="flex justify-between">
<span className="gp-label">ADN cette run</span>
<span className={`gp-value ${isBestAdn ? "gp-accent-green" : ""}`}>
{formatNumber(dnaPreview)}
{isBestAdn && bestRun && " ★"}
</span>
</div>
{bestRun && (
<div className="flex justify-between">
<span className="gp-label">Vitesse vs meilleure</span>
<span className={`gp-value ${
runDuration < bestRun.duration ? "gp-accent-green" : "gp-accent-amber"
}`}>
{runDuration < bestRun.duration
? `${Math.round((1 - runDuration / bestRun.duration) * 100)}% plus rapide`
: runDuration > bestRun.duration
? `${Math.round((runDuration / bestRun.duration - 1) * 100)}% plus lent`
: "identique"
}
</span>
</div>
)}
</div>
{bestRun && (
<>
<div className="gp-sep" />
<div className="flex flex-col gap-1">
<span className="gp-zone-label">Meilleure run</span>
<div className="flex justify-between">
<span className="gp-label">Duree</span>
<span className="gp-value">{formatDuration(bestRun.duration)}</span>
</div>
<div className="flex justify-between">
<span className="gp-label">ADN</span>
<span className="gp-value gp-accent-purple">{formatNumber(bestRun.adn)}</span>
</div>
</div>
</>
)}
<div className="gp-sep" />
{/* Reset info */}
<div className="text-center">
<p className="gp-label">
Tetards et generateurs remis a zero.
</p>
<p className="gp-label">
Arbre d'Evolution et cosmetiques conserves.
</p>
<p className="gp-label mt-1">
+1 reset d'arbre gratuit offert.
</p>
</div>
{/* Actions */}
<div className="flex gap-2 mt-1">
<button
onClick={close}
className="gp-btn flex-1 py-2!"
style={{ background: "rgba(255,255,255,0.08)", color: "rgba(255,255,255,0.7)" }}
>
Annuler
</button>
{canPrestige ? (
<button
onClick={handlePrestige}
className="gp-btn gp-btn--prestige flex-1 py-2!"
>
Nouvelle Generation
</button>
) : (
<button
className="gp-btn gp-btn--disabled flex-1 py-2!"
disabled
>
{formatNumber(threshold - state.lifetimeTadpoles)} tetards manquants
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
// TadpoleSprite.tsx — Sprite têtard avec overlays cosmétiques équipés
import { useGameStore } from "../store/useGameStore";
import { COSMETICS, type CosmeticSlot } from "../core/cosmetics";
const SLOT_ORDER: CosmeticSlot[] = ["body", "tail", "eyes", "hat", "accessory"];
export function TadpoleSprite() {
const equipped = useGameStore((s) => s.state.cosmeticEquipped);
const overlays = SLOT_ORDER
.map((slot) => {
const cosId = equipped[slot];
if (!cosId) return null;
return COSMETICS.find((c) => c.id === cosId);
})
.filter(Boolean);
return (
<div className="relative w-[280px] h-[280px] md:w-[320px] md:h-[320px]">
{/* Base sprite */}
<img
src="/svg/tadpole.svg"
alt="Têtard"
className="w-full h-full object-contain transition-transform duration-100"
draggable={false}
/>
{/* Cosmetic overlays */}
{overlays.map((cos) => (
<img
key={cos!.id}
src={cos!.svg}
alt={cos!.name}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
draggable={false}
/>
))}
</div>
);
}

View File

@@ -1,8 +1,5 @@
import { NavLink as Link } from "react-router-dom"; import { NavLink as Link } from "react-router-dom";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import "../scss/components/navbar.scss";
import "../scss/root.scss";
import PrimaryButton from "./buttons/PrimaryButton"; import PrimaryButton from "./buttons/PrimaryButton";
export default function Burger({ navData }) { export default function Burger({ navData }) {

View File

@@ -1,5 +1,4 @@
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import "../../scss/components/buttons.scss";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export default function PrimaryButton({ btnText, btnLink }) { export default function PrimaryButton({ btnText, btnLink }) {

View File

@@ -1,4 +1,3 @@
import "../../scss/components/buttons.scss";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Link } from "react-router"; import { Link } from "react-router";

View File

@@ -1,4 +1,3 @@
import "../scss/components/footer.scss";
import { NavLink as Link } from "react-router-dom"; import { NavLink as Link } from "react-router-dom";
export default function Footer() { export default function Footer() {

View File

@@ -1,9 +1,6 @@
import { NavLink as Link } from "react-router-dom"; import { NavLink as Link } from "react-router-dom";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import "../scss/components/navbar.scss";
import "../scss/root.scss";
import PrimaryButton from "./buttons/PrimaryButton"; import PrimaryButton from "./buttons/PrimaryButton";
import Burger from "./burger"; import Burger from "./burger";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";

View File

@@ -0,0 +1,68 @@
// balance.ts — Constantes d'équilibrage centralisées
// Toutes les valeurs de tuning en un seul fichier pour faciliter le playtest.
// Sprint 3 — session brainstorm 2026-03-28
// --- Formule ADN prestige ---
export const PRESTIGE_ADN_BASE = 50;
export const PRESTIGE_ADN_THRESHOLD = 1e6; // 1M têtards minimum pour prestige
export const PRESTIGE_BONUS_PER_PRESTIGE = 0.05; // +5% par prestige
export const PRESTIGE_BONUS_CAP = 3.0; // cap à ×4 total (80 prestiges)
export const PRESTIGE_ADN_MIN = 1; // clamp : jamais 0 ADN si seuil atteint
// --- Seuil prestige ---
export const BASE_PRESTIGE_THRESHOLD = 1_000_000; // 1M têtards
// --- Post-capstone scaling par tranche ---
export const POST_CAPSTONE_TIERS = [
{ maxPurchases: 5, multiplier: 1.5 }, // achats 1-5
{ maxPurchases: 10, multiplier: 1.8 }, // achats 6-10
{ maxPurchases: Infinity, multiplier: 2.0 }, // achats 11+
] as const;
/**
* Calcule le coût du N-ième achat post-capstone repeatable (0-indexed).
* Scaling par tranche : ×1.5 (achats 0-4), ×1.8 (5-9), ×2.0 (10+)
*/
export function postCapstoneCost(baseCost: number, purchased: number): number {
let cost = baseCost;
for (let i = 0; i < purchased; i++) {
if (i < 5) cost *= 1.5;
else if (i < 10) cost *= 1.8;
else cost *= 2.0;
}
return Math.floor(cost);
}
// --- Reset arbre ---
export const TREE_RESET_FREE_PER_PRESTIGE = 1; // 1 gratuit par prestige
export const TREE_RESET_EXTRA_COST = 5; // 5 ADN × n pour les resets supplémentaires
/**
* Coût du prochain reset arbre.
* 1 gratuit par prestige, puis linéaire (5 × n) au-delà.
*/
export function treeResetCost(freeResetAvailable: boolean, extraResetsUsed: number): number {
if (freeResetAvailable) return 0;
return TREE_RESET_EXTRA_COST * (extraResetsUsed + 1);
}
// --- Offline ---
export const OFFLINE_THRESHOLD_MS = 60_000; // 60s
export const OFFLINE_FULL_MS = 15 * 60_000; // 0-15min : 100%
export const OFFLINE_DECAY_END_MS = 60 * 60_000; // 15min-1h : 100% → 25%
export const OFFLINE_ZERO_MS = 2 * 60 * 60_000; // 1h-2h : 25% → 0%
export const OFFLINE_FLOOR = 0.25; // plancher decay
// --- Anti-cheat ---
export const MAX_PRODUCTION_PER_SECOND = 750_000;
export const CHEAT_MARGIN = 1.1;
// --- Save version ---
export const CURRENT_SAVE_VERSION = 2;

View File

@@ -0,0 +1,97 @@
// cosmetics.ts — Système cosmétique (récompenses achievements + prestige)
import type { GameState } from "./economy";
import { ACHIEVEMENTS } from "../data/achievements";
export type CosmeticSlot = "hat" | "eyes" | "body" | "tail" | "accessory";
export interface Cosmetic {
id: string;
name: string;
slot: CosmeticSlot;
svg: string; // chemin vers le SVG overlay (/svg/cosmetics/...)
source: "achievement" | "prestige";
sourceId: string; // achievement id ou "prestige_N"
description: string;
}
export interface CosmeticState {
inventory: string[]; // ids des cosmétiques débloqués
equipped: Partial<Record<CosmeticSlot, string>>; // slot → cosmetic id
}
export const DEFAULT_COSMETIC_STATE: CosmeticState = {
inventory: [],
equipped: {},
};
// --- Catalogue des cosmétiques ---
export const COSMETICS: Cosmetic[] = [
// Hat
{ id: "crown", name: "Couronne Ancestrale", slot: "hat", svg: "/svg/cosmetics/crown.svg", source: "prestige", sourceId: "prestige_10", description: "10 prestiges — la royauté du marais" },
{ id: "cap_swamp", name: "Casquette du Marais", slot: "hat", svg: "/svg/cosmetics/cap-swamp.svg", source: "achievement", sourceId: "industriel", description: "10 générateurs au total" },
// Eyes
{ id: "glasses_savant", name: "Lunettes du Savant", slot: "eyes", svg: "/svg/cosmetics/glasses-savant.svg", source: "prestige", sourceId: "prestige_5", description: "5 prestiges — la sagesse" },
{ id: "mask_frog", name: "Masque Grenouille", slot: "eyes", svg: "/svg/cosmetics/mask-frog.svg", source: "achievement", sourceId: "empire", description: "1M têtards — le regard de l'empire" },
// Body
{ id: "cape_algae", name: "Cape d'Algues", slot: "body", svg: "/svg/cosmetics/cape-algae.svg", source: "prestige", sourceId: "prestige_25", description: "25 prestiges — tissée par le marais" },
{ id: "armor_scales", name: "Armure d'Écailles", slot: "body", svg: "/svg/cosmetics/armor-scales.svg", source: "achievement", sourceId: "tycoon", description: "100 générateurs — blindage total" },
// Tail
{ id: "flame_tail", name: "Queue Enflammée", slot: "tail", svg: "/svg/cosmetics/flame-tail.svg", source: "prestige", sourceId: "prestige_50", description: "50 prestiges — la traîne de feu" },
{ id: "ribbon", name: "Ruban du Nouveau-Né", slot: "tail", svg: "/svg/cosmetics/ribbon.svg", source: "achievement", sourceId: "first_prestige", description: "Premier prestige — le début de tout" },
// Accessory
{ id: "aura_swamp", name: "Aura du Marais", slot: "accessory", svg: "/svg/aura-swamp.svg", source: "achievement", sourceId: "veteran", description: "5 prestiges — l'aura des anciens" },
{ id: "particles_gold", name: "Particules Dorées", slot: "accessory", svg: "/svg/cosmetics/particles-gold.svg", source: "prestige", sourceId: "prestige_3", description: "3 prestiges — poussière d'étoiles" },
];
// --- Fonctions cosmétiques ---
// Vérifie si un cosmétique devrait être débloqué
export function shouldUnlockCosmetic(cosmetic: Cosmetic, state: GameState): boolean {
if (cosmetic.source === "prestige") {
const tier = parseInt(cosmetic.sourceId.replace("prestige_", ""), 10);
return state.prestigeCount >= tier;
}
if (cosmetic.source === "achievement") {
const achievement = ACHIEVEMENTS.find((a) => a.id === cosmetic.sourceId);
return achievement ? achievement.check(state) : false;
}
return false;
}
// Calcule les cosmétiques nouvellement débloqués (pas encore dans l'inventaire)
export function computeNewUnlocks(state: GameState, cosmeticState: CosmeticState): string[] {
return COSMETICS
.filter((c) => !cosmeticState.inventory.includes(c.id) && shouldUnlockCosmetic(c, state))
.map((c) => c.id);
}
// Équiper un cosmétique (retourne le nouvel état)
export function equipCosmetic(cosmeticState: CosmeticState, cosmeticId: string): CosmeticState {
const cosmetic = COSMETICS.find((c) => c.id === cosmeticId);
if (!cosmetic) return cosmeticState;
if (!cosmeticState.inventory.includes(cosmeticId)) return cosmeticState;
return {
...cosmeticState,
equipped: { ...cosmeticState.equipped, [cosmetic.slot]: cosmeticId },
};
}
// Déséquiper un slot
export function unequipSlot(cosmeticState: CosmeticState, slot: CosmeticSlot): CosmeticState {
const { [slot]: _, ...rest } = cosmeticState.equipped;
return { ...cosmeticState, equipped: rest };
}
// Ajouter des cosmétiques à l'inventaire
export function addToInventory(cosmeticState: CosmeticState, ids: string[]): CosmeticState {
const newIds = ids.filter((id) => !cosmeticState.inventory.includes(id));
if (newIds.length === 0) return cosmeticState;
return { ...cosmeticState, inventory: [...cosmeticState.inventory, ...newIds] };
}

View File

@@ -1,6 +1,25 @@
// economy.ts — Core clicker logic (lazy calculation pattern) // economy.ts — Core clicker logic (lazy calculation pattern)
// Jamais de timer actif : tout est calculé au read depuis lastTick // Jamais de timer actif : tout est calculé au read depuis lastTick
import {
PRESTIGE_ADN_BASE,
PRESTIGE_ADN_THRESHOLD,
PRESTIGE_BONUS_PER_PRESTIGE,
PRESTIGE_BONUS_CAP,
PRESTIGE_ADN_MIN,
BASE_PRESTIGE_THRESHOLD,
OFFLINE_THRESHOLD_MS as OFFLINE_THRESHOLD,
OFFLINE_FULL_MS,
OFFLINE_DECAY_END_MS,
OFFLINE_ZERO_MS,
OFFLINE_FLOOR,
CURRENT_SAVE_VERSION,
treeResetCost,
postCapstoneCost,
} from "./balance";
import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones";
import type { PrestigeMilestone } from "../data/prestigeMilestones";
export interface Generator { export interface Generator {
id: string; id: string;
name: string; name: string;
@@ -9,54 +28,295 @@ export interface Generator {
owned: number; owned: number;
} }
export type EffectType = "click_multiplier" | "production_multiplier" | "start_bonus" | "unlock_generator" | "achievement_scaling"; export type EffectType =
| "click_multiplier"
| "production_multiplier"
| "start_bonus"
| "unlock_generator"
| "double_click_chance"
| "auto_click"
| "crit_click_chance"
| "generator_boost"
| "cost_reduction"
| "prestige_dna_bonus"
| "offline_boost"
| "prestige_threshold_reduction"
// Sprint 3 — capstones
| "auto_click_scaling" // Ponte Auto — auto-click scale avec upgrades
| "generator_synergy" // Symbiose Totale — +X% par type possédé
| "offline_cap_boost" // Mémoire du Marais — offline cap + durée
// Sprint 3 — Convergence
| "all_effects_boost" // +X% à tous les effets
| "post_capstone_discount"; // -X% coût post-capstones
export type Branch = "ponte" | "marais" | "adaptation" | "cross";
export interface EvolutionNode { export interface EvolutionNode {
id: string; id: string;
name: string; name: string;
cost: number; // en ADN Ancestral cost: number; // en ADN Ancestral (base cost for repeatables)
effect: EffectType; effect: EffectType;
value: number; value: number;
unlocked: boolean; unlocked: boolean;
requires: string | null; // id du nœud prérequis (null = racine) requires: string | null; // id du nœud prérequis (null = racine)
branch: Branch;
exclusive_with?: string; // id du nœud alternatif (pick one)
// Sprint 3 — capstone & repeatable
capstone?: boolean; // nœud capstone (bordure dorée, game-changer)
repeatable?: boolean; // post-capstone achetable en boucle
purchased?: number; // nombre d'achats pour les repeatables
// Sprint 3 — Convergence (nœud évolutif)
tier?: number; // tier actuel (1 = Alpha, 2 = Omega)
maxTier?: number; // tier max
tierUpgradeCost?: number; // coût upgrade au tier suivant
tierUpgradeRequires?: string; // condition pour upgrade ("2_capstones")
}
export interface CosmeticSlotMap {
[slot: string]: string | undefined;
}
export interface RunStats {
startedAt: number; // timestamp ms début de la run
tadpolesProduced: number; // têtards produits cette run (tracking granulaire)
bestRun: {
duration: number; // ms
tadpoles: number;
adn: number;
} | null;
} }
export interface GameState { export interface GameState {
saveVersion: number;
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
lastOnline: number; // timestamp ms — dernière activité réelle (tick actif)
prestigeCount: number; prestigeCount: number;
prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre
ancestralDna: number; ancestralDna: number;
evolutionTree: EvolutionNode[]; evolutionTree: EvolutionNode[];
lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN) lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN)
cosmeticInventory: string[]; // ids des cosmétiques débloqués
cosmeticEquipped: CosmeticSlotMap; // slot → cosmetic id
// Sprint 3
runStats: RunStats;
freeResetAvailable: boolean; // 1 gratuit par prestige
extraResetsUsed: number; // resets payants dans la génération courante
claimedMilestones: string[]; // IDs des milestones réclamés
} }
// --- Arbre d'Évolution --- // --- Arbre d'Évolution ---
export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [ 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 }, // ═══ PONTE (click) — 10 nœuds ═══
{ 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" }, // Tier 1
{ id: "mutation_alpha", name: "Mutation Alpha", cost: 25, effect: "unlock_generator", value: 0, unlocked: false, requires: "memoire_genetique" }, { id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null, branch: "ponte" },
{ id: "symbiose", name: "Symbiose", cost: 50, effect: "achievement_scaling", value: 0.01, unlocked: false, requires: "mutation_alpha" }, // Tier 2
{ id: "double_ponte", name: "Double Ponte", cost: 5, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" },
// Tier 3 (exclusif)
{ id: "ponte_frenetique", name: "Frénésie", cost: 15, effect: "click_multiplier", value: 3, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "concentration" },
{ id: "concentration", name: "Concentration", cost: 15, effect: "click_multiplier", value: 4, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "ponte_frenetique" },
// Tier 3 (parallèle)
{ id: "ponte_critique", name: "Ponte Critique", cost: 20, effect: "crit_click_chance", value: 0.05, unlocked: false, requires: "double_ponte", branch: "ponte" },
// Tier 4
{ id: "maitre_pondeur", name: "Maître Pondeur", cost: 40, effect: "click_multiplier", value: 5, unlocked: false, requires: "ponte_critique", branch: "ponte" },
// Capstone
{ id: "ponte_auto", name: "Ponte Automatique", cost: 200, effect: "auto_click_scaling", value: 1, unlocked: false, requires: "maitre_pondeur", branch: "ponte", capstone: true },
// Post-capstone (repeatable)
{ id: "ponte_post", name: "+5% auto-ponte", cost: 500, effect: "auto_click", value: 0.05, unlocked: false, requires: "ponte_auto", branch: "ponte", repeatable: true, purchased: 0 },
// ═══ MARAIS (production) — 10 nœuds ═══
// Tier 1
{ id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: null, branch: "marais" },
// Tier 2
{ id: "symbiose_algale", name: "Symbiose Algale", cost: 8, effect: "generator_boost", value: 2, unlocked: false, requires: "instinct_gregaire", branch: "marais" },
// Tier 3 (exclusif)
{ id: "courant_profond", name: "Courant Profond", cost: 25, effect: "production_multiplier", value: 2, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "maree_haute" },
{ id: "maree_haute", name: "Marée Haute", cost: 25, effect: "cost_reduction", value: 0.20, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "courant_profond" },
// Tier 3 (parallèle)
{ id: "ecosysteme_mature", name: "Écosystème Mature", cost: 25, effect: "production_multiplier", value: 3, unlocked: false, requires: "symbiose_algale", branch: "marais" },
// Tier 4
{ id: "marais_eternel", name: "Marais Éternel", cost: 60, effect: "production_multiplier", value: 5, unlocked: false, requires: "ecosysteme_mature", branch: "marais" },
// Capstone
{ id: "symbiose_totale", name: "Symbiose Totale", cost: 300, effect: "generator_synergy", value: 0.02, unlocked: false, requires: "marais_eternel", branch: "marais", capstone: true },
// Post-capstone (repeatable)
{ id: "marais_post", name: "+1% synergie", cost: 600, effect: "generator_synergy", value: 0.01, unlocked: false, requires: "symbiose_totale", branch: "marais", repeatable: true, purchased: 0 },
// ═══ ADAPTATION (utility) — 10 nœuds ═══
// Tier 1
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 2, effect: "start_bonus", value: 100, unlocked: false, requires: null, branch: "adaptation" },
// Tier 2
{ id: "adn_renforce", name: "ADN Renforcé", cost: 10, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "memoire_genetique", branch: "adaptation" },
// Tier 3 (exclusif)
{ id: "eveil_rapide", name: "Éveil Rapide", cost: 30, effect: "offline_boost", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "mutation_adn" },
{ id: "mutation_adn", name: "Mutation ADN", cost: 30, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "eveil_rapide" },
// Tier 3 (parallèle)
{ id: "heritage", name: "Héritage", cost: 30, effect: "prestige_dna_bonus", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation" },
// Tier 4
{ id: "transcendance", name: "Transcendance", cost: 60, effect: "prestige_threshold_reduction", value: 0.50, unlocked: false, requires: "heritage", branch: "adaptation" },
// Capstone
{ id: "memoire_marais", name: "Mémoire du Marais", cost: 250, effect: "offline_cap_boost", value: 0.75, unlocked: false, requires: "transcendance", branch: "adaptation", capstone: true },
// Post-capstone (repeatable)
{ id: "adapt_post", name: "+2% offline cap", cost: 500, effect: "offline_boost", value: 0.02, unlocked: false, requires: "memoire_marais", branch: "adaptation", repeatable: true, purchased: 0 },
// ═══ CROSS-BRANCHE — Convergence (nœud évolutif) ═══
{ id: "convergence", name: "Convergence", cost: 500, effect: "all_effects_boost", value: 0.10, unlocked: false, requires: null, branch: "cross",
tier: 1, maxTier: 2, tierUpgradeCost: 500, tierUpgradeRequires: "2_capstones" },
]; ];
// Calcule l'ADN gagné lors d'un prestige : floor(150 × sqrt(lifetime / 1e9)) // Formule ADN Sprint 3 : max(1, floor(base × log10(t / threshold) × (1 + bonus)))
export function computePrestigeDna(lifetimeTadpoles: number): number { // Clamp min 1 si seuil atteint, cap bonus ×4 à 80 prestiges
return Math.floor(150 * Math.sqrt(lifetimeTadpoles / 1e9));
export function computePrestigeDna(lifetimeTadpoles: number, prestigeCount: number = 0): number {
if (lifetimeTadpoles < PRESTIGE_ADN_THRESHOLD) return 0;
const ratio = lifetimeTadpoles / PRESTIGE_ADN_THRESHOLD;
if (ratio <= 1) return PRESTIGE_ADN_MIN;
const bonus = Math.min(PRESTIGE_BONUS_PER_PRESTIGE * prestigeCount, PRESTIGE_BONUS_CAP);
const raw = PRESTIGE_ADN_BASE * Math.log10(ratio) * (1 + bonus);
return Math.max(PRESTIGE_ADN_MIN, Math.floor(raw));
}
// --- Milestones prestige ---
// Milestones disponibles mais pas encore réclamés
export function getClaimableMilestones(state: GameState): PrestigeMilestone[] {
return PRESTIGE_MILESTONES.filter(
(m) => state.prestigeCount >= m.threshold && !state.claimedMilestones.includes(m.id)
);
}
// Prochain milestone non atteint
export function getNextMilestone(state: GameState): PrestigeMilestone | null {
return PRESTIGE_MILESTONES.find((m) => state.prestigeCount < m.threshold) ?? null;
}
// Réclamer un milestone
export function claimMilestone(state: GameState, milestoneId: string): GameState | null {
const milestone = PRESTIGE_MILESTONES.find((m) => m.id === milestoneId);
if (!milestone) return null;
if (state.prestigeCount < milestone.threshold) return null;
if (state.claimedMilestones.includes(milestoneId)) return null;
let newState = {
...state,
claimedMilestones: [...state.claimedMilestones, milestoneId],
};
// Appliquer la récompense
if (milestone.reward.type === "cosmetic") {
if (!newState.cosmeticInventory.includes(milestone.reward.cosmeticId)) {
newState = {
...newState,
cosmeticInventory: [...newState.cosmeticInventory, milestone.reward.cosmeticId],
};
}
}
// Les bonus gameplay sont appliqués passivement via getMilestoneBonus()
return newState;
}
// Bonus gameplay cumulés depuis les milestones réclamés
export function getMilestoneStartNid(state: GameState): number {
const claimed = state.claimedMilestones;
if (claimed.includes("milestone_5")) return 1; // 1 Nid gratuit
return 0;
}
export function getMilestoneOfflineBonus(state: GameState): number {
const claimed = state.claimedMilestones;
if (claimed.includes("milestone_15")) return 0.05; // +5% offline cap
return 0;
}
// Compte les capstones débloqués
export function getUnlockedCapstoneCount(tree: EvolutionNode[]): number {
return tree.filter((n) => n.capstone && n.unlocked).length;
}
// Coût actuel d'un nœud repeatable (scaling par tranche via balance.ts)
export function getRepeatableCost(node: EvolutionNode): number {
if (!node.repeatable) return node.cost;
return postCapstoneCost(node.cost, node.purchased ?? 0);
}
// Vérifie si le joueur peut acheter Convergence (condition spéciale)
function canBuyConvergence(state: GameState, node: EvolutionNode): boolean {
// Tier 1 : 1 capstone + au moins 1 nœud tier 3 d'une 2e branche
if (!node.unlocked && (node.tier ?? 1) === 1) {
const capstones = getUnlockedCapstoneCount(state.evolutionTree);
if (capstones < 1) return false;
// Check: au moins 1 nœud dans une branche différente de la capstone
const capstoneBranches = new Set(
state.evolutionTree.filter((n) => n.capstone && n.unlocked).map((n) => n.branch)
);
const otherBranchNodes = state.evolutionTree.filter(
(n) => n.unlocked && !capstoneBranches.has(n.branch) && n.branch !== "cross" && n.cost >= 15
);
return otherBranchNodes.length > 0 && state.ancestralDna >= node.cost;
}
return false;
}
// Vérifie si Convergence peut être upgradé au tier suivant
export function canUpgradeConvergence(state: GameState): boolean {
const conv = state.evolutionTree.find((n) => n.id === "convergence");
if (!conv || !conv.unlocked) return false;
if ((conv.tier ?? 1) >= (conv.maxTier ?? 2)) return false;
if (conv.tierUpgradeRequires === "2_capstones" && getUnlockedCapstoneCount(state.evolutionTree) < 2) return false;
return state.ancestralDna >= (conv.tierUpgradeCost ?? 500);
}
// Upgrade Convergence au tier suivant
export function upgradeConvergence(state: GameState): GameState | null {
if (!canUpgradeConvergence(state)) return null;
const conv = state.evolutionTree.find((n) => n.id === "convergence")!;
const cost = conv.tierUpgradeCost ?? 500;
return {
...state,
ancestralDna: state.ancestralDna - cost,
evolutionTree: state.evolutionTree.map((n) =>
n.id === "convergence"
? { ...n, tier: (n.tier ?? 1) + 1, effect: "post_capstone_discount" as EffectType, value: 0.20 }
: n
),
};
} }
// Vérifie si un nœud peut être acheté // Vérifie si un nœud peut être acheté
export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean { export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean {
const node = state.evolutionTree.find((n) => n.id === nodeId); const node = state.evolutionTree.find((n) => n.id === nodeId);
if (!node || node.unlocked) return false; if (!node) return false;
if (state.ancestralDna < node.cost) return false;
// Convergence a sa propre logique
if (node.id === "convergence") return canBuyConvergence(state, node);
// Repeatable : toujours achetable si unlocked + prérequis + assez d'ADN
if (node.repeatable && node.unlocked) {
const cost = getRepeatableCost(node);
return state.ancestralDna >= cost;
}
if (node.unlocked) return false;
const cost = node.repeatable ? getRepeatableCost(node) : node.cost;
if (state.ancestralDna < cost) return false;
if (node.requires) { if (node.requires) {
const prereq = state.evolutionTree.find((n) => n.id === node.requires); const prereq = state.evolutionTree.find((n) => n.id === node.requires);
if (!prereq || !prereq.unlocked) return false; if (!prereq || !prereq.unlocked) return false;
} }
// Exclusive node: can't buy if the alternative is already unlocked
if (node.exclusive_with) {
const alt = state.evolutionTree.find((n) => n.id === node.exclusive_with);
if (alt && alt.unlocked) return false;
}
return true; return true;
} }
@@ -65,15 +325,82 @@ export function buyEvolutionNode(state: GameState, nodeId: string): GameState |
if (!canBuyEvolutionNode(state, nodeId)) return null; if (!canBuyEvolutionNode(state, nodeId)) return null;
const node = state.evolutionTree.find((n) => n.id === nodeId)!; const node = state.evolutionTree.find((n) => n.id === nodeId)!;
// Repeatable node — already unlocked, increment purchased
if (node.repeatable && node.unlocked) {
const cost = getRepeatableCost(node);
return {
...state,
ancestralDna: state.ancestralDna - cost,
evolutionTree: state.evolutionTree.map((n) =>
n.id === nodeId ? { ...n, purchased: (n.purchased ?? 0) + 1 } : n
),
};
}
const cost = node.repeatable ? getRepeatableCost(node) : node.cost;
return { return {
...state, ...state,
ancestralDna: state.ancestralDna - node.cost, ancestralDna: state.ancestralDna - cost,
evolutionTree: state.evolutionTree.map((n) => evolutionTree: state.evolutionTree.map((n) =>
n.id === nodeId ? { ...n, unlocked: true } : n n.id === nodeId ? { ...n, unlocked: true } : n
), ),
}; };
} }
// Coût du prochain reset arbre (pour affichage UI)
export function getTreeResetCost(state: GameState): number {
return treeResetCost(state.freeResetAvailable, state.extraResetsUsed);
}
// Vérifie si le joueur peut reset l'arbre
export function canResetTree(state: GameState): boolean {
if (state.prestigeCount < 1) return false;
const cost = getTreeResetCost(state);
return state.ancestralDna >= cost;
}
// Reset l'arbre — rembourse l'ADN dépensé (y compris repeatables), déduit le coût du reset
export function resetEvolutionTree(state: GameState): GameState {
const cost = getTreeResetCost(state);
if (state.ancestralDna < cost) return state;
const spentDna = getSpentDna(state.evolutionTree);
return {
...state,
ancestralDna: state.ancestralDna + spentDna - cost,
evolutionTree: state.evolutionTree.map((n) => ({
...n,
unlocked: false,
purchased: n.repeatable ? 0 : n.purchased,
tier: n.maxTier ? 1 : n.tier,
})),
freeResetAvailable: state.freeResetAvailable ? false : state.freeResetAvailable,
extraResetsUsed: state.freeResetAvailable ? state.extraResetsUsed : state.extraResetsUsed + 1,
};
}
// Compte l'ADN total investi dans l'arbre (standard + repeatables + convergence upgrades)
export function getSpentDna(tree: EvolutionNode[]): number {
let total = 0;
for (const n of tree) {
if (!n.unlocked) continue;
total += n.cost; // coût initial
// Repeatables : somme des coûts de chaque achat
if (n.repeatable && (n.purchased ?? 0) > 0) {
for (let i = 0; i < n.purchased!; i++) {
total += postCapstoneCost(n.cost, i);
}
}
// Convergence tier upgrades
if (n.maxTier && (n.tier ?? 1) > 1) {
total += (n.tierUpgradeCost ?? 0) * ((n.tier ?? 1) - 1);
}
}
return total;
}
// Calcule le multiplicateur click total depuis l'arbre // Calcule le multiplicateur click total depuis l'arbre
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number { export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
return tree return tree
@@ -95,21 +422,171 @@ export function getStartBonusFromTree(tree: EvolutionNode[]): number {
.reduce((sum, n) => sum + n.value, 0); .reduce((sum, n) => sum + n.value, 0);
} }
// Chance de double click (0-1)
export function getDoubleClickChance(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "double_click_chance")
.reduce((sum, n) => sum + n.value, 0);
}
// Auto-clicks par seconde depuis l'arbre (standard + capstone scaling)
export function getAutoClicksPerSecond(tree: EvolutionNode[]): number {
const standard = tree
.filter((n) => n.unlocked && n.effect === "auto_click" && !n.repeatable)
.reduce((sum, n) => sum + n.value, 0);
const scaling = getAutoClickScaling(tree);
return standard + scaling;
}
// Chance de crit click (0-1), crit = x10
export function getCritClickChance(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "crit_click_chance")
.reduce((sum, n) => sum + n.value, 0);
}
// Multiplicateur boost sur Nid (generator_boost)
export function getGeneratorBoostFromTree(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "generator_boost")
.reduce((mult, n) => mult * n.value, 1);
}
// Réduction de coût générateurs (0-1)
export function getCostReduction(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "cost_reduction")
.reduce((sum, n) => sum + n.value, 0);
}
// Bonus ADN prestige (additif, ex: 0.25 = +25%)
export function getPrestigeDnaBonus(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "prestige_dna_bonus")
.reduce((sum, n) => sum + n.value, 0);
}
// Boost offline (additif, ex: 0.50 = +50% efficacité offline)
export function getOfflineBoost(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "offline_boost")
.reduce((sum, n) => sum + n.value, 0);
}
// Réduction seuil prestige (multiplicatif, ex: 0.50 = seuil divisé par 2)
export function getPrestigeThresholdReduction(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "prestige_threshold_reduction")
.reduce((sum, n) => sum + n.value, 0);
}
// --- Sprint 3 — Nouveaux effets ---
// Ponte Automatique (capstone) : 1 auto-click/s de base, scale avec les repeatables
export function getAutoClickScaling(tree: EvolutionNode[]): number {
const capstone = tree.find((n) => n.id === "ponte_auto" && n.unlocked);
if (!capstone) return 0;
const baseAutoClick = capstone.value; // 1/s
// Post-capstone adds flat auto-click value per purchase
const postNode = tree.find((n) => n.id === "ponte_post" && n.unlocked);
const postBonus = postNode ? postNode.value * (postNode.purchased ?? 0) : 0;
return baseAutoClick + postBonus;
}
// Symbiose Totale (capstone) : +X% par type de générateur possédé
// Retourne le multiplicateur (ex: 5 types × 0.02 = 0.10 → ×1.10)
export function getGeneratorSynergyMultiplier(tree: EvolutionNode[], generators: Generator[]): number {
const synergyNodes = tree.filter((n) => n.unlocked && n.effect === "generator_synergy");
if (synergyNodes.length === 0) return 1;
const totalSynergyRate = synergyNodes.reduce((sum, n) => {
// For repeatables, each purchase adds to the rate
const extra = n.repeatable ? n.value * (n.purchased ?? 0) : 0;
return sum + n.value + extra;
}, 0);
const typesOwned = generators.filter((g) => g.owned > 0).length;
return 1 + totalSynergyRate * typesOwned;
}
// Convergence : all_effects_boost — multiplicateur global sur tous les effets de l'arbre
export function getAllEffectsBoost(tree: EvolutionNode[]): number {
const conv = tree.find((n) => n.id === "convergence" && n.unlocked);
if (!conv) return 1;
return 1 + conv.value; // 0.10 = ×1.10
}
// Convergence Omega : post_capstone_discount
export function getPostCapstoneDiscount(tree: EvolutionNode[]): number {
const conv = tree.find((n) => n.id === "convergence" && n.unlocked && n.effect === "post_capstone_discount");
if (!conv) return 0;
return conv.value; // 0.20 = -20%
}
// --- Offline gains (courbe inversée) ---
// Retourne le multiplicateur d'efficacité offline (1.0 → 0.0)
// basé sur le temps d'absence en ms
export function offlineEfficiency(elapsedMs: number): number {
if (elapsedMs <= OFFLINE_THRESHOLD) return 1; // pas offline
if (elapsedMs <= OFFLINE_FULL_MS) return 1; // 0-15min : 100%
if (elapsedMs <= OFFLINE_DECAY_END_MS) {
// 15min-1h : linéaire 1.0 → 0.25
const t = (elapsedMs - OFFLINE_FULL_MS) / (OFFLINE_DECAY_END_MS - OFFLINE_FULL_MS);
return 1 - t * (1 - OFFLINE_FLOOR);
}
if (elapsedMs <= OFFLINE_ZERO_MS) {
// 1h-2h : linéaire 0.25 → 0.0
const t = (elapsedMs - OFFLINE_DECAY_END_MS) / (OFFLINE_ZERO_MS - OFFLINE_DECAY_END_MS);
return OFFLINE_FLOOR * (1 - t);
}
return 0; // >2h : rien
}
// Calcule les gains offline avec la courbe dégressive
// Intègre la courbe par tranches de 1 minute pour plus de précision
export function computeOfflineGains(state: GameState, now: number): number {
const elapsed = now - state.lastTick;
if (elapsed <= OFFLINE_THRESHOLD) return computeIdleGains(state, now);
const pps = totalProductionPerSecond(state);
if (pps <= 0) return 0;
const offlineBoost = 1 + getOfflineBoost(state.evolutionTree) + getMilestoneOfflineBonus(state);
// Intégration par tranches de 60s
const STEP = 60_000;
let total = 0;
for (let t = 0; t < elapsed; t += STEP) {
const chunk = Math.min(STEP, elapsed - t);
const eff = offlineEfficiency(t + chunk / 2); // milieu de la tranche
total += pps * (chunk / 1000) * eff;
}
return total * offlineBoost;
}
// --- Core economy (mis à jour pour intégrer l'arbre) --- // --- 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 × (1 - costReduction)
export function generatorCost(gen: Generator): number { export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number {
return Math.floor(gen.baseCost * Math.pow(1.15, gen.owned)); const base = Math.floor(gen.baseCost * Math.pow(1.15, gen.owned));
if (!tree) return base;
const reduction = getCostReduction(tree);
return Math.max(1, Math.floor(base * (1 - reduction)));
} }
// Production totale par seconde de tous les générateurs // Production totale par seconde de tous les générateurs
export function totalProductionPerSecond(state: GameState): number { export function totalProductionPerSecond(state: GameState): number {
const nidBoost = getGeneratorBoostFromTree(state.evolutionTree);
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
const base = state.generators.reduce( const base = state.generators.reduce(
(sum, gen) => sum + gen.baseProduction * gen.owned, (sum, gen) => {
const boost = gen.id === "nid" ? nidBoost : 1;
return sum + gen.baseProduction * gen.owned * boost;
},
0 0
); );
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree); const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
return base * state.prestigeMultiplier * treeMultiplier; const convergenceBoost = getAllEffectsBoost(state.evolutionTree);
return base * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost;
} }
// Lazy calculation : ressources accumulées depuis lastTick // Lazy calculation : ressources accumulées depuis lastTick
@@ -126,22 +603,59 @@ export function applyIdleGains(state: GameState, now: number): GameState {
resources: state.resources + gains, resources: state.resources + gains,
lifetimeTadpoles: state.lifetimeTadpoles + gains, lifetimeTadpoles: state.lifetimeTadpoles + gains,
lastTick: now, lastTick: now,
runStats: {
...state.runStats,
tadpolesProduced: state.runStats.tadpolesProduced + gains,
},
}; };
} }
// Gain réel par clic (pour affichage particule) // Gain de base par clic (sans RNG — pour affichage tooltip)
export function getClickGain(state: GameState): number { export function getClickGain(state: GameState): number {
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree); const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
return state.clickMultiplier * state.prestigeMultiplier * treeClickMult; return state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
} }
// Clic manuel export interface ClickResult {
export function applyClick(state: GameState): GameState { state: GameState;
const gain = getClickGain(state); gain: number;
isDouble: boolean;
isCrit: boolean;
}
// Clic manuel avec double ponte + crit
export function applyClick(state: GameState, rng: number = Math.random()): ClickResult {
let gain = getClickGain(state);
let isDouble = false;
let isCrit = false;
const doubleChance = getDoubleClickChance(state.evolutionTree);
if (doubleChance > 0 && rng < doubleChance) {
gain *= 2;
isDouble = true;
}
const critChance = getCritClickChance(state.evolutionTree);
// Use a second "roll" derived from rng to avoid double+crit being correlated
const critRng = (rng * 7.13) % 1;
if (critChance > 0 && critRng < critChance) {
gain *= 10;
isCrit = true;
}
return { return {
...state, state: {
resources: state.resources + gain, ...state,
lifetimeTadpoles: state.lifetimeTadpoles + gain, resources: state.resources + gain,
lifetimeTadpoles: state.lifetimeTadpoles + gain,
runStats: {
...state.runStats,
tadpolesProduced: state.runStats.tadpolesProduced + gain,
},
},
gain,
isDouble,
isCrit,
}; };
} }
@@ -151,7 +665,7 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
if (genIndex === -1) return null; if (genIndex === -1) return null;
const gen = state.generators[genIndex]; const gen = state.generators[genIndex];
const cost = generatorCost(gen); const cost = generatorCost(gen, state.evolutionTree);
if (state.resources < cost) return null; if (state.resources < cost) return null;
const updatedGenerators = [...state.generators]; const updatedGenerators = [...state.generators];
@@ -165,24 +679,63 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
} }
// Prestige : reset run, gain ADN, arbre persiste // Prestige : reset run, gain ADN, arbre persiste
export function getPrestigeThreshold(state: GameState): number {
const reduction = getPrestigeThresholdReduction(state.evolutionTree);
return Math.floor(BASE_PRESTIGE_THRESHOLD * (1 - reduction));
}
export function canPrestige(state: GameState): boolean { export function canPrestige(state: GameState): boolean {
return state.resources >= 1_000_000; return state.resources >= getPrestigeThreshold(state);
} }
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 dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
const dnaGained = Math.floor(baseDna * (1 + dnaBonus));
const startBonus = getStartBonusFromTree(state.evolutionTree); const startBonus = getStartBonusFromTree(state.evolutionTree);
// Résilience : commencer avec 1 Lac Mystique
const hasUnlockGen = state.evolutionTree.some(
(n) => n.unlocked && n.effect === "unlock_generator"
);
// Milestone bonus : Nid gratuit au départ
const milestoneNid = getMilestoneStartNid(state);
// RunStats : snapshot de la run qui se termine
const now = Date.now();
const runDuration = now - state.runStats.startedAt;
const bestRun = state.runStats.bestRun;
const newBestRun =
!bestRun || dnaGained > bestRun.adn
? { duration: runDuration, tadpoles: state.lifetimeTadpoles, adn: dnaGained }
: bestRun;
return { return {
...state, ...state,
resources: startBonus, resources: startBonus,
generators: state.generators.map((g) => ({ ...g, owned: 0 })), generators: state.generators.map((g) => ({
...g,
owned:
(hasUnlockGen && g.id === "lac") ? 1 :
(milestoneNid > 0 && g.id === "nid") ? milestoneNid :
0,
})),
prestigeCount: newPrestigeCount, prestigeCount: newPrestigeCount,
prestigeMultiplier: 1 + newPrestigeCount * 0.1, prestigeMultiplier: 1 + newPrestigeCount * 0.1,
ancestralDna: state.ancestralDna + dnaGained, ancestralDna: state.ancestralDna + dnaGained,
lifetimeTadpoles: 0, lifetimeTadpoles: 0,
lastTick: Date.now(), lastTick: now,
lastOnline: now,
// Sprint 3 — nouvelle run
runStats: {
startedAt: now,
tadpolesProduced: 0,
bestRun: newBestRun,
},
freeResetAvailable: true, // 1 reset gratuit offert par prestige
extraResetsUsed: 0,
// evolutionTree persiste — jamais reset // evolutionTree persiste — jamais reset
}; };
} }
@@ -197,13 +750,25 @@ export const DEFAULT_GENERATORS: Generator[] = [
]; ];
export const DEFAULT_STATE: GameState = { export const DEFAULT_STATE: GameState = {
saveVersion: CURRENT_SAVE_VERSION,
resources: 0, resources: 0,
clickMultiplier: 1, clickMultiplier: 1,
generators: DEFAULT_GENERATORS, generators: DEFAULT_GENERATORS,
lastTick: Date.now(), lastTick: Date.now(),
lastOnline: Date.now(),
prestigeCount: 0, prestigeCount: 0,
prestigeMultiplier: 1, prestigeMultiplier: 1,
ancestralDna: 0, ancestralDna: 0,
evolutionTree: DEFAULT_EVOLUTION_TREE, evolutionTree: DEFAULT_EVOLUTION_TREE,
lifetimeTadpoles: 0, lifetimeTadpoles: 0,
cosmeticInventory: [],
cosmeticEquipped: {},
runStats: {
startedAt: Date.now(),
tadpolesProduced: 0,
bestRun: null,
},
freeResetAvailable: true,
extraResetsUsed: 0,
claimedMilestones: [],
}; };

View File

@@ -0,0 +1,142 @@
// migrateSave.ts — Migration lazy des saves entre versions
// Appliqué au chargement (frontend + backend). Jamais de migration en DB.
// Chaque sprint ajoute un step (v2→v3, etc.)
import { CURRENT_SAVE_VERSION } from "./balance";
import type { GameState } from "./economy";
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "./economy";
/**
* Détecte la version d'une save et applique les migrations nécessaires.
* Entrée : objet brut depuis la DB/localStorage (potentiellement incomplet).
* Sortie : GameState conforme à la version courante.
*/
export function migrateSave(raw: Record<string, unknown>): GameState {
const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1;
let state = raw as Record<string, unknown>;
if (version < 2) {
state = migrateV1toV2(state);
}
// Futures migrations :
// if (version < 3) state = migrateV2toV3(state);
return state as unknown as GameState;
}
/**
* v1 → v2 : Sprint 2 → Sprint 3
* - Ajoute saveVersion
* - Ajoute runStats (vide)
* - Ajoute freeResetAvailable + extraResetsUsed
* - Merge les nouveaux nœuds arbre (conserve l'état des 18 existants)
* - Backfill champs manquants (cosmeticInventory, cosmeticEquipped, lastOnline)
*/
function migrateV1toV2(raw: Record<string, unknown>): Record<string, unknown> {
const state = { ...raw };
// saveVersion
state.saveVersion = 2;
// RunStats (nouveau Sprint 3)
if (!state.runStats) {
state.runStats = {
startedAt: typeof state.lastTick === "number" ? state.lastTick : Date.now(),
tadpolesProduced: 0,
bestRun: null,
};
}
// Reset arbre : 1 gratuit par prestige
if (typeof state.freeResetAvailable !== "boolean") {
state.freeResetAvailable = true;
}
if (typeof state.extraResetsUsed !== "number") {
state.extraResetsUsed = 0;
}
// Milestones (Sprint 3)
if (!Array.isArray(state.claimedMilestones)) {
state.claimedMilestones = [];
}
// Backfill champs Sprint 2 potentiellement manquants
if (!state.lastOnline) state.lastOnline = state.lastTick;
if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = [];
if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") {
state.cosmeticEquipped = {};
}
// Merge arbre : conserver les 18 nœuds existants + ajouter les nouveaux
state.evolutionTree = mergeEvolutionTree(
state.evolutionTree as Array<Record<string, unknown>> | undefined
);
// Merge générateurs : conserver owned + ajouter les potentiels nouveaux
state.generators = mergeGenerators(
state.generators as Array<Record<string, unknown>> | undefined
);
return state;
}
/**
* Merge l'arbre sauvegardé avec DEFAULT_EVOLUTION_TREE.
* - Nœuds existants : conserve unlocked state
* - Nœuds nouveaux : ajoutés avec unlocked: false
* - Nœuds supprimés du default : retirés (forward compat)
*/
function mergeEvolutionTree(
savedTree: Array<Record<string, unknown>> | undefined
): typeof DEFAULT_EVOLUTION_TREE {
if (!savedTree || !Array.isArray(savedTree)) {
return DEFAULT_EVOLUTION_TREE.map((n) => ({ ...n }));
}
const savedById = new Map(
savedTree.map((n) => [n.id as string, n])
);
return DEFAULT_EVOLUTION_TREE.map((defaultNode) => {
const saved = savedById.get(defaultNode.id);
if (saved) {
// Conserver l'état unlocked, tout le reste vient du default
// (permet de corriger des valeurs rebalancées sans casser les saves)
return {
...defaultNode,
unlocked: saved.unlocked === true,
};
}
// Nouveau nœud — ajouté verrouillé
return { ...defaultNode };
});
}
/**
* Merge les générateurs sauvegardés avec DEFAULT_GENERATORS.
* Conserve le owned count, met à jour les stats de base.
*/
function mergeGenerators(
savedGens: Array<Record<string, unknown>> | undefined
): typeof DEFAULT_GENERATORS {
if (!savedGens || !Array.isArray(savedGens)) {
return DEFAULT_GENERATORS.map((g) => ({ ...g }));
}
const savedById = new Map(
savedGens.map((g) => [g.id as string, g])
);
return DEFAULT_GENERATORS.map((defaultGen) => {
const saved = savedById.get(defaultGen.id);
if (saved) {
return {
...defaultGen,
owned: typeof saved.owned === "number" ? saved.owned : 0,
};
}
return { ...defaultGen };
});
}

View File

@@ -165,9 +165,12 @@ export const ACHIEVEMENTS: Achievement[] = [
{ {
id: "full_tree", id: "full_tree",
name: "Évolution Complète", name: "Évolution Complète",
description: "Débloquer tous les noeuds de l'arbre.", description: "Débloquer un nœud dans chaque branche de l'arbre.",
icon: "🌳", icon: "🌳",
check: (s) => s.evolutionTree.every((n) => n.unlocked), check: (s) => {
const branches = new Set(s.evolutionTree.filter((n) => n.unlocked).map((n) => n.branch));
return branches.size >= 3;
},
}, },
// --- Easter eggs & humour --- // --- Easter eggs & humour ---
@@ -211,6 +214,6 @@ export const ACHIEVEMENTS: Achievement[] = [
name: "Le Cercle de la Vie", name: "Le Cercle de la Vie",
description: "Symbiose activée. Même Mufasa serait fier.", description: "Symbiose activée. Même Mufasa serait fier.",
icon: "🦁", icon: "🦁",
check: (s) => hasEvolutionNode(s, "symbiose"), check: (s) => hasEvolutionNode(s, "symbiose_algale"),
}, },
]; ];

View File

@@ -0,0 +1,76 @@
// prestigeMilestones.ts — Paliers de prestige (Sprint 3)
// 8 paliers : cosmétiques exclusifs + bonus gameplay légers
export type MilestoneRewardType = "cosmetic" | "bonus" | "title";
export interface PrestigeMilestone {
id: string;
threshold: number; // nombre de prestiges requis
name: string;
description: string;
reward: MilestoneReward;
}
export type MilestoneReward =
| { type: "cosmetic"; cosmeticId: string; label: string }
| { type: "bonus"; effect: string; value: number; label: string }
| { type: "title"; title: string; label: string };
export const PRESTIGE_MILESTONES: PrestigeMilestone[] = [
{
id: "milestone_1",
threshold: 1,
name: "Premiere Generation",
description: "Premier prestige accompli",
reward: { type: "cosmetic", cosmeticId: "ribbon", label: "Ruban queue" },
},
{
id: "milestone_3",
threshold: 3,
name: "Gardien Recurrent",
description: "3 prestiges — la perseverance paie",
reward: { type: "title", title: "Gardien Recurrent", label: "Titre exclusif" },
},
{
id: "milestone_5",
threshold: 5,
name: "Nid Offert",
description: "5 prestiges — un coup de pouce au depart",
reward: { type: "bonus", effect: "start_nid", value: 1, label: "1 Nid gratuit au depart" },
},
{
id: "milestone_10",
threshold: 10,
name: "Tetard Ancestral",
description: "10 prestiges — la lignee s'affirme",
reward: { type: "cosmetic", cosmeticId: "crown", label: "Couronne doree + skin Ancestral" },
},
{
id: "milestone_15",
threshold: 15,
name: "Marais Fidele",
description: "15 prestiges — le marais te reconnait",
reward: { type: "bonus", effect: "offline_cap_perm", value: 0.05, label: "+5% offline cap permanent" },
},
{
id: "milestone_25",
threshold: 25,
name: "Gardien Emerite",
description: "25 prestiges — tissu d'algues ancestrales",
reward: { type: "cosmetic", cosmeticId: "cape_algae", label: "Cape d'algues ancestrales" },
},
{
id: "milestone_50",
threshold: 50,
name: "Legende du Marais",
description: "50 prestiges — la legende est toi",
reward: { type: "cosmetic", cosmeticId: "flame_tail", label: "Queue enflamee + particules dorees" },
},
{
id: "milestone_100",
threshold: 100,
name: "Tetard Primordial",
description: "100 prestiges — retour aux origines",
reward: { type: "cosmetic", cosmeticId: "primordial_body", label: "Skin Tetard Primordial (full set)" },
},
];

View File

@@ -5,6 +5,7 @@ import { useEffect, useRef, useCallback, useState } from "react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useGameStore } from "../store/useGameStore"; import { useGameStore } from "../store/useGameStore";
import type { GameState } from "../core/economy"; import type { GameState } from "../core/economy";
import { migrateSave } from "../core/migrateSave";
const SAVE_INTERVAL_MS = 30_000; // 30 seconds const SAVE_INTERVAL_MS = 30_000; // 30 seconds
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3310"; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3310";
@@ -51,9 +52,10 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO
apiRequest("/save").then((data) => { apiRequest("/save").then((data) => {
if (data?.gameState) { if (data?.gameState) {
onLoad(data.gameState); const migrated = migrateSave(data.gameState);
onLoad(migrated);
lastSaveRef.current = data.lastSave; lastSaveRef.current = data.lastSave;
console.info("[SaveSync] Loaded save from server — server is authority"); console.info("[SaveSync] Loaded save from server — server is authority (v%d)", migrated.saveVersion);
} else { } else {
console.info("[SaveSync] No server save found — starting fresh"); console.info("[SaveSync] No server save found — starting fresh");
} }
@@ -99,7 +101,8 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO
setTimeout(() => apiRequest("/save").then((data) => { setTimeout(() => apiRequest("/save").then((data) => {
if (data?.gameState && data.lastSave) { if (data?.gameState && data.lastSave) {
if (!lastSaveRef.current || new Date(data.lastSave) > new Date(lastSaveRef.current)) { if (!lastSaveRef.current || new Date(data.lastSave) > new Date(lastSaveRef.current)) {
onLoad(data.gameState); const migrated = migrateSave(data.gameState);
onLoad(migrated);
lastSaveRef.current = data.lastSave; lastSaveRef.current = data.lastSave;
console.info("[SaveSync] Reloaded from server on focus"); console.info("[SaveSync] Reloaded from server on focus");
} }

View File

@@ -1,12 +1,967 @@
@import "tailwindcss"; @import "tailwindcss";
:root { /* ── Tailwind v4 theme — tokens du jeu ── */
margin: 0; @theme {
padding: 0; /* Base colors */
--color-blue-light: #dcecf3;
--color-purple-light: #e4e3f3;
--color-red-light: #c33636;
--color-light: #eaeaea;
--color-grey: #202020;
--color-grey-hover: #606060;
} /* Game panel tokens */
::-webkit-scrollbar { --color-gp-bg: rgba(17, 17, 17, 0.75);
width: 1px; --color-gp-bg-hover: rgba(17, 17, 17, 0.85);
display: none; --color-gp-border: rgba(255, 255, 255, 0.08);
--color-gp-text: rgba(255, 255, 255, 0.9);
--color-gp-text-muted: rgba(255, 255, 255, 0.5);
--color-gp-accent-green: #34d399;
--color-gp-accent-purple: #a78bfa;
--color-gp-accent-amber: #fbbf24;
--color-gp-accent-green-bg: rgba(16, 185, 129, 0.12);
--color-gp-accent-purple-bg: rgba(139, 92, 246, 0.12);
--color-gp-accent-amber-bg: rgba(251, 191, 36, 0.12);
--color-gp-btn: #059669;
--color-gp-btn-hover: #10b981;
--color-gp-btn-disabled: rgba(255, 255, 255, 0.08);
--color-gp-btn-text-disabled: rgba(255, 255, 255, 0.3);
/* Spacing / sizing tokens */
--radius-gp: 0.75rem;
--spacing-gp: 0.75rem;
--spacing-gp-gap: 0.5rem;
/* Font sizes */
--font-size-gp-title: 0.8rem;
--font-size-gp-text: 0.75rem;
--font-size-gp-sm: 0.65rem;
/* Animation */
--animate-gp-pulse: gp-pulse 2s ease-in-out infinite;
}
@keyframes gp-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(124, 58, 237, 0); }
}
/* ── Global reset & base ── */
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
:root {
--font: "Hanken Grotesk", sans-serif;
--bg-color: var(--color-blue-light);
}
a {
text-decoration: none;
}
main {
min-height: 92vh;
margin-top: 80px;
padding: 0 0 2rem;
background-color: var(--bg-color);
}
::-webkit-scrollbar {
width: 1px;
display: none;
}
}
/* ── Zone system (biomes) ── */
@layer components {
.zone {
width: 100%;
min-height: 92vh;
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
background-size: cover;
background-repeat: no-repeat;
transition: background-image 0.5s ease;
}
[data-zone="swamp"] {
background-image: url("/webp/bg-cover.webp");
background-position: center 70%;
}
[data-zone="landing"] {
background: var(--bg-color);
align-items: center;
}
[data-zone="page"] {
background: var(--bg-color);
align-items: flex-start;
min-height: auto;
}
/* ── Game panels design system ── */
.gp {
display: flex;
flex-direction: column;
gap: var(--spacing-gp-gap);
padding: var(--spacing-gp);
background: var(--color-gp-bg);
backdrop-filter: blur(8px);
border: 1px solid var(--color-gp-border);
border-radius: var(--radius-gp);
}
.gp-title {
font-family: var(--font);
font-size: var(--font-size-gp-title);
font-weight: 700;
color: var(--color-gp-text);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.gp-label {
font-family: var(--font);
font-size: var(--font-size-gp-sm);
font-weight: 500;
color: var(--color-gp-text-muted);
}
.gp-value {
font-family: var(--font);
font-size: var(--font-size-gp-text);
font-weight: 600;
color: var(--color-gp-text);
}
.gp-accent-green { color: var(--color-gp-accent-green); }
.gp-accent-purple { color: var(--color-gp-accent-purple); }
.gp-accent-amber { color: var(--color-gp-accent-amber); }
/* Row item (générateur, noeud évolution) */
.gp-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.4rem 0.5rem;
border-radius: calc(var(--radius-gp) - 0.15rem);
border: 1px solid transparent;
transition: background 0.15s ease, border-color 0.15s ease;
}
.gp-row--active {
border-color: rgba(16, 185, 129, 0.3);
background: var(--color-gp-accent-green-bg);
}
.gp-row--active:hover {
background: rgba(16, 185, 129, 0.18);
}
.gp-row--locked {
border-color: var(--color-gp-border);
background: rgba(255, 255, 255, 0.02);
opacity: 0.5;
}
.gp-row--evolution {
border-color: rgba(251, 191, 36, 0.3);
background: var(--color-gp-accent-amber-bg);
}
.gp-row--unlocked {
border-color: rgba(16, 185, 129, 0.3);
background: var(--color-gp-accent-green-bg);
}
/* Bouton achat */
.gp-btn {
font-family: var(--font);
font-size: var(--font-size-gp-sm);
font-weight: 600;
padding: 0.3rem 0.6rem;
border-radius: 0.4rem;
border: none;
cursor: pointer;
transition: background 0.15s ease;
white-space: nowrap;
}
.gp-btn--buy {
background: var(--color-gp-btn);
color: white;
}
.gp-btn--buy:hover {
background: var(--color-gp-btn-hover);
}
.gp-btn--disabled {
background: var(--color-gp-btn-disabled);
color: var(--color-gp-btn-text-disabled);
cursor: not-allowed;
}
.gp-btn--prestige {
background: #7c3aed;
color: white;
padding: 0.4rem 0.8rem;
font-size: var(--font-size-gp-text);
animation: var(--animate-gp-pulse);
}
.gp-btn--prestige:hover {
background: #8b5cf6;
}
/* Header cockpit (stats résumé) */
.gp-cockpit-header {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.2rem;
padding: 0.5rem;
}
.gp-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.05rem;
}
/* Progress bar */
.gp-progress {
height: 0.35rem;
background: rgba(255, 255, 255, 0.08);
border-radius: 1rem;
overflow: hidden;
}
.gp-progress-fill {
height: 100%;
border-radius: 1rem;
transition: width 0.5s ease;
}
/* Section separator */
.gp-sep {
height: 1px;
background: var(--color-gp-border);
margin: 0.15rem 0;
}
/* Zone titles in sidebar */
.gp-zone-label {
font-family: var(--font);
font-size: var(--font-size-gp-sm);
font-weight: 600;
color: var(--color-gp-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding-left: 0.2rem;
}
/* ── Home / Game view ── */
.click-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 1rem;
padding-bottom: 2vh;
cursor: pointer;
flex: 1;
}
@media (min-width: 768px) {
.click-zone {
padding-right: 22rem;
}
}
.click-zone:active img {
transform: scale(0.95) rotate(2deg);
}
.click-zone-counter {
font-family: var(--font);
font-size: 2rem;
font-weight: 800;
color: white;
text-shadow: 0 0 12px rgba(52, 211, 153, 0.5), 0 2px 6px rgba(0, 0, 0, 0.7);
pointer-events: none;
user-select: none;
letter-spacing: 0.02em;
}
@media (min-width: 768px) {
.click-zone-counter {
font-size: 2.5rem;
}
}
.achieve-badge {
display: block;
text-align: center;
padding: 0.4rem;
border-radius: var(--radius-gp);
background: var(--color-gp-accent-green-bg);
border: 1px solid rgba(16, 185, 129, 0.2);
font-family: var(--font);
font-size: var(--font-size-gp-sm);
font-weight: 600;
color: var(--color-gp-accent-green);
text-decoration: none;
transition: all 0.15s ease;
}
.achieve-badge:hover {
background: rgba(16, 185, 129, 0.2);
}
.click-particle {
position: fixed;
pointer-events: none;
font-family: var(--font);
font-size: 1.6rem;
font-weight: 800;
color: #34d399;
text-shadow: 0 0 8px rgba(52, 211, 153, 0.6), 0 2px 4px rgba(0, 0, 0, 0.7);
z-index: 100;
animation: float-up 1.2s ease-out forwards;
}
.game-sidebar {
position: fixed;
right: 0.75rem;
top: 5.5rem;
bottom: 0.75rem;
width: 20rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
z-index: 10;
padding-right: 0.25rem;
}
@media (max-width: 767px) {
.game-sidebar {
right: 0;
left: 0;
top: auto;
bottom: 0;
width: 100%;
max-height: 45vh;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
}
}
}
@keyframes float-up {
0% {
opacity: 1;
transform: translateY(0) scale(1.2);
}
60% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translateY(-80px) scale(1.5);
}
}
/* ── Navbar ── */
@layer components {
.header-main {
display: flex;
justify-content: space-between;
position: absolute;
width: 100%;
height: 80px;
padding: 0 2rem;
top: 0;
background-color: var(--bg-color);
background-blend-mode: darken;
background-size: cover;
z-index: 99;
box-sizing: border-box;
}
@media (max-width: 999px) {
.header-main {
padding: 0 0.4rem;
}
}
.logo {
width: 5rem;
content: url(/svg/tadpole.svg);
transition: 0.2s;
}
.logo:hover {
transform: scale(0.9);
}
.navbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
box-sizing: border-box;
cursor: pointer;
}
.nav-list {
display: flex;
justify-content: space-between;
gap: 1.6rem;
align-items: center;
list-style-type: none;
}
@media (max-width: 999px) {
.nav-list {
display: none;
}
}
.nav-list li {
list-style: none;
font-family: var(--font);
font-weight: 300;
font-size: 1rem;
color: white;
float: left;
width: fit-content;
}
.mainLink {
text-decoration: none;
color: var(--color-grey);
font-weight: 500;
padding: 30px 0;
}
.mainLink:hover {
color: var(--color-red-light);
}
.dropLink {
text-decoration: none;
color: white;
font-weight: 400;
}
.dropLink:hover {
color: var(--color-red-light);
}
.dropdown-content {
display: none;
position: absolute;
background: var(--color-grey);
transform: translateY(30px);
min-width: 160px;
box-shadow: 0 8px 16px rgba(10, 10, 10, 0.2);
z-index: 1;
}
.dropdown-content a {
color: white;
padding: 12px 16px;
text-decoration: none;
display: block;
text-align: left;
}
.dropdown-content a:hover {
background-color: var(--color-grey-hover);
}
.dropdown:hover .dropdown-content {
display: block;
}
.auth-nav {
display: flex;
align-items: center;
gap: 0.6rem;
font-family: var(--font);
}
.auth-nickname {
font-size: 0.9rem;
font-weight: 500;
color: var(--color-grey);
}
.auth-btn {
padding: 0.3rem 0.8rem;
border: 1px solid var(--color-grey);
border-radius: 0.4rem;
background: none;
font-family: var(--font);
font-size: 0.8rem;
color: var(--color-grey);
cursor: pointer;
transition: all 0.15s ease;
}
.auth-btn:hover {
background: var(--color-grey);
color: white;
}
/* ── Burger menu (mobile) ── */
@media (min-width: 1000px) {
.menuToggle {
display: none;
}
}
}
@media (max-width: 999px) {
.menuToggle {
float: left;
position: relative;
box-sizing: border-box;
top: 2px;
left: -10px;
z-index: 99;
user-select: none;
}
.menuToggle a {
text-decoration: none;
color: var(--color-grey);
transition: color 0.3s ease;
}
.menuToggle a:hover {
color: var(--color-red-light);
}
.menuToggle input {
display: block;
width: 40px;
height: 32px;
position: absolute;
top: -7px;
left: -5px;
cursor: pointer;
opacity: 0;
z-index: 2;
}
.menuToggle span {
display: block;
width: 33px;
height: 4px;
margin-bottom: 5px;
position: relative;
background: var(--color-grey);
border-radius: 3px;
z-index: 1;
transform-origin: 4px 0;
transition: transform 0.2s cubic-bezier(0.77, 0.2, 0.05, 1),
background 0.2s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease;
}
.menuToggle span:first-child {
transform-origin: 0% 0%;
}
.menuToggle span:nth-last-child(2) {
transform-origin: 0% 100%;
}
.menuToggle input:checked ~ span {
opacity: 1;
transform: rotate(45deg) translate(-2px, -1px);
background: white;
}
.menuToggle input:checked ~ span:nth-last-child(3) {
opacity: 0;
transform: rotate(0deg) scale(0.2, 0.2);
}
.menuToggle input:checked ~ span:nth-last-child(2) {
transform: rotate(-45deg) translate(0, -1px);
}
.menu {
position: absolute;
display: flex;
flex-direction: column;
width: 280px;
height: 110vh;
margin: -100px 0 0 -231px;
padding: 1.2rem;
padding-top: 100px;
background: var(--color-grey);
list-style-type: none;
transform-origin: 0% 0%;
overflow: hidden;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.menu li {
padding: 10px 0;
font-size: 1.2rem;
font-family: var(--font);
font-weight: 500;
color: white;
}
.menuToggle input:checked ~ ul {
visibility: visible;
opacity: 1;
}
.sousmenu {
display: flex;
flex-direction: column;
margin-left: 1.2rem;
color: white;
font-size: 1.2rem;
font-family: var(--font);
font-weight: 500;
padding-bottom: 1rem;
}
}
/* ── Buttons ── */
@layer components {
.primary-button {
display: flex;
padding: 0.6rem 1rem;
height: fit-content;
background-color: var(--color-red-light);
border-radius: 0.6rem;
justify-content: center;
text-decoration: none;
font-family: var(--font);
color: white !important;
text-align: center;
font-size: 1rem;
font-weight: 400;
transition: transform 0.1s ease-in-out;
border: none;
}
.primary-button:hover {
transform: scale(0.95);
}
.secondary-button {
display: flex;
padding: 1rem;
background-color: white;
border-radius: 0.6rem;
justify-content: center;
width: fit-content;
height: fit-content;
text-decoration: none;
font-family: var(--font);
color: var(--color-grey) !important;
text-align: center;
font-size: 1rem;
transition: transform 0.1s ease-in-out;
border: none;
}
.secondary-button:hover {
transform: scale(0.95);
background-color: var(--color-grey-hover);
}
}
/* ── Footer ── */
@layer components {
.footer {
display: flex;
flex-direction: column;
position: relative;
align-items: center;
bottom: 0;
left: 0;
width: 100%;
background-color: var(--bg-color);
border-top: solid 1px var(--color-grey);
padding: 2rem 0;
gap: 2rem;
}
.footer-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
width: 90%;
gap: 2rem;
}
.footer-logo {
background-image: url(/svg/tadpole.svg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
width: 250px;
height: 100px;
transition: all 0.15s ease-in-out;
}
.footer-logo:hover {
transform: scale(0.9);
}
.footer .section {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1.4rem;
}
.section-title {
font-family: var(--font);
font-size: 1.2rem;
color: var(--color-grey);
text-decoration-line: underline;
text-underline-offset: 0.5rem;
}
.section-text {
max-width: 26ch;
font-family: var(--font);
font-size: 1rem;
color: var(--color-grey);
}
.section-list {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1rem;
list-style: none;
}
.section-list .section-item,
.section-list a {
width: fit-content;
font-family: var(--font);
font-size: 1rem;
color: var(--color-grey);
transition: all 0.15s ease-in-out;
}
.section-list .section-item:hover,
.section-list a:hover {
transform: scale(0.9);
}
.spacing {
min-width: 150px;
width: 10%;
}
.footer-github {
font-family: var(--font);
font-size: 0.9rem;
font-weight: 500;
color: var(--color-grey);
text-decoration: none;
transition: all 0.15s ease-in-out;
}
.footer-github:hover {
transform: scale(0.95);
}
.copyright {
font-family: var(--font);
font-size: 0.8rem;
font-weight: 300;
color: var(--color-grey);
text-align: center;
}
/* ── Pages layout (error, legal, settings, login) ── */
.container {
display: flex;
flex-direction: column;
max-width: 132ch;
width: 80%;
gap: 3rem;
margin: 150px auto 50px;
}
.container h1 {
font-family: var(--font);
color: var(--color-grey);
font-size: 1.8rem;
text-align: center;
width: fit-content;
}
.container h2 {
font-family: var(--font);
font-size: 2rem;
font-weight: 600;
color: var(--color-grey);
}
.container .subtitle {
font-family: var(--font);
color: var(--color-grey);
font-size: 1.2rem;
font-weight: 600;
text-align: left;
margin-bottom: 0.8rem;
}
.container .content {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.container .paragraphe {
font-family: var(--font);
color: var(--color-grey);
font-size: 1rem;
font-weight: 400;
margin-bottom: 0.5rem;
list-style: inside;
}
.container .info {
font-family: var(--font);
color: var(--color-grey);
font-size: 1rem;
font-weight: 400;
}
section {
display: flex;
flex-direction: column;
height: 90vh;
justify-content: center;
width: 100%;
}
.containererror {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
.containererror h1 {
font-family: var(--font);
color: var(--color-grey);
font-size: 2rem;
text-align: center;
width: fit-content;
}
.message {
font-family: var(--font);
color: var(--color-grey);
font-size: 1rem;
font-weight: 300;
text-align: center;
}
.btn-return {
display: flex;
justify-content: center;
width: fit-content;
margin: auto;
padding: 0.5rem 1rem;
background-color: var(--color-grey);
border: none;
border-radius: 0.6rem;
font-family: var(--font);
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.btn-return:hover {
transform: scale(0.9);
}
/* ── Achievements ── */
.fullachieve {
display: flex;
flex-direction: column;
padding-top: 6rem;
padding-bottom: 3rem;
background-color: var(--color-blue-light);
width: 100%;
max-width: 1280px;
margin: 0 auto;
min-height: 80vh;
}
.fullachieve h1 {
text-align: center;
font-family: var(--font);
font-size: 2.5rem;
color: var(--color-grey);
margin-bottom: 0.5rem;
}
.achieve-counter {
text-align: center;
font-family: var(--font);
font-size: 1.1rem;
color: var(--color-grey);
opacity: 0.7;
margin-bottom: 2rem;
}
.achievementscontainer {
margin: auto;
display: flex;
align-items: center;
width: 100%;
padding: 0 2rem;
}
.achievementscardcontainer {
display: flex;
justify-content: center;
flex-wrap: wrap;
min-height: 200px;
gap: 1rem;
width: 100%;
}
.achieve-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.2rem;
border-radius: 0.75rem;
width: 100%;
max-width: 380px;
transition: transform 0.15s ease;
}
.achieve-card:hover {
transform: translateY(-2px);
}
.achieve-unlocked {
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.achieve-locked {
background: rgba(107, 114, 128, 0.08);
border: 1px solid rgba(107, 114, 128, 0.15);
opacity: 0.5;
}
.achieve-icon {
font-size: 2rem;
flex-shrink: 0;
width: 3rem;
text-align: center;
}
.achieve-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.achieve-name {
font-family: var(--font);
font-size: 1rem;
font-weight: 600;
color: var(--color-grey);
}
.achieve-desc {
font-family: var(--font);
font-size: 0.85rem;
color: var(--color-grey);
opacity: 0.7;
}
/* ── Legal / Cookie pages ── */
.mentionslegales {
width: 100%;
margin: 0 auto;
max-width: 1280px;
font-family: var(--font);
display: flex;
flex-direction: column;
gap: 3rem;
padding: 15rem 1rem 4rem;
}
.mentionslegales h2 {
font-family: var(--font);
font-size: 2rem;
font-weight: 600;
color: var(--color-grey);
}
} }

View File

@@ -1,5 +1,6 @@
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import "./index.css";
import App from "./App"; import App from "./App";
import Landing from "./pages/Landing"; import Landing from "./pages/Landing";
import Home from "./pages/Home"; import Home from "./pages/Home";

View File

@@ -1,5 +1,4 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import "../scss/pages.scss";
import Lottie from "react-lottie-player"; import Lottie from "react-lottie-player";
import animation404 from "../data/404-animation.json"; import animation404 from "../data/404-animation.json";

View File

@@ -1,6 +1,5 @@
import { useGameStore } from "../store/useGameStore"; import { useGameStore } from "../store/useGameStore";
import { ACHIEVEMENTS } from "../data/achievements"; import { ACHIEVEMENTS } from "../data/achievements";
import "../scss/achievements.scss";
function Achievements() { function Achievements() {
const state = useGameStore((s) => s.state); const state = useGameStore((s) => s.state);

View File

@@ -3,7 +3,6 @@ import { useNavigate, Link } from "react-router-dom";
import { exchangeCode, loadVerifier, clearVerifier } from "../lib/oauth"; import { exchangeCode, loadVerifier, clearVerifier } from "../lib/oauth";
import { apiFetch } from "../lib/api"; import { apiFetch } from "../lib/api";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import "../scss/pages.scss";
export default function AuthCallback() { export default function AuthCallback() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -1,4 +1,3 @@
import "../scss/Cookie.scss";
function Cookie() { function Cookie() {
return ( return (
<div className="container"> <div className="container">

View File

@@ -10,9 +10,11 @@ import { PrestigePanel } from "../components/PrestigePanel";
import { EvolutionTree } from "../components/EvolutionTree"; import { EvolutionTree } from "../components/EvolutionTree";
import { MilestoneBar } from "../components/MilestoneBar"; import { MilestoneBar } from "../components/MilestoneBar";
import { CockpitHeader } from "../components/CockpitHeader"; import { CockpitHeader } from "../components/CockpitHeader";
import { TadpoleSprite } from "../components/TadpoleSprite";
import { CosmeticsPanel } from "../components/CosmeticsPanel";
import { PrestigeScreen } from "../components/PrestigeScreen";
import { MilestonesPanel } from "../components/MilestonesPanel";
import { ACHIEVEMENTS } from "../data/achievements"; import { ACHIEVEMENTS } from "../data/achievements";
import "../scss/home.scss";
import "../scss/components/game-panels.scss";
export default function Home() { export default function Home() {
const [toggleRain] = useOutletContext(); const [toggleRain] = useOutletContext();
@@ -22,21 +24,30 @@ export default function Home() {
const state = useGameStore((s) => s.state); const state = useGameStore((s) => s.state);
const clickGain = getClickGain(state); const clickGain = getClickGain(state);
const createParticle = useCallback((clientX, clientY) => { const lastClickGain = useGameStore((s) => s.lastClickGain);
const lastClickDouble = useGameStore((s) => s.lastClickDouble);
const lastClickCrit = useGameStore((s) => s.lastClickCrit);
const createParticle = useCallback((clientX, clientY, gain, isDouble, isCrit) => {
const particle = document.createElement("span"); const particle = document.createElement("span");
particle.className = "click-particle"; particle.className = "click-particle";
particle.textContent = `+${formatNumber(clickGain)}`; const prefix = isCrit ? "CRIT " : isDouble ? "x2 " : "";
particle.textContent = `${prefix}+${formatNumber(gain)}`;
if (isCrit) particle.style.color = "#f59e0b";
else if (isDouble) particle.style.color = "#a78bfa";
particle.style.left = `${clientX}px`; particle.style.left = `${clientX}px`;
particle.style.top = `${clientY}px`; particle.style.top = `${clientY}px`;
document.body.appendChild(particle); document.body.appendChild(particle);
setTimeout(() => { setTimeout(() => {
if (particle.parentNode) particle.parentNode.removeChild(particle); if (particle.parentNode) particle.parentNode.removeChild(particle);
}, 800); }, 800);
}, [clickGain]); }, []);
const handleIncrement = useCallback((e) => { const handleIncrement = useCallback((e) => {
click(); click();
createParticle(e.clientX, e.clientY); // Read latest click result from store after click
const s = useGameStore.getState();
createParticle(e.clientX, e.clientY, s.lastClickGain, s.lastClickDouble, s.lastClickCrit);
}, [click, createParticle]); }, [click, createParticle]);
// Rain effect (ambiance) // Rain effect (ambiance)
@@ -113,7 +124,7 @@ export default function Home() {
if (!ready) { if (!ready) {
return ( return (
<section className="game-container"> <section className="game-container">
<p style={{ textAlign: "center", color: "#6b7a99", marginTop: "20vh" }}> <p className="text-center text-slate-400 mt-[20vh]">
Chargement de ta progression... Chargement de ta progression...
</p> </p>
</section> </section>
@@ -127,9 +138,11 @@ export default function Home() {
<title>Clickerz Tetard Universe</title> <title>Clickerz Tetard Universe</title>
</Helmet> </Helmet>
<PrestigeScreen />
{/* Clicker area — centre */} {/* Clicker area — centre */}
<div className="click-zone" onClick={handleIncrement}> <div className="click-zone" onClick={handleIncrement}>
<div className="tadpole-sprite" /> <TadpoleSprite />
<div className="click-zone-counter"> <div className="click-zone-counter">
{formatNumber(resources)} {formatNumber(resources)}
</div> </div>
@@ -143,7 +156,9 @@ export default function Home() {
<GeneratorShop /> <GeneratorShop />
<div className="gp-sep" /> <div className="gp-sep" />
<PrestigePanel /> <PrestigePanel />
<MilestonesPanel />
<EvolutionTree /> <EvolutionTree />
<CosmeticsPanel />
<a href="/achievements" className="achieve-badge"> <a href="/achievements" className="achieve-badge">
{ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès {ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès
</a> </a>

View File

@@ -1,4 +1,3 @@
import "../scss/Legal.scss";
function Legal() { function Legal() {
return ( return (
<div className="mentionslegales"> <div className="mentionslegales">

View File

@@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { buildAuthUrl, saveVerifier } from "../lib/oauth"; import { buildAuthUrl, saveVerifier } from "../lib/oauth";
import "../scss/pages.scss";
const PROVIDERS = [ const PROVIDERS = [
{ id: "discord", label: "Discord", emoji: "🎮" }, { id: "discord", label: "Discord", emoji: "🎮" },
@@ -31,7 +30,7 @@ export default function Login() {
<div className="containererror"> <div className="containererror">
<h1>Connexion</h1> <h1>Connexion</h1>
<p className="message">Connecte-toi pour sauvegarder ta progression.</p> <p className="message">Connecte-toi pour sauvegarder ta progression.</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 16 }}> <div className="flex flex-col gap-2 mt-4">
{PROVIDERS.map((p) => ( {PROVIDERS.map((p) => (
<button <button
key={p.id} key={p.id}

View File

@@ -1,6 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import "../scss/pages.scss";
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || ""; const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || "";
const PROVIDERS = ["discord", "github", "google", "twitch"]; const PROVIDERS = ["discord", "github", "google", "twitch"];
@@ -87,7 +86,6 @@ export default function Settings() {
returnUrl: `${window.location.origin}/settings`, returnUrl: `${window.location.origin}/settings`,
}), }),
}); });
// Redirect to OAuth provider
window.location.href = data.data.authUrl; window.location.href = data.data.authUrl;
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
@@ -134,30 +132,28 @@ export default function Settings() {
return ( return (
<section> <section>
<div className="containererror" style={{ maxWidth: 500 }}> <div className="containererror max-w-[500px]">
<h1>Paramètres</h1> <h1>Paramètres</h1>
{error && ( {error && (
<p style={{ color: "#ef4444", fontSize: 13, marginBottom: 16 }}> <p className="text-red-500 text-[13px] mb-4">{error}</p>
{error}
</p>
)} )}
{/* Profile info */} {/* Profile info */}
{profile && ( {profile && (
<div style={{ marginBottom: 24, textAlign: "left" }}> <div className="mb-6 text-left">
<p style={{ fontSize: 14, color: "#9ca3af", margin: "4px 0" }}> <p className="text-sm text-gray-400 my-1">
<strong>Pseudo :</strong> {profile.nickname} <strong>Pseudo :</strong> {profile.nickname}
</p> </p>
<p style={{ fontSize: 14, color: "#9ca3af", margin: "4px 0" }}> <p className="text-sm text-gray-400 my-1">
<strong>Email :</strong> {profile.email || "—"} <strong>Email :</strong> {profile.email || "—"}
</p> </p>
</div> </div>
)} )}
{/* Linked providers */} {/* Linked providers */}
<h2 style={{ fontSize: 18, marginBottom: 12 }}>Comptes liés</h2> <h2 className="text-lg mb-3">Comptes liés</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div className="flex flex-col gap-2">
{PROVIDERS.map((provider) => { {PROVIDERS.map((provider) => {
const linked = linkedNames.has(provider); const linked = linkedNames.has(provider);
const isLoading = actionLoading === provider; const isLoading = actionLoading === provider;
@@ -165,39 +161,32 @@ export default function Settings() {
return ( return (
<div <div
key={provider} key={provider}
style={{ className={`flex items-center justify-between px-3 py-2 rounded-lg border ${
display: "flex", linked
alignItems: "center", ? "bg-[#1a2a1a] border-[#2a4a2a]"
justifyContent: "space-between", : "bg-[#1a1a2a] border-[#2a2a4a]"
padding: "8px 12px", }`}
background: linked ? "#1a2a1a" : "#1a1a2a",
borderRadius: 8,
border: `1px solid ${linked ? "#2a4a2a" : "#2a2a4a"}`,
}}
> >
<span style={{ fontSize: 14 }}> <span className="text-sm">
{EMOJIS[provider]} {provider.charAt(0).toUpperCase() + provider.slice(1)} {EMOJIS[provider]} {provider.charAt(0).toUpperCase() + provider.slice(1)}
{linked && ( {linked && (
<span style={{ color: "#4ade80", fontSize: 12, marginLeft: 8 }}> <span className="text-green-400 text-xs ml-2"> lié</span>
lié
</span>
)} )}
</span> </span>
{linked ? ( {linked ? (
<button <button
className="btn-return" className="btn-return text-xs! py-1! px-2.5!"
style={{ fontSize: 12, padding: "4px 10px", opacity: canUnlink ? 1 : 0.4 }}
disabled={!canUnlink || isLoading} disabled={!canUnlink || isLoading}
onClick={() => handleUnlink(provider)} onClick={() => handleUnlink(provider)}
type="button" type="button"
style={{ opacity: canUnlink ? 1 : 0.4 }}
> >
{isLoading ? "..." : "Délier"} {isLoading ? "..." : "Délier"}
</button> </button>
) : ( ) : (
<button <button
className="btn-return" className="btn-return text-xs! py-1! px-2.5!"
style={{ fontSize: 12, padding: "4px 10px" }}
disabled={isLoading} disabled={isLoading}
onClick={() => handleLink(provider)} onClick={() => handleLink(provider)}
type="button" type="button"
@@ -212,8 +201,7 @@ export default function Settings() {
{/* Logout */} {/* Logout */}
<button <button
className="btn-return" className="btn-return mt-6 w-full!"
style={{ marginTop: 24, width: "100%" }}
onClick={logout} onClick={logout}
type="button" type="button"
> >

View File

@@ -1,3 +0,0 @@
a {
text-decoration: none;
}

View File

@@ -1,19 +0,0 @@
.container {
width: 100%;
margin: 0 auto;
max-width: 1280px;
font-family: var(--font);
display: flex;
flex-direction: column;
gap: 3rem;
padding: 15rem 1rem 4rem;
}
h2 {
font-family: var(--font);
font-size: 2rem;
font-weight: 600;
color: var(--color-grey);
}

View File

@@ -1,19 +0,0 @@
.mentionslegales {
width: 100%;
margin: 0 auto;
max-width: 1280px;
font-family: var(--font);
display: flex;
flex-direction: column;
gap: 3rem;
padding: 15rem 1rem 4rem;
}
h2 {
font-family: var(--font);
font-size: 2rem;
font-weight: 600;
color: var(--color-grey);
}

View File

@@ -1,98 +0,0 @@
.fullachieve {
display: flex;
flex-direction: column;
padding-top: 6rem;
padding-bottom: 3rem;
background-color: var(--color-blue-light);
width: 100%;
max-width: 1280px;
margin: 0 auto;
min-height: 80vh;
h1 {
text-align: center;
font-family: var(--font);
font-size: 2.5rem;
color: var(--color-grey);
margin-bottom: 0.5rem;
}
}
.achieve-counter {
text-align: center;
font-family: var(--font);
font-size: 1.1rem;
color: var(--color-grey);
opacity: 0.7;
margin-bottom: 2rem;
}
.achievementscontainer {
margin: auto;
display: flex;
align-items: center;
width: 100%;
padding: 0 2rem;
}
.achievementscardcontainer {
display: flex;
justify-content: center;
flex-wrap: wrap;
min-height: 200px;
gap: 1rem;
width: 100%;
}
.achieve-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.2rem;
border-radius: 0.75rem;
width: 100%;
max-width: 380px;
transition: transform 0.15s ease;
&:hover {
transform: translateY(-2px);
}
}
.achieve-unlocked {
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.achieve-locked {
background: rgba(107, 114, 128, 0.08);
border: 1px solid rgba(107, 114, 128, 0.15);
opacity: 0.5;
}
.achieve-icon {
font-size: 2rem;
flex-shrink: 0;
width: 3rem;
text-align: center;
}
.achieve-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.achieve-name {
font-family: var(--font);
font-size: 1rem;
font-weight: 600;
color: var(--color-grey);
}
.achieve-desc {
font-family: var(--font);
font-size: 0.85rem;
color: var(--color-grey);
opacity: 0.7;
}

View File

@@ -1,46 +0,0 @@
.primary-button {
display: flex;
padding: 0.6rem 1rem;
height: fit-content;
background-color: var(--color-red-light);
border-radius: 0.6rem;
justify-content: center;
text-decoration: none;
font-family: var(--font);
color: var(--color-white) !important;
text-align: center;
font-size: 1rem;
font-weight: 400;
transition: transform 0.1s ease-in-out;
border: none;
&:hover {
transform: scale(0.95);
background-color: var(--color-red-light);
}
}
.secondary-button {
display: flex;
padding: 1rem 1rem;
background-color: var(--color-white);
border-radius: 0.6rem;
justify-content: center;
width: fit-content;
height: fit-content;
text-decoration: none;
font-family: var(--font);
color: var(--color-grey)!important;
text-align: center;
font-size: 1rem;
transition: transform 0.1s ease-in-out;
border: none;
&:hover {
transform: scale(0.95);
background-color: var(--color-grey-hover);
}
}

View File

@@ -1,105 +0,0 @@
.footer {
display: flex;
flex-direction: column;
position: relative;
align-items: center;
bottom: 0;
left: 0;
width: 100%;
background-color: var(--bg-color);
border-top: solid 1px var(--color-grey);
padding: 2rem 0;
gap: 2rem;
.footer-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
width: 90%;
gap: 2rem;
}
.footer-logo {
background-image: url(/svg/tadpole.svg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
width: 250px;
height: 100px;
transition: all 0.15s ease-in-out;
&:hover {
transform: scale(0.9);
}
}
.section {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1.4rem;
.section-title {
font-family: var(--font);
font-size: 1.2rem;
color: var(--color-grey);
text-decoration-line: underline;
text-underline-offset: 0.5rem;
}
.section-text {
max-width: 26ch;
font-family: var(--font);
font-size: 1rem;
color: var(--color-grey);
}
.section-list {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1rem;
list-style: none;
.section-item,
a {
width: fit-content;
font-family: var(--font);
font-size: 1rem;
color: var(--color-grey);
transition: all 0.15s ease-in-out;
&:hover {
transform: scale(0.9);
}
}
}
}
.spacing {
min-width: 150px;
width: 10%;
}
.footer-github {
font-family: var(--font);
font-size: 0.9rem;
font-weight: 500;
color: var(--color-grey);
text-decoration: none;
transition: all 0.15s ease-in-out;
&:hover {
transform: scale(0.95);
}
}
.copyright {
font-family: var(--font);
font-size: 0.8rem;
font-weight: 300;
color: var(--color-grey);
text-align: center;
}
}

View File

@@ -1,170 +0,0 @@
// game-panels.scss — Système de style partagé pour tous les panels du cockpit
// Modifier les tokens dans root.scss, pas ici.
// --- Panel de base ---
.gp {
display: flex;
flex-direction: column;
gap: var(--gp-gap);
padding: var(--gp-padding);
background: var(--gp-bg);
backdrop-filter: blur(8px);
border: 1px solid var(--gp-border);
border-radius: var(--gp-radius);
}
.gp-title {
font-family: var(--font);
font-size: var(--gp-title);
font-weight: 700;
color: var(--gp-text-color);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.gp-label {
font-family: var(--font);
font-size: var(--gp-text-sm);
font-weight: 500;
color: var(--gp-text-muted);
}
.gp-value {
font-family: var(--font);
font-size: var(--gp-text);
font-weight: 600;
color: var(--gp-text-color);
}
.gp-accent-green { color: var(--gp-accent-green); }
.gp-accent-purple { color: var(--gp-accent-purple); }
.gp-accent-amber { color: var(--gp-accent-amber); }
// --- Row item (générateur, noeud évolution) ---
.gp-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.4rem 0.5rem;
border-radius: calc(var(--gp-radius) - 0.15rem);
border: 1px solid transparent;
transition: background 0.15s ease, border-color 0.15s ease;
}
.gp-row--active {
border-color: rgba(16, 185, 129, 0.3);
background: var(--gp-accent-green-bg);
&:hover {
background: rgba(16, 185, 129, 0.18);
}
}
.gp-row--locked {
border-color: var(--gp-border);
background: rgba(255, 255, 255, 0.02);
opacity: 0.5;
}
.gp-row--evolution {
border-color: rgba(251, 191, 36, 0.3);
background: var(--gp-accent-amber-bg);
}
.gp-row--unlocked {
border-color: rgba(16, 185, 129, 0.3);
background: var(--gp-accent-green-bg);
}
// --- Bouton achat ---
.gp-btn {
font-family: var(--font);
font-size: var(--gp-text-sm);
font-weight: 600;
padding: 0.3rem 0.6rem;
border-radius: 0.4rem;
border: none;
cursor: pointer;
transition: background 0.15s ease;
white-space: nowrap;
}
.gp-btn--buy {
background: var(--gp-btn-bg);
color: white;
&:hover {
background: var(--gp-btn-bg-hover);
}
}
.gp-btn--disabled {
background: var(--gp-btn-disabled);
color: var(--gp-btn-text-disabled);
cursor: not-allowed;
}
.gp-btn--prestige {
background: #7c3aed;
color: white;
padding: 0.4rem 0.8rem;
font-size: var(--gp-text);
animation: gp-pulse 2s ease-in-out infinite;
&:hover {
background: #8b5cf6;
}
}
@keyframes gp-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(124, 58, 237, 0); }
}
// --- Header cockpit (stats résumé) ---
.gp-cockpit-header {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.2rem;
padding: 0.5rem;
}
.gp-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.05rem;
}
// --- Progress bar ---
.gp-progress {
height: 0.35rem;
background: rgba(255, 255, 255, 0.08);
border-radius: 1rem;
overflow: hidden;
}
.gp-progress-fill {
height: 100%;
border-radius: 1rem;
transition: width 0.5s ease;
}
// --- Section separator ---
.gp-sep {
height: 1px;
background: var(--gp-border);
margin: 0.15rem 0;
}
// --- Zone titles in sidebar ---
.gp-zone-label {
font-family: var(--font);
font-size: var(--gp-text-sm);
font-weight: 600;
color: var(--gp-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding-left: 0.2rem;
}

View File

@@ -1,306 +0,0 @@
.header-main {
display: flex;
justify-content: space-between;
position: absolute;
width: 100%;
height: 80px;
padding: 0rem 2rem;
top: 0;
background-color: var(--bg-color);
background-blend-mode: darken;
background-size: cover;
z-index: 99;
box-sizing: border-box;
@media (max-width: 999px) {
padding: 0rem 0.4rem;
box-sizing: border-box;
}
}
.logo {
width: 5rem;
content: url(/svg/tadpole.svg);
transition: 0.2s;
&:hover {
width: 5rem;
transition: 0.2s;
transform: scale(0.9);
}
}
.navbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
box-sizing: border-box;
cursor: pointer;
.nav-list {
display: flex;
justify-content: space-between;
gap: 1.6rem;
align-items: center;
@media screen and (max-width: 999px) {
display: none;
}
li {
list-style: none;
font-family: var(--font);
font-weight: 300;
font-size: 1rem;
color: var(--color-white);
height: 100%;
.mainLink {
text-decoration: none;
color: var(--color-grey);
font-weight: 500;
padding: 30px 0;
&:hover {
color: var(--color-red-light);
font-weight: 500;
}
}
.dropLink {
text-decoration: none;
color: var(--color-white);
font-weight: 400;
&:hover {
color: var(--color-red-light);
font-weight: 400;
}
}
}
}
ul {
list-style-type: none;
@media screen and (max-width: 999px) {
color: var(--color-white) !important;
}
li {
float: left;
width: fit-content;
.dropdown-content {
display: none;
position: absolute;
background: var(--color-black);
transform: translateY(30px);
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(10, 10, 10, 0.2);
z-index: 1;
}
li.dropdown {
display: inline-block;
}
a {
color: var(--color-white);
width: fit-content;
}
a:hover,
.dropdown:hover {
color: var(--color-red-light);
font-weight: 400;
}
}
}
.dropdown-content a {
color: var(--color-black);
padding: 12px 16px;
text-decoration: none;
display: block;
text-align: left;
&:hover {
background-color: var(--color-black);
color: var(--color-gold);
}
}
.dropdown:hover .dropdown-content {
display: block;
}
}
@media screen and (min-width: 1000px) {
.menuToggle {
display: none;
}
}
@media screen and (max-width: 999px) {
.menuToggle {
float: left;
position: relative;
box-sizing: border-box;
top: 2px;
left: -10px;
z-index: 99;
-webkit-user-select: none;
user-select: none;
}
.menuToggle a {
text-decoration: none;
color: var(--color-grey);
transition: color 0.3s ease;
}
.menuToggle a:hover {
color: var(--color-red-light);
}
.menuToggle input {
display: block;
width: 40px;
height: 32px;
position: absolute;
top: -7px;
left: -5px;
cursor: pointer;
opacity: 0;
z-index: 2;
-webkit-touch-callout: none;
}
.menuToggle span {
display: block;
width: 33px;
height: 4px;
margin-bottom: 5px;
position: relative;
background: var(--color-grey);
border-radius: 3px;
z-index: 1;
transform-origin: 4px 0px;
transition: transform 0.2s cubic-bezier(0.77, 0.2, 0.05, 1),
background 0.2s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease;
}
.menuToggle span:first-child {
transform-origin: 0% 0%;
}
.menuToggle span:nth-last-child(2) {
transform-origin: 0% 100%;
}
.menuToggle input:checked ~ span {
opacity: 1;
transform: rotate(45deg) translate(-2px, -1px);
background: var(--color-white);
}
.menuToggle input:checked ~ span:nth-last-child(3) {
opacity: 0;
transform: rotate(0deg) scale(0.2, 0.2);
}
.menuToggle input:checked ~ span:nth-last-child(2) {
transform: rotate(-45deg) translate(0, -1px);
}
.menu {
position: absolute;
display: flex;
flex-direction: column;
width: 280px;
height: 110vh;
margin: -100px 0 0 -231px;
padding: 1.2rem;
padding-top: 100px;
background: var(--color-grey);
list-style-type: none;
transform-origin: 0% 0%;
overflow: hidden !important;
visibility: hidden;
opacity: 0%;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.menu li {
padding: 10px 0;
font-size: 1.2rem;
font-family: var(--font);
font-weight: 500;
color: var(--color-white);
}
.menuToggle input:checked ~ ul {
visibility: visible;
opacity: 100;
}
.sousmenu {
display: flex;
flex-direction: column;
margin-left: 1.2rem;
color: var(--color-white);
font-size: 1.2rem;
font-family: var(--font);
font-weight: 500;
padding-bottom: 1rem;
}
.empty {
line-height: 20rem;
}
}
.auth-nav {
display: flex;
align-items: center;
gap: 0.6rem;
font-family: var(--font);
.auth-nickname {
font-size: 0.9rem;
font-weight: 500;
color: var(--color-grey);
}
.auth-btn {
padding: 0.3rem 0.8rem;
border: 1px solid var(--color-grey);
border-radius: 0.4rem;
background: none;
font-family: var(--font);
font-size: 0.8rem;
color: var(--color-grey);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--color-grey);
color: var(--color-white);
}
}
}

View File

@@ -1,133 +0,0 @@
// home.scss — Game view styles (layout géré par zones.scss)
// --- Clicker zone ---
.click-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 1rem;
padding-bottom: 2vh;
cursor: pointer;
flex: 1;
// Desktop: center
@media (min-width: 768px) {
padding-right: 22rem; // offset for sidebar
}
}
.tadpole-sprite {
width: 280px;
height: 280px;
background: url("/svg/tadpole.svg") no-repeat center / contain;
transition: transform 0.1s ease;
@media (min-width: 768px) {
width: 320px;
height: 320px;
}
.click-zone:active & {
transform: scale(0.95) rotate(2deg);
}
}
// --- Compteur ressources sous le têtard ---
.click-zone-counter {
font-family: var(--font);
font-size: 2rem;
font-weight: 800;
color: white;
text-shadow: 0 0 12px rgba(52, 211, 153, 0.5), 0 2px 6px rgba(0, 0, 0, 0.7);
pointer-events: none;
user-select: none;
letter-spacing: 0.02em;
@media (min-width: 768px) {
font-size: 2.5rem;
}
}
// --- Badge achievements sidebar ---
.achieve-badge {
display: block;
text-align: center;
padding: 0.4rem;
border-radius: var(--gp-radius);
background: var(--gp-accent-green-bg);
border: 1px solid rgba(16, 185, 129, 0.2);
font-family: var(--font);
font-size: var(--gp-text-sm);
font-weight: 600;
color: var(--gp-accent-green);
text-decoration: none;
transition: all 0.15s ease;
&:hover {
background: rgba(16, 185, 129, 0.2);
}
}
// --- Click feedback particle ---
.click-particle {
position: fixed;
pointer-events: none;
font-family: var(--font);
font-size: 1.6rem;
font-weight: 800;
color: #34d399;
text-shadow: 0 0 8px rgba(52, 211, 153, 0.6), 0 2px 4px rgba(0, 0, 0, 0.7);
z-index: 100;
animation: float-up 1.2s ease-out forwards;
}
@keyframes float-up {
0% {
opacity: 1;
transform: translateY(0) scale(1.2);
}
60% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translateY(-80px) scale(1.5);
}
}
// --- Game sidebar ---
.game-sidebar {
position: fixed;
right: 0.75rem;
top: 5.5rem;
bottom: 0.75rem;
width: 20rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
z-index: 10;
padding-right: 0.25rem;
// Mobile: bottom drawer
@media (max-width: 767px) {
position: fixed;
right: 0;
left: 0;
top: auto;
bottom: 0;
width: 100%;
max-height: 45vh;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
}
}

View File

@@ -1,171 +0,0 @@
.container {
display: flex;
flex-direction: column;
max-width: 132ch;
width: 80%;
gap: 3rem;
margin: 150px auto 50px;
h1 {
font-family: var(--font);
color: var(--color-black);
font-size: 1.8rem;
text-align: center;
width: fit-content;
}
.separator {
border: solid 1px var(--color-gold-hover);
}
.massageinfo {
display: flex;
flex-direction: column;
gap: 0.4rem;
.info {
font-family: var(--font);
color: var(--color-grey);
font-size: 1rem;
font-weight: 400;
}
}
.subtitle {
font-family: var(--font);
color: var(--color-black);
font-size: 1.2rem;
font-weight: 600;
text-align: left;
margin-bottom: 0.8rem;
}
.content {
display: flex;
flex-direction: column;
gap: 0.6rem;
.subtitle {
font-family: var(--font);
color: var(--color-black);
font-size: 1.2rem;
font-weight: 600;
text-align: left;
margin-bottom: 0.5rem;
}
.paragraphe {
font-family: var(--font);
color: var(--color-grey);
font-size: 1rem;
font-weight: 400;
margin-bottom: 0.5rem;
list-style: inside;
a {
font-family: var(--font);
color: var(--color-gold-link);
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
list-style: inside;
text-decoration: none;
&:hover {
color: var(--color-gold-hover);
}
}
}
.picture-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 1rem;
@media (max-width: 449px) {
flex-direction: column;
}
.picture {
background-position: center;
background-size: cover;
width: 100%;
height: 300px;
}
}
}
//Massages pages
.listing-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 3rem;
.listcontent {
display: flex;
flex-direction: column;
.listdetail {
font-family: var(--font);
color: var(--color-grey);
font-size: 0.95rem;
font-weight: 400;
margin-bottom: 0.5rem;
list-style-type: none;
padding-left: 1rem;
}
}
}
}
//error pages
section {
display: flex;
flex-direction: column;
height: 90vh;
justify-content: center;
width: 100%;
.containererror {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
h1 {
font-family: var(--font);
color: var(--color-black);
font-size: 2rem;
text-align: center;
width: fit-content;
}
.message {
font-family: var(--font);
color: var(--color-grey);
font-size: 1rem;
font-weight: 300;
text-align: center;
}
.btn-return {
display: flex;
justify-content: center;
width: fit-content;
margin: auto;
padding: 0.5rem 1rem;
background-color: var(--color-grey);
border: none;
border-radius: 0.6rem;
font-family: var(--font);
color: var(--color-white);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
&:hover {
background-color: var(--color-grey);
transform: scale(0.9);
}
}
}
}

View File

@@ -1,60 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
:root {
--color-blue-light: #dcecf3;
--color-purple-light: #e4e3f3;
--color-red-light: #c33636;
--color-white: #ffffff;
--color-light: #eaeaea;
--color-grey: #202020;
--color-grey-hover: #606060;
--bg-color: var(--color-blue-light);
--font: "Hanken Grotesk", sans-serif;
// --- Game panel tokens ---
--gp-bg: rgba(17, 17, 17, 0.75);
--gp-bg-hover: rgba(17, 17, 17, 0.85);
--gp-border: rgba(255, 255, 255, 0.08);
--gp-radius: 0.75rem;
--gp-padding: 0.75rem;
--gp-gap: 0.5rem;
// Text
--gp-title: 0.8rem;
--gp-text: 0.75rem;
--gp-text-sm: 0.65rem;
--gp-text-color: rgba(255, 255, 255, 0.9);
--gp-text-muted: rgba(255, 255, 255, 0.5);
// Accent colors
--gp-accent-green: #34d399;
--gp-accent-purple: #a78bfa;
--gp-accent-amber: #fbbf24;
--gp-accent-green-bg: rgba(16, 185, 129, 0.12);
--gp-accent-purple-bg: rgba(139, 92, 246, 0.12);
--gp-accent-amber-bg: rgba(251, 191, 36, 0.12);
// Buttons
--gp-btn-bg: #059669;
--gp-btn-bg-hover: #10b981;
--gp-btn-disabled: rgba(255, 255, 255, 0.08);
--gp-btn-text-disabled: rgba(255, 255, 255, 0.3);
}
a {
text-decoration: none;
}
main {
min-height: 92vh;
margin-top: 80px;
padding: 0 0 2rem;
background-color: var(--bg-color);
}

View File

@@ -1,41 +0,0 @@
// zones.scss — Système de zones visuelles
// Chaque page déclare sa zone via data-zone sur le wrapper.
// Le background, les tons, l'ambiance changent — navbar/footer restent fixes.
//
// Usage : <div className="zone" data-zone="swamp"> ... </div>
// Ajouter un nouveau biome : juste un nouveau [data-zone="xxx"] ici.
.zone {
width: 100%;
min-height: 92vh;
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
background-size: cover;
background-repeat: no-repeat;
transition: background-image 0.5s ease;
}
// --- Biomes ---
[data-zone="swamp"] {
background-image: url("/webp/bg-cover.webp");
background-position: center 70%;
}
[data-zone="landing"] {
background: var(--bg-color);
align-items: center;
}
[data-zone="page"] {
background: var(--bg-color);
align-items: flex-start;
min-height: auto;
}
// Futur :
// [data-zone="cave"] { background-image: url("/webp/bg-cave.webp"); }
// [data-zone="volcano"] { background-image: url("/webp/bg-volcano.webp"); }
// [data-zone="ocean"] { background-image: url("/webp/bg-ocean.webp"); }

View File

@@ -8,24 +8,44 @@ import {
DEFAULT_STATE, DEFAULT_STATE,
applyIdleGains, applyIdleGains,
applyClick, applyClick,
getClickGain,
getAutoClicksPerSecond,
buyGenerator, buyGenerator,
buyEvolutionNode, buyEvolutionNode,
resetEvolutionTree,
canResetTree,
upgradeConvergence,
canUpgradeConvergence,
claimMilestone as claimMilestoneFn,
applyPrestige, applyPrestige,
canPrestige as canPrestigeCheck, canPrestige as canPrestigeCheck,
totalProductionPerSecond, totalProductionPerSecond,
generatorCost as genCost, generatorCost as genCost,
computeOfflineGains,
offlineEfficiency,
} from "../core/economy"; } from "../core/economy";
import { migrateSave } from "../core/migrateSave";
import {
computeNewUnlocks,
equipCosmetic as equipCosmeticFn,
unequipSlot as unequipSlotFn,
addToInventory,
DEFAULT_COSMETIC_STATE,
type CosmeticSlot,
} from "../core/cosmetics";
const SAVE_KEY = "clickerz_state"; const SAVE_KEY = "clickerz_state";
const OFFLINE_THRESHOLD = 60_000; // 60s — same as economy.ts
function loadLocalState(): GameState { function loadLocalState(): GameState {
try { try {
const raw = localStorage.getItem(SAVE_KEY); const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now() }; if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
const saved = JSON.parse(raw) as GameState; const parsed = JSON.parse(raw);
const saved = migrateSave(parsed);
return applyIdleGains(saved, Date.now()); return applyIdleGains(saved, Date.now());
} catch { } catch {
return { ...DEFAULT_STATE, lastTick: Date.now() }; return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
} }
} }
@@ -33,11 +53,32 @@ function saveLocal(state: GameState): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(state)); localStorage.setItem(SAVE_KEY, JSON.stringify(state));
} }
export interface OfflineReport {
wasOffline: boolean;
duration: number; // ms
gains: number;
efficiency: number; // 0-1 average
}
interface GameStore { interface GameStore {
// State // State
state: GameState; state: GameState;
playSeconds: number; playSeconds: number;
ready: boolean; // true once the authoritative state is loaded ready: boolean;
// Offline report (shown once after load)
offlineReport: OfflineReport | null;
dismissOfflineReport: () => void;
// Prestige screen (modal fullscreen)
showPrestigeScreen: boolean;
openPrestigeScreen: () => void;
closePrestigeScreen: () => void;
// Last click result (for particle feedback)
lastClickGain: number;
lastClickDouble: boolean;
lastClickCrit: boolean;
// Derived (recalculated on tick) // Derived (recalculated on tick)
canPrestige: boolean; canPrestige: boolean;
@@ -49,26 +90,99 @@ interface GameStore {
buy: (genId: string) => void; buy: (genId: string) => void;
buyNode: (nodeId: string) => void; buyNode: (nodeId: string) => void;
prestige: () => void; prestige: () => void;
resetTree: () => void;
upgradeConvergenceNode: () => void;
claimMilestone: (milestoneId: string) => void;
equipCosmetic: (cosmeticId: string) => void;
unequipCosmetic: (slot: CosmeticSlot) => void;
reset: () => void; reset: () => void;
loadFromServer: (serverState: GameState) => void; loadFromServer: (serverState: GameState) => void;
initGuest: () => void; // fallback when no server save (guest mode) initGuest: () => void;
generatorCost: typeof genCost; generatorCost: typeof genCost;
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => number;
} }
// Start with DEFAULT_STATE — game is NOT ready until loadFromServer or initGuest function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
const initialState = { ...DEFAULT_STATE, lastTick: Date.now() }; // migrateSave handles backfill — no manual patching needed here
const elapsed = now - saved.lastTick;
if (elapsed <= OFFLINE_THRESHOLD) {
// Normal idle — no offline report
const hydrated = applyIdleGains(saved, now);
return {
state: { ...hydrated, lastOnline: now },
report: null,
};
}
// Offline — use degraded curve
const gains = computeOfflineGains(saved, now);
const pps = totalProductionPerSecond(saved);
const fullGains = pps * (elapsed / 1000);
const avgEfficiency = fullGains > 0 ? gains / fullGains : 0;
const hydrated: GameState = {
...saved,
resources: saved.resources + gains,
lifetimeTadpoles: saved.lifetimeTadpoles + gains,
lastTick: now,
lastOnline: now,
};
return {
state: hydrated,
report: {
wasOffline: true,
duration: elapsed,
gains,
efficiency: avgEfficiency,
},
};
}
const initialState = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
export const useGameStore = create<GameStore>((set, get) => ({ export const useGameStore = create<GameStore>((set, get) => ({
state: initialState, state: initialState,
playSeconds: 0, playSeconds: 0,
ready: false, ready: false,
offlineReport: null,
showPrestigeScreen: false,
openPrestigeScreen: () => set({ showPrestigeScreen: true }),
closePrestigeScreen: () => set({ showPrestigeScreen: false }),
lastClickGain: 0,
lastClickDouble: false,
lastClickCrit: false,
canPrestige: false, canPrestige: false,
productionPerSecond: 0, productionPerSecond: 0,
dismissOfflineReport: () => set({ offlineReport: null }),
tick: () => { tick: () => {
if (!get().ready) return; if (!get().ready) return;
const now = Date.now();
set((s) => { set((s) => {
const updated = applyIdleGains(s.state, Date.now()); const updated = applyIdleGains(s.state, now);
updated.lastOnline = now;
// Auto-click from evolution tree
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
if (autoClicks > 0) {
const autoGain = getClickGain(updated) * autoClicks;
updated.resources += autoGain;
updated.lifetimeTadpoles += autoGain;
}
// Check cosmetic unlocks every 5s
if (s.playSeconds % 5 === 0) {
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
const newUnlocks = computeNewUnlocks(updated, cosState);
if (newUnlocks.length > 0) {
const newCos = addToInventory(cosState, newUnlocks);
updated.cosmeticInventory = newCos.inventory;
}
}
saveLocal(updated); saveLocal(updated);
return { return {
state: updated, state: updated,
@@ -82,12 +196,15 @@ export const useGameStore = create<GameStore>((set, get) => ({
click: () => { click: () => {
if (!get().ready) return; if (!get().ready) return;
set((s) => { set((s) => {
const updated = applyClick(applyIdleGains(s.state, Date.now())); const result = applyClick(applyIdleGains(s.state, Date.now()));
saveLocal(updated); saveLocal(result.state);
return { return {
state: updated, state: result.state,
canPrestige: canPrestigeCheck(updated), lastClickGain: result.gain,
productionPerSecond: totalProductionPerSecond(updated), lastClickDouble: result.isDouble,
lastClickCrit: result.isCrit,
canPrestige: canPrestigeCheck(result.state),
productionPerSecond: totalProductionPerSecond(result.state),
}; };
}); });
}, },
@@ -129,44 +246,108 @@ export const useGameStore = create<GameStore>((set, get) => ({
state: updated, state: updated,
canPrestige: canPrestigeCheck(updated), canPrestige: canPrestigeCheck(updated),
productionPerSecond: totalProductionPerSecond(updated), productionPerSecond: totalProductionPerSecond(updated),
showPrestigeScreen: false,
}; };
}); });
}, },
equipCosmetic: (cosmeticId: string) => {
if (!get().ready) return;
set((s) => {
const cosState = { inventory: s.state.cosmeticInventory, equipped: s.state.cosmeticEquipped };
const updated = equipCosmeticFn(cosState, cosmeticId);
const newState = { ...s.state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
return { state: newState };
});
},
unequipCosmetic: (slot: CosmeticSlot) => {
if (!get().ready) return;
set((s) => {
const cosState = { inventory: s.state.cosmeticInventory, equipped: s.state.cosmeticEquipped };
const updated = unequipSlotFn(cosState, slot);
const newState = { ...s.state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
return { state: newState };
});
},
resetTree: () => {
if (!get().ready) return;
set((s) => {
if (!canResetTree(s.state)) return s;
const updated = resetEvolutionTree(s.state);
saveLocal(updated);
return {
state: updated,
productionPerSecond: totalProductionPerSecond(updated),
};
});
},
upgradeConvergenceNode: () => {
if (!get().ready) return;
set((s) => {
const updated = upgradeConvergence(s.state);
if (!updated) return s;
saveLocal(updated);
return {
state: updated,
productionPerSecond: totalProductionPerSecond(updated),
};
});
},
claimMilestone: (milestoneId: string) => {
if (!get().ready) return;
set((s) => {
const updated = claimMilestoneFn(s.state, milestoneId);
if (!updated) return s;
saveLocal(updated);
return { state: updated };
});
},
reset: () => { reset: () => {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now() }; const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh); saveLocal(fresh);
set({ set({
state: fresh, state: fresh,
playSeconds: 0, playSeconds: 0,
ready: true, ready: true,
offlineReport: null,
canPrestige: false, canPrestige: false,
productionPerSecond: 0, productionPerSecond: 0,
}); });
}, },
// Server save loaded — this IS the authority
loadFromServer: (serverState: GameState) => { loadFromServer: (serverState: GameState) => {
const hydrated = applyIdleGains(serverState, Date.now()); const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
saveLocal(hydrated); // mirror to localStorage as cache const { state: hydrated, report } = hydrateWithOffline(migrated, Date.now());
saveLocal(hydrated);
set({ set({
state: hydrated, state: hydrated,
ready: true, ready: true,
offlineReport: report,
canPrestige: canPrestigeCheck(hydrated), canPrestige: canPrestigeCheck(hydrated),
productionPerSecond: totalProductionPerSecond(hydrated), productionPerSecond: totalProductionPerSecond(hydrated),
}); });
}, },
// Guest mode — no server save, use localStorage or fresh state
initGuest: () => { initGuest: () => {
const local = loadLocalState(); const local = loadLocalState();
const { state: hydrated, report } = hydrateWithOffline(local, Date.now());
saveLocal(hydrated);
set({ set({
state: local, state: hydrated,
ready: true, ready: true,
canPrestige: canPrestigeCheck(local), offlineReport: report,
productionPerSecond: totalProductionPerSecond(local), canPrestige: canPrestigeCheck(hydrated),
productionPerSecond: totalProductionPerSecond(hydrated),
}); });
}, },
generatorCost: genCost, generatorCost: genCost,
generatorCostWithTree: (gen) => genCost(gen, get().state.evolutionTree),
})); }));

View File

@@ -165,18 +165,63 @@ Sprint 1 — linéaire (5 nœuds). Sprint 2+ → branches.
--- ---
## Hors scope Sprint 1 ## Sprint 2 — Offline, Branches & Cosmétiques
Brief technique : `docs/SPRINT2.md`
| Feature | Design |
|---------|--------|
| Offline gains | Courbe inversée 100%→0% sur 2h, cap 25% prod idle, écran résumé au retour |
| Arbre 3 voies | Ponte (click) / Marais (production) / Adaptation (utility), ~15 nœuds, reset gratuit |
| Cosmétiques V1 | 5 slots SVG overlay, récompenses achievements + prestige tiers, inventaire |
## Hors scope Sprint 2
- Boucle 3 (méta, events, leaderboard) - Boucle 3 (méta, events, leaderboard)
- Branches arbre d'évolution (linéaire suffit) - Monétisation effective (boutique cosmétique payante)
- Cosmétiques / skins
- Monétisation effective
- Sound / musique - Sound / musique
- Mobile responsive (desktop first) - Mobile responsive (session dédiée)
- Offline gains calculés côté serveur (Sprint 2) - Migration Express → Fastify
- Migration Express → Fastify (si besoin Sprint 3+)
- Intégration Twitch - Intégration Twitch
- Multijoueur - Multijoueur
- Coût reset arbre (gratuit Sprint 2, payant plus tard)
---
## Sprint 3 — Prestige Loop (endless)
Brief technique : `docs/SPRINT3.md`
| Feature | Design |
|---------|--------|
| Migration saves | Pattern `saveVersion` + `migrateSave()` — backward compat Sprint 2, lazy au chargement |
| Prestige Experience | Écran redesigné (preview ADN, stats run, comparaison), animation reset, hooks audio-ready |
| Arbre V2 endless | ~30 nœuds (3 branches approfondies), capstones game-changers, post-capstone repeatable (scaling par tranche ×1.5/×1.8/×2.0), cross-branche |
| Milestones prestige | 8 paliers (1→100 prestiges), cosmétiques exclusifs + bonus gameplay légers |
| Reset arbre | 1 gratuit par prestige, payant linéaire au-delà (5 ADN × n). Build exportable (fondation Sprint 4 sharing) |
| Formule ADN rebalancée | `max(1, floor(50 × log10(t/1e6) × (1 + min(0.05×p, 3.0))))` — clamp + cap bonus ×4 |
### Capstones par branche
| Branche | Capstone | Effet |
|---------|----------|-------|
| Ponte (click) | **Ponte Automatique** | Auto-click 1/sec, scale avec upgrades ponte |
| Marais (production) | **Symbiose Totale** | Chaque générateur booste les autres (+2% par type possédé) |
| Adaptation (utility) | **Mémoire du Marais** | Offline cap 25%→75%, durée 2h→8h |
### Profils joueurs émergents
- **Joueur Ponte** : joue activement, optimise les clics, capstone = auto-click idle
- **Joueur Marais** : optimise les achats générateurs, capstone = boucle multiplicative
- **Joueur Adaptation** : joue casual 2-3×/jour, capstone = idle puissant
## Hors scope Sprint 3
- Boucle 3 (méta, events, leaderboard, cross-promo TetaRdPG)
- Son / musique (prévu Sprint 4+ — hooks audio posés dans prestige)
- Mobile responsive / client natif Godot (projet séparé envisagé)
- Monétisation effective (boutique cosmétique payante)
- Analytics joueur (event log backend — Sprint 4)
--- ---
@@ -186,3 +231,6 @@ Sprint 1 — linéaire (5 nœuds). Sprint 2+ → branches.
|------|------------| |------|------------|
| 2026-03-17 | GDD initial — sprint1-step1, stack React+TS+Vite, mécaniques core | | 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 | | 2026-03-20 | Refonte game-designer — Tetard Universe, Arbre d'Évolution, anti-triche backend, SuperOAuth, stack confirmée Express |
| 2026-03-28 | Sprint 1 livré (6/6). Sprint 2 briefé — offline gains courbe inversée, arbre 3 voies, cosmétiques récompenses |
| 2026-03-28 | Sprint 2 livré (3/3). Sprint 3 briefé — prestige loop endless, arbre V2 30 nœuds, capstones, milestones, formule ADN rebalancée |
| 2026-03-28 | Sprint 3 brainstorm — 5 décisions : saveVersion migration, formule ADN clamp+cap, scaling post-capstone par tranche, reset 1 gratuit/prestige + vision build-sharing, Convergence évolutif (Alpha→Omega, nœud unique à tiers) |

143
docs/SPRINT2.md Normal file
View File

@@ -0,0 +1,143 @@
# SPRINT2.md — Offline, Branches & Cosmétiques
> Brief technique — Sprint 2
> Date : 2026-03-28
> Réf GDD : docs/GDD.md
> Dépend : Sprint 1 livré (6/6)
> Agents : game-designer (design) → build (implémentation)
---
## Objectif
Trois features qui transforment Clickerz d'un prototype jouable en un jeu avec rétention :
- Offline gains avec courbe inversée (incite à jouer actif)
- Arbre d'Évolution 3 voies (builds, choix, rejouabilité)
- Cosmétiques par récompense (feedback visuel, motivation prestige)
---
## Steps
### Step 1 — Offline gains (courbe inversée)
**Scope :** Calculer les gains hors-ligne avec une courbe dégressive pour inciter le jeu actif.
**Design :**
- Courbe inversée : efficacité 100% → 0% sur 2h d'absence
- 0-15min : 100% de la prod idle
- 15min-1h : dégression linéaire 100% → 25%
- 1h-2h : dégression 25% → 0%
- >2h : cap — plus rien (le marais dort)
- Prod offline plafonnée à 25% de la prod idle moyenne
- Au retour du joueur : écran "Pendant ton absence..." avec résumé des gains
**Technique :**
- `applyIdleGains()` existe déjà — ajouter un facteur `offlineEfficiency(elapsedMs)`
- `offlineEfficiency(ms)` : retourne le multiplicateur (1.0 → 0.0) basé sur la courbe
- Backend : valider le gain offline au `GET /api/save` (même pattern anti-triche)
- Frontend : composant `OfflineReport` affiché si `elapsed > 60s` au chargement
- GameState : ajouter `lastOnline: number` (timestamp dernière activité réelle)
**Critère done :** fermer le jeu, revenir 30min après → écran résumé + gains corrects. Revenir 3h après → même résultat qu'après 2h (cap).
---
### Step 2 — Arbre d'Évolution 3 voies
**Scope :** Transformer l'arbre linéaire en arbre à 3 branches avec choix de build.
**Design :**
- 3 voies depuis la racine :
- **Ponte** (click) — multiplicateurs click, click critique, auto-click
- **Marais** (production) — multiplicateurs idle, générateurs bonus, production globale
- **Adaptation** (utility) — start bonus, offline boost, prestige ADN bonus, cost reduction
- Chaque voie : 4-5 nœuds progressifs (coût ADN croissant)
- Le joueur peut investir dans plusieurs voies — pas de lock exclusif
- Choix intra-nœud : certains nœuds proposent 2 options mutuellement exclusives (pick one)
- Reset : gratuit pour l'instant (bouton "Réinitialiser l'Arbre" — rembourse tout l'ADN dépensé)
- Futur : coût ADN croissant par reset (1er gratuit, 2e = 10 ADN, etc.)
**Technique :**
- Refactorer `EvolutionNode` :
```ts
interface EvolutionNode {
id: string;
name: string;
cost: number;
effect: EffectType;
value: number;
unlocked: boolean;
requires: string | null; // nœud parent
branch: "ponte" | "marais" | "adaptation";
exclusive_with?: string; // id du nœud alternatif (pick one)
}
```
- `DEFAULT_EVOLUTION_TREE` : ~15 nœuds (5 par voie)
- `resetEvolutionTree(state)` : rembourse tout l'ADN, relock tous les nœuds
- UI : `EvolutionTree.tsx` → layout 3 colonnes (une par voie), header avec couleur
- Ponte = vert, Marais = bleu, Adaptation = ambre
- Animer les connexions entre nœuds (lignes SVG ou borders)
**Critère done :** le joueur voit 3 voies distinctes, peut investir ADN dans chaque, constater les effets, et reset gratuitement.
---
### Step 3 — Cosmétiques V1 (récompenses)
**Scope :** Système d'équipement cosmétique sur le sprite têtard, gagné par achievements et prestige.
**Design :**
- 5 slots : hat, eyes, body, tail, accessory (déjà dans tadpole.svg V5)
- Sources de cosmétiques :
- **Achievements** : débloquer un cosmétique quand un achievement est complété
- **Prestige tiers** : skin spécial à 5, 10, 25, 50 prestiges
- Inventaire : liste des cosmétiques possédés, équipés (1 par slot max)
- Rendu : stack SVG — base (tadpole.svg) + overlays par slot équipé
- Pas de boutique / pas de currency cosmétique — uniquement des récompenses
**Technique :**
- GameState : ajouter `cosmetics: { inventory: string[], equipped: Record<Slot, string | null> }`
- Définir les cosmétiques :
```ts
interface Cosmetic {
id: string;
name: string;
slot: "hat" | "eyes" | "body" | "tail" | "accessory";
svg: string; // chemin vers le SVG overlay
source: "achievement" | "prestige";
sourceId: string; // achievement id ou "prestige_10"
}
```
- 8-10 cosmétiques initiaux (2 par slot) :
- hat : couronne (prestige 10), casquette marais (achievement 10 succès)
- eyes : lunettes savant (prestige 5), masque grenouille (achievement 1M têtards)
- body : cape algues (prestige 25), armure écailles (achievement tous générateurs)
- tail : flamme (prestige 50), ruban (achievement premier prestige)
- accessory : aura-swamp (existe déjà), particules dorées (achievement 5 prestiges)
- UI : page `/cosmetics` ou section dans Settings — grille inventaire + preview sprite
- `TadpoleSprite.tsx` : composant qui empile base SVG + overlays équipés
**Critère done :** débloquer un achievement → cosmétique dans l'inventaire → équiper → voir le changement sur le sprite en jeu.
---
## Résumé séquentiel
```
Step 1 (offline gains) → Step 2 (arbre 3 voies) → Step 3 (cosmétiques)
```
Step 1 = rétention. Step 2 = profondeur. Step 3 = récompense visuelle.
---
## Risques identifiés
| Risque | Mitigation |
|--------|------------|
| Courbe offline trop punitive | Playtester, ajuster les paliers. Logs pour traquer le % de gains offline |
| Arbre déséquilibré (une voie OP) | 3 voies testables indépendamment. Reset gratuit = le joueur corrige |
| Nœuds exclusifs confus UX | Tooltip clair "Choisis l'un des deux", preview de l'effet avant achat |
| SVG overlay perf (mobile) | Lazy load des SVG, max 5 overlays simultanés (1 par slot) |
| Reset arbre exploitable | Gratuit = pas de cheese possible. Le coût viendra plus tard |

315
docs/SPRINT3.md Normal file
View File

@@ -0,0 +1,315 @@
# SPRINT3.md — Prestige Loop
> Brief technique — Sprint 3
> Date : 2026-03-28
> Réf GDD : docs/GDD.md
> Dépend : Sprint 2 livré (3/3)
> Agents : game-designer (design) → build (implémentation)
---
## Objectif
Transformer le prestige d'une mécanique de reset en une **boucle de progression motivante et endless**. Le joueur doit sentir l'accélération à chaque génération, faire des choix de build durables, et viser des paliers qui récompensent la spécialisation.
---
## Pré-requis technique — Migration saves
> À traiter en amont de Step 1 — pas un step visible, mais bloquant.
### Pattern `saveVersion` + `migrateSave()` (décision session 2026-03-28)
Le GameState est stocké en JSON unique dans MySQL. Ajouter/modifier des champs
sans migration = `undefined` silencieux → NaN → bugs fantômes.
**Mécanisme :**
- Ajouter `saveVersion: number` au `GameState`
- Saves Sprint 2 existantes = version absente → traitées comme `v1`
- Sprint 3 = `v2`
- Fonction `migrateSave(state: unknown): GameState` appliquée au chargement
(frontend `useSaveSync` + backend `saveControllers`)
- `v1 → v2` : injecter defaults (`runStats` vide, `treeResetCount: 0`,
`freeResetAvailable: true`, `repeatableNodes: {}`, nouveaux nœuds arbre)
- Les 18 nœuds Sprint 2 conservent leur `id` — les nouveaux s'ajoutent
- Champ critique manquant → log warning + default safe (jamais de crash)
- Chaque sprint futur ajoute un step de migration (`v2 → v3`, etc.)
### Schéma DB
- `ALTER TABLE game_saves ADD COLUMN save_version INT DEFAULT 1`
- Pas de migration du JSON existant en DB — la migration est lazy (au chargement)
### Validation
- Charger une save Sprint 2 brute sur le nouveau code → `migrateSave` injecte v2
- Pas de perte de données, pas de reset forcé
- Les nœuds arbre existants (18) gardent leur position et état unlocked
---
## Steps
### Step 1 — Prestige Experience
**Scope :** Rendre le moment du prestige satisfaisant et informatif.
**Design :**
- Écran de prestige redesigné :
- Preview ADN gagné (avant de confirmer)
- Comparaison : "Run actuelle vs meilleure run" (durée, têtards, ADN)
- Compteur de générations (nombre total de prestiges)
- Statistiques de run persistées :
- Durée de la run
- Têtards produits (lifetime cette run)
- Vitesse de progression vs run précédente (% plus rapide / plus lent)
- Branche d'arbre principale utilisée
- Animation de reset : transition visuelle (le marais "renaît")
- Hooks audio-ready : prévoir les points d'ancrage pour le son (Sprint futur)
**Technique :**
- `PrestigeScreen.tsx` : composant modal fullscreen (pas un simple bouton)
- `RunStats` dans le GameState :
```ts
interface RunStats {
startedAt: number;
tadpolesProduced: number;
prestigeCount: number;
bestRun: {
duration: number;
tadpoles: number;
adn: number;
};
}
```
- Backend : persister `runStats` dans la save (même pattern save serveur)
- Rebalancer la formule prestige pour l'endless (voir section Formules)
**Critère done :** le joueur clique Prestige → voit un écran avec preview ADN + stats comparées → confirme → animation → nouvelle run. Les stats de la meilleure run sont persistées.
---
### Step 2 — Arbre d'Évolution V2 — Profondeur endless
**Scope :** Étendre l'arbre à ~30 nœuds avec capstones game-changers et scaling post-capstone infini.
**Design — Structure par branche :**
**Branche Ponte (click) — 8-10 nœuds :**
- Tier 1 : Ponte Améliorée (+100% click) — 1 ADN
- Tier 2 : Click Critique (5% chance ×10) — 5 ADN
- Tier 3 : Frénésie (click power +1% par click dans les 10 dernières sec, cap +50%) — 15 ADN
- Tier 3 alt (exclusif) : Concentration (+200% click, -25% idle) — 15 ADN
- Capstone : **Ponte Automatique** — auto-click 1/sec, scale avec upgrades ponte — 200 ADN
- Post-capstone (repeatable) : +5% auto-click speed, coût ×2 — départ 500 ADN
**Branche Marais (production) — 8-10 nœuds :**
- Tier 1 : Instinct Grégaire (+50% production tous générateurs) — 3 ADN
- Tier 2 : Spécialisation (un générateur au choix ×3) — 8 ADN
- Tier 3 : Écosystème (+10% prod par type de générateur possédé) — 25 ADN
- Tier 3 alt (exclusif) : Monoculture (un seul type ×5, les autres ×0.5) — 25 ADN
- Capstone : **Symbiose Totale** — chaque générateur booste les autres (+2% par type possédé) — 300 ADN
- Post-capstone (repeatable) : +1% symbiose, coût ×2 — départ 600 ADN
**Branche Adaptation (utility) — 8-10 nœuds :**
- Tier 1 : Mémoire Génétique (commence chaque run avec 100 têtards) — 2 ADN
- Tier 2 : Métabolisme Rapide (+25% offline cap) — 10 ADN
- Tier 3 : Héritage (conserve 5% des générateurs tier 1 entre prestiges) — 30 ADN
- Tier 3 alt (exclusif) : Mutation ADN (+25% ADN gagné au prestige) — 30 ADN
- Capstone : **Mémoire du Marais** — offline cap 25% → 75%, durée 2h → 8h — 250 ADN
- Post-capstone (repeatable) : +2% offline cap (au-delà de 75%), coût ×2 — départ 500 ADN
**Nœud cross-branche — Convergence (évolutif, décision session 2026-03-28) :**
Un seul nœud qui évolue quand le joueur atteint de nouveaux paliers de diversification.
Pattern `tier` sur le nœud — fondation pour d'autres nœuds évolutifs futurs.
```
Convergence Alpha (tier 1)
Condition : 1 capstone + tier 3 d'une 2e branche (~prestige 12-15)
Coût : 500 ADN
Effet : +10% à tous les effets de l'arbre
Convergence Omega (tier 2 — même nœud, upgrade auto)
Condition : 2 capstones atteintes
Coût : 500 ADN supplémentaires (total investi : 1000)
Effet : +10% tous effets + -20% coût post-capstones
```
Technique — champ `tier` sur `EvolutionNode` :
```ts
tier?: number; // 1 = Alpha, 2 = Omega (current level)
maxTier?: number; // 2
tierUpgradeCost?: number; // 500
tierUpgradeCondition?: string; // "2_capstones"
```
UX : notification "Convergence a évolué !" quand la condition tier 2 est remplie.
Le joueur voit sa récompense grandir — pas un 2e nœud à acheter séparément.
**Technique :**
- `DEFAULT_EVOLUTION_TREE` : ~30 nœuds (tree data structure, pas array)
- Ajouter `tier: number` et `repeatable: boolean` aux `EvolutionNode`
- `repeatableCount: number` dans le state pour les nœuds post-capstone
- UI : garder le layout 3 colonnes, ajouter scroll vertical par branche
- Capstones visuellement distincts (bordure dorée, icône spéciale)
- Cross-branche : section basse, verrouillée visuellement jusqu'aux conditions
- Convergence : badge évolutif (Alpha → Omega), indicateur de progression vers le prochain tier
**Reset arbre — 1 gratuit par prestige (décision session 2026-03-28) :**
Chaque prestige offre 1 reset gratuit (nouvelle génération = nouvelle chance de build).
Resets supplémentaires dans la même génération = payants.
```
freeResetAvailable: boolean // true après chaque prestige, false après usage
resetCostInGeneration: number = 5 × extraResetsUsed // linéaire, pas exponentiel
```
| Reset# (dans la même génération) | Coût ADN |
|----------------------------------|----------|
| 1er | Gratuit (offert par prestige) |
| 2e | 5 |
| 3e | 10 |
| 4e | 15 |
Pas punitif, encourage l'expérimentation, mais décourage le spam.
**Fondation build-sharing (Sprint 4+ vision) :**
Le build de l'arbre est exportable en string compacte (nœuds unlocked encodés).
Permet le partage de builds entre joueurs et les "runs prestige" communautaires.
→ Structurer l'arbre state pour faciliter l'export dès Sprint 3 :
`buildCode: string` = nœuds unlocked encodés base36 ou similaire.
**Critère done :** l'arbre affiche ~30 nœuds sur 3 branches + cross-branche, les capstones changent le gameplay de manière perceptible, les post-capstones sont achetables en boucle.
---
### Step 3 — Milestones de Prestige
**Scope :** Récompenser la persévérance avec des paliers de prestige qui débloquent cosmétiques exclusifs et bonus légers.
**Design :**
| Palier | Récompense | Type |
|--------|------------|------|
| 1 prestige | Badge "Première Génération" + ruban queue | cosmétique |
| 3 prestiges | Titre "Gardien Récurrent" | cosmétique |
| 5 prestiges | Start avec 1 Nid gratuit | gameplay léger |
| 10 prestiges | Skin "Têtard Ancestral" (body prestige) + couronne dorée | cosmétique |
| 15 prestiges | +5% offline cap permanent | gameplay léger |
| 25 prestiges | Cape d'algues ancestrales + aura prestige | cosmétique |
| 50 prestiges | Titre "Légende du Marais" + particules dorées permanentes | cosmétique |
| 100 prestiges | Skin "Têtard Primordial" (full set) | cosmétique |
- Les bonus gameplay sont **légers** — jamais assez pour casser l'économie
- Les cosmétiques prestige-only ne sont pas obtenables autrement (exclusivité = motivation)
- Écran milestones accessible depuis le menu prestige (progress bar vers le prochain palier)
**Technique :**
- `PrestigeMilestone` type :
```ts
interface PrestigeMilestone {
id: string;
threshold: number; // nombre de prestiges requis
reward: MilestoneReward;
claimed: boolean;
}
type MilestoneReward =
| { type: "cosmetic"; cosmeticId: string }
| { type: "bonus"; effect: EffectType; value: number }
| { type: "title"; title: string };
```
- Intégrer avec le système cosmétiques V1 existant — ajouter `source: "prestige_milestone"` aux cosmétiques
- UI : `MilestonesPanel.tsx` — liste verticale avec progress bar, claim button, preview reward
- Backend : valider les claims (le joueur ne peut pas claim un milestone sans le nombre de prestiges)
**Critère done :** atteindre 5 prestiges → notification milestone → claim → Nid gratuit actif + cosmétique dans l'inventaire.
---
## Formules — Rebalancing endless (décisions session 2026-03-28)
### Formule ADN (remplace l'actuelle)
Actuelle : `adn = floor(150 × sqrt(lifetime_tadpoles / 1e9))` — s'aplatit trop vite.
Nouvelle :
```
adn = max(1, floor(base × log10(tadpoles / threshold) × (1 + bonus)))
base = 50
threshold = 1e6 (1M têtards — seuil minimum pour prestige)
bonus = min(0.05 × prestigeCount, 3.0) // cap ×4 max à 80 prestiges
clamp = minimum 1 ADN si tadpoles >= threshold
```
| Run | Tadpoles | Prestige# | Bonus | ADN gagné |
|-----|----------|-----------|-------|-----------|
| 1 | 1M (seuil) | 0 | ×1.0 | **1** (clamp) |
| 1 | 10M | 0 | ×1.0 | **50** |
| 5 | 100M | 4 | ×1.20 | **120** |
| 15 | 1B | 14 | ×1.70 | **255** |
| 30 | 10B | 29 | ×2.45 | **430** |
| 80+ | 100B | 80 | ×4.00 | **800** (cap bonus atteint) |
La courbe log scale bien : récompense toujours plus, oblige à aller exponentiellement plus loin.
Le cap du bonus évite que l'endgame trivialise l'arbre.
### Courbe coût post-capstones — paliers par tranche
Le ×2 brut crée un mur après ~8 achats. Scaling par tranche :
```
Achats 1-5 : cost = base × 1.5^n
Achats 6-10 : cost = base × 1.5^5 × 1.8^(n-5)
Achats 11+ : cost = base × 1.5^5 × 1.8^5 × 2.0^(n-10)
```
| Achat# | Coût (base 500) | ~Runs nécessaires (400 ADN/run) |
|--------|-----------------|--------------------------------|
| 1 | 750 | ~2 |
| 5 | 3 797 | ~10 |
| 10 | 68 890 | ~172 |
| 15 | 2.2M | endgame long |
Chaque achat reste "quelques sessions" en mid-game, puis ralentit en endgame
sans frapper un mur infranchissable.
### Courbe coût nœuds standards
Définis manuellement par nœud (1, 3, 5, 8, 10, 15, 25, 30, 200, 250, 300, 500, 1000 ADN).
### Vérification d'équilibre
- Premier capstone : entre le 8e et 12e prestige (spécialisation une branche)
- Arbre "complet" (hors post-capstone) : ~15-20 prestiges
- Post-capstones : progression infinie, chaque achat = investissement plus lourd mais gain marginal
- Cross-branche : joueur dédié à 25+ prestiges
**Niveau de confiance : moyen** — les chiffres sont calibrés sur les formules mais devront être playtestés. Toutes les constantes centralisées dans `balance.ts` pour ajustement rapide.
---
## Résumé séquentiel
```
Migration saves → Step 1 (prestige experience) → Step 2 (arbre V2 endless) → Step 3 (milestones)
```
Step 1 = le moment. Step 2 = la profondeur. Step 3 = la motivation long terme.
---
## Risques identifiés
| Risque | Mitigation | Statut |
|--------|------------|--------|
| Formule ADN mal calibrée | Constantes dans `balance.ts`. Clamp min 1 + cap bonus ×4. Playtest après Step 1 | ✅ adressé |
| Capstones trop puissants (Ponte Auto trivialise) | Tester chaque capstone isolément. Auto-click = 1/sec de base | à tester |
| Post-capstone wall en late-game | Scaling par tranche (×1.5/×1.8/×2.0) au lieu de ×2 brut | ✅ adressé |
| Migration saves casse les joueurs existants | Pattern `saveVersion` + `migrateSave()` lazy. Saves Sprint 2 = v1, migrées automatiquement | ✅ adressé |
| Reset arbre frustrant | 1 gratuit par prestige + linéaire au-delà. Pas d'exponentiel | ✅ adressé |
| Arbre V2 complexe visuellement | Progressive disclosure : griser les nœuds non débloquables, tooltip clair | à tester |
| Build-sharing string : encodage fragile | Valider le décodage côté client. Nœud inconnu dans le code = ignoré (forward compat) | Sprint 4 |