fix: câbler tous les effets arbre + cleanup dette Sprint 2

- double_click_chance + crit_click_chance câblés dans applyClick (RNG)
- auto_click câblé dans le tick (auto-pontes/s)
- unlock_generator (Résilience) → 1 Lac Mystique gratuit au prestige
- ponte_critique requires double_ponte (fix branche morte)
- achievement_scaling retiré (nœud absent), full_tree + symbiose fixés
- Particule feedback coloré (crit=ambre, double=violet)
- 99 tests (tous passent)
This commit is contained in:
2026-03-28 12:41:12 +01:00
parent 2a242e97cc
commit 2c924c1e4a
6 changed files with 185 additions and 30 deletions

View File

@@ -17,6 +17,7 @@ import {
getStartBonusFromTree,
getPrestigeDnaBonus,
getCostReduction,
getAutoClicksPerSecond,
offlineEfficiency,
computeOfflineGains,
DEFAULT_STATE,
@@ -173,13 +174,15 @@ describe("computeIdleGains (lazy calculation)", () => {
});
});
// --- Click ---
// --- Click (avec double + crit) ---
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);
const result = applyClick(state, 0.99); // rng high → no double, no crit
expect(result.state.resources).toBe(6);
expect(result.isDouble).toBe(false);
expect(result.isCrit).toBe(false);
});
it("applique le multiplicateur click de l'arbre", () => {
@@ -191,14 +194,58 @@ describe("applyClick", () => {
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
),
};
const result = applyClick(state);
expect(result.resources).toBe(2); // ×2 depuis Ponte Améliorée
const result = applyClick(state, 0.99);
expect(result.state.resources).toBe(2);
});
it("incrémente lifetimeTadpoles", () => {
const state = { ...DEFAULT_STATE, clickMultiplier: 5, prestigeMultiplier: 1 };
const result = applyClick(state);
expect(result.lifetimeTadpoles).toBe(5);
const result = applyClick(state, 0.99);
expect(result.state.lifetimeTadpoles).toBe(5);
});
it("double ponte x2 quand rng < doubleClickChance", () => {
const state = {
...DEFAULT_STATE,
clickMultiplier: 1,
prestigeMultiplier: 1,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "double_ponte" ? { ...n, unlocked: true } : n
),
};
// double_ponte = 10% chance, rng=0.05 < 0.10 → double
const result = applyClick(state, 0.05);
expect(result.isDouble).toBe(true);
expect(result.gain).toBe(2);
});
it("pas de double ponte quand rng > doubleClickChance", () => {
const state = {
...DEFAULT_STATE,
clickMultiplier: 1,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "double_ponte" ? { ...n, unlocked: true } : n
),
};
const result = applyClick(state, 0.50);
expect(result.isDouble).toBe(false);
expect(result.gain).toBe(1);
});
it("crit x10 quand critRng < critClickChance", () => {
const state = {
...DEFAULT_STATE,
clickMultiplier: 1,
prestigeMultiplier: 1,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "ponte_critique" ? { ...n, unlocked: true } : n
),
};
// ponte_critique = 5% chance, need critRng = (rng * 7.13) % 1 < 0.05
// rng = 0.007 → critRng = 0.04991 < 0.05 → crit!
const result = applyClick(state, 0.007);
expect(result.isCrit).toBe(true);
expect(result.gain).toBe(10);
});
});
@@ -444,6 +491,46 @@ describe("Evolution Tree (3 branches)", () => {
});
});
describe("unlock_generator (Résilience)", () => {
it("prestige avec Résilience donne 1 Lac Mystique", () => {
const state = {
...DEFAULT_STATE,
resources: 2_000_000,
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })),
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "resilience" ? { ...n, unlocked: true } : n
),
};
const result = applyPrestige(state);
const lac = result.generators.find((g) => g.id === "lac");
expect(lac!.owned).toBe(1);
});
it("prestige sans Résilience donne 0 Lac Mystique", () => {
const state = {
...DEFAULT_STATE,
resources: 2_000_000,
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })),
};
const result = applyPrestige(state);
const lac = result.generators.find((g) => g.id === "lac");
expect(lac!.owned).toBe(0);
});
});
describe("auto_click (getAutoClicksPerSecond)", () => {
it("retourne 0 si auto_ponte non débloqué", () => {
expect(getAutoClicksPerSecond(DEFAULT_EVOLUTION_TREE)).toBe(0);
});
it("retourne 1 si auto_ponte débloqué", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "auto_ponte" ? { ...n, unlocked: true } : n
);
expect(getAutoClicksPerSecond(tree)).toBe(1);
});
});
describe("prestige threshold reduction", () => {
it("Transcendance réduit le seuil de 50%", () => {
const state = {

View File

@@ -9,7 +9,6 @@ const EFFECT_LABELS: Record<string, (v: number) => string> = {
production_multiplier: (v) => `x${v} production`,
start_bonus: (v) => `+${v} têtards au départ`,
unlock_generator: () => `Lac Mystique dès le début`,
achievement_scaling: (v) => `+${(v * 100).toFixed(0)}% prod/succès`,
double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
auto_click: (v) => `${v} auto-ponte/s`,
crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`,

View File

@@ -14,7 +14,6 @@ export type EffectType =
| "production_multiplier"
| "start_bonus"
| "unlock_generator"
| "achievement_scaling"
| "double_click_chance"
| "auto_click"
| "crit_click_chance"
@@ -65,7 +64,7 @@ export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [
{ id: "double_ponte", name: "Double Ponte", cost: 3, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" },
{ id: "ponte_frenetique", name: "Ponte Frénétique", cost: 8, effect: "click_multiplier", value: 3, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "auto_ponte" },
{ id: "auto_ponte", name: "Auto-Ponte", cost: 8, effect: "auto_click", value: 1, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "ponte_frenetique" },
{ id: "ponte_critique", name: "Ponte Critique", cost: 20, effect: "crit_click_chance", value: 0.05, unlocked: false, requires: "ponte_frenetique", branch: "ponte" },
{ id: "ponte_critique", name: "Ponte Critique", cost: 20, effect: "crit_click_chance", value: 0.05, unlocked: false, requires: "double_ponte", branch: "ponte" },
{ id: "maitre_pondeur", name: "Maître Pondeur", cost: 40, effect: "click_multiplier", value: 5, unlocked: false, requires: "ponte_critique", branch: "ponte" },
// --- Marais (production) ---
@@ -305,19 +304,48 @@ export function applyIdleGains(state: GameState, now: number): GameState {
};
}
// Gain réel par clic (pour affichage particule)
// Gain de base par clic (sans RNG — pour affichage tooltip)
export function getClickGain(state: GameState): number {
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
return state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
}
// Clic manuel
export function applyClick(state: GameState): GameState {
const gain = getClickGain(state);
export interface ClickResult {
state: GameState;
gain: number;
isDouble: boolean;
isCrit: boolean;
}
// Clic manuel avec double ponte + crit
export function applyClick(state: GameState, rng: number = Math.random()): ClickResult {
let gain = getClickGain(state);
let isDouble = false;
let isCrit = false;
const doubleChance = getDoubleClickChance(state.evolutionTree);
if (doubleChance > 0 && rng < doubleChance) {
gain *= 2;
isDouble = true;
}
const critChance = getCritClickChance(state.evolutionTree);
// Use a second "roll" derived from rng to avoid double+crit being correlated
const critRng = (rng * 7.13) % 1;
if (critChance > 0 && critRng < critChance) {
gain *= 10;
isCrit = true;
}
return {
state: {
...state,
resources: state.resources + gain,
lifetimeTadpoles: state.lifetimeTadpoles + gain,
},
gain,
isDouble,
isCrit,
};
}
@@ -359,10 +387,18 @@ export function applyPrestige(state: GameState): GameState {
const dnaGained = Math.floor(baseDna * (1 + dnaBonus));
const startBonus = getStartBonusFromTree(state.evolutionTree);
// Résilience : commencer avec 1 Lac Mystique
const hasUnlockGen = state.evolutionTree.some(
(n) => n.unlocked && n.effect === "unlock_generator"
);
return {
...state,
resources: startBonus,
generators: state.generators.map((g) => ({ ...g, owned: 0 })),
generators: state.generators.map((g) => ({
...g,
owned: hasUnlockGen && g.id === "lac" ? 1 : 0,
})),
prestigeCount: newPrestigeCount,
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
ancestralDna: state.ancestralDna + dnaGained,

View File

@@ -165,9 +165,12 @@ export const ACHIEVEMENTS: Achievement[] = [
{
id: "full_tree",
name: "Évolution Complète",
description: "Débloquer tous les noeuds de l'arbre.",
description: "Débloquer un nœud dans chaque branche de l'arbre.",
icon: "🌳",
check: (s) => s.evolutionTree.every((n) => n.unlocked),
check: (s) => {
const branches = new Set(s.evolutionTree.filter((n) => n.unlocked).map((n) => n.branch));
return branches.size >= 3;
},
},
// --- Easter eggs & humour ---
@@ -211,6 +214,6 @@ export const ACHIEVEMENTS: Achievement[] = [
name: "Le Cercle de la Vie",
description: "Symbiose activée. Même Mufasa serait fier.",
icon: "🦁",
check: (s) => hasEvolutionNode(s, "symbiose"),
check: (s) => hasEvolutionNode(s, "symbiose_algale"),
},
];

View File

@@ -22,21 +22,30 @@ export default function Home() {
const state = useGameStore((s) => s.state);
const clickGain = getClickGain(state);
const createParticle = useCallback((clientX, clientY) => {
const lastClickGain = useGameStore((s) => s.lastClickGain);
const lastClickDouble = useGameStore((s) => s.lastClickDouble);
const lastClickCrit = useGameStore((s) => s.lastClickCrit);
const createParticle = useCallback((clientX, clientY, gain, isDouble, isCrit) => {
const particle = document.createElement("span");
particle.className = "click-particle";
particle.textContent = `+${formatNumber(clickGain)}`;
const prefix = isCrit ? "CRIT " : isDouble ? "x2 " : "";
particle.textContent = `${prefix}+${formatNumber(gain)}`;
if (isCrit) particle.style.color = "#f59e0b";
else if (isDouble) particle.style.color = "#a78bfa";
particle.style.left = `${clientX}px`;
particle.style.top = `${clientY}px`;
document.body.appendChild(particle);
setTimeout(() => {
if (particle.parentNode) particle.parentNode.removeChild(particle);
}, 800);
}, [clickGain]);
}, []);
const handleIncrement = useCallback((e) => {
click();
createParticle(e.clientX, e.clientY);
// Read latest click result from store after click
const s = useGameStore.getState();
createParticle(e.clientX, e.clientY, s.lastClickGain, s.lastClickDouble, s.lastClickCrit);
}, [click, createParticle]);
// Rain effect (ambiance)

View File

@@ -8,6 +8,8 @@ import {
DEFAULT_STATE,
applyIdleGains,
applyClick,
getClickGain,
getAutoClicksPerSecond,
buyGenerator,
buyEvolutionNode,
resetEvolutionTree,
@@ -66,6 +68,11 @@ interface GameStore {
offlineReport: OfflineReport | null;
dismissOfflineReport: () => void;
// Last click result (for particle feedback)
lastClickGain: number;
lastClickDouble: boolean;
lastClickCrit: boolean;
// Derived (recalculated on tick)
canPrestige: boolean;
productionPerSecond: number;
@@ -135,6 +142,9 @@ export const useGameStore = create<GameStore>((set, get) => ({
playSeconds: 0,
ready: false,
offlineReport: null,
lastClickGain: 0,
lastClickDouble: false,
lastClickCrit: false,
canPrestige: false,
productionPerSecond: 0,
@@ -147,6 +157,14 @@ export const useGameStore = create<GameStore>((set, get) => ({
const updated = applyIdleGains(s.state, now);
updated.lastOnline = now;
// Auto-click from evolution tree
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
if (autoClicks > 0) {
const autoGain = getClickGain(updated) * autoClicks;
updated.resources += autoGain;
updated.lifetimeTadpoles += autoGain;
}
// Check cosmetic unlocks every 5s
if (s.playSeconds % 5 === 0) {
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
@@ -170,12 +188,15 @@ export const useGameStore = create<GameStore>((set, get) => ({
click: () => {
if (!get().ready) return;
set((s) => {
const updated = applyClick(applyIdleGains(s.state, Date.now()));
saveLocal(updated);
const result = applyClick(applyIdleGains(s.state, Date.now()));
saveLocal(result.state);
return {
state: updated,
canPrestige: canPrestigeCheck(updated),
productionPerSecond: totalProductionPerSecond(updated),
state: result.state,
lastClickGain: result.gain,
lastClickDouble: result.isDouble,
lastClickCrit: result.isCrit,
canPrestige: canPrestigeCheck(result.state),
productionPerSecond: totalProductionPerSecond(result.state),
};
});
},