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:
14
Backend/database/migrations/002_game_saves.sql
Normal file
14
Backend/database/migrations/002_game_saves.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Migration 002 — Game saves with anti-cheat metadata
|
||||
-- Safe: CREATE TABLE IF NOT EXISTS, no data loss
|
||||
-- Run: mysql -u <user> -p clickerz < migrations/002_game_saves.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS game_saves (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL UNIQUE,
|
||||
game_state JSON NOT NULL,
|
||||
last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
lifetime_tadpoles BIGINT DEFAULT 0,
|
||||
prestige_count INT DEFAULT 0,
|
||||
play_time_seconds INT DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
DROP TABLE IF EXISTS game_saves;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
CREATE TABLE users (
|
||||
@@ -10,3 +11,14 @@ CREATE TABLE users (
|
||||
lastname VARCHAR(50) NULL,
|
||||
super_oauth_id VARCHAR(36) NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE game_saves (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL UNIQUE,
|
||||
game_state JSON NOT NULL,
|
||||
last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
lifetime_tadpoles BIGINT DEFAULT 0,
|
||||
prestige_count INT DEFAULT 0,
|
||||
play_time_seconds INT DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
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 };
|
||||
43
Backend/src/models/GameSaveManager.js
Normal file
43
Backend/src/models/GameSaveManager.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
/* ************************************************************************* */
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user