142 lines
5.5 KiB
C#
142 lines
5.5 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Options;
|
|
using TheLivingWorld.Core.Contracts;
|
|
using TheLivingWorld.Core.Export;
|
|
|
|
namespace TheLivingWorld.Api.Storage;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<WorldStore> 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;
|
|
|
|
/// <summary>
|
|
/// Ids are used as directory names, so only a conservative slug alphabet is accepted. Everything that
|
|
/// reaches the filesystem goes through here.
|
|
/// </summary>
|
|
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<IReadOnlyList<WorldSummaryDto>> ListAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
if (!Directory.Exists(_root)) return [];
|
|
|
|
var summaries = new List<WorldSummaryDto>();
|
|
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<WorldSummaryDto?> 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<WorldSummaryDto>(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<WorldDto?> 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<WorldDto>(stream, MapJson.Options, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>Opens a chunk file for streaming straight to the response. Null when the chunk is empty.</summary>
|
|
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<ExportedChunk> 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<T>(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);
|
|
}
|
|
}
|