feat: Sprint 3 — Prestige Loop endless
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
This commit is contained in:
2026-03-28 18:24:24 +01:00
parent f80f071c24
commit ed8cf87d4e
22 changed files with 1917 additions and 158 deletions

View File

@@ -0,0 +1,4 @@
-- Migration 003: Add save_version column for Sprint 3 migration system
-- Safe to run on existing data — defaults to 1 (Sprint 2 saves)
ALTER TABLE game_saves ADD COLUMN save_version INT DEFAULT 1 AFTER game_state;

View File

@@ -16,6 +16,7 @@ CREATE TABLE game_saves (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
game_state JSON NOT NULL,
save_version INT DEFAULT 1,
last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
lifetime_tadpoles BIGINT DEFAULT 0,
prestige_count INT DEFAULT 0,

View File

@@ -1,4 +1,5 @@
const tables = require("../tables");
const { migrateSave } = require("../services/migrateSave");
// --- Anti-cheat validation ---
@@ -75,11 +76,14 @@ const load = async (req, res) => {
}
// game_state est stocké en JSON — MySQL le retourne comme objet si type JSON
const gameState =
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,

View File

@@ -15,18 +15,20 @@ class GameSaveManager extends AbstractManager {
async upsert(userId, gameState, metadata) {
const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata;
const saveVersion = gameState.saveVersion ?? 1;
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 (?, ?, ?, ?, ?)
`INSERT INTO ${this.table} (user_id, game_state, save_version, lifetime_tadpoles, prestige_count, play_time_seconds)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
game_state = VALUES(game_state),
save_version = VALUES(save_version),
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]
[userId, gameStateJson, saveVersion, lifetimeTadpoles, prestigeCount, playTimeSeconds]
);
return result.affectedRows;

View File

@@ -0,0 +1,65 @@
// migrateSave.js — Backend save migration (mirrors Frontend/src/core/migrateSave.ts)
// Applied on load — lazy migration, never touch DB directly.
const CURRENT_SAVE_VERSION = 2;
// Default evolution tree (Sprint 2 — 18 nodes)
// Used to merge new nodes into old saves
const DEFAULT_TREE_IDS = [
"ponte_amelioree", "double_ponte", "ponte_frenetique", "auto_ponte",
"ponte_critique", "maitre_pondeur",
"instinct_gregaire", "symbiose_algale", "courant_profond", "maree_haute",
"ecosysteme_mature", "marais_eternel",
"memoire_genetique", "adn_renforce", "eveil_rapide", "resilience",
"heritage", "transcendance",
];
/**
* Migrate a raw game state to the current version.
* Backend only needs structural migration for anti-cheat validation —
* the full tree/generator merge happens on the frontend.
*/
function migrateSave(raw) {
if (!raw || typeof raw !== "object") return raw;
const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1;
let state = { ...raw };
if (version < 2) {
state = migrateV1toV2(state);
}
return state;
}
function migrateV1toV2(state) {
state.saveVersion = 2;
// RunStats
if (!state.runStats) {
state.runStats = {
startedAt: state.lastTick || Date.now(),
tadpolesProduced: 0,
bestRun: null,
};
}
// Tree reset fields
if (typeof state.freeResetAvailable !== "boolean") {
state.freeResetAvailable = true;
}
if (typeof state.extraResetsUsed !== "number") {
state.extraResetsUsed = 0;
}
// Backfill cosmetics
if (!state.lastOnline) state.lastOnline = state.lastTick;
if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = [];
if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") {
state.cosmeticEquipped = {};
}
return state;
}
module.exports = { migrateSave, CURRENT_SAVE_VERSION };