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 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 The default coordinates are Robert Lee, Texas (`31.8966010, -100.4858591`) — a small town that generates in a
few seconds. few seconds.
@@ -102,8 +103,8 @@ them without reworking the data model.
| Endpoint | Purpose | | Endpoint | Purpose |
| --- | --- | | --- | --- |
| `POST /api/worlds` | Start generating a world. Returns immediately with `status: "pending"` | | `POST /api/worlds` | Start generating a world. Returns immediately with `status: "pending"`; `409` when the slot budget is full |
| `GET /api/worlds` | List worlds, with live status for anything still generating | | `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}` | Status of one world |
| `GET /api/worlds/{id}/map` | Metadata plus the chunk index | | `GET /api/worlds/{id}/map` | Metadata plus the chunk index |
| `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry | | `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 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. 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 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 `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 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 `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. 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`: `src/TheLivingWorld.Api/appsettings.json`:
- `WorldStorage:RootDirectory` — where generated worlds go (default `data/worlds`) - `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:Endpoints` — Overpass mirrors, tried in order
- `Osm:CacheDirectory` — raw Overpass responses (default `data/osm-cache`) - `Osm:CacheDirectory` — raw Overpass responses (default `data/osm-cache`)
- `Osm:QueryTimeoutSeconds` / `Osm:RequestTimeoutSeconds` — server-side and client-side budgets - `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) .Select(summary => generation.GetInFlight(summary.Id) ?? summary)
.ToArray(); .ToArray();
return Results.Ok(merged); return Results.Ok(new WorldListDto
{
Worlds = merged,
MaxConcurrentWorlds = generation.MaxConcurrentWorlds,
});
} }
private static async Task<IResult> CreateWorld( private static async Task<IResult> CreateWorld(
@@ -52,6 +56,15 @@ public static class WorldEndpoints
["request"] = [ex.Message], ["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( 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.Collections.Concurrent;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using Microsoft.Extensions.Options;
using TheLivingWorld.Api.Storage; using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export; using TheLivingWorld.Core.Export;
@@ -19,6 +20,7 @@ public sealed class WorldGenerationService(
OsmWorldGenerator generator, OsmWorldGenerator generator,
WorldStore store, WorldStore store,
ChunkExporter exporter, ChunkExporter exporter,
IOptions<WorldStorageOptions> storageOptions,
IHostApplicationLifetime lifetime, IHostApplicationLifetime lifetime,
ILogger<WorldGenerationService> logger) : IDisposable 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> /// <summary>Overpass mirrors are shared infrastructure, so only one download runs at a time.</summary>
private readonly SemaphoreSlim _gate = new(1, 1); 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(); private readonly ConcurrentDictionary<string, WorldSummaryDto> _inFlight = new();
public async Task<WorldSummaryDto> StartAsync(CreateWorldRequest request, CancellationToken cancellationToken) public async Task<WorldSummaryDto> StartAsync(CreateWorldRequest request, CancellationToken cancellationToken)
@@ -56,8 +61,24 @@ public sealed class WorldGenerationService(
CreatedAt = DateTimeOffset.UtcNow, 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; _inFlight[id] = summary;
await store.SaveSummaryAsync(summary, cancellationToken).ConfigureAwait(false); await store.SaveSummaryAsync(summary, cancellationToken).ConfigureAwait(false);
}
finally
{
_capacityGate.Release();
}
// Detached on purpose: the caller gets the pending world back straight away. // Detached on purpose: the caller gets the pending world back straight away.
_ = Task.Run(() => RunAsync(summary, request.ForceRefresh), CancellationToken.None); _ = 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> /// <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 WorldSummaryDto? GetInFlight(string id) => _inFlight.GetValueOrDefault(id);
public int MaxConcurrentWorlds => storageOptions.Value.MaxConcurrentWorlds;
private async Task RunAsync(WorldSummaryDto summary, bool forceRefresh) private async Task RunAsync(WorldSummaryDto summary, bool forceRefresh)
{ {
// Generation should stop when the host does, not drag shutdown out for minutes. // 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]}"; 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> 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> /// <summary>Where generated worlds live, relative to the content root unless rooted.</summary>
public string RootDirectory { get; set; } = "data/worlds"; 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; 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) public async Task<WorldSummaryDto?> GetSummaryAsync(string id, CancellationToken cancellationToken = default)
{ {
var path = Path.Combine(WorldDirectory(id), StateFileName); var path = Path.Combine(WorldDirectory(id), StateFileName);
+2 -1
View File
@@ -7,7 +7,8 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"WorldStorage": { "WorldStorage": {
"RootDirectory": "data/worlds" "RootDirectory": "data/worlds",
"MaxConcurrentWorlds": 8
}, },
"Osm": { "Osm": {
"Endpoints": [ "Endpoints": [
@@ -24,6 +24,15 @@ public sealed record CreateWorldRequest
public bool ForceRefresh { get; init; } 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> /// <summary>A world as it appears in listings and while generation is still running.</summary>
public sealed record WorldSummaryDto public sealed record WorldSummaryDto
{ {
+32 -13
View File
@@ -7,20 +7,29 @@
<link rel="icon" href="data:," /> <link rel="icon" href="data:," />
</head> </head>
<body> <body>
<div id="stage"></div> <div id="menu" class="screen screen--menu">
<div class="menu">
<aside id="panel" class="panel"> <header class="menu__header">
<header class="panel__header"> <div class="menu__titles">
<div class="panel__titles">
<h1>The Living World</h1> <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> </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> </button>
</header> </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"> <form id="generate-form" class="form">
<h2 class="form__title">Create world</h2>
<label class="field"> <label class="field">
<span>Name</span> <span>Name</span>
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" /> <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> <button id="generate-button" type="submit" class="button">Generate world</button>
</form> </form>
<section class="worlds">
<h2>Worlds</h2>
<ul id="world-list" class="world-list"></ul>
</section>
<footer id="status" class="status"></footer> <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 id="hud" class="hud"></div>
</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </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'; 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> { async function describeFailure(response: Response): Promise<string> {
try { 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; const details = problem.errors ? Object.values(problem.errors).flat().join('; ') : undefined;
if (details) return details; if (details) return details;
if (problem.detail) return problem.detail;
if (problem.title) return problem.title; if (problem.title) return problem.title;
} catch { } catch {
// Not a problem-details body; fall through to the status line. // 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 = { export const api = {
listWorlds: () => request<WorldSummary[]>(BASE), listWorlds: () => request<WorldList>(BASE),
createWorld: (body: CreateWorldRequest) => createWorld: (body: CreateWorldRequest) =>
request<WorldSummary>(BASE, { request<WorldSummary>(BASE, {
+6
View File
@@ -23,6 +23,12 @@ export interface WorldSummary {
stats?: WorldStats; stats?: WorldStats;
} }
/** Response body for GET /api/worlds. */
export interface WorldList {
worlds: WorldSummary[];
maxConcurrentWorlds: number;
}
export interface CreateWorldRequest { export interface CreateWorldRequest {
name?: string; name?: string;
latitude: number; 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 THEME_KEY = 'the-living-world:theme';
const elements = { const elements = {
menu: required<HTMLDivElement>('menu'),
game: required<HTMLDivElement>('game'),
stage: required<HTMLDivElement>('stage'), stage: required<HTMLDivElement>('stage'),
form: required<HTMLFormElement>('generate-form'), form: required<HTMLFormElement>('generate-form'),
name: required<HTMLInputElement>('field-name'), name: required<HTMLInputElement>('field-name'),
@@ -17,13 +19,20 @@ const elements = {
sizeValue: required<HTMLOutputElement>('field-size-value'), sizeValue: required<HTMLOutputElement>('field-size-value'),
generate: required<HTMLButtonElement>('generate-button'), generate: required<HTMLButtonElement>('generate-button'),
worldList: required<HTMLUListElement>('world-list'), worldList: required<HTMLUListElement>('world-list'),
slotCount: required<HTMLElement>('slot-count'),
status: required<HTMLElement>('status'), status: required<HTMLElement>('status'),
hud: required<HTMLDivElement>('hud'), 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(); const view = new MapView();
let activeWorldId: string | null = null; let activeWorldId: string | null = null;
let maxConcurrentWorlds = 8;
let mapReady = false;
let generating = false;
function required<T extends HTMLElement>(id: string): T { function required<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id); const element = document.getElementById(id);
@@ -36,6 +45,16 @@ function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): v
elements.status.dataset.tone = tone; 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 { function renderHud(status: MapStatus): void {
const scale = status.metersPerPixel >= 10 const scale = status.metersPerPixel >= 10
? `${Math.round(status.metersPerPixel)} m/px` ? `${Math.round(status.metersPerPixel)} m/px`
@@ -47,17 +66,26 @@ function renderHud(status: MapStatus): void {
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`; 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[]> { async function refreshWorldList(): Promise<WorldSummary[]> {
const worlds = await api.listWorlds(); const list = await api.listWorlds();
elements.worldList.replaceChildren(...worlds.map(renderWorldItem)); maxConcurrentWorlds = list.maxConcurrentWorlds;
return worlds; 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 { function renderWorldItem(world: WorldSummary): HTMLLIElement {
const item = document.createElement('li'); const item = document.createElement('li');
item.className = 'world'; item.className = 'world';
item.dataset.status = world.status; 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'); const open = document.createElement('button');
open.type = '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`; 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> { async function openWorld(id: string): Promise<void> {
try { try {
setStatus('Loading map…', 'busy'); setStatus('Loading map…', 'busy');
await ensureMap();
const map = await api.getMap(id); const map = await api.getMap(id);
activeWorldId = id; activeWorldId = id;
localStorage.setItem(LAST_WORLD_KEY, id); localStorage.setItem(LAST_WORLD_KEY, id);
view.showWorld(map); 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(); await refreshWorldList();
} catch (error) { } catch (error) {
setStatus(`Could not open world: ${message(error)}`, '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> { async function deleteWorld(world: WorldSummary): Promise<void> {
if (!confirm(`Delete "${world.name}"?`)) return; if (!confirm(`Delete "${world.name}"?`)) return;
@@ -125,8 +179,12 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
if (activeWorldId === world.id) { if (activeWorldId === world.id) {
activeWorldId = null; activeWorldId = null;
localStorage.removeItem(LAST_WORLD_KEY); localStorage.removeItem(LAST_WORLD_KEY);
view.clear(); if (mapReady) view.clear();
elements.hud.textContent = ''; elements.hud.textContent = '';
elements.worldTitle.textContent = '';
showMenu();
} else if (localStorage.getItem(LAST_WORLD_KEY) === world.id) {
localStorage.removeItem(LAST_WORLD_KEY);
} }
await refreshWorldList(); await refreshWorldList();
@@ -143,7 +201,8 @@ async function generate(event: SubmitEvent): Promise<void> {
const longitude = Number(elements.longitude.value); const longitude = Number(elements.longitude.value);
const sizeKm = Number(elements.size.value); const sizeKm = Number(elements.size.value);
elements.generate.disabled = true; generating = true;
updateGenerateEnabled(Number.POSITIVE_INFINITY);
setStatus('Requesting world…', 'busy'); setStatus('Requesting world…', 'busy');
try { try {
@@ -170,7 +229,7 @@ async function generate(event: SubmitEvent): Promise<void> {
} catch (error) { } catch (error) {
setStatus(`Generation failed: ${message(error)}`, 'error'); setStatus(`Generation failed: ${message(error)}`, 'error');
} finally { } finally {
elements.generate.disabled = false; generating = false;
void refreshWorldList(); 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. */ /** Applies a theme to both halves of the app: the Pixi map and the surrounding page chrome. */
function applyTheme(name: ThemeName): void { function applyTheme(name: ThemeName): void {
view.setTheme(name); if (mapReady) view.setTheme(name);
document.documentElement.dataset.theme = 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); localStorage.setItem(THEME_KEY, name);
} }
@@ -194,6 +255,11 @@ function readStoredTheme(): ThemeName {
return matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'day'; 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> { async function start(): Promise<void> {
elements.size.addEventListener('input', () => { elements.size.addEventListener('input', () => {
elements.sizeValue.textContent = `${elements.size.value} km`; elements.sizeValue.textContent = `${elements.size.value} km`;
@@ -201,24 +267,19 @@ async function start(): Promise<void> {
elements.form.addEventListener('submit', (event) => { elements.form.addEventListener('submit', (event) => {
void generate(event); void generate(event);
}); });
elements.back.addEventListener('click', () => {
elements.themeToggle.addEventListener('click', () => { void returnToMenu();
applyTheme(view.themeName === 'day' ? 'night' : 'day');
}); });
elements.menuThemeToggle.addEventListener('click', toggleTheme);
elements.gameThemeToggle.addEventListener('click', toggleTheme);
view.onStatusChange = renderHud;
await view.init(elements.stage);
applyTheme(readStoredTheme()); applyTheme(readStoredTheme());
showMenu();
try { try {
const worlds = await refreshWorldList(); const worlds = await refreshWorldList();
const remembered = localStorage.getItem(LAST_WORLD_KEY); if (worlds.length === 0) setStatus('No worlds yet — create one to get started.');
const target = else setStatus('Choose a world or create a new one.');
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.');
} catch (error) { } catch (error) {
setStatus(`Could not reach the API: ${message(error)}`, 'error'); setStatus(`Could not reach the API: ${message(error)}`, 'error');
} }
+123 -44
View File
@@ -41,9 +41,58 @@ body {
background: var(--page-bg); background: var(--page-bg);
} }
#stage { .screen[hidden] {
display: none !important;
}
.screen--menu {
position: fixed; position: fixed;
inset: 0; 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 { #stage canvas {
@@ -55,37 +104,38 @@ body {
cursor: grabbing; cursor: grabbing;
} }
.panel { .game-bar {
position: fixed; position: absolute;
top: 16px; top: 16px;
left: 16px; left: 16px;
width: 320px; right: 16px;
max-height: calc(100vh - 32px); z-index: 2;
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 18px; gap: 10px;
padding: 18px; pointer-events: none;
overflow-y: auto; }
.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); background: var(--panel-bg);
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 12px; border-radius: 8px;
box-shadow: 0 10px 30px rgba(30, 35, 28, 0.14);
backdrop-filter: blur(6px); 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 { .icon-button {
flex: none; flex: none;
width: 30px; width: 30px;
@@ -104,18 +154,22 @@ body {
background: var(--surface-hover); background: var(--surface-hover);
} }
.panel__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
}
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; 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 { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -174,15 +228,25 @@ body {
.button:disabled { .button:disabled {
opacity: 0.55; opacity: 0.55;
cursor: progress; cursor: not-allowed;
} }
.worlds h2 { .worlds {
margin: 0 0 8px; 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-size: 12px;
font-weight: 600; font-variant-numeric: tabular-nums;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -193,6 +257,16 @@ body {
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; 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 { .world {
@@ -294,9 +368,10 @@ body {
} }
.hud { .hud {
position: fixed; position: absolute;
right: 16px; right: 16px;
bottom: 16px; bottom: 16px;
z-index: 2;
padding: 6px 10px; padding: 6px 10px;
font-size: 11px; font-size: 11px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -308,12 +383,16 @@ body {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.panel { .screen--menu {
top: auto; padding: 12px;
bottom: 12px; }
left: 12px;
right: 12px; .menu {
width: auto; padding: 16px;
max-height: 55vh; gap: 18px;
}
.world-list {
max-height: 180px;
} }
} }
@@ -21,6 +21,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\TheLivingWorld.Core\TheLivingWorld.Core.csproj" /> <ProjectReference Include="..\..\src\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
<ProjectReference Include="..\..\src\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj" /> <ProjectReference Include="..\..\src\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj" />
<ProjectReference Include="..\..\src\TheLivingWorld.Api\TheLivingWorld.Api.csproj" />
</ItemGroup> </ItemGroup>
</Project> </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);
}
}