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:
2026-03-20 13:40:16 +01:00
parent 9f0ccda99b
commit a52746ed0c
20 changed files with 1167 additions and 152 deletions

View 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 };

View File

@@ -0,0 +1,43 @@
const AbstractManager = require("./AbstractManager");
class GameSaveManager extends AbstractManager {
constructor() {
super({ table: "game_saves" });
}
async getByUserId(userId) {
const [rows] = await this.database.query(
`SELECT * FROM ${this.table} WHERE user_id = ?`,
[userId]
);
return rows[0] ?? null;
}
async upsert(userId, gameState, metadata) {
const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata;
const gameStateJson = JSON.stringify(gameState);
const [result] = await this.database.query(
`INSERT INTO ${this.table} (user_id, game_state, lifetime_tadpoles, prestige_count, play_time_seconds)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
game_state = VALUES(game_state),
lifetime_tadpoles = VALUES(lifetime_tadpoles),
prestige_count = VALUES(prestige_count),
play_time_seconds = VALUES(play_time_seconds),
last_save = CURRENT_TIMESTAMP`,
[userId, gameStateJson, lifetimeTadpoles, prestigeCount, playTimeSeconds]
);
return result.affectedRows;
}
async delete(userId) {
await this.database.query(
`DELETE FROM ${this.table} WHERE user_id = ?`,
[userId]
);
}
}
module.exports = GameSaveManager;

View File

@@ -9,6 +9,7 @@ const router = express.Router();
// Import Controllers
const userControllers = require("./controllers/userControllers");
const authControllers = require("./controllers/authControllers");
const saveControllers = require("./controllers/saveControllers");
const verifyToken = require("./middlewares/verifyToken");
const verifyOAuth = require("./middlewares/verifyOAuth");
@@ -36,6 +37,10 @@ router.post("/login", userControllers.login);
// Sync game state — SuperOAuth uniquement
router.patch("/users/:id/coins", verifyOAuth, verifySelf, userControllers.updateCoins);
// Game saves — JWT required
router.get("/save", verifyToken, saveControllers.load);
router.post("/save", verifyToken, saveControllers.save);
/* ************************************************************************* */

View File

@@ -4,10 +4,11 @@
// Import the manager modules responsible for handling data operations on the tables
const UserManager = require("./models/UserManager");
const GameSaveManager = require("./models/GameSaveManager");
const managers = [
UserManager,
// Add other managers here
GameSaveManager,
];
// Create an empty object to hold data managers for different tables