Update README with project details and setup instructions; add data directories to .gitignore
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
namespace TheLivingWorld.Api.Storage;
|
||||
|
||||
public sealed class WorldStorageOptions
|
||||
{
|
||||
public const string SectionName = "WorldStorage";
|
||||
|
||||
/// <summary>Where generated worlds live, relative to the content root unless rooted.</summary>
|
||||
public string RootDirectory { get; set; } = "data/worlds";
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user