@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/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 validateObjectAsset(gltf, asset, fail);
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
- function validateObjectManifest(value, version, options, fail, warn) {
377
- const traits = value.traits, slots = value.slots || {}, names = Object.keys(traits), b = value.bounds;
378
- if (options.directory !== undefined && options.directory !== value.id) fail(`id: "${value.id}" differs from its directory "${options.directory}"`);
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
- for (let j = 0; j < 3; j++) if (b.min[j] >= b.max[j]) fail(`bounds: min ${'xyz'[j]} must be below max`);
381
- const t = objectLimits.boundsTolerance, surfaces = new Set(traits.movable ? traits.movable.surfaces || ['floor'] : []);
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)) return { valid: false, errors: check.errors.map(e => `${e.instancePath || '/'} ${e.message}`) };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyprune/schema",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Versioned JSON Schemas for Hyprune world packages and the IPC protocol, with a validator and CLI for world authors",
5
5
  "license": "MIT",
6
6
  "type": "module",