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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,11 @@ public sealed record CreateWorldRequest
|
||||
|
||||
/// <summary>Re-download from Overpass even if a cached response for this box exists.</summary>
|
||||
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>
|
||||
|
||||
@@ -28,4 +28,20 @@ public static class GameTime
|
||||
|
||||
public static bool IsValidTimeScale(int timeScale) =>
|
||||
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 1–9998).
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,16 @@
|
||||
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Start date & 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>
|
||||
<button id="generate-button" type="submit" class="button">Generate world</button>
|
||||
</form>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<HTMLInputElement>('field-coords'),
|
||||
size: required<HTMLInputElement>('field-size'),
|
||||
sizeValue: required<HTMLOutputElement>('field-size-value'),
|
||||
start: required<HTMLInputElement>('field-start'),
|
||||
generate: required<HTMLButtonElement>('generate-button'),
|
||||
formHint: required<HTMLElement>('form-hint'),
|
||||
useLocation: required<HTMLButtonElement>('use-location'),
|
||||
@@ -446,6 +452,13 @@ async function generate(event: SubmitEvent): Promise<void> {
|
||||
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<void> {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
sizeKm,
|
||||
startGameTime,
|
||||
});
|
||||
|
||||
await refreshWorldList();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
Reference in New Issue
Block a user