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