aissia/docs/systeme-sauvegarde.md
StillHammer ba42b6d9c7 Update CDC with hybrid architecture (WarFactory + multi-target)
- Add hybrid deployment modes: local_dev (MVP) and production_pwa (optional)
- Integrate WarFactory engine reuse with hot-reload 0.4ms
- Define multi-target compilation strategy (DLL/SO/WASM)
- Detail both deployment modes with cost analysis
- Add progressive roadmap: Phase 1 (local), Phase 2 (POC WASM), Phase 3 (cloud)
- Budget clarified: $10-20/mois (local) vs $13-25/mois (cloud)
- Document open questions for technical validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 11:49:09 +08:00

716 lines
21 KiB
Markdown

# Système de Sauvegarde
## Philosophie
Le système de sauvegarde suit la philosophie du projet : **évolution itérative avec versioning des systèmes**. Chaque version améliore progressivement les performances et fonctionnalités tout en maintenant la compatibilité.
Le système est conçu pour être **modulaire et autonome**, chaque module gérant sa propre persistence de manière indépendante.
## Version 1 : Save JSON Local Sans Versioning de Format
### Principes de Base
- **Format** : JSON pur pour lisibilité, debugging et édition manuelle
- **Granularité** : Un fichier par module
- **Distribution** : Chaque serveur sauvegarde localement (multi-serveur ready)
- **Coordination** : Clé de save distribuée par le coordinateur
- **Autonomie** : Chaque module implémente son propre `serialize()` / `deserialize()`
- **Chunks** : Seulement les chunks modifiés (metachunks 512x512)
- **Objectif** : Système fonctionnel et suffisant pour tests et développement
### Architecture de Fichiers
```
saves/
└── abc123_1728226320/ # Clé de save : [id]_[timestamp]
├── save_metadata.json # Métadonnées globales de la save
├── server_1/ # Un dossier par serveur (future-proof)
│ ├── tank.json
│ └── combat.json
├── server_2/
│ ├── economy.json
│ └── factory.json
└── metachunks/
├── 0_0.json # Metachunk coords (x, y) en 512x512
├── 0_1.json
└── ...
```
**Note** : En configuration single-process V1, un seul dossier serveur existe (ex: `server_1/`), mais la structure supporte déjà le multi-serveur.
### Format save_metadata.json
Fichier global décrivant la sauvegarde complète :
```json
{
"save_key": "abc123_1728226320",
"timestamp": "2025-10-06T14:32:00Z",
"game_version": "0.1.0-alpha",
"modules": {
"tank": {
"version": "0.1.15.3847",
"load_status": "ok"
},
"economy": {
"version": "0.2.8.1203",
"load_status": "ok"
},
"factory": {
"version": "0.1.20.4512",
"load_status": "load_pending"
},
"transport": {
"version": "0.1.5.982",
"load_status": "ok"
}
},
"metachunks_count": 42,
"world_seed": 1847293847
}
```
**Champs** :
- `save_key` : Identifiant unique de la save (format : `[id]_[timestamp_unix]`)
- `timestamp` : Date/heure de sauvegarde (ISO 8601)
- `game_version` : Version du jeu ayant créé la save
- `modules` : État de chaque module sauvé
- `version` : Version du module (voir `module-versioning.md`)
- `load_status` : `"ok"`, `"load_pending"`, `"failed"`
- `metachunks_count` : Nombre de metachunks sauvés
- `world_seed` : Seed de génération procédurale
### Format Fichiers Modules
Chaque module implémente `IModuleSave` et définit son propre format JSON.
#### Exemple : tank.json
```json
{
"module_name": "tank",
"module_version": "0.1.15.3847",
"save_timestamp": "2025-10-06T14:32:00Z",
"data": {
"tanks": [
{
"id": "tank_001",
"chassis_type": "m1_abrams",
"position": {"x": 1250.5, "y": 3480.2},
"rotation": 45.0,
"components": [
{
"type": "turret_120mm",
"grid_pos": [2, 3],
"health": 100,
"ammo_loaded": "apfsds"
},
{
"type": "engine_turbine",
"grid_pos": [1, 1],
"health": 85,
"fuel": 0.75
}
],
"inventory": {
"120mm_apfsds": 32,
"120mm_heat": 18
},
"crew_status": "operational"
}
]
}
}
```
#### Exemple : economy.json
```json
{
"module_name": "economy",
"module_version": "0.2.8.1203",
"save_timestamp": "2025-10-06T14:32:00Z",
"data": {
"market_prices": {
"iron_ore": 2.5,
"copper_ore": 3.2,
"steel_plate": 8.0
},
"player_balance": 125000,
"pending_transactions": [
{
"id": "tx_4829",
"type": "buy",
"resource": "steel_plate",
"quantity": 1000,
"price_per_unit": 8.0,
"timestamp": "2025-10-06T14:30:15Z"
}
]
}
}
```
**Convention** : Chaque fichier module contient :
- `module_name` : Nom du module (vérification cohérence)
- `module_version` : Version du module ayant créé la save
- `save_timestamp` : Timestamp de sauvegarde
- `data` : Objet contenant toutes les données spécifiques au module
### Format Metachunks (512x512)
Pour réduire le nombre de fichiers, les chunks 64x64 sont regroupés en **metachunks 512x512** (8x8 chunks par metachunk = 64 chunks).
#### Structure Metachunk
```json
{
"metachunk_coords": {"x": 0, "y": 0},
"metachunk_size": 512,
"chunk_size": 64,
"chunks": {
"0_0": {
"terrain": {
"land_ids_base64": "AQIDBAUGBwg...",
"roof_ids_base64": "CQAKAA..."
},
"buildings": [
{
"id": "building_factory_001",
"type": "assembler_mk1",
"position": {"x": 10, "y": 15},
"recipe": "ammunition_7.62mm",
"progress": 0.45,
"inventory": {
"iron_plate": 50,
"copper_wire": 30
}
}
],
"resources": [
{
"type": "iron_ore",
"patch_id": "iron_patch_042",
"positions": [[5,5], [5,6], [6,5], [6,6]],
"amounts": [1000, 950, 980, 1020]
}
]
},
"0_1": {
"terrain": { "land_ids_base64": "..." },
"buildings": [],
"resources": []
}
}
}
```
**Optimisations** :
- **Base64 compression** : Données terrain denses compressées
- **Sparse storage** : Chunks vides omis du metachunk
- **Dirty tracking** : Seulement metachunks avec chunks modifiés sont sauvés
#### Calcul Coords Metachunk
```cpp
// Chunk 64x64 coords → Metachunk 512x512 coords
MetachunkCoords getMetachunkCoords(int chunkX, int chunkY) {
return {
chunkX / 8, // 512 / 64 = 8 chunks par metachunk
chunkY / 8
};
}
// Coords locales dans le metachunk
ChunkLocalCoords getLocalCoords(int chunkX, int chunkY) {
return {
chunkX % 8,
chunkY % 8
};
}
```
### Interface IModuleSave
Chaque module implémente cette interface pour gérer sa persistence :
```cpp
class IModuleSave {
public:
virtual ~IModuleSave() = default;
// Sérialise l'état complet du module en JSON
virtual nlohmann::json serialize() = 0;
// Restaure l'état du module depuis JSON
// Retourne true si succès, false si erreur (→ load_pending)
virtual bool deserialize(const nlohmann::json& data) = 0;
// Version du module (auto-générée, voir module-versioning.md)
virtual std::string getVersion() const = 0;
};
```
#### Exemple Implémentation
```cpp
class TankModule : public IModule, public IModuleSave {
private:
std::vector<Tank> tanks;
public:
nlohmann::json serialize() override {
json data;
data["module_name"] = "tank";
data["module_version"] = getVersion();
data["save_timestamp"] = getCurrentTimestamp();
json tanksArray = json::array();
for (const auto& tank : tanks) {
tanksArray.push_back(tank.toJson());
}
data["data"]["tanks"] = tanksArray;
return data;
}
bool deserialize(const json& data) override {
try {
// Validation version
if (data["module_name"] != "tank") {
LOG_ERROR("Invalid module name in save file");
return false;
}
// Charge les tanks
tanks.clear();
for (const auto& tankJson : data["data"]["tanks"]) {
tanks.push_back(Tank::fromJson(tankJson));
}
return true;
}
catch (const std::exception& e) {
LOG_ERROR("Deserialization failed: {}", e.what());
return false;
}
}
std::string getVersion() const override {
return MODULE_VERSION; // Défini par CMake
}
};
```
### Workflow de Sauvegarde
#### Déclenchement
**En single-process** :
```cpp
saveSystem->saveGame("abc123");
```
**En multi-serveur** :
Le coordinateur broadcast une clé de save :
```cpp
// Coordinateur
coordinator->broadcastSaveCommand("abc123_1728226320");
// Chaque serveur reçoit et exécute
void Server::onSaveCommand(const std::string& saveKey) {
saveSystem->saveGame(saveKey);
}
```
#### Processus de Sauvegarde
```cpp
void SaveSystem::saveGame(const std::string& saveKey) {
auto startTime = std::chrono::steady_clock::now();
// 1. Créer structure de dossiers
std::string savePath = "saves/" + saveKey + "/";
fs::create_directories(savePath + "server_1/");
fs::create_directories(savePath + "metachunks/");
// 2. Sauvegarder chaque module
json metadata;
metadata["save_key"] = saveKey;
metadata["timestamp"] = getCurrentTimestamp();
metadata["game_version"] = GAME_VERSION;
for (auto& module : moduleSystem->getAllModules()) {
std::string moduleName = module->getName();
try {
// Sérialiser module
json moduleData = module->serialize();
// Écrire fichier
std::string modulePath = savePath + "server_1/" +
moduleName + ".json";
writeJsonFile(modulePath, moduleData);
// Mettre à jour metadata
metadata["modules"][moduleName] = {
{"version", module->getVersion()},
{"load_status", "ok"}
};
LOG_INFO("Module {} saved successfully", moduleName);
}
catch (const std::exception& e) {
LOG_ERROR("Failed to save module {}: {}", moduleName, e.what());
metadata["modules"][moduleName] = {
{"version", module->getVersion()},
{"load_status", "failed"}
};
}
}
// 3. Sauvegarder metachunks modifiés
int metachunkCount = 0;
for (auto& [coords, metachunk] : dirtyMetachunks) {
json metachunkData = metachunk->serialize();
std::string filename = std::to_string(coords.x) + "_" +
std::to_string(coords.y) + ".json";
writeJsonFile(savePath + "metachunks/" + filename, metachunkData);
metachunkCount++;
}
metadata["metachunks_count"] = metachunkCount;
// 4. Écrire metadata
writeJsonFile(savePath + "save_metadata.json", metadata);
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - startTime
);
LOG_INFO("Save completed in {}ms: {} modules, {} metachunks",
duration.count(), metadata["modules"].size(), metachunkCount);
}
```
### Workflow de Chargement
```cpp
void SaveSystem::loadGame(const std::string& saveKey) {
std::string savePath = "saves/" + saveKey + "/";
// 1. Charger metadata
json metadata = readJsonFile(savePath + "save_metadata.json");
// Validation game version (warning seulement en V1)
if (metadata["game_version"] != GAME_VERSION) {
LOG_WARN("Save created with different game version: {} vs {}",
metadata["game_version"], GAME_VERSION);
}
// 2. Charger world data (seed, params)
worldGenerator->setSeed(metadata["world_seed"]);
// 3. Charger chaque module
for (auto& module : moduleSystem->getAllModules()) {
std::string moduleName = module->getName();
std::string modulePath = savePath + "server_1/" +
moduleName + ".json";
if (!fs::exists(modulePath)) {
LOG_WARN("No save file for module {}, using defaults", moduleName);
continue;
}
try {
json moduleData = readJsonFile(modulePath);
// Vérification version module
std::string savedVersion = moduleData["module_version"];
std::string currentVersion = module->getVersion();
if (savedVersion != currentVersion) {
LOG_WARN("Module {} version mismatch: save={}, current={}",
moduleName, savedVersion, currentVersion);
}
// Désérialiser
bool success = module->deserialize(moduleData);
if (!success) {
LOG_ERROR("Module {} deserialization failed, marked as load_pending",
moduleName);
metadata["modules"][moduleName]["load_status"] = "load_pending";
}
else {
LOG_INFO("Module {} loaded successfully", moduleName);
}
}
catch (const std::exception& e) {
LOG_ERROR("Failed to load module {}: {}", moduleName, e.what());
metadata["modules"][moduleName]["load_status"] = "load_pending";
}
}
// 4. Metachunks chargés à la demande (streaming)
// Voir Chunk Streaming ci-dessous
LOG_INFO("Save loaded: {}", saveKey);
}
```
### Chunk Streaming
Les metachunks ne sont **pas tous chargés au démarrage** pour économiser la mémoire. Système de chargement à la demande :
```cpp
Chunk* ChunkManager::getChunk(int x, int y) {
ChunkCoords coords{x, y};
// 1. Vérifier si déjà en RAM
if (loadedChunks.contains(coords)) {
return loadedChunks[coords];
}
// 2. Calculer metachunk parent
MetachunkCoords metaCoords = getMetachunkCoords(x, y);
ChunkLocalCoords localCoords = getLocalCoords(x, y);
// 3. Charger metachunk si nécessaire
if (!loadedMetachunks.contains(metaCoords)) {
loadMetachunk(metaCoords);
}
// 4. Extraire chunk du metachunk
Metachunk* metachunk = loadedMetachunks[metaCoords];
Chunk* chunk = metachunk->getChunk(localCoords);
if (chunk) {
loadedChunks[coords] = chunk;
return chunk;
}
// 5. Si chunk n'existe pas dans save → génération procédurale
return generateNewChunk(coords);
}
void ChunkManager::loadMetachunk(MetachunkCoords coords) {
std::string path = "saves/" + currentSave + "/metachunks/" +
std::to_string(coords.x) + "_" +
std::to_string(coords.y) + ".json";
if (!fs::exists(path)) {
// Metachunk pas encore exploré/modifié
loadedMetachunks[coords] = new Metachunk(coords);
return;
}
// Charger et désérialiser metachunk
json metachunkData = readJsonFile(path);
Metachunk* metachunk = new Metachunk(coords);
metachunk->deserialize(metachunkData);
loadedMetachunks[coords] = metachunk;
LOG_DEBUG("Metachunk loaded: ({}, {})", coords.x, coords.y);
}
```
### Gestion des Erreurs : load_pending
Lorsqu'un module échoue à charger (JSON corrompu, incompatibilité version, etc.), il est marqué `load_pending` dans le metadata.
#### Workflow de Récupération
```cpp
// Au chargement, si erreur détectée
if (!module->deserialize(moduleData)) {
metadata["modules"][moduleName]["load_status"] = "load_pending";
// Sauvegarder metadata mis à jour
writeJsonFile(savePath + "save_metadata.json", metadata);
// Continuer le chargement des autres modules
}
```
#### Interface Utilisateur
```cpp
void UI::displayLoadPendingModules() {
for (const auto& [moduleName, info] : metadata["modules"].items()) {
if (info["load_status"] == "load_pending") {
std::cout << "[WARNING] Module '" << moduleName
<< "' failed to load. Check logs and fix JSON file.\n";
std::cout << " File: saves/" << saveKey << "/server_1/"
<< moduleName << ".json\n";
std::cout << " Version: " << info["version"] << "\n\n";
}
}
}
```
#### Retry Manuel
Après avoir corrigé le fichier JSON manuellement :
```cpp
void SaveSystem::retryLoadModule(const std::string& moduleName) {
auto module = moduleSystem->getModule(moduleName);
std::string modulePath = savePath + "server_1/" + moduleName + ".json";
try {
json moduleData = readJsonFile(modulePath);
bool success = module->deserialize(moduleData);
if (success) {
metadata["modules"][moduleName]["load_status"] = "ok";
writeJsonFile(savePath + "save_metadata.json", metadata);
LOG_INFO("Module {} successfully reloaded", moduleName);
}
}
catch (const std::exception& e) {
LOG_ERROR("Retry failed for module {}: {}", moduleName, e.what());
}
}
```
### Dirty Tracking
Seuls les chunks/metachunks **modifiés** sont sauvegardés pour optimiser la taille des saves.
```cpp
class ChunkManager {
private:
std::unordered_set<MetachunkCoords> dirtyMetachunks;
public:
void markChunkDirty(int chunkX, int chunkY) {
MetachunkCoords metaCoords = getMetachunkCoords(chunkX, chunkY);
dirtyMetachunks.insert(metaCoords);
}
void onBuildingPlaced(int x, int y) {
int chunkX = x / 64;
int chunkY = y / 64;
markChunkDirty(chunkX, chunkY);
}
void onResourceMined(int x, int y) {
int chunkX = x / 64;
int chunkY = y / 64;
markChunkDirty(chunkX, chunkY);
}
};
```
### Performance Targets V1
- **Autosave** : < 100ms pause perceptible (background thread recommandé)
- **Save manuelle** : < 500ms acceptable (avec feedback UI)
- **Load game** : < 3 secondes pour metadata + modules
- **Chunk streaming** : < 16ms par metachunk (1 frame @60fps)
- **Taille save** : ~10-50MB pour partie moyenne (dépend exploration)
### Limitations V1
Ces limitations seront résolues dans les versions futures :
1. **Pas de migration format** : Si le format JSON d'un module change, incompatibilité
2. **JSON verbeux** : Fichiers volumineux comparé à format binaire (acceptable pour debug)
3. **Pas de compression** : Taille disque non optimale
4. **Pas de checksums** : Corruption de données détectée tard (au parsing JSON)
5. **Concurrence limitée** : Save/load synchrones (pas de multi-threading)
## Évolution Future
### Version 2 : Compression et Migration
- **Compression LZ4/Zstd** : Réduction 60-80% taille fichiers
- **Format binaire optionnel** : MessagePack ou Protobuf pour données volumineuses
- **Migration automatique** : Système de conversion entre versions de format
- **Checksums** : Validation intégrité (CRC32, SHA256)
- **Async I/O** : Save/load en background threads
### Version 3 : Incremental Saves
- **Delta encoding** : Sauvegardes différentielles (snapshot + journal de changements)
- **Rollback temporel** : Restaurer état à timestamp spécifique
- **Compression inter-saves** : Déduplication entre sauvegardes
- **Hot-save** : Sauvegarde pendant le jeu sans pause
### Version 4 : Cloud et Multiplayer
- **Synchronisation cloud** : Steam Cloud, Google Drive, etc.
- **Save distribué** : Réplication multi-serveur automatique
- **Conflict resolution** : Merge intelligent pour multiplayer
- **Versioning git-like** : Branches, merge, rollback
## Références Croisées
- `module-versioning.md` : Système de versioning automatique des modules
- `architecture-modulaire.md` : Interface IModule, contraintes autonomie
- `systemes-techniques.md` : Architecture chunks multi-échelle
- `map-system.md` : Génération procédurale, resource patches
- `metriques-joueur.md` : Métriques à sauvegarder (3.1GB analytics)
## Exemples Pratiques
### Créer une Nouvelle Save
```cpp
// Single-process
saveSystem->saveGame("my_first_base");
// Multi-serveur
coordinator->broadcastSaveCommand("my_first_base_1728226320");
```
### Charger une Save Existante
```cpp
saveSystem->loadGame("my_first_base_1728226320");
```
### Éditer une Save Manuellement
```bash
# Ouvrir fichier module
nano saves/abc123_1728226320/server_1/economy.json
# Modifier prix du marché
# "iron_ore": 2.5 → "iron_ore": 5.0
# Recharger le module spécifique
saveSystem->retryLoadModule("economy");
```
### Lister les Saves Disponibles
```cpp
std::vector<SaveInfo> SaveSystem::listSaves() {
std::vector<SaveInfo> saves;
for (const auto& entry : fs::directory_iterator("saves/")) {
if (!entry.is_directory()) continue;
std::string metadataPath = entry.path().string() + "/save_metadata.json";
if (!fs::exists(metadataPath)) continue;
json metadata = readJsonFile(metadataPath);
saves.push_back({
.saveKey = metadata["save_key"],
.timestamp = metadata["timestamp"],
.gameVersion = metadata["game_version"],
.moduleCount = metadata["modules"].size(),
.metachunkCount = metadata["metachunks_count"]
});
}
// Trier par timestamp décroissant (plus récent en premier)
std::sort(saves.begin(), saves.end(), [](const auto& a, const auto& b) {
return a.timestamp > b.timestamp;
});
return saves;
}
```