feat: Sprint 3 — Prestige Loop endless
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 35s
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:
4
Backend/database/migrations/003_save_version.sql
Normal file
4
Backend/database/migrations/003_save_version.sql
Normal 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;
|
||||
@@ -16,6 +16,7 @@ CREATE TABLE game_saves (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL UNIQUE,
|
||||
game_state JSON NOT NULL,
|
||||
save_version INT DEFAULT 1,
|
||||
last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
lifetime_tadpoles BIGINT DEFAULT 0,
|
||||
prestige_count INT DEFAULT 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const tables = require("../tables");
|
||||
const { migrateSave } = require("../services/migrateSave");
|
||||
|
||||
// --- 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
|
||||
const gameState =
|
||||
const rawState =
|
||||
typeof save.game_state === "string"
|
||||
? JSON.parse(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({
|
||||
gameState,
|
||||
lastSave: save.last_save,
|
||||
|
||||
@@ -15,18 +15,20 @@ class GameSaveManager extends AbstractManager {
|
||||
|
||||
async upsert(userId, gameState, metadata) {
|
||||
const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata;
|
||||
const saveVersion = gameState.saveVersion ?? 1;
|
||||
const gameStateJson = JSON.stringify(gameState);
|
||||
|
||||
const [result] = await this.database.query(
|
||||
`INSERT INTO ${this.table} (user_id, game_state, lifetime_tadpoles, prestige_count, play_time_seconds)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`INSERT INTO ${this.table} (user_id, game_state, save_version, lifetime_tadpoles, prestige_count, play_time_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
game_state = VALUES(game_state),
|
||||
save_version = VALUES(save_version),
|
||||
lifetime_tadpoles = VALUES(lifetime_tadpoles),
|
||||
prestige_count = VALUES(prestige_count),
|
||||
play_time_seconds = VALUES(play_time_seconds),
|
||||
last_save = CURRENT_TIMESTAMP`,
|
||||
[userId, gameStateJson, lifetimeTadpoles, prestigeCount, playTimeSeconds]
|
||||
[userId, gameStateJson, saveVersion, lifetimeTadpoles, prestigeCount, playTimeSeconds]
|
||||
);
|
||||
|
||||
return result.affectedRows;
|
||||
|
||||
65
Backend/src/services/migrateSave.js
Normal file
65
Backend/src/services/migrateSave.js
Normal 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 };
|
||||
53
Frontend/src/__tests__/balance.test.ts
Normal file
53
Frontend/src/__tests__/balance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -299,31 +299,51 @@ describe("computePrestigeDna", () => {
|
||||
expect(computePrestigeDna(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("retourne 150 pour 1e9 têtards (sqrt(1) = 1)", () => {
|
||||
expect(computePrestigeDna(1e9)).toBe(150);
|
||||
it("retourne 0 sous le seuil de 1M", () => {
|
||||
expect(computePrestigeDna(999_999)).toBe(0);
|
||||
});
|
||||
|
||||
it("retourne 212 pour 2e9 têtards (sqrt(2) ≈ 1.414)", () => {
|
||||
expect(computePrestigeDna(2e9)).toBe(Math.floor(150 * Math.sqrt(2)));
|
||||
it("retourne 1 (clamp) à exactement 1M têtards", () => {
|
||||
expect(computePrestigeDna(1e6)).toBe(1);
|
||||
});
|
||||
|
||||
it("scaling sub-linéaire — 10× têtards ≠ 10× ADN", () => {
|
||||
const dna1 = computePrestigeDna(1e9);
|
||||
const dna10 = computePrestigeDna(10e9);
|
||||
expect(dna10 / dna1).toBeCloseTo(Math.sqrt(10), 1);
|
||||
it("retourne 50 pour 10M têtards (log10(10) = 1)", () => {
|
||||
expect(computePrestigeDna(10e6)).toBe(50);
|
||||
});
|
||||
|
||||
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 ---
|
||||
|
||||
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 marais = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "marais");
|
||||
const adaptation = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "adaptation");
|
||||
expect(ponte.length).toBe(6);
|
||||
expect(marais.length).toBe(6);
|
||||
expect(adaptation.length).toBe(6);
|
||||
const cross = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "cross");
|
||||
expect(ponte.length).toBe(8);
|
||||
expect(marais.length).toBe(8);
|
||||
expect(adaptation.length).toBe(8);
|
||||
expect(cross.length).toBe(1);
|
||||
expect(DEFAULT_EVOLUTION_TREE.length).toBe(25);
|
||||
});
|
||||
|
||||
describe("canBuyEvolutionNode", () => {
|
||||
@@ -363,8 +383,8 @@ describe("Evolution Tree (3 branches)", () => {
|
||||
n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n
|
||||
),
|
||||
};
|
||||
// auto_ponte exclusive_with ponte_frenetique → locked
|
||||
expect(canBuyEvolutionNode(state, "auto_ponte")).toBe(false);
|
||||
// concentration exclusive_with ponte_frenetique → locked
|
||||
expect(canBuyEvolutionNode(state, "concentration")).toBe(false);
|
||||
});
|
||||
|
||||
it("peut acheter un nœud exclusif si l'alternative n'est pas débloquée", () => {
|
||||
@@ -375,7 +395,7 @@ describe("Evolution Tree (3 branches)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -399,15 +419,17 @@ describe("Evolution Tree (3 branches)", () => {
|
||||
const state = {
|
||||
...DEFAULT_STATE,
|
||||
ancestralDna: 50,
|
||||
prestigeCount: 1,
|
||||
freeResetAvailable: true,
|
||||
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
||||
n.id === "ponte_amelioree" || n.id === "instinct_gregaire"
|
||||
? { ...n, unlocked: true }
|
||||
: n
|
||||
),
|
||||
};
|
||||
// ponte_amelioree (1) + instinct_gregaire (1) = 2 ADN spent
|
||||
// ponte_amelioree (1) + instinct_gregaire (3) = 4 ADN spent, free reset
|
||||
const result = resetEvolutionTree(state);
|
||||
expect(result.ancestralDna).toBe(52);
|
||||
expect(result.ancestralDna).toBe(54);
|
||||
expect(result.evolutionTree.every((n) => !n.unlocked)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -491,41 +513,27 @@ describe("Evolution Tree (3 branches)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlock_generator (Résilience)", () => {
|
||||
it("prestige avec Résilience donne 1 Lac Mystique", () => {
|
||||
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", () => {
|
||||
describe("prestige reset generators", () => {
|
||||
it("prestige remet les générateurs à 0", () => {
|
||||
const state = {
|
||||
...DEFAULT_STATE,
|
||||
resources: 2_000_000,
|
||||
lifetimeTadpoles: 2_000_000,
|
||||
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })),
|
||||
};
|
||||
const result = applyPrestige(state);
|
||||
const lac = result.generators.find((g) => g.id === "lac");
|
||||
expect(lac!.owned).toBe(0);
|
||||
expect(result.generators.every((g) => g.owned === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("retourne 1 si auto_ponte débloqué", () => {
|
||||
it("retourne 1 si capstone Ponte Automatique débloqué", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
123
Frontend/src/__tests__/migrateSave.test.ts
Normal file
123
Frontend/src/__tests__/migrateSave.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
101
Frontend/src/__tests__/milestones.test.ts
Normal file
101
Frontend/src/__tests__/milestones.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { canBuyEvolutionNode, getSpentDna } from "../core/economy";
|
||||
import {
|
||||
canBuyEvolutionNode,
|
||||
getSpentDna,
|
||||
getTreeResetCost,
|
||||
canResetTree,
|
||||
getRepeatableCost,
|
||||
canUpgradeConvergence,
|
||||
} from "../core/economy";
|
||||
import type { EvolutionNode, Branch } from "../core/economy";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
|
||||
const EFFECT_LABELS: Record<string, (v: number) => string> = {
|
||||
const EFFECT_LABELS: Record<string, (v: number, n?: EvolutionNode) => string> = {
|
||||
click_multiplier: (v) => `x${v} ponte`,
|
||||
production_multiplier: (v) => `x${v} production`,
|
||||
start_bonus: (v) => `+${v} têtards au départ`,
|
||||
unlock_generator: () => `Lac Mystique dès le début`,
|
||||
start_bonus: (v) => `+${v} tetards au depart`,
|
||||
unlock_generator: () => `Lac Mystique des le debut`,
|
||||
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`,
|
||||
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`,
|
||||
offline_boost: (v) => `+${(v * 100).toFixed(0)}% gains offline`,
|
||||
prestige_threshold_reduction: (v) => `Prestige à ${((1 - v) * 100).toFixed(0)}% du seuil`,
|
||||
offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`,
|
||||
offline_cap_boost: (v) => `Offline cap → ${(v * 100).toFixed(0)}%, duree 8h`,
|
||||
prestige_threshold_reduction: (v) => `Prestige a ${((1 - v) * 100).toFixed(0)}% du seuil`,
|
||||
all_effects_boost: (v) => `+${(v * 100).toFixed(0)}% tous effets`,
|
||||
post_capstone_discount: (v) => `-${(v * 100).toFixed(0)}% cout post-capstones`,
|
||||
};
|
||||
|
||||
const BRANCH_CONFIG: Record<Branch, { 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" },
|
||||
marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" },
|
||||
adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" },
|
||||
cross: { label: "Convergence", color: "border-purple-500/30", accent: "gp-accent-purple" },
|
||||
};
|
||||
|
||||
function NodeRow({
|
||||
@@ -36,36 +51,62 @@ function NodeRow({
|
||||
isExcluded: boolean;
|
||||
onBuy: () => void;
|
||||
}) {
|
||||
const isCapstone = node.capstone;
|
||||
const isRepeatable = node.repeatable;
|
||||
const purchased = node.purchased ?? 0;
|
||||
|
||||
const rowClass = node.unlocked
|
||||
? "gp-row gp-row--unlocked"
|
||||
? isCapstone
|
||||
? "gp-row gp-row--unlocked border-amber-400/40!"
|
||||
: "gp-row gp-row--unlocked"
|
||||
: isExcluded
|
||||
? "gp-row gp-row--locked opacity-30!"
|
||||
: canBuy
|
||||
? "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";
|
||||
|
||||
const cost = isRepeatable && node.unlocked
|
||||
? getRepeatableCost(node)
|
||||
: isRepeatable
|
||||
? node.cost
|
||||
: node.cost;
|
||||
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<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>
|
||||
{isRepeatable && node.unlocked && (
|
||||
<span className="gp-label text-[0.55rem]!">x{purchased}</span>
|
||||
)}
|
||||
{node.exclusive_with && !node.unlocked && !isExcluded && (
|
||||
<span className="gp-label text-[0.55rem]!">OU</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value) ?? node.effect}</span>
|
||||
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
|
||||
</div>
|
||||
{node.unlocked ? (
|
||||
{node.unlocked && !isRepeatable ? (
|
||||
<span className="gp-label gp-accent-green">OK</span>
|
||||
) : node.unlocked && isRepeatable ? (
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onClick={onBuy}
|
||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
) : isExcluded ? (
|
||||
<span className="gp-label text-[0.55rem]!">verrouillé</span>
|
||||
<span className="gp-label text-[0.55rem]!">verrouille</span>
|
||||
) : (
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onClick={onBuy}
|
||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
>
|
||||
{node.cost}
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
)}
|
||||
</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() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const resetTree = useGameStore((s) => s.resetTree);
|
||||
@@ -108,13 +217,16 @@ export function EvolutionTree() {
|
||||
|
||||
const spentDna = getSpentDna(evolutionTree);
|
||||
const hasUnlocked = spentDna > 0;
|
||||
const resetCost = getTreeResetCost(state);
|
||||
const canReset = canResetTree(state);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!hasUnlocked) return;
|
||||
if (!canReset) return;
|
||||
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : " (gratuit)";
|
||||
const confirmed = window.confirm(
|
||||
`Réinitialiser l'Arbre d'Évolution ?\n\n` +
|
||||
`Tu récupères ${spentDna} ADN Ancestral.\n` +
|
||||
`Tous les nœuds seront verrouillés.\n\n` +
|
||||
`Reinitialiser l'Arbre d'Evolution ?\n\n` +
|
||||
`Tu recuperes ${spentDna} ADN Ancestral.${costLabel}\n` +
|
||||
`Tous les noeuds seront verrouilles.\n\n` +
|
||||
`Confirmer ?`
|
||||
);
|
||||
if (confirmed) resetTree();
|
||||
@@ -123,16 +235,21 @@ export function EvolutionTree() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<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">
|
||||
<span className="gp-value gp-accent-amber">{ancestralDna} ADN</span>
|
||||
<span className="gp-value gp-accent-amber">{formatNumber(ancestralDna)} ADN</span>
|
||||
{hasUnlocked && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="gp-btn gp-btn--disabled text-[0.55rem]! hover:bg-red-500/20! hover:text-red-400!"
|
||||
title={`Récupérer ${spentDna} ADN`}
|
||||
disabled={!canReset}
|
||||
className={`gp-btn text-[0.55rem]! ${
|
||||
canReset
|
||||
? "gp-btn--disabled hover:bg-red-500/20! hover:text-red-400!"
|
||||
: "gp-btn--disabled"
|
||||
}`}
|
||||
title={`Recuperer ${spentDna} ADN${resetCost > 0 ? ` (coute ${resetCost})` : " (gratuit)"}`}
|
||||
>
|
||||
Reset
|
||||
Reset{resetCost > 0 ? ` (${resetCost})` : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,6 +259,7 @@ export function EvolutionTree() {
|
||||
<BranchColumn branch="marais" />
|
||||
<BranchColumn branch="adaptation" />
|
||||
</div>
|
||||
<ConvergenceSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
89
Frontend/src/components/MilestonesPanel.tsx
Normal file
89
Frontend/src/components/MilestonesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,28 +5,15 @@ import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from ".
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
|
||||
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 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 dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
|
||||
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 (
|
||||
<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>
|
||||
@@ -35,12 +22,12 @@ export function PrestigePanel() {
|
||||
<span className="gp-value gp-accent-purple">
|
||||
+{dnaPreview} ADN · +0.1x mult
|
||||
</span>
|
||||
<button onClick={handlePrestige} className="gp-btn gp-btn--prestige">
|
||||
Nouvelle Génération
|
||||
<button onClick={openPrestigeScreen} className="gp-btn gp-btn--prestige">
|
||||
Nouvelle Generation
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
||||
182
Frontend/src/components/PrestigeScreen.tsx
Normal file
182
Frontend/src/components/PrestigeScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
Frontend/src/core/balance.ts
Normal file
68
Frontend/src/core/balance.ts
Normal 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;
|
||||
@@ -1,6 +1,25 @@
|
||||
// economy.ts — Core clicker logic (lazy calculation pattern)
|
||||
// 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -21,27 +40,54 @@ export type EffectType =
|
||||
| "cost_reduction"
|
||||
| "prestige_dna_bonus"
|
||||
| "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 {
|
||||
id: string;
|
||||
name: string;
|
||||
cost: number; // en ADN Ancestral
|
||||
cost: number; // en ADN Ancestral (base cost for repeatables)
|
||||
effect: EffectType;
|
||||
value: number;
|
||||
unlocked: boolean;
|
||||
requires: string | null; // id du nœud prérequis (null = racine)
|
||||
branch: Branch;
|
||||
exclusive_with?: string; // id du nœud alternatif (pick one)
|
||||
// Sprint 3 — capstone & repeatable
|
||||
capstone?: boolean; // nœud capstone (bordure dorée, game-changer)
|
||||
repeatable?: boolean; // post-capstone achetable en boucle
|
||||
purchased?: number; // nombre d'achats pour les repeatables
|
||||
// Sprint 3 — Convergence (nœud évolutif)
|
||||
tier?: number; // tier actuel (1 = Alpha, 2 = Omega)
|
||||
maxTier?: number; // tier max
|
||||
tierUpgradeCost?: number; // coût upgrade au tier suivant
|
||||
tierUpgradeRequires?: string; // condition pour upgrade ("2_capstones")
|
||||
}
|
||||
|
||||
export interface CosmeticSlotMap {
|
||||
[slot: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface RunStats {
|
||||
startedAt: number; // timestamp ms début de la run
|
||||
tadpolesProduced: number; // têtards produits cette run (tracking granulaire)
|
||||
bestRun: {
|
||||
duration: number; // ms
|
||||
tadpoles: number;
|
||||
adn: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
saveVersion: number;
|
||||
resources: number;
|
||||
clickMultiplier: number;
|
||||
generators: Generator[];
|
||||
@@ -54,46 +100,214 @@ export interface GameState {
|
||||
lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN)
|
||||
cosmeticInventory: string[]; // ids des cosmétiques débloqués
|
||||
cosmeticEquipped: CosmeticSlotMap; // slot → cosmetic id
|
||||
// Sprint 3
|
||||
runStats: RunStats;
|
||||
freeResetAvailable: boolean; // 1 gratuit par prestige
|
||||
extraResetsUsed: number; // resets payants dans la génération courante
|
||||
claimedMilestones: string[]; // IDs des milestones réclamés
|
||||
}
|
||||
|
||||
// --- Arbre d'Évolution ---
|
||||
|
||||
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: "double_ponte", name: "Double Ponte", cost: 3, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" },
|
||||
{ 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: "auto_ponte", name: "Auto-Ponte", cost: 8, effect: "auto_click", value: 1, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "ponte_frenetique" },
|
||||
// Tier 2
|
||||
{ id: "double_ponte", name: "Double Ponte", cost: 5, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" },
|
||||
// Tier 3 (exclusif)
|
||||
{ id: "ponte_frenetique", name: "Frénésie", cost: 15, effect: "click_multiplier", value: 3, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "concentration" },
|
||||
{ id: "concentration", name: "Concentration", cost: 15, effect: "click_multiplier", value: 4, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "ponte_frenetique" },
|
||||
// Tier 3 (parallèle)
|
||||
{ id: "ponte_critique", name: "Ponte Critique", cost: 20, effect: "crit_click_chance", value: 0.05, unlocked: false, requires: "double_ponte", branch: "ponte" },
|
||||
// Tier 4
|
||||
{ id: "maitre_pondeur", name: "Maître Pondeur", cost: 40, effect: "click_multiplier", value: 5, unlocked: false, requires: "ponte_critique", branch: "ponte" },
|
||||
// Capstone
|
||||
{ id: "ponte_auto", name: "Ponte Automatique", cost: 200, effect: "auto_click_scaling", value: 1, unlocked: false, requires: "maitre_pondeur", branch: "ponte", capstone: true },
|
||||
// Post-capstone (repeatable)
|
||||
{ id: "ponte_post", name: "+5% auto-ponte", cost: 500, effect: "auto_click", value: 0.05, unlocked: false, requires: "ponte_auto", branch: "ponte", repeatable: true, purchased: 0 },
|
||||
|
||||
// --- Marais (production) ---
|
||||
{ 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" },
|
||||
// ═══ MARAIS (production) — 10 nœuds ═══
|
||||
|
||||
// --- Adaptation (utility) ---
|
||||
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 1, effect: "start_bonus", value: 100, unlocked: false, requires: null, branch: "adaptation" },
|
||||
{ id: "adn_renforce", name: "ADN Renforcé", cost: 3, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "memoire_genetique", branch: "adaptation" },
|
||||
{ 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: "resilience", name: "Résilience", cost: 8, effect: "unlock_generator", value: 0, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "eveil_rapide" },
|
||||
{ id: "heritage", name: "Héritage", cost: 20, effect: "prestige_dna_bonus", value: 0.50, unlocked: false, requires: "eveil_rapide", branch: "adaptation" },
|
||||
{ id: "transcendance", name: "Transcendance", cost: 40, effect: "prestige_threshold_reduction", value: 0.50, unlocked: false, requires: "heritage", branch: "adaptation" },
|
||||
// Tier 1
|
||||
{ id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: null, branch: "marais" },
|
||||
// Tier 2
|
||||
{ id: "symbiose_algale", name: "Symbiose Algale", cost: 8, effect: "generator_boost", value: 2, unlocked: false, requires: "instinct_gregaire", branch: "marais" },
|
||||
// Tier 3 (exclusif)
|
||||
{ id: "courant_profond", name: "Courant Profond", cost: 25, effect: "production_multiplier", value: 2, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "maree_haute" },
|
||||
{ id: "maree_haute", name: "Marée Haute", cost: 25, effect: "cost_reduction", value: 0.20, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "courant_profond" },
|
||||
// Tier 3 (parallèle)
|
||||
{ id: "ecosysteme_mature", name: "Écosystème Mature", cost: 25, effect: "production_multiplier", value: 3, unlocked: false, requires: "symbiose_algale", branch: "marais" },
|
||||
// Tier 4
|
||||
{ id: "marais_eternel", name: "Marais Éternel", cost: 60, effect: "production_multiplier", value: 5, unlocked: false, requires: "ecosysteme_mature", branch: "marais" },
|
||||
// Capstone
|
||||
{ id: "symbiose_totale", name: "Symbiose Totale", cost: 300, effect: "generator_synergy", value: 0.02, unlocked: false, requires: "marais_eternel", branch: "marais", capstone: true },
|
||||
// Post-capstone (repeatable)
|
||||
{ id: "marais_post", name: "+1% synergie", cost: 600, effect: "generator_synergy", value: 0.01, unlocked: false, requires: "symbiose_totale", branch: "marais", repeatable: true, purchased: 0 },
|
||||
|
||||
// ═══ ADAPTATION (utility) — 10 nœuds ═══
|
||||
|
||||
// Tier 1
|
||||
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 2, effect: "start_bonus", value: 100, unlocked: false, requires: null, branch: "adaptation" },
|
||||
// Tier 2
|
||||
{ id: "adn_renforce", name: "ADN Renforcé", cost: 10, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "memoire_genetique", branch: "adaptation" },
|
||||
// Tier 3 (exclusif)
|
||||
{ id: "eveil_rapide", name: "Éveil Rapide", cost: 30, effect: "offline_boost", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "mutation_adn" },
|
||||
{ id: "mutation_adn", name: "Mutation ADN", cost: 30, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "eveil_rapide" },
|
||||
// Tier 3 (parallèle)
|
||||
{ id: "heritage", name: "Héritage", cost: 30, effect: "prestige_dna_bonus", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation" },
|
||||
// Tier 4
|
||||
{ id: "transcendance", name: "Transcendance", cost: 60, effect: "prestige_threshold_reduction", value: 0.50, unlocked: false, requires: "heritage", branch: "adaptation" },
|
||||
// Capstone
|
||||
{ id: "memoire_marais", name: "Mémoire du Marais", cost: 250, effect: "offline_cap_boost", value: 0.75, unlocked: false, requires: "transcendance", branch: "adaptation", capstone: true },
|
||||
// Post-capstone (repeatable)
|
||||
{ id: "adapt_post", name: "+2% offline cap", cost: 500, effect: "offline_boost", value: 0.02, unlocked: false, requires: "memoire_marais", branch: "adaptation", repeatable: true, purchased: 0 },
|
||||
|
||||
// ═══ CROSS-BRANCHE — Convergence (nœud évolutif) ═══
|
||||
|
||||
{ id: "convergence", name: "Convergence", cost: 500, effect: "all_effects_boost", value: 0.10, unlocked: false, requires: null, branch: "cross",
|
||||
tier: 1, maxTier: 2, tierUpgradeCost: 500, tierUpgradeRequires: "2_capstones" },
|
||||
];
|
||||
|
||||
// Calcule l'ADN gagné lors d'un prestige : floor(150 × sqrt(lifetime / 1e9))
|
||||
export function computePrestigeDna(lifetimeTadpoles: number): number {
|
||||
return Math.floor(150 * Math.sqrt(lifetimeTadpoles / 1e9));
|
||||
// Formule ADN Sprint 3 : max(1, floor(base × log10(t / threshold) × (1 + bonus)))
|
||||
// Clamp min 1 si seuil atteint, cap bonus ×4 à 80 prestiges
|
||||
|
||||
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é
|
||||
export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean {
|
||||
const node = state.evolutionTree.find((n) => n.id === nodeId);
|
||||
if (!node || node.unlocked) return false;
|
||||
if (state.ancestralDna < node.cost) return false;
|
||||
if (!node) 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) {
|
||||
const prereq = state.evolutionTree.find((n) => n.id === node.requires);
|
||||
if (!prereq || !prereq.unlocked) return false;
|
||||
@@ -111,31 +325,80 @@ export function buyEvolutionNode(state: GameState, nodeId: string): GameState |
|
||||
if (!canBuyEvolutionNode(state, nodeId)) return null;
|
||||
|
||||
const node = state.evolutionTree.find((n) => n.id === nodeId)!;
|
||||
|
||||
// Repeatable node — already unlocked, increment purchased
|
||||
if (node.repeatable && node.unlocked) {
|
||||
const cost = getRepeatableCost(node);
|
||||
return {
|
||||
...state,
|
||||
ancestralDna: state.ancestralDna - 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) =>
|
||||
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 {
|
||||
const spentDna = state.evolutionTree
|
||||
.filter((n) => n.unlocked)
|
||||
.reduce((sum, n) => sum + n.cost, 0);
|
||||
const cost = getTreeResetCost(state);
|
||||
if (state.ancestralDna < cost) return state;
|
||||
|
||||
const spentDna = getSpentDna(state.evolutionTree);
|
||||
|
||||
return {
|
||||
...state,
|
||||
ancestralDna: state.ancestralDna + spentDna,
|
||||
evolutionTree: state.evolutionTree.map((n) => ({ ...n, unlocked: false })),
|
||||
ancestralDna: state.ancestralDna + spentDna - cost,
|
||||
evolutionTree: state.evolutionTree.map((n) => ({
|
||||
...n,
|
||||
unlocked: false,
|
||||
purchased: n.repeatable ? 0 : n.purchased,
|
||||
tier: n.maxTier ? 1 : n.tier,
|
||||
})),
|
||||
freeResetAvailable: state.freeResetAvailable ? false : state.freeResetAvailable,
|
||||
extraResetsUsed: state.freeResetAvailable ? state.extraResetsUsed : state.extraResetsUsed + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Compte l'ADN total investi dans l'arbre
|
||||
// Compte l'ADN total investi dans l'arbre (standard + repeatables + convergence upgrades)
|
||||
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
|
||||
@@ -166,11 +429,13 @@ export function getDoubleClickChance(tree: EvolutionNode[]): number {
|
||||
.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 {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "auto_click")
|
||||
const standard = tree
|
||||
.filter((n) => n.unlocked && n.effect === "auto_click" && !n.repeatable)
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
const scaling = getAutoClickScaling(tree);
|
||||
return standard + scaling;
|
||||
}
|
||||
|
||||
// Chance de crit click (0-1), crit = x10
|
||||
@@ -215,13 +480,48 @@ export function getPrestigeThresholdReduction(tree: EvolutionNode[]): number {
|
||||
.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
|
||||
const OFFLINE_FULL_MS = 15 * 60_000; // 0-15min : 100%
|
||||
const OFFLINE_DECAY_END_MS = 60 * 60_000; // 15min-1h : 100% → 25%
|
||||
const OFFLINE_ZERO_MS = 2 * 60 * 60_000; // 1h-2h : 25% → 0%
|
||||
const OFFLINE_FLOOR = 0.25; // plancher de la phase de decay
|
||||
// Ponte Automatique (capstone) : 1 auto-click/s de base, scale avec les repeatables
|
||||
export function getAutoClickScaling(tree: EvolutionNode[]): number {
|
||||
const capstone = tree.find((n) => n.id === "ponte_auto" && n.unlocked);
|
||||
if (!capstone) return 0;
|
||||
const baseAutoClick = capstone.value; // 1/s
|
||||
// Post-capstone adds flat auto-click value per purchase
|
||||
const postNode = tree.find((n) => n.id === "ponte_post" && n.unlocked);
|
||||
const postBonus = postNode ? postNode.value * (postNode.purchased ?? 0) : 0;
|
||||
return baseAutoClick + postBonus;
|
||||
}
|
||||
|
||||
// Symbiose Totale (capstone) : +X% par type de générateur possédé
|
||||
// Retourne le multiplicateur (ex: 5 types × 0.02 = 0.10 → ×1.10)
|
||||
export function getGeneratorSynergyMultiplier(tree: EvolutionNode[], generators: Generator[]): number {
|
||||
const synergyNodes = tree.filter((n) => n.unlocked && n.effect === "generator_synergy");
|
||||
if (synergyNodes.length === 0) return 1;
|
||||
const totalSynergyRate = synergyNodes.reduce((sum, n) => {
|
||||
// For repeatables, each purchase adds to the rate
|
||||
const extra = n.repeatable ? n.value * (n.purchased ?? 0) : 0;
|
||||
return sum + n.value + extra;
|
||||
}, 0);
|
||||
const typesOwned = generators.filter((g) => g.owned > 0).length;
|
||||
return 1 + totalSynergyRate * typesOwned;
|
||||
}
|
||||
|
||||
// Convergence : all_effects_boost — multiplicateur global sur tous les effets de l'arbre
|
||||
export function getAllEffectsBoost(tree: EvolutionNode[]): number {
|
||||
const conv = tree.find((n) => n.id === "convergence" && n.unlocked);
|
||||
if (!conv) return 1;
|
||||
return 1 + conv.value; // 0.10 = ×1.10
|
||||
}
|
||||
|
||||
// Convergence Omega : post_capstone_discount
|
||||
export function getPostCapstoneDiscount(tree: EvolutionNode[]): number {
|
||||
const conv = tree.find((n) => n.id === "convergence" && n.unlocked && n.effect === "post_capstone_discount");
|
||||
if (!conv) return 0;
|
||||
return conv.value; // 0.20 = -20%
|
||||
}
|
||||
|
||||
// --- Offline gains (courbe inversée) ---
|
||||
|
||||
// Retourne le multiplicateur d'efficacité offline (1.0 → 0.0)
|
||||
// basé sur le temps d'absence en ms
|
||||
@@ -250,7 +550,7 @@ export function computeOfflineGains(state: GameState, now: number): number {
|
||||
const pps = totalProductionPerSecond(state);
|
||||
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
|
||||
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
|
||||
export function totalProductionPerSecond(state: GameState): number {
|
||||
const nidBoost = getGeneratorBoostFromTree(state.evolutionTree);
|
||||
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
|
||||
const base = state.generators.reduce(
|
||||
(sum, gen) => {
|
||||
const boost = gen.id === "nid" ? nidBoost : 1;
|
||||
@@ -284,7 +585,8 @@ export function totalProductionPerSecond(state: GameState): number {
|
||||
0
|
||||
);
|
||||
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
|
||||
@@ -301,6 +603,10 @@ export function applyIdleGains(state: GameState, now: number): GameState {
|
||||
resources: state.resources + gains,
|
||||
lifetimeTadpoles: state.lifetimeTadpoles + gains,
|
||||
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,
|
||||
resources: state.resources + gain,
|
||||
lifetimeTadpoles: state.lifetimeTadpoles + gain,
|
||||
runStats: {
|
||||
...state.runStats,
|
||||
tadpolesProduced: state.runStats.tadpolesProduced + gain,
|
||||
},
|
||||
},
|
||||
gain,
|
||||
isDouble,
|
||||
@@ -369,7 +679,6 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
|
||||
}
|
||||
|
||||
// Prestige : reset run, gain ADN, arbre persiste
|
||||
const BASE_PRESTIGE_THRESHOLD = 1_000_000;
|
||||
|
||||
export function getPrestigeThreshold(state: GameState): number {
|
||||
const reduction = getPrestigeThresholdReduction(state.evolutionTree);
|
||||
@@ -383,7 +692,7 @@ export function canPrestige(state: GameState): boolean {
|
||||
export function applyPrestige(state: GameState): GameState {
|
||||
const newPrestigeCount = state.prestigeCount + 1;
|
||||
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 startBonus = getStartBonusFromTree(state.evolutionTree);
|
||||
|
||||
@@ -391,20 +700,42 @@ export function applyPrestige(state: GameState): GameState {
|
||||
const hasUnlockGen = state.evolutionTree.some(
|
||||
(n) => n.unlocked && n.effect === "unlock_generator"
|
||||
);
|
||||
// Milestone bonus : Nid gratuit au départ
|
||||
const milestoneNid = getMilestoneStartNid(state);
|
||||
|
||||
// RunStats : snapshot de la run qui se termine
|
||||
const now = Date.now();
|
||||
const runDuration = now - state.runStats.startedAt;
|
||||
const bestRun = state.runStats.bestRun;
|
||||
const newBestRun =
|
||||
!bestRun || dnaGained > bestRun.adn
|
||||
? { duration: runDuration, tadpoles: state.lifetimeTadpoles, adn: dnaGained }
|
||||
: bestRun;
|
||||
|
||||
return {
|
||||
...state,
|
||||
resources: startBonus,
|
||||
generators: state.generators.map((g) => ({
|
||||
...g,
|
||||
owned: hasUnlockGen && g.id === "lac" ? 1 : 0,
|
||||
owned:
|
||||
(hasUnlockGen && g.id === "lac") ? 1 :
|
||||
(milestoneNid > 0 && g.id === "nid") ? milestoneNid :
|
||||
0,
|
||||
})),
|
||||
prestigeCount: newPrestigeCount,
|
||||
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
|
||||
ancestralDna: state.ancestralDna + dnaGained,
|
||||
lifetimeTadpoles: 0,
|
||||
lastTick: Date.now(),
|
||||
lastOnline: Date.now(),
|
||||
lastTick: now,
|
||||
lastOnline: now,
|
||||
// Sprint 3 — nouvelle run
|
||||
runStats: {
|
||||
startedAt: now,
|
||||
tadpolesProduced: 0,
|
||||
bestRun: newBestRun,
|
||||
},
|
||||
freeResetAvailable: true, // 1 reset gratuit offert par prestige
|
||||
extraResetsUsed: 0,
|
||||
// evolutionTree persiste — jamais reset
|
||||
};
|
||||
}
|
||||
@@ -419,6 +750,7 @@ export const DEFAULT_GENERATORS: Generator[] = [
|
||||
];
|
||||
|
||||
export const DEFAULT_STATE: GameState = {
|
||||
saveVersion: CURRENT_SAVE_VERSION,
|
||||
resources: 0,
|
||||
clickMultiplier: 1,
|
||||
generators: DEFAULT_GENERATORS,
|
||||
@@ -431,4 +763,12 @@ export const DEFAULT_STATE: GameState = {
|
||||
lifetimeTadpoles: 0,
|
||||
cosmeticInventory: [],
|
||||
cosmeticEquipped: {},
|
||||
runStats: {
|
||||
startedAt: Date.now(),
|
||||
tadpolesProduced: 0,
|
||||
bestRun: null,
|
||||
},
|
||||
freeResetAvailable: true,
|
||||
extraResetsUsed: 0,
|
||||
claimedMilestones: [],
|
||||
};
|
||||
|
||||
142
Frontend/src/core/migrateSave.ts
Normal file
142
Frontend/src/core/migrateSave.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
76
Frontend/src/data/prestigeMilestones.ts
Normal file
76
Frontend/src/data/prestigeMilestones.ts
Normal 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)" },
|
||||
},
|
||||
];
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import type { GameState } from "../core/economy";
|
||||
import { migrateSave } from "../core/migrateSave";
|
||||
|
||||
const SAVE_INTERVAL_MS = 30_000; // 30 seconds
|
||||
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) => {
|
||||
if (data?.gameState) {
|
||||
onLoad(data.gameState);
|
||||
const migrated = migrateSave(data.gameState);
|
||||
onLoad(migrated);
|
||||
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 {
|
||||
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) => {
|
||||
if (data?.gameState && data.lastSave) {
|
||||
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;
|
||||
console.info("[SaveSync] Reloaded from server on focus");
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { MilestoneBar } from "../components/MilestoneBar";
|
||||
import { CockpitHeader } from "../components/CockpitHeader";
|
||||
import { TadpoleSprite } from "../components/TadpoleSprite";
|
||||
import { CosmeticsPanel } from "../components/CosmeticsPanel";
|
||||
import { PrestigeScreen } from "../components/PrestigeScreen";
|
||||
import { MilestonesPanel } from "../components/MilestonesPanel";
|
||||
import { ACHIEVEMENTS } from "../data/achievements";
|
||||
|
||||
export default function Home() {
|
||||
@@ -136,6 +138,8 @@ export default function Home() {
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
</Helmet>
|
||||
|
||||
<PrestigeScreen />
|
||||
|
||||
{/* Clicker area — centre */}
|
||||
<div className="click-zone" onClick={handleIncrement}>
|
||||
<TadpoleSprite />
|
||||
@@ -152,6 +156,7 @@ export default function Home() {
|
||||
<GeneratorShop />
|
||||
<div className="gp-sep" />
|
||||
<PrestigePanel />
|
||||
<MilestonesPanel />
|
||||
<EvolutionTree />
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" className="achieve-badge">
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
buyGenerator,
|
||||
buyEvolutionNode,
|
||||
resetEvolutionTree,
|
||||
canResetTree,
|
||||
upgradeConvergence,
|
||||
canUpgradeConvergence,
|
||||
claimMilestone as claimMilestoneFn,
|
||||
applyPrestige,
|
||||
canPrestige as canPrestigeCheck,
|
||||
totalProductionPerSecond,
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
computeOfflineGains,
|
||||
offlineEfficiency,
|
||||
} from "../core/economy";
|
||||
import { migrateSave } from "../core/migrateSave";
|
||||
import {
|
||||
computeNewUnlocks,
|
||||
equipCosmetic as equipCosmeticFn,
|
||||
@@ -36,11 +41,8 @@ function loadLocalState(): GameState {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
const saved = JSON.parse(raw) as GameState;
|
||||
// Backfill for old saves
|
||||
if (!saved.lastOnline) saved.lastOnline = saved.lastTick;
|
||||
if (!saved.cosmeticInventory) saved.cosmeticInventory = [];
|
||||
if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {};
|
||||
const parsed = JSON.parse(raw);
|
||||
const saved = migrateSave(parsed);
|
||||
return applyIdleGains(saved, Date.now());
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
@@ -68,6 +70,11 @@ interface GameStore {
|
||||
offlineReport: OfflineReport | null;
|
||||
dismissOfflineReport: () => void;
|
||||
|
||||
// Prestige screen (modal fullscreen)
|
||||
showPrestigeScreen: boolean;
|
||||
openPrestigeScreen: () => void;
|
||||
closePrestigeScreen: () => void;
|
||||
|
||||
// Last click result (for particle feedback)
|
||||
lastClickGain: number;
|
||||
lastClickDouble: boolean;
|
||||
@@ -84,6 +91,8 @@ interface GameStore {
|
||||
buyNode: (nodeId: string) => void;
|
||||
prestige: () => void;
|
||||
resetTree: () => void;
|
||||
upgradeConvergenceNode: () => void;
|
||||
claimMilestone: (milestoneId: string) => void;
|
||||
equipCosmetic: (cosmeticId: string) => void;
|
||||
unequipCosmetic: (slot: CosmeticSlot) => void;
|
||||
reset: () => void;
|
||||
@@ -94,11 +103,7 @@ interface GameStore {
|
||||
}
|
||||
|
||||
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
|
||||
// Backfill for old saves
|
||||
if (!saved.lastOnline) saved.lastOnline = saved.lastTick;
|
||||
if (!saved.cosmeticInventory) saved.cosmeticInventory = [];
|
||||
if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {};
|
||||
|
||||
// migrateSave handles backfill — no manual patching needed here
|
||||
const elapsed = now - saved.lastTick;
|
||||
|
||||
if (elapsed <= OFFLINE_THRESHOLD) {
|
||||
@@ -142,6 +147,9 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
playSeconds: 0,
|
||||
ready: false,
|
||||
offlineReport: null,
|
||||
showPrestigeScreen: false,
|
||||
openPrestigeScreen: () => set({ showPrestigeScreen: true }),
|
||||
closePrestigeScreen: () => set({ showPrestigeScreen: false }),
|
||||
lastClickGain: 0,
|
||||
lastClickDouble: false,
|
||||
lastClickCrit: false,
|
||||
@@ -238,6 +246,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
state: updated,
|
||||
canPrestige: canPrestigeCheck(updated),
|
||||
productionPerSecond: totalProductionPerSecond(updated),
|
||||
showPrestigeScreen: false,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -267,6 +276,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
resetTree: () => {
|
||||
if (!get().ready) return;
|
||||
set((s) => {
|
||||
if (!canResetTree(s.state)) return s;
|
||||
const updated = resetEvolutionTree(s.state);
|
||||
saveLocal(updated);
|
||||
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: () => {
|
||||
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
saveLocal(fresh);
|
||||
@@ -290,7 +323,8 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
},
|
||||
|
||||
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);
|
||||
set({
|
||||
state: hydrated,
|
||||
|
||||
39
docs/GDD.md
39
docs/GDD.md
@@ -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
|
||||
|
||||
| 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-20 | Refonte game-designer — Tetard Universe, Arbre d'Évolution, anti-triche backend, SuperOAuth, stack confirmée Express |
|
||||
| 2026-03-28 | Sprint 1 livré (6/6). Sprint 2 briefé — offline gains courbe inversée, arbre 3 voies, cosmétiques récompenses |
|
||||
| 2026-03-28 | Sprint 2 livré (3/3). Sprint 3 briefé — prestige loop endless, arbre V2 30 nœuds, capstones, milestones, formule ADN rebalancée |
|
||||
| 2026-03-28 | Sprint 3 brainstorm — 5 décisions : saveVersion migration, formule ADN clamp+cap, scaling post-capstone par tranche, reset 1 gratuit/prestige + vision build-sharing, Convergence évolutif (Alpha→Omega, nœud unique à tiers) |
|
||||
|
||||
315
docs/SPRINT3.md
Normal file
315
docs/SPRINT3.md
Normal 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 |
|
||||
Reference in New Issue
Block a user