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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user