@irtio/schema 0.10.1 → 1.0.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/dist/index.d.ts CHANGED
@@ -200,6 +200,80 @@ declare function applyChannel2d(channel: PhysicsBodyChannel, value: number, targ
200
200
  wz: number;
201
201
  }): void;
202
202
 
203
+ /**
204
+ * M6 lane K (D77): the declarative ownership policies a collection may declare.
205
+ *
206
+ * A relay room with state has no room file, so the three things a rung-1 room file exists for —
207
+ * add one record per player, remove it when they go, and decide contested handovers — have to be
208
+ * declarations instead. These are those declarations, compiled the way `physics` is compiled: a
209
+ * literal in the schema, a descriptor on the collection, and a canonical fragment that is emitted
210
+ * **only when declared**, so every schema written before this keeps the hash it had.
211
+ *
212
+ * They are server behaviour, not rendering behaviour, which is why they join the hash at all: a
213
+ * client and a room that disagree about who may take a crate disagree about the world, the way
214
+ * they would about `physics`, and not the way they would about `interpolate`.
215
+ *
216
+ * Nothing here reads state. Compilation is pure and the descriptors are plain data, so the same
217
+ * declaration means the same thing in a coded room, in the relay host, and in a test.
218
+ */
219
+
220
+ /** How a contested record changes hands. `free | closer | ask | never` (D77). */
221
+ type OwnershipTransfer = 'free' | 'closer' | 'ask' | 'never';
222
+ /** What happens to a held record when its hold runs out. */
223
+ type OwnershipLeaveAction = 'release' | 'nearest' | 'random' | 'remove';
224
+ /** The fields `closer` and `nearest` read positions from. Same shape `spatial-grid` uses. */
225
+ interface OwnershipPosition {
226
+ /** A numeric field of this collection: the object's x. */
227
+ readonly x: string;
228
+ /** A numeric field of this collection: the object's y. */
229
+ readonly y: string;
230
+ /** The entity collection holding one record per player, keyed by client id. */
231
+ readonly players: string;
232
+ }
233
+ /**
234
+ * Hold, then act. When a player's presence is removed, every record they own is held for `hold`
235
+ * (still theirs, still visible, unwritable and untakeable by anyone else); when the hold expires,
236
+ * `then` fires. `hold: 0` — the default — makes `then` fire at once.
237
+ */
238
+ interface OwnershipLeave {
239
+ /** `'5m'`, `'30s'`, `'2h'`, or milliseconds as a number. Default `0`. */
240
+ readonly hold?: string | number;
241
+ /** Default `'release'`, except on a `perPlayer` collection where it is `'remove'`. */
242
+ readonly then?: OwnershipLeaveAction;
243
+ }
244
+ interface OwnershipOptions {
245
+ /** Default `'free'`'s opposite: with no `ownership` declared at all the old rule applies. */
246
+ readonly transfer?: OwnershipTransfer;
247
+ /** `closer`'s flap guard: the requester must be nearer by at least this many world units. */
248
+ readonly by?: number;
249
+ /** Required by `transfer: 'closer'` and by `onLeave.then: 'nearest'`. */
250
+ readonly position?: OwnershipPosition;
251
+ readonly onLeave?: OwnershipLeave;
252
+ }
253
+ /** The compiled form. `holdMs` is milliseconds, always; `then` is always resolved. */
254
+ interface OwnershipDesc {
255
+ readonly transfer: OwnershipTransfer;
256
+ readonly by: number;
257
+ readonly position: OwnershipPosition | undefined;
258
+ readonly holdMs: number;
259
+ readonly then: OwnershipLeaveAction;
260
+ }
261
+ /** A hold window, in milliseconds. Accepts `'5m'`-style strings and plain millisecond numbers. */
262
+ declare function parseHold(at: string, raw: string | number | undefined): number;
263
+ /**
264
+ * Compiles one collection's `ownership` declaration, refusing the combinations that cannot mean
265
+ * anything. Cross-collection checks (does `position.players` name an entity?) happen in
266
+ * `defineSchema`, once every collection is compiled.
267
+ */
268
+ declare function compileOwnership(name: string, kind: 'entity' | 'singleton', fields: readonly FieldDesc[], options: OwnershipOptions, perPlayer: boolean): OwnershipDesc;
269
+ /**
270
+ * The canonical fragment. `hold` is emitted in milliseconds so the round trip through
271
+ * `schemaFromCanonical` is exact whichever spelling the author used.
272
+ */
273
+ declare function canonicalOwnership(o: OwnershipDesc): Record<string, unknown>;
274
+ /** Reads a canonical ownership fragment back (`schemaFromCanonical`). */
275
+ declare function ownershipFromCanonical(at: string, raw: unknown): OwnershipOptions;
276
+
203
277
  /**
204
278
  * `entity`, `singleton`, `server`, `client`, `defineSchema`, and the compiled schema
205
279
  * (ordered collection descriptors, sorted RPC table, canonical form, hash).
@@ -269,6 +343,27 @@ interface EntityOptions {
269
343
  * world. Requires `physics:` on the room config; the runtime rejects the pairing otherwise.
270
344
  */
271
345
  readonly physics?: EntityPhysics;
346
+ /**
347
+ * D77: one record per joined client, id = the client id, owned by them. The runtime adds it on
348
+ * join and applies `ownership.onLeave` when the player goes, which is what replaces a room
349
+ * file's `onJoin`/`onLeave` pair. Defaults its leave rule to `{ hold: 0, then: 'remove' }` and
350
+ * accepts no other `then`, because a per-player record cannot belong to someone else.
351
+ *
352
+ * Server behaviour, so it is part of the canonical form and the hash — unlike `predicted`.
353
+ */
354
+ readonly perPlayer?: boolean;
355
+ /**
356
+ * D77: clients may `add` records to this collection (the adder becomes the owner, with the id
357
+ * it chose) and the owner may `remove` one. Off by default, which is what every collection
358
+ * written before this is: `add` and `remove` from a client are refused by name.
359
+ */
360
+ readonly clientCreate?: boolean;
361
+ /**
362
+ * D77: how a contested record changes hands, and what happens to it when its owner leaves.
363
+ * Absent means the old rule and nothing else: `requestOwnership` grants only when the record is
364
+ * unowned, and a leaving player's records keep their (now absent) owner.
365
+ */
366
+ readonly ownership?: OwnershipOptions;
272
367
  }
273
368
  interface EntityDef<F extends Fields = Fields, O extends EntityOptions = EntityOptions> {
274
369
  readonly kind: 'entity';
@@ -323,6 +418,12 @@ declare function client<P extends Fields = {}, R extends Fields = Fields>(spec:
323
418
  }): RpcDef<'client', P, R>;
324
419
  /** RPC names a builder may not declare (built-ins live in `@irtio/protocol`). */
325
420
  declare const RESERVED_RPC_NAMES: readonly string[];
421
+ /**
422
+ * The prefix irtio's built-in RPC names carry (`$ownership.ask`), refused to a builder here and to
423
+ * a caller in `@irtio/protocol`'s `isReservedRpcName`. Stated twice because the dependency runs
424
+ * schema → protocol and never the other way.
425
+ */
426
+ declare const RESERVED_RPC_PREFIX = "$";
326
427
  /** Collection names a builder may not declare (the runtime merges built-in state under them). */
327
428
  declare const RESERVED_COLLECTION_NAMES: readonly string[];
328
429
  interface FieldDesc {
@@ -351,6 +452,12 @@ interface CollectionDesc {
351
452
  readonly proxy: boolean;
352
453
  /** D22 body/intent field split, compiled. `undefined` for ordinary collections. In the hash. */
353
454
  readonly physics: PhysicsDesc | undefined;
455
+ /** D77: one record per joined client, owned by them. In the hash. */
456
+ readonly perPlayer: boolean;
457
+ /** D77: clients may add records here and their owner may remove them. In the hash. */
458
+ readonly clientCreate: boolean;
459
+ /** D77: the compiled transfer and leave rules. `undefined` when none were declared. In the hash. */
460
+ readonly ownership: OwnershipDesc | undefined;
354
461
  }
355
462
  interface RpcDesc {
356
463
  readonly name: string;
@@ -1009,5 +1116,12 @@ interface SchemaChange {
1009
1116
  }
1010
1117
  /** Compares two compiled schemas and returns every classified change, sorted by path then code. */
1011
1118
  declare function diffSchemas(oldSchema: AnySchema, newSchema: AnySchema): SchemaChange[];
1119
+ /**
1120
+ * True when every breaking change in `changes` lives under the RPC table (`rpc` / `rpc.*`
1121
+ * paths) and there is at least one. Such a change breaks connected clients (the version fence
1122
+ * reconnects them) but touches nothing a snapshot stores, so a deploy needs `--allow-breaking`
1123
+ * and no migration: a chain step without one decodes and re-encodes losslessly.
1124
+ */
1125
+ declare function breakingIsWireOnly(changes: readonly SchemaChange[]): boolean;
1012
1126
 
1013
- export { type AddOptions, type AnyDef, type AnyMessageMap, type AnyRpc, type AnySchema, type AnyType, type Body2dState, type BodyFieldsOf, type BroadcastProxy, ByteReader, ByteWriter, CANONICAL_VERSION, type ClientCallProxy, type ClientImplementations, type ClientRpcs, type ClientState, type Collection, type CollectionDesc, type CollectionDirty, type DecodedSnapshot, type DeepReadonly, type DeepWritable, type DefMap, type Delta, type DeltaCollection, type DeltaOp, type DirtySet, EntityCollection, type EntityDef, type EntityOptions, type EntityPhysics, type FieldDesc, type FieldMask, type Fields, type FromCanonicalOptions, type GridDesc, type GridOptions, type Header, ID_MAX_BYTES, type Implementations, type Infer, type InferFields, type InitFields, type InitOf, type InstanceOf, type Kind, MAX_MESSAGES, type MessageChannels, type MessageDesc, type MessageMap, type MessageNames, type MessageValue, type MessagesOf, type OwnableKeys, type Owned, PHYSICS_BODY_CHANNELS, type PhysicsBodyChannel, type PhysicsBodyMap, type PhysicsDesc, type PhysicsKeys, type PlainState, RESERVED_COLLECTION_NAMES, RESERVED_RPC_NAMES, type ReadonlyBodyFields, type ReadonlyCollection, type RecordDirty, type RoleOf, type RpcDef, type RpcDesc, type RpcDirection, type RpcMap, type RpcParams, type RpcReturns, type RpcSpec, SERVER_OWNER, SINGLETON_ID, type Schema, type SchemaChange, type SchemaDefs, type SchemaIssue, type SchemaMessages, type SchemaOptions, type SchemaRoles, type SchemaRpc, type ServerCallProxy, type ServerMessageChannels, type ServerOwnedKeys, type ServerRpcs, type SingletonDef, type SnapshotOptions, type State, type Tracked, type Type, type TypeDesc, type ValueSink, type Visibility, type VisibleKeys, type WalkEvents, angleFrom2d, applyChannel2d, applyDelta, bool, bytesEqual, canonicalPhysics, canonicalType, channelOf2d, client, cloneValue, collectionDirty, compilePhysics, computeDirty, createDirtySet, createFieldMask, createState, decodeDelta, decodeDeltaFrom, decodeFields, decodeSnapshot, defaultRecord, defaultValue, defineSchema, describeType, diffSchemas, encodeDelta, encodeFields, encodeSnapshot, entity, enumOf, estimateSize, f32, f64, filterDirty, frozenProxy, i32, isDirtyEmpty, isWhole, list, markAdd, markField, markOwner, markPath, markRemove, mergeDirty, mergeMask, normalizeRecord, normalizeValue, physicsFromCanonical, readValue, ref, schemaFromCanonical, server, sha256, sha256Hex, singleton, stableStringify, str, struct, toHex, track, u16, u32, u8, validateForDeploy, validateValue, walkDelta, walkSnapshot, writeValue };
1127
+ export { type AddOptions, type AnyDef, type AnyMessageMap, type AnyRpc, type AnySchema, type AnyType, type Body2dState, type BodyFieldsOf, type BroadcastProxy, ByteReader, ByteWriter, CANONICAL_VERSION, type ClientCallProxy, type ClientImplementations, type ClientRpcs, type ClientState, type Collection, type CollectionDesc, type CollectionDirty, type DecodedSnapshot, type DeepReadonly, type DeepWritable, type DefMap, type Delta, type DeltaCollection, type DeltaOp, type DirtySet, EntityCollection, type EntityDef, type EntityOptions, type EntityPhysics, type FieldDesc, type FieldMask, type Fields, type FromCanonicalOptions, type GridDesc, type GridOptions, type Header, ID_MAX_BYTES, type Implementations, type Infer, type InferFields, type InitFields, type InitOf, type InstanceOf, type Kind, MAX_MESSAGES, type MessageChannels, type MessageDesc, type MessageMap, type MessageNames, type MessageValue, type MessagesOf, type OwnableKeys, type Owned, type OwnershipDesc, type OwnershipLeave, type OwnershipLeaveAction, type OwnershipOptions, type OwnershipPosition, type OwnershipTransfer, PHYSICS_BODY_CHANNELS, type PhysicsBodyChannel, type PhysicsBodyMap, type PhysicsDesc, type PhysicsKeys, type PlainState, RESERVED_COLLECTION_NAMES, RESERVED_RPC_NAMES, RESERVED_RPC_PREFIX, type ReadonlyBodyFields, type ReadonlyCollection, type RecordDirty, type RoleOf, type RpcDef, type RpcDesc, type RpcDirection, type RpcMap, type RpcParams, type RpcReturns, type RpcSpec, SERVER_OWNER, SINGLETON_ID, type Schema, type SchemaChange, type SchemaDefs, type SchemaIssue, type SchemaMessages, type SchemaOptions, type SchemaRoles, type SchemaRpc, type ServerCallProxy, type ServerMessageChannels, type ServerOwnedKeys, type ServerRpcs, type SingletonDef, type SnapshotOptions, type State, type Tracked, type Type, type TypeDesc, type ValueSink, type Visibility, type VisibleKeys, type WalkEvents, angleFrom2d, applyChannel2d, applyDelta, bool, breakingIsWireOnly, bytesEqual, canonicalOwnership, canonicalPhysics, canonicalType, channelOf2d, client, cloneValue, collectionDirty, compileOwnership, compilePhysics, computeDirty, createDirtySet, createFieldMask, createState, decodeDelta, decodeDeltaFrom, decodeFields, decodeSnapshot, defaultRecord, defaultValue, defineSchema, describeType, diffSchemas, encodeDelta, encodeFields, encodeSnapshot, entity, enumOf, estimateSize, f32, f64, filterDirty, frozenProxy, i32, isDirtyEmpty, isWhole, list, markAdd, markField, markOwner, markPath, markRemove, mergeDirty, mergeMask, normalizeRecord, normalizeValue, ownershipFromCanonical, parseHold, physicsFromCanonical, readValue, ref, schemaFromCanonical, server, sha256, sha256Hex, singleton, stableStringify, str, struct, toHex, track, u16, u32, u8, validateForDeploy, validateValue, walkDelta, walkSnapshot, writeValue };
package/dist/index.js CHANGED
@@ -369,6 +369,141 @@ function applyChannel2d(channel, value, target) {
369
369
  }
370
370
  }
371
371
 
372
+ // src/policy.ts
373
+ var DURATION_RE = /^(\d+)(ms|s|m|h|d)$/;
374
+ var UNIT_MS = {
375
+ ms: 1,
376
+ s: 1e3,
377
+ m: 6e4,
378
+ h: 36e5,
379
+ d: 864e5
380
+ };
381
+ function parseHold(at, raw) {
382
+ if (raw === void 0) return 0;
383
+ if (typeof raw === "number") {
384
+ if (!Number.isFinite(raw) || raw < 0 || !Number.isInteger(raw)) {
385
+ throw new Error(`${at}: ownership.onLeave.hold must be a non-negative whole ms, got ${raw}`);
386
+ }
387
+ return raw;
388
+ }
389
+ const m = DURATION_RE.exec(raw);
390
+ if (!m) {
391
+ throw new Error(
392
+ `${at}: ownership.onLeave.hold must be a duration like '30s', '5m', '2h' or milliseconds, got ${JSON.stringify(raw)}`
393
+ );
394
+ }
395
+ return Number(m[1]) * UNIT_MS[m[2]];
396
+ }
397
+ var NUMERIC = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
398
+ function compileOwnership(name, kind, fields, options, perPlayer) {
399
+ const at = name;
400
+ if (kind !== "entity") {
401
+ throw new Error(`${at}: ownership is entity-only \u2014 a singleton has no per-record owner`);
402
+ }
403
+ const transfer = options.transfer ?? (perPlayer ? "never" : "free");
404
+ if (!["free", "closer", "ask", "never"].includes(transfer)) {
405
+ throw new Error(
406
+ `${at}: ownership.transfer must be 'free', 'closer', 'ask' or 'never', got ${JSON.stringify(
407
+ transfer
408
+ )}`
409
+ );
410
+ }
411
+ const then = options.onLeave?.then ?? (perPlayer ? "remove" : "release");
412
+ if (!["release", "nearest", "random", "remove"].includes(then)) {
413
+ throw new Error(
414
+ `${at}: ownership.onLeave.then must be 'release', 'nearest', 'random' or 'remove', got ` + JSON.stringify(then)
415
+ );
416
+ }
417
+ if (perPlayer && then !== "remove") {
418
+ throw new Error(
419
+ `${at}: a perPlayer collection only accepts onLeave.then: 'remove' \u2014 a per-player record cannot belong to someone else`
420
+ );
421
+ }
422
+ const holdMs = parseHold(at, options.onLeave?.hold);
423
+ const position = options.position;
424
+ if ((transfer === "closer" || then === "nearest") && position === void 0) {
425
+ throw new Error(
426
+ `${at}: ownership.position: { x, y, players } is required by transfer: 'closer' and by onLeave.then: 'nearest' \u2014 there is no other way to know who is nearer`
427
+ );
428
+ }
429
+ if (options.by !== void 0) {
430
+ if (transfer !== "closer") {
431
+ throw new Error(
432
+ `${at}: ownership.by is the 'closer' flap guard and means nothing without it`
433
+ );
434
+ }
435
+ if (!Number.isFinite(options.by) || options.by < 0) {
436
+ throw new Error(`${at}: ownership.by must be a non-negative finite number`);
437
+ }
438
+ }
439
+ if (position !== void 0) {
440
+ for (const axis of ["x", "y"]) {
441
+ const field = fields.find((f) => f.name === position[axis]);
442
+ if (!field || !NUMERIC.has(field.type.kind)) {
443
+ throw new Error(`${at}: ownership.position.${axis} must name a numeric field of ${at}`);
444
+ }
445
+ }
446
+ if (typeof position.players !== "string" || position.players === "") {
447
+ throw new Error(`${at}: ownership.position.players must name an entity collection`);
448
+ }
449
+ }
450
+ return {
451
+ transfer,
452
+ by: options.by ?? 0,
453
+ position: position ? { x: position.x, y: position.y, players: position.players } : void 0,
454
+ holdMs,
455
+ then
456
+ };
457
+ }
458
+ function canonicalOwnership(o) {
459
+ return {
460
+ transfer: o.transfer,
461
+ ...o.by !== 0 ? { by: o.by } : {},
462
+ ...o.position ? { position: { x: o.position.x, y: o.position.y, players: o.position.players } } : {},
463
+ // `then` is the owner-decided name for the leave action in the D77 spec, and this is plain
464
+ // data that is never awaited.
465
+ // biome-ignore lint/suspicious/noThenProperty: see above
466
+ onLeave: { hold: o.holdMs, then: o.then }
467
+ };
468
+ }
469
+ function ownershipFromCanonical(at, raw) {
470
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
471
+ throw new Error(`${at}: ownership must be an object`);
472
+ }
473
+ const o = raw;
474
+ const transfer = o.transfer;
475
+ if (typeof transfer !== "string") throw new Error(`${at}: ownership.transfer must be a string`);
476
+ const leaveRaw = o.onLeave;
477
+ if (typeof leaveRaw !== "object" || leaveRaw === null || Array.isArray(leaveRaw)) {
478
+ throw new Error(`${at}: ownership.onLeave must be an object`);
479
+ }
480
+ const leave = leaveRaw;
481
+ if (typeof leave.hold !== "number")
482
+ throw new Error(`${at}: ownership.onLeave.hold must be a ms number`);
483
+ if (typeof leave.then !== "string")
484
+ throw new Error(`${at}: ownership.onLeave.then must be a string`);
485
+ let position;
486
+ if (o.position !== void 0 && o.position !== null) {
487
+ const p = o.position;
488
+ if (typeof p.x !== "string" || typeof p.y !== "string" || typeof p.players !== "string") {
489
+ throw new Error(`${at}: ownership.position needs string x, y and players`);
490
+ }
491
+ position = { x: p.x, y: p.y, players: p.players };
492
+ }
493
+ const by = o.by;
494
+ if (by !== void 0 && typeof by !== "number")
495
+ throw new Error(`${at}: ownership.by must be a number`);
496
+ return {
497
+ transfer,
498
+ ...by !== void 0 ? { by } : {},
499
+ ...position !== void 0 ? { position } : {},
500
+ // `then` is the owner-decided name for the leave action in the D77 spec, and this is plain
501
+ // data that is never awaited.
502
+ // biome-ignore lint/suspicious/noThenProperty: see above
503
+ onLeave: { hold: leave.hold, then: leave.then }
504
+ };
505
+ }
506
+
372
507
  // src/sha256.ts
373
508
  var K = new Uint32Array([
374
509
  1116352408,
@@ -541,6 +676,7 @@ function makeRpc(direction, spec) {
541
676
  return { kind: "rpc", direction, params, returns: spec.returns };
542
677
  }
543
678
  var RESERVED_RPC_NAMES = ["requestOwnership"];
679
+ var RESERVED_RPC_PREFIX = "$";
544
680
  var RESERVED_COLLECTION_NAMES = ["clients"];
545
681
  var MAX_MESSAGES = 256;
546
682
  function defineSchema(defs, options = {}) {
@@ -565,6 +701,19 @@ function defineSchema(defs, options = {}) {
565
701
  `${name}: proxy needs physics \u2014 only body-backed collections get a body in a client's local world`
566
702
  );
567
703
  }
704
+ const perPlayer = o.perPlayer === true;
705
+ if (perPlayer && def.kind !== "entity") {
706
+ throw new Error(`${name}: perPlayer is entity-only \u2014 a singleton has no per-player record`);
707
+ }
708
+ if (o.clientCreate === true && def.kind !== "entity") {
709
+ throw new Error(`${name}: clientCreate is entity-only \u2014 a singleton is never added`);
710
+ }
711
+ if ((perPlayer || o.clientCreate === true || o.ownership) && o.serverOwned === true) {
712
+ throw new Error(
713
+ `${name}: perPlayer, clientCreate and ownership all describe client ownership, which a serverOwned collection does not have`
714
+ );
715
+ }
716
+ const ownership = o.ownership || perPlayer ? compileOwnership(name, def.kind, fields, o.ownership ?? {}, perPlayer) : void 0;
568
717
  return {
569
718
  name,
570
719
  index,
@@ -572,6 +721,9 @@ function defineSchema(defs, options = {}) {
572
721
  fields,
573
722
  fieldIndex: new Map(fields.map((f) => [f.name, f.index])),
574
723
  serverOwned: o.serverOwned === true,
724
+ perPlayer,
725
+ clientCreate: o.clientCreate === true,
726
+ ownership,
575
727
  visibility: o.visibility ?? "all",
576
728
  roles: o.roles ? [...o.roles] : void 0,
577
729
  grid: o.grid ? {
@@ -591,12 +743,37 @@ function defineSchema(defs, options = {}) {
591
743
  for (const c of collections) {
592
744
  for (const f of c.fields) checkRefs(f.type, entityNames, `${c.name}.${f.name}`);
593
745
  }
746
+ for (const c of collections) {
747
+ const players = c.ownership?.position?.players;
748
+ if (players === void 0) continue;
749
+ const target = collections.find((o) => o.name === players);
750
+ if (!target || target.kind !== "entity") {
751
+ throw new Error(
752
+ `${c.name}: ownership.position.players ${JSON.stringify(players)} does not name an entity collection in this schema`
753
+ );
754
+ }
755
+ const position = c.ownership?.position;
756
+ if (!position) continue;
757
+ for (const axis of ["x", "y"]) {
758
+ const field = target.fields.find((f) => f.name === position[axis]);
759
+ if (!field || !["u8", "u16", "u32", "i32", "f32", "f64"].includes(field.type.kind)) {
760
+ throw new Error(
761
+ `${c.name}: ownership.position.${axis} must name a numeric field of BOTH ${c.name} and ${players} \u2014 the comparison is between an object and a player`
762
+ );
763
+ }
764
+ }
765
+ }
594
766
  const rpcMap = options.rpc ?? {};
595
767
  for (const n of Object.keys(rpcMap)) {
596
768
  assertFieldName(n);
597
769
  if (RESERVED_RPC_NAMES.includes(n)) {
598
770
  throw new Error(`RPC name ${JSON.stringify(n)} is reserved by irtio (built-in RPC)`);
599
771
  }
772
+ if (n.startsWith(RESERVED_RPC_PREFIX)) {
773
+ throw new Error(
774
+ `RPC name ${JSON.stringify(n)} starts with ${JSON.stringify(RESERVED_RPC_PREFIX)}, which is reserved for irtio's built-in RPCs \u2014 pick another name`
775
+ );
776
+ }
600
777
  }
601
778
  const rpcs = Object.keys(rpcMap).sort().map((name, index) => {
602
779
  const r = rpcMap[name];
@@ -738,7 +915,17 @@ function canonicalize(s) {
738
915
  } : void 0,
739
916
  // Emitted only when declared: `stableStringify` drops `undefined`, so every schema written
740
917
  // before D22 keeps the byte-identical canonical form — and therefore the hash — it had.
741
- physics: c.physics ? canonicalPhysics(c.physics) : void 0
918
+ physics: c.physics ? canonicalPhysics(c.physics) : void 0,
919
+ // ---- M6 lane K: stateful relay ----
920
+ // D77: the same only-when-declared rule as `grid`, `physics` and `messages`, for the same
921
+ // reason — every schema written before ownership policies existed keeps the byte-identical
922
+ // canonical form, and therefore the hash, it had. They are in the hash when declared because
923
+ // they are server behaviour: a client and a room that disagree about who may take a crate
924
+ // disagree about the world.
925
+ perPlayer: c.perPlayer ? true : void 0,
926
+ clientCreate: c.clientCreate ? true : void 0,
927
+ ownership: c.ownership ? canonicalOwnership(c.ownership) : void 0
928
+ // ---- end M6 lane K ----
742
929
  // Deliberately absent, and this is the list: `interpolate` (D20), `predicted` (D21) and
743
930
  // `proxy` (D71) are all decisions about what one client does with a body it was sent, not
744
931
  // about the shape of the world. A room and a client that disagree about any of them still
@@ -997,12 +1184,18 @@ function entityOptions(e, name) {
997
1184
  });
998
1185
  const physics = e.physics === void 0 || e.physics === null ? void 0 : physicsFromCanonical(`schemaFromCanonical: ${name}`, e.physics);
999
1186
  const grid = e.grid === void 0 || e.grid === null ? void 0 : gridFromCanonical(name, objectAt(e, "grid"));
1187
+ const ownership = e.ownership === void 0 || e.ownership === null ? void 0 : ownershipFromCanonical(`schemaFromCanonical: ${name}`, e.ownership);
1000
1188
  return {
1001
1189
  serverOwned: e.serverOwned === true,
1002
1190
  visibility,
1003
1191
  ...roles !== void 0 ? { roles } : {},
1004
1192
  ...grid !== void 0 ? { grid } : {},
1005
- ...physics !== void 0 ? { physics } : {}
1193
+ ...physics !== void 0 ? { physics } : {},
1194
+ // ---- M6 lane K: stateful relay ----
1195
+ ...e.perPlayer === true ? { perPlayer: true } : {},
1196
+ ...e.clientCreate === true ? { clientCreate: true } : {},
1197
+ ...ownership !== void 0 ? { ownership } : {}
1198
+ // ---- end M6 lane K ----
1006
1199
  };
1007
1200
  }
1008
1201
  function gridFromCanonical(name, grid) {
@@ -2597,6 +2790,10 @@ function diffSchemas(oldSchema, newSchema) {
2597
2790
  );
2598
2791
  return changes;
2599
2792
  }
2793
+ function breakingIsWireOnly(changes) {
2794
+ const breaking = changes.filter((c) => c.kind === "breaking");
2795
+ return breaking.length > 0 && breaking.every((c) => c.path === "rpc" || c.path.startsWith("rpc."));
2796
+ }
2600
2797
  function diffCollections(oldCollections, newCollections, changes) {
2601
2798
  const oldByName = new Map(oldCollections.map((c) => [c.name, c]));
2602
2799
  const newByName = new Map(newCollections.map((c) => [c.name, c]));
@@ -3009,19 +3206,23 @@ export {
3009
3206
  PHYSICS_BODY_CHANNELS,
3010
3207
  RESERVED_COLLECTION_NAMES,
3011
3208
  RESERVED_RPC_NAMES,
3209
+ RESERVED_RPC_PREFIX,
3012
3210
  SERVER_OWNER,
3013
3211
  SINGLETON_ID,
3014
3212
  angleFrom2d,
3015
3213
  applyChannel2d,
3016
3214
  applyDelta,
3017
3215
  bool,
3216
+ breakingIsWireOnly,
3018
3217
  bytesEqual,
3218
+ canonicalOwnership,
3019
3219
  canonicalPhysics,
3020
3220
  canonicalType,
3021
3221
  channelOf2d,
3022
3222
  client,
3023
3223
  cloneValue,
3024
3224
  collectionDirty,
3225
+ compileOwnership,
3025
3226
  compilePhysics,
3026
3227
  computeDirty,
3027
3228
  createDirtySet,
@@ -3059,6 +3260,8 @@ export {
3059
3260
  mergeMask,
3060
3261
  normalizeRecord,
3061
3262
  normalizeValue,
3263
+ ownershipFromCanonical,
3264
+ parseHold,
3062
3265
  physicsFromCanonical,
3063
3266
  readValue,
3064
3267
  ref,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/schema",
3
- "version": "0.10.1",
3
+ "version": "1.0.0",
4
4
  "description": "irtio schema DSL, type inference, canonical hash, codec, change tracking, and schema diff",
5
5
  "license": "MIT",
6
6
  "publishConfig": {