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