diff --git a/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs index 4e29d2a..b5b1cf5 100644 --- a/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs +++ b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs @@ -7,6 +7,7 @@ using TheLivingWorld.Api.Storage; using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Export; using TheLivingWorld.Core.Geo; +using TheLivingWorld.Core.Simulation; using TheLivingWorld.Core.Worlds; using TheLivingWorld.Osm.Import; @@ -46,6 +47,16 @@ public sealed class WorldGenerationService( if (!double.IsFinite(request.SizeKm) || request.SizeKm is < MinSizeKm or > MaxSizeKm) 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) ? $"{origin.Latitude:F4}, {origin.Longitude:F4}" : request.Name.Trim(); @@ -61,7 +72,7 @@ public sealed class WorldGenerationService( Status = WorldStatus.Pending, Stage = "Queued", CreatedAt = DateTimeOffset.UtcNow, - Clock = WorldSimulation.DefaultClock(), + Clock = WorldSimulation.DefaultClock(startGameTime), }; await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs index 83f1aa8..e73f665 100644 --- a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs +++ b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs @@ -70,9 +70,9 @@ public sealed class WorldSimulation : IDisposable 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, Paused = false, }; diff --git a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs index 0f610ba..921f097 100644 --- a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs +++ b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs @@ -22,6 +22,11 @@ public sealed record CreateWorldRequest /// Re-download from Overpass even if a cached response for this box exists. public bool ForceRefresh { get; init; } + + /// + /// Naive in-world calendar start. When omitted, defaults to 12 April 2012 06:00. + /// + public DateTime? StartGameTime { get; init; } } /// Response body for GET /api/worlds. diff --git a/src/TheLivingWorld.Core/Simulation/GameTime.cs b/src/TheLivingWorld.Core/Simulation/GameTime.cs index e25816c..9904c65 100644 --- a/src/TheLivingWorld.Core/Simulation/GameTime.cs +++ b/src/TheLivingWorld.Core/Simulation/GameTime.cs @@ -28,4 +28,20 @@ public static class GameTime public static bool IsValidTimeScale(int timeScale) => timeScale is >= MinTimeScale and <= MaxTimeScale; + + /// + /// Normalises a requested start to a naive local calendar instant. Null → . + /// Rejects values outside a practical game range (year 1–9998). + /// + 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); + } } diff --git a/src/TheLivingWorld.Web/index.html b/src/TheLivingWorld.Web/index.html index 71e0c81..9e9f26a 100644 --- a/src/TheLivingWorld.Web/index.html +++ b/src/TheLivingWorld.Web/index.html @@ -68,6 +68,16 @@ + + diff --git a/src/TheLivingWorld.Web/src/api/types.ts b/src/TheLivingWorld.Web/src/api/types.ts index 997a5d4..23a967f 100644 --- a/src/TheLivingWorld.Web/src/api/types.ts +++ b/src/TheLivingWorld.Web/src/api/types.ts @@ -48,6 +48,8 @@ export interface CreateWorldRequest { longitude: number; sizeKm: number; 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. */ diff --git a/src/TheLivingWorld.Web/src/main.ts b/src/TheLivingWorld.Web/src/main.ts index 0fde483..2bd24a6 100644 --- a/src/TheLivingWorld.Web/src/main.ts +++ b/src/TheLivingWorld.Web/src/main.ts @@ -4,7 +4,12 @@ import type { WorldClock, WorldSummary } from './api/types'; import { MapView, type MapStatus } from './map/mapView'; import { THEMES, type ThemeName } from './map/theme'; 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 THEME_KEY = 'the-living-world:theme'; @@ -21,6 +26,7 @@ const elements = { coords: required('field-coords'), size: required('field-size'), sizeValue: required('field-size-value'), + start: required('field-start'), generate: required('generate-button'), formHint: required('form-hint'), useLocation: required('use-location'), @@ -446,6 +452,13 @@ async function generate(event: SubmitEvent): Promise { 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); generating = true; updateGenerateEnabled(); @@ -457,6 +470,7 @@ async function generate(event: SubmitEvent): Promise { latitude: location.latitude, longitude: location.longitude, sizeKm, + startGameTime, }); await refreshWorldList(); diff --git a/src/TheLivingWorld.Web/src/styles.css b/src/TheLivingWorld.Web/src/styles.css index 2de7bf1..9797ea2 100644 --- a/src/TheLivingWorld.Web/src/styles.css +++ b/src/TheLivingWorld.Web/src/styles.css @@ -323,7 +323,8 @@ body { } .field input[type='text'], -.field input[type='number'] { +.field input[type='number'], +.field input[type='datetime-local'] { padding: 7px 9px; font: inherit; font-size: 13px; diff --git a/src/TheLivingWorld.Web/src/ui/gameTime.test.ts b/src/TheLivingWorld.Web/src/ui/gameTime.test.ts index 27dc64f..9aa2a51 100644 --- a/src/TheLivingWorld.Web/src/ui/gameTime.test.ts +++ b/src/TheLivingWorld.Web/src/ui/gameTime.test.ts @@ -5,6 +5,8 @@ import { GAME_MINUTES_PER_REAL_SECOND, interpolateGameTime, parseGameTime, + startGameTimeFromInput, + DEFAULT_START_INPUT, } from './gameTime'; describe('parseGameTime', () => { @@ -64,3 +66,12 @@ describe('interpolateGameTime', () => { 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(); + }); +}); diff --git a/src/TheLivingWorld.Web/src/ui/gameTime.ts b/src/TheLivingWorld.Web/src/ui/gameTime.ts index de659bf..26d26a4 100644 --- a/src/TheLivingWorld.Web/src/ui/gameTime.ts +++ b/src/TheLivingWorld.Web/src/ui/gameTime.ts @@ -68,3 +68,39 @@ export function interpolateGameTime( const gameMs = (elapsedRealMs / 1000) * GAME_MINUTES_PER_REAL_SECOND * scale * 60_000; 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`; +} diff --git a/tests/TheLivingWorld.Tests/GameTimeTests.cs b/tests/TheLivingWorld.Tests/GameTimeTests.cs index e4c68ee..15f1990 100644 --- a/tests/TheLivingWorld.Tests/GameTimeTests.cs +++ b/tests/TheLivingWorld.Tests/GameTimeTests.cs @@ -58,4 +58,20 @@ public sealed class GameTimeTests { 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(() => + GameTime.ResolveStart(new DateTime(10000, 1, 1))); + } } diff --git a/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs b/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs index 1a94591..52f4c8d 100644 --- a/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs +++ b/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs @@ -45,6 +45,29 @@ public sealed class WorldGenerationServiceTests : IDisposable 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) { // The capacity check runs before any Overpass work, so this generator is never invoked by the