using System.Text.Json; using Microsoft.Extensions.Options; using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Export; namespace TheLivingWorld.Api.Storage; /// /// Persists generated worlds as plain files: one folder per world holding its state, its index, and one file /// per chunk. Chunk files are written in the exact wire format so serving them is a byte copy. /// public sealed class WorldStore(IOptions options, ILogger logger) { private const string StateFileName = "state.json"; private const string IndexFileName = "world.json"; private const string ChunksDirectoryName = "chunks"; private readonly string _root = options.Value.RootDirectory; /// /// Ids are used as directory names, so only a conservative slug alphabet is accepted. Everything that /// reaches the filesystem goes through here. /// public static bool IsValidId(string? id) => !string.IsNullOrEmpty(id) && id.Length <= 64 && id.All(static c => char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c == '-'); public async Task> ListAsync(CancellationToken cancellationToken = default) { if (!Directory.Exists(_root)) return []; var summaries = new List(); foreach (var directory in Directory.EnumerateDirectories(_root)) { var id = Path.GetFileName(directory); if (!IsValidId(id)) continue; if (await GetSummaryAsync(id, cancellationToken).ConfigureAwait(false) is { } summary) summaries.Add(summary); } summaries.Sort(static (a, b) => b.CreatedAt.CompareTo(a.CreatedAt)); return summaries; } public async Task GetSummaryAsync(string id, CancellationToken cancellationToken = default) { var path = Path.Combine(WorldDirectory(id), StateFileName); if (!File.Exists(path)) return null; try { await using var stream = File.OpenRead(path); return await JsonSerializer .DeserializeAsync(stream, MapJson.Options, cancellationToken) .ConfigureAwait(false); } catch (Exception ex) when (ex is JsonException or IOException) { logger.LogWarning(ex, "Could not read state for world {Id}", id); return null; } } public async Task SaveSummaryAsync(WorldSummaryDto summary, CancellationToken cancellationToken = default) { var directory = WorldDirectory(summary.Id); Directory.CreateDirectory(directory); await WriteJsonAsync(Path.Combine(directory, StateFileName), summary, cancellationToken).ConfigureAwait(false); } public async Task GetWorldAsync(string id, CancellationToken cancellationToken = default) { var path = Path.Combine(WorldDirectory(id), IndexFileName); if (!File.Exists(path)) return null; await using var stream = File.OpenRead(path); return await JsonSerializer .DeserializeAsync(stream, MapJson.Options, cancellationToken) .ConfigureAwait(false); } /// Opens a chunk file for streaming straight to the response. Null when the chunk is empty. public Stream? OpenChunk(string id, int x, int y) { if (x < 0 || y < 0) return null; var path = Path.Combine(WorldDirectory(id), ChunksDirectoryName, $"{x}_{y}.json"); return File.Exists(path) ? File.OpenRead(path) : null; } public async Task SaveWorldAsync( WorldDto world, IReadOnlyList chunks, CancellationToken cancellationToken = default) { var directory = WorldDirectory(world.Id); var chunkDirectory = Path.Combine(directory, ChunksDirectoryName); // A regenerated world must not keep chunks that no longer exist. if (Directory.Exists(chunkDirectory)) Directory.Delete(chunkDirectory, recursive: true); Directory.CreateDirectory(chunkDirectory); foreach (var chunk in chunks) { var path = Path.Combine(chunkDirectory, $"{chunk.Coord.X}_{chunk.Coord.Y}.json"); await WriteJsonAsync(path, chunk.Chunk, cancellationToken).ConfigureAwait(false); } await WriteJsonAsync(Path.Combine(directory, IndexFileName), world, cancellationToken).ConfigureAwait(false); logger.LogInformation("Saved world {Id} with {Chunks:N0} chunks to {Directory}", world.Id, chunks.Count, directory); } public bool Delete(string id) { var directory = WorldDirectory(id); if (!Directory.Exists(directory)) return false; Directory.Delete(directory, recursive: true); return true; } private string WorldDirectory(string id) { if (!IsValidId(id)) throw new ArgumentException($"'{id}' is not a valid world id.", nameof(id)); return Path.Combine(_root, id); } private static async Task WriteJsonAsync(string path, T value, CancellationToken cancellationToken) { // Write beside the target and swap, so a reader never sees a half-written file. var temporary = path + ".tmp"; await using (var stream = File.Create(temporary)) { await JsonSerializer.SerializeAsync(stream, value, MapJson.Options, cancellationToken).ConfigureAwait(false); } File.Move(temporary, path, overwrite: true); } }