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
This commit is contained in:
2026-03-28 18:24:24 +01:00
parent f80f071c24
commit ed8cf87d4e
22 changed files with 1917 additions and 158 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

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

@@ -299,31 +299,51 @@ 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 3 voies --- // --- Arbre d'Évolution 3 voies ---
describe("Evolution Tree (3 branches)", () => { describe("Evolution Tree (3 branches)", () => {
it("arbre a 18 nœuds répartis en 3 branches", () => { it("arbre V2 : 3 branches + cross (~30 nœuds)", () => {
const ponte = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "ponte"); const ponte = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "ponte");
const marais = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "marais"); const marais = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "marais");
const adaptation = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "adaptation"); const adaptation = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "adaptation");
expect(ponte.length).toBe(6); const cross = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "cross");
expect(marais.length).toBe(6); expect(ponte.length).toBe(8);
expect(adaptation.length).toBe(6); expect(marais.length).toBe(8);
expect(adaptation.length).toBe(8);
expect(cross.length).toBe(1);
expect(DEFAULT_EVOLUTION_TREE.length).toBe(25);
}); });
describe("canBuyEvolutionNode", () => { describe("canBuyEvolutionNode", () => {
@@ -363,8 +383,8 @@ describe("Evolution Tree (3 branches)", () => {
n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n
), ),
}; };
// auto_ponte exclusive_with ponte_frenetique → locked // concentration exclusive_with ponte_frenetique → locked
expect(canBuyEvolutionNode(state, "auto_ponte")).toBe(false); expect(canBuyEvolutionNode(state, "concentration")).toBe(false);
}); });
it("peut acheter un nœud exclusif si l'alternative n'est pas débloquée", () => { it("peut acheter un nœud exclusif si l'alternative n'est pas débloquée", () => {
@@ -375,7 +395,7 @@ describe("Evolution Tree (3 branches)", () => {
n.id === "double_ponte" || n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n n.id === "double_ponte" || n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
), ),
}; };
expect(canBuyEvolutionNode(state, "auto_ponte")).toBe(true); expect(canBuyEvolutionNode(state, "concentration")).toBe(true);
expect(canBuyEvolutionNode(state, "ponte_frenetique")).toBe(true); expect(canBuyEvolutionNode(state, "ponte_frenetique")).toBe(true);
}); });
}); });
@@ -399,15 +419,17 @@ describe("Evolution Tree (3 branches)", () => {
const state = { const state = {
...DEFAULT_STATE, ...DEFAULT_STATE,
ancestralDna: 50, ancestralDna: 50,
prestigeCount: 1,
freeResetAvailable: true,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "ponte_amelioree" || n.id === "instinct_gregaire" n.id === "ponte_amelioree" || n.id === "instinct_gregaire"
? { ...n, unlocked: true } ? { ...n, unlocked: true }
: n : n
), ),
}; };
// ponte_amelioree (1) + instinct_gregaire (1) = 2 ADN spent // ponte_amelioree (1) + instinct_gregaire (3) = 4 ADN spent, free reset
const result = resetEvolutionTree(state); const result = resetEvolutionTree(state);
expect(result.ancestralDna).toBe(52); expect(result.ancestralDna).toBe(54);
expect(result.evolutionTree.every((n) => !n.unlocked)).toBe(true); expect(result.evolutionTree.every((n) => !n.unlocked)).toBe(true);
}); });
@@ -491,41 +513,27 @@ describe("Evolution Tree (3 branches)", () => {
}); });
}); });
describe("unlock_generator (Résilience)", () => { describe("prestige reset generators", () => {
it("prestige avec Résilience donne 1 Lac Mystique", () => { it("prestige remet les générateurs à 0", () => {
const state = {
...DEFAULT_STATE,
resources: 2_000_000,
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })),
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "resilience" ? { ...n, unlocked: true } : n
),
};
const result = applyPrestige(state);
const lac = result.generators.find((g) => g.id === "lac");
expect(lac!.owned).toBe(1);
});
it("prestige sans Résilience donne 0 Lac Mystique", () => {
const state = { const state = {
...DEFAULT_STATE, ...DEFAULT_STATE,
resources: 2_000_000, resources: 2_000_000,
lifetimeTadpoles: 2_000_000,
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })), generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })),
}; };
const result = applyPrestige(state); const result = applyPrestige(state);
const lac = result.generators.find((g) => g.id === "lac"); expect(result.generators.every((g) => g.owned === 0)).toBe(true);
expect(lac!.owned).toBe(0);
}); });
}); });
describe("auto_click (getAutoClicksPerSecond)", () => { describe("auto_click (getAutoClicksPerSecond)", () => {
it("retourne 0 si auto_ponte non débloqué", () => { it("retourne 0 si capstone ponte non débloqué", () => {
expect(getAutoClicksPerSecond(DEFAULT_EVOLUTION_TREE)).toBe(0); expect(getAutoClicksPerSecond(DEFAULT_EVOLUTION_TREE)).toBe(0);
}); });
it("retourne 1 si auto_ponte débloqué", () => { it("retourne 1 si capstone Ponte Automatique débloqué", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) => const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "auto_ponte" ? { ...n, unlocked: true } : n n.id === "ponte_auto" ? { ...n, unlocked: true } : n
); );
expect(getAutoClicksPerSecond(tree)).toBe(1); expect(getAutoClicksPerSecond(tree)).toBe(1);
}); });

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

@@ -1,28 +1,43 @@
// EvolutionTree.tsx — Arbre d'Évolution 3 voies (permanent — jamais reset par prestige) // 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, getSpentDna } from "../core/economy"; import {
canBuyEvolutionNode,
getSpentDna,
getTreeResetCost,
canResetTree,
getRepeatableCost,
canUpgradeConvergence,
} from "../core/economy";
import type { EvolutionNode, Branch } 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`,
double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`, double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
auto_click: (v) => `${v} auto-ponte/s`, 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`, crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`,
generator_boost: (v) => `x${v} Nid`, generator_boost: (v) => `x${v} Nid`,
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% coût générateurs`, 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`, prestige_dna_bonus: (v) => `+${(v * 100).toFixed(0)}% ADN prestige`,
offline_boost: (v) => `+${(v * 100).toFixed(0)}% gains offline`, offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`,
prestige_threshold_reduction: (v) => `Prestige à ${((1 - v) * 100).toFixed(0)}% du seuil`, 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, { label: string; color: string; accent: string }> = { const BRANCH_CONFIG: Record<Branch | "cross", { label: string; color: string; accent: string }> = {
ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" }, ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" },
marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" }, marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" },
adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" }, 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({
@@ -36,36 +51,62 @@ function NodeRow({
isExcluded: 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 : isExcluded
? "gp-row gp-row--locked opacity-30!" ? "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 className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isCapstone && <span className="text-amber-400 text-[0.6rem]"></span>}
<span className="gp-value text-[0.7rem]!">{node.name}</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 && ( {node.exclusive_with && !node.unlocked && !isExcluded && (
<span className="gp-label text-[0.55rem]!">OU</span> <span className="gp-label text-[0.55rem]!">OU</span>
)} )}
</div> </div>
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value) ?? node.effect}</span> <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 ? ( ) : isExcluded ? (
<span className="gp-label text-[0.55rem]!">verrouillé</span> <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"}`}
> >
{node.cost} {formatNumber(cost)}
</button> </button>
)} )}
</div> </div>
@@ -99,6 +140,74 @@ function BranchColumn({ branch }: { branch: Branch }) {
); );
} }
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 resetTree = useGameStore((s) => s.resetTree); const resetTree = useGameStore((s) => s.resetTree);
@@ -108,13 +217,16 @@ export function EvolutionTree() {
const spentDna = getSpentDna(evolutionTree); const spentDna = getSpentDna(evolutionTree);
const hasUnlocked = spentDna > 0; const hasUnlocked = spentDna > 0;
const resetCost = getTreeResetCost(state);
const canReset = canResetTree(state);
const handleReset = () => { const handleReset = () => {
if (!hasUnlocked) return; if (!canReset) return;
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : " (gratuit)";
const confirmed = window.confirm( const confirmed = window.confirm(
`Réinitialiser l'Arbre d'Évolution ?\n\n` + `Reinitialiser l'Arbre d'Evolution ?\n\n` +
`Tu récupères ${spentDna} ADN Ancestral.\n` + `Tu recuperes ${spentDna} ADN Ancestral.${costLabel}\n` +
`Tous les nœuds seront verrouillés.\n\n` + `Tous les noeuds seront verrouilles.\n\n` +
`Confirmer ?` `Confirmer ?`
); );
if (confirmed) resetTree(); if (confirmed) resetTree();
@@ -123,16 +235,21 @@ export function EvolutionTree() {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex justify-between items-center px-1"> <div className="flex justify-between items-center px-1">
<span className="gp-title">Évolution</span> <span className="gp-title">Evolution</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="gp-value gp-accent-amber">{ancestralDna} ADN</span> <span className="gp-value gp-accent-amber">{formatNumber(ancestralDna)} ADN</span>
{hasUnlocked && ( {hasUnlocked && (
<button <button
onClick={handleReset} onClick={handleReset}
className="gp-btn gp-btn--disabled text-[0.55rem]! hover:bg-red-500/20! hover:text-red-400!" disabled={!canReset}
title={`Récupérer ${spentDna} ADN`} 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 Reset{resetCost > 0 ? ` (${resetCost})` : ""}
</button> </button>
)} )}
</div> </div>
@@ -142,6 +259,7 @@ export function EvolutionTree() {
<BranchColumn branch="marais" /> <BranchColumn branch="marais" />
<BranchColumn branch="adaptation" /> <BranchColumn branch="adaptation" />
</div> </div>
<ConvergenceSection />
</div> </div>
); );
} }

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

@@ -5,28 +5,15 @@ import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from ".
import { formatNumber } from "../utils/formatNumber"; import { formatNumber } from "../utils/formatNumber";
export function PrestigePanel() { export function PrestigePanel() {
const { lifetimeTadpoles } = useGameStore((s) => s.state);
const canPrestige = useGameStore((s) => s.canPrestige);
const prestige = useGameStore((s) => s.prestige);
const state = useGameStore((s) => s.state); const state = useGameStore((s) => s.state);
const baseDna = computePrestigeDna(lifetimeTadpoles); const canPrestige = useGameStore((s) => s.canPrestige);
const openPrestigeScreen = useGameStore((s) => s.openPrestigeScreen);
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree); const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const dnaPreview = Math.floor(baseDna * (1 + dnaBonus)); const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
const threshold = getPrestigeThreshold(state); const threshold = getPrestigeThreshold(state);
const handlePrestige = () => {
const confirmed = window.confirm(
`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>
@@ -35,12 +22,12 @@ export function PrestigePanel() {
<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 {formatNumber(threshold)} 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,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

@@ -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;
@@ -21,27 +40,54 @@ export type EffectType =
| "cost_reduction" | "cost_reduction"
| "prestige_dna_bonus" | "prestige_dna_bonus"
| "offline_boost" | "offline_boost"
| "prestige_threshold_reduction"; | "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"; 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; branch: Branch;
exclusive_with?: string; // id du nœud alternatif (pick one) 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 { export interface CosmeticSlotMap {
[slot: string]: string | undefined; [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[];
@@ -54,46 +100,214 @@ export interface GameState {
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 cosmeticInventory: string[]; // ids des cosmétiques débloqués
cosmeticEquipped: CosmeticSlotMap; // slot → cosmetic id 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[] = [
// --- Ponte (click) --- // ═══ PONTE (click) — 10 nœuds ═══
// Tier 1
{ id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null, branch: "ponte" }, { id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null, branch: "ponte" },
{ id: "double_ponte", name: "Double Ponte", cost: 3, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" }, // Tier 2
{ id: "ponte_frenetique", name: "Ponte Frénétique", cost: 8, effect: "click_multiplier", value: 3, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "auto_ponte" }, { id: "double_ponte", name: "Double Ponte", cost: 5, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" },
{ id: "auto_ponte", name: "Auto-Ponte", cost: 8, effect: "auto_click", value: 1, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "ponte_frenetique" }, // 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" }, { 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" }, { 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) --- // ═══ MARAIS (production) — 10 nœuds ═══
{ id: "instinct_gregaire", name: "Instinct Grégaire", cost: 1, effect: "production_multiplier", value: 1.5, unlocked: false, requires: null, branch: "marais" },
{ id: "symbiose_algale", name: "Symbiose Algale", cost: 3, effect: "generator_boost", value: 2, unlocked: false, requires: "instinct_gregaire", branch: "marais" },
{ id: "courant_profond", name: "Courant Profond", cost: 8, effect: "production_multiplier", value: 2, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "maree_haute" },
{ id: "maree_haute", name: "Marée Haute", cost: 8, effect: "cost_reduction", value: 0.20, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "courant_profond" },
{ id: "ecosysteme_mature", name: "Écosystème Mature", cost: 20, effect: "production_multiplier", value: 3, unlocked: false, requires: "courant_profond", branch: "marais" },
{ id: "marais_eternel", name: "Marais Éternel", cost: 40, effect: "production_multiplier", value: 5, unlocked: false, requires: "ecosysteme_mature", branch: "marais" },
// --- Adaptation (utility) --- // Tier 1
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 1, effect: "start_bonus", value: 100, unlocked: false, requires: null, branch: "adaptation" }, { id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: null, branch: "marais" },
{ id: "adn_renforce", name: "ADN Renforcé", cost: 3, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "memoire_genetique", branch: "adaptation" }, // Tier 2
{ id: "eveil_rapide", name: "Éveil Rapide", cost: 8, effect: "offline_boost", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "resilience" }, { id: "symbiose_algale", name: "Symbiose Algale", cost: 8, effect: "generator_boost", value: 2, unlocked: false, requires: "instinct_gregaire", branch: "marais" },
{ id: "resilience", name: "Résilience", cost: 8, effect: "unlock_generator", value: 0, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "eveil_rapide" }, // Tier 3 (exclusif)
{ id: "heritage", name: "Héritage", cost: 20, effect: "prestige_dna_bonus", value: 0.50, unlocked: false, requires: "eveil_rapide", branch: "adaptation" }, { 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: "transcendance", name: "Transcendance", cost: 40, effect: "prestige_threshold_reduction", value: 0.50, unlocked: false, requires: "heritage", branch: "adaptation" }, { 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;
@@ -111,31 +325,80 @@ 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 { return {
...state, ...state,
ancestralDna: state.ancestralDna - node.cost, 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 {
...state,
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
), ),
}; };
} }
// Reset l'arbre — rembourse tout l'ADN dépensé, relock tous les nœuds // 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 { export function resetEvolutionTree(state: GameState): GameState {
const spentDna = state.evolutionTree const cost = getTreeResetCost(state);
.filter((n) => n.unlocked) if (state.ancestralDna < cost) return state;
.reduce((sum, n) => sum + n.cost, 0);
const spentDna = getSpentDna(state.evolutionTree);
return { return {
...state, ...state,
ancestralDna: state.ancestralDna + spentDna, ancestralDna: state.ancestralDna + spentDna - cost,
evolutionTree: state.evolutionTree.map((n) => ({ ...n, unlocked: false })), 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 // Compte l'ADN total investi dans l'arbre (standard + repeatables + convergence upgrades)
export function getSpentDna(tree: EvolutionNode[]): number { export function getSpentDna(tree: EvolutionNode[]): number {
return tree.filter((n) => n.unlocked).reduce((sum, n) => sum + n.cost, 0); 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
@@ -166,11 +429,13 @@ export function getDoubleClickChance(tree: EvolutionNode[]): number {
.reduce((sum, n) => sum + n.value, 0); .reduce((sum, n) => sum + n.value, 0);
} }
// Auto-clicks par seconde depuis l'arbre // Auto-clicks par seconde depuis l'arbre (standard + capstone scaling)
export function getAutoClicksPerSecond(tree: EvolutionNode[]): number { export function getAutoClicksPerSecond(tree: EvolutionNode[]): number {
return tree const standard = tree
.filter((n) => n.unlocked && n.effect === "auto_click") .filter((n) => n.unlocked && n.effect === "auto_click" && !n.repeatable)
.reduce((sum, n) => sum + n.value, 0); .reduce((sum, n) => sum + n.value, 0);
const scaling = getAutoClickScaling(tree);
return standard + scaling;
} }
// Chance de crit click (0-1), crit = x10 // Chance de crit click (0-1), crit = x10
@@ -215,13 +480,48 @@ export function getPrestigeThresholdReduction(tree: EvolutionNode[]): number {
.reduce((sum, n) => sum + n.value, 0); .reduce((sum, n) => sum + n.value, 0);
} }
// --- Offline gains (courbe inversée) --- // --- Sprint 3 — Nouveaux effets ---
const OFFLINE_THRESHOLD = 60_000; // 60s — en-dessous = idle normal, au-dessus = offline // Ponte Automatique (capstone) : 1 auto-click/s de base, scale avec les repeatables
const OFFLINE_FULL_MS = 15 * 60_000; // 0-15min : 100% export function getAutoClickScaling(tree: EvolutionNode[]): number {
const OFFLINE_DECAY_END_MS = 60 * 60_000; // 15min-1h : 100% → 25% const capstone = tree.find((n) => n.id === "ponte_auto" && n.unlocked);
const OFFLINE_ZERO_MS = 2 * 60 * 60_000; // 1h-2h : 25% → 0% if (!capstone) return 0;
const OFFLINE_FLOOR = 0.25; // plancher de la phase de decay 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) // Retourne le multiplicateur d'efficacité offline (1.0 → 0.0)
// basé sur le temps d'absence en ms // basé sur le temps d'absence en ms
@@ -250,7 +550,7 @@ export function computeOfflineGains(state: GameState, now: number): number {
const pps = totalProductionPerSecond(state); const pps = totalProductionPerSecond(state);
if (pps <= 0) return 0; if (pps <= 0) return 0;
const offlineBoost = 1 + getOfflineBoost(state.evolutionTree); const offlineBoost = 1 + getOfflineBoost(state.evolutionTree) + getMilestoneOfflineBonus(state);
// Intégration par tranches de 60s // Intégration par tranches de 60s
const STEP = 60_000; const STEP = 60_000;
@@ -276,6 +576,7 @@ export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number {
// 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 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) => {
const boost = gen.id === "nid" ? nidBoost : 1; const boost = gen.id === "nid" ? nidBoost : 1;
@@ -284,7 +585,8 @@ export function totalProductionPerSecond(state: GameState): number {
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
@@ -301,6 +603,10 @@ 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,
},
}; };
} }
@@ -342,6 +648,10 @@ export function applyClick(state: GameState, rng: number = Math.random()): Click
...state, ...state,
resources: state.resources + gain, resources: state.resources + gain,
lifetimeTadpoles: state.lifetimeTadpoles + gain, lifetimeTadpoles: state.lifetimeTadpoles + gain,
runStats: {
...state.runStats,
tadpolesProduced: state.runStats.tadpolesProduced + gain,
},
}, },
gain, gain,
isDouble, isDouble,
@@ -369,7 +679,6 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
} }
// Prestige : reset run, gain ADN, arbre persiste // Prestige : reset run, gain ADN, arbre persiste
const BASE_PRESTIGE_THRESHOLD = 1_000_000;
export function getPrestigeThreshold(state: GameState): number { export function getPrestigeThreshold(state: GameState): number {
const reduction = getPrestigeThresholdReduction(state.evolutionTree); const reduction = getPrestigeThresholdReduction(state.evolutionTree);
@@ -383,7 +692,7 @@ export function canPrestige(state: GameState): boolean {
export function applyPrestige(state: GameState): GameState { export function applyPrestige(state: GameState): GameState {
const newPrestigeCount = state.prestigeCount + 1; const newPrestigeCount = state.prestigeCount + 1;
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree); const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const baseDna = computePrestigeDna(state.lifetimeTadpoles); const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
const dnaGained = Math.floor(baseDna * (1 + dnaBonus)); const dnaGained = Math.floor(baseDna * (1 + dnaBonus));
const startBonus = getStartBonusFromTree(state.evolutionTree); const startBonus = getStartBonusFromTree(state.evolutionTree);
@@ -391,20 +700,42 @@ export function applyPrestige(state: GameState): GameState {
const hasUnlockGen = state.evolutionTree.some( const hasUnlockGen = state.evolutionTree.some(
(n) => n.unlocked && n.effect === "unlock_generator" (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) => ({ generators: state.generators.map((g) => ({
...g, ...g,
owned: hasUnlockGen && g.id === "lac" ? 1 : 0, 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: Date.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
}; };
} }
@@ -419,6 +750,7 @@ 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,
@@ -431,4 +763,12 @@ export const DEFAULT_STATE: GameState = {
lifetimeTadpoles: 0, lifetimeTadpoles: 0,
cosmeticInventory: [], cosmeticInventory: [],
cosmeticEquipped: {}, 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

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

@@ -12,6 +12,8 @@ import { MilestoneBar } from "../components/MilestoneBar";
import { CockpitHeader } from "../components/CockpitHeader"; import { CockpitHeader } from "../components/CockpitHeader";
import { TadpoleSprite } from "../components/TadpoleSprite"; import { TadpoleSprite } from "../components/TadpoleSprite";
import { CosmeticsPanel } from "../components/CosmeticsPanel"; 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";
export default function Home() { export default function Home() {
@@ -136,6 +138,8 @@ 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}>
<TadpoleSprite /> <TadpoleSprite />
@@ -152,6 +156,7 @@ export default function Home() {
<GeneratorShop /> <GeneratorShop />
<div className="gp-sep" /> <div className="gp-sep" />
<PrestigePanel /> <PrestigePanel />
<MilestonesPanel />
<EvolutionTree /> <EvolutionTree />
<CosmeticsPanel /> <CosmeticsPanel />
<a href="/achievements" className="achieve-badge"> <a href="/achievements" className="achieve-badge">

View File

@@ -13,6 +13,10 @@ import {
buyGenerator, buyGenerator,
buyEvolutionNode, buyEvolutionNode,
resetEvolutionTree, resetEvolutionTree,
canResetTree,
upgradeConvergence,
canUpgradeConvergence,
claimMilestone as claimMilestoneFn,
applyPrestige, applyPrestige,
canPrestige as canPrestigeCheck, canPrestige as canPrestigeCheck,
totalProductionPerSecond, totalProductionPerSecond,
@@ -20,6 +24,7 @@ import {
computeOfflineGains, computeOfflineGains,
offlineEfficiency, offlineEfficiency,
} from "../core/economy"; } from "../core/economy";
import { migrateSave } from "../core/migrateSave";
import { import {
computeNewUnlocks, computeNewUnlocks,
equipCosmetic as equipCosmeticFn, equipCosmetic as equipCosmeticFn,
@@ -36,11 +41,8 @@ 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(), lastOnline: 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);
// Backfill for old saves const saved = migrateSave(parsed);
if (!saved.lastOnline) saved.lastOnline = saved.lastTick;
if (!saved.cosmeticInventory) saved.cosmeticInventory = [];
if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {};
return applyIdleGains(saved, Date.now()); return applyIdleGains(saved, Date.now());
} catch { } catch {
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
@@ -68,6 +70,11 @@ interface GameStore {
offlineReport: OfflineReport | null; offlineReport: OfflineReport | null;
dismissOfflineReport: () => void; dismissOfflineReport: () => void;
// Prestige screen (modal fullscreen)
showPrestigeScreen: boolean;
openPrestigeScreen: () => void;
closePrestigeScreen: () => void;
// Last click result (for particle feedback) // Last click result (for particle feedback)
lastClickGain: number; lastClickGain: number;
lastClickDouble: boolean; lastClickDouble: boolean;
@@ -84,6 +91,8 @@ interface GameStore {
buyNode: (nodeId: string) => void; buyNode: (nodeId: string) => void;
prestige: () => void; prestige: () => void;
resetTree: () => void; resetTree: () => void;
upgradeConvergenceNode: () => void;
claimMilestone: (milestoneId: string) => void;
equipCosmetic: (cosmeticId: string) => void; equipCosmetic: (cosmeticId: string) => void;
unequipCosmetic: (slot: CosmeticSlot) => void; unequipCosmetic: (slot: CosmeticSlot) => void;
reset: () => void; reset: () => void;
@@ -94,11 +103,7 @@ interface GameStore {
} }
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } { function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
// Backfill for old saves // migrateSave handles backfill — no manual patching needed here
if (!saved.lastOnline) saved.lastOnline = saved.lastTick;
if (!saved.cosmeticInventory) saved.cosmeticInventory = [];
if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {};
const elapsed = now - saved.lastTick; const elapsed = now - saved.lastTick;
if (elapsed <= OFFLINE_THRESHOLD) { if (elapsed <= OFFLINE_THRESHOLD) {
@@ -142,6 +147,9 @@ export const useGameStore = create<GameStore>((set, get) => ({
playSeconds: 0, playSeconds: 0,
ready: false, ready: false,
offlineReport: null, offlineReport: null,
showPrestigeScreen: false,
openPrestigeScreen: () => set({ showPrestigeScreen: true }),
closePrestigeScreen: () => set({ showPrestigeScreen: false }),
lastClickGain: 0, lastClickGain: 0,
lastClickDouble: false, lastClickDouble: false,
lastClickCrit: false, lastClickCrit: false,
@@ -238,6 +246,7 @@ 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,
}; };
}); });
}, },
@@ -267,6 +276,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
resetTree: () => { resetTree: () => {
if (!get().ready) return; if (!get().ready) return;
set((s) => { set((s) => {
if (!canResetTree(s.state)) return s;
const updated = resetEvolutionTree(s.state); const updated = resetEvolutionTree(s.state);
saveLocal(updated); saveLocal(updated);
return { return {
@@ -276,6 +286,29 @@ export const useGameStore = create<GameStore>((set, get) => ({
}); });
}, },
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(), lastOnline: Date.now() }; const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh); saveLocal(fresh);
@@ -290,7 +323,8 @@ export const useGameStore = create<GameStore>((set, get) => ({
}, },
loadFromServer: (serverState: GameState) => { loadFromServer: (serverState: GameState) => {
const { state: hydrated, report } = hydrateWithOffline(serverState, Date.now()); const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
const { state: hydrated, report } = hydrateWithOffline(migrated, Date.now());
saveLocal(hydrated); saveLocal(hydrated);
set({ set({
state: hydrated, state: hydrated,

View File

@@ -188,6 +188,43 @@ Brief technique : `docs/SPRINT2.md`
--- ---
## 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)
---
## Changelog ## Changelog
| Date | Changement | | Date | Changement |
@@ -195,3 +232,5 @@ Brief technique : `docs/SPRINT2.md`
| 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 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) |

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 |