File size: 4,001 Bytes
3ce1a40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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;
    /// <summary>repoverse:// URI of the current selection, if any.</summary>
    public string? Selection { get; init; }
    public IReadOnlyList<Bookmark> Bookmarks { get; init; } = [];
}

public sealed record ChunkEdits(string Chunk, IReadOnlyDictionary<int, ushort> Voxels);

/// <summary>
/// Versioned save (§42, §43). Holds player state and user modifications only;
/// canonical and generated state are rebuilt from the snapshot and manifest.
/// </summary>
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<string, string> Snapshots { get; init; }
    public required PlayerState Player { get; init; }
    public IReadOnlyList<ChunkEdits> Edits { get; init; } = [];
    public long SimulationTick { get; init; }
    public IReadOnlyList<BuilderState> 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<int>() ?? 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<SaveGame>(Json.Options) ?? throw new InvalidDataException("Save file could not be read.");
    }

    /// <summary>Migration from schema N to N+1, applied in order.</summary>
    private static readonly Dictionary<int, Action<JsonObject>> 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<string>(); }
            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<string>(); }
                p["bookmarks"] ??= new JsonArray();
            }
            n["edits"] ??= new JsonArray();
        },
    };
}