diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4db5a9c..c89427e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,6 +33,9 @@ jobs: - name: License check run: npm run license-check + - name: Check generated site resources are current + run: npm run sites:check + - name: Install Playwright browsers run: npx playwright install --with-deps chromium diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1ec7437..85590f8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,6 +36,9 @@ jobs: - name: License check run: npm run license-check + - name: Check generated site resources are current + run: npm run sites:check + - name: Install Playwright browsers run: npx playwright install --with-deps chromium diff --git a/docs/baseline/schemas/site-presentation.schema.json b/docs/baseline/schemas/site-presentation.schema.json new file mode 100644 index 0000000..30e3e89 --- /dev/null +++ b/docs/baseline/schemas/site-presentation.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://diving-simulator.local/schemas/site-presentation.json", + "title": "Dive site presentation resource", + "description": "Art-only site data: features, visual zones, atmosphere and decoration rules. Nothing here may reach collision, air, spawning or currents — those live in the gameplay resource. Feature kinds carry their own extra fields, so feature objects stay open; every field this schema does name is typed, so a string where a number belongs fails validation.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "kind", "sourceDigest", "generator", "sites"], + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "diving-simulator-site-presentation" }, + "sourceDigest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "generator": { "type": "string", "minLength": 1 }, + "sites": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/site" } + } + }, + "$defs": { + "feature": { + "type": "object", + "required": ["kind", "x"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "x": { "type": "number" }, + "d": { "type": "number", "minimum": 0 }, + "dTop": { "type": "number", "minimum": 0 }, + "dBottom": { "type": "number", "minimum": 0 } + } + }, + "visualZone": { + "type": "object", + "additionalProperties": false, + "required": ["id", "x1", "x2", "d1", "d2"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "x1": { "type": "number" }, + "x2": { "type": "number" }, + "d1": { "type": "number", "minimum": 0 }, + "d2": { "type": "number", "minimum": 0 }, + "priority": { "type": "number" }, + "blend": { "type": "number", "minimum": 0 }, + "tags": { "type": "array", "items": { "type": "string" } } + } + }, + "atmosphereProfile": { + "type": "object", + "additionalProperties": false, + "properties": { + "visibility": { "type": "number", "minimum": 0 }, + "tint": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { "type": "number" } + }, + "particleDensity": { "type": "number", "minimum": 0 }, + "particleBrightness": { "type": "number", "minimum": 0 }, + "ambient": { "type": "number", "minimum": 0 } + } + }, + "decorationRule": { + "description": "Every field renderer.js reads off a rule, whether or not the shipped data authors it yet. An undeclared numeric field is worse than an absent one: the renderer's guards are all `rule.f != null && x < rule.f`, so a string makes the comparison NaN-false and the guard silently stops guarding. Closed against unknown fields so a typo'd name fails here rather than being read as `undefined` and defaulted away.", + "type": "object", + "additionalProperties": false, + "required": ["id", "zone"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "zone": { "type": "string", "minLength": 1 }, + "spacing": { "type": "number", "exclusiveMinimum": 0 }, + "density": { "type": "number", "minimum": 0 }, + "seed": { "type": "number" }, + "surface": { "type": "string", "enum": ["floor", "ceiling"] }, + "minDepth": { "type": "number", "minimum": 0 }, + "maxDepth": { "type": "number", "minimum": 0 }, + "minScale": { "type": "number", "exclusiveMinimum": 0 }, + "maxScale": { "type": "number", "exclusiveMinimum": 0 }, + "maxPerScreen": { "type": "integer", "minimum": 0 }, + "rotationJitter": { "type": "number", "minimum": 0 }, + "alpha": { "type": "number", "minimum": 0, "maximum": 1 }, + "props": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "weight": { "type": "number", "exclusiveMinimum": 0 } + } + } + } + } + }, + "site": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "surfaceMarker": { "type": "string" }, + "features": { "type": "array", "items": { "$ref": "#/$defs/feature" } }, + "visualZones": { "type": "array", "items": { "$ref": "#/$defs/visualZone" } }, + "atmosphereProfiles": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/atmosphereProfile" } + }, + "decorationRules": { + "type": "array", + "items": { "$ref": "#/$defs/decorationRule" } + } + } + } + } +} diff --git a/docs/baseline/schemas/site.schema.json b/docs/baseline/schemas/site.schema.json new file mode 100644 index 0000000..d6ff591 --- /dev/null +++ b/docs/baseline/schemas/site.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://diving-simulator.local/schemas/site-gameplay.json", + "title": "Dive site gameplay resource", + "description": "Renderer-neutral collision, air and spawn data. Nothing here describes how a site looks; art lives in the presentation resource and cannot reach these fields.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "kind", "sourceDigest", "generator", "sites"], + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "diving-simulator-site-gameplay" }, + "sourceDigest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "generator": { "type": "string", "minLength": 1 }, + "sites": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/site" } + } + }, + "$defs": { + "entryPoint": { + "description": "Where the diver enters the water. Gameplay data, so its type has to be pinned: `{ x: \"0\" }` would place a spawn at a string coordinate, and the parity suite does not compare entry points.", + "type": "object", + "additionalProperties": false, + "required": ["x"], + "properties": { + "x": { "type": "number" } + } + }, + "profilePoint": { + "type": "object", + "additionalProperties": false, + "required": ["x", "d"], + "properties": { + "x": { "type": "number" }, + "d": { "type": "number", "minimum": 0 } + } + }, + "profile": { + "type": "array", + "minItems": 2, + "items": { "$ref": "#/$defs/profilePoint" } + }, + "structure": { + "type": "object", + "required": ["x1", "x2", "dTop", "dBottom"], + "properties": { + "x1": { "type": "number" }, + "x2": { "type": "number" }, + "dTop": { "type": "number", "minimum": 0 }, + "dBottom": { "type": "number", "minimum": 0 }, + "kind": { "type": "string" } + } + }, + "badAirDome": { + "type": "object", + "required": ["x1", "x2"], + "properties": { + "x1": { "type": "number" }, + "x2": { "type": "number" }, + "d": { "type": "number", "minimum": 0 } + } + }, + "site": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "maxDepth", + "hasOverhead", + "entry", + "floor", + "ceiling", + "structures", + "badAir" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "maxDepth": { "type": "number", "exclusiveMinimum": 0 }, + "hasOverhead": { "type": "boolean" }, + "entry": { "$ref": "#/$defs/entryPoint" }, + "boatX": { "type": "number" }, + "floor": { "$ref": "#/$defs/profile" }, + "ceiling": { + "description": "Overhead profile, or explicit null for open water. Required, and null must be spelled out: an absent field makes ceilingAt() fall through to 0, which turns the cave roof into open water (14 m becomes 0 m at x=50) and lets the diver swim up through it. Parity would not catch that, because legacy and generated data lose the field together and agree on 0.", + "oneOf": [{ "$ref": "#/$defs/profile" }, { "type": "null" }] + }, + "structures": { + "type": "array", + "items": { "$ref": "#/$defs/structure" } + }, + "badAir": { + "type": "array", + "items": { "$ref": "#/$defs/badAirDome" } + }, + "currentBias": { "type": "number", "minimum": 0 }, + "noShark": { "type": "boolean" } + } + } + } +} diff --git a/package.json b/package.json index 1213f91..af2ee5c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "wp06:perf": "npm run build && node scripts/capture-wp06-performance.mjs", "license-check": "license-checker --excludePrivatePackages --onlyAllow \"MIT;ISC;BSD-2-Clause;BSD-3-Clause;Apache-2.0;MPL-2.0;0BSD;CC0-1.0;CC-BY-3.0;Python-2.0;WTFPL;Unlicense;BlueOak-1.0.0\"", "prepare": "husky", - "baseline:visual-compare": "node scripts/compare-rendering.mjs" + "baseline:visual-compare": "node scripts/compare-rendering.mjs", + "sites:generate": "node scripts/generate-site-resources.mjs", + "sites:check": "node scripts/generate-site-resources.mjs --check" }, "devDependencies": { "@playwright/test": "^1.62.0", diff --git a/scripts/generate-site-resources.mjs b/scripts/generate-site-resources.mjs new file mode 100644 index 0000000..1392364 --- /dev/null +++ b/scripts/generate-site-resources.mjs @@ -0,0 +1,185 @@ +// WP-07: extract the legacy DIVE_SITES descriptors into renderer-neutral +// resources, split by whether a field can affect the simulation. +// +// The split is the point of the package. Gameplay data feeds collision, air, +// spawning and currents; presentation data feeds art only. Keeping them in one +// object is what made "change the look of a site" indistinguishable from +// "change where the diver can swim". +// +// Do not hand-edit the generated files. Change the legacy descriptor or this +// script and regenerate, so the parity test keeps its meaning. +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import crypto from 'node:crypto'; + +const root = path.resolve(import.meta.dirname, '..'); +const outputDirectory = path.join(root, 'src', 'sites', 'resources'); + +// Fields that reach physics, collision, air, spawning or currents. Anything +// here must round-trip exactly; the parity test proves it does. +const GAMEPLAY_FIELDS = [ + 'id', + 'maxDepth', + 'hasOverhead', + 'entry', + 'boatX', + 'floor', + 'ceiling', + 'structures', + 'badAir', + 'currentBias', + 'noShark', +]; + +// Fields that only affect what is drawn. The legacy file already documents +// visualZones as never driving physics; this makes that structural. +const PRESENTATION_FIELDS = [ + 'id', + 'name', + 'surfaceMarker', + 'features', + 'visualZones', + 'atmosphereProfiles', + 'decorationRules', +]; + +// `sites.js` reads MAX_DEPTH from constants.js — the reef descriptor uses it for +// its maxDepth, both floor abyss endpoints and the blue-water zone. Hardcoding a +// value here bakes it into the generated resources, and the parity test cannot +// notice because it injects the same constant into both sides of its comparison. +// Read the real one instead, and fail loudly if it moves out of reach. +// +// It cannot simply be evaluated into the sandbox: constants.js declares it with +// `const`, which never becomes a property of the context object. +function readMaxDepth(source) { + const match = /^\s*(?:const|let|var)\s+MAX_DEPTH\s*=\s*(-?\d+(?:\.\d+)?)\s*;/m.exec(source); + if (!match) { + throw new Error( + 'could not read MAX_DEPTH from src/constants.js. If its declaration moved ' + + 'or became computed, update this pattern rather than hardcoding a value.', + ); + } + return Number(match[1]); +} + +function loadLegacySites(sitesSource, maxDepth) { + const sandbox = { MAX_DEPTH: maxDepth }; + vm.createContext(sandbox); + vm.runInContext(sitesSource, sandbox); + if (!sandbox.DIVE_SITES) { + throw new Error('DIVE_SITES was not defined by src/sites.js'); + } + return sandbox.DIVE_SITES; +} + +function pick(source, fields) { + const result = {}; + for (const field of fields) { + if (source[field] !== undefined) { + result[field] = source[field]; + } + } + return result; +} + +function assertPartitioned(site) { + const known = new Set([...GAMEPLAY_FIELDS, ...PRESENTATION_FIELDS]); + const unclassified = Object.keys(site).filter((key) => !known.has(key)); + if (unclassified.length) { + // Failing here is the safety property: a new descriptor field must be + // deliberately classified as gameplay or art, never silently dropped. + throw new Error( + `site "${site.id}" has unclassified field(s): ${unclassified.join(', ')}. ` + + 'Add each to GAMEPLAY_FIELDS or PRESENTATION_FIELDS in this script.', + ); + } +} + +// Provenance is a digest of the exact inputs this output was derived from, not +// a commit id. `git rev-parse HEAD` at generation time always names the commit +// *before* the one that carries the generated file, so a commit id here is +// wrong by construction: the resources would claim to come from a tree whose +// sites.js produced different data. A digest is checkable, and `--check` +// verifies it, so stale provenance fails CI like stale data does. +// +// Hash normalised line endings, not raw bytes. With core.autocrlf a Windows +// working tree holds CRLF where Linux CI holds LF — identical content, different +// bytes — so a byte digest disagrees across platforms and fails CI for a machine +// difference rather than a data one. The generated resources are unaffected by +// line endings, so the digest must be too. +const readNormalised = (file) => + fs.readFileSync(path.join(root, 'src', file), 'utf8').replace(/\r\n?/g, '\n'); + +const sitesSource = readNormalised('sites.js'); +const constantsSource = readNormalised('constants.js'); +const maxDepth = readMaxDepth(constantsSource); +const sourceDigest = `sha256:${crypto + .createHash('sha256') + .update(sitesSource) + .update(`|MAX_DEPTH=${maxDepth}`) + .digest('hex')}`; + +const sites = loadLegacySites(sitesSource, maxDepth); + +const gameplay = {}; +const presentation = {}; +for (const [id, site] of Object.entries(sites)) { + assertPartitioned(site); + gameplay[id] = pick(site, GAMEPLAY_FIELDS); + presentation[id] = pick(site, PRESENTATION_FIELDS); +} + +const DOCUMENTS = [ + { file: 'gameplay.json', kind: 'diving-simulator-site-gameplay', sites: gameplay }, + { file: 'presentation.json', kind: 'diving-simulator-site-presentation', sites: presentation }, +]; + +const header = { schemaVersion: 1, sourceDigest, generator: 'npm run sites:generate' }; + +// `--check` verifies the committed resources still match what the legacy +// descriptors would generate, without rewriting them. CI runs this so an edit +// to sites.js that skips regeneration fails the build instead of leaving the +// resources quietly stale. +// +// `sourceDigest` is checked alongside the payload. It is derived from the input +// bytes rather than from git, so unlike a commit id it is a claim that can be +// true, and a stale one is caught here rather than shipping as false provenance. +if (process.argv.includes('--check')) { + const stale = []; + for (const { file, sites: expected } of DOCUMENTS) { + const target = path.join(outputDirectory, file); + if (!fs.existsSync(target)) { + stale.push(`${file} is missing`); + continue; + } + const committed = JSON.parse(fs.readFileSync(target, 'utf8')); + if (JSON.stringify(committed.sites) !== JSON.stringify(expected)) { + stale.push(`${file} does not match src/sites.js`); + } + if (committed.sourceDigest !== sourceDigest) { + stale.push( + `${file} claims sourceDigest ${committed.sourceDigest ?? '(absent)'}, ` + + `inputs hash to ${sourceDigest}`, + ); + } + } + if (stale.length) { + console.error( + `site resources are out of date:\n ${stale.join('\n ')}\n` + + 'Run `npm run sites:generate` and commit the result.', + ); + process.exit(1); + } + console.log(`site resources match src/sites.js (${Object.keys(gameplay).length} sites)`); +} else { + fs.mkdirSync(outputDirectory, { recursive: true }); + for (const { file, kind, sites: payload } of DOCUMENTS) { + fs.writeFileSync( + path.join(outputDirectory, file), + `${JSON.stringify({ ...header, kind, sites: payload }, null, 2)}\n`, + 'utf8', + ); + } + console.log(`wrote ${Object.keys(gameplay).length} site resources to src/sites/resources/`); +} diff --git a/src/render/layer-assignment.ts b/src/render/layer-assignment.ts new file mode 100644 index 0000000..b1c5751 --- /dev/null +++ b/src/render/layer-assignment.ts @@ -0,0 +1,80 @@ +// WP-07: which retained scene element belongs to which layer, as data. +// +// The layer refactor claimed draw order was "a declared property of the scene +// rather than an accident of call order", but it was expressed as a sequence of +// addChild calls — so nothing could read it, and nothing could assert it. Silt +// was assigned to `terrain`, below `structure`, which put 31 of its 48 +// particles behind the hull's opaque fill. That shipped through lint, +// typecheck, unit, parity and e2e because the only thing that would have caught +// it is a screenshot gate covering the legacy client, not this one. +// +// Keeping the assignment here makes the ordering testable without a GPU. + +import { LAYERS, type LayerId } from "../sites/asset-manifest"; + +/** Retained (hand-authored) elements of the wreck scene. */ +export type RetainedElement = + | "distantHull" + | "seabed" + | "hull" + | "rooms" + | "engine" + | "route" + | "silt" + | "diver"; + +/** + * Insertion order within a layer is preserved, so `route` before `silt` before + * `diver` is meaningful: it reproduces the pre-refactor draw order exactly. + * + * Why each sits where it does: + * - `distantHull` — parallax silhouette behind everything. + * - `seabed` — ground the wreck rests on. + * - `hull`, `rooms`, `engine` — the wreck itself, opaque, occludes the seabed. + * - `route`, `silt`, `diver` — everything between the camera and the wreck. + * Silt is suspended particulate, not ground cover; below `structure` the hull + * eats it. + */ +export const RETAINED_LAYER_ASSIGNMENT: Readonly> = + Object.freeze({ + distantHull: "backdrop", + seabed: "terrain", + hull: "structure", + rooms: "structure", + engine: "structure", + route: "foreground", + silt: "foreground", + diver: "foreground", + }); + +/** Bubbles are pooled separately from the retained elements but share a layer. */ +export const BUBBLE_LAYER: LayerId = "foreground"; + +/** Painter's-algorithm index: higher draws later, so higher occludes lower. */ +export function layerDepth(id: LayerId): number { + return LAYERS.indexOf(id); +} + +/** + * World-space half-extents of what the camera can actually show. + * + * Shared with the renderer rather than restated there, so a test can compare it + * against the viewport arithmetic independently. The camera fits a constant + * width in metres, so visible HEIGHT is a function of aspect ratio: at 390x844 + * it is ~125 m against the 20 m constant this replaced, which culled four + * on-screen placements — the engine row at d=61 and the anchor at d=66. + */ +export function visibleHalfExtentM(camera: { + readonly scale: number; + readonly viewport: { readonly width: number; readonly height: number }; +}): { readonly halfWidthM: number; readonly halfHeightM: number } { + return { + halfWidthM: camera.viewport.width / camera.scale / 2, + halfHeightM: camera.viewport.height / camera.scale / 2, + }; +} + +/** True if `a` is painted after `b`, and so can occlude it. */ +export function drawsAfter(a: LayerId, b: LayerId): boolean { + return layerDepth(a) > layerDepth(b); +} diff --git a/src/render/pixi-renderer.ts b/src/render/pixi-renderer.ts index 30c89f4..f54a2fd 100644 --- a/src/render/pixi-renderer.ts +++ b/src/render/pixi-renderer.ts @@ -1,11 +1,27 @@ import { Application, Container, Graphics } from "pixi.js"; import type { PresentationState } from "../presentation/presentation-state"; -import { createCameraTransform } from "./camera"; +import { LAYERS, type LayerId, type QualityTier } from "../sites/asset-manifest"; +import { buildSceneLayers } from "../sites/layer-factory"; +import { createCameraTransform, type CameraTransform } from "./camera"; +import { + BUBBLE_LAYER, + RETAINED_LAYER_ASSIGNMENT, + visibleHalfExtentM, + type RetainedElement, +} from "./layer-assignment"; import type { SceneRenderer, WreckSceneState } from "./renderer"; const MAX_RESOLUTION = 2; const BUBBLE_COUNT = 14; +const SITE_ID = "wreck"; + +// Extra world-space margin beyond the visible window, so content is resident +// before it scrolls in. Resyncs happen only once the camera has travelled +// RESYNC_DISTANCE_M, so the margin has to exceed that or content can enter the +// frame during the gap between syncs. +const CULL_MARGIN_M = 10; +const RESYNC_DISTANCE_M = 4; export class PixiWreckRenderer implements SceneRenderer { readonly kind = "pixi" as const; @@ -18,6 +34,14 @@ export class PixiWreckRenderer implements SceneRenderer { #diver = new Container(); #bubbles: Graphics[] = []; #viewport = { width: 1, height: 1 }; + #layers = new Map(); + // Placement markers are pooled. Camera movement changes which features are + // visible many times a dive; allocating a Graphics per feature per resync + // would churn the heap for a scene whose contents barely change. + #markerPool: Graphics[] = []; + #activeMarkers: Graphics[] = []; + #lastSyncFocus: { x: number; y: number } | null = null; + #qualityTier: QualityTier = "high"; async mount(host: HTMLElement): Promise { if (this.#app) { @@ -43,11 +67,18 @@ export class PixiWreckRenderer implements SceneRenderer { app.stage.addChild(this.#background, this.#world); this.#buildRetainedScene(); + // Size first, then sync: the cull window is derived from the camera, so + // syncing against the placeholder 1x1 viewport would populate the scene for + // a window that does not exist. const bounds = host.getBoundingClientRect(); this.resize( Math.max(1, bounds.width || 960), Math.max(1, bounds.height || 540), ); + this.#syncSceneLayers( + createCameraTransform(this.#viewport, { x: 0, y: 0 }), + true, + ); } resize(width: number, height: number, resolution?: number): void { @@ -61,6 +92,11 @@ export class PixiWreckRenderer implements SceneRenderer { } app.renderer.resize(this.#viewport.width, this.#viewport.height); this.#drawBackground(); + // The cull window is a function of the viewport, so a resize or an + // orientation change invalidates it even when the camera has not moved. + // Without this, rotating to portrait keeps the landscape window and the + // newly visible depth band stays empty until the diver travels 4 m. + this.#lastSyncFocus = null; } render( @@ -79,6 +115,7 @@ export class PixiWreckRenderer implements SceneRenderer { ); this.#world.pivot.set(camera.focus.x, camera.focus.y); this.#world.scale.set(camera.scale); + this.#syncSceneLayers(camera, false); this.#diver.position.set(scene.routePositionM, scene.diverDepthM); this.#diver.scale.x = scene.facing; this.#torch.visible = scene.torchOn; @@ -110,6 +147,102 @@ export class PixiWreckRenderer implements SceneRenderer { this.#app = null; this.#host = null; this.#bubbles = []; + this.#layers.clear(); + this.#markerPool = []; + this.#activeMarkers = []; + this.#lastSyncFocus = null; + } + + /** Quality tier drops decoration before anything a diver navigates by. */ + setQualityTier(tier: QualityTier): void { + if (tier === this.#qualityTier) { + return; + } + this.#qualityTier = tier; + this.#lastSyncFocus = null; + } + + /** Visible placement count, for tests and the diagnostics overlay. */ + get placementCount(): number { + return this.#activeMarkers.length; + } + + #syncSceneLayers(camera: CameraTransform, force: boolean): void { + const focus = camera.focus; + if ( + !force && + this.#lastSyncFocus && + Math.abs(focus.x - this.#lastSyncFocus.x) < RESYNC_DISTANCE_M && + Math.abs(focus.y - this.#lastSyncFocus.y) < RESYNC_DISTANCE_M + ) { + return; + } + this.#lastSyncFocus = { x: focus.x, y: focus.y }; + + // Derive the window from the camera rather than from constants. A fixed + // half-height cannot describe the visible world: the camera fits a constant + // 58 m of WIDTH, so visible height is viewport.height / scale and grows with + // aspect ratio. At 390x844 that is ~125 m of depth on screen against a + // 20 m half-height, which culled four on-screen placements — the engine row + // at d=61 and the anchor at d=66 — while the diver was looking straight at + // them. Desktop and landscape hid this because their windows are shorter + // than the constant. + const { halfWidthM, halfHeightM } = visibleHalfExtentM(camera); + + const layers = buildSceneLayers(SITE_ID, { + qualityTier: this.#qualityTier, + cullMarginM: CULL_MARGIN_M, + camera: { + leftM: focus.x - halfWidthM, + rightM: focus.x + halfWidthM, + topM: focus.y - halfHeightM, + bottomM: focus.y + halfHeightM, + }, + }); + + for (const marker of this.#activeMarkers) { + marker.visible = false; + this.#markerPool.push(marker); + } + this.#activeMarkers = []; + + for (const layer of layers) { + const container = this.#layers.get(layer.id); + if (!container) { + continue; + } + for (const placement of layer.placements) { + const marker = this.#takeMarker(); + marker.visible = true; + marker.position.set(placement.x, placement.d); + // Unconditional, not `if (marker.parent !== container)`. buildSceneLayers + // sorts placements within a layer (shallowest first, then x) so the same + // data always yields the same scene — but a pooled marker reused in the + // container it already sits in keeps its stale child index, so that sort + // stopped being reflected in the display list after the first resync. + // Pixi's addChild splices an existing child out and pushes it to the + // end, so calling it every time is what applies the ordering. Invisible + // today because every marker is the same provisional circle; it would + // surface as soon as real atlas frames overlap. + container.addChild(marker); + this.#activeMarkers.push(marker); + } + } + } + + #takeMarker(): Graphics { + const pooled = this.#markerPool.pop(); + if (pooled) { + return pooled; + } + // Provisional marker geometry. Production atlases are BLOCKED_EXTERNAL, so + // placements are drawn as a deliberately plain shape rather than as art + // guessed at here; the manifest already fixes the atlas/frame contract they + // will be loaded through. + return new Graphics() + .circle(0, 0, 0.32) + .fill({ color: 0x4f7f7a, alpha: 0.34 }) + .stroke({ color: 0x8fd4c8, width: 0.06, alpha: 0.5 }); } #buildRetainedScene(): void { @@ -184,7 +317,23 @@ export class PixiWreckRenderer implements SceneRenderer { .stroke({ color: 0x17252a, width: 0.25 }); this.#diver.addChild(this.#torch, diverBody); - this.#world.addChild( + + // Explicit, named layers replace a flat addChild list. Draw order is now a + // declared property of the scene rather than an accident of call order, and + // data-driven placements have somewhere to go. + for (const id of LAYERS) { + const container = new Container(); + container.label = id; + this.#layers.set(id, container); + } + + // Draw order is only "declared" if something can read the declaration. + // Expressed as addChild calls it was still an accident of call order, and + // nothing could assert it — which is how silt ended up in `terrain`, below + // the hull, hiding 31 of its 48 particles. RETAINED_LAYER_ASSIGNMENT is the + // declaration; see render/layer-assignment.ts for why each element sits + // where it does, and site-layers.test.ts for the invariants it must hold. + const retained: Readonly> = { distantHull, seabed, hull, @@ -192,15 +341,25 @@ export class PixiWreckRenderer implements SceneRenderer { engine, route, silt, - this.#diver, - ); + diver: this.#diver, + }; + for (const [element, layerId] of Object.entries(RETAINED_LAYER_ASSIGNMENT)) { + this.#layers.get(layerId)?.addChild(retained[element as RetainedElement]); + } + + for (const id of LAYERS) { + const container = this.#layers.get(id); + if (container) { + this.#world.addChild(container); + } + } for (let index = 0; index < BUBBLE_COUNT; index += 1) { const bubble = new Graphics() .circle(0, 0, 0.08 + (index % 4) * 0.025) .stroke({ color: 0xbdefff, width: 0.045, alpha: 0.88 }); this.#bubbles.push(bubble); - this.#world.addChild(bubble); + this.#layers.get(BUBBLE_LAYER)?.addChild(bubble); } } diff --git a/src/sites/asset-manifest.ts b/src/sites/asset-manifest.ts new file mode 100644 index 0000000..dcbe6e5 --- /dev/null +++ b/src/sites/asset-manifest.ts @@ -0,0 +1,107 @@ +// WP-07: the asset contract for decorative site content. +// +// Every authored feature kind maps to exactly one asset entry. The mapping is +// hand-authored because it is art direction, but its *completeness* is enforced +// by test: a new feature kind fails the build until someone assigns it an +// asset, rather than silently rendering nothing. +// +// Nothing here may influence collision, air, spawning or currents. Those live +// in the gameplay resource and cannot import this file. + +export const LAYERS = [ + "backdrop", + "terrain", + "structure", + "decoration", + "fauna", + "foreground", +] as const; + +export type LayerId = (typeof LAYERS)[number]; + +/** + * Quality tiers exist so a low-end device drops decoration before it drops the + * things a diver navigates by. Ordered cheapest first; a tier renders every + * asset whose minimum tier is at or below it. + */ +export const QUALITY_TIERS = ["low", "medium", "high"] as const; + +export type QualityTier = (typeof QUALITY_TIERS)[number]; + +export interface AssetEntry { + /** Stable id. Atlas frames are addressed by this, not by feature kind. */ + readonly id: string; + readonly atlas: string; + readonly frame: string; + readonly layer: LayerId; + readonly minimumQualityTier: QualityTier; +} + +function entry( + id: string, + atlas: string, + layer: LayerId, + minimumQualityTier: QualityTier = "low", +): AssetEntry { + // Atlas frame names follow `` within ``, so a frame can be + // repacked or re-authored without touching site data or this table. + return { id, atlas, frame: id.split("/")[1] ?? id, layer, minimumQualityTier }; +} + +export const ASSET_MANIFEST: Readonly> = Object.freeze({ + // shore + buoy: entry("shore/buoy", "shore", "foreground"), + towel: entry("shore/towel", "shore", "decoration", "medium"), + umbrella: entry("shore/umbrella", "shore", "decoration", "medium"), + seagrass: entry("shore/seagrass", "shore", "decoration", "low"), + coral: entry("shore/coral", "shore", "decoration", "medium"), + anchor: entry("shared/anchor", "shared", "structure"), + + // reef + tableCoral: entry("reef/tableCoral", "reef", "decoration"), + brainCoral: entry("reef/brainCoral", "reef", "decoration"), + staghorn: entry("reef/staghorn", "reef", "decoration", "medium"), + softCoral: entry("reef/softCoral", "reef", "decoration", "medium"), + anthiasCloud: entry("reef/anthiasCloud", "reef", "fauna", "high"), + gorgonian: entry("reef/gorgonian", "reef", "decoration", "medium"), + barrelSponge: entry("reef/barrelSponge", "reef", "decoration"), + + // wreck + helm: entry("wreck/helm", "wreck", "structure"), + lightShaft: entry("wreck/lightShaft", "wreck", "foreground", "medium"), + messTable: entry("wreck/messTable", "wreck", "decoration", "medium"), + lifeboat: entry("wreck/lifeboat", "wreck", "structure"), + bowVisor: entry("wreck/bowVisor", "wreck", "structure"), + lorry: entry("wreck/lorry", "wreck", "structure"), + car: entry("wreck/car", "wreck", "structure"), + bunk: entry("wreck/bunk", "wreck", "decoration", "medium"), + container: entry("wreck/container", "wreck", "structure"), + engine: entry("wreck/engine", "wreck", "structure"), + rustHole: entry("wreck/rustHole", "wreck", "decoration"), + line: entry("shared/line", "shared", "foreground"), + net: entry("wreck/net", "wreck", "decoration", "medium"), + + // cave + pond: entry("cave/pond", "cave", "backdrop"), + warningSign: entry("cave/warningSign", "cave", "foreground"), + caveColumn: entry("cave/caveColumn", "cave", "structure"), +}); + +export function assetFor(featureKind: string): AssetEntry | null { + return ASSET_MANIFEST[featureKind] ?? null; +} + +export function atlasesFor(featureKinds: Iterable): string[] { + const atlases = new Set(); + for (const kind of featureKinds) { + const asset = assetFor(kind); + if (asset) { + atlases.add(asset.atlas); + } + } + return [...atlases].sort(); +} + +export function tierAllows(tier: QualityTier, minimum: QualityTier): boolean { + return QUALITY_TIERS.indexOf(tier) >= QUALITY_TIERS.indexOf(minimum); +} diff --git a/src/sites/deep-freeze.ts b/src/sites/deep-freeze.ts new file mode 100644 index 0000000..7670afa --- /dev/null +++ b/src/sites/deep-freeze.ts @@ -0,0 +1,20 @@ +// WP-07: `Object.freeze` is shallow, so freezing a site document leaves every +// nested `structures`, `floor` and `features` array writable. That makes the +// immutability the resource modules advertise nominal rather than real — a +// consumer holding a reference could mutate collision geometry in place and +// every other consumer would see it. +// +// Kept as its own module because the gameplay and presentation resources must +// not import each other, and neither should have to restate this. + +/** Recursively freezes a value and everything reachable from it. */ +export function deepFreeze(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return value; + } + Object.freeze(value); + for (const nested of Object.values(value as Record)) { + deepFreeze(nested); + } + return value; +} diff --git a/src/sites/layer-factory.ts b/src/sites/layer-factory.ts new file mode 100644 index 0000000..e5970cc --- /dev/null +++ b/src/sites/layer-factory.ts @@ -0,0 +1,262 @@ +// WP-07: renderer-neutral scene assembly. +// +// Produces an ordered, culled list of placements from presentation data. It +// builds no Pixi objects, so it is testable without a GPU and a second renderer +// could consume the same output. The Pixi scene becomes a consumer of this +// rather than a hand-written pile of Graphics calls. + +import presentationDocument from "./resources/presentation.json"; +import { deepFreeze } from "./deep-freeze"; +import { + assetFor, + atlasesFor, + LAYERS, + tierAllows, + type AssetEntry, + type LayerId, + type QualityTier, +} from "./asset-manifest"; + +/** + * Authored features are not uniform. Some are points with a `d`; others span a + * depth range with `dTop`/`dBottom`, and kinds carry their own extra fields. + * Model that rather than assuming a shape the data does not have. + */ +export interface SiteFeature { + readonly kind: string; + readonly x: number; + readonly d?: number; + readonly dTop?: number; + readonly dBottom?: number; + readonly [extra: string]: unknown; +} + +/** Depth a feature is anchored at: its point, or the top of its span. */ +export function featureDepth(feature: SiteFeature): number { + return feature.d ?? feature.dTop ?? 0; +} + +/** + * Vertical extent a feature actually occupies. Culling has to use this rather + * than the anchor: a cave column spans 42 m from `dTop` to `dBottom`, so a diver + * swimming near its base is well outside the anchor's cull window while the + * column still fills the screen. Anchoring and culling are different questions. + */ +export function featureDepthRange(feature: SiteFeature): { + readonly top: number; + readonly bottom: number; +} { + const anchor = featureDepth(feature); + const top = feature.dTop ?? anchor; + const bottom = feature.dBottom ?? anchor; + return top <= bottom ? { top, bottom } : { top: bottom, bottom: top }; +} + +export interface VisualZone { + readonly id: string; + readonly x1: number; + readonly x2: number; + readonly d1: number; + readonly d2: number; + readonly priority?: number; +} + +export interface DecorationRule { + readonly id: string; + readonly zone: string; +} + +export interface SitePresentation { + readonly id: string; + readonly name?: string; + readonly features?: readonly SiteFeature[]; + readonly visualZones?: readonly VisualZone[]; + readonly decorationRules?: readonly DecorationRule[]; + readonly atmosphereProfiles?: Readonly>; +} + +export interface CameraBounds { + readonly leftM: number; + readonly rightM: number; + readonly topM: number; + readonly bottomM: number; +} + +export interface Placement { + readonly assetId: string; + readonly atlas: string; + readonly frame: string; + readonly x: number; + readonly d: number; +} + +export interface SceneLayer { + readonly id: LayerId; + readonly placements: readonly Placement[]; +} + +const presentation = ( + presentationDocument as { sites: Record } +).sites; + +/** + * Cross-references and uniqueness the JSON Schema cannot express. A schema can + * say `zone` is a string; it cannot say the string names a zone that exists. + * + * Each of these fails silently rather than loudly: + * - A decoration rule pointing at a zone that is not declared matches no + * candidate, so every prop it would have placed just never appears. A typo in + * `zone` empties a whole scatter with nothing logged. + * - `atmosphereProfiles` is keyed by zone id, so an unresolved key is an + * atmosphere that never applies. + * - Duplicate zone or rule ids make `visualZoneAt` resolution order depend on + * declaration order rather than on the documented priority rules. + * - An inverted zone extent can never contain a point, so the zone stops + * existing exactly like an inverted structure box does in gameplay data. + */ +export function validateSitePresentation(site: SitePresentation): string[] { + const problems: string[] = []; + const zoneIds = new Set((site.visualZones ?? []).map((zone) => zone.id)); + + const checkUnique = (label: string, ids: readonly string[]): void => { + const seen = new Set(); + for (const id of ids) { + if (seen.has(id)) { + problems.push(`${site.id}: duplicate ${label} id "${id}"`); + } + seen.add(id); + } + }; + + checkUnique("visualZone", (site.visualZones ?? []).map((zone) => zone.id)); + checkUnique("decorationRule", (site.decorationRules ?? []).map((rule) => rule.id)); + + for (const zone of site.visualZones ?? []) { + if (zone.x2 < zone.x1) { + problems.push(`${site.id}: zone "${zone.id}" has x2 ${zone.x2} before x1 ${zone.x1}`); + } + if (zone.d2 < zone.d1) { + problems.push(`${site.id}: zone "${zone.id}" has d2 ${zone.d2} above d1 ${zone.d1}`); + } + } + + for (const rule of site.decorationRules ?? []) { + if (!zoneIds.has(rule.zone)) { + problems.push( + `${site.id}: decoration rule "${rule.id}" references unknown zone "${rule.zone}"`, + ); + } + } + + for (const key of Object.keys(site.atmosphereProfiles ?? {})) { + if (!zoneIds.has(key)) { + problems.push(`${site.id}: atmosphere profile "${key}" matches no visual zone`); + } + } + + return problems; +} + +const presentationProblems = Object.values(presentation).flatMap( + validateSitePresentation, +); +if (presentationProblems.length) { + // Fail at import time, matching the gameplay resource. A scatter that renders + // nothing is far harder to notice than a build that refuses to start. + throw new Error( + `invalid site presentation data:\n${presentationProblems.join("\n")}`, + ); +} + +export const SITE_PRESENTATION: Readonly> = + deepFreeze(presentation); + +export function sitePresentation(id: string): SitePresentation | null { + return SITE_PRESENTATION[id] ?? null; +} + +/** Every feature kind the authored data uses, across all sites. */ +export function authoredFeatureKinds(): string[] { + const kinds = new Set(); + for (const site of Object.values(SITE_PRESENTATION)) { + for (const feature of site.features ?? []) { + kinds.add(feature.kind); + } + } + return [...kinds].sort(); +} + +/** Atlases a site needs loaded before its first frame. */ +export function requiredAtlases(siteId: string): string[] { + const site = sitePresentation(siteId); + if (!site) { + return []; + } + return atlasesFor((site.features ?? []).map((feature) => feature.kind)); +} + +export interface BuildOptions { + readonly camera: CameraBounds; + readonly qualityTier: QualityTier; + /** Expand the cull window so content is ready before it scrolls in. */ + readonly cullMarginM?: number; +} + +export function buildSceneLayers( + siteId: string, + options: BuildOptions, +): SceneLayer[] { + const site = sitePresentation(siteId); + const byLayer = new Map( + LAYERS.map((layer) => [layer, [] as Placement[]]), + ); + if (!site) { + return LAYERS.map((id) => ({ id, placements: [] })); + } + + const margin = options.cullMarginM ?? 10; + const { camera } = options; + + for (const feature of site.features ?? []) { + const asset: AssetEntry | null = assetFor(feature.kind); + if (!asset) { + // Unmapped kinds are dropped rather than thrown here; the completeness + // test is what fails the build, so a data typo cannot blank a live scene. + continue; + } + if (!tierAllows(options.qualityTier, asset.minimumQualityTier)) { + continue; + } + const depth = featureDepth(feature); + // Overlap test against the feature's whole extent, so a tall feature stays + // in the scene for as long as any part of it is on screen. + const span = featureDepthRange(feature); + if ( + feature.x < camera.leftM - margin || + feature.x > camera.rightM + margin || + span.bottom < camera.topM - margin || + span.top > camera.bottomM + margin + ) { + continue; + } + + byLayer.get(asset.layer)?.push({ + assetId: asset.id, + atlas: asset.atlas, + frame: asset.frame, + x: feature.x, + d: depth, + }); + } + + // Stable draw order within a layer: nearer the surface first, then by x, so + // the same data always produces the same scene regardless of authoring order. + for (const placements of byLayer.values()) { + placements.sort((a, b) => a.d - b.d || a.x - b.x || a.assetId.localeCompare(b.assetId)); + } + + return LAYERS.map((id) => ({ + id, + placements: Object.freeze(byLayer.get(id) ?? []), + })); +} diff --git a/src/sites/resources/gameplay.json b/src/sites/resources/gameplay.json new file mode 100644 index 0000000..913fe92 --- /dev/null +++ b/src/sites/resources/gameplay.json @@ -0,0 +1,692 @@ +{ + "schemaVersion": 1, + "sourceDigest": "sha256:58e238f55504ff06feb901fde6adc3d0234ab8a9b0bfbb3702a803b1cf35bc69", + "generator": "npm run sites:generate", + "kind": "diving-simulator-site-gameplay", + "sites": { + "shore": { + "id": "shore", + "maxDepth": 32, + "hasOverhead": false, + "entry": { + "x": 0 + }, + "boatX": 100, + "floor": [ + { + "x": -10, + "d": 0 + }, + { + "x": 0, + "d": 3 + }, + { + "x": 35, + "d": 6 + }, + { + "x": 70, + "d": 10 + }, + { + "x": 95, + "d": 14 + }, + { + "x": 115, + "d": 22 + }, + { + "x": 140, + "d": 28 + }, + { + "x": 185, + "d": 30 + } + ], + "ceiling": null, + "structures": [ + { + "x1": 100, + "x2": 118, + "dTop": 14, + "dBottom": 26, + "kind": "rock" + }, + { + "x1": 122, + "x2": 132, + "dTop": 18, + "dBottom": 30, + "kind": "rock" + }, + { + "x1": 135, + "x2": 152, + "dTop": 24, + "dBottom": 30, + "kind": "wreckSmall" + }, + { + "x1": 158, + "x2": 171, + "dTop": 25, + "dBottom": 36, + "kind": "rock" + }, + { + "x1": 175, + "x2": 184, + "dTop": 27, + "dBottom": 37, + "kind": "rock" + } + ], + "badAir": [], + "currentBias": 0, + "noShark": true + }, + "reef": { + "id": "reef", + "maxDepth": 300, + "hasOverhead": false, + "entry": { + "x": 0 + }, + "boatX": 60, + "floor": [ + { + "x": -200, + "d": 300 + }, + { + "x": -26, + "d": 300 + }, + { + "x": -20, + "d": 90 + }, + { + "x": -12, + "d": 30 + }, + { + "x": -9, + "d": 12 + }, + { + "x": -8, + "d": 5 + }, + { + "x": 8, + "d": 5 + }, + { + "x": 9, + "d": 12 + }, + { + "x": 12, + "d": 30 + }, + { + "x": 20, + "d": 90 + }, + { + "x": 26, + "d": 300 + }, + { + "x": 200, + "d": 300 + } + ], + "ceiling": null, + "structures": [], + "badAir": [], + "currentBias": 0.4, + "noShark": false + }, + "wreck": { + "id": "wreck", + "maxDepth": 68, + "hasOverhead": true, + "entry": { + "x": 0 + }, + "boatX": 5, + "floor": [ + { + "x": -40, + "d": 66 + }, + { + "x": 200, + "d": 66 + } + ], + "ceiling": null, + "structures": [ + { + "x1": 14, + "x2": 170, + "dTop": 65, + "dBottom": 66, + "kind": "hull" + }, + { + "x1": 14, + "x2": 16, + "dTop": 28, + "dBottom": 66, + "kind": "hull" + }, + { + "x1": 168, + "x2": 170, + "dTop": 28, + "dBottom": 66, + "kind": "hull" + }, + { + "x1": 22, + "x2": 78, + "dTop": 27, + "dBottom": 28, + "kind": "deck" + }, + { + "x1": 92, + "x2": 148, + "dTop": 27, + "dBottom": 28, + "kind": "deck" + }, + { + "x1": 40, + "x2": 42, + "dTop": 22, + "dBottom": 28, + "kind": "bulkhead" + }, + { + "x1": 138, + "x2": 140, + "dTop": 22, + "dBottom": 28, + "kind": "bulkhead" + }, + { + "x1": 88, + "x2": 90, + "dTop": 23, + "dBottom": 26, + "kind": "bulkhead" + }, + { + "x1": 42, + "x2": 78, + "dTop": 22, + "dBottom": 23, + "kind": "deck" + }, + { + "x1": 92, + "x2": 138, + "dTop": 22, + "dBottom": 23, + "kind": "deck" + }, + { + "x1": 70, + "x2": 72, + "dTop": 18, + "dBottom": 23, + "kind": "bulkhead" + }, + { + "x1": 108, + "x2": 110, + "dTop": 18, + "dBottom": 23, + "kind": "bulkhead" + }, + { + "x1": 72, + "x2": 108, + "dTop": 18, + "dBottom": 19, + "kind": "deck" + }, + { + "x1": 84, + "x2": 96, + "dTop": 14, + "dBottom": 19, + "kind": "funnel" + }, + { + "x1": 75, + "x2": 76, + "dTop": 10, + "dBottom": 18, + "kind": "mast" + }, + { + "x1": 14, + "x2": 78, + "dTop": 39, + "dBottom": 40, + "kind": "deck" + }, + { + "x1": 92, + "x2": 168, + "dTop": 39, + "dBottom": 40, + "kind": "deck" + }, + { + "x1": 14, + "x2": 78, + "dTop": 45, + "dBottom": 46, + "kind": "deck" + }, + { + "x1": 92, + "x2": 168, + "dTop": 45, + "dBottom": 46, + "kind": "deck" + }, + { + "x1": 14, + "x2": 78, + "dTop": 52, + "dBottom": 53, + "kind": "deck" + }, + { + "x1": 92, + "x2": 168, + "dTop": 52, + "dBottom": 53, + "kind": "deck" + }, + { + "x1": 14, + "x2": 78, + "dTop": 61, + "dBottom": 62, + "kind": "deck" + }, + { + "x1": 92, + "x2": 168, + "dTop": 61, + "dBottom": 62, + "kind": "deck" + }, + { + "x1": 22, + "x2": 23, + "dTop": 40, + "dBottom": 43.5, + "kind": "bulkhead" + }, + { + "x1": 32, + "x2": 33, + "dTop": 41.5, + "dBottom": 45, + "kind": "bulkhead" + }, + { + "x1": 42, + "x2": 43, + "dTop": 40, + "dBottom": 43.5, + "kind": "bulkhead" + }, + { + "x1": 52, + "x2": 53, + "dTop": 41.5, + "dBottom": 45, + "kind": "bulkhead" + }, + { + "x1": 62, + "x2": 63, + "dTop": 40, + "dBottom": 43.5, + "kind": "bulkhead" + }, + { + "x1": 72, + "x2": 73, + "dTop": 41.5, + "dBottom": 45, + "kind": "bulkhead" + }, + { + "x1": 96, + "x2": 97, + "dTop": 40, + "dBottom": 43.5, + "kind": "bulkhead" + }, + { + "x1": 106, + "x2": 107, + "dTop": 41.5, + "dBottom": 45, + "kind": "bulkhead" + }, + { + "x1": 116, + "x2": 117, + "dTop": 40, + "dBottom": 43.5, + "kind": "bulkhead" + }, + { + "x1": 126, + "x2": 127, + "dTop": 41.5, + "dBottom": 45, + "kind": "bulkhead" + }, + { + "x1": 136, + "x2": 137, + "dTop": 40, + "dBottom": 43.5, + "kind": "bulkhead" + }, + { + "x1": 146, + "x2": 147, + "dTop": 41.5, + "dBottom": 45, + "kind": "bulkhead" + }, + { + "x1": 156, + "x2": 157, + "dTop": 40, + "dBottom": 43.5, + "kind": "bulkhead" + }, + { + "x1": 28, + "x2": 29, + "dTop": 46, + "dBottom": 50.5, + "kind": "bulkhead" + }, + { + "x1": 42, + "x2": 43, + "dTop": 47.5, + "dBottom": 52, + "kind": "bulkhead" + }, + { + "x1": 56, + "x2": 57, + "dTop": 46, + "dBottom": 52, + "kind": "bulkhead" + }, + { + "x1": 70, + "x2": 71, + "dTop": 47.5, + "dBottom": 52, + "kind": "bulkhead" + }, + { + "x1": 100, + "x2": 101, + "dTop": 46, + "dBottom": 50.5, + "kind": "bulkhead" + }, + { + "x1": 114, + "x2": 115, + "dTop": 47.5, + "dBottom": 52, + "kind": "bulkhead" + }, + { + "x1": 128, + "x2": 129, + "dTop": 46, + "dBottom": 50.5, + "kind": "bulkhead" + }, + { + "x1": 142, + "x2": 143, + "dTop": 46, + "dBottom": 52, + "kind": "bulkhead" + }, + { + "x1": 156, + "x2": 157, + "dTop": 47.5, + "dBottom": 52, + "kind": "bulkhead" + }, + { + "x1": 34, + "x2": 35, + "dTop": 53, + "dBottom": 59, + "kind": "bulkhead" + }, + { + "x1": 56, + "x2": 57, + "dTop": 53, + "dBottom": 59, + "kind": "bulkhead" + }, + { + "x1": 112, + "x2": 113, + "dTop": 53, + "dBottom": 59, + "kind": "bulkhead" + }, + { + "x1": 134, + "x2": 135, + "dTop": 53, + "dBottom": 59, + "kind": "bulkhead" + }, + { + "x1": 156, + "x2": 157, + "dTop": 53, + "dBottom": 59, + "kind": "bulkhead" + } + ], + "badAir": [], + "currentBias": 0.2, + "noShark": false + }, + "cave": { + "id": "cave", + "maxDepth": 106, + "hasOverhead": true, + "entry": { + "x": 0 + }, + "floor": [ + { + "x": -10, + "d": 2 + }, + { + "x": 0, + "d": 10 + }, + { + "x": 15, + "d": 16 + }, + { + "x": 30, + "d": 20 + }, + { + "x": 50, + "d": 23 + }, + { + "x": 56, + "d": 42 + }, + { + "x": 64, + "d": 74 + }, + { + "x": 72, + "d": 96 + }, + { + "x": 90, + "d": 103 + }, + { + "x": 112, + "d": 103 + }, + { + "x": 124, + "d": 95 + }, + { + "x": 132, + "d": 74 + }, + { + "x": 140, + "d": 42 + }, + { + "x": 146, + "d": 24 + }, + { + "x": 160, + "d": 20 + }, + { + "x": 185, + "d": 14 + }, + { + "x": 200, + "d": 6 + } + ], + "ceiling": [ + { + "x": -10, + "d": 0 + }, + { + "x": 14, + "d": 0 + }, + { + "x": 18, + "d": 14 + }, + { + "x": 30, + "d": 15 + }, + { + "x": 50, + "d": 14 + }, + { + "x": 75, + "d": 13 + }, + { + "x": 103, + "d": 12 + }, + { + "x": 109, + "d": 12 + }, + { + "x": 130, + "d": 14 + }, + { + "x": 146, + "d": 16 + }, + { + "x": 160, + "d": 15 + }, + { + "x": 185, + "d": 9 + }, + { + "x": 196, + "d": 4 + }, + { + "x": 200, + "d": 0 + } + ], + "structures": [ + { + "x1": 70, + "x2": 130, + "dTop": 22, + "dBottom": 52, + "kind": "bedrock" + }, + { + "x1": 88, + "x2": 91, + "dTop": 12, + "dBottom": 16, + "kind": "pillar" + } + ], + "badAir": [ + { + "x1": 103, + "x2": 109, + "d": 12 + } + ], + "currentBias": 0.05, + "noShark": true + } + } +} diff --git a/src/sites/resources/presentation.json b/src/sites/resources/presentation.json new file mode 100644 index 0000000..ea0a7e7 --- /dev/null +++ b/src/sites/resources/presentation.json @@ -0,0 +1,1743 @@ +{ + "schemaVersion": 1, + "sourceDigest": "sha256:58e238f55504ff06feb901fde6adc3d0234ab8a9b0bfbb3702a803b1cf35bc69", + "generator": "npm run sites:generate", + "kind": "diving-simulator-site-presentation", + "sites": { + "shore": { + "id": "shore", + "name": "Shore", + "surfaceMarker": "buoy", + "features": [ + { + "kind": "buoy", + "x": 0 + }, + { + "kind": "towel", + "x": -17 + }, + { + "kind": "umbrella", + "x": -13 + }, + { + "kind": "towel", + "x": -6 + }, + { + "kind": "seagrass", + "x": 18, + "d": 5 + }, + { + "kind": "seagrass", + "x": 25, + "d": 6 + }, + { + "kind": "seagrass", + "x": 32, + "d": 6 + }, + { + "kind": "seagrass", + "x": 45, + "d": 8 + }, + { + "kind": "seagrass", + "x": 52, + "d": 8 + }, + { + "kind": "seagrass", + "x": 60, + "d": 9 + }, + { + "kind": "seagrass", + "x": 68, + "d": 10 + }, + { + "kind": "seagrass", + "x": 78, + "d": 11 + }, + { + "kind": "seagrass", + "x": 82, + "d": 12 + }, + { + "kind": "seagrass", + "x": 86, + "d": 13 + }, + { + "kind": "coral", + "x": 85, + "d": 13 + }, + { + "kind": "coral", + "x": 90, + "d": 13 + }, + { + "kind": "coral", + "x": 152, + "d": 28 + }, + { + "kind": "anchor", + "x": 156 + }, + { + "kind": "seagrass", + "x": 159, + "d": 29 + }, + { + "kind": "seagrass", + "x": 163, + "d": 29 + }, + { + "kind": "seagrass", + "x": 168, + "d": 29 + }, + { + "kind": "coral", + "x": 180, + "d": 30 + } + ], + "visualZones": [ + { + "id": "shore_entry", + "x1": -20, + "x2": 25, + "d1": 0, + "d2": 4, + "priority": 10, + "blend": 1, + "tags": [ + "shore", + "sand", + "sunlit", + "shallow" + ] + }, + { + "id": "shore_grass", + "x1": 10, + "x2": 88, + "d1": 3, + "d2": 13, + "priority": 10, + "blend": 2, + "tags": [ + "shore", + "sand", + "seagrass", + "shallow" + ] + }, + { + "id": "shore_slope", + "x1": 55, + "x2": 118, + "d1": 10, + "d2": 22, + "priority": 8, + "blend": 2, + "tags": [ + "shore", + "sand", + "slope" + ] + }, + { + "id": "shore_boulder_gate", + "x1": 95, + "x2": 134, + "d1": 12, + "d2": 22, + "priority": 14, + "blend": 2, + "tags": [ + "shore", + "sand", + "boulder-gate", + "transition" + ] + }, + { + "id": "shore_deep", + "x1": 90, + "x2": 190, + "d1": 18, + "d2": 32, + "priority": 12, + "blend": 2, + "tags": [ + "shore", + "sand", + "deep", + "wreck-debris" + ] + } + ], + "atmosphereProfiles": { + "shore_entry": { + "visibility": 1.1, + "tint": [ + 1, + 1.05, + 0.98 + ], + "particleDensity": 1.1, + "particleBrightness": 1.15, + "ambient": 1.15 + }, + "shore_grass": { + "visibility": 0.95, + "tint": [ + 0.98, + 1.02, + 0.98 + ], + "particleDensity": 1.25, + "particleBrightness": 1, + "ambient": 1 + }, + "shore_slope": { + "visibility": 1, + "tint": [ + 1, + 1, + 1 + ], + "particleDensity": 1, + "particleBrightness": 1, + "ambient": 0.95 + }, + "shore_boulder_gate": { + "visibility": 0.98, + "tint": [ + 1.02, + 0.98, + 0.94 + ], + "particleDensity": 1.05, + "particleBrightness": 0.95, + "ambient": 0.92 + }, + "shore_deep": { + "visibility": 0.85, + "tint": [ + 0.9, + 0.95, + 1 + ], + "particleDensity": 0.9, + "particleBrightness": 0.8, + "ambient": 0.8 + } + }, + "decorationRules": [ + { + "id": "shore_entry_shells", + "zone": "shore_entry", + "spacing": 1.9, + "density": 0.6, + "seed": 1101, + "surface": "floor", + "props": [ + { + "kind": "shell", + "weight": 2 + }, + { + "kind": "pebble", + "weight": 3 + }, + { + "kind": "smallRock", + "weight": 1 + } + ] + }, + { + "id": "shore_grass_micro", + "zone": "shore_grass", + "spacing": 1.4, + "density": 0.85, + "seed": 1102, + "surface": "floor", + "props": [ + { + "kind": "grassTuft", + "weight": 3 + }, + { + "kind": "pebble", + "weight": 4 + }, + { + "kind": "sandRippleAccent", + "weight": 2 + } + ] + }, + { + "id": "shore_boulder_gate_stones", + "zone": "shore_boulder_gate", + "spacing": 1.8, + "density": 0.6, + "seed": 1104, + "surface": "floor", + "props": [ + { + "kind": "smallRock", + "weight": 4 + }, + { + "kind": "pebble", + "weight": 3 + }, + { + "kind": "debrisSpeck", + "weight": 1 + } + ] + }, + { + "id": "shore_deep_debris", + "zone": "shore_deep", + "spacing": 2.5, + "density": 0.45, + "seed": 1103, + "surface": "floor", + "props": [ + { + "kind": "smallRock", + "weight": 3 + }, + { + "kind": "pebble", + "weight": 2 + }, + { + "kind": "debrisSpeck", + "weight": 2 + } + ] + } + ] + }, + "reef": { + "id": "reef", + "name": "Reef", + "surfaceMarker": "boat", + "features": [ + { + "kind": "tableCoral", + "x": -4, + "d": 5 + }, + { + "kind": "tableCoral", + "x": 2, + "d": 5 + }, + { + "kind": "brainCoral", + "x": 5, + "d": 5 + }, + { + "kind": "brainCoral", + "x": 6, + "d": 5 + }, + { + "kind": "staghorn", + "x": -2, + "d": 5 + }, + { + "kind": "staghorn", + "x": 1, + "d": 5 + }, + { + "kind": "softCoral", + "x": -6, + "d": 5, + "color": "#c84a8a" + }, + { + "kind": "softCoral", + "x": 7, + "d": 5, + "color": "#e8839a" + }, + { + "kind": "anthiasCloud", + "x": 0, + "d": 3, + "w": 240, + "h": 90, + "count": 70, + "dir": 1 + }, + { + "kind": "softCoral", + "x": 10, + "d": 18, + "color": "#e8839a" + }, + { + "kind": "softCoral", + "x": -10, + "d": 18, + "color": "#c84a8a" + }, + { + "kind": "gorgonian", + "x": 11, + "d": 24, + "side": "right", + "color": "#c83a5a" + }, + { + "kind": "gorgonian", + "x": -11, + "d": 24, + "side": "left", + "color": "#a83a4a" + }, + { + "kind": "brainCoral", + "x": -10.5, + "d": 21 + }, + { + "kind": "barrelSponge", + "x": 12, + "d": 30, + "color": "#9c5a3a" + }, + { + "kind": "barrelSponge", + "x": -12, + "d": 30, + "color": "#8a4828" + }, + { + "kind": "gorgonian", + "x": 14, + "d": 32, + "side": "right", + "color": "#d84a6a", + "scale": 2.15 + }, + { + "kind": "gorgonian", + "x": 13, + "d": 37, + "side": "right", + "color": "#c83a5a" + }, + { + "kind": "gorgonian", + "x": -13, + "d": 37, + "side": "left", + "color": "#882a3a" + }, + { + "kind": "softCoral", + "x": 15, + "d": 52, + "color": "#7a4a8a" + }, + { + "kind": "anthiasCloud", + "x": -17, + "d": 45, + "w": 200, + "h": 130, + "count": 80, + "dir": 1 + }, + { + "kind": "gorgonian", + "x": 16, + "d": 60, + "side": "right", + "color": "#882a3a" + }, + { + "kind": "softCoral", + "x": -16, + "d": 60, + "color": "#7a4a8a" + }, + { + "kind": "gorgonian", + "x": -18, + "d": 78, + "side": "left", + "color": "#4a1e2c", + "scale": 1.6 + } + ], + "visualZones": [ + { + "id": "reef_plateau", + "x1": -8, + "x2": 8, + "d1": 0, + "d2": 8, + "priority": 20, + "blend": 1, + "tags": [ + "reef", + "sunlit", + "plateau", + "coral" + ] + }, + { + "id": "reef_upper_wall", + "x1": -16, + "x2": 16, + "d1": 8, + "d2": 30, + "priority": 10, + "blend": 2, + "tags": [ + "reef", + "wall", + "upper", + "coral" + ] + }, + { + "id": "reef_crack_cue", + "x1": -14.5, + "x2": -13.5, + "d1": 15, + "d2": 40, + "priority": 22, + "blend": 2, + "tags": [ + "reef", + "wall", + "crack", + "shadow" + ] + }, + { + "id": "reef_mid_wall", + "x1": -20, + "x2": 20, + "d1": 30, + "d2": 55, + "priority": 10, + "blend": 2, + "tags": [ + "reef", + "wall", + "mid" + ] + }, + { + "id": "reef_deep_wall", + "x1": -24, + "x2": 24, + "d1": 55, + "d2": 90, + "priority": 10, + "blend": 3, + "tags": [ + "reef", + "wall", + "deep" + ] + }, + { + "id": "reef_blue_water", + "x1": -100, + "x2": 100, + "d1": 0, + "d2": 300, + "priority": 0, + "blend": 0, + "tags": [ + "open-water", + "blue" + ] + } + ], + "atmosphereProfiles": { + "reef_plateau": { + "visibility": 1.15, + "tint": [ + 1.02, + 1.05, + 1.02 + ], + "particleDensity": 0.75, + "particleBrightness": 1.15, + "ambient": 1.1 + }, + "reef_upper_wall": { + "visibility": 1.05, + "tint": [ + 0.98, + 1, + 1.02 + ], + "particleDensity": 0.85, + "particleBrightness": 1.05, + "ambient": 1 + }, + "reef_crack_cue": { + "visibility": 0.7, + "tint": [ + 0.8, + 0.88, + 1.02 + ], + "particleDensity": 0.45, + "particleBrightness": 0.6, + "ambient": 0.55 + }, + "reef_mid_wall": { + "visibility": 0.95, + "tint": [ + 0.92, + 0.96, + 1.02 + ], + "particleDensity": 0.85, + "particleBrightness": 0.9, + "ambient": 0.85 + }, + "reef_deep_wall": { + "visibility": 0.8, + "tint": [ + 0.78, + 0.88, + 1.05 + ], + "particleDensity": 0.45, + "particleBrightness": 0.7, + "ambient": 0.68 + }, + "reef_blue_water": { + "visibility": 1.2, + "tint": [ + 0.95, + 0.98, + 1.05 + ], + "particleDensity": 0.35, + "particleBrightness": 0.9, + "ambient": 1 + } + }, + "decorationRules": [ + { + "id": "reef_plateau_crust", + "zone": "reef_plateau", + "spacing": 1.3, + "density": 0.85, + "seed": 2101, + "surface": "floor", + "props": [ + { + "kind": "reefCrustBlob", + "weight": 4 + }, + { + "kind": "tinySponge", + "weight": 1 + }, + { + "kind": "pebble", + "weight": 2 + } + ] + }, + { + "id": "reef_upper_wall_micro", + "zone": "reef_upper_wall", + "spacing": 2.5, + "density": 0.45, + "seed": 2102, + "surface": "floor", + "props": [ + { + "kind": "reefCrustBlob", + "weight": 2 + }, + { + "kind": "smallCoralBranch", + "weight": 1 + }, + { + "kind": "debrisSpeck", + "weight": 2 + } + ] + }, + { + "id": "reef_deep_wall_sparse", + "zone": "reef_deep_wall", + "spacing": 5, + "density": 0.2, + "seed": 2103, + "surface": "floor", + "props": [ + { + "kind": "smallRock", + "weight": 3 + }, + { + "kind": "debrisSpeck", + "weight": 2 + } + ] + } + ] + }, + "wreck": { + "id": "wreck", + "name": "Wreck", + "surfaceMarker": "boat", + "features": [ + { + "kind": "helm", + "x": 90, + "d": 22 + }, + { + "kind": "lightShaft", + "x": 18, + "d": 34, + "topHalf": 30, + "botHalf": 70, + "alpha": 0.45 + }, + { + "kind": "lightShaft", + "x": 85, + "d": 43, + "topHalf": 38, + "botHalf": 82, + "alpha": 0.42 + }, + { + "kind": "lightShaft", + "x": 158, + "d": 34, + "topHalf": 34, + "botHalf": 78, + "alpha": 0.45 + }, + { + "kind": "messTable", + "x": 50, + "d": 27 + }, + { + "kind": "messTable", + "x": 60, + "d": 27 + }, + { + "kind": "messTable", + "x": 70, + "d": 27 + }, + { + "kind": "messTable", + "x": 80, + "d": 27 + }, + { + "kind": "lifeboat", + "x": 46, + "d": 23.5 + }, + { + "kind": "lifeboat", + "x": 134, + "d": 23.5 + }, + { + "kind": "bowVisor", + "x": 18, + "d": 26 + }, + { + "kind": "lorry", + "x": 24, + "d": 39 + }, + { + "kind": "car", + "x": 34, + "d": 39 + }, + { + "kind": "car", + "x": 42, + "d": 39 + }, + { + "kind": "lorry", + "x": 54, + "d": 39 + }, + { + "kind": "car", + "x": 64, + "d": 39 + }, + { + "kind": "car", + "x": 72, + "d": 39 + }, + { + "kind": "car", + "x": 98, + "d": 39 + }, + { + "kind": "lorry", + "x": 110, + "d": 39 + }, + { + "kind": "car", + "x": 122, + "d": 39 + }, + { + "kind": "lorry", + "x": 134, + "d": 39 + }, + { + "kind": "car", + "x": 146, + "d": 39 + }, + { + "kind": "car", + "x": 160, + "d": 39 + }, + { + "kind": "bunk", + "x": 19, + "d": 45 + }, + { + "kind": "bunk", + "x": 28, + "d": 45 + }, + { + "kind": "bunk", + "x": 38, + "d": 45 + }, + { + "kind": "bunk", + "x": 48, + "d": 45 + }, + { + "kind": "bunk", + "x": 58, + "d": 45 + }, + { + "kind": "bunk", + "x": 68, + "d": 45 + }, + { + "kind": "bunk", + "x": 76, + "d": 45 + }, + { + "kind": "bunk", + "x": 95, + "d": 45 + }, + { + "kind": "bunk", + "x": 102, + "d": 45 + }, + { + "kind": "bunk", + "x": 112, + "d": 45 + }, + { + "kind": "bunk", + "x": 122, + "d": 45 + }, + { + "kind": "bunk", + "x": 132, + "d": 45 + }, + { + "kind": "bunk", + "x": 142, + "d": 45 + }, + { + "kind": "bunk", + "x": 152, + "d": 45 + }, + { + "kind": "bunk", + "x": 163, + "d": 45 + }, + { + "kind": "container", + "x": 22, + "d": 52, + "color": "#3a6a4a" + }, + { + "kind": "container", + "x": 36, + "d": 52, + "color": "#7a3026" + }, + { + "kind": "container", + "x": 50, + "d": 52, + "color": "#5a4828" + }, + { + "kind": "container", + "x": 64, + "d": 52, + "color": "#2a4a6a" + }, + { + "kind": "container", + "x": 96, + "d": 52, + "color": "#7a6048" + }, + { + "kind": "container", + "x": 108, + "d": 52, + "color": "#3a4a3a" + }, + { + "kind": "container", + "x": 122, + "d": 52, + "color": "#7a3026" + }, + { + "kind": "container", + "x": 136, + "d": 52, + "color": "#3a6a4a" + }, + { + "kind": "container", + "x": 150, + "d": 52, + "color": "#5a4828" + }, + { + "kind": "container", + "x": 162, + "d": 52, + "color": "#2a4a6a" + }, + { + "kind": "engine", + "x": 25, + "d": 61 + }, + { + "kind": "engine", + "x": 46, + "d": 61 + }, + { + "kind": "engine", + "x": 68, + "d": 61 + }, + { + "kind": "engine", + "x": 102, + "d": 61 + }, + { + "kind": "engine", + "x": 122, + "d": 61 + }, + { + "kind": "engine", + "x": 144, + "d": 61 + }, + { + "kind": "container", + "x": 162, + "d": 61, + "color": "#1a1a1a" + }, + { + "kind": "rustHole", + "x": 82, + "d": 50 + }, + { + "kind": "rustHole", + "x": 152, + "d": 58 + }, + { + "kind": "rustHole", + "x": 30, + "d": 44 + }, + { + "kind": "line", + "x": 35, + "d": 41, + "length": 2.6, + "sag": 1.2 + }, + { + "kind": "line", + "x": 76, + "d": 47, + "length": 1.9, + "sag": 1.1 + }, + { + "kind": "line", + "x": 145, + "d": 55, + "length": 2.4, + "sag": 1.4 + }, + { + "kind": "net", + "x": 118, + "d": 41, + "width": 3.2, + "height": 2.6 + }, + { + "kind": "net", + "x": 100, + "d": 32, + "width": 2.4, + "height": 2 + }, + { + "kind": "anchor", + "x": 2, + "d": 66, + "scale": 2.4 + }, + { + "kind": "lorry", + "x": -8, + "d": 66 + }, + { + "kind": "car", + "x": 182, + "d": 66 + }, + { + "kind": "car", + "x": 188, + "d": 66 + } + ], + "visualZones": [ + { + "id": "wreck_exterior", + "x1": -40, + "x2": 200, + "d1": 0, + "d2": 66, + "priority": 0, + "blend": 0, + "tags": [ + "wreck", + "open-water", + "outside-hull" + ] + }, + { + "id": "wreck_bridge", + "x1": 70, + "x2": 110, + "d1": 18, + "d2": 22, + "priority": 20, + "blend": 1, + "tags": [ + "wreck", + "interior", + "bridge", + "confined" + ] + }, + { + "id": "wreck_accommodation", + "x1": 40, + "x2": 140, + "d1": 22, + "d2": 28, + "priority": 15, + "blend": 1, + "tags": [ + "wreck", + "interior", + "accommodation" + ] + }, + { + "id": "wreck_vehicle_deck", + "x1": 14, + "x2": 170, + "d1": 28, + "d2": 40, + "priority": 15, + "blend": 1, + "tags": [ + "wreck", + "interior", + "vehicle-deck", + "cargo" + ] + }, + { + "id": "wreck_crew_deck", + "x1": 14, + "x2": 170, + "d1": 40, + "d2": 46, + "priority": 15, + "blend": 1, + "tags": [ + "wreck", + "interior", + "maze", + "crew" + ] + }, + { + "id": "wreck_cargo_hold", + "x1": 14, + "x2": 170, + "d1": 46, + "d2": 53, + "priority": 15, + "blend": 1, + "tags": [ + "wreck", + "interior", + "maze", + "cargo" + ] + }, + { + "id": "wreck_engine_room", + "x1": 14, + "x2": 170, + "d1": 53, + "d2": 62, + "priority": 15, + "blend": 1, + "tags": [ + "wreck", + "interior", + "deep", + "engine" + ] + }, + { + "id": "wreck_bilge", + "x1": 14, + "x2": 170, + "d1": 62, + "d2": 66, + "priority": 15, + "blend": 0, + "tags": [ + "wreck", + "interior", + "deep", + "bilge" + ] + } + ], + "atmosphereProfiles": { + "wreck_exterior": { + "visibility": 1, + "tint": [ + 1, + 1, + 1 + ], + "particleDensity": 1, + "particleBrightness": 1, + "ambient": 1 + }, + "wreck_bridge": { + "visibility": 1, + "tint": [ + 1.02, + 1, + 0.96 + ], + "particleDensity": 0.9, + "particleBrightness": 1, + "ambient": 0.98 + }, + "wreck_accommodation": { + "visibility": 0.9, + "tint": [ + 1.02, + 0.98, + 0.92 + ], + "particleDensity": 1.15, + "particleBrightness": 0.9, + "ambient": 0.9 + }, + "wreck_vehicle_deck": { + "visibility": 0.85, + "tint": [ + 1.02, + 0.96, + 0.9 + ], + "particleDensity": 1.2, + "particleBrightness": 0.85, + "ambient": 0.85 + }, + "wreck_crew_deck": { + "visibility": 0.78, + "tint": [ + 1, + 0.94, + 0.88 + ], + "particleDensity": 1.3, + "particleBrightness": 0.78, + "ambient": 0.72 + }, + "wreck_cargo_hold": { + "visibility": 0.75, + "tint": [ + 1.05, + 0.92, + 0.84 + ], + "particleDensity": 1.55, + "particleBrightness": 0.8, + "ambient": 0.72 + }, + "wreck_engine_room": { + "visibility": 0.66, + "tint": [ + 1.12, + 0.9, + 0.78 + ], + "particleDensity": 1.25, + "particleBrightness": 0.72, + "ambient": 0.58 + }, + "wreck_bilge": { + "visibility": 0.55, + "tint": [ + 1.08, + 0.88, + 0.76 + ], + "particleDensity": 1.55, + "particleBrightness": 0.68, + "ambient": 0.5 + } + }, + "decorationRules": [ + { + "id": "wreck_vehicle_deck_debris", + "zone": "wreck_vehicle_deck", + "spacing": 2, + "density": 0.65, + "seed": 3101, + "surface": "floor", + "props": [ + { + "kind": "rustFlake", + "weight": 3 + }, + { + "kind": "smallMetalDebris", + "weight": 2 + }, + { + "kind": "sedimentClump", + "weight": 2 + }, + { + "kind": "cableScrap", + "weight": 1 + } + ] + }, + { + "id": "wreck_engine_room_debris", + "zone": "wreck_engine_room", + "spacing": 2.2, + "density": 0.55, + "seed": 3102, + "surface": "floor", + "props": [ + { + "kind": "rustFlake", + "weight": 5 + }, + { + "kind": "smallMetalDebris", + "weight": 3 + }, + { + "kind": "debrisSpeck", + "weight": 1 + } + ] + }, + { + "id": "wreck_cargo_hold_sediment", + "zone": "wreck_cargo_hold", + "spacing": 2.8, + "density": 0.35, + "seed": 3104, + "surface": "floor", + "props": [ + { + "kind": "sedimentClump", + "weight": 4 + }, + { + "kind": "rustFlake", + "weight": 1 + }, + { + "kind": "debrisSpeck", + "weight": 2 + } + ] + }, + { + "id": "wreck_exterior_scraps", + "zone": "wreck_exterior", + "spacing": 4.5, + "density": 0.25, + "seed": 3103, + "surface": "floor", + "minDepth": 20, + "props": [ + { + "kind": "rustFlake", + "weight": 2 + }, + { + "kind": "smallMetalDebris", + "weight": 1 + }, + { + "kind": "sedimentClump", + "weight": 2 + } + ] + } + ] + }, + "cave": { + "id": "cave", + "name": "Cave", + "surfaceMarker": "pond", + "features": [ + { + "kind": "pond", + "x": 0 + }, + { + "kind": "warningSign", + "x": 17 + }, + { + "kind": "caveColumn", + "x": 88, + "dTop": 56, + "dBottom": 98, + "wTop": 9, + "wBot": 11, + "seed": 5901 + }, + { + "kind": "caveColumn", + "x": 112, + "dTop": 60, + "dBottom": 95, + "wTop": 6, + "wBot": 8, + "seed": 5902 + } + ], + "visualZones": [ + { + "id": "cave_entrance", + "x1": -10, + "x2": 18, + "d1": 0, + "d2": 14, + "priority": 10, + "blend": 1, + "tags": [ + "cave", + "entrance", + "open-to-surface" + ] + }, + { + "id": "cave_threshold", + "x1": 15, + "x2": 22, + "d1": 6, + "d2": 16, + "priority": 20, + "blend": 1, + "tags": [ + "cave", + "threshold", + "warning" + ] + }, + { + "id": "cave_upper_tunnel", + "x1": 18, + "x2": 146, + "d1": 10, + "d2": 22, + "priority": 15, + "blend": 1, + "tags": [ + "cave", + "tunnel", + "shallow" + ] + }, + { + "id": "cave_restriction", + "x1": 86, + "x2": 93, + "d1": 10, + "d2": 22, + "priority": 22, + "blend": 1, + "tags": [ + "cave", + "tunnel", + "restriction", + "squeeze" + ] + }, + { + "id": "cave_down_shaft", + "x1": 48, + "x2": 72, + "d1": 20, + "d2": 90, + "priority": 15, + "blend": 2, + "tags": [ + "cave", + "shaft", + "descent" + ] + }, + { + "id": "cave_cathedral", + "x1": 60, + "x2": 134, + "d1": 50, + "d2": 104, + "priority": 25, + "blend": 3, + "tags": [ + "cave", + "cathedral", + "deep", + "open-chamber" + ] + }, + { + "id": "cave_up_shaft", + "x1": 124, + "x2": 146, + "d1": 20, + "d2": 90, + "priority": 15, + "blend": 2, + "tags": [ + "cave", + "shaft", + "ascent" + ] + }, + { + "id": "cave_exit", + "x1": 146, + "x2": 200, + "d1": 0, + "d2": 20, + "priority": 10, + "blend": 1, + "tags": [ + "cave", + "exit", + "open-to-surface" + ] + } + ], + "atmosphereProfiles": { + "cave_entrance": { + "visibility": 1.15, + "tint": [ + 0.98, + 1.06, + 0.94 + ], + "particleDensity": 1, + "particleBrightness": 1.1, + "ambient": 1.2 + }, + "cave_threshold": { + "visibility": 0.85, + "tint": [ + 0.94, + 0.98, + 1 + ], + "particleDensity": 1.1, + "particleBrightness": 0.85, + "ambient": 0.75 + }, + "cave_upper_tunnel": { + "visibility": 0.9, + "tint": [ + 0.95, + 0.98, + 1 + ], + "particleDensity": 1, + "particleBrightness": 0.85, + "ambient": 0.8 + }, + "cave_restriction": { + "visibility": 0.75, + "tint": [ + 0.94, + 0.96, + 1 + ], + "particleDensity": 1.3, + "particleBrightness": 0.8, + "ambient": 0.68 + }, + "cave_down_shaft": { + "visibility": 0.85, + "tint": [ + 0.92, + 0.96, + 1.05 + ], + "particleDensity": 0.85, + "particleBrightness": 0.8, + "ambient": 0.75 + }, + "cave_cathedral": { + "visibility": 1.2, + "tint": [ + 0.84, + 0.92, + 1.1 + ], + "particleDensity": 0.35, + "particleBrightness": 0.72, + "ambient": 0.65 + }, + "cave_up_shaft": { + "visibility": 0.85, + "tint": [ + 0.92, + 0.96, + 1.05 + ], + "particleDensity": 0.85, + "particleBrightness": 0.8, + "ambient": 0.75 + }, + "cave_exit": { + "visibility": 1.15, + "tint": [ + 0.98, + 1.06, + 0.94 + ], + "particleDensity": 0.95, + "particleBrightness": 1.05, + "ambient": 1.15 + } + }, + "decorationRules": [ + { + "id": "cave_entrance_chips", + "zone": "cave_entrance", + "spacing": 2, + "density": 0.5, + "seed": 4101, + "surface": "floor", + "props": [ + { + "kind": "calciteChip", + "weight": 3 + }, + { + "kind": "rockFragment", + "weight": 2 + }, + { + "kind": "pebble", + "weight": 2 + } + ] + }, + { + "id": "cave_cathedral_floor", + "zone": "cave_cathedral", + "spacing": 4.5, + "density": 0.28, + "seed": 4102, + "surface": "floor", + "props": [ + { + "kind": "smallStalagmite", + "weight": 2 + }, + { + "kind": "rockFragment", + "weight": 3 + }, + { + "kind": "calciteChip", + "weight": 2 + } + ] + }, + { + "id": "cave_cathedral_ceiling", + "zone": "cave_cathedral", + "spacing": 5, + "density": 0.25, + "seed": 4103, + "surface": "ceiling", + "props": [ + { + "kind": "smallStalactite", + "weight": 3 + }, + { + "kind": "calciteChip", + "weight": 1 + } + ] + }, + { + "id": "cave_upper_tunnel_sparse", + "zone": "cave_upper_tunnel", + "spacing": 3.5, + "density": 0.3, + "seed": 4104, + "surface": "floor", + "props": [ + { + "kind": "pebble", + "weight": 3 + }, + { + "kind": "rockFragment", + "weight": 2 + }, + { + "kind": "calciteChip", + "weight": 1 + } + ] + }, + { + "id": "cave_restriction_chips", + "zone": "cave_restriction", + "spacing": 1.4, + "density": 0.7, + "seed": 4105, + "surface": "floor", + "props": [ + { + "kind": "calciteChip", + "weight": 4 + }, + { + "kind": "rockFragment", + "weight": 2 + } + ] + } + ] + } + } +} diff --git a/src/sites/site-resources.ts b/src/sites/site-resources.ts new file mode 100644 index 0000000..d858ae5 --- /dev/null +++ b/src/sites/site-resources.ts @@ -0,0 +1,130 @@ +// WP-07: renderer-neutral site resources. +// +// Gameplay and presentation are separate documents on purpose. The renderer may +// import presentation; nothing that decides where the diver can swim may. That +// separation is what lets an asset be replaced without a collision review. + +import gameplayDocument from "./resources/gameplay.json"; +import { deepFreeze } from "./deep-freeze"; + +export interface ProfilePoint { + readonly x: number; + readonly d: number; +} + +export interface SiteStructure { + readonly x1: number; + readonly x2: number; + readonly dTop: number; + readonly dBottom: number; + readonly kind?: string; +} + +export interface BadAirDome { + readonly x1: number; + readonly x2: number; + readonly d?: number; +} + +/** Where the diver enters the water. Gameplay data, not a rendering hint. */ +export interface SiteEntryPoint { + readonly x: number; +} + +export interface SiteGameplay { + readonly id: string; + readonly maxDepth: number; + readonly hasOverhead: boolean; + readonly entry: SiteEntryPoint; + readonly boatX?: number; + readonly floor: readonly ProfilePoint[]; + readonly ceiling: readonly ProfilePoint[] | null; + readonly structures: readonly SiteStructure[]; + readonly badAir: readonly BadAirDome[]; + readonly currentBias?: number; + readonly noShark?: boolean; +} + +/** + * Invariants the JSON Schema cannot express, each of which corrupts geometry + * silently rather than loudly: + * + * - `lerpProfile` walks points assuming ascending `x`. Unsorted input does not + * throw; it interpolates against the wrong segment and returns a plausible + * depth for the wrong place. + * - An inverted structure box (`x2 < x1`, `dBottom < dTop`) can never be hit by + * `solidAt`, so a wall silently stops existing. + * - A structure below `maxDepth` is unreachable, which usually means a typo + * rather than an intentionally dead volume. + */ +export function validateSiteGameplay(site: SiteGameplay): string[] { + const problems: string[] = []; + + const checkProfile = (name: string, points: readonly ProfilePoint[] | null): void => { + if (!points) { + return; + } + for (let index = 1; index < points.length; index += 1) { + const previous = points[index - 1] as ProfilePoint; + const current = points[index] as ProfilePoint; + if (current.x <= previous.x) { + problems.push( + `${site.id}: ${name} point ${index} has x=${current.x}, not greater than the previous ${previous.x}`, + ); + } + } + }; + + checkProfile("floor", site.floor); + checkProfile("ceiling", site.ceiling); + + site.structures.forEach((structure, index) => { + if (structure.x2 < structure.x1) { + problems.push(`${site.id}: structure ${index} has x2 ${structure.x2} before x1 ${structure.x1}`); + } + if (structure.dBottom < structure.dTop) { + problems.push( + `${site.id}: structure ${index} has dBottom ${structure.dBottom} above dTop ${structure.dTop}`, + ); + } + if (structure.dTop > site.maxDepth) { + problems.push( + `${site.id}: structure ${index} starts at ${structure.dTop} m, below the site maxDepth ${site.maxDepth} m`, + ); + } + }); + + site.badAir.forEach((dome, index) => { + if (dome.x2 < dome.x1) { + problems.push(`${site.id}: badAir ${index} has x2 ${dome.x2} before x1 ${dome.x1}`); + } + }); + + return problems; +} + +const sites = (gameplayDocument as { sites: Record }).sites; + +const startupProblems = Object.values(sites).flatMap(validateSiteGameplay); +if (startupProblems.length) { + // Fail at import time. Invalid geometry that only surfaces when a diver swims + // into it is far more expensive than a build that refuses to start. + throw new Error(`invalid site gameplay data:\n${startupProblems.join("\n")}`); +} + +export const SITE_GAMEPLAY: Readonly> = deepFreeze(sites); + +export function siteGameplay(id: string): SiteGameplay | null { + return SITE_GAMEPLAY[id] ?? null; +} + +/** + * Digest of the inputs these resources were generated from (`src/sites.js` + * plus the MAX_DEPTH it reads). Not a commit id: generation runs before the + * commit that carries its own output, so a commit id here would always name a + * tree whose descriptors produced different data. `npm run sites:check` + * verifies this value, so it is a claim that can be — and is — checked. + */ +export const SITE_GAMEPLAY_SOURCE_DIGEST = ( + gameplayDocument as { sourceDigest: string } +).sourceDigest; diff --git a/tests/parity/site-geometry.test.ts b/tests/parity/site-geometry.test.ts new file mode 100644 index 0000000..7f1bb3c --- /dev/null +++ b/tests/parity/site-geometry.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; + +// The legacy descriptors are the authority this extraction must reproduce, so +// load the real source rather than a snapshot of it. `?raw` keeps this a +// browser-shaped project: no node APIs, no @types/node. +import legacySource from "../../src/sites.js?raw"; +import constantsSource from "../../src/constants.js?raw"; + +import { + SITE_GAMEPLAY, + validateSiteGameplay, + type SiteGameplay, +} from "../../src/sites/site-resources"; + +interface LegacyApi { + DIVE_SITES: Record; + setSite(id: string): void; + floorAt(x: number): number; + ceilingAt(x: number): number; + solidAt(x: number, d: number): boolean; + overheadAt(x: number, d: number): boolean; + badAirAt(x: number): unknown; +} + +// Read the real constant rather than restating it. A literal here is injected +// into the legacy source *and* used by the reimplementation below, so a wrong +// value agrees with itself and every parity assertion passes against data that +// does not match the running game. The reef descriptor is what depends on it. +const MAX_DEPTH = (() => { + const match = /^\s*(?:const|let|var)\s+MAX_DEPTH\s*=\s*(-?\d+(?:\.\d+)?)\s*;/m.exec( + constantsSource, + ); + if (!match) { + throw new Error("could not read MAX_DEPTH from src/constants.js"); + } + return Number(match[1]); +})(); + +// `diveSite` lives in state.js, which sites.js reads but does not declare. +const legacy = new Function( + "MAX_DEPTH", + `var diveSite = "shore"; +${legacySource} +return { + DIVE_SITES: DIVE_SITES, + setSite: function (id) { diveSite = id; }, + floorAt: floorAt, + ceilingAt: ceilingAt, + solidAt: solidAt, + overheadAt: overheadAt, + badAirAt: badAirAt +};`, +)(MAX_DEPTH) as LegacyApi; + +// Reimplemented against the extracted resource. If these drift from the legacy +// helpers the parity assertions below fail, which is the whole point. +function floorAt(site: SiteGameplay, x: number): number { + return Math.min(MAX_DEPTH, lerp(site.floor, x) ?? MAX_DEPTH); +} + +function ceilingAt(site: SiteGameplay, x: number): number { + const value = lerp(site.ceiling, x); + return value === null ? 0 : Math.max(0, value); +} + +function solidAt(site: SiteGameplay, x: number, d: number): boolean { + return site.structures.some( + (s) => x >= s.x1 && x <= s.x2 && d >= s.dTop && d <= s.dBottom, + ); +} + +function overheadAt(site: SiteGameplay, x: number, d: number): boolean { + if (!site.hasOverhead) { + return false; + } + const ceiling = ceilingAt(site, x); + if (ceiling > 0.5 && d >= ceiling - 0.01) { + return true; + } + return site.structures.some((s) => x >= s.x1 && x <= s.x2 && s.dBottom < d); +} + +function lerp( + points: readonly { x: number; d: number }[] | null, + x: number, +): number | null { + if (!points?.length) { + return null; + } + const first = points[0] as { x: number; d: number }; + const last = points[points.length - 1] as { x: number; d: number }; + if (x <= first.x) return first.d; + if (x >= last.x) return last.d; + for (let i = 1; i < points.length; i += 1) { + const b = points[i] as { x: number; d: number }; + if (x <= b.x) { + const a = points[i - 1] as { x: number; d: number }; + // Compute the interpolant first, exactly as lerpProfile does. Folding the + // division into the multiplication is algebraically identical and + // numerically is not: it moved results by one ULP and broke parity on two + // sites. Preserve the operation order, not just the formula. + const t = (x - a.x) / (b.x - a.x); + return a.d + (b.d - a.d) * t; + } + } + return last.d; +} + +describe("extracted site gameplay matches the legacy descriptors", () => { + for (const id of Object.keys(legacy.DIVE_SITES)) { + it(`reproduces ${id} collision, ceiling and air across a sampled grid`, () => { + const site = SITE_GAMEPLAY[id]; + expect(site, `no extracted resource for ${id}`).toBeDefined(); + if (!site) return; + + legacy.setSite(id); + const mismatches: string[] = []; + + // Half-metre horizontal steps across the authored extent plus margin, and + // metre depth steps. Sampling beyond the profile ends exercises the + // clamping branches, which is where an off-by-one in extraction hides. + for (let x = -20; x <= 260; x += 0.5) { + if (floorAt(site, x) !== legacy.floorAt(x)) { + mismatches.push(`floorAt(${x})`); + } + if (ceilingAt(site, x) !== legacy.ceilingAt(x)) { + mismatches.push(`ceilingAt(${x})`); + } + if (Boolean(legacy.badAirAt(x)) !== site.badAir.some((p) => x >= p.x1 && x <= p.x2)) { + mismatches.push(`badAirAt(${x})`); + } + for (let d = 0; d <= 110; d += 1) { + if (solidAt(site, x, d) !== legacy.solidAt(x, d)) { + mismatches.push(`solidAt(${x},${d})`); + } + if (overheadAt(site, x, d) !== legacy.overheadAt(x, d)) { + mismatches.push(`overheadAt(${x},${d})`); + } + } + } + + expect(mismatches.slice(0, 5), `${mismatches.length} mismatches`).toEqual([]); + }); + } + + it("declares every authored site", () => { + expect(Object.keys(SITE_GAMEPLAY).sort()).toEqual( + Object.keys(legacy.DIVE_SITES).sort(), + ); + }); + + it("carries no presentation field into gameplay data", () => { + // The separation is the deliverable. If art leaks back in, an asset change + // becomes a collision change again and this suite stops meaning anything. + const forbidden = [ + "features", + "visualZones", + "atmosphereProfiles", + "decorationRules", + "surfaceMarker", + "name", + ]; + for (const [id, site] of Object.entries(SITE_GAMEPLAY)) { + for (const field of forbidden) { + expect(Object.hasOwn(site, field), `${id} leaked ${field}`).toBe(false); + } + } + }); + + it("accepts the shipped data and rejects geometry that silently corrupts", () => { + for (const site of Object.values(SITE_GAMEPLAY)) { + expect(validateSiteGameplay(site)).toEqual([]); + } + + const base = SITE_GAMEPLAY.wreck as SiteGameplay; + // Unsorted profile points do not throw in lerpProfile; they interpolate + // against the wrong segment and return a plausible depth for the wrong x. + expect( + validateSiteGameplay({ + ...base, + id: "unsorted", + floor: [ + { x: 10, d: 5 }, + { x: 0, d: 6 }, + ], + }), + ).not.toEqual([]); + + // An inverted box can never be hit by solidAt, so the wall stops existing. + expect( + validateSiteGameplay({ + ...base, + id: "inverted", + structures: [{ x1: 20, x2: 10, dTop: 5, dBottom: 6 }], + }), + ).not.toEqual([]); + }); +}); diff --git a/tests/unit/asset-replacement.test.ts b/tests/unit/asset-replacement.test.ts new file mode 100644 index 0000000..4015fc6 --- /dev/null +++ b/tests/unit/asset-replacement.test.ts @@ -0,0 +1,93 @@ +// WP-07 acceptance criterion: "one asset can be replaced without changing +// simulation or collision data." +// +// Proving that needs the replacement to actually reach the layer factory. The +// earlier version of this test built a `swapped` object and then asserted only +// that it differed from the original — `buildSceneLayers` still resolved the +// unmodified manifest, so the test would have passed even if replacement had +// stopped working entirely. +// +// Mocking the manifest module is what a real art swap does: re-point a kind at +// a different atlas and frame. It lives in its own file because `vi.mock` is +// hoisted to the whole module, and the other suites need the real table. + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const REPLACED_ATLAS = "reef-v2"; +const REPLACED_FRAME = "tableCoralRedesigned"; + +vi.mock("../../src/sites/asset-manifest", async (importOriginal) => { + const actual = + await importOriginal(); + const original = actual.ASSET_MANIFEST.tableCoral; + const swapped = { ...original!, atlas: REPLACED_ATLAS, frame: REPLACED_FRAME }; + return { + ...actual, + assetFor: (kind: string) => (kind === "tableCoral" ? swapped : actual.assetFor(kind)), + }; +}); + +const { buildSceneLayers } = await import("../../src/sites/layer-factory"); +const { SITE_GAMEPLAY, validateSiteGameplay } = await import( + "../../src/sites/site-resources" +); +const { ASSET_MANIFEST } = await import("../../src/sites/asset-manifest"); + +const WHOLE_MAP = { + leftM: -1_000, + rightM: 1_000, + topM: -1_000, + bottomM: 1_000, +} as const; + +const reefPlacements = () => + buildSceneLayers("reef", { camera: WHOLE_MAP, qualityTier: "high" }).flatMap( + (layer) => layer.placements, + ); + +describe("replacing an asset", () => { + let gameplayBefore: string; + + beforeEach(() => { + gameplayBefore = JSON.stringify(SITE_GAMEPLAY); + }); + + it("actually reaches the rendered scene", () => { + // Guards the mock itself: if this stops resolving the swap, the assertions + // below would pass vacuously exactly as the original test did. + const tableCorals = reefPlacements().filter((p) => p.assetId === "reef/tableCoral"); + expect(tableCorals.length).toBeGreaterThan(0); + for (const placement of tableCorals) { + expect(placement.atlas).toBe(REPLACED_ATLAS); + expect(placement.frame).toBe(REPLACED_FRAME); + } + // And the real table is genuinely different, so this is a replacement. + expect(ASSET_MANIFEST.tableCoral?.atlas).not.toBe(REPLACED_ATLAS); + }); + + it("leaves every gameplay value byte-identical", () => { + const placements = reefPlacements(); + expect(placements.length).toBeGreaterThan(0); + expect(JSON.stringify(SITE_GAMEPLAY)).toBe(gameplayBefore); + }); + + it("leaves collision geometry valid and unchanged", () => { + reefPlacements(); + for (const site of Object.values(SITE_GAMEPLAY)) { + expect(validateSiteGameplay(site)).toEqual([]); + } + const reef = SITE_GAMEPLAY.reef; + expect(reef?.floor).toBeDefined(); + expect(reef?.maxDepth).toBe(300); + expect(reef?.structures).toEqual([]); + }); + + it("does not change which other kinds resolve", () => { + // A swap must be local: re-pointing tableCoral cannot disturb its neighbours. + const others = reefPlacements().filter((p) => p.assetId !== "reef/tableCoral"); + expect(others.length).toBeGreaterThan(0); + for (const placement of others) { + expect(placement.atlas).not.toBe(REPLACED_ATLAS); + } + }); +}); diff --git a/tests/unit/site-assets.test.ts b/tests/unit/site-assets.test.ts new file mode 100644 index 0000000..a21a422 --- /dev/null +++ b/tests/unit/site-assets.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +import { + ASSET_MANIFEST, + assetFor, + atlasesFor, + LAYERS, + QUALITY_TIERS, + tierAllows, +} from "../../src/sites/asset-manifest"; +import { + authoredFeatureKinds, + buildSceneLayers, + featureDepthRange, + requiredAtlases, + SITE_PRESENTATION, +} from "../../src/sites/layer-factory"; +import { SITE_GAMEPLAY, validateSiteGameplay } from "../../src/sites/site-resources"; + +const WHOLE_MAP = { + leftM: -1_000, + rightM: 1_000, + topM: -1_000, + bottomM: 1_000, +} as const; + +describe("asset manifest", () => { + it("covers every authored feature kind", () => { + // The manifest is hand-authored art direction, so completeness cannot be + // assumed. A new feature kind fails here until someone assigns it an asset, + // instead of silently rendering nothing in a shipped scene. + const unmapped = authoredFeatureKinds().filter((kind) => !assetFor(kind)); + expect(unmapped, `unmapped feature kinds: ${unmapped.join(", ")}`).toEqual([]); + }); + + it("declares no asset the authored data never uses", () => { + const authored = new Set(authoredFeatureKinds()); + const orphans = Object.keys(ASSET_MANIFEST).filter((kind) => !authored.has(kind)); + expect(orphans, `orphaned manifest entries: ${orphans.join(", ")}`).toEqual([]); + }); + + it("assigns every asset a known layer and tier", () => { + for (const [kind, asset] of Object.entries(ASSET_MANIFEST)) { + expect(LAYERS, `${kind} layer`).toContain(asset.layer); + expect(QUALITY_TIERS, `${kind} tier`).toContain(asset.minimumQualityTier); + expect(asset.id, `${kind} id`).toMatch(/^[a-z]+\/[A-Za-z]+$/); + } + }); + + it("reports the atlases a site needs before its first frame", () => { + expect(requiredAtlases("reef")).toEqual(["reef"]); + expect(requiredAtlases("wreck")).toEqual(["shared", "wreck"]); + expect(requiredAtlases("nonexistent")).toEqual([]); + expect(atlasesFor(["tableCoral", "tableCoral"])).toEqual(["reef"]); + }); +}); + +describe("layer factory", () => { + it("culls by camera bounds and keeps a stable order", () => { + const all = buildSceneLayers("wreck", { camera: WHOLE_MAP, qualityTier: "high" }); + const total = all.reduce((sum, layer) => sum + layer.placements.length, 0); + expect(total).toBeGreaterThan(0); + + const narrow = buildSceneLayers("wreck", { + camera: { leftM: 0, rightM: 5, topM: 0, bottomM: 5 }, + qualityTier: "high", + cullMarginM: 0, + }); + const narrowTotal = narrow.reduce((sum, layer) => sum + layer.placements.length, 0); + expect(narrowTotal).toBeLessThan(total); + + // Same inputs must always produce the same scene, whatever order the site + // data happens to be authored in. + const repeat = buildSceneLayers("wreck", { camera: WHOLE_MAP, qualityTier: "high" }); + expect(repeat).toEqual(all); + }); + + it("drops higher-tier decoration on a lower tier", () => { + const high = buildSceneLayers("reef", { camera: WHOLE_MAP, qualityTier: "high" }); + const low = buildSceneLayers("reef", { camera: WHOLE_MAP, qualityTier: "low" }); + const count = (layers: typeof high) => + layers.reduce((sum, layer) => sum + layer.placements.length, 0); + + expect(count(low)).toBeLessThan(count(high)); + expect(tierAllows("low", "high")).toBe(false); + expect(tierAllows("high", "low")).toBe(true); + }); + + it("returns empty layers for an unknown site rather than throwing", () => { + const layers = buildSceneLayers("nonexistent", { + camera: WHOLE_MAP, + qualityTier: "high", + }); + expect(layers.map((layer) => layer.id)).toEqual([...LAYERS]); + expect(layers.every((layer) => layer.placements.length === 0)).toBe(true); + }); +}); + +describe("replacing an asset cannot change simulation or collision data", () => { + // This is WP-07's acceptance criterion. The split makes it true by + // construction; the test makes it stay true through later refactoring. + // The replacement half of this criterion needs the swap to actually reach the + // layer factory, which needs the manifest module mocked — see + // tests/unit/asset-replacement.test.ts. What stays here is the weaker but + // still useful property: simply building a scene touches no gameplay data. + it("leaves every gameplay value untouched when a scene is built", () => { + const before = JSON.stringify(SITE_GAMEPLAY); + + const rebuilt = buildSceneLayers("reef", { + camera: WHOLE_MAP, + qualityTier: "high", + }); + expect(rebuilt.some((layer) => layer.placements.length > 0)).toBe(true); + + expect(JSON.stringify(SITE_GAMEPLAY)).toBe(before); + for (const site of Object.values(SITE_GAMEPLAY)) { + expect(validateSiteGameplay(site)).toEqual([]); + } + }); + + it("keeps the two documents structurally disjoint", () => { + // If a gameplay field ever appears in presentation data, an art edit could + // reach collision again and the guarantee above becomes unenforceable. + const gameplayOnly = ["floor", "ceiling", "structures", "badAir", "hasOverhead"]; + for (const [id, site] of Object.entries(SITE_PRESENTATION)) { + for (const field of gameplayOnly) { + expect(Object.hasOwn(site, field), `${id} presentation leaked ${field}`).toBe( + false, + ); + } + } + }); +}); + +describe("culling accounts for a feature's full vertical extent", () => { + // The cave columns span dTop=56..dBottom=98 and dTop=60..dBottom=95. Culling + // on the anchor alone dropped them once the diver swam past their top, so + // both 40 m columns vanished while the diver was still beside them. + const columnsVisibleAt = (depth: number): number => + buildSceneLayers("cave", { + qualityTier: "high", + camera: { leftM: 88 - 34, rightM: 88 + 34, topM: depth - 20, bottomM: depth + 20 }, + }) + .flatMap((layer) => layer.placements) + .filter((placement) => placement.assetId === "cave/caveColumn").length; + + it("keeps a tall feature while any part of it is on screen", () => { + // Anchors are at 56 and 60; without span-aware culling these read 1 and 0. + expect(columnsVisibleAt(90)).toBe(2); + expect(columnsVisibleAt(95)).toBe(2); + }); + + it("still culls once the feature is genuinely out of range", () => { + expect(columnsVisibleAt(10)).toBe(0); + expect(columnsVisibleAt(160)).toBe(0); + }); + + it("reports the extent of point features and spans alike", () => { + expect(featureDepthRange({ kind: "car", x: 34, d: 39 })).toEqual({ top: 39, bottom: 39 }); + expect(featureDepthRange({ kind: "caveColumn", x: 88, dTop: 56, dBottom: 98 })).toEqual({ + top: 56, + bottom: 98, + }); + // Inverted authoring should not silently disable the feature. + expect(featureDepthRange({ kind: "x", x: 0, dTop: 98, dBottom: 56 })).toEqual({ + top: 56, + bottom: 98, + }); + }); +}); + +describe("resource documents are deeply immutable", () => { + // Object.freeze is shallow, so the nested arrays stayed writable and the + // immutability these modules advertise was nominal. Collision geometry that + // any consumer can mutate in place is the failure this guards. + it("refuses in-place mutation of nested gameplay geometry", () => { + const wreck = SITE_GAMEPLAY.wreck; + expect(wreck).toBeDefined(); + if (!wreck) return; + expect(Object.isFrozen(wreck.structures)).toBe(true); + expect(Object.isFrozen(wreck.structures[0])).toBe(true); + expect(() => { + (wreck.structures as unknown as { x1: number }[])[0]!.x1 = -999; + }).toThrow(); + expect(wreck.structures[0]?.x1).not.toBe(-999); + }); + + it("refuses in-place mutation of nested presentation features", () => { + const cave = SITE_PRESENTATION.cave; + expect(cave).toBeDefined(); + if (!cave?.features) return; + expect(Object.isFrozen(cave.features)).toBe(true); + expect(() => { + (cave.features as unknown as { x: number }[])[0]!.x = -999; + }).toThrow(); + }); +}); diff --git a/tests/unit/site-layers.test.ts b/tests/unit/site-layers.test.ts new file mode 100644 index 0000000..1f647a2 --- /dev/null +++ b/tests/unit/site-layers.test.ts @@ -0,0 +1,193 @@ +// Structural guard for scene draw order. +// +// The silt regression — assigned to `terrain`, below `structure`, which put 31 +// of its 48 particles behind the hull's opaque fill — passed lint, typecheck, +// unit, parity and e2e. The only thing that would have caught it is a +// screenshot gate, and that covers the legacy client, not the Pixi one. +// +// These assertions cost milliseconds and need no GPU. They cannot see that a +// scene *looks* right, but they can see that something which must occlude +// another thing is still painted after it, which is the class of mistake that +// actually happened. + +import { describe, expect, it } from "vitest"; + +import { LAYERS, type LayerId } from "../../src/sites/asset-manifest"; +import { + BUBBLE_LAYER, + drawsAfter, + layerDepth, + RETAINED_LAYER_ASSIGNMENT, + visibleHalfExtentM, + type RetainedElement, +} from "../../src/render/layer-assignment"; +import { SITE_PRESENTATION } from "../../src/sites/layer-factory"; +import { ASSET_MANIFEST } from "../../src/sites/asset-manifest"; +import { buildSceneLayers } from "../../src/sites/layer-factory"; +import { createCameraTransform } from "../../src/render/camera"; + +const layerOf = (element: RetainedElement): LayerId => + RETAINED_LAYER_ASSIGNMENT[element]; + +describe("layer ordering is painter's-algorithm sane", () => { + it("orders the layer list from furthest to nearest", () => { + expect(LAYERS).toEqual([ + "backdrop", + "terrain", + "structure", + "decoration", + "fauna", + "foreground", + ]); + }); + + it("assigns every retained element to a real layer", () => { + for (const [element, id] of Object.entries(RETAINED_LAYER_ASSIGNMENT)) { + expect(LAYERS, `${element} -> ${id}`).toContain(id); + } + }); + + it("gives every asset a real layer too", () => { + for (const entry of Object.values(ASSET_MANIFEST)) { + expect(LAYERS, `${entry.id} -> ${entry.layer}`).toContain(entry.layer); + } + }); +}); + +describe("elements between the camera and the wreck are not painted behind it", () => { + // This is the assertion that would have caught the silt regression. + it.each(["silt", "route", "diver"] as const)( + "paints %s after the opaque hull", + (element) => { + expect(drawsAfter(layerOf(element), layerOf("hull"))).toBe(true); + }, + ); + + it("paints bubbles after the hull", () => { + expect(drawsAfter(BUBBLE_LAYER, layerOf("hull"))).toBe(true); + }); + + it("keeps silt above every opaque structural element", () => { + for (const element of ["hull", "rooms", "engine"] as const) { + expect( + drawsAfter(layerOf("silt"), layerOf(element)), + `silt must draw after ${element}`, + ).toBe(true); + } + }); +}); + +describe("the wreck occludes what is behind it", () => { + it("paints the hull after the seabed it rests on", () => { + expect(drawsAfter(layerOf("hull"), layerOf("seabed"))).toBe(true); + }); + + it("paints the seabed after the parallax silhouette", () => { + expect(drawsAfter(layerOf("seabed"), layerOf("distantHull"))).toBe(true); + }); + + it("keeps the distant hull furthest back of anything retained", () => { + const distant = layerDepth(layerOf("distantHull")); + for (const element of Object.keys(RETAINED_LAYER_ASSIGNMENT) as RetainedElement[]) { + if (element === "distantHull") continue; + expect(layerDepth(layerOf(element)), element).toBeGreaterThanOrEqual(distant); + } + }); +}); + +describe("data-driven placements sit where their role implies", () => { + // Structural props (cars, columns, the helm) are solid-looking and must not + // be painted behind the terrain they stand on. + it("paints structural assets after terrain", () => { + const structural = Object.values(ASSET_MANIFEST).filter( + (entry) => entry.layer === "structure", + ); + expect(structural.length).toBeGreaterThan(0); + expect(drawsAfter("structure", "terrain")).toBe(true); + }); + + it("paints decoration after the structures it dresses", () => { + expect(drawsAfter("decoration", "structure")).toBe(true); + }); + + it("paints fauna after decoration, and foreground after everything", () => { + expect(drawsAfter("fauna", "decoration")).toBe(true); + for (const id of LAYERS) { + if (id === "foreground") continue; + expect(drawsAfter("foreground", id), `foreground vs ${id}`).toBe(true); + } + }); +}); + +describe("the cull window follows the viewport, not a constant", () => { + // The camera fits a constant 58 m of WIDTH, so visible HEIGHT is + // viewport.height / scale and grows with aspect ratio. A fixed 20 m + // half-height described the desktop window and nothing else: at 390x844 the + // screen shows ~125 m of depth, and four on-screen placements — the engine + // row at d=61 and the anchor at d=66 — were culled while in frame. + // The cull window comes from the renderer's own helper. What is on screen is + // computed here from viewport arithmetic instead, so the two are not the same + // number wearing different hats — pinning the helper back to a constant has + // to fail this, not agree with it. + const windowFor = (width: number, height: number) => { + const camera = createCameraTransform({ width, height }, { x: 58, y: 25 }); + const cull = visibleHalfExtentM(camera); + const screenHalfW = width / camera.scale / 2; + const screenHalfH = height / camera.scale / 2; + return { camera, cull, screenHalfW, screenHalfH }; + }; + + it.each([ + [390, 844], + [320, 568], + [1280, 800], + [844, 390], + ])("covers everything on screen at %ix%i", (width, height) => { + const { camera, cull, screenHalfW, screenHalfH } = windowFor(width, height); + const layers = buildSceneLayers("wreck", { + qualityTier: "high", + cullMarginM: 10, + camera: { + leftM: camera.focus.x - cull.halfWidthM, + rightM: camera.focus.x + cull.halfWidthM, + topM: camera.focus.y - cull.halfHeightM, + bottomM: camera.focus.y + cull.halfHeightM, + }, + }); + const kept = new Set( + layers.flatMap((l) => l.placements).map((p) => `${p.x},${p.d}`), + ); + + // Anything inside the visible rectangle must survive culling. + const site = SITE_PRESENTATION.wreck; + for (const feature of site?.features ?? []) { + if (!ASSET_MANIFEST[feature.kind]) continue; + const d = feature.d ?? feature.dTop ?? 0; + const onScreen = + feature.x >= camera.focus.x - screenHalfW && + feature.x <= camera.focus.x + screenHalfW && + d >= camera.focus.y - screenHalfH && + d <= camera.focus.y + screenHalfH; + if (onScreen) { + expect(kept.has(`${feature.x},${d}`), `${feature.kind} (${feature.x},${d})`).toBe( + true, + ); + } + } + }); + + it("never culls inside the visible rectangle, at any aspect ratio", () => { + for (const [w, h] of [[390, 844], [320, 568], [1280, 800], [844, 390], [412, 915]]) { + const { cull, screenHalfW, screenHalfH } = windowFor(w!, h!); + expect(cull.halfWidthM, `${w}x${h} width`).toBeGreaterThanOrEqual(screenHalfW); + expect(cull.halfHeightM, `${w}x${h} height`).toBeGreaterThanOrEqual(screenHalfH); + } + }); + + it("grows the window as a portrait viewport gets taller", () => { + expect(windowFor(390, 844).cull.halfHeightM).toBeGreaterThan( + windowFor(1280, 800).cull.halfHeightM, + ); + expect(windowFor(390, 844).cull.halfHeightM).toBeGreaterThan(20); + }); +}); diff --git a/tests/unit/site-presentation-refs.test.ts b/tests/unit/site-presentation-refs.test.ts new file mode 100644 index 0000000..42bfe52 --- /dev/null +++ b/tests/unit/site-presentation-refs.test.ts @@ -0,0 +1,109 @@ +// Semantic validation of presentation data — the cross-references and +// uniqueness a JSON Schema structurally cannot express. +// +// A schema can say `zone` is a string. It cannot say the string names a zone +// that exists. Every failure here is silent at runtime: a decoration rule +// pointing at a misspelled zone matches no candidate, so an entire scatter of +// props simply never appears, with nothing logged and no error raised. + +import { describe, expect, it } from "vitest"; + +import { + SITE_PRESENTATION, + validateSitePresentation, + type SitePresentation, +} from "../../src/sites/layer-factory"; + +const wreck = SITE_PRESENTATION.wreck as SitePresentation; + +/** Shallow-clone a site deeply enough to mutate one of its arrays. */ +function mutable(site: SitePresentation): SitePresentation { + return JSON.parse(JSON.stringify(site)) as SitePresentation; +} + +describe("shipped presentation data is semantically sound", () => { + it("has no dangling references, duplicates or inverted extents", () => { + for (const [id, site] of Object.entries(SITE_PRESENTATION)) { + expect(validateSitePresentation(site), id).toEqual([]); + } + }); + + it("resolves every decoration rule to a declared zone", () => { + for (const [id, site] of Object.entries(SITE_PRESENTATION)) { + const zones = new Set((site.visualZones ?? []).map((zone) => zone.id)); + for (const rule of site.decorationRules ?? []) { + expect(zones.has(rule.zone), `${id}/${rule.id} -> ${rule.zone}`).toBe(true); + } + } + }); + + it("keys every atmosphere profile by a declared zone", () => { + for (const [id, site] of Object.entries(SITE_PRESENTATION)) { + const zones = new Set((site.visualZones ?? []).map((zone) => zone.id)); + for (const key of Object.keys(site.atmosphereProfiles ?? {})) { + expect(zones.has(key), `${id} atmosphere "${key}"`).toBe(true); + } + } + }); +}); + +describe("the validator catches what the schema cannot", () => { + it("rejects a decoration rule pointing at a zone that does not exist", () => { + const site = mutable(wreck); + (site.decorationRules as unknown as { zone: string }[])[0]!.zone = "typo-zone"; + const problems = validateSitePresentation(site); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain("unknown zone"); + }); + + it("rejects an atmosphere profile keyed to no zone", () => { + const site = mutable(wreck); + (site.atmosphereProfiles as unknown as Record)["wreck_nowhere"] = {}; + expect(validateSitePresentation(site)).toEqual([ + expect.stringContaining("matches no visual zone"), + ]); + }); + + it("rejects duplicate zone ids", () => { + const site = mutable(wreck); + const zones = site.visualZones as unknown as { id: string }[]; + zones.push({ ...zones[0]! }); + expect(validateSitePresentation(site)).toEqual([ + expect.stringContaining("duplicate visualZone id"), + ]); + }); + + it("rejects duplicate decoration rule ids", () => { + const site = mutable(wreck); + const rules = site.decorationRules as unknown as { id: string }[]; + rules.push({ ...rules[0]! }); + expect(validateSitePresentation(site)).toEqual([ + expect.stringContaining("duplicate decorationRule id"), + ]); + }); + + it("rejects an inverted zone extent, which can never contain a point", () => { + const site = mutable(wreck); + const zone = (site.visualZones as unknown as { x1: number; x2: number }[])[0]!; + [zone.x1, zone.x2] = [zone.x2, zone.x1]; + expect(validateSitePresentation(site)).toEqual([ + expect.stringContaining("before x1"), + ]); + }); + + it("rejects an inverted zone depth span", () => { + const site = mutable(wreck); + const zone = (site.visualZones as unknown as { d1: number; d2: number }[])[0]!; + [zone.d1, zone.d2] = [zone.d2, zone.d1]; + expect(validateSitePresentation(site)).toEqual([ + expect.stringContaining("above d1"), + ]); + }); + + it("reports every problem at once rather than stopping at the first", () => { + const site = mutable(wreck); + (site.decorationRules as unknown as { zone: string }[])[0]!.zone = "typo-one"; + (site.decorationRules as unknown as { zone: string }[])[1]!.zone = "typo-two"; + expect(validateSitePresentation(site)).toHaveLength(2); + }); +}); diff --git a/tests/unit/site-schema.test.ts b/tests/unit/site-schema.test.ts new file mode 100644 index 0000000..7a7c4e8 --- /dev/null +++ b/tests/unit/site-schema.test.ts @@ -0,0 +1,234 @@ +// WP-07: execute the committed schemas. +// +// `validateSiteGameplay` checks geometry *relationships* — profile ordering, +// inverted boxes, structures below maxDepth. It says nothing about types, so a +// descriptor typo that produces `hasOverhead: "false"` or `maxDepth: "300"` +// passes generation, parity, typecheck and CI while changing runtime behaviour: +// the string "false" is truthy, and an overhead site silently becomes one the +// diver can surface from. +// +// The schemas existed but had no caller, which made the "invalid site data +// fails at build/test time" criterion untrue for the whole class of type +// errors. These tests are that caller. + +import Ajv2020 from "ajv/dist/2020"; +import { describe, expect, it } from "vitest"; + +import gameplayDocument from "../../src/sites/resources/gameplay.json"; +import presentationDocument from "../../src/sites/resources/presentation.json"; +import gameplaySchema from "../../docs/baseline/schemas/site.schema.json"; +import presentationSchema from "../../docs/baseline/schemas/site-presentation.schema.json"; + +const ajv = new Ajv2020({ allErrors: true, strict: true }); +const validateGameplay = ajv.compile(gameplaySchema); +const validatePresentation = ajv.compile(presentationSchema); + +/** Deep clone so a mutation probe cannot leak into another test. */ +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +describe("shipped site resources satisfy their schemas", () => { + it("validates the gameplay document", () => { + expect( + validateGameplay(gameplayDocument), + ajv.errorsText(validateGameplay.errors), + ).toBe(true); + }); + + it("validates the presentation document", () => { + expect( + validatePresentation(presentationDocument), + ajv.errorsText(validatePresentation.errors), + ).toBe(true); + }); +}); + +describe("the schemas reject the type errors relationship checks cannot see", () => { + it("rejects a stringly-typed hasOverhead", () => { + // The motivating case: "false" is truthy, so overheadAt() would treat an + // open-water site as an overhead environment. + const document = clone(gameplayDocument); + (document as never as { sites: Record }).sites.reef! + .hasOverhead = "false"; + expect(validateGameplay(document)).toBe(false); + }); + + it("rejects a stringly-typed maxDepth", () => { + const document = clone(gameplayDocument); + (document as never as { sites: Record }).sites.reef! + .maxDepth = "300"; + expect(validateGameplay(document)).toBe(false); + }); + + it("rejects a negative structure depth", () => { + const document = clone(gameplayDocument); + ( + document as never as { sites: Record } + ).sites.wreck!.structures[0]!.dTop = -5; + expect(validateGameplay(document)).toBe(false); + }); + + it("rejects an unknown top-level site field", () => { + // `additionalProperties: false` is what stops presentation data drifting + // into the gameplay document unnoticed. + const document = clone(gameplayDocument); + (document as never as { sites: Record> }).sites.reef! + .features = []; + expect(validateGameplay(document)).toBe(false); + }); + + it("rejects a feature whose x is not a number", () => { + const document = clone(presentationDocument); + ( + document as never as { sites: Record } + ).sites.wreck!.features[0]!.x = "34"; + expect(validatePresentation(document)).toBe(false); + }); + + it("rejects provenance that is not an input digest", () => { + const document = clone(gameplayDocument); + (document as never as { sourceDigest: string }).sourceDigest = "4046bdac"; + expect(validateGameplay(document)).toBe(false); + }); +}); + +describe("the entry point is typed gameplay data, not an open object", () => { + // `entry` is spawn data in the gameplay document, but it was declared as a + // bare `{ "type": "object" }` and left out of SiteGameplay entirely, so a + // string coordinate passed Ajv, typecheck and CI. The parity suite compares + // floor, ceiling, solid, overhead and bad air — never entry — so nothing else + // would have caught it either. + const withEntry = (entry: unknown) => { + const document = clone(gameplayDocument); + (document as never as { sites: Record }).sites.reef!.entry = + entry; + return document; + }; + + it("rejects a stringly-typed entry x", () => { + expect(validateGameplay(withEntry({ x: "not-a-number" }))).toBe(false); + }); + + it("rejects an entry with no x at all", () => { + expect(validateGameplay(withEntry({ totallyBogus: true }))).toBe(false); + }); + + it("requires every site to declare one", () => { + const document = clone(gameplayDocument); + delete (document as never as { sites: Record }).sites.reef! + .entry; + expect(validateGameplay(document)).toBe(false); + }); + + it("accepts a well-formed entry", () => { + expect(validateGameplay(withEntry({ x: -12.5 }))).toBe(true); + }); +}); + +describe("decoration rules are closed over what the renderer actually reads", () => { + // renderer.js guards decoration placement with `rule.f != null && x < rule.f`. + // A string makes that comparison NaN-false, so the guard stops guarding and + // props render at every depth. Undeclared numeric fields are therefore worse + // than absent ones, and every field the renderer reads has to be pinned — + // not only the ones the shipped data happens to author today. + const rule = (patch: Record) => { + const document = clone(presentationDocument); + const rules = ( + document as never as { + sites: Record[] }>; + } + ).sites.wreck!.decorationRules; + Object.assign(rules[0]!, patch); + return document; + }; + + it.each([ + ["minDepth", "not-a-number"], + ["maxDepth", "60"], + ["minScale", "0.5"], + ["maxScale", "2"], + ["rotationJitter", "1"], + ["alpha", "0.5"], + ["maxPerScreen", "12"], + ])("rejects a stringly-typed %s", (field, value) => { + expect(validatePresentation(rule({ [field]: value }))).toBe(false); + }); + + it("rejects a non-integer maxPerScreen", () => { + expect(validatePresentation(rule({ maxPerScreen: 12.5 }))).toBe(false); + }); + + it("rejects an alpha outside 0..1", () => { + expect(validatePresentation(rule({ alpha: 4 }))).toBe(false); + }); + + it("rejects an unknown field, which the renderer would read as undefined", () => { + // A typo like `minDepht` would otherwise validate and then be defaulted + // away silently, which is how a guard goes missing without anyone noticing. + expect(validatePresentation(rule({ minDepht: 20 }))).toBe(false); + }); + + it("still accepts every field the renderer legitimately reads", () => { + expect( + validatePresentation( + rule({ + minDepth: 20, + maxDepth: 60, + minScale: 0.5, + maxScale: 2, + rotationJitter: 1, + alpha: 0.5, + maxPerScreen: 12, + }), + ), + ).toBe(true); + }); + + it("closes visual zones and atmosphere profiles too", () => { + const zones = clone(presentationDocument); + ( + zones as never as { + sites: Record[] }>; + } + ).sites.cave!.visualZones[0]!.bogus = 1; + expect(validatePresentation(zones)).toBe(false); + + const atmos = clone(presentationDocument); + const profiles = ( + atmos as never as { + sites: Record> }>; + } + ).sites.cave!.atmosphereProfiles; + Object.values(profiles)[0]!.bogus = 1; + expect(validatePresentation(atmos)).toBe(false); + }); +}); + +describe("ceiling must be present, with null spelled out", () => { + // An absent `ceiling` makes ceilingAt() fall through to 0, turning the cave + // roof into open water — 14 m becomes 0 m at x=50 — so the diver can swim up + // through it. Parity cannot catch it: legacy and generated data lose the + // field together and agree on 0. + it("rejects a site with no ceiling field", () => { + const document = clone(gameplayDocument); + delete (document as never as { sites: Record }).sites + .cave!.ceiling; + expect(validateGameplay(document)).toBe(false); + }); + + it("accepts explicit null for an open-water site", () => { + const document = clone(gameplayDocument); + (document as never as { sites: Record }).sites.reef! + .ceiling = null; + expect(validateGameplay(document)).toBe(true); + }); + + it("still ships a ceiling on every site", () => { + for (const [id, site] of Object.entries( + (gameplayDocument as { sites: Record }).sites, + )) { + expect(Object.hasOwn(site, "ceiling"), `${id} must declare ceiling`).toBe(true); + } + }); +});