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

- game_saves table + migration 002 (JSON state, anti-cheat metadata)
- saveControllers.js : load/save avec validation delta ressources (750k/s × 1.1)
- GameSaveManager : upsert MySQL ON DUPLICATE KEY UPDATE
- useSaveSync hook : auto-save 30s + keepalive beforeunload + guest fallback
- save-validation.test.ts : 8 tests anti-cheat
- economy.ts : arbre d'évolution 5 nœuds + prestige ADN (rattrapage step 2)
- economy.test.ts : +40 tests (évolution tree, multipliers, start bonus)
- GDD + SPRINT1.md : docs sprint complètes
- Rethème data : shop.json, Achievements.json, Cookie, Legal (rattrapage step 1)
This commit is contained in:
2026-03-20 13:40:16 +01:00
parent 9f0ccda99b
commit a52746ed0c
20 changed files with 1167 additions and 152 deletions

View File

@@ -7,13 +7,19 @@ import {
buyGenerator,
applyPrestige,
canPrestige,
computePrestigeDna,
canBuyEvolutionNode,
buyEvolutionNode,
getClickMultiplierFromTree,
getProductionMultiplierFromTree,
getStartBonusFromTree,
DEFAULT_STATE,
DEFAULT_GENERATORS,
DEFAULT_EVOLUTION_TREE,
} from "../core/economy";
// PrestigePanel visibility guard — canPrestige drives render condition
// Ces tests valident l'invariant : le panneau prestige ne doit jamais être
// visible (canPrestige = false) si les ressources sont inférieures au seuil.
// --- PrestigePanel visibility ---
describe("PrestigePanel visibility (canPrestige guard)", () => {
it("canPrestige = false pour resources = 0 → panneau non visible", () => {
expect(canPrestige({ ...DEFAULT_STATE, resources: 0 })).toBe(false);
@@ -28,6 +34,8 @@ describe("PrestigePanel visibility (canPrestige guard)", () => {
});
});
// --- Prestige reset ---
describe("applyPrestige — post-prestige state", () => {
const prestigeState = {
...DEFAULT_STATE,
@@ -35,9 +43,10 @@ describe("applyPrestige — post-prestige state", () => {
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 3 })),
prestigeCount: 0,
prestigeMultiplier: 1,
lifetimeTadpoles: 2_000_000_000,
};
it("ressources = 0 après prestige", () => {
it("ressources = startBonus (0 sans nœud Mémoire Génétique) après prestige", () => {
expect(applyPrestige(prestigeState).resources).toBe(0);
});
@@ -53,8 +62,43 @@ describe("applyPrestige — post-prestige state", () => {
it("prestigeCount incrémenté à 1 après premier prestige", () => {
expect(applyPrestige(prestigeState).prestigeCount).toBe(1);
});
it("gagne de l'ADN Ancestral au prestige", () => {
const result = applyPrestige(prestigeState);
const expectedDna = computePrestigeDna(2_000_000_000);
expect(result.ancestralDna).toBe(expectedDna);
expect(expectedDna).toBeGreaterThan(0);
});
it("lifetimeTadpoles reset à 0 après prestige", () => {
expect(applyPrestige(prestigeState).lifetimeTadpoles).toBe(0);
});
it("arbre d'évolution persiste après prestige", () => {
const stateWithNode = {
...prestigeState,
evolutionTree: prestigeState.evolutionTree.map((n) =>
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
),
};
const result = applyPrestige(stateWithNode);
expect(result.evolutionTree.find((n) => n.id === "ponte_amelioree")!.unlocked).toBe(true);
});
it("startBonus appliqué si Mémoire Génétique débloquée", () => {
const stateWithMemory = {
...prestigeState,
evolutionTree: prestigeState.evolutionTree.map((n) =>
n.id === "memoire_genetique" ? { ...n, unlocked: true } : n
),
};
const result = applyPrestige(stateWithMemory);
expect(result.resources).toBe(100);
});
});
// --- Generator cost ---
describe("generatorCost", () => {
it("retourne baseCost quand owned = 0", () => {
const gen = { ...DEFAULT_GENERATORS[0], owned: 0 };
@@ -67,6 +111,8 @@ describe("generatorCost", () => {
});
});
// --- Production ---
describe("totalProductionPerSecond", () => {
it("retourne 0 si aucun générateur acheté", () => {
expect(totalProductionPerSecond(DEFAULT_STATE)).toBe(0);
@@ -87,8 +133,21 @@ describe("totalProductionPerSecond", () => {
const state = { ...DEFAULT_STATE, generators: DEFAULT_STATE.generators.map((g, i) => i === 0 ? { ...g, owned: 1 } : g), prestigeMultiplier: 1.5 };
expect(totalProductionPerSecond(state)).toBeCloseTo(DEFAULT_GENERATORS[0].baseProduction * 1.5);
});
it("applique le multiplicateur arbre d'évolution", () => {
const state = {
...DEFAULT_STATE,
generators: DEFAULT_STATE.generators.map((g, i) => i === 0 ? { ...g, owned: 1 } : g),
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "instinct_gregaire" ? { ...n, unlocked: true } : n
),
};
expect(totalProductionPerSecond(state)).toBeCloseTo(DEFAULT_GENERATORS[0].baseProduction * 1.5);
});
});
// --- Idle gains ---
describe("computeIdleGains (lazy calculation)", () => {
it("calcule les gains proportionnellement au temps écoulé", () => {
const state = {
@@ -108,29 +167,54 @@ describe("computeIdleGains (lazy calculation)", () => {
});
});
// --- Click ---
describe("applyClick", () => {
it("augmente les ressources du clickMultiplier × prestigeMultiplier", () => {
const state = { ...DEFAULT_STATE, clickMultiplier: 3, prestigeMultiplier: 2 };
const result = applyClick(state);
expect(result.resources).toBe(6);
});
it("applique le multiplicateur click de l'arbre", () => {
const state = {
...DEFAULT_STATE,
clickMultiplier: 1,
prestigeMultiplier: 1,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
),
};
const result = applyClick(state);
expect(result.resources).toBe(2); // ×2 depuis Ponte Améliorée
});
it("incrémente lifetimeTadpoles", () => {
const state = { ...DEFAULT_STATE, clickMultiplier: 5, prestigeMultiplier: 1 };
const result = applyClick(state);
expect(result.lifetimeTadpoles).toBe(5);
});
});
// --- Buy generator ---
describe("buyGenerator", () => {
it("retourne null si fonds insuffisants", () => {
const result = buyGenerator(DEFAULT_STATE, "manic");
expect(result).toBeNull(); // 0 ressources, coût = 15
const result = buyGenerator(DEFAULT_STATE, "nid");
expect(result).toBeNull();
});
it("achète correctement et déduit le coût", () => {
const state = { ...DEFAULT_STATE, resources: 100 };
const result = buyGenerator(state, "manic");
const result = buyGenerator(state, "nid");
expect(result).not.toBeNull();
expect(result!.generators.find((g) => g.id === "manic")!.owned).toBe(1);
expect(result!.generators.find((g) => g.id === "nid")!.owned).toBe(1);
expect(result!.resources).toBe(100 - DEFAULT_GENERATORS[0].baseCost);
});
});
// --- Prestige legacy ---
describe("prestige", () => {
it("canPrestige retourne false si < 1 000 000 ressources", () => {
expect(canPrestige({ ...DEFAULT_STATE, resources: 999_999 })).toBe(false);
@@ -154,3 +238,129 @@ describe("prestige", () => {
expect(result.generators.every((g) => g.owned === 0)).toBe(true);
});
});
// --- ADN Ancestral ---
describe("computePrestigeDna", () => {
it("retourne 0 pour 0 têtards", () => {
expect(computePrestigeDna(0)).toBe(0);
});
it("retourne 150 pour 1e9 têtards (sqrt(1) = 1)", () => {
expect(computePrestigeDna(1e9)).toBe(150);
});
it("retourne 212 pour 2e9 têtards (sqrt(2) ≈ 1.414)", () => {
expect(computePrestigeDna(2e9)).toBe(Math.floor(150 * Math.sqrt(2)));
});
it("scaling sub-linéaire — 10× têtards ≠ 10× ADN", () => {
const dna1 = computePrestigeDna(1e9);
const dna10 = computePrestigeDna(10e9);
expect(dna10 / dna1).toBeCloseTo(Math.sqrt(10), 1);
});
});
// --- Arbre d'Évolution ---
describe("Evolution Tree", () => {
describe("canBuyEvolutionNode", () => {
it("peut acheter le premier nœud (pas de prérequis) avec assez d'ADN", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 5 };
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(true);
});
it("ne peut pas acheter sans assez d'ADN", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 0 };
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(false);
});
it("ne peut pas acheter un nœud déjà débloqué", () => {
const state = {
...DEFAULT_STATE,
ancestralDna: 100,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
),
};
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(false);
});
it("ne peut pas acheter un nœud dont le prérequis n'est pas débloqué", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 100 };
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(false);
});
it("peut acheter un nœud si le prérequis est débloqué", () => {
const state = {
...DEFAULT_STATE,
ancestralDna: 100,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
),
};
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(true);
});
});
describe("buyEvolutionNode", () => {
it("débloque le nœud et déduit l'ADN", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 5 };
const result = buyEvolutionNode(state, "ponte_amelioree");
expect(result).not.toBeNull();
expect(result!.ancestralDna).toBe(4);
expect(result!.evolutionTree.find((n) => n.id === "ponte_amelioree")!.unlocked).toBe(true);
});
it("retourne null si impossible", () => {
const result = buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree");
expect(result).toBeNull();
});
it("ne modifie pas les autres nœuds", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 5 };
const result = buyEvolutionNode(state, "ponte_amelioree")!;
const otherNodes = result.evolutionTree.filter((n) => n.id !== "ponte_amelioree");
expect(otherNodes.every((n) => n.unlocked === false)).toBe(true);
});
});
describe("getClickMultiplierFromTree", () => {
it("retourne 1 si aucun nœud click débloqué", () => {
expect(getClickMultiplierFromTree(DEFAULT_EVOLUTION_TREE)).toBe(1);
});
it("retourne 2 si Ponte Améliorée débloquée", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
);
expect(getClickMultiplierFromTree(tree)).toBe(2);
});
});
describe("getProductionMultiplierFromTree", () => {
it("retourne 1 si aucun nœud production débloqué", () => {
expect(getProductionMultiplierFromTree(DEFAULT_EVOLUTION_TREE)).toBe(1);
});
it("retourne 1.5 si Instinct Grégaire débloqué", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "instinct_gregaire" ? { ...n, unlocked: true } : n
);
expect(getProductionMultiplierFromTree(tree)).toBe(1.5);
});
});
describe("getStartBonusFromTree", () => {
it("retourne 0 si aucun nœud start débloqué", () => {
expect(getStartBonusFromTree(DEFAULT_EVOLUTION_TREE)).toBe(0);
});
it("retourne 100 si Mémoire Génétique débloquée", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "memoire_genetique" ? { ...n, unlocked: true } : n
);
expect(getStartBonusFromTree(tree)).toBe(100);
});
});
});