diff options
| author | Bobby <[email protected]> | 2026-04-22 10:07:23 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-04-22 10:07:23 +0530 |
| commit | cf691853a93df3e242e229956d03725b89c31fc3 (patch) | |
| tree | 681f58da4e644b3392334d4ccff0e29939de999a /content-system | |
| parent | 9f19aba9a4775e09ad9c92b173ca876c5157556d (diff) | |
| download | hollowdark-cf691853a93df3e242e229956d03725b89c31fc3.tar.xz hollowdark-cf691853a93df3e242e229956d03725b89c31fc3.zip | |
Add the content pipeline and the Mirror Coast region as the first authored sample
Diffstat (limited to 'content-system')
| -rw-r--r-- | content-system/loader/loader.ts | 83 | ||||
| -rw-r--r-- | content-system/manifest/schema.ts | 35 | ||||
| -rw-r--r-- | content-system/regions/schema.ts | 117 | ||||
| -rw-r--r-- | content-system/registry/registry.ts | 91 |
4 files changed, 326 insertions, 0 deletions
diff --git a/content-system/loader/loader.ts b/content-system/loader/loader.ts new file mode 100644 index 0000000..97a6c0f --- /dev/null +++ b/content-system/loader/loader.ts @@ -0,0 +1,83 @@ +import type { ContentManifest, ChunkPayload } from '@hollowdark/content-system/manifest/schema' +import { HollowdarkContentCache } from '@hollowdark/persistence/db' +import type { JsonValue } from '@hollowdark/utils/types/json' + +let cacheInstance: HollowdarkContentCache | null = null + +function cache(): HollowdarkContentCache { + if (cacheInstance === null) { + cacheInstance = new HollowdarkContentCache() + } + return cacheInstance +} + +const MANIFEST_CACHE_KEY = 'latest' + +/** + * Fetch the content manifest from static hosting. Falls back to the last + * cached manifest when the network request fails — the game works fully + * offline once the first download has succeeded. + */ +export async function fetchManifest(baseUrl: string): Promise<ContentManifest> { + const url = `${baseUrl}/content-manifest.json` + try { + const response = await fetch(url, { cache: 'no-cache' }) + if (!response.ok) throw new Error(`manifest ${response.status}`) + const live = (await response.json()) as ContentManifest + await cache().manifest.put({ + id: MANIFEST_CACHE_KEY, + contentVersion: live.content_version, + appVersion: live.app_version, + generatedAt: live.generated_at, + payload: live as unknown as JsonValue + }) + return live + } catch { + const cached = await cache().manifest.get(MANIFEST_CACHE_KEY) + if (cached === undefined) { + throw new Error('Content manifest unavailable and no cached copy exists.') + } + return cached.payload as unknown as ContentManifest + } +} + +/** + * Load a single chunk by id, using the manifest to resolve the hashed + * URL. Served from the IndexedDB cache whenever the cached hash matches + * the manifest's; refetched otherwise. Falls back to the cached payload + * on network failure so gameplay continues offline. + */ +export async function loadChunk( + chunkId: string, + manifest: ContentManifest, + baseUrl: string +): Promise<ChunkPayload> { + const entry = manifest.chunks[chunkId] + if (entry === undefined) { + throw new Error(`Content chunk not listed in manifest: ${chunkId}`) + } + + const cached = await cache().content.get(chunkId) + if (cached !== undefined && cached.hash === entry.hash) { + return { chunkId, data: cached.data, version: cached.version, hash: cached.hash } + } + + try { + const response = await fetch(`${baseUrl}${entry.url}`) + if (!response.ok) throw new Error(`chunk ${response.status}`) + const data = (await response.json()) as JsonValue + await cache().content.put({ + chunkId, + version: entry.version, + hash: entry.hash, + data, + fetchedAt: new Date().toISOString() + }) + return { chunkId, data, version: entry.version, hash: entry.hash } + } catch (err) { + if (cached !== undefined) { + return { chunkId, data: cached.data, version: cached.version, hash: cached.hash } + } + throw err + } +} diff --git a/content-system/manifest/schema.ts b/content-system/manifest/schema.ts new file mode 100644 index 0000000..07eb76f --- /dev/null +++ b/content-system/manifest/schema.ts @@ -0,0 +1,35 @@ +import type { JsonValue } from '@hollowdark/utils/types/json' + +/** + * A single entry in the content manifest — one compiled chunk served + * from static hosting. `url` is relative to the app base path. + */ +export interface ManifestEntry { + readonly version: string + readonly hash: string + readonly url: string + readonly size_bytes: number +} + +/** + * The content manifest — an index of every compiled content chunk the + * client might need. Fetched once at session start; diffed against the + * cached copy to decide which chunks must be re-downloaded. + */ +export interface ContentManifest { + readonly content_version: string + readonly app_version: string + readonly generated_at: string + readonly chunks: Readonly<Record<string, ManifestEntry>> +} + +/** Chunk ids are stable, slash-separated, human-readable. */ +export type ChunkId = string + +/** A raw chunk payload as received from the network. */ +export interface ChunkPayload { + readonly chunkId: ChunkId + readonly data: JsonValue + readonly version: string + readonly hash: string +} diff --git a/content-system/regions/schema.ts b/content-system/regions/schema.ts new file mode 100644 index 0000000..6d053a5 --- /dev/null +++ b/content-system/regions/schema.ts @@ -0,0 +1,117 @@ +import type { Brand } from '@hollowdark/utils/types/brand' + +/** Stable slug identifying a region across versions. */ +export type RegionId = Brand<string, 'RegionId'> + +/** Stable slug identifying a city across versions. */ +export type CityId = Brand<string, 'CityId'> + +/** Stable slug identifying an institution across versions. */ +export type InstitutionContentId = Brand<string, 'InstitutionContentId'> + +/** Dominant economic sector categories used across the world's regions. */ +export type EconomicSector = + | 'agriculture' + | 'publishing' + | 'education' + | 'finance' + | 'government' + | 'industry' + | 'logistics' + | 'mining' + | 'oil_and_gas' + | 'renewable_energy' + | 'services' + | 'shipping' + | 'technology' + | 'timber' + | 'tourism' + | 'trades' + | 'entertainment' + | 'fishing' + | 'retail' + | 'design' + | 'construction' + | 'healthcare' + | 'transportation' + +/** Coarse measure of income disparity in a region. */ +export type Inequality = 'flat' | 'moderate' | 'high' | 'extreme' + +/** + * Share of the population affiliated, nominally or actively, with each of + * the three main religions plus secular/unaffiliated. Values in [0, 1], + * sum ~= 1 per region. + */ +export interface ReligiousComposition { + readonly covenant: number + readonly old_faith: number + readonly quiet_path: number + readonly secular: number +} + +/** Climate texture — prose for scenes, plus rough numeric anchors. */ +export interface RegionClimate { + readonly summary: string + readonly temperature_range_c: readonly [number, number] + readonly annual_rainfall_mm: number +} + +/** The role a city plays relative to its region. */ +export type CityRole = + | 'metropolis' + | 'port' + | 'university_town' + | 'industrial_town' + | 'capital' + | 'agricultural_hub' + | 'resort' + | 'mining_town' + | 'frontier_town' + | 'tourist' + | 'mill_town' + +/** Region-level content source, one YAML file per region. */ +export interface RegionContent { + readonly id: RegionId + readonly display_name: string + readonly population_weight: number + readonly climate: RegionClimate + readonly economy: { + readonly summary: string + readonly dominant_sectors: readonly EconomicSector[] + readonly inequality: Inequality + } + readonly culture: { + readonly summary: string + readonly texture: string + } + readonly class_texture: string + readonly religion: ReligiousComposition + readonly city_ids: readonly CityId[] + readonly institution_ids: readonly InstitutionContentId[] + readonly stereotypes: { + readonly held_by_others: string + readonly self_image: string + } +} + +/** City-level content source, one YAML file per city. */ +export interface CityContent { + readonly id: CityId + readonly region_id: RegionId + readonly display_name: string + readonly population: number + readonly role: CityRole + readonly character: string +} + +/** Institution-level content source, one YAML file per institution. */ +export interface InstitutionContent { + readonly id: InstitutionContentId + readonly region_id: RegionId + readonly city_id: CityId + readonly display_name: string + readonly kind: 'publisher' | 'university' | 'hospital' | 'newspaper' | 'employer' | 'religious' | 'government' + readonly character: string +} diff --git a/content-system/registry/registry.ts b/content-system/registry/registry.ts new file mode 100644 index 0000000..38f0a91 --- /dev/null +++ b/content-system/registry/registry.ts @@ -0,0 +1,91 @@ +import type { + CityContent, + CityId, + InstitutionContent, + InstitutionContentId, + RegionContent, + RegionId +} from '@hollowdark/content-system/regions/schema' +import type { ContentManifest } from '@hollowdark/content-system/manifest/schema' +import { loadChunk } from '@hollowdark/content-system/loader/loader' + +/** + * The in-memory content registry. Populated once at session start by + * walking the manifest and loading every chunk into typed maps. All + * simulation-time reads go through here — synchronous, typed, fast. + */ +export class ContentRegistry { + readonly #regions = new Map<RegionId, RegionContent>() + readonly #cities = new Map<CityId, CityContent>() + readonly #institutions = new Map<InstitutionContentId, InstitutionContent>() + + get regions(): ReadonlyMap<RegionId, RegionContent> { + return this.#regions + } + + get cities(): ReadonlyMap<CityId, CityContent> { + return this.#cities + } + + get institutions(): ReadonlyMap<InstitutionContentId, InstitutionContent> { + return this.#institutions + } + + addRegion(region: RegionContent): void { + this.#regions.set(region.id, region) + } + + addCity(city: CityContent): void { + this.#cities.set(city.id, city) + } + + addInstitution(institution: InstitutionContent): void { + this.#institutions.set(institution.id, institution) + } + + region(id: RegionId): RegionContent { + const value = this.#regions.get(id) + if (value === undefined) throw new Error(`Unknown region: ${id}`) + return value + } + + city(id: CityId): CityContent { + const value = this.#cities.get(id) + if (value === undefined) throw new Error(`Unknown city: ${id}`) + return value + } + + institution(id: InstitutionContentId): InstitutionContent { + const value = this.#institutions.get(id) + if (value === undefined) throw new Error(`Unknown institution: ${id}`) + return value + } +} + +/** + * Populate a fresh registry from the supplied manifest. Fetches every + * chunk whose id begins with a world-content prefix; ignores unknown + * prefixes so the registry can grow without breaking older clients. + */ +export async function populateFromManifest( + manifest: ContentManifest, + baseUrl: string +): Promise<ContentRegistry> { + const registry = new ContentRegistry() + + const entries = Object.entries(manifest.chunks) + await Promise.all( + entries.map(async ([chunkId]) => { + const { data } = await loadChunk(chunkId, manifest, baseUrl) + if (chunkId.startsWith('world/regions/')) { + registry.addRegion(data as unknown as RegionContent) + } else if (chunkId.startsWith('world/cities/')) { + registry.addCity(data as unknown as CityContent) + } else if (chunkId.startsWith('world/institutions/')) { + registry.addInstitution(data as unknown as InstitutionContent) + } + }) + ) + + return registry +} |
