warfactoryracine/docs/systeme-sauvegarde.md
StillHammer 5e4235889a Add comprehensive AI, diplomacy, and save system documentation
- ai-framework.md: Unified decision framework with scoring system, RL integration, doctrines
- systeme-diplomatique.md: Relations (shared), intentions (bilateral), threat, reliability systems
- calcul-menace.md: Contextual threat calculation with sword & shield mechanics
- systeme-sauvegarde.md: V1 save system with JSON, metachunks, module autonomy
- module-versioning.md: Automatic MAJOR.MINOR.PATCH.BUILD versioning via CMake

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 22:39:36 +08:00

21 KiB

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 :

{
  "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

{
  "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

{
  "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

{
  "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

// 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 :

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

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 :

saveSystem->saveGame("abc123");

En multi-serveur : Le coordinateur broadcast une clé de save :

// 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

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

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 :

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

// 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

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 :

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.

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

// Single-process
saveSystem->saveGame("my_first_base");

// Multi-serveur
coordinator->broadcastSaveCommand("my_first_base_1728226320");

Charger une Save Existante

saveSystem->loadGame("my_first_base_1728226320");

Éditer une Save Manuellement

# 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

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