feat(sprint1-step2): core economy TS + useEconomy hook (lazy calc) + 13 tests vitest
This commit is contained in:
19
.claude/settings.json
Normal file
19
.claude/settings.json
Normal 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(*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,10 @@ const cors = require("cors");
|
||||
app.use(
|
||||
cors({
|
||||
origin: [
|
||||
process.env.FRONTEND_URL, // keep this one, after checking the value in `backend/.env`
|
||||
"http://mysite.com",
|
||||
"http://another-domain.com",
|
||||
process.env.FRONTEND_URL,
|
||||
process.env.SUPER_OAUTH_URL,
|
||||
],
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
78
Backend/src/controllers/authControllers.js
Normal file
78
Backend/src/controllers/authControllers.js
Normal 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 };
|
||||
@@ -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 = {
|
||||
browse,
|
||||
read,
|
||||
@@ -419,4 +440,5 @@ module.exports = {
|
||||
login,
|
||||
forgottenPassword,
|
||||
resetPassword,
|
||||
updateCoins,
|
||||
};
|
||||
|
||||
57
Backend/src/middlewares/verifyOAuth.js
Normal file
57
Backend/src/middlewares/verifyOAuth.js
Normal 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;
|
||||
@@ -8,8 +8,9 @@ const router = express.Router();
|
||||
|
||||
// Import Controllers
|
||||
const userControllers = require("./controllers/userControllers");
|
||||
const authControllers = require("./controllers/authControllers");
|
||||
const verifyToken = require("./middlewares/verifyToken");
|
||||
|
||||
const verifyOAuth = require("./middlewares/verifyOAuth");
|
||||
|
||||
// Vérifie que le token appartient au même utilisateur que :id
|
||||
const verifySelf = (req, res, next) => {
|
||||
@@ -19,7 +20,11 @@ const verifySelf = (req, res, 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/:id", 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.post("/login", userControllers.login);
|
||||
|
||||
// Sync game state — SuperOAuth uniquement
|
||||
router.patch("/users/:id/coins", verifyOAuth, verifySelf, userControllers.updateCoins);
|
||||
|
||||
|
||||
/* ************************************************************************* */
|
||||
|
||||
|
||||
2202
Frontend/package-lock.json
generated
2202
Frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
@@ -18,13 +19,15 @@
|
||||
"sass": "^1.69.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"vite": "^5.0.0"
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
112
Frontend/src/__tests__/economy.test.ts
Normal file
112
Frontend/src/__tests__/economy.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generatorCost,
|
||||
totalProductionPerSecond,
|
||||
computeIdleGains,
|
||||
applyClick,
|
||||
buyGenerator,
|
||||
applyPrestige,
|
||||
canPrestige,
|
||||
DEFAULT_STATE,
|
||||
DEFAULT_GENERATORS,
|
||||
} from "../core/economy";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
111
Frontend/src/core/economy.ts
Normal file
111
Frontend/src/core/economy.ts
Normal 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,
|
||||
};
|
||||
93
Frontend/src/hooks/useEconomy.ts
Normal file
93
Frontend/src/hooks/useEconomy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
|
||||
76
docs/GDD.md
Normal file
76
docs/GDD.md
Normal 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.
|
||||
Reference in New Issue
Block a user