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
+10
View File
@@ -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 &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>
<button id="generate-button" type="submit" class="button">Generate world</button>
</form>
+2
View File
@@ -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. */
+15 -1
View File
@@ -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();
+2 -1
View File
@@ -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();
});
});
+36
View File
@@ -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`;
}