Add start game time feature to world generation; update API and UI to support custom start dates for in-world calendar

This commit is contained in:
Leonid Pershin
2026-08-16 19:58:32 +03:00
parent 3b7fca1495
commit 9d1e3c29c7
12 changed files with 150 additions and 5 deletions
@@ -7,6 +7,7 @@ using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export; using TheLivingWorld.Core.Export;
using TheLivingWorld.Core.Geo; using TheLivingWorld.Core.Geo;
using TheLivingWorld.Core.Simulation;
using TheLivingWorld.Core.Worlds; using TheLivingWorld.Core.Worlds;
using TheLivingWorld.Osm.Import; using TheLivingWorld.Osm.Import;
@@ -46,6 +47,16 @@ public sealed class WorldGenerationService(
if (!double.IsFinite(request.SizeKm) || request.SizeKm is < MinSizeKm or > MaxSizeKm) if (!double.IsFinite(request.SizeKm) || request.SizeKm is < MinSizeKm or > MaxSizeKm)
throw new ArgumentException($"Size must be between {MinSizeKm} and {MaxSizeKm} km."); throw new ArgumentException($"Size must be between {MinSizeKm} and {MaxSizeKm} km.");
DateTime startGameTime;
try
{
startGameTime = GameTime.ResolveStart(request.StartGameTime);
}
catch (ArgumentOutOfRangeException ex)
{
throw new ArgumentException(ex.Message, ex);
}
var name = string.IsNullOrWhiteSpace(request.Name) var name = string.IsNullOrWhiteSpace(request.Name)
? $"{origin.Latitude:F4}, {origin.Longitude:F4}" ? $"{origin.Latitude:F4}, {origin.Longitude:F4}"
: request.Name.Trim(); : request.Name.Trim();
@@ -61,7 +72,7 @@ public sealed class WorldGenerationService(
Status = WorldStatus.Pending, Status = WorldStatus.Pending,
Stage = "Queued", Stage = "Queued",
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
Clock = WorldSimulation.DefaultClock(), Clock = WorldSimulation.DefaultClock(startGameTime),
}; };
await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false); await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -70,9 +70,9 @@ public sealed class WorldSimulation : IDisposable
return simulation; return simulation;
} }
public static WorldClockDto DefaultClock() => new() public static WorldClockDto DefaultClock(DateTime? startGameTime = null) => new()
{ {
GameTime = GameTime.DefaultStart, GameTime = GameTime.ResolveStart(startGameTime),
TimeScale = GameTime.MinTimeScale, TimeScale = GameTime.MinTimeScale,
Paused = false, Paused = false,
}; };
@@ -22,6 +22,11 @@ public sealed record CreateWorldRequest
/// <summary>Re-download from Overpass even if a cached response for this box exists.</summary> /// <summary>Re-download from Overpass even if a cached response for this box exists.</summary>
public bool ForceRefresh { get; init; } public bool ForceRefresh { get; init; }
/// <summary>
/// Naive in-world calendar start. When omitted, defaults to 12 April 2012 06:00.
/// </summary>
public DateTime? StartGameTime { get; init; }
} }
/// <summary>Response body for <c>GET /api/worlds</c>.</summary> /// <summary>Response body for <c>GET /api/worlds</c>.</summary>
@@ -28,4 +28,20 @@ public static class GameTime
public static bool IsValidTimeScale(int timeScale) => public static bool IsValidTimeScale(int timeScale) =>
timeScale is >= MinTimeScale and <= MaxTimeScale; timeScale is >= MinTimeScale and <= MaxTimeScale;
/// <summary>
/// Normalises a requested start to a naive local calendar instant. Null → <see cref="DefaultStart"/>.
/// Rejects values outside a practical game range (year 19998).
/// </summary>
public static DateTime ResolveStart(DateTime? requested)
{
if (requested is null) return DefaultStart;
var value = DateTime.SpecifyKind(requested.Value, DateTimeKind.Unspecified);
if (value.Year is < 1 or > 9998)
throw new ArgumentOutOfRangeException(nameof(requested), "Start game time year must be between 1 and 9998.");
// Drop sub-minute noise — the clock displays minutes only.
return new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, DateTimeKind.Unspecified);
}
} }
+10
View File
@@ -68,6 +68,16 @@
<input id="field-size" type="range" min="1" max="20" step="1" value="10" /> <input id="field-size" type="range" min="1" max="20" step="1" value="10" />
</label> </label>
<label class="field">
<span>Start date &amp; time</span>
<input
id="field-start"
type="datetime-local"
value="2012-04-12T06:00"
required
/>
</label>
<p id="form-hint" class="form__hint" hidden></p> <p id="form-hint" class="form__hint" hidden></p>
<button id="generate-button" type="submit" class="button">Generate world</button> <button id="generate-button" type="submit" class="button">Generate world</button>
</form> </form>
+2
View File
@@ -48,6 +48,8 @@ export interface CreateWorldRequest {
longitude: number; longitude: number;
sizeKm: number; sizeKm: number;
forceRefresh?: boolean; forceRefresh?: boolean;
/** Naive local game calendar start, e.g. `2012-04-12T06:00:00`. Omits → server default. */
startGameTime?: string;
} }
/** [minX, minY, maxX, maxY] in world metres. */ /** [minX, minY, maxX, maxY] in world metres. */
+15 -1
View File
@@ -4,7 +4,12 @@ import type { WorldClock, WorldSummary } from './api/types';
import { MapView, type MapStatus } from './map/mapView'; import { MapView, type MapStatus } from './map/mapView';
import { THEMES, type ThemeName } from './map/theme'; import { THEMES, type ThemeName } from './map/theme';
import { formatCoordinates, parseCoordinates } from './ui/coordinates'; import { formatCoordinates, parseCoordinates } from './ui/coordinates';
import { formatGameTime, formatGameTimeRaw, interpolateGameTime } from './ui/gameTime'; import {
formatGameTime,
formatGameTimeRaw,
interpolateGameTime,
startGameTimeFromInput,
} from './ui/gameTime';
const LAST_WORLD_KEY = 'the-living-world:last-world'; const LAST_WORLD_KEY = 'the-living-world:last-world';
const THEME_KEY = 'the-living-world:theme'; const THEME_KEY = 'the-living-world:theme';
@@ -21,6 +26,7 @@ const elements = {
coords: required<HTMLInputElement>('field-coords'), coords: required<HTMLInputElement>('field-coords'),
size: required<HTMLInputElement>('field-size'), size: required<HTMLInputElement>('field-size'),
sizeValue: required<HTMLOutputElement>('field-size-value'), sizeValue: required<HTMLOutputElement>('field-size-value'),
start: required<HTMLInputElement>('field-start'),
generate: required<HTMLButtonElement>('generate-button'), generate: required<HTMLButtonElement>('generate-button'),
formHint: required<HTMLElement>('form-hint'), formHint: required<HTMLElement>('form-hint'),
useLocation: required<HTMLButtonElement>('use-location'), useLocation: required<HTMLButtonElement>('use-location'),
@@ -446,6 +452,13 @@ async function generate(event: SubmitEvent): Promise<void> {
return; return;
} }
const startGameTime = startGameTimeFromInput(elements.start.value);
if (!startGameTime) {
setStatus('Pick a start date and time for the in-world calendar.', 'error');
elements.start.focus();
return;
}
const sizeKm = Number(elements.size.value); const sizeKm = Number(elements.size.value);
generating = true; generating = true;
updateGenerateEnabled(); updateGenerateEnabled();
@@ -457,6 +470,7 @@ async function generate(event: SubmitEvent): Promise<void> {
latitude: location.latitude, latitude: location.latitude,
longitude: location.longitude, longitude: location.longitude,
sizeKm, sizeKm,
startGameTime,
}); });
await refreshWorldList(); await refreshWorldList();
+2 -1
View File
@@ -323,7 +323,8 @@ body {
} }
.field input[type='text'], .field input[type='text'],
.field input[type='number'] { .field input[type='number'],
.field input[type='datetime-local'] {
padding: 7px 9px; padding: 7px 9px;
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
@@ -5,6 +5,8 @@ import {
GAME_MINUTES_PER_REAL_SECOND, GAME_MINUTES_PER_REAL_SECOND,
interpolateGameTime, interpolateGameTime,
parseGameTime, parseGameTime,
startGameTimeFromInput,
DEFAULT_START_INPUT,
} from './gameTime'; } from './gameTime';
describe('parseGameTime', () => { describe('parseGameTime', () => {
@@ -64,3 +66,12 @@ describe('interpolateGameTime', () => {
expect(formatGameTime(date!)).toBe('12 April 2012 · 06:10'); expect(formatGameTime(date!)).toBe('12 April 2012 · 06:10');
}); });
}); });
describe('startGameTimeFromInput', () => {
it('converts a datetime-local value to naive ISO', () => {
expect(startGameTimeFromInput('2012-04-12T06:00')).toBe('2012-04-12T06:00:00');
expect(startGameTimeFromInput(DEFAULT_START_INPUT)).toBe('2012-04-12T06:00:00');
expect(startGameTimeFromInput('')).toBeNull();
expect(startGameTimeFromInput('not-a-date')).toBeNull();
});
});
+36
View File
@@ -68,3 +68,39 @@ export function interpolateGameTime(
const gameMs = (elapsedRealMs / 1000) * GAME_MINUTES_PER_REAL_SECOND * scale * 60_000; const gameMs = (elapsedRealMs / 1000) * GAME_MINUTES_PER_REAL_SECOND * scale * 60_000;
return new Date(base.getTime() + gameMs); return new Date(base.getTime() + gameMs);
} }
/** Default start shown in the create form (`datetime-local` value). */
export const DEFAULT_START_INPUT = '2012-04-12T06:00';
/**
* Converts a `datetime-local` value (`YYYY-MM-DDTHH:mm`) into the naive ISO string the API expects.
* Returns null when the field is empty or malformed.
*/
export function startGameTimeFromInput(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(trimmed);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const second = Number(match[6] ?? '0');
const date = new Date(year, month - 1, day, hour, minute, second);
if (
date.getFullYear() !== year
|| date.getMonth() !== month - 1
|| date.getDate() !== day
|| date.getHours() !== hour
|| date.getMinutes() !== minute
) {
return null;
}
const pad = (n: number) => String(n).padStart(2, '0');
return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:00`;
}
@@ -58,4 +58,20 @@ public sealed class GameTimeTests
{ {
Assert.Equal(expected, GameTime.IsValidTimeScale(scale)); Assert.Equal(expected, GameTime.IsValidTimeScale(scale));
} }
[Fact]
public void ResolveStart_defaults_and_normalises()
{
Assert.Equal(GameTime.DefaultStart, GameTime.ResolveStart(null));
Assert.Equal(
new DateTime(1999, 12, 31, 23, 45, 0, DateTimeKind.Unspecified),
GameTime.ResolveStart(new DateTime(1999, 12, 31, 23, 45, 59, DateTimeKind.Utc)));
}
[Fact]
public void ResolveStart_rejects_out_of_range_years()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
GameTime.ResolveStart(new DateTime(10000, 1, 1)));
}
} }
@@ -45,6 +45,29 @@ public sealed class WorldGenerationServiceTests : IDisposable
Assert.Equal("Taken", Assert.Single(await _store.ListAsync()).Name); Assert.Equal("Taken", Assert.Single(await _store.ListAsync()).Name);
} }
[Fact]
public async Task StartAsync_uses_requested_start_game_time_on_the_pending_clock()
{
using var service = CreateService(maxConcurrentWorlds: 2);
var start = new DateTime(1995, 6, 15, 8, 30, 0, DateTimeKind.Unspecified);
var summary = await service.StartAsync(new CreateWorldRequest
{
Name = "Custom clock",
Latitude = 31.8966010,
Longitude = -100.4858591,
SizeKm = 5,
StartGameTime = start,
}, CancellationToken.None);
Assert.NotNull(summary.Clock);
Assert.Equal(start, summary.Clock.GameTime);
var stored = await _store.GetSummaryAsync(summary.Id);
Assert.NotNull(stored?.Clock);
Assert.Equal(start, stored.Clock.GameTime);
}
private WorldGenerationService CreateService(int maxConcurrentWorlds) private WorldGenerationService CreateService(int maxConcurrentWorlds)
{ {
// The capacity check runs before any Overpass work, so this generator is never invoked by the // The capacity check runs before any Overpass work, so this generator is never invoked by the