// migrateSave.ts — Migration lazy des saves entre versions // Appliqué au chargement (frontend + backend). Jamais de migration en DB. // Chaque sprint ajoute un step (v2→v3, etc.) import { CURRENT_SAVE_VERSION } from "./balance"; import type { GameState, ClickUpgrade } from "./economy"; import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS, DEFAULT_CLICK_UPGRADES } from "./economy"; /** * Détecte la version d'une save et applique les migrations nécessaires. * Entrée : objet brut depuis la DB/localStorage (potentiellement incomplet). * Sortie : GameState conforme à la version courante. */ export function migrateSave(raw: Record): GameState { const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1; let state = raw as Record; if (version < 2) { state = migrateV1toV2(state); } // Futures migrations : // if (version < 3) state = migrateV2toV3(state); // Always rebuild tree & generators from defaults — the server/localStorage // may not store all fields (branch, cost, effect, baseProduction, etc.) state.evolutionTree = mergeEvolutionTree( state.evolutionTree as Array> | undefined ); state.generators = mergeGenerators( state.generators as Array> | undefined ); // Click upgrades — merge with defaults (preserves levels, adds new upgrades) state.clickUpgrades = mergeClickUpgrades( state.clickUpgrades as Array> | undefined ); return state as unknown as GameState; } /** * v1 → v2 : Sprint 2 → Sprint 3 * - Ajoute saveVersion * - Ajoute runStats (vide) * - Ajoute freeResetAvailable + extraResetsUsed * - Merge les nouveaux nœuds arbre (conserve l'état des 18 existants) * - Backfill champs manquants (cosmeticInventory, cosmeticEquipped, lastOnline) */ function migrateV1toV2(raw: Record): Record { const state = { ...raw }; // saveVersion state.saveVersion = 2; // RunStats (nouveau Sprint 3) if (!state.runStats) { state.runStats = { startedAt: typeof state.lastTick === "number" ? state.lastTick : Date.now(), tadpolesProduced: 0, bestRun: null, }; } // Reset arbre : 1 gratuit par prestige if (typeof state.freeResetAvailable !== "boolean") { state.freeResetAvailable = true; } if (typeof state.extraResetsUsed !== "number") { state.extraResetsUsed = 0; } // Milestones (Sprint 3) if (!Array.isArray(state.claimedMilestones)) { state.claimedMilestones = []; } // Backfill champs Sprint 2 potentiellement manquants if (!state.lastOnline) state.lastOnline = state.lastTick; if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = []; if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") { state.cosmeticEquipped = {}; } // Merge arbre : conserver les 18 nœuds existants + ajouter les nouveaux state.evolutionTree = mergeEvolutionTree( state.evolutionTree as Array> | undefined ); // Merge générateurs : conserver owned + ajouter les potentiels nouveaux state.generators = mergeGenerators( state.generators as Array> | undefined ); return state; } /** * Merge l'arbre sauvegardé avec DEFAULT_EVOLUTION_TREE. * - Nœuds existants : conserve unlocked state * - Nœuds nouveaux : ajoutés avec unlocked: false * - Nœuds supprimés du default : retirés (forward compat) */ function mergeEvolutionTree( savedTree: Array> | undefined ): typeof DEFAULT_EVOLUTION_TREE { if (!savedTree || !Array.isArray(savedTree)) { return DEFAULT_EVOLUTION_TREE.map((n) => ({ ...n })); } const savedById = new Map( savedTree.map((n) => [n.id as string, n]) ); return DEFAULT_EVOLUTION_TREE.map((defaultNode) => { const saved = savedById.get(defaultNode.id); if (saved) { // Conserver l'état unlocked, tout le reste vient du default // (permet de corriger des valeurs rebalancées sans casser les saves) return { ...defaultNode, unlocked: saved.unlocked === true, }; } // Nouveau nœud — ajouté verrouillé return { ...defaultNode }; }); } /** * Merge les générateurs sauvegardés avec DEFAULT_GENERATORS. * Conserve le owned count, met à jour les stats de base. */ function mergeGenerators( savedGens: Array> | undefined ): typeof DEFAULT_GENERATORS { if (!savedGens || !Array.isArray(savedGens)) { return DEFAULT_GENERATORS.map((g) => ({ ...g })); } const savedById = new Map( savedGens.map((g) => [g.id as string, g]) ); return DEFAULT_GENERATORS.map((defaultGen) => { const saved = savedById.get(defaultGen.id); if (saved) { return { ...defaultGen, owned: typeof saved.owned === "number" ? saved.owned : 0, }; } return { ...defaultGen }; }); } /** * Merge les click upgrades sauvegardés avec DEFAULT_CLICK_UPGRADES. * Conserve le level, met à jour les stats de base. */ function mergeClickUpgrades( saved: Array> | undefined ): ClickUpgrade[] { if (!saved || !Array.isArray(saved)) { return DEFAULT_CLICK_UPGRADES.map((u) => ({ ...u })); } const savedById = new Map(saved.map((u) => [u.id as string, u])); return DEFAULT_CLICK_UPGRADES.map((def) => { const s = savedById.get(def.id); if (s) { return { ...def, level: typeof s.level === "number" ? s.level : 0 }; } return { ...def }; }); }