Compare commits

...

3 Commits

18 changed files with 2819 additions and 182 deletions

19
.claude/settings.json Normal file
View File

@@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(npm *)",
"Bash(git *)",
"Bash(pm2 *)",
"Bash(curl *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(grep *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(node *)",
"Bash(npx *)",
"Write(*)"
]
}
}

View File

@@ -0,0 +1,12 @@
-- Migration 001 — Add OAuth + profile fields
-- Safe: ADD COLUMN NULL only, no data loss
-- Run: mysql -u <user> -p clickerz < migrations/001_add_oauth_profile_fields.sql
ALTER TABLE users
ADD COLUMN IF NOT EXISTS firstname VARCHAR(50) NULL,
ADD COLUMN IF NOT EXISTS lastname VARCHAR(50) NULL,
ADD COLUMN IF NOT EXISTS super_oauth_id VARCHAR(36) NULL;
-- Unique index separately (idempotent)
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_super_oauth_id
ON users (super_oauth_id);

View File

@@ -5,5 +5,8 @@ id INT AUTO_INCREMENT PRIMARY KEY,
nickname VARCHAR(30) NOT NULL, nickname VARCHAR(30) NOT NULL,
mail VARCHAR(90) NOT NULL, mail VARCHAR(90) NOT NULL,
password VARCHAR(200) NOT NULL, password VARCHAR(200) NOT NULL,
tetardcoin INT default 0 tetardcoin INT DEFAULT 0,
firstname VARCHAR(50) NULL,
lastname VARCHAR(50) NULL,
super_oauth_id VARCHAR(36) NULL UNIQUE
); );

View File

@@ -30,10 +30,10 @@ const cors = require("cors");
app.use( app.use(
cors({ cors({
origin: [ origin: [
process.env.FRONTEND_URL, // keep this one, after checking the value in `backend/.env` process.env.FRONTEND_URL,
"http://mysite.com", process.env.SUPER_OAUTH_URL,
"http://another-domain.com",
], ],
credentials: true,
}) })
); );

View File

@@ -0,0 +1,78 @@
const jwt = require("jsonwebtoken");
const tables = require("../tables");
const secretKey = process.env.APP_SECRET;
/**
* GET /api/auth/callback?code=<token>
*
* Reçoit le token SuperOAuth depuis le frontend après redirect OAuth.
* Valide auprès de SuperOAuth, résout ou crée le user local, retourne un JWT local.
*/
const callback = async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).json({ message: "Missing OAuth code." });
}
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
if (!superOAuthUrl) {
return res.status(500).json({ message: "Auth service not configured." });
}
try {
const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: code }),
});
const data = await response.json();
if (!response.ok || !data.data?.valid || !data.data?.user) {
return res.status(401).json({ message: "Invalid OAuth token." });
}
if (!data.data.user.isActive) {
return res.status(401).json({ message: "Account is disabled." });
}
const { id: superOAuthId, email, nickname } = data.data.user;
let localUser = await tables.users.getBySuperOAuthId(superOAuthId);
if (!localUser) {
// Premier login OAuth — créer le compte local
const insertId = await tables.users.create({
nickname: nickname ?? email?.split("@")[0] ?? `user_${Date.now()}`,
mail: email ?? `${superOAuthId}@oauth.local`,
password: "", // pas de password local — auth via SuperOAuth uniquement
tetardcoin: 1000,
});
await tables.users.linkSuperOAuth(insertId, superOAuthId);
localUser = await tables.users.read(insertId);
}
const token = jwt.sign({ user: localUser.id }, secretKey, { expiresIn: "7d" });
return res.status(200).json({
message: "Connexion réussie",
user: localUser,
token,
});
} catch (err) {
console.error("auth/callback — error", err);
return res.status(500).json({ message: "Internal server error." });
}
};
/**
* POST /api/auth/logout
* Stateless — le token JWT est géré côté client.
*/
const logout = (_req, res) => {
return res.status(200).json({ message: "Déconnexion réussie." });
};
module.exports = { callback, logout };

View File

@@ -410,6 +410,27 @@ const destroy = async (req, res) => {
}; };
const updateCoins = async (req, res) => {
const userId = req.params.id;
const { tetardcoin } = req.body;
if (tetardcoin === undefined || typeof tetardcoin !== "number") {
return res.status(400).json({ message: "tetardcoin (number) requis." });
}
try {
const affectedRows = await tables.users.edit(userId, { tetardcoin });
if (affectedRows === 0) {
return res.status(404).json({ message: "Utilisateur non trouvé." });
}
return res.status(200).json({ tetardcoin });
} catch (err) {
return res.status(500).json({ message: "Erreur lors de la mise à jour.", error: err.message });
}
};
module.exports = { module.exports = {
browse, browse,
read, read,
@@ -419,4 +440,5 @@ module.exports = {
login, login,
forgottenPassword, forgottenPassword,
resetPassword, resetPassword,
updateCoins,
}; };

View File

@@ -0,0 +1,57 @@
const tables = require("../tables");
/**
* Middleware verifyOAuth — Token Introspection via SuperOAuth.
*
* Flow :
* 1. Extraire le token du header x-auth-token
* 2. Appeler SuperOAuth POST /api/v1/auth/token/validate
* 3. Résoudre l'utilisateur local par super_oauth_id
* 4. req.user = localUser.id (integer) — verifySelf inchangé
*/
const verifyOAuth = async (req, res, next) => {
const token = req.header("x-auth-token");
if (!token) {
return res.status(401).json({ message: "Access denied. No token provided." });
}
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
if (!superOAuthUrl) {
console.error("verifyOAuth — SUPER_OAUTH_URL not configured");
return res.status(500).json({ message: "Auth service not configured." });
}
try {
const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await response.json();
if (!response.ok || !data.data?.valid || !data.data?.user) {
return res.status(401).json({ message: "Invalid or expired token." });
}
if (!data.data.user.isActive) {
return res.status(401).json({ message: "Account is disabled." });
}
const superOAuthId = data.data.user.id;
const localUser = await tables.users.getBySuperOAuthId(superOAuthId);
if (!localUser) {
return res.status(401).json({ message: "Account not linked. Please log in via SuperOAuth." });
}
req.user = localUser.id;
return next();
} catch (err) {
console.error("verifyOAuth — auth service unreachable", err);
return res.status(500).json({ message: "Authentication service unreachable." });
}
};
module.exports = verifyOAuth;

View File

@@ -7,16 +7,32 @@ class UserManager extends AbstractManager {
// The C of CRUD - Create operation // The C of CRUD - Create operation
async create(user) { async create(user) {
const { nickname, mail, tetardcoin, password } = user; const { nickname, mail, tetardcoin, password, firstname, lastname } = user;
const [result] = await this.database.query( const [result] = await this.database.query(
`INSERT INTO ${this.table} (nickname, mail, tetardcoin, password) VALUES (?, ?, ?, ?)`, `INSERT INTO ${this.table} (nickname, mail, tetardcoin, password, firstname, lastname) VALUES (?, ?, ?, ?, ?, ?)`,
[nickname, mail, tetardcoin, password] [nickname, mail, tetardcoin, password, firstname ?? null, lastname ?? null]
); );
return result.insertId; return result.insertId;
} }
async getBySuperOAuthId(superOAuthId) {
const [rows] = await this.database.query(
`SELECT * FROM ${this.table} WHERE super_oauth_id = ?`,
[superOAuthId]
);
return rows[0] ?? null;
}
async linkSuperOAuth(id, superOAuthId) {
const [result] = await this.database.query(
`UPDATE ${this.table} SET super_oauth_id = ? WHERE id = ?`,
[superOAuthId, id]
);
return result.affectedRows;
}
// The Rs of CRUD - Read operations // The Rs of CRUD - Read operations
async read(id, field) { async read(id, field) {
if (field) { if (field) {
@@ -69,6 +85,8 @@ class UserManager extends AbstractManager {
"nickname", "nickname",
"tetardcoin", "tetardcoin",
"password", "password",
"firstname",
"lastname",
]; ];
const fieldsToUpdate = Object.keys(updatedFields).filter((field) => const fieldsToUpdate = Object.keys(updatedFields).filter((field) =>

View File

@@ -8,8 +8,9 @@ const router = express.Router();
// Import Controllers // Import Controllers
const userControllers = require("./controllers/userControllers"); const userControllers = require("./controllers/userControllers");
const authControllers = require("./controllers/authControllers");
const verifyToken = require("./middlewares/verifyToken"); const verifyToken = require("./middlewares/verifyToken");
const verifyOAuth = require("./middlewares/verifyOAuth");
// Vérifie que le token appartient au même utilisateur que :id // Vérifie que le token appartient au même utilisateur que :id
const verifySelf = (req, res, next) => { const verifySelf = (req, res, next) => {
@@ -19,7 +20,11 @@ const verifySelf = (req, res, next) => {
return next(); return next();
}; };
// User management // Auth SuperOAuth
router.get("/auth/callback", authControllers.callback);
router.post("/auth/logout", authControllers.logout);
// User management (auth locale — conservée pendant migration)
router.get("/users", verifyToken, userControllers.browse); router.get("/users", verifyToken, userControllers.browse);
router.get("/users/:id", verifyToken, verifySelf, userControllers.read); router.get("/users/:id", verifyToken, verifySelf, userControllers.read);
router.get("/users/:id/field", verifyToken, verifySelf, userControllers.read); router.get("/users/:id/field", verifyToken, verifySelf, userControllers.read);
@@ -28,6 +33,9 @@ router.post("/users", userControllers.add);
router.delete("/users/:id", verifyToken, verifySelf, userControllers.destroy); router.delete("/users/:id", verifyToken, verifySelf, userControllers.destroy);
router.post("/login", userControllers.login); router.post("/login", userControllers.login);
// Sync game state — SuperOAuth uniquement
router.patch("/users/:id/coins", verifyOAuth, verifySelf, userControllers.updateCoins);
/* ************************************************************************* */ /* ************************************************************************* */

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"react": "^18.2.0", "react": "^18.2.0",
@@ -18,13 +19,15 @@
"sass": "^1.69.5" "sass": "^1.69.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.37", "@types/react": "^18.3.28",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"eslint": "^8.53.0", "eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4", "eslint-plugin-react-refresh": "^0.4.4",
"vite": "^5.0.0" "typescript": "^5.9.3",
"vite": "^5.0.0",
"vitest": "^4.1.0"
} }
} }

View File

@@ -0,0 +1,156 @@
import { describe, it, expect } from "vitest";
import {
generatorCost,
totalProductionPerSecond,
computeIdleGains,
applyClick,
buyGenerator,
applyPrestige,
canPrestige,
DEFAULT_STATE,
DEFAULT_GENERATORS,
} 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.
describe("PrestigePanel visibility (canPrestige guard)", () => {
it("canPrestige = false pour resources = 0 → panneau non visible", () => {
expect(canPrestige({ ...DEFAULT_STATE, resources: 0 })).toBe(false);
});
it("canPrestige = false pour resources = 999 999 → panneau non visible", () => {
expect(canPrestige({ ...DEFAULT_STATE, resources: 999_999 })).toBe(false);
});
it("canPrestige = true pour resources = 1 000 000 → panneau visible", () => {
expect(canPrestige({ ...DEFAULT_STATE, resources: 1_000_000 })).toBe(true);
});
});
describe("applyPrestige — post-prestige state", () => {
const prestigeState = {
...DEFAULT_STATE,
resources: 1_500_000,
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 3 })),
prestigeCount: 0,
prestigeMultiplier: 1,
};
it("ressources = 0 après prestige", () => {
expect(applyPrestige(prestigeState).resources).toBe(0);
});
it("multiplicateur = 1.1 après premier prestige", () => {
expect(applyPrestige(prestigeState).prestigeMultiplier).toBeCloseTo(1.1);
});
it("tous les générateurs owned = 0 après prestige", () => {
const result = applyPrestige(prestigeState);
expect(result.generators.every((g) => g.owned === 0)).toBe(true);
});
it("prestigeCount incrémenté à 1 après premier prestige", () => {
expect(applyPrestige(prestigeState).prestigeCount).toBe(1);
});
});
describe("generatorCost", () => {
it("retourne baseCost quand owned = 0", () => {
const gen = { ...DEFAULT_GENERATORS[0], owned: 0 };
expect(generatorCost(gen)).toBe(gen.baseCost);
});
it("applique la formule base × 1.15^n", () => {
const gen = { ...DEFAULT_GENERATORS[0], owned: 2 };
expect(generatorCost(gen)).toBe(Math.floor(gen.baseCost * Math.pow(1.15, 2)));
});
});
describe("totalProductionPerSecond", () => {
it("retourne 0 si aucun générateur acheté", () => {
expect(totalProductionPerSecond(DEFAULT_STATE)).toBe(0);
});
it("somme correctement la production de plusieurs générateurs", () => {
const state = {
...DEFAULT_STATE,
generators: DEFAULT_STATE.generators.map((g, i) =>
i === 0 ? { ...g, owned: 2 } : i === 1 ? { ...g, owned: 1 } : g
),
};
const expected = (DEFAULT_GENERATORS[0].baseProduction * 2 + DEFAULT_GENERATORS[1].baseProduction * 1) * 1;
expect(totalProductionPerSecond(state)).toBeCloseTo(expected);
});
it("applique le multiplicateur de prestige", () => {
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);
});
});
describe("computeIdleGains (lazy calculation)", () => {
it("calcule les gains proportionnellement au temps écoulé", () => {
const state = {
...DEFAULT_STATE,
generators: DEFAULT_STATE.generators.map((g, i) => i === 0 ? { ...g, owned: 10 } : g),
lastTick: 0,
};
const gains = computeIdleGains(state, 5000); // 5 secondes
const expected = DEFAULT_GENERATORS[0].baseProduction * 10 * 5;
expect(gains).toBeCloseTo(expected);
});
it("retourne 0 si aucun temps écoulé", () => {
const now = Date.now();
const state = { ...DEFAULT_STATE, lastTick: now };
expect(computeIdleGains(state, now)).toBeCloseTo(0);
});
});
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);
});
});
describe("buyGenerator", () => {
it("retourne null si fonds insuffisants", () => {
const result = buyGenerator(DEFAULT_STATE, "manic");
expect(result).toBeNull(); // 0 ressources, coût = 15
});
it("achète correctement et déduit le coût", () => {
const state = { ...DEFAULT_STATE, resources: 100 };
const result = buyGenerator(state, "manic");
expect(result).not.toBeNull();
expect(result!.generators.find((g) => g.id === "manic")!.owned).toBe(1);
expect(result!.resources).toBe(100 - DEFAULT_GENERATORS[0].baseCost);
});
});
describe("prestige", () => {
it("canPrestige retourne false si < 1 000 000 ressources", () => {
expect(canPrestige({ ...DEFAULT_STATE, resources: 999_999 })).toBe(false);
});
it("canPrestige retourne true si ≥ 1 000 000 ressources", () => {
expect(canPrestige({ ...DEFAULT_STATE, resources: 1_000_000 })).toBe(true);
});
it("reset les ressources + générateurs + incrémente le multiplicateur", () => {
const state = {
...DEFAULT_STATE,
resources: 2_000_000,
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })),
prestigeCount: 0,
};
const result = applyPrestige(state);
expect(result.resources).toBe(0);
expect(result.prestigeCount).toBe(1);
expect(result.prestigeMultiplier).toBeCloseTo(1.1);
expect(result.generators.every((g) => g.owned === 0)).toBe(true);
});
});

View File

@@ -0,0 +1,50 @@
// MilestoneBar.tsx — Progression vers le prochain prestige
// Barre visuelle ressources / 1 000 000 + indicateur restant
import React from "react";
const PRESTIGE_THRESHOLD = 1_000_000;
interface MilestoneBarProps {
resources: number;
}
export function MilestoneBar({ resources }: MilestoneBarProps) {
const progress = Math.min(resources / PRESTIGE_THRESHOLD, 1);
const progressPercent = (progress * 100).toFixed(1);
const remaining = Math.max(PRESTIGE_THRESHOLD - resources, 0);
const formatNumber = (n: number): string => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return Math.floor(n).toString();
};
return (
<div className="milestone-bar" aria-label="Progression vers le prestige">
<div className="milestone-label">
Prochain prestige : {formatNumber(resources)} / {formatNumber(PRESTIGE_THRESHOLD)}
</div>
<div
className="milestone-track"
role="progressbar"
aria-valuenow={Math.floor(progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="milestone-fill"
style={{ width: `${progressPercent}%` }}
/>
</div>
{remaining > 0 && (
<div className="milestone-remaining">
{formatNumber(remaining)} ressources restantes
</div>
)}
{remaining === 0 && (
<div className="milestone-ready">Prestige disponible !</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
// PrestigePanel.tsx — Boucle de prestige long terme
// Visible uniquement quand canPrestige = true (ressources ≥ 1 000 000)
import React from "react";
interface PrestigePanelProps {
prestigeCount: number;
prestigeMultiplier: number;
canPrestige: boolean;
onPrestige: () => void;
}
export function PrestigePanel({
prestigeCount,
prestigeMultiplier,
canPrestige,
onPrestige,
}: PrestigePanelProps) {
const handlePrestige = () => {
const confirmed = window.confirm(
`Prestige — Reset total : ressources et générateurs à zéro.\n` +
`Récompense : +0.1× multiplicateur permanent.\n\n` +
`Multiplicateur actuel : ×${prestigeMultiplier.toFixed(1)}\n` +
`Multiplicateur après : ×${(prestigeMultiplier + 0.1).toFixed(1)}\n\n` +
`Confirmer le prestige ?`
);
if (confirmed) {
onPrestige();
}
};
return (
<div className="prestige-panel">
<div className="prestige-stats">
<span className="prestige-count">Prestiges : {prestigeCount}</span>
<span className="prestige-multiplier">
Multiplicateur : ×{prestigeMultiplier.toFixed(1)}
</span>
</div>
{canPrestige && (
<div className="prestige-action">
<div className="prestige-reward">
Récompense disponible : <strong>+0.1× multiplicateur permanent</strong>
</div>
<button
className="prestige-button"
onClick={handlePrestige}
aria-label="Déclencher le prestige"
>
Prestige
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
// economy.ts — Core clicker logic (lazy calculation pattern)
// Jamais de timer actif : tout est calculé au read depuis lastTick
export interface Generator {
id: string;
name: string;
baseCost: number;
baseProduction: number; // ressource/s
owned: number;
}
export interface GameState {
resources: number;
clickMultiplier: number;
generators: Generator[];
lastTick: number; // timestamp ms — lazy calc reference
prestigeCount: number;
prestigeMultiplier: number; // 1 + prestigeCount * 0.1
}
// 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));
}
// Production totale par seconde de tous les générateurs
export function totalProductionPerSecond(state: GameState): number {
const base = state.generators.reduce(
(sum, gen) => sum + gen.baseProduction * gen.owned,
0
);
return base * state.prestigeMultiplier;
}
// Lazy calculation : ressources accumulées depuis lastTick
export function computeIdleGains(state: GameState, now: number): number {
const elapsedSeconds = (now - state.lastTick) / 1000;
return totalProductionPerSecond(state) * elapsedSeconds;
}
// Applique les gains idle et met à jour lastTick
export function applyIdleGains(state: GameState, now: number): GameState {
const gains = computeIdleGains(state, now);
return {
...state,
resources: state.resources + gains,
lastTick: now,
};
}
// Clic manuel
export function applyClick(state: GameState): GameState {
return {
...state,
resources: state.resources + state.clickMultiplier * state.prestigeMultiplier,
};
}
// Achat d'un générateur (retourne null si fonds insuffisants)
export function buyGenerator(state: GameState, genId: string): GameState | null {
const genIndex = state.generators.findIndex((g) => g.id === genId);
if (genIndex === -1) return null;
const gen = state.generators[genIndex];
const cost = generatorCost(gen);
if (state.resources < cost) return null;
const updatedGenerators = [...state.generators];
updatedGenerators[genIndex] = { ...gen, owned: gen.owned + 1 };
return {
...state,
resources: state.resources - cost,
generators: updatedGenerators,
};
}
// Prestige : reset ressources + générateurs, +0.1× multiplicateur permanent
export function canPrestige(state: GameState): boolean {
return state.resources >= 1_000_000;
}
export function applyPrestige(state: GameState): GameState {
const newPrestigeCount = state.prestigeCount + 1;
return {
...state,
resources: 0,
generators: state.generators.map((g) => ({ ...g, owned: 0 })),
prestigeCount: newPrestigeCount,
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
lastTick: Date.now(),
};
}
// Valeurs par défaut — 5 tiers alignés GDD (x10 coût / tier, x5 production)
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 },
];
export const DEFAULT_STATE: GameState = {
resources: 0,
clickMultiplier: 1,
generators: DEFAULT_GENERATORS,
lastTick: Date.now(),
prestigeCount: 0,
prestigeMultiplier: 1,
};

View File

@@ -0,0 +1,93 @@
// useEconomy.ts — Hook React avec lazy calculation + localStorage
// Pas de setInterval pour les gains passifs — tout est calculé au read
import { useState, useCallback, useEffect } from "react";
import {
GameState,
DEFAULT_STATE,
applyIdleGains,
applyClick,
buyGenerator,
applyPrestige,
canPrestige,
totalProductionPerSecond,
generatorCost,
} from "../core/economy";
const SAVE_KEY = "clickerz_state";
function loadState(): GameState {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now() };
const saved = JSON.parse(raw) as GameState;
// Appliquer les gains idle accumulés pendant l'absence
return applyIdleGains(saved, Date.now());
} catch {
return { ...DEFAULT_STATE, lastTick: Date.now() };
}
}
function saveState(state: GameState): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
}
export function useEconomy() {
const [state, setState] = useState<GameState>(loadState);
// Auto-save + tick UI toutes les secondes (pour rafraîchir l'affichage uniquement)
// La vraie valeur est calculée lazily dans totalProductionPerSecond
useEffect(() => {
const id = setInterval(() => {
setState((prev) => {
const updated = applyIdleGains(prev, Date.now());
saveState(updated);
return updated;
});
}, 1000);
return () => clearInterval(id);
}, []);
const click = useCallback(() => {
setState((prev) => {
const updated = applyClick(applyIdleGains(prev, Date.now()));
saveState(updated);
return updated;
});
}, []);
const buy = useCallback((genId: string) => {
setState((prev) => {
const withIdle = applyIdleGains(prev, Date.now());
const updated = buyGenerator(withIdle, genId);
if (!updated) return prev;
saveState(updated);
return updated;
});
}, []);
const prestige = useCallback(() => {
setState((prev) => {
if (!canPrestige(prev)) return prev;
const updated = applyPrestige(prev);
saveState(updated);
return updated;
});
}, []);
const reset = useCallback(() => {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now() };
saveState(fresh);
setState(fresh);
}, []);
return {
state,
click,
buy,
prestige,
canPrestige: canPrestige(state),
productionPerSecond: totalProductionPerSecond(state),
generatorCost,
};
}

View File

@@ -1,7 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
test: {
environment: 'node',
},
}) })

76
docs/GDD.md Normal file
View File

@@ -0,0 +1,76 @@
# Clickerz — GDD Minimal
> Sprint 1 — Step 1 output
> Date : 2026-03-17
---
## Stack technique
**React + TypeScript + Vite** — vanilla, sans moteur de jeu
Justification : prototype existant déjà en React/Vite (Xmass Clicker), shop.json et Achievements.json validés, backend Node.js en place. Phaser/PixiJS = overhead injustifié pour un clicker — la logique est dans les chiffres, pas dans le rendu.
---
## Mécaniques core
**Ressource principale** : Cookies (ou ressource thématique à nommer)
**Sources de production** :
- Clic manuel : +1 ressource/clic (multiplicateur upgradable)
- Générateurs idle : 5 tiers, coût `base × 1.15^n`, production `/s` cumulative
**Tiers upgrades** (structure shop.json existante — x10 de coût par tier validé) :
| Tier | Coût base | Production/s |
|------|-----------|-------------|
| 1 | 10 | 0.1 |
| 2 | 100 | 0.5 |
| 3 | 1 000 | 3 |
| 4 | 10 000 | 20 |
| 5 | 100 000 | 150 |
---
## Boucle de progression
**Prestige / Reset**
- Seuil déclencheur : 1 000 000 ressources (ajustable à l'équilibrage)
- Récompense : +0.1× multiplicateur permanent par reset (stackable)
- Reset : ressources + générateurs à 0, upgrades prestige conservés
- Courbe : reset 1 = ×1.1, reset 10 = ×2.0, reset 50 = ×6.0
**Milestones visibles** : barre de progression vers prestige, compteur resets, multiplicateur actuel affiché
---
## Monétisation
**Cosmétiques only** — pas de pay-to-win
- Thèmes visuels (couleurs, icônes)
- Titres / badges de prestige
- Effets de clic (particules)
- Raison : 0 compliance fiscale, 0 déséquilibre économie, communauté saine
---
## Sauvegarde
- localStorage (sprint 1 — immédiat, zéro infra)
- Sync API backend (backend déjà en place — câblage sprint 1 si temps le permet)
- Auto-save toutes les 30 secondes
---
## Hors scope Sprint 1
- Leaderboard (exclu — infra ranking = sprint 2)
- Intégration Twitch
- Multijoueur
---
## Prochaines étapes → Step 2
Fondations techniques : init projet React/TS/Vite, boucle core clic → +ressource, 1er générateur idle, 1er upgrade.