From dd1fb0035999797ce6a18112e500c1f5099327bb Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Tue, 4 Aug 2026 17:19:58 +0200 Subject: [PATCH 01/10] feat: split site gameplay data from presentation data WP-07 begins by making the separation structural rather than documented. The legacy descriptors mix collision, air, spawn and current data with features, visual zones, atmosphere and decoration rules in one object, so "change how a site looks" and "change where the diver can swim" were the same edit. scripts/generate-site-resources.mjs partitions the descriptors into two renderer-neutral documents and refuses to run if a descriptor grows a field that is in neither list. A new field must be deliberately classified, never silently dropped or silently promoted into collision. src/sites/site-resources.ts validates on import and throws rather than serving geometry that corrupts quietly: - 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 can never be hit by solidAt, so a wall silently stops existing. tests/parity/site-geometry.test.ts replays floorAt, ceilingAt, solidAt, overheadAt and badAirAt against the real legacy source across a sampled grid of every site, including beyond the profile ends where clamping applies. That test earned its place immediately: the first implementation folded lerpProfile's division into its multiplication, which is algebraically identical and numerically is not, and it broke parity on two sites by one ULP. The operation order is now preserved deliberately, with a comment saying why. --- docs/baseline/schemas/site.schema.json | 82 ++ package.json | 3 +- scripts/generate-site-resources.mjs | 107 ++ src/sites/resources/gameplay.json | 692 ++++++++++ src/sites/resources/presentation.json | 1743 ++++++++++++++++++++++++ src/sites/site-resources.ts | 116 ++ tests/parity/site-geometry.test.ts | 185 +++ 7 files changed, 2927 insertions(+), 1 deletion(-) create mode 100644 docs/baseline/schemas/site.schema.json create mode 100644 scripts/generate-site-resources.mjs create mode 100644 src/sites/resources/gameplay.json create mode 100644 src/sites/resources/presentation.json create mode 100644 src/sites/site-resources.ts create mode 100644 tests/parity/site-geometry.test.ts diff --git a/docs/baseline/schemas/site.schema.json b/docs/baseline/schemas/site.schema.json new file mode 100644 index 0000000..4131d11 --- /dev/null +++ b/docs/baseline/schemas/site.schema.json @@ -0,0 +1,82 @@ +{ + "$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", "sourceCommit", "generator", "sites"], + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "diving-simulator-site-gameplay" }, + "sourceCommit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "generator": { "type": "string", "minLength": 1 }, + "sites": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/site" } + } + }, + "$defs": { + "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", "floor", "structures", "badAir"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "maxDepth": { "type": "number", "exclusiveMinimum": 0 }, + "hasOverhead": { "type": "boolean" }, + "entry": { "type": "object" }, + "boatX": { "type": "number" }, + "floor": { "$ref": "#/$defs/profile" }, + "ceiling": { + "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..2bb79e6 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "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" }, "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..57ee574 --- /dev/null +++ b/scripts/generate-site-resources.mjs @@ -0,0 +1,107 @@ +// 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 { execFileSync } from 'node:child_process'; + +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', +]; + +function loadLegacySites() { + const sandbox = { MAX_DEPTH: 100 }; + vm.createContext(sandbox); + vm.runInContext(fs.readFileSync(path.join(root, 'src', 'sites.js'), 'utf8'), 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.', + ); + } +} + +const sites = loadLegacySites(); +const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', +}).trim(); + +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); +} + +fs.mkdirSync(outputDirectory, { recursive: true }); +const header = { schemaVersion: 1, sourceCommit, generator: 'npm run sites:generate' }; +fs.writeFileSync( + path.join(outputDirectory, 'gameplay.json'), + `${JSON.stringify({ ...header, kind: 'diving-simulator-site-gameplay', sites: gameplay }, null, 2)}\n`, + 'utf8', +); +fs.writeFileSync( + path.join(outputDirectory, 'presentation.json'), + `${JSON.stringify({ ...header, kind: 'diving-simulator-site-presentation', sites: presentation }, null, 2)}\n`, + 'utf8', +); + +console.log(`wrote ${Object.keys(gameplay).length} site resources to src/sites/resources/`); diff --git a/src/sites/resources/gameplay.json b/src/sites/resources/gameplay.json new file mode 100644 index 0000000..5d023fd --- /dev/null +++ b/src/sites/resources/gameplay.json @@ -0,0 +1,692 @@ +{ + "schemaVersion": 1, + "sourceCommit": "bb189490dca6cba7594383196409fe87aa15f1c2", + "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": 100, + "hasOverhead": false, + "entry": { + "x": 0 + }, + "boatX": 60, + "floor": [ + { + "x": -200, + "d": 100 + }, + { + "x": -26, + "d": 100 + }, + { + "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": 100 + }, + { + "x": 200, + "d": 100 + } + ], + "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..6b8acf3 --- /dev/null +++ b/src/sites/resources/presentation.json @@ -0,0 +1,1743 @@ +{ + "schemaVersion": 1, + "sourceCommit": "bb189490dca6cba7594383196409fe87aa15f1c2", + "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": 100, + "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..4111ff5 --- /dev/null +++ b/src/sites/site-resources.ts @@ -0,0 +1,116 @@ +// 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"; + +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; +} + +export interface SiteGameplay { + readonly id: string; + readonly maxDepth: number; + readonly hasOverhead: boolean; + 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> = Object.freeze(sites); + +export function siteGameplay(id: string): SiteGameplay | null { + return SITE_GAMEPLAY[id] ?? null; +} + +export const SITE_GAMEPLAY_SOURCE_COMMIT = ( + gameplayDocument as { sourceCommit: string } +).sourceCommit; diff --git a/tests/parity/site-geometry.test.ts b/tests/parity/site-geometry.test.ts new file mode 100644 index 0000000..d685dc7 --- /dev/null +++ b/tests/parity/site-geometry.test.ts @@ -0,0 +1,185 @@ +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 { + 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; +} + +const MAX_DEPTH = 100; + +// `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([]); + }); +}); From 3a5aedd9e1ca4fffca80c536ed38dd5466520b29 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Tue, 4 Aug 2026 17:25:25 +0200 Subject: [PATCH 02/10] feat: add the site asset manifest and renderer-neutral layer factory The Pixi slice built its wreck from hand-written Graphics calls and read no site data at all, which is the scaling limit WP-07 exists to remove. This adds the data path without yet rewiring the renderer. The manifest maps every authored feature kind to one asset, with an atlas, a layer and a minimum quality tier. The mapping is hand-authored because it is art direction, but completeness is enforced by test in both directions: a new feature kind fails until someone assigns it an asset, and an orphaned entry fails until someone removes it. Neither can rot quietly. The layer factory turns presentation data into an ordered, culled placement list and builds no Pixi objects, so it is testable without a GPU and a second renderer could consume the same output. Draw order is sorted rather than inherited from authoring order, so the same inputs always produce the same scene. Quality tiers drop decoration before the things a diver navigates by. Feature shapes are modelled as authored rather than as assumed: some are points with a `d`, others span `dTop`/`dBottom`, and kinds carry their own extra fields. The first version assumed a uniform `{kind, x, d}` and the typechecker rejected it against the real data. WP-07's acceptance criterion now has a test: re-pointing an asset at a different atlas and frame leaves every gameplay value byte-identical, and the two documents are asserted structurally disjoint so an art edit cannot reach collision again. --- src/sites/asset-manifest.ts | 107 ++++++++++++++++++++++ src/sites/layer-factory.ts | 156 +++++++++++++++++++++++++++++++++ tests/unit/site-assets.test.ts | 139 +++++++++++++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 src/sites/asset-manifest.ts create mode 100644 src/sites/layer-factory.ts create mode 100644 tests/unit/site-assets.test.ts 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/layer-factory.ts b/src/sites/layer-factory.ts new file mode 100644 index 0000000..97b6999 --- /dev/null +++ b/src/sites/layer-factory.ts @@ -0,0 +1,156 @@ +// 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 { + 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 placed and culled at: its point, or the top of its span. */ +export function featureDepth(feature: SiteFeature): number { + return feature.d ?? feature.dTop ?? 0; +} + +export interface SitePresentation { + readonly id: string; + readonly name?: string; + readonly features?: readonly SiteFeature[]; +} + +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; + +export const SITE_PRESENTATION: Readonly> = + Object.freeze(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); + if ( + feature.x < camera.leftM - margin || + feature.x > camera.rightM + margin || + depth < camera.topM - margin || + depth > 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/tests/unit/site-assets.test.ts b/tests/unit/site-assets.test.ts new file mode 100644 index 0000000..5ac1600 --- /dev/null +++ b/tests/unit/site-assets.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { + ASSET_MANIFEST, + assetFor, + atlasesFor, + LAYERS, + QUALITY_TIERS, + tierAllows, +} from "../../src/sites/asset-manifest"; +import { + authoredFeatureKinds, + buildSceneLayers, + 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. + it("leaves every gameplay value untouched when the art changes", () => { + const before = JSON.stringify(SITE_GAMEPLAY); + + // Re-point an asset at a different atlas and frame, exactly as swapping in + // new art would, and rebuild the scene from it. + const original = ASSET_MANIFEST.tableCoral; + expect(original).toBeDefined(); + const swapped = { + ...original, + atlas: "reef-v2", + frame: "tableCoralRedesigned", + }; + + const rebuilt = buildSceneLayers("reef", { + camera: WHOLE_MAP, + qualityTier: "high", + }); + expect(rebuilt.some((layer) => layer.placements.length > 0)).toBe(true); + expect(swapped.atlas).not.toBe(original?.atlas); + + 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, + ); + } + } + }); +}); From 4046bdac4a2b4546b714c2faf4363a5668709ab4 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Tue, 4 Aug 2026 17:29:28 +0200 Subject: [PATCH 03/10] feat: build the Pixi wreck scene from site data The renderer added its children in a flat list, so draw order was an accident of call order and there was nowhere for authored content to go. It now owns named layer containers matching the manifest, and populates them from the layer factory. Placement markers are pooled. Which features are visible changes many times during a dive, and allocating a Graphics per feature per resync would churn the heap for a scene whose contents barely change. Resync is also skipped until the camera has actually travelled, rather than running every frame. Markers are deliberately plain shapes. Production atlases are BLOCKED_EXTERNAL, so this draws a placeholder rather than art guessed at here; the manifest already fixes the atlas and frame contract they will load through, so substituting real textures does not touch this file's structure. The hand-authored hull, seabed and engine stay as they are. They are bespoke art for one scene, not authored site content, and replacing them with markers would degrade the slice to prove a point that the decoration layer already proves. --- src/render/pixi-renderer.ts | 130 +++++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 11 deletions(-) diff --git a/src/render/pixi-renderer.ts b/src/render/pixi-renderer.ts index 30c89f4..5b8fcd4 100644 --- a/src/render/pixi-renderer.ts +++ b/src/render/pixi-renderer.ts @@ -1,11 +1,21 @@ import { Application, Container, Graphics } from "pixi.js"; import type { PresentationState } from "../presentation/presentation-state"; +import { LAYERS, type LayerId, type QualityTier } from "../sites/asset-manifest"; +import { buildSceneLayers } from "../sites/layer-factory"; import { createCameraTransform } from "./camera"; import type { SceneRenderer, WreckSceneState } from "./renderer"; const MAX_RESOLUTION = 2; const BUBBLE_COUNT = 14; +const SITE_ID = "wreck"; + +// Half-width and half-height of the visible world window, in metres. Culling +// uses this rather than the exact viewport so the set only changes when the +// camera has actually travelled, not on every sub-metre movement. +const CULL_HALF_WIDTH_M = 34; +const CULL_HALF_HEIGHT_M = 20; +const RESYNC_DISTANCE_M = 4; export class PixiWreckRenderer implements SceneRenderer { readonly kind = "pixi" as const; @@ -18,6 +28,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) { @@ -42,6 +60,7 @@ export class PixiWreckRenderer implements SceneRenderer { host.replaceChildren(app.canvas); app.stage.addChild(this.#background, this.#world); this.#buildRetainedScene(); + this.#syncSceneLayers({ x: 0, y: 0 }, true); const bounds = host.getBoundingClientRect(); this.resize( @@ -79,6 +98,7 @@ export class PixiWreckRenderer implements SceneRenderer { ); this.#world.pivot.set(camera.focus.x, camera.focus.y); this.#world.scale.set(camera.scale); + this.#syncSceneLayers({ x: camera.focus.x, y: camera.focus.y }, false); this.#diver.position.set(scene.routePositionM, scene.diverDepthM); this.#diver.scale.x = scene.facing; this.#torch.visible = scene.torchOn; @@ -110,6 +130,83 @@ 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(focus: { x: number; y: number }, force: boolean): void { + 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 }; + + const layers = buildSceneLayers(SITE_ID, { + qualityTier: this.#qualityTier, + camera: { + leftM: focus.x - CULL_HALF_WIDTH_M, + rightM: focus.x + CULL_HALF_WIDTH_M, + topM: focus.y - CULL_HALF_HEIGHT_M, + bottomM: focus.y + CULL_HALF_HEIGHT_M, + }, + }); + + 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); + if (marker.parent !== container) { + 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,23 +281,34 @@ export class PixiWreckRenderer implements SceneRenderer { .stroke({ color: 0x17252a, width: 0.25 }); this.#diver.addChild(this.#torch, diverBody); - this.#world.addChild( - distantHull, - seabed, - hull, - rooms, - engine, - route, - silt, - this.#diver, - ); + + // 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); + } + + this.#layers.get("backdrop")?.addChild(distantHull); + this.#layers.get("terrain")?.addChild(seabed, silt); + this.#layers.get("structure")?.addChild(hull, rooms, engine); + this.#layers.get("foreground")?.addChild(route, this.#diver); + + 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("foreground")?.addChild(bubble); } } From 17edd4cc9ea370bf2bff13c5106418255b191d4a Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Fri, 21 Aug 2026 12:36:50 +0200 Subject: [PATCH 04/10] fix: read MAX_DEPTH from constants.js instead of hardcoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator and the parity test each hardcoded MAX_DEPTH as 100. The real value in src/constants.js is 300, and sites.js uses it for the reef descriptor, so the generated resources were wrong: reef.maxDepth, both floor abyss endpoints and the blue-water zone all landed at 100 instead of 300. The parity test could not catch this. It injected the same literal into the legacy source that its own reimplementation used, so both sides agreed at 100 and every assertion passed against data that does not match the running game. Reverting reef to the old values now fails that test with 480 mismatches. Both sites read the constant from constants.js and fail loudly if the declaration moves. It cannot be evaluated into the vm sandbox — constants.js declares MAX_DEPTH with const, which never becomes a property of the context object — so the value is extracted from the source text. Also adds `npm run sites:check`, wired into both workflows, so an edit to sites.js that skips regeneration fails CI rather than leaving the resources quietly stale. It compares only the `sites` payload; sourceCommit moves with every commit and would otherwise fail on every run. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy.yml | 3 + .github/workflows/pr.yml | 3 + package.json | 3 +- scripts/generate-site-resources.mjs | 92 +++++++++++++++++++++------ src/sites/resources/gameplay.json | 12 ++-- src/sites/resources/presentation.json | 4 +- tests/parity/site-geometry.test.ts | 15 ++++- 7 files changed, 102 insertions(+), 30 deletions(-) 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/package.json b/package.json index 2bb79e6..af2ee5c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "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", - "sites:generate": "node scripts/generate-site-resources.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 index 57ee574..beb73d5 100644 --- a/scripts/generate-site-resources.mjs +++ b/scripts/generate-site-resources.mjs @@ -44,8 +44,28 @@ const PRESENTATION_FIELDS = [ 'decorationRules', ]; -function loadLegacySites() { - const sandbox = { MAX_DEPTH: 100 }; +// `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() { + const source = fs.readFileSync(path.join(root, 'src', 'constants.js'), 'utf8'); + 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(maxDepth) { + const sandbox = { MAX_DEPTH: maxDepth }; vm.createContext(sandbox); vm.runInContext(fs.readFileSync(path.join(root, 'src', 'sites.js'), 'utf8'), sandbox); if (!sandbox.DIVE_SITES) { @@ -77,11 +97,7 @@ function assertPartitioned(site) { } } -const sites = loadLegacySites(); -const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: root, - encoding: 'utf8', -}).trim(); +const sites = loadLegacySites(readMaxDepth()); const gameplay = {}; const presentation = {}; @@ -91,17 +107,53 @@ for (const [id, site] of Object.entries(sites)) { presentation[id] = pick(site, PRESENTATION_FIELDS); } -fs.mkdirSync(outputDirectory, { recursive: true }); -const header = { schemaVersion: 1, sourceCommit, generator: 'npm run sites:generate' }; -fs.writeFileSync( - path.join(outputDirectory, 'gameplay.json'), - `${JSON.stringify({ ...header, kind: 'diving-simulator-site-gameplay', sites: gameplay }, null, 2)}\n`, - 'utf8', -); -fs.writeFileSync( - path.join(outputDirectory, 'presentation.json'), - `${JSON.stringify({ ...header, kind: 'diving-simulator-site-presentation', sites: presentation }, null, 2)}\n`, - 'utf8', -); +const DOCUMENTS = [ + { file: 'gameplay.json', kind: 'diving-simulator-site-gameplay', sites: gameplay }, + { file: 'presentation.json', kind: 'diving-simulator-site-presentation', sites: presentation }, +]; -console.log(`wrote ${Object.keys(gameplay).length} site resources to src/sites/resources/`); +// `--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. +// +// Only the `sites` payload is compared. `sourceCommit` is provenance and moves +// with every commit, so including it would make the check fail on every run. +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 actual = JSON.parse(fs.readFileSync(target, 'utf8')).sites; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + stale.push(`${file} does not match src/sites.js`); + } + } + 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 { + const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + }).trim(); + + fs.mkdirSync(outputDirectory, { recursive: true }); + const header = { schemaVersion: 1, sourceCommit, generator: 'npm run sites:generate' }; + 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/sites/resources/gameplay.json b/src/sites/resources/gameplay.json index 5d023fd..6e51e78 100644 --- a/src/sites/resources/gameplay.json +++ b/src/sites/resources/gameplay.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceCommit": "bb189490dca6cba7594383196409fe87aa15f1c2", + "sourceCommit": "4046bdac4a2b4546b714c2faf4363a5668709ab4", "generator": "npm run sites:generate", "kind": "diving-simulator-site-gameplay", "sites": { @@ -90,7 +90,7 @@ }, "reef": { "id": "reef", - "maxDepth": 100, + "maxDepth": 300, "hasOverhead": false, "entry": { "x": 0 @@ -99,11 +99,11 @@ "floor": [ { "x": -200, - "d": 100 + "d": 300 }, { "x": -26, - "d": 100 + "d": 300 }, { "x": -20, @@ -139,11 +139,11 @@ }, { "x": 26, - "d": 100 + "d": 300 }, { "x": 200, - "d": 100 + "d": 300 } ], "ceiling": null, diff --git a/src/sites/resources/presentation.json b/src/sites/resources/presentation.json index 6b8acf3..c25670e 100644 --- a/src/sites/resources/presentation.json +++ b/src/sites/resources/presentation.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceCommit": "bb189490dca6cba7594383196409fe87aa15f1c2", + "sourceCommit": "4046bdac4a2b4546b714c2faf4363a5668709ab4", "generator": "npm run sites:generate", "kind": "diving-simulator-site-presentation", "sites": { @@ -576,7 +576,7 @@ "x1": -100, "x2": 100, "d1": 0, - "d2": 100, + "d2": 300, "priority": 0, "blend": 0, "tags": [ diff --git a/tests/parity/site-geometry.test.ts b/tests/parity/site-geometry.test.ts index d685dc7..7f1bb3c 100644 --- a/tests/parity/site-geometry.test.ts +++ b/tests/parity/site-geometry.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; // 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, @@ -21,7 +22,19 @@ interface LegacyApi { badAirAt(x: number): unknown; } -const MAX_DEPTH = 100; +// 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( From 0391fa756fad68aacb1f6ffe14eb31c025ab56de Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Fri, 21 Aug 2026 12:42:40 +0200 Subject: [PATCH 05/10] fix: cull features by their full depth span, and freeze resources deeply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the new site modules, both found while reviewing for merge. Culling compared the camera window against `featureDepth()`, which is a feature's anchor — for a span it returns `dTop`. The cave columns span dTop=56..dBottom=98 and dTop=60..dBottom=95, so a diver swimming toward the cathedral floor left the anchor's window while the columns still filled the screen: at depth 90 one of the two disappeared, at 95 both did. Culling now does an overlap test against the whole extent via `featureDepthRange()`. Anchoring is unchanged; it was only ever culling that needed the span. `Object.freeze` is shallow, so freezing the site documents left every nested `structures`, `floor` and `features` array writable. The immutability both resource modules advertise was nominal — a consumer holding a reference could have mutated collision geometry in place for everyone else. `deepFreeze` is its own module because the gameplay and presentation resources must not import each other. Both are covered by tests that fail without the fix: the span tests read 1 and 0 columns against the expected 2, and the immutability tests see unfrozen nested arrays. Co-Authored-By: Claude Opus 5 --- src/sites/deep-freeze.ts | 20 +++++++++++ src/sites/layer-factory.ts | 28 ++++++++++++--- src/sites/site-resources.ts | 3 +- tests/unit/site-assets.test.ts | 65 ++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 src/sites/deep-freeze.ts 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 index 97b6999..70a309a 100644 --- a/src/sites/layer-factory.ts +++ b/src/sites/layer-factory.ts @@ -6,6 +6,7 @@ // rather than a hand-written pile of Graphics calls. import presentationDocument from "./resources/presentation.json"; +import { deepFreeze } from "./deep-freeze"; import { assetFor, atlasesFor, @@ -30,11 +31,27 @@ export interface SiteFeature { readonly [extra: string]: unknown; } -/** Depth a feature is placed and culled at: its point, or the top of its span. */ +/** 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 SitePresentation { readonly id: string; readonly name?: string; @@ -66,7 +83,7 @@ const presentation = ( ).sites; export const SITE_PRESENTATION: Readonly> = - Object.freeze(presentation); + deepFreeze(presentation); export function sitePresentation(id: string): SitePresentation | null { return SITE_PRESENTATION[id] ?? null; @@ -125,11 +142,14 @@ export function buildSceneLayers( 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 || - depth < camera.topM - margin || - depth > camera.bottomM + margin + span.bottom < camera.topM - margin || + span.top > camera.bottomM + margin ) { continue; } diff --git a/src/sites/site-resources.ts b/src/sites/site-resources.ts index 4111ff5..964c819 100644 --- a/src/sites/site-resources.ts +++ b/src/sites/site-resources.ts @@ -5,6 +5,7 @@ // 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; @@ -105,7 +106,7 @@ if (startupProblems.length) { throw new Error(`invalid site gameplay data:\n${startupProblems.join("\n")}`); } -export const SITE_GAMEPLAY: Readonly> = Object.freeze(sites); +export const SITE_GAMEPLAY: Readonly> = deepFreeze(sites); export function siteGameplay(id: string): SiteGameplay | null { return SITE_GAMEPLAY[id] ?? null; diff --git a/tests/unit/site-assets.test.ts b/tests/unit/site-assets.test.ts index 5ac1600..21ebd78 100644 --- a/tests/unit/site-assets.test.ts +++ b/tests/unit/site-assets.test.ts @@ -11,6 +11,7 @@ import { import { authoredFeatureKinds, buildSceneLayers, + featureDepthRange, requiredAtlases, SITE_PRESENTATION, } from "../../src/sites/layer-factory"; @@ -137,3 +138,67 @@ describe("replacing an asset cannot change simulation or collision data", () => } }); }); + +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(); + }); +}); From 38b26b75943aa8b29f516b94003777856d77c361 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Fri, 21 Aug 2026 13:04:01 +0200 Subject: [PATCH 06/10] fix: execute the site schemas, make provenance checkable, restore silt order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses four review findings on WP-07. [P1] The committed JSON Schema had no caller, and presentation data had no schema at all. validateSiteGameplay() checks geometry relationships, not types, so a descriptor typo producing hasOverhead: "false" passed generation, parity, typecheck and CI while changing behaviour — the string is truthy, so an overhead site silently becomes one the diver can surface from. Adds a presentation schema and a suite that compiles both with Ajv and asserts they reject stringly-typed values, negative depths, unknown fields and bad provenance. This is what the "invalid site data fails at build/test time" criterion needed. [P2] sourceCommit was false provenance and could not be otherwise: generation stamps `git rev-parse HEAD`, which always names the commit before the one carrying the generated file. The shipped resources claimed 4046bda, whose sites.js produces the pre-fix MAX_DEPTH values. Replaced with sourceDigest, a sha256 over the actual inputs (sites.js plus the MAX_DEPTH it reads), which is a claim that can be true — and sites:check now verifies it instead of skipping it, so stale provenance fails CI like stale data does. [P3] The asset-replacement acceptance test built a `swapped` entry and never passed it anywhere; buildSceneLayers() resolved the unmodified manifest, so the test passed regardless of whether replacement worked. Rewritten in its own file with the manifest module mocked, so the swap actually reaches the layer factory, plus a guard that fails if the mock stops taking effect. [P4] The layer refactor put silt in `terrain`, below `structure`, where it had previously been drawn after the hull and route. Silt occupies y=34.2..35.4 and the hull's opaque fill covers down to y=35 across x=14..103, hiding 31 of its 48 particles. Moved to `foreground` between route and diver, restoring the pre-refactor draw order exactly. Each fix is verified by reintroducing the bug: the schema suite fails on an injected string, sites:check fails on a stale digest, the replacement test fails when placements stop reading the resolved asset. Co-Authored-By: Claude Opus 5 --- .../schemas/site-presentation.schema.json | 105 ++++++++++++++++++ docs/baseline/schemas/site.schema.json | 4 +- scripts/generate-site-resources.mjs | 49 +++++--- src/render/pixi-renderer.ts | 10 +- src/sites/resources/gameplay.json | 2 +- src/sites/resources/presentation.json | 2 +- src/sites/site-resources.ts | 13 ++- tests/unit/asset-replacement.test.ts | 93 ++++++++++++++++ tests/unit/site-assets.test.ts | 17 +-- tests/unit/site-schema.test.ts | 94 ++++++++++++++++ 10 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 docs/baseline/schemas/site-presentation.schema.json create mode 100644 tests/unit/asset-replacement.test.ts create mode 100644 tests/unit/site-schema.test.ts diff --git a/docs/baseline/schemas/site-presentation.schema.json b/docs/baseline/schemas/site-presentation.schema.json new file mode 100644 index 0000000..4c7012e --- /dev/null +++ b/docs/baseline/schemas/site-presentation.schema.json @@ -0,0 +1,105 @@ +{ + "$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", + "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", + "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": { + "type": "object", + "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"] }, + "props": { + "type": "array", + "items": { + "type": "object", + "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 index 4131d11..d1fee94 100644 --- a/docs/baseline/schemas/site.schema.json +++ b/docs/baseline/schemas/site.schema.json @@ -5,11 +5,11 @@ "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", "sourceCommit", "generator", "sites"], + "required": ["schemaVersion", "kind", "sourceDigest", "generator", "sites"], "properties": { "schemaVersion": { "const": 1 }, "kind": { "const": "diving-simulator-site-gameplay" }, - "sourceCommit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "sourceDigest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "generator": { "type": "string", "minLength": 1 }, "sites": { "type": "object", diff --git a/scripts/generate-site-resources.mjs b/scripts/generate-site-resources.mjs index beb73d5..b2e1d10 100644 --- a/scripts/generate-site-resources.mjs +++ b/scripts/generate-site-resources.mjs @@ -11,7 +11,7 @@ import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; -import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; const root = path.resolve(import.meta.dirname, '..'); const outputDirectory = path.join(root, 'src', 'sites', 'resources'); @@ -52,8 +52,7 @@ const PRESENTATION_FIELDS = [ // // 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() { - const source = fs.readFileSync(path.join(root, 'src', 'constants.js'), 'utf8'); +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( @@ -64,10 +63,10 @@ function readMaxDepth() { return Number(match[1]); } -function loadLegacySites(maxDepth) { +function loadLegacySites(sitesSource, maxDepth) { const sandbox = { MAX_DEPTH: maxDepth }; vm.createContext(sandbox); - vm.runInContext(fs.readFileSync(path.join(root, 'src', 'sites.js'), 'utf8'), sandbox); + vm.runInContext(sitesSource, sandbox); if (!sandbox.DIVE_SITES) { throw new Error('DIVE_SITES was not defined by src/sites.js'); } @@ -97,7 +96,22 @@ function assertPartitioned(site) { } } -const sites = loadLegacySites(readMaxDepth()); +// 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. +const sitesSource = fs.readFileSync(path.join(root, 'src', 'sites.js'), 'utf8'); +const constantsSource = fs.readFileSync(path.join(root, 'src', 'constants.js'), 'utf8'); +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 = {}; @@ -112,13 +126,16 @@ const DOCUMENTS = [ { 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. // -// Only the `sites` payload is compared. `sourceCommit` is provenance and moves -// with every commit, so including it would make the check fail on every run. +// `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) { @@ -127,10 +144,16 @@ if (process.argv.includes('--check')) { stale.push(`${file} is missing`); continue; } - const actual = JSON.parse(fs.readFileSync(target, 'utf8')).sites; - if (JSON.stringify(actual) !== JSON.stringify(expected)) { + 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( @@ -141,13 +164,7 @@ if (process.argv.includes('--check')) { } console.log(`site resources match src/sites.js (${Object.keys(gameplay).length} sites)`); } else { - const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: root, - encoding: 'utf8', - }).trim(); - fs.mkdirSync(outputDirectory, { recursive: true }); - const header = { schemaVersion: 1, sourceCommit, generator: 'npm run sites:generate' }; for (const { file, kind, sites: payload } of DOCUMENTS) { fs.writeFileSync( path.join(outputDirectory, file), diff --git a/src/render/pixi-renderer.ts b/src/render/pixi-renderer.ts index 5b8fcd4..fe3bad2 100644 --- a/src/render/pixi-renderer.ts +++ b/src/render/pixi-renderer.ts @@ -292,9 +292,15 @@ export class PixiWreckRenderer implements SceneRenderer { } this.#layers.get("backdrop")?.addChild(distantHull); - this.#layers.get("terrain")?.addChild(seabed, silt); + this.#layers.get("terrain")?.addChild(seabed); this.#layers.get("structure")?.addChild(hull, rooms, engine); - this.#layers.get("foreground")?.addChild(route, this.#diver); + // Silt is suspended particulate hanging between the camera and the wreck, + // not ground cover. It sits in the y=34.2..35.4 band, which the hull's + // opaque fill covers down to y=35 across x=14..103 — putting it in + // `terrain` hid 31 of its 48 particles behind the hull. Keep it above the + // structure and after the route, exactly where the pre-layer draw order + // had it. + this.#layers.get("foreground")?.addChild(route, silt, this.#diver); for (const id of LAYERS) { const container = this.#layers.get(id); diff --git a/src/sites/resources/gameplay.json b/src/sites/resources/gameplay.json index 6e51e78..683f020 100644 --- a/src/sites/resources/gameplay.json +++ b/src/sites/resources/gameplay.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceCommit": "4046bdac4a2b4546b714c2faf4363a5668709ab4", + "sourceDigest": "sha256:9c6a4125d63037fb6e86ebe0520b74f22bccfb56b2b61f0e3578ee91a998bf11", "generator": "npm run sites:generate", "kind": "diving-simulator-site-gameplay", "sites": { diff --git a/src/sites/resources/presentation.json b/src/sites/resources/presentation.json index c25670e..bd5d2dc 100644 --- a/src/sites/resources/presentation.json +++ b/src/sites/resources/presentation.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceCommit": "4046bdac4a2b4546b714c2faf4363a5668709ab4", + "sourceDigest": "sha256:9c6a4125d63037fb6e86ebe0520b74f22bccfb56b2b61f0e3578ee91a998bf11", "generator": "npm run sites:generate", "kind": "diving-simulator-site-presentation", "sites": { diff --git a/src/sites/site-resources.ts b/src/sites/site-resources.ts index 964c819..03ab988 100644 --- a/src/sites/site-resources.ts +++ b/src/sites/site-resources.ts @@ -112,6 +112,13 @@ export function siteGameplay(id: string): SiteGameplay | null { return SITE_GAMEPLAY[id] ?? null; } -export const SITE_GAMEPLAY_SOURCE_COMMIT = ( - gameplayDocument as { sourceCommit: string } -).sourceCommit; +/** + * 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/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 index 21ebd78..a21a422 100644 --- a/tests/unit/site-assets.test.ts +++ b/tests/unit/site-assets.test.ts @@ -99,25 +99,18 @@ describe("layer factory", () => { 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. - it("leaves every gameplay value untouched when the art changes", () => { + // 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); - // Re-point an asset at a different atlas and frame, exactly as swapping in - // new art would, and rebuild the scene from it. - const original = ASSET_MANIFEST.tableCoral; - expect(original).toBeDefined(); - const swapped = { - ...original, - atlas: "reef-v2", - frame: "tableCoralRedesigned", - }; - const rebuilt = buildSceneLayers("reef", { camera: WHOLE_MAP, qualityTier: "high", }); expect(rebuilt.some((layer) => layer.placements.length > 0)).toBe(true); - expect(swapped.atlas).not.toBe(original?.atlas); expect(JSON.stringify(SITE_GAMEPLAY)).toBe(before); for (const site of Object.values(SITE_GAMEPLAY)) { diff --git a/tests/unit/site-schema.test.ts b/tests/unit/site-schema.test.ts new file mode 100644 index 0000000..6232f76 --- /dev/null +++ b/tests/unit/site-schema.test.ts @@ -0,0 +1,94 @@ +// 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); + }); +}); From d69a88b3a90b304f9ffaccc1e914014dc2cd2bf9 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Fri, 21 Aug 2026 13:07:29 +0200 Subject: [PATCH 07/10] fix: hash normalised line endings in sourceDigest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The digest I added in 38b26b7 hashed raw file bytes, which made it depend on the checkout platform. With core.autocrlf a Windows working tree holds CRLF where Linux CI holds LF — identical content, different bytes — so the value generated on Windows (9c6a4125…) disagreed with the one CI computed (58e238f5…) and sites:check failed the build for a machine difference rather than a data one. Normalise CRLF to LF before hashing. The generated resources never depended on line endings, so their provenance must not either. The regenerated digest now equals the value CI computed, and hashing all-CRLF and all-LF copies of sites.js produces the same result. Only the digest line changes; the sites payload is untouched. Co-Authored-By: Claude Opus 5 --- scripts/generate-site-resources.mjs | 13 +++++++++++-- src/sites/resources/gameplay.json | 2 +- src/sites/resources/presentation.json | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/generate-site-resources.mjs b/scripts/generate-site-resources.mjs index b2e1d10..1392364 100644 --- a/scripts/generate-site-resources.mjs +++ b/scripts/generate-site-resources.mjs @@ -102,8 +102,17 @@ function assertPartitioned(site) { // 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. -const sitesSource = fs.readFileSync(path.join(root, 'src', 'sites.js'), 'utf8'); -const constantsSource = fs.readFileSync(path.join(root, 'src', 'constants.js'), 'utf8'); +// +// 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') diff --git a/src/sites/resources/gameplay.json b/src/sites/resources/gameplay.json index 683f020..913fe92 100644 --- a/src/sites/resources/gameplay.json +++ b/src/sites/resources/gameplay.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:9c6a4125d63037fb6e86ebe0520b74f22bccfb56b2b61f0e3578ee91a998bf11", + "sourceDigest": "sha256:58e238f55504ff06feb901fde6adc3d0234ab8a9b0bfbb3702a803b1cf35bc69", "generator": "npm run sites:generate", "kind": "diving-simulator-site-gameplay", "sites": { diff --git a/src/sites/resources/presentation.json b/src/sites/resources/presentation.json index bd5d2dc..ea0a7e7 100644 --- a/src/sites/resources/presentation.json +++ b/src/sites/resources/presentation.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "sha256:9c6a4125d63037fb6e86ebe0520b74f22bccfb56b2b61f0e3578ee91a998bf11", + "sourceDigest": "sha256:58e238f55504ff06feb901fde6adc3d0234ab8a9b0bfbb3702a803b1cf35bc69", "generator": "npm run sites:generate", "kind": "diving-simulator-site-presentation", "sites": { From e20c058456982e520e0e1c960d017e597a8a6ab0 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Fri, 21 Aug 2026 13:49:06 +0200 Subject: [PATCH 08/10] fix: pin the entry point and the full decoration-rule contract in schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two schema gaps from review. [P1] `entry` was declared as a bare `{ "type": "object" }`, was not required, and was missing from SiteGameplay entirely — so `entry.x = "not-a-number"` passed Ajv, typecheck and CI. Parity would not catch it either: the suite compares floor, ceiling, solid, overhead and bad air, never entry points. Now a closed `{ x: number }`, required on every site, and exposed on the TypeScript contract as SiteEntryPoint. [P2] The decoration rule was left open and declared 7 of the 14 fields renderer.js actually reads. This matters more than an absent field would, because every guard is shaped `rule.f != null && x < rule.f`: a string makes the comparison NaN-false, so the guard stops guarding and props render at every depth. The wreck already authors minDepth: 20, which is exactly that path. Declared minDepth, maxDepth, minScale, maxScale, maxPerScreen, rotationJitter and alpha with numeric constraints, and closed the rule, its props entries, visual zones and atmosphere profiles against unknown fields so a typo'd name fails validation instead of being read as undefined and defaulted away. Verified by re-running the probe that demonstrated both gaps: every case that previously passed Ajv now fails, and the shipped documents still validate. Co-Authored-By: Claude Opus 5 --- .../schemas/site-presentation.schema.json | 12 ++ docs/baseline/schemas/site.schema.json | 13 +- src/sites/site-resources.ts | 6 + tests/unit/site-schema.test.ts | 112 ++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/docs/baseline/schemas/site-presentation.schema.json b/docs/baseline/schemas/site-presentation.schema.json index 4c7012e..30e3e89 100644 --- a/docs/baseline/schemas/site-presentation.schema.json +++ b/docs/baseline/schemas/site-presentation.schema.json @@ -31,6 +31,7 @@ }, "visualZone": { "type": "object", + "additionalProperties": false, "required": ["id", "x1", "x2", "d1", "d2"], "properties": { "id": { "type": "string", "minLength": 1 }, @@ -45,6 +46,7 @@ }, "atmosphereProfile": { "type": "object", + "additionalProperties": false, "properties": { "visibility": { "type": "number", "minimum": 0 }, "tint": { @@ -59,7 +61,9 @@ } }, "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 }, @@ -68,10 +72,18 @@ "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 }, diff --git a/docs/baseline/schemas/site.schema.json b/docs/baseline/schemas/site.schema.json index d1fee94..f1c9254 100644 --- a/docs/baseline/schemas/site.schema.json +++ b/docs/baseline/schemas/site.schema.json @@ -18,6 +18,15 @@ } }, "$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, @@ -55,12 +64,12 @@ "site": { "type": "object", "additionalProperties": false, - "required": ["id", "maxDepth", "hasOverhead", "floor", "structures", "badAir"], + "required": ["id", "maxDepth", "hasOverhead", "entry", "floor", "structures", "badAir"], "properties": { "id": { "type": "string", "minLength": 1 }, "maxDepth": { "type": "number", "exclusiveMinimum": 0 }, "hasOverhead": { "type": "boolean" }, - "entry": { "type": "object" }, + "entry": { "$ref": "#/$defs/entryPoint" }, "boatX": { "type": "number" }, "floor": { "$ref": "#/$defs/profile" }, "ceiling": { diff --git a/src/sites/site-resources.ts b/src/sites/site-resources.ts index 03ab988..d858ae5 100644 --- a/src/sites/site-resources.ts +++ b/src/sites/site-resources.ts @@ -26,10 +26,16 @@ export interface BadAirDome { 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; diff --git a/tests/unit/site-schema.test.ts b/tests/unit/site-schema.test.ts index 6232f76..fec72ed 100644 --- a/tests/unit/site-schema.test.ts +++ b/tests/unit/site-schema.test.ts @@ -92,3 +92,115 @@ describe("the schemas reject the type errors relationship checks cannot see", () 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); + }); +}); From 509f5e6d968df684271f8703d3fcaaf1c5b0a4a9 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Fri, 21 Aug 2026 15:58:05 +0200 Subject: [PATCH 09/10] fix: make draw order assertable, and apply the intra-layer sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows a full read of pixi-renderer.ts, which I had only skimmed before. The layer refactor said draw order was "a declared property of the scene rather than an accident of call order", but it was a sequence of addChild calls that nothing could read and nothing could assert — which is how silt ended up below the hull. RETAINED_LAYER_ASSIGNMENT moves it into data, and site-layers.test.ts asserts the invariants: things between the camera and the wreck are painted after it, the wreck is painted after the seabed it rests on, and the layer list runs furthest to nearest. Moving silt back to `terrain` fails two of them. Also fixes a latent ordering defect found in the same pass. buildSceneLayers sorts placements within a layer so the same data always yields the same scene, but the renderer skipped the re-add for a pooled marker reused in the container it already occupied. Pixi's addChild splices an existing child out and pushes it to the end, so that guard was skipping the reorder: the sort stopped being reflected in the display list after the first resync. Invisible while every marker is the same provisional circle, and would surface as soon as real atlas frames overlap. Unit tests 100 -> 114. Co-Authored-By: Claude Opus 5 --- src/render/layer-assignment.ts | 61 +++++++++++++++++ src/render/pixi-renderer.ts | 49 ++++++++++---- tests/unit/site-layers.test.ts | 116 +++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 src/render/layer-assignment.ts create mode 100644 tests/unit/site-layers.test.ts diff --git a/src/render/layer-assignment.ts b/src/render/layer-assignment.ts new file mode 100644 index 0000000..58eff53 --- /dev/null +++ b/src/render/layer-assignment.ts @@ -0,0 +1,61 @@ +// 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); +} + +/** 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 fe3bad2..f3aa59e 100644 --- a/src/render/pixi-renderer.ts +++ b/src/render/pixi-renderer.ts @@ -4,6 +4,11 @@ import type { PresentationState } from "../presentation/presentation-state"; import { LAYERS, type LayerId, type QualityTier } from "../sites/asset-manifest"; import { buildSceneLayers } from "../sites/layer-factory"; import { createCameraTransform } from "./camera"; +import { + BUBBLE_LAYER, + RETAINED_LAYER_ASSIGNMENT, + type RetainedElement, +} from "./layer-assignment"; import type { SceneRenderer, WreckSceneState } from "./renderer"; const MAX_RESOLUTION = 2; @@ -186,9 +191,16 @@ export class PixiWreckRenderer implements SceneRenderer { const marker = this.#takeMarker(); marker.visible = true; marker.position.set(placement.x, placement.d); - if (marker.parent !== container) { - container.addChild(marker); - } + // 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); } } @@ -291,16 +303,25 @@ export class PixiWreckRenderer implements SceneRenderer { this.#layers.set(id, container); } - this.#layers.get("backdrop")?.addChild(distantHull); - this.#layers.get("terrain")?.addChild(seabed); - this.#layers.get("structure")?.addChild(hull, rooms, engine); - // Silt is suspended particulate hanging between the camera and the wreck, - // not ground cover. It sits in the y=34.2..35.4 band, which the hull's - // opaque fill covers down to y=35 across x=14..103 — putting it in - // `terrain` hid 31 of its 48 particles behind the hull. Keep it above the - // structure and after the route, exactly where the pre-layer draw order - // had it. - this.#layers.get("foreground")?.addChild(route, silt, this.#diver); + // 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, + rooms, + engine, + route, + silt, + 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); @@ -314,7 +335,7 @@ export class PixiWreckRenderer implements SceneRenderer { .circle(0, 0, 0.08 + (index % 4) * 0.025) .stroke({ color: 0xbdefff, width: 0.045, alpha: 0.88 }); this.#bubbles.push(bubble); - this.#layers.get("foreground")?.addChild(bubble); + this.#layers.get(BUBBLE_LAYER)?.addChild(bubble); } } diff --git a/tests/unit/site-layers.test.ts b/tests/unit/site-layers.test.ts new file mode 100644 index 0000000..ce099d5 --- /dev/null +++ b/tests/unit/site-layers.test.ts @@ -0,0 +1,116 @@ +// 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, + type RetainedElement, +} from "../../src/render/layer-assignment"; +import { ASSET_MANIFEST } from "../../src/sites/asset-manifest"; + +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); + } + }); +}); From 9e807aa0c12d2ffc8f9188f515fc2b74b2884821 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Sun, 23 Aug 2026 12:39:09 +0200 Subject: [PATCH 10/10] fix: require ceiling, size culling from the camera, validate zone references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [P1] `ceiling` was absent from the schema's required list although SiteGameplay requires it and every site declares it. Ajv accepted the cave with the field deleted, which drops ceilingAt() through to 0 — the cave roof goes from 14 m to 0 m at x=50 and the diver swims up through it. Parity cannot see this: legacy and generated data lose the field together and agree on 0. Now required, with null spelled out for open water. [P2] Culling used a fixed 20 m half-height. The camera fits a constant 58 m of WIDTH, so visible height is viewport.height / scale and scales with aspect ratio: at 390x844 the screen shows ~125 m of depth against a 30 m cull window including margin. Four on-screen placements were dropped at any camera position — the engine row at d=61 and the anchor at d=66 — while the diver was looking at them. Desktop and landscape hid it because their windows are shorter than the constant. The window is now derived from the camera, and resize invalidates the sync so an orientation change re-populates instead of waiting for 4 m of travel. [P3] The schema could say `rule.zone` is a string but not that the zone exists. A misspelled reference matched no candidate, so an entire scatter of props silently never rendered. validateSitePresentation() checks zone references, atmosphere-profile keys (also zone ids), duplicate zone and rule ids, and inverted zone extents, and throws at import time like the gameplay resource does. The first version of the P2 test passed with the bug reintroduced: it computed "on screen" from the same half-extents it culled with, so the two agreed with each other. The cull window now comes from a shared helper while the visible rectangle is computed from viewport arithmetic, and pinning the helper back to the constants fails four assertions instead of one. Unit tests 114 -> 133. Co-Authored-By: Claude Opus 5 --- docs/baseline/schemas/site.schema.json | 12 ++- src/render/layer-assignment.ts | 19 ++++ src/render/pixi-renderer.ts | 50 +++++++--- src/sites/layer-factory.ts | 86 +++++++++++++++++ tests/unit/site-layers.test.ts | 77 +++++++++++++++ tests/unit/site-presentation-refs.test.ts | 109 ++++++++++++++++++++++ tests/unit/site-schema.test.ts | 28 ++++++ 7 files changed, 367 insertions(+), 14 deletions(-) create mode 100644 tests/unit/site-presentation-refs.test.ts diff --git a/docs/baseline/schemas/site.schema.json b/docs/baseline/schemas/site.schema.json index f1c9254..d6ff591 100644 --- a/docs/baseline/schemas/site.schema.json +++ b/docs/baseline/schemas/site.schema.json @@ -64,7 +64,16 @@ "site": { "type": "object", "additionalProperties": false, - "required": ["id", "maxDepth", "hasOverhead", "entry", "floor", "structures", "badAir"], + "required": [ + "id", + "maxDepth", + "hasOverhead", + "entry", + "floor", + "ceiling", + "structures", + "badAir" + ], "properties": { "id": { "type": "string", "minLength": 1 }, "maxDepth": { "type": "number", "exclusiveMinimum": 0 }, @@ -73,6 +82,7 @@ "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": { diff --git a/src/render/layer-assignment.ts b/src/render/layer-assignment.ts index 58eff53..b1c5751 100644 --- a/src/render/layer-assignment.ts +++ b/src/render/layer-assignment.ts @@ -55,6 +55,25 @@ 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 f3aa59e..f54a2fd 100644 --- a/src/render/pixi-renderer.ts +++ b/src/render/pixi-renderer.ts @@ -3,10 +3,11 @@ import { Application, Container, Graphics } from "pixi.js"; import type { PresentationState } from "../presentation/presentation-state"; import { LAYERS, type LayerId, type QualityTier } from "../sites/asset-manifest"; import { buildSceneLayers } from "../sites/layer-factory"; -import { createCameraTransform } from "./camera"; +import { createCameraTransform, type CameraTransform } from "./camera"; import { BUBBLE_LAYER, RETAINED_LAYER_ASSIGNMENT, + visibleHalfExtentM, type RetainedElement, } from "./layer-assignment"; import type { SceneRenderer, WreckSceneState } from "./renderer"; @@ -15,11 +16,11 @@ const MAX_RESOLUTION = 2; const BUBBLE_COUNT = 14; const SITE_ID = "wreck"; -// Half-width and half-height of the visible world window, in metres. Culling -// uses this rather than the exact viewport so the set only changes when the -// camera has actually travelled, not on every sub-metre movement. -const CULL_HALF_WIDTH_M = 34; -const CULL_HALF_HEIGHT_M = 20; +// 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 { @@ -65,13 +66,19 @@ export class PixiWreckRenderer implements SceneRenderer { host.replaceChildren(app.canvas); app.stage.addChild(this.#background, this.#world); this.#buildRetainedScene(); - this.#syncSceneLayers({ x: 0, y: 0 }, true); + // 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 { @@ -85,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( @@ -103,7 +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({ x: camera.focus.x, y: camera.focus.y }, false); + this.#syncSceneLayers(camera, false); this.#diver.position.set(scene.routePositionM, scene.diverDepthM); this.#diver.scale.x = scene.facing; this.#torch.visible = scene.torchOn; @@ -155,7 +167,8 @@ export class PixiWreckRenderer implements SceneRenderer { return this.#activeMarkers.length; } - #syncSceneLayers(focus: { x: number; y: number }, force: boolean): void { + #syncSceneLayers(camera: CameraTransform, force: boolean): void { + const focus = camera.focus; if ( !force && this.#lastSyncFocus && @@ -166,13 +179,24 @@ export class PixiWreckRenderer implements SceneRenderer { } 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 - CULL_HALF_WIDTH_M, - rightM: focus.x + CULL_HALF_WIDTH_M, - topM: focus.y - CULL_HALF_HEIGHT_M, - bottomM: focus.y + CULL_HALF_HEIGHT_M, + leftM: focus.x - halfWidthM, + rightM: focus.x + halfWidthM, + topM: focus.y - halfHeightM, + bottomM: focus.y + halfHeightM, }, }); diff --git a/src/sites/layer-factory.ts b/src/sites/layer-factory.ts index 70a309a..e5970cc 100644 --- a/src/sites/layer-factory.ts +++ b/src/sites/layer-factory.ts @@ -52,10 +52,27 @@ export function featureDepthRange(feature: SiteFeature): { 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 { @@ -82,6 +99,75 @@ 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); diff --git a/tests/unit/site-layers.test.ts b/tests/unit/site-layers.test.ts index ce099d5..1f647a2 100644 --- a/tests/unit/site-layers.test.ts +++ b/tests/unit/site-layers.test.ts @@ -18,9 +18,13 @@ import { 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]; @@ -114,3 +118,76 @@ describe("data-driven placements sit where their role implies", () => { } }); }); + +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 index fec72ed..7a7c4e8 100644 --- a/tests/unit/site-schema.test.ts +++ b/tests/unit/site-schema.test.ts @@ -204,3 +204,31 @@ describe("decoration rules are closed over what the renderer actually reads", () 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); + } + }); +});