diff --git a/README.md b/README.md index aca533f..29eafe3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs index 96f4a72..beb9804 100644 --- a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs +++ b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs @@ -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 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 GetWorld( diff --git a/src/TheLivingWorld.Api/Generation/WorldCapacityExceededException.cs b/src/TheLivingWorld.Api/Generation/WorldCapacityExceededException.cs new file mode 100644 index 0000000..8ad8936 --- /dev/null +++ b/src/TheLivingWorld.Api/Generation/WorldCapacityExceededException.cs @@ -0,0 +1,13 @@ +namespace TheLivingWorld.Api.Generation; + +/// Raised when creating a world would exceed WorldStorage:MaxConcurrentWorlds. +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; } +} diff --git a/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs index 98b93de..630e72d 100644 --- a/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs +++ b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs @@ -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 storageOptions, IHostApplicationLifetime lifetime, ILogger logger) : IDisposable { @@ -28,6 +30,9 @@ public sealed class WorldGenerationService( /// Overpass mirrors are shared infrastructure, so only one download runs at a time. private readonly SemaphoreSlim _gate = new(1, 1); + /// Serialises capacity checks against concurrent create requests. + private readonly SemaphoreSlim _capacityGate = new(1, 1); + private readonly ConcurrentDictionary _inFlight = new(); public async Task StartAsync(CreateWorldRequest request, CancellationToken cancellationToken) @@ -56,8 +61,24 @@ public sealed class WorldGenerationService( CreatedAt = DateTimeOffset.UtcNow, }; - _inFlight[id] = summary; - await store.SaveSummaryAsync(summary, cancellationToken).ConfigureAwait(false); + 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( /// Returns the live status of a generation still in progress, if there is one. 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 callback) : IProgress { diff --git a/src/TheLivingWorld.Api/Storage/WorldStorageOptions.cs b/src/TheLivingWorld.Api/Storage/WorldStorageOptions.cs index 639559c..edc1169 100644 --- a/src/TheLivingWorld.Api/Storage/WorldStorageOptions.cs +++ b/src/TheLivingWorld.Api/Storage/WorldStorageOptions.cs @@ -6,4 +6,10 @@ public sealed class WorldStorageOptions /// Where generated worlds live, relative to the content root unless rooted. public string RootDirectory { get; set; } = "data/worlds"; + + /// + /// 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. + /// + public int MaxConcurrentWorlds { get; set; } = 8; } diff --git a/src/TheLivingWorld.Api/Storage/WorldStore.cs b/src/TheLivingWorld.Api/Storage/WorldStore.cs index 16dd5f4..3bd59e4 100644 --- a/src/TheLivingWorld.Api/Storage/WorldStore.cs +++ b/src/TheLivingWorld.Api/Storage/WorldStore.cs @@ -44,6 +44,25 @@ public sealed class WorldStore(IOptions options, ILoggerNumber of worlds that occupy a storage slot (any status with a readable state file). + public async Task 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 GetSummaryAsync(string id, CancellationToken cancellationToken = default) { var path = Path.Combine(WorldDirectory(id), StateFileName); diff --git a/src/TheLivingWorld.Api/appsettings.json b/src/TheLivingWorld.Api/appsettings.json index 95ee22a..5af7728 100644 --- a/src/TheLivingWorld.Api/appsettings.json +++ b/src/TheLivingWorld.Api/appsettings.json @@ -7,7 +7,8 @@ }, "AllowedHosts": "*", "WorldStorage": { - "RootDirectory": "data/worlds" + "RootDirectory": "data/worlds", + "MaxConcurrentWorlds": 8 }, "Osm": { "Endpoints": [ diff --git a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs index 5ca27c8..f090efa 100644 --- a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs +++ b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs @@ -24,6 +24,15 @@ public sealed record CreateWorldRequest public bool ForceRefresh { get; init; } } +/// Response body for GET /api/worlds. +public sealed record WorldListDto +{ + public required IReadOnlyList Worlds { get; init; } + + /// Server-wide slot budget; see WorldStorage:MaxConcurrentWorlds. + public required int MaxConcurrentWorlds { get; init; } +} + /// A world as it appears in listings and while generation is still running. public sealed record WorldSummaryDto { diff --git a/src/TheLivingWorld.Web/index.html b/src/TheLivingWorld.Web/index.html index 7ccbba2..c243068 100644 --- a/src/TheLivingWorld.Web/index.html +++ b/src/TheLivingWorld.Web/index.html @@ -7,53 +7,72 @@ -
+ + + diff --git a/src/TheLivingWorld.Web/src/api/client.ts b/src/TheLivingWorld.Web/src/api/client.ts index d46e954..2058dc0 100644 --- a/src/TheLivingWorld.Web/src/api/client.ts +++ b/src/TheLivingWorld.Web/src/api/client.ts @@ -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(url: string, init?: RequestInit): Promise { async function describeFailure(response: Response): Promise { try { - const problem = (await response.json()) as { title?: string; errors?: Record }; + const problem = (await response.json()) as { + title?: string; + detail?: string; + errors?: Record; + }; 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 { } export const api = { - listWorlds: () => request(BASE), + listWorlds: () => request(BASE), createWorld: (body: CreateWorldRequest) => request(BASE, { diff --git a/src/TheLivingWorld.Web/src/api/types.ts b/src/TheLivingWorld.Web/src/api/types.ts index de9cf30..5cd5b4e 100644 --- a/src/TheLivingWorld.Web/src/api/types.ts +++ b/src/TheLivingWorld.Web/src/api/types.ts @@ -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; diff --git a/src/TheLivingWorld.Web/src/main.ts b/src/TheLivingWorld.Web/src/main.ts index 139832a..1585f0c 100644 --- a/src/TheLivingWorld.Web/src/main.ts +++ b/src/TheLivingWorld.Web/src/main.ts @@ -8,6 +8,8 @@ const LAST_WORLD_KEY = 'the-living-world:last-world'; const THEME_KEY = 'the-living-world:theme'; const elements = { + menu: required('menu'), + game: required('game'), stage: required('stage'), form: required('generate-form'), name: required('field-name'), @@ -17,13 +19,20 @@ const elements = { sizeValue: required('field-size-value'), generate: required('generate-button'), worldList: required('world-list'), + slotCount: required('slot-count'), status: required('status'), hud: required('hud'), - themeToggle: required('theme-toggle'), + worldTitle: required('world-title'), + back: required('back-button'), + menuThemeToggle: required('menu-theme-toggle'), + gameThemeToggle: required('game-theme-toggle'), }; const view = new MapView(); let activeWorldId: string | null = null; +let maxConcurrentWorlds = 8; +let mapReady = false; +let generating = false; function required(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 { - 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 { + if (mapReady) return; + await view.init(elements.stage); + view.onStatusChange = renderHud; + mapReady = true; + applyTheme(readStoredTheme()); +} + async function openWorld(id: string): Promise { 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 { + 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 { if (!confirm(`Delete "${world.name}"?`)) return; @@ -125,8 +179,12 @@ async function deleteWorld(world: WorldSummary): Promise { 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 { 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 { } 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 { elements.size.addEventListener('input', () => { elements.sizeValue.textContent = `${elements.size.value} km`; @@ -201,24 +267,19 @@ async function start(): Promise { 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'); } diff --git a/src/TheLivingWorld.Web/src/styles.css b/src/TheLivingWorld.Web/src/styles.css index 2a883af..3dda4b3 100644 --- a/src/TheLivingWorld.Web/src/styles.css +++ b/src/TheLivingWorld.Web/src/styles.css @@ -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; } } diff --git a/tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj b/tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj index 07966ea..3d7ed22 100644 --- a/tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj +++ b/tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj @@ -21,6 +21,7 @@ + \ No newline at end of file diff --git a/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs b/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs new file mode 100644 index 0000000..8c9762f --- /dev/null +++ b/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs @@ -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.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(() => + 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.Instance), + new OsmWorldBuilder(NullLogger.Instance), + NullLogger.Instance); + + return new WorldGenerationService( + generator, + _store, + new ChunkExporter(), + Options.Create(new WorldStorageOptions + { + RootDirectory = _root, + MaxConcurrentWorlds = maxConcurrentWorlds, + }), + new NeverStoppingLifetime(), + NullLogger.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 SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Overpass must not be contacted by these tests."); + } +} diff --git a/tests/TheLivingWorld.Tests/WorldStoreTests.cs b/tests/TheLivingWorld.Tests/WorldStoreTests.cs new file mode 100644 index 0000000..813f7ec --- /dev/null +++ b/tests/TheLivingWorld.Tests/WorldStoreTests.cs @@ -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.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); + } +}