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);
});
});
});

View File

@@ -0,0 +1,123 @@
// save-validation.test.ts — Tests anti-cheat validation logic
// Ported from backend saveControllers.validateGameState for unit testing
import { describe, it, expect } from "vitest";
import { DEFAULT_STATE } from "../core/economy";
// Reproduce the validation logic client-side for testing
const MAX_PRODUCTION_PER_SECOND = 750_000;
const CHEAT_MARGIN = 1.1;
interface PreviousSave {
last_save: string;
game_state: { resources: number };
}
function validateGameState(
gameState: { resources: number; generators?: unknown[] },
previousSave: PreviousSave | null
): { valid: boolean; reason?: string } {
if (!gameState || typeof gameState !== "object") {
return { valid: false, reason: "Invalid game state format" };
}
if (typeof gameState.resources !== "number" || gameState.resources < 0) {
return { valid: false, reason: "Invalid resources value" };
}
if (!Array.isArray(gameState.generators)) {
return { valid: false, reason: "Invalid generators" };
}
if (!previousSave) {
return { valid: true };
}
const lastSaveTime = new Date(previousSave.last_save).getTime();
const now = Date.now();
const elapsedSeconds = Math.max((now - lastSaveTime) / 1000, 0);
const maxPossibleResources =
MAX_PRODUCTION_PER_SECOND * elapsedSeconds * CHEAT_MARGIN;
const previousResources = previousSave.game_state?.resources ?? 0;
const resourceDelta = gameState.resources - previousResources;
if (resourceDelta > maxPossibleResources && resourceDelta > 0) {
return {
valid: false,
reason: `Resource gain exceeds maximum possible`,
};
}
return { valid: true };
}
describe("Anti-cheat validation", () => {
it("accepts first save (no previous)", () => {
const result = validateGameState(
{ ...DEFAULT_STATE, resources: 1000 },
null
);
expect(result.valid).toBe(true);
});
it("accepts legitimate resource gain", () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const result = validateGameState(
{ ...DEFAULT_STATE, resources: 100_000 },
{ last_save: fiveMinutesAgo, game_state: { resources: 50_000 } }
);
expect(result.valid).toBe(true);
});
it("rejects impossibly large resource gain", () => {
const oneSecondAgo = new Date(Date.now() - 1000).toISOString();
const result = validateGameState(
{ ...DEFAULT_STATE, resources: 999_999_999_999 },
{ last_save: oneSecondAgo, game_state: { resources: 0 } }
);
expect(result.valid).toBe(false);
expect(result.reason).toContain("exceeds maximum");
});
it("rejects negative resources", () => {
const result = validateGameState(
{ ...DEFAULT_STATE, resources: -100 },
null
);
expect(result.valid).toBe(false);
});
it("rejects invalid game state format", () => {
const result = validateGameState(null as any, null);
expect(result.valid).toBe(false);
});
it("rejects missing generators array", () => {
const result = validateGameState(
{ resources: 100, generators: "not an array" } as any,
null
);
expect(result.valid).toBe(false);
});
it("accepts resource loss (spending on generators)", () => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString();
const result = validateGameState(
{ ...DEFAULT_STATE, resources: 1000 },
{ last_save: oneMinuteAgo, game_state: { resources: 5000 } }
);
expect(result.valid).toBe(true); // delta < 0, so always OK
});
it("accepts gains within max production window", () => {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
// 10 min × 750,000/s × 1.1 = 495,000,000
const result = validateGameState(
{ ...DEFAULT_STATE, resources: 400_000_000 },
{ last_save: tenMinutesAgo, game_state: { resources: 0 } }
);
expect(result.valid).toBe(true);
});
});

View File

@@ -9,15 +9,94 @@ export interface Generator {
owned: number;
}
export type EffectType = "click_multiplier" | "production_multiplier" | "start_bonus" | "unlock_generator" | "achievement_scaling";
export interface EvolutionNode {
id: string;
name: string;
cost: number; // en ADN Ancestral
effect: EffectType;
value: number;
unlocked: boolean;
requires: string | null; // id du nœud prérequis (null = racine)
}
export interface GameState {
resources: number;
clickMultiplier: number;
generators: Generator[];
lastTick: number; // timestamp ms — lazy calc reference
prestigeCount: number;
prestigeMultiplier: number; // 1 + prestigeCount * 0.1
prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre
ancestralDna: number;
evolutionTree: EvolutionNode[];
lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN)
}
// --- Arbre d'Évolution ---
export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [
{ id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null },
{ id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: "ponte_amelioree" },
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 10, effect: "start_bonus", value: 100, unlocked: false, requires: "instinct_gregaire" },
{ id: "mutation_alpha", name: "Mutation Alpha", cost: 25, effect: "unlock_generator", value: 0, unlocked: false, requires: "memoire_genetique" },
{ id: "symbiose", name: "Symbiose", cost: 50, effect: "achievement_scaling", value: 0.01, unlocked: false, requires: "mutation_alpha" },
];
// 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));
}
// 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.requires) {
const prereq = state.evolutionTree.find((n) => n.id === node.requires);
if (!prereq || !prereq.unlocked) return false;
}
return true;
}
// Achète un nœud d'évolution (retourne null si impossible)
export function buyEvolutionNode(state: GameState, nodeId: string): GameState | null {
if (!canBuyEvolutionNode(state, nodeId)) return null;
const node = state.evolutionTree.find((n) => n.id === nodeId)!;
return {
...state,
ancestralDna: state.ancestralDna - node.cost,
evolutionTree: state.evolutionTree.map((n) =>
n.id === nodeId ? { ...n, unlocked: true } : n
),
};
}
// Calcule le multiplicateur click total depuis l'arbre
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "click_multiplier")
.reduce((mult, n) => mult * n.value, 1);
}
// Calcule le multiplicateur production total depuis l'arbre
export function getProductionMultiplierFromTree(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "production_multiplier")
.reduce((mult, n) => mult * n.value, 1);
}
// Bonus de départ (têtards offerts au début de chaque run)
export function getStartBonusFromTree(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "start_bonus")
.reduce((sum, n) => sum + n.value, 0);
}
// --- Core economy (mis à jour pour intégrer l'arbre) ---
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned
export function generatorCost(gen: Generator): number {
return Math.floor(gen.baseCost * Math.pow(1.15, gen.owned));
@@ -29,7 +108,8 @@ export function totalProductionPerSecond(state: GameState): number {
(sum, gen) => sum + gen.baseProduction * gen.owned,
0
);
return base * state.prestigeMultiplier;
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
return base * state.prestigeMultiplier * treeMultiplier;
}
// Lazy calculation : ressources accumulées depuis lastTick
@@ -44,15 +124,19 @@ export function applyIdleGains(state: GameState, now: number): GameState {
return {
...state,
resources: state.resources + gains,
lifetimeTadpoles: state.lifetimeTadpoles + gains,
lastTick: now,
};
}
// Clic manuel
export function applyClick(state: GameState): GameState {
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
const gain = state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
return {
...state,
resources: state.resources + state.clickMultiplier * state.prestigeMultiplier,
resources: state.resources + gain,
lifetimeTadpoles: state.lifetimeTadpoles + gain,
};
}
@@ -75,30 +159,36 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
};
}
// Prestige : reset ressources + générateurs, +0.1× multiplicateur permanent
// Prestige : reset run, gain ADN, arbre persiste
export function canPrestige(state: GameState): boolean {
return state.resources >= 1_000_000;
}
export function applyPrestige(state: GameState): GameState {
const newPrestigeCount = state.prestigeCount + 1;
const dnaGained = computePrestigeDna(state.lifetimeTadpoles);
const startBonus = getStartBonusFromTree(state.evolutionTree);
return {
...state,
resources: 0,
resources: startBonus,
generators: state.generators.map((g) => ({ ...g, owned: 0 })),
prestigeCount: newPrestigeCount,
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
ancestralDna: state.ancestralDna + dnaGained,
lifetimeTadpoles: 0,
lastTick: Date.now(),
// evolutionTree persiste — jamais reset
};
}
// Valeurs par défaut — 5 tiers alignés GDD (x10 coût / tier, x5 production)
// Valeurs par défaut — 5 tiers alignés GDD Tetard Universe (x10 coût / tier)
export const DEFAULT_GENERATORS: Generator[] = [
{ id: "manic", name: "Manic", baseCost: 10, baseProduction: 0.1, owned: 0 },
{ id: "coffee", name: "Tasse à café", baseCost: 100, baseProduction: 0.5, owned: 0 },
{ id: "sugar", name: "Sucre", baseCost: 1_000, baseProduction: 3, owned: 0 },
{ id: "factory", name: "Usine", baseCost: 10_000, baseProduction: 20, owned: 0 },
{ id: "portal", name: "Portail", baseCost: 100_000, baseProduction: 150, owned: 0 },
{ id: "nid", name: "Nid", baseCost: 10, baseProduction: 0.1, owned: 0 },
{ id: "mare", name: "Mare", baseCost: 100, baseProduction: 0.5, owned: 0 },
{ id: "marecage", name: "Marécage", baseCost: 1_000, baseProduction: 3, owned: 0 },
{ id: "etang", name: "Étang Ancien", baseCost: 10_000, baseProduction: 20, owned: 0 },
{ id: "lac", name: "Lac Mystique", baseCost: 100_000, baseProduction: 150, owned: 0 },
];
export const DEFAULT_STATE: GameState = {
@@ -108,4 +198,7 @@ export const DEFAULT_STATE: GameState = {
lastTick: Date.now(),
prestigeCount: 0,
prestigeMultiplier: 1,
ancestralDna: 0,
evolutionTree: DEFAULT_EVOLUTION_TREE,
lifetimeTadpoles: 0,
};

View File

@@ -111,7 +111,7 @@
},
{
"id": 18,
"name": "un pull de noël",
"name": "Peau de grenouille rare",
"founded": false,
"image": "https://i.goopics.net/uwjwn1.jpg"
},
@@ -189,7 +189,7 @@
},
{
"id": 30,
"name": "Calendrier de l'Avent avec des chocolats au wasabi et moutarde forte",
"name": "Kit de survie du marais au wasabi et moutarde forte",
"founded": false,
"image": "https://i.goopics.net/dakyj9.png"
},
@@ -228,7 +228,7 @@
},
{
"id": 36,
"name": "Chapeau de Noël clignotant",
"name": "Couronne de nénuphars lumineuse",
"founded": false,
"image": "https://i.goopics.net/d4su7e.png"
},

View File

@@ -1,89 +1,89 @@
[
{
"name": "Manic",
"name": "Griffes de Grenouille",
"price": 15,
"incrementValue": 1,
"description": "Evite de vous bruler quand vous sortez les cookies du four, vous gagnez 5 CPS",
"description": "Des griffes acérées pour une ponte plus efficace. +1 par clic.",
"link": "/",
"image": "./svg/Hand.svg",
"buyed": false,
"type": "actif"
},
{
"name": "Tasse à café",
"name": "Algues Nutritives",
"price": 15,
"incrementValue": 1,
"description": "Bien chaud vous permet de tenir sur la durée",
"description": "Les algues nourrissent le marais en continu. +1 têtard/s.",
"link": "/",
"image": "./svg/Tasse.svg",
"buyed": false,
"type": "passif"
},
{
"name": "Mr Bonhomme",
"name": "Crapaud Gardien",
"price": 150,
"incrementValue": 10,
"description": "Un assistant idéal pour le click",
"description": "Un ancien du marais qui veille sur les pontes. +10 par clic.",
"link": "/",
"image": "./svg/Bonhome.svg",
"buyed": false,
"type": "actif"
},
{
"name": "Bonnet",
"name": "Nénuphar Géant",
"price": 150,
"incrementValue": 10,
"description": "Garder vos oreilles bien à l'abri du froid et click !",
"description": "Un nénuphar massif qui attire les têtards. +10 têtards/s.",
"link": "/",
"image": "./svg/Bonnet.svg",
"buyed": false,
"type": "passif"
},
{
"name": "Cookie",
"name": "Oeuf Doré",
"price": 1500,
"incrementValue": 100,
"description": "Fait avec amour",
"description": "Un oeuf rare qui éclot en masse. +100 par clic.",
"link": "/",
"image": "./svg/Cookie.svg",
"buyed": false,
"type": "actif"
},
{
"name": "Canne en sucre",
"name": "Mousse Lumineuse",
"price": 1500,
"incrementValue": 100,
"description": "Le sucre c'est connu, ca reboost",
"description": "La mousse phosphorescente accélère la croissance. +100 têtards/s.",
"link": "/",
"image": "./svg/Canne.svg",
"buyed": false,
"type": "passif"
},
{
"name": "Couronne d'hiver",
"name": "Couronne de Roseaux",
"price": 15000,
"incrementValue": 1000,
"description": "Un bisous ou rien du tout !",
"description": "Le symbole du Gardien suprême du Marais. +1000 par clic.",
"link": "/",
"image": "./svg/Courone.svg",
"buyed": false,
"type": "actif"
},
{
"name": "Mr pain d'épice",
"name": "Esprit du Marais",
"price": 15000,
"incrementValue": 1000,
"description": "Le meilleur c'est la tête",
"description": "L'esprit ancestral bénit les eaux. +1000 têtards/s.",
"link": "/",
"image": "./svg/PainDep.svg",
"buyed": false,
"type": "passif"
},
{
"name": "Bière",
"name": "Nectar de Lotus",
"price": 8000,
"incrementValue": 1000,
"description": "Boisson de qualité, double tout les CPS, attention à ne pas trop en abuser",
"description": "Un nectar enivrant qui trouble les eaux... mais booste la ponte. Attention aux effets secondaires.",
"link": "/",
"image": "./svg/Beer.svg",
"buyed": false,

View File

@@ -0,0 +1,115 @@
// useSaveSync.ts — Auto-save game state to backend every 30s
// Requires JWT token in localStorage (set by auth flow)
// Falls back silently if no token (guest mode)
import { useEffect, useRef, useCallback } from "react";
import type { GameState } from "../core/economy";
const SAVE_INTERVAL_MS = 30_000; // 30 seconds
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3310";
interface SaveSyncOptions {
getGameState: () => GameState;
onLoad: (state: GameState) => void;
playTimeSeconds: number;
}
async function apiRequest(path: string, options: RequestInit = {}) {
const token = localStorage.getItem("token");
if (!token) return null;
const res = await fetch(`${BACKEND_URL}/api${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"x-auth-token": token,
...options.headers,
},
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.warn(`[SaveSync] ${path} failed:`, res.status, body);
return null;
}
return res.json();
}
export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncOptions) {
const lastSaveRef = useRef<string | null>(null);
const loadedRef = useRef(false);
// Load save on mount (once)
useEffect(() => {
if (loadedRef.current) return;
loadedRef.current = true;
const token = localStorage.getItem("token");
if (!token) return;
apiRequest("/save").then((data) => {
if (data?.gameState) {
onLoad(data.gameState);
lastSaveRef.current = data.lastSave;
console.info("[SaveSync] Loaded save from server");
}
});
}, [onLoad]);
// Save function
const saveToServer = useCallback(async () => {
const token = localStorage.getItem("token");
if (!token) return;
const gameState = getGameState();
const result = await apiRequest("/save", {
method: "POST",
body: JSON.stringify({ gameState, playTimeSeconds }),
});
if (result?.lastSave) {
lastSaveRef.current = result.lastSave;
}
}, [getGameState, playTimeSeconds]);
// Auto-save interval
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) return undefined;
const interval = setInterval(() => {
saveToServer();
}, SAVE_INTERVAL_MS);
return () => clearInterval(interval);
}, [saveToServer]);
// Save on page unload
useEffect(() => {
const handleUnload = () => {
const token = localStorage.getItem("token");
if (!token) return;
const gameState = getGameState();
const payload = JSON.stringify({ gameState, playTimeSeconds });
// Use fetch with keepalive for reliable save on tab close
// (sendBeacon doesn't support custom headers)
fetch(`${BACKEND_URL}/api/save`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-auth-token": token,
},
body: payload,
keepalive: true,
}).catch(() => {});
};
window.addEventListener("beforeunload", handleUnload);
return () => window.removeEventListener("beforeunload", handleUnload);
}, [getGameState, playTimeSeconds]);
return { saveToServer, lastSave: lastSaveRef.current };
}

View File

@@ -3,32 +3,32 @@ function Cookie() {
return (
<div className="container">
<div className="item">
<h2>Quest-ce quun cookie ?</h2>
<h2>Qu'est-ce qu'un cookie ?</h2>
<p>
Un cookie est un petit fichier texte sauvegardé sur votre ordinateur
lorsque vous visitez un site web. Ce fichier texte enregistre des
informations qui peuvent être lues par un site web lorsque vous le
visitez de nouveau plus tard. Certains de ces cookies sont nécessaires
pour accéder à certaines fonctionnalités dun site. Dautres cookies
sont dutilité pratique pour le visiteur : ils sauvegardent de manière
sécurisée votre nom dutilisateur ou vos préférences linguistiques par
exemple. Les cookies signifient tout simplement quà chaque fois que
vous visitez un site web, vous navez pas besoin de saisir à nouveau
pour accéder à certaines fonctionnalités d'un site. D'autres cookies
sont d'utilité pratique pour le visiteur : ils sauvegardent de manière
sécurisée votre nom d'utilisateur ou vos préférences linguistiques par
exemple. Les cookies signifient tout simplement quchaque fois que
vous visitez un site web, vous n'avez pas besoin de saisir à nouveau
les mêmes informations.
</p>
</div>
<div className="item">
<h2>Pourquoi Xmass Clicker utilise des cookies ?</h2>
<h2>Pourquoi Clickerz utilise des cookies ?</h2>
<p>
Nous utilisons des cookies pour vous fournir une expérience
utilisateur optimale et adaptée à vos préférences personnelles. En
utilisant les cookies, Les cookies sont également utilisés pour
optimiser la performance du site. Xmass Clicker a pris toutes les
mesures organisationnelles et techniques pour protéger vos données
personnelles ainsi que dune éventuelle perte dinformations ou de
toute forme de traitement illicite. Pour davantage dinformations,
consultez notre Politique de confidentialité.
utilisateur optimale et adaptée à vos préférences personnelles.
Les cookies sont également utilisés pour optimiser la performance
du site. Clickerz a pris toutes les mesures organisationnelles et
techniques pour protéger vos données personnelles ainsi que d'une
éventuelle perte d'informations ou de toute forme de traitement
illicite. Pour davantage d'informations, consultez notre Politique
de confidentialité.
</p>
</div>
@@ -37,7 +37,7 @@ function Cookie() {
<p>
Vous pouvez paramétrer votre navigateur Internet pour désactiver les
cookies. Notez toutefois que si vous désactivez les cookies, votre nom
dutilisateur ainsi que votre mot de passe ne seront plus sauvegardés
d'utilisateur ainsi que votre mot de passe ne seront plus sauvegardés
sur aucun site web.
</p>
</div>
@@ -49,9 +49,9 @@ function Cookie() {
2. Appuyez sur la touche « Alt » <br />
3. Dans le menu en haut de la page cliquez sur « Outils » puis «
Options » <br />
4. Sélectionnez longlet « Vie privée » <br />
4. Sélectionnez l'onglet « Vie privée » <br />
5. Dans le menu déroulant à droite de « Règles de conservation »,
cliquez sur « utiliser les paramètres personnalisés pour lhistorique
cliquez sur « utiliser les paramètres personnalisés pour l'historique
» <br />
6. Un peu plus bas, décochez « Accepter les cookies » <br />
7. Sauvegardez vos préférences en cliquant sur « OK »
@@ -63,7 +63,7 @@ function Cookie() {
<p>
1. Ouvrez Internet Explorer <br />
2. Dans le menu « Outils », sélectionnez « Options Internet » <br />
3. Cliquez sur longlet « Confidentialité » <br />
3. Cliquez sur l'onglet « Confidentialité » <br />
4. Cliquez sur « Avancé » et décochez « Accepter » <br />
5. Sauvegardez vos préférences en cliquant sur « OK »
</p>
@@ -75,7 +75,7 @@ function Cookie() {
1. Ouvrez Safari <br />
2. Dans la barre de menu en haut, cliquez sur « Safari », puis «
Préférences » <br />
3. Sélectionnez licône « Sécurité » <br />
3. Sélectionnez l'icône « Sécurité » <br />
4. À côté de « Accepter les cookies », cochez « Jamais » <br />
5. Si vous souhaitez voir les cookies qui sont déjà sauvegardés sur
votre ordinateur, cliquez sur « Afficher les cookies »
@@ -86,9 +86,9 @@ function Cookie() {
<h2>Google Chrome :</h2>
<p>
1. Ouvrez Google Chrome <br />
2. Cliquez sur licône doutils dans la barre de menu <br />
2. Cliquez sur l'icône d'outils dans la barre de menu <br />
3. Sélectionnez « Options » <br />
4. Cliquez sur longlet « Options avancées » <br />
4. Cliquez sur l'onglet « Options avancées » <br />
5. Dans le menu déroulant « Paramètres des cookies », sélectionnez «
Bloquer tous les cookies »
</p>

View File

@@ -4,20 +4,18 @@ function Legal() {
<div className="mentionslegales">
<h2>Éditeur :</h2>
<p>
Xmass'Click est un projet réalisé dans le cadre d'un hackathon sur 2
jours.
Clickerz est un projet indépendant faisant partie du Tetard Universe.
</p>
<h2>Coordonnées :</h2>
<p>
Téléphone : 04 22 52 10 10 <br />
E-mail : pere-noel@laposte.net <br />
Adresse : 250 avenue des Nuages, 1000 Pôle Nord <br />
E-mail : contact@tetardtek.com <br />
Site : https://tetardtek.com <br />
</p>
<h2>Responsabilité :</h2>
<p>
Xmass'Click décline toute responsabilité quant à l'utilisation du site.
Clickerz décline toute responsabilité quant à l'utilisation du site.
Les informations fournies sont à titre informatif et peuvent être
sujettes à des erreurs.
</p>
@@ -25,26 +23,26 @@ function Legal() {
<h2>Propriété Intellectuelle :</h2>
<p>
Tout le contenu du site (textes, images, etc.) reste la propriété de
Xmass'Click. Toute reproduction est interdite sans autorisation
Tetardtek. Toute reproduction est interdite sans autorisation
préalable.
</p>
<h2>Protection des Données Personnelles :</h2>
<p>
Xmass'Click ne collecte pas de données personnelles. Aucune information
personnelle n'est stockée lors de l'utilisation du site.
Clickerz utilise un système d'authentification via SuperOAuth.
Les données de jeu sont sauvegardées côté serveur.
Aucune donnée personnelle n'est partagée avec des tiers.
</p>
<h2>Conditions Générales d'Utilisation :</h2>
<p>
Aucune condition générale d'utilisation n'est applicable. L'utilisation
du site Xmass'Click se fait à titre gratuit et sans engagement.
L'utilisation du site Clickerz se fait à titre gratuit et sans engagement.
</p>
<h2>Loi Applicable :</h2>
<p>
Le présent site est régi par la loi du Pôle Nord. En cas de litige, les
tribunaux du Père Noël seront compétents.
Le présent site est régi par la loi française. En cas de litige, les
tribunaux compétents seront ceux du ressort du siège social de l'éditeur.
</p>
</div>
);