feat(sprint1-step2): core economy TS + useEconomy hook (lazy calc) + 13 tests vitest

This commit is contained in:
2026-03-17 06:36:51 +01:00
parent c414cf2d07
commit c69da320cc
13 changed files with 2627 additions and 174 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

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

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 react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'node',
},
})