aboutsummaryrefslogtreecommitdiff
path: root/content-system
diff options
context:
space:
mode:
Diffstat (limited to 'content-system')
-rw-r--r--content-system/loader/loader.ts83
-rw-r--r--content-system/manifest/schema.ts35
-rw-r--r--content-system/regions/schema.ts117
-rw-r--r--content-system/registry/registry.ts91
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
+}