feat(sprint1-step3b): backend save system + anti-cheat + données rattrapées
- game_saves table + migration 002 (JSON state, anti-cheat metadata) - saveControllers.js : load/save avec validation delta ressources (750k/s × 1.1) - GameSaveManager : upsert MySQL ON DUPLICATE KEY UPDATE - useSaveSync hook : auto-save 30s + keepalive beforeunload + guest fallback - save-validation.test.ts : 8 tests anti-cheat - economy.ts : arbre d'évolution 5 nœuds + prestige ADN (rattrapage step 2) - economy.test.ts : +40 tests (évolution tree, multipliers, start bonus) - GDD + SPRINT1.md : docs sprint complètes - Rethème data : shop.json, Achievements.json, Cookie, Legal (rattrapage step 1)
This commit is contained in:
141
Backend/src/controllers/saveControllers.js
Normal file
141
Backend/src/controllers/saveControllers.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const tables = require("../tables");
|
||||
|
||||
// --- 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 gameState =
|
||||
typeof save.game_state === "string"
|
||||
? JSON.parse(save.game_state)
|
||||
: save.game_state;
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user