Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 35s
- 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
146 lines
4.5 KiB
JavaScript
146 lines
4.5 KiB
JavaScript
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 };
|