diff options
| -rw-r--r-- | routes/+page.svelte | 8 | ||||
| -rw-r--r-- | tests/unit/worldgen/world.test.ts | 48 | ||||
| -rw-r--r-- | worldgen/world.ts | 75 |
3 files changed, 130 insertions, 1 deletions
diff --git a/routes/+page.svelte b/routes/+page.svelte index babf994..ffa8428 100644 --- a/routes/+page.svelte +++ b/routes/+page.svelte @@ -10,6 +10,8 @@ hasCompletedInitialLoad, markInitialLoadComplete } from '@hollowdark/loading/lifecycle' + import { saveWorld } from '@hollowdark/persistence/worlds' + import { generateWorld } from '@hollowdark/worldgen/world' type View = 'loading' | 'begin' @@ -25,7 +27,11 @@ view = 'begin' }) - function handleBegin(): void {} + async function handleBegin(): Promise<void> { + const world = generateWorld() + await saveWorld(world) + beginState = await detectBeginState() + } function handleContinue(): void {} function handleSettings(): void { goto(resolve('/settings')) diff --git a/tests/unit/worldgen/world.test.ts b/tests/unit/worldgen/world.test.ts new file mode 100644 index 0000000..7f7e0ce --- /dev/null +++ b/tests/unit/worldgen/world.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest' + +import type { WorldId } from '@hollowdark/engine/entities/base' +import { FIRST_EVER_YEAR, generateWorld } from '@hollowdark/worldgen/world' + +const FIXED_ID = 'test-world-1111' as WorldId +const FIXED_SEED = 'test-seed-1111' +const FIXED_CREATED_AT = '2026-04-22T09:00:00.000Z' + +describe('generateWorld', () => { + test('lands at year 1111, Thawing 1', () => { + const world = generateWorld({ id: FIXED_ID, seed: FIXED_SEED, createdAt: FIXED_CREATED_AT }) + expect(world.currentGameTime).toEqual({ + year: FIRST_EVER_YEAR, + month: 1, + day: 1, + tickOfDay: 0 + }) + }) + + test('starts with no characters, no events, no crisis', () => { + const world = generateWorld({ id: FIXED_ID, seed: FIXED_SEED, createdAt: FIXED_CREATED_AT }) + expect(world.currentPlayerCharacterId).toBeNull() + expect(world.playedCharacterIds).toEqual([]) + expect(world.tierOneIds).toEqual([]) + expect(world.tierTwoIds).toEqual([]) + expect(world.activeEventIds).toEqual([]) + expect(world.scheduledEvents).toEqual([]) + expect(world.crisisState.active).toBe(false) + expect(world.politicsByRegion.size).toBe(0) + }) + + test('seeded generation is deterministic given fixed inputs', () => { + const a = generateWorld({ id: FIXED_ID, seed: FIXED_SEED, createdAt: FIXED_CREATED_AT }) + const b = generateWorld({ id: FIXED_ID, seed: FIXED_SEED, createdAt: FIXED_CREATED_AT }) + expect(a).toEqual(b) + }) + + test('defaults the seed to the generated id when only id is provided', () => { + const world = generateWorld({ id: FIXED_ID, createdAt: FIXED_CREATED_AT }) + expect(world.seed).toBe(FIXED_ID) + }) + + test('records the current schema version in settings', () => { + const world = generateWorld({ id: FIXED_ID, seed: FIXED_SEED, createdAt: FIXED_CREATED_AT }) + expect(world.settings.schemaVersion).toBe(1) + }) +}) diff --git a/worldgen/world.ts b/worldgen/world.ts new file mode 100644 index 0000000..b2e9bd0 --- /dev/null +++ b/worldgen/world.ts @@ -0,0 +1,75 @@ +import type { WorldId } from '@hollowdark/engine/entities/base' +import type { + CrisisState, + MacroEconomicState, + World, + WorldSettings +} from '@hollowdark/engine/entities/world' +import { SCHEMA_VERSION } from '@hollowdark/persistence/schema' +import { makeGameTime } from '@hollowdark/time/gameTime' +import { APP_VERSION_FULL } from '@hollowdark/utils/version/version' + +/** + * The year the first-ever character in any Hollowdark save is born. Per + * design, this number is never shown to the player again — subsequent + * characters are born in whatever year the world has reached. + */ +export const FIRST_EVER_YEAR = 1111 as const + +const DEFAULT_ECONOMY: MacroEconomicState = Object.freeze({ + inflationAnnual: 0.02, + employmentRate: 0.94, + marketIndex: 1.0, + recessionDepth: 0 +}) + +const DEFAULT_CRISIS: CrisisState = Object.freeze({ + active: false, + crisisEventId: null, + sceneIndex: 0, + startedAt: null +}) + +export interface GenerateWorldInput { + readonly id?: WorldId + readonly seed?: string + readonly createdAt?: string +} + +/** + * Produce a fresh `World` record sitting at the start of year 1111. No + * characters, no scheduled events, no political state — the shape is a + * clean slate that downstream generators (people, regions, institutions) + * fill in before the simulation begins to tick. + * + * Entropy is injected here only: the default `seed` comes from + * `crypto.randomUUID()`, the sole non-deterministic call in a world's + * lifetime. Tests and imports can supply their own seed for replay. + */ +export function generateWorld(input: GenerateWorldInput = {}): World { + const id = input.id ?? (crypto.randomUUID() as WorldId) + const seed = input.seed ?? id + const createdAt = input.createdAt ?? new Date().toISOString() + + const settings: WorldSettings = { + contentVersionAtCreation: APP_VERSION_FULL, + schemaVersion: SCHEMA_VERSION + } + + return { + id, + seed, + createdAt, + currentGameTime: makeGameTime(FIRST_EVER_YEAR, 1, 1), + currentPlayerCharacterId: null, + playedCharacterIds: [], + tierOneIds: [], + tierTwoIds: [], + economy: DEFAULT_ECONOMY, + politicsByRegion: new Map(), + activeEventIds: [], + scheduledEvents: [], + crisisState: DEFAULT_CRISIS, + settings + } +} |
