using Repoverse.Core.Bridge; using System.Text.Json; using System.Text.Json.Nodes; using Repoverse.Core.Generation; using Repoverse.Core.Voxels; namespace Repoverse.Core.Persistence; public enum ViewMode { World, Code, Graph, Build, Cad, Audit, Agent } public sealed record Bookmark(string Label, string Uri); public sealed record PlayerState { public double X { get; init; } public double Y { get; init; } public double Z { get; init; } public double Yaw { get; init; } public ViewMode Mode { get; init; } = ViewMode.World; /// repoverse:// URI of the current selection, if any. public string? Selection { get; init; } public IReadOnlyList Bookmarks { get; init; } = []; } public sealed record ChunkEdits(string Chunk, IReadOnlyDictionary Voxels); /// /// Versioned save (§42, §43). Holds player state and user modifications only; /// canonical and generated state are rebuilt from the snapshot and manifest. /// public sealed record SaveGame { public const int CurrentSchema = 2; public int SchemaVersion { get; init; } = CurrentSchema; public required string WorldSeed { get; init; } public required string Generator { get; init; } public required IReadOnlyDictionary Snapshots { get; init; } public required PlayerState Player { get; init; } public IReadOnlyList Edits { get; init; } = []; public long SimulationTick { get; init; } public IReadOnlyList Builders { get; init; } = []; public static SaveGame Capture(WorldManifest manifest, PlayerState player, WorldEdits edits, long tick) => new() { WorldSeed = manifest.WorldSeed, Generator = manifest.Generator, Snapshots = manifest.Snapshots, Player = player, Edits = edits.Snapshot().OrderBy(kv => kv.Key.ToString(), StringComparer.Ordinal) .Select(kv => new ChunkEdits(kv.Key.ToString(), kv.Value)).ToList(), SimulationTick = tick, }; public void RestoreEdits(WorldEdits edits) => edits.Load(Edits.ToDictionary( e => { var p = e.Chunk.Split(','); return new ChunkCoord(int.Parse(p[0]), int.Parse(p[1]), int.Parse(p[2])); }, e => e.Voxels)); public static void Write(string path, SaveGame save) { var tmp = path + ".tmp"; File.WriteAllText(tmp, JsonSerializer.Serialize(save, Json.Options)); File.Move(tmp, path, overwrite: true); } public static SaveGame Read(string path) => Parse(File.ReadAllText(path)); public static SaveGame Parse(string json) { var node = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidDataException("Save file is empty."); var version = node["schemaVersion"]?.GetValue() ?? 1; if (version > CurrentSchema) throw new InvalidDataException($"Save schema {version} is newer than this build supports ({CurrentSchema})."); for (; version < CurrentSchema; version++) Migrations[version](node); node["schemaVersion"] = CurrentSchema; return node.Deserialize(Json.Options) ?? throw new InvalidDataException("Save file could not be read."); } /// Migration from schema N to N+1, applied in order. private static readonly Dictionary> Migrations = new() { // v1 kept "seed" and "cameraMode" and had no bookmarks or edits. [1] = n => { if (n["seed"] is { } seed) { n.Remove("seed"); n["worldSeed"] = seed.GetValue(); } n["generator"] ??= "repoverse-gen/0"; n["snapshots"] ??= new JsonObject(); if (n["player"] is JsonObject p) { if (p["cameraMode"] is { } mode) { p.Remove("cameraMode"); p["mode"] = mode.GetValue(); } p["bookmarks"] ??= new JsonArray(); } n["edits"] ??= new JsonArray(); }, }; }