Enhance README with updated instructions for world generation and menu navigation; implement world capacity management in the API with new configuration options; improve client interface with a full-screen menu and world list display; add theme toggle functionality and refine styling for better user experience.

This commit is contained in:
Leonid Pershin
2026-08-16 18:48:54 +03:00
parent 312d6bc58a
commit ee077a3bb9
16 changed files with 566 additions and 120 deletions
@@ -21,6 +21,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
<ProjectReference Include="..\..\src\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj" />
<ProjectReference Include="..\..\src\TheLivingWorld.Api\TheLivingWorld.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,113 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using TheLivingWorld.Api.Generation;
using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export;
using TheLivingWorld.Osm;
using TheLivingWorld.Osm.Import;
using TheLivingWorld.Osm.Overpass;
namespace TheLivingWorld.Tests;
public sealed class WorldGenerationServiceTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"tlw-gen-{Guid.NewGuid():n}");
private readonly WorldStore _store;
public WorldGenerationServiceTests()
{
_store = new WorldStore(
Options.Create(new WorldStorageOptions { RootDirectory = _root }),
NullLogger<WorldStore>.Instance);
}
[Fact]
public async Task StartAsync_rejects_creation_when_the_slot_budget_is_full()
{
await _store.SaveSummaryAsync(Summary("taken-aaaaaaaa", "Taken"));
using var service = CreateService(maxConcurrentWorlds: 1);
var failure = await Assert.ThrowsAsync<WorldCapacityExceededException>(() =>
service.StartAsync(new CreateWorldRequest
{
Name = "Overflow",
Latitude = 31.8966010,
Longitude = -100.4858591,
SizeKm = 10,
}, CancellationToken.None));
Assert.Equal(1, failure.MaxConcurrentWorlds);
Assert.Equal(1, await _store.CountAsync());
Assert.Equal("Taken", Assert.Single(await _store.ListAsync()).Name);
}
private WorldGenerationService CreateService(int maxConcurrentWorlds)
{
// The capacity check runs before any Overpass work, so this generator is never invoked by the
// rejection test. The acceptance test only asserts the pending summary was written.
var generator = new OsmWorldGenerator(
new OverpassClient(
new HttpClient(new UnreachableHandler()),
Options.Create(new OsmOptions
{
Endpoints = ["https://unreachable.example/api"],
CacheDirectory = Path.Combine(_root, "osm-cache"),
MaxAttemptsPerEndpoint = 1,
RequestTimeoutSeconds = 1,
}),
NullLogger<OverpassClient>.Instance),
new OsmWorldBuilder(NullLogger<OsmWorldBuilder>.Instance),
NullLogger<OsmWorldGenerator>.Instance);
return new WorldGenerationService(
generator,
_store,
new ChunkExporter(),
Options.Create(new WorldStorageOptions
{
RootDirectory = _root,
MaxConcurrentWorlds = maxConcurrentWorlds,
}),
new NeverStoppingLifetime(),
NullLogger<WorldGenerationService>.Instance);
}
private static WorldSummaryDto Summary(string id, string name) => new()
{
Id = id,
Name = name,
Latitude = 31.9,
Longitude = -100.5,
SizeMeters = 10_000,
Status = WorldStatus.Ready,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Dispose()
{
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
GC.SuppressFinalize(this);
}
private sealed class NeverStoppingLifetime : IHostApplicationLifetime
{
public CancellationToken ApplicationStarted => CancellationToken.None;
public CancellationToken ApplicationStopped => CancellationToken.None;
public CancellationToken ApplicationStopping => CancellationToken.None;
public void StopApplication()
{
}
}
private sealed class UnreachableHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
throw new InvalidOperationException("Overpass must not be contacted by these tests.");
}
}
@@ -0,0 +1,66 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts;
namespace TheLivingWorld.Tests;
public sealed class WorldStoreTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"tlw-worlds-{Guid.NewGuid():n}");
private readonly WorldStore _store;
public WorldStoreTests()
{
_store = new WorldStore(
Options.Create(new WorldStorageOptions { RootDirectory = _root }),
NullLogger<WorldStore>.Instance);
}
[Fact]
public async Task CountAsync_is_zero_when_the_root_is_missing()
{
Assert.Equal(0, await _store.CountAsync());
Assert.Empty(await _store.ListAsync());
}
[Fact]
public async Task CountAsync_and_ListAsync_track_saved_summaries()
{
await _store.SaveSummaryAsync(Summary("alpha-11111111", "Alpha"));
await _store.SaveSummaryAsync(Summary("bravo-22222222", "Bravo"));
Assert.Equal(2, await _store.CountAsync());
var listed = await _store.ListAsync();
Assert.Equal(2, listed.Count);
Assert.Equal(["Bravo", "Alpha"], listed.Select(world => world.Name).ToArray());
}
[Fact]
public async Task CountAsync_ignores_directories_without_readable_state()
{
await _store.SaveSummaryAsync(Summary("alpha-11111111", "Alpha"));
Directory.CreateDirectory(Path.Combine(_root, "not-a-valid-id!!!"));
Directory.CreateDirectory(Path.Combine(_root, "orphan-33333333"));
Assert.Equal(1, await _store.CountAsync());
}
private static WorldSummaryDto Summary(string id, string name) => new()
{
Id = id,
Name = name,
Latitude = 31.9,
Longitude = -100.5,
SizeMeters = 10_000,
Status = WorldStatus.Ready,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Dispose()
{
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
GC.SuppressFinalize(this);
}
}