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:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user