@hyprune/schema 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.md +34 -4
- package/examples/world-npcs.json +493 -0
- package/lib/validate.d.mts +11 -1
- package/lib/validate.mjs +209 -21
- package/package.json +1 -1
- package/v0.10/object.schema.json +132 -5
- package/v0.23/ipc.schema.json +19261 -0
- package/v0.24/ipc.schema.json +19679 -0
- package/v0.9/common.schema.json +478 -0
- package/v0.9/world.schema.json +2390 -0
package/lib/validate.d.mts
CHANGED
|
@@ -19,9 +19,17 @@ export interface ValidateOptions {
|
|
|
19
19
|
/**
|
|
20
20
|
* For kind `scene` from world 0.8: validate `value` as the glTF of this
|
|
21
21
|
* `world.objectAssets[].id` (extension rules, animation and collision node
|
|
22
|
-
* names, nesting
|
|
22
|
+
* names, nesting; from 0.9 also an inline character's skin and clips) instead
|
|
23
|
+
* of the world's scene.gltf.
|
|
23
24
|
*/
|
|
24
25
|
asset?: string;
|
|
26
|
+
/**
|
|
27
|
+
* For kind `world` from 0.9: the parsed object.json of `dir` and `library` object
|
|
28
|
+
* assets, keyed by `objectAssets[].id`. Their traits make the character-only
|
|
29
|
+
* fields, seat anchor references, `locked` and the character limit checkable as
|
|
30
|
+
* for inline assets; without them those checks skip the asset.
|
|
31
|
+
*/
|
|
32
|
+
assets?: Record<string, { id?: string; formatVersion?: string; traits?: Record<string, unknown> }>;
|
|
25
33
|
/** Kind object: the parsed scene glTF (or a GLB's JSON chunk) for node, material, variant, clip, budget and bounds checks. */
|
|
26
34
|
gltf?: unknown;
|
|
27
35
|
/** Kind object: the directory name, which must equal the object id. */
|
|
@@ -60,5 +68,7 @@ export const contractVersions: readonly string[];
|
|
|
60
68
|
export const objectFormats: readonly string[];
|
|
61
69
|
/** RFC-0011 directory, asset and library limits the object and library checks apply. */
|
|
62
70
|
export const objectLimits: Readonly<Record<string, number>>;
|
|
71
|
+
/** World 0.9 limits the world checks apply: character instances, characters per path, path points in total, minimum arc chord (m). */
|
|
72
|
+
export const worldLimits: Readonly<{ characters: number; charactersPerPath: number; pathPoints: number; arcChord: number }>;
|
|
63
73
|
/** glTF extensions a scene may list in extensionsRequired for a world format. */
|
|
64
74
|
export function sceneExtensions(version: string): string[];
|
package/lib/validate.mjs
CHANGED
|
@@ -87,6 +87,9 @@ function sceneExtensionRules(gltf, version, fail) {
|
|
|
87
87
|
// lightmap textures (world.lightmaps[].texture) stay PNG in every v0 format.
|
|
88
88
|
// With options.asset, `gltf` is that world 0.8 object asset's glTF instead: the
|
|
89
89
|
// same extension rules, the asset rules above, and no lightmaps (assets are never baked).
|
|
90
|
+
// World 0.9 inline assets may give the animations and proxy as traits, and a
|
|
91
|
+
// character trait needs a skin and its clips; dir and library assets are checked
|
|
92
|
+
// through their object.json (kind object).
|
|
90
93
|
function validateScene(gltf, version, world, assetId) {
|
|
91
94
|
const errors = [], fail = m => errors.push(m);
|
|
92
95
|
if (!worldFormats.includes(version)) throw new Error(`Unknown world format: ${version}`);
|
|
@@ -94,8 +97,15 @@ function validateScene(gltf, version, world, assetId) {
|
|
|
94
97
|
const textures = gltf.textures || [], images = gltf.images || [];
|
|
95
98
|
if (assetId !== undefined) {
|
|
96
99
|
const asset = atLeast(version, '0.8') ? (world?.objectAssets || []).find(a => a.id === assetId) : undefined;
|
|
100
|
+
const label = `objectAssets ${assetId}`, character = asset?.traits?.character;
|
|
97
101
|
if (!asset) fail(`objectAssets: unknown asset ${assetId} for world ${version}`);
|
|
98
|
-
else
|
|
102
|
+
else if (asset.scene === undefined) fail(`${label}: not an inline glTF asset; check its object.json with kind object`);
|
|
103
|
+
else validateObjectAsset(gltf, { id: asset.id, animations: asset.animations ?? asset.traits?.decorative?.animations, collision: asset.collision ?? asset.traits?.collidable }, fail);
|
|
104
|
+
if (asset?.scene !== undefined && character) {
|
|
105
|
+
if (!(gltf.skins || []).length) fail(`${label}: traits.character: the glTF has no skin`);
|
|
106
|
+
const clips = new Set((gltf.animations || []).map(a => a.name));
|
|
107
|
+
for (const name of Object.keys(character.clips)) if (!clips.has(name)) fail(`${label}: traits.character: clip "${name}" not found in the glTF`);
|
|
108
|
+
}
|
|
99
109
|
return { valid: errors.length === 0, errors };
|
|
100
110
|
}
|
|
101
111
|
for (const lightmap of world?.lightmaps || []) {
|
|
@@ -247,6 +257,17 @@ function validateObjectGltf(value, version, gltf, options, fail, warn) {
|
|
|
247
257
|
}
|
|
248
258
|
}
|
|
249
259
|
for (const s of slots.stacks || []) node(s.node, `slots.stacks ${s.id}`);
|
|
260
|
+
const levelNodes = new Map();
|
|
261
|
+
for (const l of slots.levels || []) {
|
|
262
|
+
const where = `slots.levels ${l.id}`;
|
|
263
|
+
for (const name of [l.node, l.surface]) {
|
|
264
|
+
if (name === undefined) continue;
|
|
265
|
+
if (name === traits.collidable?.node) { fail(`${where}: the collision proxy can't be moved`); continue; }
|
|
266
|
+
if (levelNodes.has(name)) fail(`${where}: node "${name}" is also moved by levels slot ${levelNodes.get(name)}`); else levelNodes.set(name, l.id);
|
|
267
|
+
}
|
|
268
|
+
const i = node(l.node, where), j = l.surface === undefined ? undefined : node(l.surface, where);
|
|
269
|
+
if (i !== undefined && j !== undefined && subtree(i).includes(j)) fail(`${where}: surface "${l.surface}" must not be inside the fill node's subtree`);
|
|
270
|
+
}
|
|
250
271
|
for (const t of slots.toggles || []) for (const name of t.nodes) {
|
|
251
272
|
if (name === traits.collidable?.node) fail(`slots.toggles ${t.id}: the collision proxy can't be toggled`);
|
|
252
273
|
node(name, `slots.toggles ${t.id}`);
|
|
@@ -318,7 +339,18 @@ function validateMachine(value, fail) {
|
|
|
318
339
|
return input;
|
|
319
340
|
};
|
|
320
341
|
// What a mapping can produce from an input: a set of scalars, a numeric range, or a kind.
|
|
342
|
+
const numeric = input => input?.type === 'int' || input?.type === 'number';
|
|
321
343
|
const outputs = (map, input) => {
|
|
344
|
+
if (map?.average) {
|
|
345
|
+
// The form applies to the running average (or the override's value): an int input's
|
|
346
|
+
// average is fractional, and an override widens a direct range.
|
|
347
|
+
const {average, ...form} = map, o = outputs(form, input);
|
|
348
|
+
if (Object.keys(form).length) return o;
|
|
349
|
+
const over = average.override === undefined ? undefined : inputs.get(average.override.split('.')[0]);
|
|
350
|
+
if (o.range && numeric(over)) o.range = [Math.min(o.range[0], over.min), Math.max(o.range[1], over.max)];
|
|
351
|
+
else if (over && !numeric(over)) return { kind: over.type };
|
|
352
|
+
return o.range ? { ...o, integer: false } : o;
|
|
353
|
+
}
|
|
322
354
|
if (!map || !Object.keys(map).length) {
|
|
323
355
|
if (input.type === 'enum') return { values: input.values };
|
|
324
356
|
if (input.type === 'bool') return { values: [true, false] };
|
|
@@ -345,16 +377,25 @@ function validateMachine(value, fail) {
|
|
|
345
377
|
const [kind, name] = [drive.target.slice(0, drive.target.indexOf(':')), drive.target.slice(drive.target.indexOf(':') + 1)];
|
|
346
378
|
const known = { animation: () => animated.has(name), position: () => positions.has(name), state: () => palettes.has(name), emission: () => palettes.has(name),
|
|
347
379
|
label: () => slotIds('labels').has(name), visible: () => slotIds('toggles').has(name), count: () => slotIds('stacks').has(name),
|
|
348
|
-
spawn: () => slotIds('spawns').has(name), effect: () => slotIds('effects').has(name) }[kind];
|
|
380
|
+
spawn: () => slotIds('spawns').has(name), effect: () => slotIds('effects').has(name), level: () => slotIds('levels').has(name) }[kind];
|
|
349
381
|
if (!known()) { fail(`${where}: unknown ${kind} target "${name}"`); continue; }
|
|
350
382
|
if (!input) continue;
|
|
383
|
+
if (drive.map?.average) {
|
|
384
|
+
const ref = drive.map.average.override;
|
|
385
|
+
if (!numeric(input)) { fail(`${where}: average needs an int or number input`); continue; }
|
|
386
|
+
if (ref !== undefined) {
|
|
387
|
+
const over = resolve(ref, `${where} average override`);
|
|
388
|
+
if (ref === drive.input) fail(`${where}: average override must name another input`);
|
|
389
|
+
else if (over && (over.type === 'event' || over.type === 'items')) fail(`${where}: average override ${ref} must be a value input, not ${over.type}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
351
392
|
const o = outputs(drive.map, input), event = o.kind === 'event' || (input.type === 'event' && !drive.map);
|
|
352
393
|
if (kind === 'state') {
|
|
353
394
|
if (!o.values) fail(`${where}: needs palette ids (an enum input or cases)`);
|
|
354
395
|
else for (const v of o.values) if (!palettes.get(name).has(v)) fail(`${where}: "${v}" is not a palette id of ${name}`);
|
|
355
396
|
} else if (kind === 'animation') within(o, 0, 10, where);
|
|
356
397
|
else if (kind === 'emission') within(o, 0, 16, where);
|
|
357
|
-
else if (kind === 'position') within(o, 0, 1, where);
|
|
398
|
+
else if (kind === 'position' || kind === 'level') within(o, 0, 1, where);
|
|
358
399
|
else if (kind === 'count') { within(o, 0, Infinity, where); if (o.range && !o.integer) fail(`${where}: counts need an int input or a rounded scale`); }
|
|
359
400
|
else if (kind === 'visible') { if (!o.values || o.values.some(v => typeof v !== 'boolean')) fail(`${where}: needs boolean outputs`); }
|
|
360
401
|
else if (kind === 'spawn' || kind === 'effect') { if (!event) fail(`${where}: needs an event (an event input, counter or becomes)`); }
|
|
@@ -368,26 +409,21 @@ function validateMachine(value, fail) {
|
|
|
368
409
|
for (const ref of Object.keys(recipe.fields)) resolve(ref, `${label} recipe ${recipe.name}`);
|
|
369
410
|
for (const [ref, map] of Object.entries(recipe.maps || {})) {
|
|
370
411
|
const input = resolve(ref, `${label} recipe ${recipe.name}`);
|
|
412
|
+
if (map.average && input && !numeric(input)) fail(`${label} recipe ${recipe.name}: ${ref} average needs an int or number input`);
|
|
413
|
+
if (map.average?.override !== undefined) fail(`${label} recipe ${recipe.name}: ${ref} average override is for drives only`);
|
|
371
414
|
if (input?.type === 'enum') for (const v of outputs(map, { type: 'text' }).values || [])
|
|
372
415
|
if (!input.values.includes(v)) fail(`${label} recipe ${recipe.name}: ${ref} maps to "${v}", not a value of the input`);
|
|
373
416
|
}
|
|
374
417
|
}
|
|
375
418
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
419
|
+
// The trait and slot rules that need no geometry: combinations, animation fields,
|
|
420
|
+
// a static proxy, movable scale, unique ids, anchor capacity, slot poses and palettes.
|
|
421
|
+
// Object manifests pass `inside` (poses within the grown bounds); world 0.9 inline
|
|
422
|
+
// assets have no bounds. Messages are relative to the manifest (traits..., slots...).
|
|
423
|
+
function traitCombinations(names, fail) {
|
|
379
424
|
for (const a of names) for (const c of names) if (a < c && !(traitPairs[a] || []).includes(c)) fail(`traits: ${a} and ${c} can't be combined`);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const one = surfaces.size === 1 ? [...surfaces][0] : undefined;
|
|
383
|
-
if (one === 'floor' && Math.abs(b.min[1]) > t) fail(`bounds: min y must be 0 (the base at the origin), is ${b.min[1]}`);
|
|
384
|
-
// A character's rest (bind) pose is not the pose it stands in, so its feet get a looser check.
|
|
385
|
-
if (traits.character && Math.abs(b.min[1]) > objectLimits.characterOriginTolerance) fail(`bounds: min y must be within ${objectLimits.characterOriginTolerance} m of 0 (the feet at the origin), is ${b.min[1]}`);
|
|
386
|
-
if (one === 'wall' && Math.abs(b.min[2]) > t) fail(`bounds: min z must be 0 (the back against the wall), is ${b.min[2]}`);
|
|
387
|
-
if (one === 'ceiling' && Math.abs(b.max[1]) > t) fail(`bounds: max y must be 0 (the top at the origin), is ${b.max[1]}`);
|
|
388
|
-
const m = objectLimits.slotMargin, inside = (p, where, grow = m) => {
|
|
389
|
-
if (p.some((v, j) => v < b.min[j] - grow || v > b.max[j] + grow)) fail(`${where}: lies outside the bounds grown by ${grow} m`);
|
|
390
|
-
};
|
|
425
|
+
}
|
|
426
|
+
function objectTraitRules(traits, slots, fail, inside = () => {}) {
|
|
391
427
|
const quat = (q, where) => { if (Math.abs(Math.hypot(...q) - 1) > 0.002) fail(`${where}: quaternion must be normalized`); };
|
|
392
428
|
const unique = (items, field, label) => { const ids = items.map(v => v[field]); if (new Set(ids).size !== ids.length) fail(`${label}: duplicate ${field}`); };
|
|
393
429
|
// Traits.
|
|
@@ -427,10 +463,28 @@ function validateObjectManifest(value, version, options, fail, warn) {
|
|
|
427
463
|
if (!tr) fail(`slots.spawns ${s.id}: unknown track ${s.track}`);
|
|
428
464
|
else for (const a of s.items || []) if (tr.items && !tr.items.assets.includes(a)) fail(`slots.spawns ${s.id}: item ${a} is not in track ${tr.id}'s items`);
|
|
429
465
|
}
|
|
466
|
+
for (const l of slots.levels || []) if (l.axis && Math.abs(Math.hypot(...l.axis) - 1) > 0.001) fail(`slots.levels ${l.id}: axis must be a unit vector`);
|
|
430
467
|
for (const s of slots.stacks || []) {
|
|
431
468
|
if (s.cap > s.layout.grid.reduce((x, y) => x * y, 1)) fail(`slots.stacks ${s.id}: cap exceeds the layout grid`);
|
|
432
|
-
s.layout.grid.forEach((n, j) => { if (n > 1 && !(s.layout.spacing[j]
|
|
469
|
+
s.layout.grid.forEach((n, j) => { if (n > 1 && !(Number.isFinite(s.layout.spacing[j]) && s.layout.spacing[j] !== 0)) fail(`slots.stacks ${s.id}: spacing ${'xyz'[j]} must be non-zero for ${n} cells`); });
|
|
433
470
|
}
|
|
471
|
+
}
|
|
472
|
+
function validateObjectManifest(value, version, options, fail, warn) {
|
|
473
|
+
const traits = value.traits, slots = value.slots || {}, names = Object.keys(traits), b = value.bounds;
|
|
474
|
+
if (options.directory !== undefined && options.directory !== value.id) fail(`id: "${value.id}" differs from its directory "${options.directory}"`);
|
|
475
|
+
traitCombinations(names, fail);
|
|
476
|
+
for (let j = 0; j < 3; j++) if (b.min[j] >= b.max[j]) fail(`bounds: min ${'xyz'[j]} must be below max`);
|
|
477
|
+
const t = objectLimits.boundsTolerance, surfaces = new Set(traits.movable ? traits.movable.surfaces || ['floor'] : []);
|
|
478
|
+
const one = surfaces.size === 1 ? [...surfaces][0] : undefined;
|
|
479
|
+
if (one === 'floor' && Math.abs(b.min[1]) > t) fail(`bounds: min y must be 0 (the base at the origin), is ${b.min[1]}`);
|
|
480
|
+
// A character's rest (bind) pose is not the pose it stands in, so its feet get a looser check.
|
|
481
|
+
if (traits.character && Math.abs(b.min[1]) > objectLimits.characterOriginTolerance) fail(`bounds: min y must be within ${objectLimits.characterOriginTolerance} m of 0 (the feet at the origin), is ${b.min[1]}`);
|
|
482
|
+
if (one === 'wall' && Math.abs(b.min[2]) > t) fail(`bounds: min z must be 0 (the back against the wall), is ${b.min[2]}`);
|
|
483
|
+
if (one === 'ceiling' && Math.abs(b.max[1]) > t) fail(`bounds: max y must be 0 (the top at the origin), is ${b.max[1]}`);
|
|
484
|
+
const m = objectLimits.slotMargin, inside = (p, where, grow = m) => {
|
|
485
|
+
if (p.some((v, j) => v < b.min[j] - grow || v > b.max[j] + grow)) fail(`${where}: lies outside the bounds grown by ${grow} m`);
|
|
486
|
+
};
|
|
487
|
+
objectTraitRules(traits, slots, fail, inside);
|
|
434
488
|
if (traits.machine) validateMachine(value, fail);
|
|
435
489
|
// Directory contents, thumbnail and measured values.
|
|
436
490
|
const files = options.files;
|
|
@@ -529,6 +583,135 @@ function validateManifestKind(kind, value, method, version, options) {
|
|
|
529
583
|
if (kind === 'library-releases') validateLibraryReleases(value, fail);
|
|
530
584
|
return { valid: errors.length === 0, errors, warnings, ...stats };
|
|
531
585
|
}
|
|
586
|
+
// World 0.9 (RFC-0011 P2): object asset sources, characters, paths, seats and
|
|
587
|
+
// keepClear. `supplied` maps a dir or library asset id to its object.json, so its
|
|
588
|
+
// traits (character, seat, movable) can be checked like an inline asset's.
|
|
589
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
|
|
590
|
+
const REVERSE_DOMAIN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
|
591
|
+
export const worldLimits = Object.freeze({ characters: 16, charactersPerPath: 8, pathPoints: 1024, arcChord: 0.1 });
|
|
592
|
+
function validateWorldObjects(value, supplied, fail, unique, quat) {
|
|
593
|
+
const format = value.formatVersion, assets = value.objectAssets || [], objects = value.objects || [];
|
|
594
|
+
const paths = value.paths || [], seats = value.seats || [], keepClear = value.keepClear || [];
|
|
595
|
+
unique(paths, 'id', 'paths'); unique(seats, 'id', 'seats'); unique(keepClear, 'id', 'keepClear');
|
|
596
|
+
// Traits per asset id, when known: inline traits (with the 0.8 shorthand), or a supplied object.json.
|
|
597
|
+
const known = new Map();
|
|
598
|
+
for (const a of assets) {
|
|
599
|
+
const label = `objectAssets ${a.id}`, f = m => fail(`${label}: ${m}`);
|
|
600
|
+
if (a.scene !== undefined) {
|
|
601
|
+
const traits = { ...(a.traits || {}) };
|
|
602
|
+
if (a.animations && a.traits?.decorative) f('animations is shorthand for traits.decorative; give one form');
|
|
603
|
+
if (a.collision && a.traits?.collidable) f('collision is shorthand for traits.collidable; give one form');
|
|
604
|
+
if (a.animations) traits.decorative = { animations: a.animations };
|
|
605
|
+
if (a.collision) traits.collidable = a.collision;
|
|
606
|
+
traitCombinations(Object.keys(traits), f);
|
|
607
|
+
objectTraitRules(a.traits || {}, a.slots || {}, f);
|
|
608
|
+
// The proxy from one form, the animations from the other.
|
|
609
|
+
const crossed = (a.collision && a.traits?.decorative) || (a.traits?.collidable && a.animations);
|
|
610
|
+
if (crossed && traits.decorative.animations.some(n => n.node === traits.collidable.node)) f(`collision node "${traits.collidable.node}" is animated`);
|
|
611
|
+
known.set(a.id, traits);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (a.library !== undefined) {
|
|
615
|
+
const [id, version] = a.library.split('@');
|
|
616
|
+
if (!REVERSE_DOMAIN.test(id) || !SEMVER.test(version)) f(`library must be <reverse-domain id>@<semver>, is ${a.library}`);
|
|
617
|
+
}
|
|
618
|
+
const manifest = Object.hasOwn(supplied, a.id) ? supplied[a.id] : undefined;
|
|
619
|
+
if (manifest === undefined) continue;
|
|
620
|
+
if (!manifest || typeof manifest.traits !== 'object' || manifest.traits === null) { f('the supplied object.json has no traits'); continue; }
|
|
621
|
+
if (typeof manifest.formatVersion === 'string' && /^\d+\.\d+$/.test(manifest.formatVersion) && !atLeast(format, manifest.formatVersion))
|
|
622
|
+
f(`object format ${manifest.formatVersion} is newer than the world's ${format}`);
|
|
623
|
+
if (a.library !== undefined && manifest.id !== undefined && manifest.id !== a.object) f(`the supplied object.json is ${manifest.id}, not ${a.object}`);
|
|
624
|
+
known.set(a.id, manifest.traits);
|
|
625
|
+
}
|
|
626
|
+
const objectsById = new Map(objects.map(o => [o.id, o])), pathsById = new Map(paths.map(p => [p.id, p]));
|
|
627
|
+
const seatIds = new Set(seats.map(s => s.id)), zoneIds = new Set((value.zones || []).map(z => z.id)), anchorIds = new Set((value.anchors || []).map(a => a.id));
|
|
628
|
+
// A world seat id, else <objectId>[/<anchorId>]; anchors are checkable when the seat's asset is known.
|
|
629
|
+
const seatRef = (ref, where) => {
|
|
630
|
+
const [objectId, anchor] = ref.split('/');
|
|
631
|
+
if (anchor === undefined && seatIds.has(objectId)) return;
|
|
632
|
+
const target = objectsById.get(objectId);
|
|
633
|
+
if (!target) { fail(`${where}: unknown seat ${ref} (not a seats[] id or an object)`); return; }
|
|
634
|
+
const traits = known.get(target.asset);
|
|
635
|
+
if (!traits) return;
|
|
636
|
+
if (!traits.seat) fail(`${where}: object ${objectId} is not a seat`);
|
|
637
|
+
else if (anchor !== undefined && !traits.seat.anchors.some(x => x.id === anchor)) fail(`${where}: seat ${objectId} has no anchor ${anchor}`);
|
|
638
|
+
};
|
|
639
|
+
const objectTarget = (target, where) => {
|
|
640
|
+
if (target.anchor !== undefined && !anchorIds.has(target.anchor)) fail(`${where}: unknown anchor ${target.anchor}`);
|
|
641
|
+
if (target.object !== undefined && !objectsById.has(target.object)) fail(`${where}: unknown object ${target.object}`);
|
|
642
|
+
if (target.zoneId !== undefined && !zoneIds.has(target.zoneId)) fail(`${where}: unknown zone ${target.zoneId}`);
|
|
643
|
+
};
|
|
644
|
+
const route = (id, where) => {
|
|
645
|
+
const p = pathsById.get(id);
|
|
646
|
+
if (!p) fail(`${where}: unknown path ${id}`);
|
|
647
|
+
else if ((p.use ?? 'generic') !== 'route') fail(`${where}: path ${id} is not a route (use ${p.use ?? 'generic'})`);
|
|
648
|
+
};
|
|
649
|
+
let characters = 0;
|
|
650
|
+
const perPath = new Map();
|
|
651
|
+
for (const o of objects) {
|
|
652
|
+
const label = `objects ${o.id}`, traits = known.get(o.asset), character = traits ? !!traits.character : undefined;
|
|
653
|
+
if (o.path === undefined && o.pose === undefined) fail(`${label}: pose is required unless path is set`);
|
|
654
|
+
if (o.path !== undefined && o.pose !== undefined) fail(`${label}: pose and path are exclusive (a bound character starts at its phase along the route)`);
|
|
655
|
+
if (o.reverse !== undefined && o.path === undefined) fail(`${label}: reverse needs a path`);
|
|
656
|
+
if (o.seat !== undefined && o.path !== undefined) fail(`${label}: seat is for characters without a path (routes sit at their stops)`);
|
|
657
|
+
if (character === false) for (const field of ['path', 'seat', 'accent', 'interactions'])
|
|
658
|
+
if (o[field] !== undefined) fail(`${label}: ${field} is for characters; asset ${o.asset} is not a character`);
|
|
659
|
+
if (character && o.scale !== undefined && o.scale !== 1) fail(`${label}: a character's scale must be 1`);
|
|
660
|
+
if (o.locked !== undefined && traits && !traits.movable) fail(`${label}: locked is for movable assets; asset ${o.asset} is not movable`);
|
|
661
|
+
if (character || (character === undefined && o.path !== undefined)) characters++;
|
|
662
|
+
if (o.path !== undefined) { route(o.path, label); perPath.set(o.path, (perPath.get(o.path) || 0) + 1); }
|
|
663
|
+
if (o.seat !== undefined) seatRef(o.seat, `${label} seat`);
|
|
664
|
+
unique(o.interactions || [], 'id', `${label} interactions`);
|
|
665
|
+
for (const i of o.interactions || []) {
|
|
666
|
+
const where = `${label} interaction ${i.id}`;
|
|
667
|
+
if (i.action.type === 'guide') route(i.action.path, where);
|
|
668
|
+
if (i.action.type === 'waypoint') objectTarget(i.action.target, where);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (characters > worldLimits.characters) fail(`objects: ${characters} character instances, more than ${worldLimits.characters}`);
|
|
672
|
+
for (const [id, n] of perPath) if (n > worldLimits.charactersPerPath) fail(`paths ${id}: ${n} characters, more than ${worldLimits.charactersPerPath}`);
|
|
673
|
+
let total = 0;
|
|
674
|
+
for (const p of paths) {
|
|
675
|
+
const label = `paths ${p.id}`, n = p.points.length;
|
|
676
|
+
total += n;
|
|
677
|
+
// The point's type is the arriving segment; an open path ignores the first point's.
|
|
678
|
+
p.points.forEach((point, i) => {
|
|
679
|
+
if (point.type !== 'arc' || (i === 0 && !p.closed)) return;
|
|
680
|
+
const prev = p.points[i === 0 ? n - 1 : i - 1].position;
|
|
681
|
+
if (Math.hypot(point.position[0] - prev[0], point.position[2] - prev[2]) < worldLimits.arcChord) fail(`${label} point ${i}: an arc needs a horizontal chord of at least ${worldLimits.arcChord} m`);
|
|
682
|
+
});
|
|
683
|
+
const stopped = new Set();
|
|
684
|
+
for (const stop of p.stops || []) {
|
|
685
|
+
const where = `${label} stop ${stop.point}`;
|
|
686
|
+
if (stop.point >= n) fail(`${where}: the path has ${n} points`);
|
|
687
|
+
if (stopped.has(stop.point)) fail(`${label}: more than one stop at point ${stop.point}`);
|
|
688
|
+
stopped.add(stop.point);
|
|
689
|
+
if (stop.seat !== undefined) seatRef(stop.seat, where);
|
|
690
|
+
if (stop.face) objectTarget(stop.face, where);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (total > worldLimits.pathPoints) fail(`paths: ${total} points in total, more than ${worldLimits.pathPoints}`);
|
|
694
|
+
for (const seat of seats) quat(seat.pose.rotation, `seats ${seat.id}`);
|
|
695
|
+
for (const box of keepClear) if (box.min.some((v, i) => v >= box.max[i])) fail(`keepClear ${box.id}: min must be below max on every axis`);
|
|
696
|
+
}
|
|
697
|
+
// Plain reasons for names world 0.9 reserves; the schema alone reports them as
|
|
698
|
+
// unmatched alternatives. Schema errors under each path are replaced by the reason.
|
|
699
|
+
function worldReserved(value, version) {
|
|
700
|
+
if (!atLeast(version, '0.9') || !value || typeof value !== 'object') return [];
|
|
701
|
+
const out = [], list = v => Array.isArray(v) ? v : [];
|
|
702
|
+
const traitWhy = { mount: 'reserved for props that hold a window (RFC-0011 P4)', interactive: 'reserved for use actions (RFC-0011 P4)',
|
|
703
|
+
mover: 'needs world format 0.10', machine: 'reserved for RFC-0013 live objects' };
|
|
704
|
+
list(value.objectAssets).forEach((a, i) => {
|
|
705
|
+
if (a?.traits && typeof a.traits === 'object') for (const [name, why] of Object.entries(traitWhy))
|
|
706
|
+
if (Object.hasOwn(a.traits, name)) out.push([`/objectAssets/${i}`, `objectAssets ${a.id}: traits.${name}: ${why}; rejected in world ${version}`]);
|
|
707
|
+
});
|
|
708
|
+
list(value.objects).forEach((o, i) => list(o?.interactions).forEach((x, j) => {
|
|
709
|
+
const type = x?.action?.type;
|
|
710
|
+
if (type === 'travel' || type === 'quest') out.push([`/objects/${i}/interactions/${j}/action`,
|
|
711
|
+
`objects ${o.id} interaction ${x.id}: ${type} is reserved (${type === 'quest' ? 'RFC-0012 quests' : 'player travel'}); rejected in world ${version}`]);
|
|
712
|
+
}));
|
|
713
|
+
return out;
|
|
714
|
+
}
|
|
532
715
|
// The optional method binds a response to the caller's outstanding request.
|
|
533
716
|
// An envelope alone cannot know whether a valid result belongs to that method.
|
|
534
717
|
export function validate(kind, value, method, version, options = {}) {
|
|
@@ -552,7 +735,11 @@ export function validate(kind, value, method, version, options = {}) {
|
|
|
552
735
|
validators.set(key, ajv.getSchema(schema));
|
|
553
736
|
}
|
|
554
737
|
const check = validators.get(key);
|
|
555
|
-
if (!check(value))
|
|
738
|
+
if (!check(value)) {
|
|
739
|
+
const reserved = kind === 'world' && version !== '0.1' ? worldReserved(value, version) : [];
|
|
740
|
+
const said = e => reserved.some(([path]) => e.instancePath === path || e.instancePath.startsWith(path + '/'));
|
|
741
|
+
return { valid: false, errors: [...reserved.map(([, message]) => message), ...check.errors.filter(e => !said(e)).map(e => `${e.instancePath || '/'} ${e.message}`)] };
|
|
742
|
+
}
|
|
556
743
|
const errors = [];
|
|
557
744
|
const fail = message => errors.push(message);
|
|
558
745
|
const unique = (items, field, label) => {
|
|
@@ -644,7 +831,7 @@ export function validate(kind, value, method, version, options = {}) {
|
|
|
644
831
|
const ids = new Set(assets.map(a => a.id));
|
|
645
832
|
for (const a of assets) {
|
|
646
833
|
const label = `objectAssets ${a.id}`, animations = a.animations || [];
|
|
647
|
-
if (a.library !== undefined) fail(`${label}: library references are reserved for a later world format; use a package scene`);
|
|
834
|
+
if (a.library !== undefined && !atLeast(value.formatVersion, '0.9')) fail(`${label}: library references are reserved for a later world format; use a package scene`);
|
|
648
835
|
unique(animations, 'node', `${label} animations`);
|
|
649
836
|
for (const n of animations) {
|
|
650
837
|
if (Math.abs(Math.hypot(...n.axis) - 1) > 0.001) fail(`${label}: animation "${n.node}" axis must be a unit vector`);
|
|
@@ -655,9 +842,10 @@ export function validate(kind, value, method, version, options = {}) {
|
|
|
655
842
|
}
|
|
656
843
|
for (const o of objects) {
|
|
657
844
|
if (!ids.has(o.asset)) fail(`objects ${o.id}: unknown asset ${o.asset}`);
|
|
658
|
-
quat(o.pose.rotation, `objects ${o.id}`);
|
|
845
|
+
if (o.pose) quat(o.pose.rotation, `objects ${o.id}`);
|
|
659
846
|
}
|
|
660
847
|
}
|
|
848
|
+
if (atLeast(value.formatVersion, '0.9')) validateWorldObjects(value, options.assets || {}, fail, unique, quat);
|
|
661
849
|
unique(value.attribution, 'path', 'attribution');
|
|
662
850
|
for (const a of value.attribution) license(a.license, `attribution ${a.path}`);
|
|
663
851
|
}
|
package/package.json
CHANGED
package/v0.10/object.schema.json
CHANGED
|
@@ -420,7 +420,7 @@
|
|
|
420
420
|
"target": {
|
|
421
421
|
"type": "string",
|
|
422
422
|
"maxLength": 160,
|
|
423
|
-
"pattern": "^(animation|state|emission|label|visible|count|spawn|effect|position):[^:\\s]{1,128}$"
|
|
423
|
+
"pattern": "^(animation|state|emission|label|visible|count|spawn|effect|position|level):[^:\\s]{1,128}$"
|
|
424
424
|
},
|
|
425
425
|
"map": {
|
|
426
426
|
"$ref": "#/$defs/mapping"
|
|
@@ -1005,7 +1005,7 @@
|
|
|
1005
1005
|
"cap": {
|
|
1006
1006
|
"type": "integer",
|
|
1007
1007
|
"minimum": 1,
|
|
1008
|
-
"maximum":
|
|
1008
|
+
"maximum": 100
|
|
1009
1009
|
},
|
|
1010
1010
|
"layout": {
|
|
1011
1011
|
"type": "object",
|
|
@@ -1025,10 +1025,11 @@
|
|
|
1025
1025
|
"items": {
|
|
1026
1026
|
"type": "number",
|
|
1027
1027
|
"maximum": 10,
|
|
1028
|
-
"minimum":
|
|
1028
|
+
"minimum": -10
|
|
1029
1029
|
},
|
|
1030
1030
|
"minItems": 3,
|
|
1031
|
-
"maxItems": 3
|
|
1031
|
+
"maxItems": 3,
|
|
1032
|
+
"description": "Metres between copies per axis; a negative component fills from the node towards -axis. Non-zero where that axis's grid is above 1."
|
|
1032
1033
|
},
|
|
1033
1034
|
"fill": {
|
|
1034
1035
|
"enum": [
|
|
@@ -1047,6 +1048,12 @@
|
|
|
1047
1048
|
"fill"
|
|
1048
1049
|
],
|
|
1049
1050
|
"additionalProperties": false
|
|
1051
|
+
},
|
|
1052
|
+
"rate": {
|
|
1053
|
+
"type": "number",
|
|
1054
|
+
"minimum": 0,
|
|
1055
|
+
"maximum": 1000,
|
|
1056
|
+
"description": "Copies per second the shown count moves toward the target; 0 or absent: at once."
|
|
1050
1057
|
}
|
|
1051
1058
|
},
|
|
1052
1059
|
"required": [
|
|
@@ -1089,6 +1096,78 @@
|
|
|
1089
1096
|
"minItems": 1,
|
|
1090
1097
|
"maxItems": 8
|
|
1091
1098
|
},
|
|
1099
|
+
"levels": {
|
|
1100
|
+
"type": "array",
|
|
1101
|
+
"items": {
|
|
1102
|
+
"type": "object",
|
|
1103
|
+
"properties": {
|
|
1104
|
+
"id": {
|
|
1105
|
+
"$ref": "../v0.8/common.schema.json#/$defs/slug"
|
|
1106
|
+
},
|
|
1107
|
+
"node": {
|
|
1108
|
+
"type": "string",
|
|
1109
|
+
"minLength": 1,
|
|
1110
|
+
"maxLength": 128
|
|
1111
|
+
},
|
|
1112
|
+
"mode": {
|
|
1113
|
+
"enum": [
|
|
1114
|
+
"fill",
|
|
1115
|
+
"slide"
|
|
1116
|
+
]
|
|
1117
|
+
},
|
|
1118
|
+
"axis": {
|
|
1119
|
+
"type": "array",
|
|
1120
|
+
"items": {
|
|
1121
|
+
"type": "number",
|
|
1122
|
+
"minimum": -1,
|
|
1123
|
+
"maximum": 1
|
|
1124
|
+
},
|
|
1125
|
+
"minItems": 3,
|
|
1126
|
+
"maxItems": 3
|
|
1127
|
+
},
|
|
1128
|
+
"length": {
|
|
1129
|
+
"type": "number",
|
|
1130
|
+
"exclusiveMinimum": 0,
|
|
1131
|
+
"maximum": 10
|
|
1132
|
+
},
|
|
1133
|
+
"surface": {
|
|
1134
|
+
"type": "string",
|
|
1135
|
+
"minLength": 1,
|
|
1136
|
+
"maxLength": 128
|
|
1137
|
+
},
|
|
1138
|
+
"settle": {
|
|
1139
|
+
"enum": [
|
|
1140
|
+
"ease",
|
|
1141
|
+
"spring"
|
|
1142
|
+
]
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
"required": [
|
|
1146
|
+
"id",
|
|
1147
|
+
"node",
|
|
1148
|
+
"mode",
|
|
1149
|
+
"length"
|
|
1150
|
+
],
|
|
1151
|
+
"additionalProperties": false,
|
|
1152
|
+
"not": {
|
|
1153
|
+
"properties": {
|
|
1154
|
+
"mode": {
|
|
1155
|
+
"const": "slide"
|
|
1156
|
+
},
|
|
1157
|
+
"surface": {
|
|
1158
|
+
"type": "string"
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1161
|
+
"required": [
|
|
1162
|
+
"mode",
|
|
1163
|
+
"surface"
|
|
1164
|
+
]
|
|
1165
|
+
},
|
|
1166
|
+
"description": "A subtree scaled (fill) or moved (slide) by level:<id>, 0-1. axis: asset-space unit vector, default [0, 1, 0]; surface: fill only; settle default ease."
|
|
1167
|
+
},
|
|
1168
|
+
"minItems": 1,
|
|
1169
|
+
"maxItems": 8
|
|
1170
|
+
},
|
|
1092
1171
|
"ports": {
|
|
1093
1172
|
"type": "array",
|
|
1094
1173
|
"items": {
|
|
@@ -1548,10 +1627,58 @@
|
|
|
1548
1627
|
}
|
|
1549
1628
|
},
|
|
1550
1629
|
"additionalProperties": false
|
|
1630
|
+
},
|
|
1631
|
+
"average": {
|
|
1632
|
+
"type": "object",
|
|
1633
|
+
"properties": {
|
|
1634
|
+
"windowS": {
|
|
1635
|
+
"type": "number",
|
|
1636
|
+
"minimum": 1,
|
|
1637
|
+
"maximum": 3600
|
|
1638
|
+
},
|
|
1639
|
+
"override": {
|
|
1640
|
+
"type": "string",
|
|
1641
|
+
"minLength": 1,
|
|
1642
|
+
"maxLength": 40,
|
|
1643
|
+
"pattern": "^[a-z][A-Za-z0-9]*(\\.[1-9][0-9]?)?$"
|
|
1644
|
+
}
|
|
1645
|
+
},
|
|
1646
|
+
"additionalProperties": false,
|
|
1647
|
+
"description": "Running average modifier: the time-weighted mean of the value over the last windowS seconds (default 60) of fresh data; the mapping's form applies to it. override (drives only): another non-event input whose value, while present, replaces the average."
|
|
1551
1648
|
}
|
|
1552
1649
|
},
|
|
1553
1650
|
"additionalProperties": false,
|
|
1554
|
-
"description": "RFC-0013 mapping language: direct ({}), cases, thresholds, scale, becomes, counter or format."
|
|
1651
|
+
"description": "RFC-0013 mapping language: direct ({}), cases, thresholds, scale, becomes, counter or format; average modifies the value forms (not becomes or counter).",
|
|
1652
|
+
"not": {
|
|
1653
|
+
"anyOf": [
|
|
1654
|
+
{
|
|
1655
|
+
"properties": {
|
|
1656
|
+
"average": {
|
|
1657
|
+
"type": "object"
|
|
1658
|
+
},
|
|
1659
|
+
"becomes": {}
|
|
1660
|
+
},
|
|
1661
|
+
"required": [
|
|
1662
|
+
"average",
|
|
1663
|
+
"becomes"
|
|
1664
|
+
]
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
"properties": {
|
|
1668
|
+
"average": {
|
|
1669
|
+
"type": "object"
|
|
1670
|
+
},
|
|
1671
|
+
"counter": {
|
|
1672
|
+
"const": true
|
|
1673
|
+
}
|
|
1674
|
+
},
|
|
1675
|
+
"required": [
|
|
1676
|
+
"average",
|
|
1677
|
+
"counter"
|
|
1678
|
+
]
|
|
1679
|
+
}
|
|
1680
|
+
]
|
|
1681
|
+
}
|
|
1555
1682
|
},
|
|
1556
1683
|
"input": {
|
|
1557
1684
|
"type": "object",
|