Files
ClickerZ/Backend/src/controllers/saveControllers.js
Tetardtek ed8cf87d4e
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 35s
feat: Sprint 3 — Prestige Loop endless
- Migration saves: saveVersion pattern + migrateSave lazy (v1→v2)
- Formule ADN rebalancée: log10 + clamp min 1 + cap bonus ×4
- Prestige Experience: modal fullscreen, preview ADN, stats run, best run
- Arbre V2: 25 nœuds, 3 capstones, post-capstones repeatables (scaling par tranche)
- Convergence évolutif Alpha→Omega (tier system)
- Reset arbre: 1 gratuit/prestige, payant linéaire au-delà
- Milestones prestige: 8 paliers (1→100), cosmétiques exclusifs, bonus gameplay
- balance.ts: constantes centralisées pour playtest
- 136 tests green, 0 regression
2026-03-28 18:24:24 +01:00

146 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const tables = require("../tables");
const { migrateSave } = require("../services/migrateSave");
// --- Anti-cheat validation ---
// Max production théorique par seconde (5 generators maxés + click humain réaliste)
// Tier 5 = 150/s × 200 owned = 30 000/s (extreme case)
// Tous tiers cumulés extreme ≈ 50 000/s
// × prestige multiplier max réaliste (×10) = 500 000/s
// × evolution tree multiplier (×1.5) = 750 000/s
// Marge ×1.1 appliquée dans la validation
const MAX_PRODUCTION_PER_SECOND = 750_000;
const CHEAT_MARGIN = 1.1;
function validateGameState(gameState, previousSave) {
// Vérification structurelle basique
if (!gameState || typeof gameState !== "object") {
return { valid: false, reason: "Invalid game state format" };
}
if (typeof gameState.resources !== "number" || gameState.resources < 0) {
return { valid: false, reason: "Invalid resources value" };
}
if (!Array.isArray(gameState.generators)) {
return { valid: false, reason: "Invalid generators" };
}
// Si pas de save précédente, c'est la première — accepter
if (!previousSave) {
return { valid: true };
}
// Calcul du temps écoulé depuis le dernier save
const lastSaveTime = new Date(previousSave.last_save).getTime();
const now = Date.now();
const elapsedSeconds = Math.max((now - lastSaveTime) / 1000, 0);
// Ressources max possibles = production max × temps × marge
const maxPossibleResources =
MAX_PRODUCTION_PER_SECOND * elapsedSeconds * CHEAT_MARGIN;
// Comparer les ressources actuelles avec le maximum théorique
// On compare le delta (gain depuis dernier save)
const previousState = previousSave.game_state;
const previousResources =
typeof previousState === "object"
? previousState.resources ?? 0
: 0;
const resourceDelta = gameState.resources - previousResources;
if (resourceDelta > maxPossibleResources && resourceDelta > 0) {
return {
valid: false,
reason: `Resource gain (${Math.floor(resourceDelta)}) exceeds maximum possible (${Math.floor(maxPossibleResources)}) in ${Math.floor(elapsedSeconds)}s`,
};
}
return { valid: true };
}
// --- Controllers ---
/**
* GET /api/save
* Charge la save du joueur connecté.
*/
const load = async (req, res) => {
try {
const userId = req.user;
const save = await tables.game_saves.getByUserId(userId);
if (!save) {
return res.status(200).json({ gameState: null });
}
// game_state est stocké en JSON — MySQL le retourne comme objet si type JSON
const rawState =
typeof save.game_state === "string"
? JSON.parse(save.game_state)
: save.game_state;
// Migrate on load — lazy migration, never touch DB rows directly
const gameState = migrateSave(rawState);
return res.status(200).json({
gameState,
lastSave: save.last_save,
lifetimeTadpoles: save.lifetime_tadpoles,
prestigeCount: save.prestige_count,
playTimeSeconds: save.play_time_seconds,
});
} catch (err) {
console.error("save/load error:", err);
return res.status(500).json({ message: "Failed to load save." });
}
};
/**
* POST /api/save
* Sauvegarde le game state du joueur connecté.
* Anti-cheat : valide le delta de ressources vs temps écoulé.
*/
const save = async (req, res) => {
try {
const userId = req.user;
const { gameState, playTimeSeconds } = req.body;
if (!gameState) {
return res.status(400).json({ message: "gameState required." });
}
// Récupérer la save précédente pour validation
const previousSave = await tables.game_saves.getByUserId(userId);
// Valider l'état
const validation = validateGameState(gameState, previousSave);
if (!validation.valid) {
console.warn(
`Anti-cheat: user ${userId} rejected — ${validation.reason}`
);
return res.status(422).json({
message: "Save rejected: anomaly detected.",
reason: validation.reason,
});
}
// Extraire les métadonnées pour la table
const metadata = {
lifetimeTadpoles: gameState.lifetimeTadpoles ?? 0,
prestigeCount: gameState.prestigeCount ?? 0,
playTimeSeconds: playTimeSeconds ?? 0,
};
await tables.game_saves.upsert(userId, gameState, metadata);
return res.status(200).json({ message: "Save successful.", lastSave: new Date().toISOString() });
} catch (err) {
console.error("save/save error:", err);
return res.status(500).json({ message: "Failed to save." });
}
};
module.exports = { load, save };