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:
@@ -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 = {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
resources: state.resources + gain,
|
||||
lifetimeTadpoles: state.lifetimeTadpoles + gain,
|
||||
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,
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user