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.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 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" />
|
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
||||||
</label>
|
</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>
|
<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>
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user