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
+11 -3
View File
@@ -31,7 +31,8 @@ dotnet run --project src/TheLivingWorld.AppHost
```
Aspire starts the API, runs `npm install` for the client, launches the Vite dev server, and prints a dashboard
URL. Open the `web` endpoint from the dashboard, enter coordinates, and press **Generate world**.
URL. Open the `web` endpoint from the dashboard: the main menu lists existing worlds and lets you create a new
one. Enter coordinates and press **Generate world**, then open a ready world to explore the map.
The default coordinates are Robert Lee, Texas (`31.8966010, -100.4858591`) — a small town that generates in a
few seconds.
@@ -102,8 +103,8 @@ them without reworking the data model.
| Endpoint | Purpose |
| --- | --- |
| `POST /api/worlds` | Start generating a world. Returns immediately with `status: "pending"` |
| `GET /api/worlds` | List worlds, with live status for anything still generating |
| `POST /api/worlds` | Start generating a world. Returns immediately with `status: "pending"`; `409` when the slot budget is full |
| `GET /api/worlds` | `{ worlds, maxConcurrentWorlds }` — list plus the server slot budget, with live status for anything still generating |
| `GET /api/worlds/{id}` | Status of one world |
| `GET /api/worlds/{id}/map` | Metadata plus the chunk index |
| `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry |
@@ -111,6 +112,8 @@ them without reworking the data model.
Generation takes tens of seconds — mostly waiting on Overpass — so `POST` returns straight away and the client
polls for status. Only one generation runs at a time, to stay a good citizen on the shared Overpass mirrors.
The number of worlds that may exist at once is capped by `WorldStorage:MaxConcurrentWorlds` (today that means
folders on disk; later the same budget will limit concurrent simulation).
Geometry travels as flat `[x0, y0, x1, y1, …]` arrays of world metres, which is exactly what PixiJS
`Graphics.poly()` accepts, so the client never reshapes it. Responses are compressed; chunk files are written
@@ -118,6 +121,10 @@ in wire format and streamed straight from disk.
## The client
The app opens on a full-screen main menu: a list of worlds with a slot counter, the create form, and theme
controls. Opening a ready world switches to the map screen (back button returns to the menu). PixiJS is
initialised on first open and kept alive across visits.
`MapView` owns one scaled container holding the layer stack from `layers.ts`, plus a screen-space layer for
place names above it. `Camera` is the only place the Y flip lives; everything else thinks in map coordinates.
@@ -161,6 +168,7 @@ usual dissolve instead of a special case. The page chrome follows via a `data-th
`src/TheLivingWorld.Api/appsettings.json`:
- `WorldStorage:RootDirectory` — where generated worlds go (default `data/worlds`)
- `WorldStorage:MaxConcurrentWorlds` — how many worlds may exist at once (default `8`)
- `Osm:Endpoints` — Overpass mirrors, tried in order
- `Osm:CacheDirectory` — raw Overpass responses (default `data/osm-cache`)
- `Osm:QueryTimeoutSeconds` / `Osm:RequestTimeoutSeconds` — server-side and client-side budgets
@@ -32,7 +32,11 @@ public static class WorldEndpoints
.Select(summary => generation.GetInFlight(summary.Id) ?? summary)
.ToArray();
return Results.Ok(merged);
return Results.Ok(new WorldListDto
{
Worlds = merged,
MaxConcurrentWorlds = generation.MaxConcurrentWorlds,
});
}
private static async Task<IResult> CreateWorld(
@@ -52,6 +56,15 @@ public static class WorldEndpoints
["request"] = [ex.Message],
});
}
catch (WorldCapacityExceededException ex)
{
return Results.Conflict(new
{
title = "World capacity exceeded",
detail = ex.Message,
maxConcurrentWorlds = ex.MaxConcurrentWorlds,
});
}
}
private static async Task<IResult> GetWorld(
@@ -0,0 +1,13 @@
namespace TheLivingWorld.Api.Generation;
/// <summary>Raised when creating a world would exceed <c>WorldStorage:MaxConcurrentWorlds</c>.</summary>
public sealed class WorldCapacityExceededException : InvalidOperationException
{
public WorldCapacityExceededException(int maxConcurrentWorlds)
: base($"The server already holds {maxConcurrentWorlds} worlds; delete one before creating another.")
{
MaxConcurrentWorlds = maxConcurrentWorlds;
}
public int MaxConcurrentWorlds { get; }
}
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Options;
using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export;
@@ -19,6 +20,7 @@ public sealed class WorldGenerationService(
OsmWorldGenerator generator,
WorldStore store,
ChunkExporter exporter,
IOptions<WorldStorageOptions> storageOptions,
IHostApplicationLifetime lifetime,
ILogger<WorldGenerationService> logger) : IDisposable
{
@@ -28,6 +30,9 @@ public sealed class WorldGenerationService(
/// <summary>Overpass mirrors are shared infrastructure, so only one download runs at a time.</summary>
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Serialises capacity checks against concurrent create requests.</summary>
private readonly SemaphoreSlim _capacityGate = new(1, 1);
private readonly ConcurrentDictionary<string, WorldSummaryDto> _inFlight = new();
public async Task<WorldSummaryDto> StartAsync(CreateWorldRequest request, CancellationToken cancellationToken)
@@ -56,8 +61,24 @@ public sealed class WorldGenerationService(
CreatedAt = DateTimeOffset.UtcNow,
};
await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var max = storageOptions.Value.MaxConcurrentWorlds;
if (max < 1)
throw new ArgumentException("WorldStorage:MaxConcurrentWorlds must be at least 1.");
var count = await store.CountAsync(cancellationToken).ConfigureAwait(false);
if (count >= max)
throw new WorldCapacityExceededException(max);
_inFlight[id] = summary;
await store.SaveSummaryAsync(summary, cancellationToken).ConfigureAwait(false);
}
finally
{
_capacityGate.Release();
}
// Detached on purpose: the caller gets the pending world back straight away.
_ = Task.Run(() => RunAsync(summary, request.ForceRefresh), CancellationToken.None);
@@ -68,6 +89,8 @@ public sealed class WorldGenerationService(
/// <summary>Returns the live status of a generation still in progress, if there is one.</summary>
public WorldSummaryDto? GetInFlight(string id) => _inFlight.GetValueOrDefault(id);
public int MaxConcurrentWorlds => storageOptions.Value.MaxConcurrentWorlds;
private async Task RunAsync(WorldSummaryDto summary, bool forceRefresh)
{
// Generation should stop when the host does, not drag shutdown out for minutes.
@@ -219,7 +242,11 @@ public sealed class WorldGenerationService(
return $"{prefix}-{Guid.NewGuid().ToString("n")[..8]}";
}
public void Dispose() => _gate.Dispose();
public void Dispose()
{
_gate.Dispose();
_capacityGate.Dispose();
}
private sealed class CallbackProgress(Action<string> callback) : IProgress<string>
{
@@ -6,4 +6,10 @@ public sealed class WorldStorageOptions
/// <summary>Where generated worlds live, relative to the content root unless rooted.</summary>
public string RootDirectory { get; set; } = "data/worlds";
/// <summary>
/// How many worlds may exist at once. Today that means folders on disk; later the same budget will cap
/// how many worlds the backend simulates concurrently.
/// </summary>
public int MaxConcurrentWorlds { get; set; } = 8;
}
@@ -44,6 +44,25 @@ public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<Wo
return summaries;
}
/// <summary>Number of worlds that occupy a storage slot (any status with a readable state file).</summary>
public async Task<int> CountAsync(CancellationToken cancellationToken = default)
{
if (!Directory.Exists(_root)) return 0;
var count = 0;
foreach (var directory in Directory.EnumerateDirectories(_root))
{
cancellationToken.ThrowIfCancellationRequested();
var id = Path.GetFileName(directory);
if (!IsValidId(id)) continue;
if (await GetSummaryAsync(id, cancellationToken).ConfigureAwait(false) is not null)
count++;
}
return count;
}
public async Task<WorldSummaryDto?> GetSummaryAsync(string id, CancellationToken cancellationToken = default)
{
var path = Path.Combine(WorldDirectory(id), StateFileName);
+2 -1
View File
@@ -7,7 +7,8 @@
},
"AllowedHosts": "*",
"WorldStorage": {
"RootDirectory": "data/worlds"
"RootDirectory": "data/worlds",
"MaxConcurrentWorlds": 8
},
"Osm": {
"Endpoints": [
@@ -24,6 +24,15 @@ public sealed record CreateWorldRequest
public bool ForceRefresh { get; init; }
}
/// <summary>Response body for <c>GET /api/worlds</c>.</summary>
public sealed record WorldListDto
{
public required IReadOnlyList<WorldSummaryDto> Worlds { get; init; }
/// <summary>Server-wide slot budget; see <c>WorldStorage:MaxConcurrentWorlds</c>.</summary>
public required int MaxConcurrentWorlds { get; init; }
}
/// <summary>A world as it appears in listings and while generation is still running.</summary>
public sealed record WorldSummaryDto
{
+32 -13
View File
@@ -7,20 +7,29 @@
<link rel="icon" href="data:," />
</head>
<body>
<div id="stage"></div>
<aside id="panel" class="panel">
<header class="panel__header">
<div class="panel__titles">
<div id="menu" class="screen screen--menu">
<div class="menu">
<header class="menu__header">
<div class="menu__titles">
<h1>The Living World</h1>
<p class="panel__subtitle">Generate a world from OpenStreetMap</p>
<p class="menu__subtitle">Generate a world from OpenStreetMap</p>
</div>
<button id="theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
<button id="menu-theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
</button>
</header>
<section class="worlds">
<div class="worlds__header">
<h2>Worlds</h2>
<span id="slot-count" class="slot-count"></span>
</div>
<ul id="world-list" class="world-list"></ul>
</section>
<form id="generate-form" class="form">
<h2 class="form__title">Create world</h2>
<label class="field">
<span>Name</span>
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" />
@@ -45,15 +54,25 @@
<button id="generate-button" type="submit" class="button">Generate world</button>
</form>
<section class="worlds">
<h2>Worlds</h2>
<ul id="world-list" class="world-list"></ul>
</section>
<footer id="status" class="status"></footer>
</aside>
</div>
</div>
<div id="game" class="screen screen--game" hidden>
<div id="stage"></div>
<header class="game-bar">
<button id="back-button" type="button" class="icon-button" title="Back to menu" aria-label="Back to menu">
</button>
<span id="world-title" class="game-bar__title"></span>
<button id="game-theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
</button>
</header>
<div id="hud" class="hud"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
+8 -3
View File
@@ -1,4 +1,4 @@
import type { CreateWorldRequest, MapChunk, WorldMap, WorldSummary } from './types';
import type { CreateWorldRequest, MapChunk, WorldList, WorldMap, WorldSummary } from './types';
const BASE = '/api/worlds';
@@ -14,9 +14,14 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
async function describeFailure(response: Response): Promise<string> {
try {
const problem = (await response.json()) as { title?: string; errors?: Record<string, string[]> };
const problem = (await response.json()) as {
title?: string;
detail?: string;
errors?: Record<string, string[]>;
};
const details = problem.errors ? Object.values(problem.errors).flat().join('; ') : undefined;
if (details) return details;
if (problem.detail) return problem.detail;
if (problem.title) return problem.title;
} catch {
// Not a problem-details body; fall through to the status line.
@@ -26,7 +31,7 @@ async function describeFailure(response: Response): Promise<string> {
}
export const api = {
listWorlds: () => request<WorldSummary[]>(BASE),
listWorlds: () => request<WorldList>(BASE),
createWorld: (body: CreateWorldRequest) =>
request<WorldSummary>(BASE, {
+6
View File
@@ -23,6 +23,12 @@ export interface WorldSummary {
stats?: WorldStats;
}
/** Response body for GET /api/worlds. */
export interface WorldList {
worlds: WorldSummary[];
maxConcurrentWorlds: number;
}
export interface CreateWorldRequest {
name?: string;
latitude: number;
+84 -23
View File
@@ -8,6 +8,8 @@ const LAST_WORLD_KEY = 'the-living-world:last-world';
const THEME_KEY = 'the-living-world:theme';
const elements = {
menu: required<HTMLDivElement>('menu'),
game: required<HTMLDivElement>('game'),
stage: required<HTMLDivElement>('stage'),
form: required<HTMLFormElement>('generate-form'),
name: required<HTMLInputElement>('field-name'),
@@ -17,13 +19,20 @@ const elements = {
sizeValue: required<HTMLOutputElement>('field-size-value'),
generate: required<HTMLButtonElement>('generate-button'),
worldList: required<HTMLUListElement>('world-list'),
slotCount: required<HTMLElement>('slot-count'),
status: required<HTMLElement>('status'),
hud: required<HTMLDivElement>('hud'),
themeToggle: required<HTMLButtonElement>('theme-toggle'),
worldTitle: required<HTMLElement>('world-title'),
back: required<HTMLButtonElement>('back-button'),
menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'),
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
};
const view = new MapView();
let activeWorldId: string | null = null;
let maxConcurrentWorlds = 8;
let mapReady = false;
let generating = false;
function required<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
@@ -36,6 +45,16 @@ function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): v
elements.status.dataset.tone = tone;
}
function showMenu(): void {
elements.menu.hidden = false;
elements.game.hidden = true;
}
function showGame(): void {
elements.menu.hidden = true;
elements.game.hidden = false;
}
function renderHud(status: MapStatus): void {
const scale = status.metersPerPixel >= 10
? `${Math.round(status.metersPerPixel)} m/px`
@@ -47,17 +66,26 @@ function renderHud(status: MapStatus): void {
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`;
}
function updateGenerateEnabled(worldCount: number): void {
elements.generate.disabled = generating || worldCount >= maxConcurrentWorlds;
}
async function refreshWorldList(): Promise<WorldSummary[]> {
const worlds = await api.listWorlds();
elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
return worlds;
const list = await api.listWorlds();
maxConcurrentWorlds = list.maxConcurrentWorlds;
elements.slotCount.textContent = `${list.worlds.length} / ${maxConcurrentWorlds}`;
elements.worldList.replaceChildren(...list.worlds.map(renderWorldItem));
updateGenerateEnabled(list.worlds.length);
return list.worlds;
}
function renderWorldItem(world: WorldSummary): HTMLLIElement {
const item = document.createElement('li');
item.className = 'world';
item.dataset.status = world.status;
if (world.id === activeWorldId) item.dataset.active = 'true';
if (world.id === activeWorldId || world.id === localStorage.getItem(LAST_WORLD_KEY)) {
item.dataset.active = 'true';
}
const open = document.createElement('button');
open.type = 'button';
@@ -100,22 +128,48 @@ function describeWorld(world: WorldSummary): string {
return `${size} · ${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`;
}
async function ensureMap(): Promise<void> {
if (mapReady) return;
await view.init(elements.stage);
view.onStatusChange = renderHud;
mapReady = true;
applyTheme(readStoredTheme());
}
async function openWorld(id: string): Promise<void> {
try {
setStatus('Loading map…', 'busy');
await ensureMap();
const map = await api.getMap(id);
activeWorldId = id;
localStorage.setItem(LAST_WORLD_KEY, id);
view.showWorld(map);
setStatus(`${map.name}${map.stats.buildings.toLocaleString()} buildings, ${map.chunks.length} chunks`);
elements.worldTitle.textContent = map.name;
elements.hud.textContent = '';
showGame();
setStatus('');
await refreshWorldList();
} catch (error) {
setStatus(`Could not open world: ${message(error)}`, 'error');
}
}
async function returnToMenu(): Promise<void> {
activeWorldId = null;
if (mapReady) view.clear();
elements.hud.textContent = '';
elements.worldTitle.textContent = '';
showMenu();
try {
await refreshWorldList();
setStatus('');
} catch (error) {
setStatus(`Could not reach the API: ${message(error)}`, 'error');
}
}
async function deleteWorld(world: WorldSummary): Promise<void> {
if (!confirm(`Delete "${world.name}"?`)) return;
@@ -125,8 +179,12 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
if (activeWorldId === world.id) {
activeWorldId = null;
localStorage.removeItem(LAST_WORLD_KEY);
view.clear();
if (mapReady) view.clear();
elements.hud.textContent = '';
elements.worldTitle.textContent = '';
showMenu();
} else if (localStorage.getItem(LAST_WORLD_KEY) === world.id) {
localStorage.removeItem(LAST_WORLD_KEY);
}
await refreshWorldList();
@@ -143,7 +201,8 @@ async function generate(event: SubmitEvent): Promise<void> {
const longitude = Number(elements.longitude.value);
const sizeKm = Number(elements.size.value);
elements.generate.disabled = true;
generating = true;
updateGenerateEnabled(Number.POSITIVE_INFINITY);
setStatus('Requesting world…', 'busy');
try {
@@ -170,7 +229,7 @@ async function generate(event: SubmitEvent): Promise<void> {
} catch (error) {
setStatus(`Generation failed: ${message(error)}`, 'error');
} finally {
elements.generate.disabled = false;
generating = false;
void refreshWorldList();
}
}
@@ -181,9 +240,11 @@ function message(error: unknown): string {
/** Applies a theme to both halves of the app: the Pixi map and the surrounding page chrome. */
function applyTheme(name: ThemeName): void {
view.setTheme(name);
if (mapReady) view.setTheme(name);
document.documentElement.dataset.theme = name;
elements.themeToggle.textContent = THEMES[name].dark ? '☀' : '☾';
const label = THEMES[name].dark ? '☀' : '☾';
elements.menuThemeToggle.textContent = label;
elements.gameThemeToggle.textContent = label;
localStorage.setItem(THEME_KEY, name);
}
@@ -194,6 +255,11 @@ function readStoredTheme(): ThemeName {
return matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'day';
}
function toggleTheme(): void {
const current = (document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme();
applyTheme(current === 'day' ? 'night' : 'day');
}
async function start(): Promise<void> {
elements.size.addEventListener('input', () => {
elements.sizeValue.textContent = `${elements.size.value} km`;
@@ -201,24 +267,19 @@ async function start(): Promise<void> {
elements.form.addEventListener('submit', (event) => {
void generate(event);
});
elements.themeToggle.addEventListener('click', () => {
applyTheme(view.themeName === 'day' ? 'night' : 'day');
elements.back.addEventListener('click', () => {
void returnToMenu();
});
elements.menuThemeToggle.addEventListener('click', toggleTheme);
elements.gameThemeToggle.addEventListener('click', toggleTheme);
view.onStatusChange = renderHud;
await view.init(elements.stage);
applyTheme(readStoredTheme());
showMenu();
try {
const worlds = await refreshWorldList();
const remembered = localStorage.getItem(LAST_WORLD_KEY);
const target =
worlds.find((world) => world.id === remembered && world.status === 'ready') ??
worlds.find((world) => world.status === 'ready');
if (target) await openWorld(target.id);
else setStatus('No worlds yet — generate one to get started.');
if (worlds.length === 0) setStatus('No worlds yet — create one to get started.');
else setStatus('Choose a world or create a new one.');
} catch (error) {
setStatus(`Could not reach the API: ${message(error)}`, 'error');
}
+123 -44
View File
@@ -41,9 +41,58 @@ body {
background: var(--page-bg);
}
#stage {
.screen[hidden] {
display: none !important;
}
.screen--menu {
position: fixed;
inset: 0;
overflow-y: auto;
padding: 32px 16px;
}
.menu {
width: min(420px, 100%);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 22px;
padding: 22px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(30, 35, 28, 0.14);
backdrop-filter: blur(6px);
}
.menu__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.menu__header h1 {
margin: 0;
font-size: 22px;
letter-spacing: 0.01em;
}
.menu__subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--text-muted);
}
.screen--game {
position: fixed;
inset: 0;
}
#stage {
position: absolute;
inset: 0;
}
#stage canvas {
@@ -55,37 +104,38 @@ body {
cursor: grabbing;
}
.panel {
position: fixed;
.game-bar {
position: absolute;
top: 16px;
left: 16px;
width: 320px;
max-height: calc(100vh - 32px);
right: 16px;
z-index: 2;
display: flex;
flex-direction: column;
gap: 18px;
padding: 18px;
overflow-y: auto;
align-items: center;
gap: 10px;
pointer-events: none;
}
.game-bar > * {
pointer-events: auto;
}
.game-bar__title {
flex: 1;
min-width: 0;
padding: 6px 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
color: var(--text);
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(30, 35, 28, 0.14);
border-radius: 8px;
backdrop-filter: blur(6px);
}
.panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.panel__header h1 {
margin: 0;
font-size: 17px;
letter-spacing: 0.01em;
}
.icon-button {
flex: none;
width: 30px;
@@ -104,18 +154,22 @@ body {
background: var(--surface-hover);
}
.panel__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
}
.form {
display: flex;
flex-direction: column;
gap: 12px;
}
.form__title,
.worlds__header h2 {
margin: 0;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.field {
display: flex;
flex-direction: column;
@@ -174,15 +228,25 @@ body {
.button:disabled {
opacity: 0.55;
cursor: progress;
cursor: not-allowed;
}
.worlds h2 {
margin: 0 0 8px;
.worlds {
display: flex;
flex-direction: column;
gap: 8px;
}
.worlds__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.slot-count {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
}
@@ -193,6 +257,16 @@ body {
margin: 0;
padding: 0;
list-style: none;
max-height: 240px;
overflow-y: auto;
}
.world-list:empty::after {
content: 'No worlds yet — create one below.';
display: block;
padding: 10px 0;
font-size: 12px;
color: var(--text-muted);
}
.world {
@@ -294,9 +368,10 @@ body {
}
.hud {
position: fixed;
position: absolute;
right: 16px;
bottom: 16px;
z-index: 2;
padding: 6px 10px;
font-size: 11px;
font-variant-numeric: tabular-nums;
@@ -308,12 +383,16 @@ body {
}
@media (max-width: 640px) {
.panel {
top: auto;
bottom: 12px;
left: 12px;
right: 12px;
width: auto;
max-height: 55vh;
.screen--menu {
padding: 12px;
}
.menu {
padding: 16px;
gap: 18px;
}
.world-list {
max-height: 180px;
}
}
@@ -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);
}
}