Update README with project details and setup instructions; add data directories to .gitignore

This commit is contained in:
Leonid Pershin
2026-08-16 17:09:16 +03:00
parent 9d09d6e97f
commit 8460921bfa
73 changed files with 6978 additions and 1 deletions
@@ -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);
}
}