@hyprune/schema 0.7.0 → 0.8.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 +92 -0
- package/README.md +25 -4
- package/examples/world-npcs.json +493 -0
- package/lib/validate.d.mts +11 -1
- package/lib/validate.mjs +172 -18
- package/package.json +1 -1
- package/v0.23/ipc.schema.json +19261 -0
- package/v0.9/common.schema.json +478 -0
- package/v0.9/world.schema.json +2390 -0
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 || []) {
|
|
@@ -373,21 +383,14 @@ function validateMachine(value, fail) {
|
|
|
373
383
|
}
|
|
374
384
|
}
|
|
375
385
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
386
|
+
// The trait and slot rules that need no geometry: combinations, animation fields,
|
|
387
|
+
// a static proxy, movable scale, unique ids, anchor capacity, slot poses and palettes.
|
|
388
|
+
// Object manifests pass `inside` (poses within the grown bounds); world 0.9 inline
|
|
389
|
+
// assets have no bounds. Messages are relative to the manifest (traits..., slots...).
|
|
390
|
+
function traitCombinations(names, fail) {
|
|
379
391
|
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
|
-
};
|
|
392
|
+
}
|
|
393
|
+
function objectTraitRules(traits, slots, fail, inside = () => {}) {
|
|
391
394
|
const quat = (q, where) => { if (Math.abs(Math.hypot(...q) - 1) > 0.002) fail(`${where}: quaternion must be normalized`); };
|
|
392
395
|
const unique = (items, field, label) => { const ids = items.map(v => v[field]); if (new Set(ids).size !== ids.length) fail(`${label}: duplicate ${field}`); };
|
|
393
396
|
// Traits.
|
|
@@ -431,6 +434,23 @@ function validateObjectManifest(value, version, options, fail, warn) {
|
|
|
431
434
|
if (s.cap > s.layout.grid.reduce((x, y) => x * y, 1)) fail(`slots.stacks ${s.id}: cap exceeds the layout grid`);
|
|
432
435
|
s.layout.grid.forEach((n, j) => { if (n > 1 && !(s.layout.spacing[j] > 0)) fail(`slots.stacks ${s.id}: spacing ${'xyz'[j]} must be positive for ${n} cells`); });
|
|
433
436
|
}
|
|
437
|
+
}
|
|
438
|
+
function validateObjectManifest(value, version, options, fail, warn) {
|
|
439
|
+
const traits = value.traits, slots = value.slots || {}, names = Object.keys(traits), b = value.bounds;
|
|
440
|
+
if (options.directory !== undefined && options.directory !== value.id) fail(`id: "${value.id}" differs from its directory "${options.directory}"`);
|
|
441
|
+
traitCombinations(names, fail);
|
|
442
|
+
for (let j = 0; j < 3; j++) if (b.min[j] >= b.max[j]) fail(`bounds: min ${'xyz'[j]} must be below max`);
|
|
443
|
+
const t = objectLimits.boundsTolerance, surfaces = new Set(traits.movable ? traits.movable.surfaces || ['floor'] : []);
|
|
444
|
+
const one = surfaces.size === 1 ? [...surfaces][0] : undefined;
|
|
445
|
+
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]}`);
|
|
446
|
+
// A character's rest (bind) pose is not the pose it stands in, so its feet get a looser check.
|
|
447
|
+
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]}`);
|
|
448
|
+
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]}`);
|
|
449
|
+
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]}`);
|
|
450
|
+
const m = objectLimits.slotMargin, inside = (p, where, grow = m) => {
|
|
451
|
+
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`);
|
|
452
|
+
};
|
|
453
|
+
objectTraitRules(traits, slots, fail, inside);
|
|
434
454
|
if (traits.machine) validateMachine(value, fail);
|
|
435
455
|
// Directory contents, thumbnail and measured values.
|
|
436
456
|
const files = options.files;
|
|
@@ -529,6 +549,135 @@ function validateManifestKind(kind, value, method, version, options) {
|
|
|
529
549
|
if (kind === 'library-releases') validateLibraryReleases(value, fail);
|
|
530
550
|
return { valid: errors.length === 0, errors, warnings, ...stats };
|
|
531
551
|
}
|
|
552
|
+
// World 0.9 (RFC-0011 P2): object asset sources, characters, paths, seats and
|
|
553
|
+
// keepClear. `supplied` maps a dir or library asset id to its object.json, so its
|
|
554
|
+
// traits (character, seat, movable) can be checked like an inline asset's.
|
|
555
|
+
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-]+)*)?$/;
|
|
556
|
+
const REVERSE_DOMAIN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
|
557
|
+
export const worldLimits = Object.freeze({ characters: 16, charactersPerPath: 8, pathPoints: 1024, arcChord: 0.1 });
|
|
558
|
+
function validateWorldObjects(value, supplied, fail, unique, quat) {
|
|
559
|
+
const format = value.formatVersion, assets = value.objectAssets || [], objects = value.objects || [];
|
|
560
|
+
const paths = value.paths || [], seats = value.seats || [], keepClear = value.keepClear || [];
|
|
561
|
+
unique(paths, 'id', 'paths'); unique(seats, 'id', 'seats'); unique(keepClear, 'id', 'keepClear');
|
|
562
|
+
// Traits per asset id, when known: inline traits (with the 0.8 shorthand), or a supplied object.json.
|
|
563
|
+
const known = new Map();
|
|
564
|
+
for (const a of assets) {
|
|
565
|
+
const label = `objectAssets ${a.id}`, f = m => fail(`${label}: ${m}`);
|
|
566
|
+
if (a.scene !== undefined) {
|
|
567
|
+
const traits = { ...(a.traits || {}) };
|
|
568
|
+
if (a.animations && a.traits?.decorative) f('animations is shorthand for traits.decorative; give one form');
|
|
569
|
+
if (a.collision && a.traits?.collidable) f('collision is shorthand for traits.collidable; give one form');
|
|
570
|
+
if (a.animations) traits.decorative = { animations: a.animations };
|
|
571
|
+
if (a.collision) traits.collidable = a.collision;
|
|
572
|
+
traitCombinations(Object.keys(traits), f);
|
|
573
|
+
objectTraitRules(a.traits || {}, a.slots || {}, f);
|
|
574
|
+
// The proxy from one form, the animations from the other.
|
|
575
|
+
const crossed = (a.collision && a.traits?.decorative) || (a.traits?.collidable && a.animations);
|
|
576
|
+
if (crossed && traits.decorative.animations.some(n => n.node === traits.collidable.node)) f(`collision node "${traits.collidable.node}" is animated`);
|
|
577
|
+
known.set(a.id, traits);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (a.library !== undefined) {
|
|
581
|
+
const [id, version] = a.library.split('@');
|
|
582
|
+
if (!REVERSE_DOMAIN.test(id) || !SEMVER.test(version)) f(`library must be <reverse-domain id>@<semver>, is ${a.library}`);
|
|
583
|
+
}
|
|
584
|
+
const manifest = Object.hasOwn(supplied, a.id) ? supplied[a.id] : undefined;
|
|
585
|
+
if (manifest === undefined) continue;
|
|
586
|
+
if (!manifest || typeof manifest.traits !== 'object' || manifest.traits === null) { f('the supplied object.json has no traits'); continue; }
|
|
587
|
+
if (typeof manifest.formatVersion === 'string' && /^\d+\.\d+$/.test(manifest.formatVersion) && !atLeast(format, manifest.formatVersion))
|
|
588
|
+
f(`object format ${manifest.formatVersion} is newer than the world's ${format}`);
|
|
589
|
+
if (a.library !== undefined && manifest.id !== undefined && manifest.id !== a.object) f(`the supplied object.json is ${manifest.id}, not ${a.object}`);
|
|
590
|
+
known.set(a.id, manifest.traits);
|
|
591
|
+
}
|
|
592
|
+
const objectsById = new Map(objects.map(o => [o.id, o])), pathsById = new Map(paths.map(p => [p.id, p]));
|
|
593
|
+
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));
|
|
594
|
+
// A world seat id, else <objectId>[/<anchorId>]; anchors are checkable when the seat's asset is known.
|
|
595
|
+
const seatRef = (ref, where) => {
|
|
596
|
+
const [objectId, anchor] = ref.split('/');
|
|
597
|
+
if (anchor === undefined && seatIds.has(objectId)) return;
|
|
598
|
+
const target = objectsById.get(objectId);
|
|
599
|
+
if (!target) { fail(`${where}: unknown seat ${ref} (not a seats[] id or an object)`); return; }
|
|
600
|
+
const traits = known.get(target.asset);
|
|
601
|
+
if (!traits) return;
|
|
602
|
+
if (!traits.seat) fail(`${where}: object ${objectId} is not a seat`);
|
|
603
|
+
else if (anchor !== undefined && !traits.seat.anchors.some(x => x.id === anchor)) fail(`${where}: seat ${objectId} has no anchor ${anchor}`);
|
|
604
|
+
};
|
|
605
|
+
const objectTarget = (target, where) => {
|
|
606
|
+
if (target.anchor !== undefined && !anchorIds.has(target.anchor)) fail(`${where}: unknown anchor ${target.anchor}`);
|
|
607
|
+
if (target.object !== undefined && !objectsById.has(target.object)) fail(`${where}: unknown object ${target.object}`);
|
|
608
|
+
if (target.zoneId !== undefined && !zoneIds.has(target.zoneId)) fail(`${where}: unknown zone ${target.zoneId}`);
|
|
609
|
+
};
|
|
610
|
+
const route = (id, where) => {
|
|
611
|
+
const p = pathsById.get(id);
|
|
612
|
+
if (!p) fail(`${where}: unknown path ${id}`);
|
|
613
|
+
else if ((p.use ?? 'generic') !== 'route') fail(`${where}: path ${id} is not a route (use ${p.use ?? 'generic'})`);
|
|
614
|
+
};
|
|
615
|
+
let characters = 0;
|
|
616
|
+
const perPath = new Map();
|
|
617
|
+
for (const o of objects) {
|
|
618
|
+
const label = `objects ${o.id}`, traits = known.get(o.asset), character = traits ? !!traits.character : undefined;
|
|
619
|
+
if (o.path === undefined && o.pose === undefined) fail(`${label}: pose is required unless path is set`);
|
|
620
|
+
if (o.path !== undefined && o.pose !== undefined) fail(`${label}: pose and path are exclusive (a bound character starts at its phase along the route)`);
|
|
621
|
+
if (o.reverse !== undefined && o.path === undefined) fail(`${label}: reverse needs a path`);
|
|
622
|
+
if (o.seat !== undefined && o.path !== undefined) fail(`${label}: seat is for characters without a path (routes sit at their stops)`);
|
|
623
|
+
if (character === false) for (const field of ['path', 'seat', 'accent', 'interactions'])
|
|
624
|
+
if (o[field] !== undefined) fail(`${label}: ${field} is for characters; asset ${o.asset} is not a character`);
|
|
625
|
+
if (character && o.scale !== undefined && o.scale !== 1) fail(`${label}: a character's scale must be 1`);
|
|
626
|
+
if (o.locked !== undefined && traits && !traits.movable) fail(`${label}: locked is for movable assets; asset ${o.asset} is not movable`);
|
|
627
|
+
if (character || (character === undefined && o.path !== undefined)) characters++;
|
|
628
|
+
if (o.path !== undefined) { route(o.path, label); perPath.set(o.path, (perPath.get(o.path) || 0) + 1); }
|
|
629
|
+
if (o.seat !== undefined) seatRef(o.seat, `${label} seat`);
|
|
630
|
+
unique(o.interactions || [], 'id', `${label} interactions`);
|
|
631
|
+
for (const i of o.interactions || []) {
|
|
632
|
+
const where = `${label} interaction ${i.id}`;
|
|
633
|
+
if (i.action.type === 'guide') route(i.action.path, where);
|
|
634
|
+
if (i.action.type === 'waypoint') objectTarget(i.action.target, where);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (characters > worldLimits.characters) fail(`objects: ${characters} character instances, more than ${worldLimits.characters}`);
|
|
638
|
+
for (const [id, n] of perPath) if (n > worldLimits.charactersPerPath) fail(`paths ${id}: ${n} characters, more than ${worldLimits.charactersPerPath}`);
|
|
639
|
+
let total = 0;
|
|
640
|
+
for (const p of paths) {
|
|
641
|
+
const label = `paths ${p.id}`, n = p.points.length;
|
|
642
|
+
total += n;
|
|
643
|
+
// The point's type is the arriving segment; an open path ignores the first point's.
|
|
644
|
+
p.points.forEach((point, i) => {
|
|
645
|
+
if (point.type !== 'arc' || (i === 0 && !p.closed)) return;
|
|
646
|
+
const prev = p.points[i === 0 ? n - 1 : i - 1].position;
|
|
647
|
+
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`);
|
|
648
|
+
});
|
|
649
|
+
const stopped = new Set();
|
|
650
|
+
for (const stop of p.stops || []) {
|
|
651
|
+
const where = `${label} stop ${stop.point}`;
|
|
652
|
+
if (stop.point >= n) fail(`${where}: the path has ${n} points`);
|
|
653
|
+
if (stopped.has(stop.point)) fail(`${label}: more than one stop at point ${stop.point}`);
|
|
654
|
+
stopped.add(stop.point);
|
|
655
|
+
if (stop.seat !== undefined) seatRef(stop.seat, where);
|
|
656
|
+
if (stop.face) objectTarget(stop.face, where);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (total > worldLimits.pathPoints) fail(`paths: ${total} points in total, more than ${worldLimits.pathPoints}`);
|
|
660
|
+
for (const seat of seats) quat(seat.pose.rotation, `seats ${seat.id}`);
|
|
661
|
+
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`);
|
|
662
|
+
}
|
|
663
|
+
// Plain reasons for names world 0.9 reserves; the schema alone reports them as
|
|
664
|
+
// unmatched alternatives. Schema errors under each path are replaced by the reason.
|
|
665
|
+
function worldReserved(value, version) {
|
|
666
|
+
if (!atLeast(version, '0.9') || !value || typeof value !== 'object') return [];
|
|
667
|
+
const out = [], list = v => Array.isArray(v) ? v : [];
|
|
668
|
+
const traitWhy = { mount: 'reserved for props that hold a window (RFC-0011 P4)', interactive: 'reserved for use actions (RFC-0011 P4)',
|
|
669
|
+
mover: 'needs world format 0.10', machine: 'reserved for RFC-0013 live objects' };
|
|
670
|
+
list(value.objectAssets).forEach((a, i) => {
|
|
671
|
+
if (a?.traits && typeof a.traits === 'object') for (const [name, why] of Object.entries(traitWhy))
|
|
672
|
+
if (Object.hasOwn(a.traits, name)) out.push([`/objectAssets/${i}`, `objectAssets ${a.id}: traits.${name}: ${why}; rejected in world ${version}`]);
|
|
673
|
+
});
|
|
674
|
+
list(value.objects).forEach((o, i) => list(o?.interactions).forEach((x, j) => {
|
|
675
|
+
const type = x?.action?.type;
|
|
676
|
+
if (type === 'travel' || type === 'quest') out.push([`/objects/${i}/interactions/${j}/action`,
|
|
677
|
+
`objects ${o.id} interaction ${x.id}: ${type} is reserved (${type === 'quest' ? 'RFC-0012 quests' : 'player travel'}); rejected in world ${version}`]);
|
|
678
|
+
}));
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
532
681
|
// The optional method binds a response to the caller's outstanding request.
|
|
533
682
|
// An envelope alone cannot know whether a valid result belongs to that method.
|
|
534
683
|
export function validate(kind, value, method, version, options = {}) {
|
|
@@ -552,7 +701,11 @@ export function validate(kind, value, method, version, options = {}) {
|
|
|
552
701
|
validators.set(key, ajv.getSchema(schema));
|
|
553
702
|
}
|
|
554
703
|
const check = validators.get(key);
|
|
555
|
-
if (!check(value))
|
|
704
|
+
if (!check(value)) {
|
|
705
|
+
const reserved = kind === 'world' && version !== '0.1' ? worldReserved(value, version) : [];
|
|
706
|
+
const said = e => reserved.some(([path]) => e.instancePath === path || e.instancePath.startsWith(path + '/'));
|
|
707
|
+
return { valid: false, errors: [...reserved.map(([, message]) => message), ...check.errors.filter(e => !said(e)).map(e => `${e.instancePath || '/'} ${e.message}`)] };
|
|
708
|
+
}
|
|
556
709
|
const errors = [];
|
|
557
710
|
const fail = message => errors.push(message);
|
|
558
711
|
const unique = (items, field, label) => {
|
|
@@ -644,7 +797,7 @@ export function validate(kind, value, method, version, options = {}) {
|
|
|
644
797
|
const ids = new Set(assets.map(a => a.id));
|
|
645
798
|
for (const a of assets) {
|
|
646
799
|
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`);
|
|
800
|
+
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
801
|
unique(animations, 'node', `${label} animations`);
|
|
649
802
|
for (const n of animations) {
|
|
650
803
|
if (Math.abs(Math.hypot(...n.axis) - 1) > 0.001) fail(`${label}: animation "${n.node}" axis must be a unit vector`);
|
|
@@ -655,9 +808,10 @@ export function validate(kind, value, method, version, options = {}) {
|
|
|
655
808
|
}
|
|
656
809
|
for (const o of objects) {
|
|
657
810
|
if (!ids.has(o.asset)) fail(`objects ${o.id}: unknown asset ${o.asset}`);
|
|
658
|
-
quat(o.pose.rotation, `objects ${o.id}`);
|
|
811
|
+
if (o.pose) quat(o.pose.rotation, `objects ${o.id}`);
|
|
659
812
|
}
|
|
660
813
|
}
|
|
814
|
+
if (atLeast(value.formatVersion, '0.9')) validateWorldObjects(value, options.assets || {}, fail, unique, quat);
|
|
661
815
|
unique(value.attribution, 'path', 'attribution');
|
|
662
816
|
for (const a of value.attribution) license(a.license, `attribution ${a.path}`);
|
|
663
817
|
}
|
package/package.json
CHANGED