@irtio/schema 0.1.0 → 0.2.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
@@ -111,19 +111,107 @@ declare function cloneValue<T>(v: T): T;
111
111
  */
112
112
  declare function normalizeValue(d: TypeDesc, v: unknown, path?: string): unknown;
113
113
 
114
+ /**
115
+ * The `physics` entity option (D22): which of an entity's fields the simulation owns, and which
116
+ * are intents the owner writes for the step to consume.
117
+ *
118
+ * The split is the whole idea. **Body fields** are written by the physics step and are read-only
119
+ * to everyone else — room code (a compile error), and clients (a `WRITE` touching one is
120
+ * corrected back). **Intent fields** are ordinary owned fields: the client writes them, the
121
+ * validator judges them, and room code turns them into forces during `tick()`. Nothing else on a
122
+ * physics entity is client-writable, so a client's write history for one of these entities is
123
+ * exactly its intent frames — which is what week 10's client-side re-stepping replays.
124
+ *
125
+ * Unlike week 8's `interpolate` (a client-side rendering choice, deliberately outside the schema
126
+ * hash), physics options are **server shape**: a room and a client that disagree about which
127
+ * fields the simulation owns disagree about the world. They go in the canonical form and
128
+ * therefore in the hash.
129
+ *
130
+ * The channel names are 3D because `@dimforge/rapier3d-compat` is the blessed engine (D22). A 2D
131
+ * game maps only the channels it uses (`x`, `y`, and `qz`/`qw` for a locked-axis rotation) and
132
+ * locks the remaining axis on the body — the schema surface never picks a dimensionality.
133
+ */
134
+
135
+ /** Body state channels the runtime syncs, in canonical order. */
136
+ declare const PHYSICS_BODY_CHANNELS: readonly ["x", "y", "z", "qx", "qy", "qz", "qw", "vx", "vy", "vz", "wx", "wy", "wz"];
137
+ type PhysicsBodyChannel = (typeof PHYSICS_BODY_CHANNELS)[number];
138
+ /** Channel → the entity field that mirrors it. Every channel is optional; at least one required. */
139
+ type PhysicsBodyMap = {
140
+ readonly [C in PhysicsBodyChannel]?: string;
141
+ };
142
+ interface EntityPhysics {
143
+ /** Fields the simulation owns: translation (`x`/`y`/`z`), rotation quaternion (`q*`), velocities (`v*`/`w*`). */
144
+ readonly body: PhysicsBodyMap;
145
+ /** Owner-written input fields the step consumes. Declaration order is the canonical order. */
146
+ readonly intents?: readonly string[];
147
+ }
148
+ /** Compiled form on `CollectionDesc.physics`. */
149
+ interface PhysicsDesc {
150
+ readonly body: PhysicsBodyMap;
151
+ /** `[channel, field]` in `PHYSICS_BODY_CHANNELS` order — the sync loop's iteration order. */
152
+ readonly channels: readonly (readonly [PhysicsBodyChannel, string])[];
153
+ readonly intents: readonly string[];
154
+ /** Every field named by `body`, for the read-only checks. */
155
+ readonly bodyFields: ReadonlySet<string>;
156
+ }
157
+ declare function compilePhysics(at: string, kind: 'entity' | 'singleton', fields: readonly FieldDesc[], physics: EntityPhysics): PhysicsDesc;
158
+ /** The canonical fragment for one collection's physics (omitted entirely when there is none). */
159
+ declare function canonicalPhysics(p: PhysicsDesc): Record<string, unknown>;
160
+ /** Reads a canonical physics fragment back (`schemaFromCanonical`). */
161
+ declare function physicsFromCanonical(at: string, raw: unknown): EntityPhysics;
162
+
114
163
  /**
115
164
  * `entity`, `singleton`, `server`, `client`, `defineSchema`, and the compiled schema
116
165
  * (ordered collection descriptors, sorted RPC table, canonical form, hash).
117
166
  */
118
167
 
119
168
  type Visibility = 'all' | 'role' | 'spatial-grid';
169
+ interface GridOptions<K extends string = string> {
170
+ /** Numeric fields containing the entity's world position. */
171
+ readonly x: K;
172
+ readonly y: K;
173
+ /** Positive, finite world units per cell. */
174
+ readonly cell: number;
175
+ /** Non-negative integer number of cells in the square AOI radius. */
176
+ readonly radius: number;
177
+ /** Declared roles which see the complete collection without an anchor. */
178
+ readonly wideRoles?: readonly string[];
179
+ }
180
+ interface GridDesc extends GridOptions<string> {
181
+ }
120
182
  interface EntityOptions {
121
183
  /** `true` → never client-owned; `DeepReadonly` on the client at compile time. */
122
184
  readonly serverOwned?: boolean;
123
- /** `'all'` (default) | `'role'` (per-role views, needs `roles`) | `'spatial-grid'` (accepted by the types, rejected at deploy — not yet supported). */
185
+ /** `'all'` (default) | `'role'` (per-role views, needs `roles`) | `'spatial-grid'` (per-client AOI). */
124
186
  readonly visibility?: Visibility;
125
187
  /** With `visibility: 'role'`: the roles that see this collection. */
126
188
  readonly roles?: readonly string[];
189
+ /** Required with `visibility: 'spatial-grid'`; invalid on every other visibility mode. */
190
+ readonly grid?: GridOptions;
191
+ /**
192
+ * `false` → the client's `room.render` read path snaps this collection to the authoritative
193
+ * value on arrival instead of interpolating it (D20, week 8). Rendering behavior only —
194
+ * identity, not shape: like `project`, it is not part of the schema hash, and
195
+ * `schemaFromCanonical` does not round-trip it (the canonical form has no rendering options).
196
+ */
197
+ readonly interpolate?: boolean;
198
+ /**
199
+ * D21: the client simulates this collection's bodies ahead for instances it does **not** own
200
+ * (owned bodies are always predicted when prediction is active). Capped per client (default 64
201
+ * non-owned bodies; over-cap instances render by interpolation but have no body in the client's
202
+ * local world, so predicted bodies pass through them). Like `interpolate`, this is
203
+ * client-side simulation behavior, not server shape — it is not part of the canonical form or
204
+ * the schema hash, and `schemaFromCanonical` does not round-trip it. Requires `physics`.
205
+ */
206
+ readonly predicted?: boolean;
207
+ /**
208
+ * D22: this collection's instances are backed by rigid bodies. `body` names the fields the
209
+ * simulation owns (read-only everywhere else), `intents` the owner-written inputs the step
210
+ * consumes. Unlike `interpolate`, this is **server shape** — it is part of the canonical form
211
+ * and the schema hash, because a client and a room that disagree about it disagree about the
212
+ * world. Requires `physics:` on the room config; the runtime rejects the pairing otherwise.
213
+ */
214
+ readonly physics?: EntityPhysics;
127
215
  }
128
216
  interface EntityDef<F extends Fields = Fields, O extends EntityOptions = EntityOptions> {
129
217
  readonly kind: 'entity';
@@ -196,6 +284,14 @@ interface CollectionDesc {
196
284
  readonly serverOwned: boolean;
197
285
  readonly visibility: Visibility;
198
286
  readonly roles: readonly string[] | undefined;
287
+ /** D23 spatial AOI contract, present only when declared. */
288
+ readonly grid: GridDesc | undefined;
289
+ /** `false` → `room.render` steps this collection (D20). Not part of the canonical form/hash. */
290
+ readonly interpolate: boolean;
291
+ /** D21: clients simulate non-owned bodies ahead. Client-side only — not in the hash. */
292
+ readonly predicted: boolean;
293
+ /** D22 body/intent field split, compiled. `undefined` for ordinary collections. In the hash. */
294
+ readonly physics: PhysicsDesc | undefined;
199
295
  }
200
296
  interface RpcDesc {
201
297
  readonly name: string;
@@ -358,8 +454,28 @@ type InstanceOf<D> = D extends EntityDef<infer F, any> ? InferFields<F> : D exte
358
454
  /** Init type (`add()` values) of an entity definition. */
359
455
  type InitOf<D> = D extends EntityDef<infer F, any> ? InitFields<F> : D extends SingletonDef<infer F, any> ? InitFields<F> : never;
360
456
  type DefOptions<D> = D extends EntityDef<any, infer O> ? O : D extends SingletonDef<any, infer O> ? O : never;
457
+ /**
458
+ * The field names `physics.body` maps for a def (D22), as a union of string literals — `never`
459
+ * for an ordinary collection. Needs `entity(fields, { ... } as const)`, which the `const O`
460
+ * type parameter on `entity()`/`singleton()` already gives every call site.
461
+ */
462
+ type BodyFieldsOf<D> = DefOptions<D> extends {
463
+ physics: {
464
+ body: infer B;
465
+ };
466
+ } ? B[keyof B] & string : never;
467
+ /**
468
+ * `T` with the keys in `Names` marked `readonly`. The simulation owns those fields between
469
+ * steps, so a handler assigning one is a compile error — the intent fields are where input goes.
470
+ * `add()` still takes them (`InitFields` is untouched): that is how a body's initial pose is set.
471
+ */
472
+ type ReadonlyBodyFields<T, Names extends string> = [Names] extends [never] ? T : Simplify<{
473
+ readonly [K in keyof T as K extends Names ? K : never]: T[K];
474
+ } & {
475
+ [K in keyof T as K extends Names ? never : K]: T[K];
476
+ }>;
361
477
  type State<S> = {
362
- [K in keyof SchemaDefs<S>]: SchemaDefs<S>[K] extends EntityDef<infer F, any> ? Collection<InferFields<F>, InitFields<F>> : SchemaDefs<S>[K] extends SingletonDef<infer F, any> ? InferFields<F> : never;
478
+ [K in keyof SchemaDefs<S>]: SchemaDefs<S>[K] extends EntityDef<infer F, any> ? Collection<ReadonlyBodyFields<InferFields<F>, BodyFieldsOf<SchemaDefs<S>[K]>>, InitFields<F>> : SchemaDefs<S>[K] extends SingletonDef<infer F, any> ? InferFields<F> : never;
363
479
  };
364
480
  type RolesOf<O extends EntityOptions> = O extends {
365
481
  roles: readonly (infer R)[];
@@ -385,6 +501,12 @@ type ServerOwnedKeys<S> = {
385
501
  serverOwned: true;
386
502
  } ? K : never;
387
503
  }[keyof SchemaDefs<S>];
504
+ /** Entity collections backed by rigid bodies (D22). */
505
+ type PhysicsKeys<S> = {
506
+ [K in keyof SchemaDefs<S>]: DefOptions<SchemaDefs<S>[K]> extends {
507
+ physics: object;
508
+ } ? K : never;
509
+ }[keyof SchemaDefs<S>];
388
510
  /** Entity collections whose instances a client may own. */
389
511
  type OwnableKeys<S> = {
390
512
  [K in keyof SchemaDefs<S>]: SchemaDefs<S>[K] extends EntityDef<any, infer O> ? O extends {
@@ -597,9 +719,10 @@ type PlainRecord = Record<string, unknown>;
597
719
  interface Header {
598
720
  readonly tick: number;
599
721
  }
600
- /** Snapshot options: `collections` limits which collections carry data (others are encoded empty/default). */
722
+ /** Snapshot options limit collections and, for entities, individual ids. */
601
723
  interface SnapshotOptions {
602
724
  readonly collections?: (c: CollectionDesc) => boolean;
725
+ readonly entities?: (c: CollectionDesc, id: string) => boolean;
603
726
  }
604
727
  declare function encodeSnapshot(schema: AnySchema, state: PlainState, header: Header, options?: SnapshotOptions): Uint8Array;
605
728
  interface DecodedSnapshot {
@@ -701,4 +824,4 @@ interface SchemaChange {
701
824
  /** Compares two compiled schemas and returns every classified change, sorted by path then code. */
702
825
  declare function diffSchemas(oldSchema: AnySchema, newSchema: AnySchema): SchemaChange[];
703
826
 
704
- export { type AddOptions, type AnyDef, type AnyRpc, type AnySchema, type AnyType, 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 FieldDesc, type FieldMask, type Fields, type FromCanonicalOptions, type Header, ID_MAX_BYTES, type Implementations, type Infer, type InferFields, type InitFields, type InitOf, type InstanceOf, type Kind, type OwnableKeys, type Owned, type PlainState, RESERVED_COLLECTION_NAMES, RESERVED_RPC_NAMES, 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 SchemaOptions, type SchemaRoles, type SchemaRpc, type ServerCallProxy, type ServerOwnedKeys, type ServerRpcs, type SingletonDef, type SnapshotOptions, type State, type Tracked, type Type, type TypeDesc, type ValueSink, type Visibility, type VisibleKeys, applyDelta, bool, bytesEqual, canonicalType, client, cloneValue, collectionDirty, 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, readValue, ref, schemaFromCanonical, server, sha256, sha256Hex, singleton, stableStringify, str, struct, toHex, track, u16, u32, u8, validateForDeploy, validateValue, writeValue };
827
+ export { type AddOptions, type AnyDef, type AnyRpc, type AnySchema, type AnyType, 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, 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 SchemaOptions, type SchemaRoles, type SchemaRpc, type ServerCallProxy, type ServerOwnedKeys, type ServerRpcs, type SingletonDef, type SnapshotOptions, type State, type Tracked, type Type, type TypeDesc, type ValueSink, type Visibility, type VisibleKeys, applyDelta, bool, bytesEqual, canonicalPhysics, canonicalType, 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, writeValue };
package/dist/index.js CHANGED
@@ -211,6 +211,114 @@ function normalizeValue(d, v, path = "") {
211
211
  }
212
212
  }
213
213
 
214
+ // src/physics.ts
215
+ var PHYSICS_BODY_CHANNELS = [
216
+ "x",
217
+ "y",
218
+ "z",
219
+ "qx",
220
+ "qy",
221
+ "qz",
222
+ "qw",
223
+ "vx",
224
+ "vy",
225
+ "vz",
226
+ "wx",
227
+ "wy",
228
+ "wz"
229
+ ];
230
+ var CHANNEL_SET = new Set(PHYSICS_BODY_CHANNELS);
231
+ var NUMERIC_KINDS = /* @__PURE__ */ new Set(["f32", "f64"]);
232
+ function compilePhysics(at, kind, fields, physics) {
233
+ if (kind !== "entity") {
234
+ throw new Error(`${at}: physics is only available on entity collections, not singletons`);
235
+ }
236
+ const byName = new Map(fields.map((f) => [f.name, f]));
237
+ const channels = [];
238
+ const bodyFields = /* @__PURE__ */ new Set();
239
+ const seen = /* @__PURE__ */ new Map();
240
+ for (const key of Object.keys(physics.body)) {
241
+ if (!CHANNEL_SET.has(key)) {
242
+ throw new Error(
243
+ `${at}: physics.body has unknown channel ${JSON.stringify(key)} (one of ${PHYSICS_BODY_CHANNELS.join(", ")})`
244
+ );
245
+ }
246
+ }
247
+ for (const channel of PHYSICS_BODY_CHANNELS) {
248
+ const field = physics.body[channel];
249
+ if (field === void 0) continue;
250
+ const desc = byName.get(field);
251
+ if (!desc) {
252
+ throw new Error(
253
+ `${at}: physics.body.${channel} names ${JSON.stringify(field)}, which is not a field of this entity`
254
+ );
255
+ }
256
+ if (!NUMERIC_KINDS.has(desc.type.kind)) {
257
+ throw new Error(
258
+ `${at}: physics.body.${channel} \u2192 ${JSON.stringify(field)} must be f32 or f64, got ${desc.type.kind}`
259
+ );
260
+ }
261
+ const already = seen.get(field);
262
+ if (already !== void 0) {
263
+ throw new Error(
264
+ `${at}: physics.body.${channel} and physics.body.${already} both map ${JSON.stringify(field)}`
265
+ );
266
+ }
267
+ seen.set(field, channel);
268
+ channels.push([channel, field]);
269
+ bodyFields.add(field);
270
+ }
271
+ if (channels.length === 0) {
272
+ throw new Error(`${at}: physics.body must map at least one channel to a field`);
273
+ }
274
+ const intents = [];
275
+ for (const name of physics.intents ?? []) {
276
+ if (!byName.has(name)) {
277
+ throw new Error(
278
+ `${at}: physics.intents names ${JSON.stringify(name)}, which is not a field of this entity`
279
+ );
280
+ }
281
+ if (bodyFields.has(name)) {
282
+ throw new Error(
283
+ `${at}: ${JSON.stringify(name)} is both a body field and an intent \u2014 pick one`
284
+ );
285
+ }
286
+ if (intents.includes(name)) {
287
+ throw new Error(`${at}: physics.intents lists ${JSON.stringify(name)} twice`);
288
+ }
289
+ intents.push(name);
290
+ }
291
+ return { body: { ...physics.body }, channels, intents, bodyFields };
292
+ }
293
+ function canonicalPhysics(p) {
294
+ const body = {};
295
+ for (const [channel, field] of p.channels) body[channel] = field;
296
+ return { body, intents: [...p.intents] };
297
+ }
298
+ function physicsFromCanonical(at, raw) {
299
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
300
+ throw new Error(`${at}: physics must be an object`);
301
+ }
302
+ const o = raw;
303
+ const bodyRaw = o.body;
304
+ if (typeof bodyRaw !== "object" || bodyRaw === null || Array.isArray(bodyRaw)) {
305
+ throw new Error(`${at}: physics.body must be an object`);
306
+ }
307
+ const body = {};
308
+ for (const [k, v] of Object.entries(bodyRaw)) {
309
+ if (typeof v !== "string") throw new Error(`${at}: physics.body.${k} must be a field name`);
310
+ body[k] = v;
311
+ }
312
+ const intentsRaw = o.intents ?? [];
313
+ if (!Array.isArray(intentsRaw)) throw new Error(`${at}: physics.intents must be an array`);
314
+ const intents = [];
315
+ for (const v of intentsRaw) {
316
+ if (typeof v !== "string") throw new Error(`${at}: physics.intents must be field names`);
317
+ intents.push(v);
318
+ }
319
+ return { body, intents };
320
+ }
321
+
214
322
  // src/sha256.ts
215
323
  var K = new Uint32Array([
216
324
  1116352408,
@@ -396,6 +504,11 @@ function defineSchema(defs, options = {}) {
396
504
  const def = defs[name];
397
505
  const fields = compileFields(def.fields);
398
506
  const o = def.options;
507
+ if (o.predicted === true && !o.physics) {
508
+ throw new Error(
509
+ `${name}: predicted: true needs physics \u2014 only body-backed collections can be simulated ahead`
510
+ );
511
+ }
399
512
  return {
400
513
  name,
401
514
  index,
@@ -404,7 +517,17 @@ function defineSchema(defs, options = {}) {
404
517
  fieldIndex: new Map(fields.map((f) => [f.name, f.index])),
405
518
  serverOwned: o.serverOwned === true,
406
519
  visibility: o.visibility ?? "all",
407
- roles: o.roles ? [...o.roles] : void 0
520
+ roles: o.roles ? [...o.roles] : void 0,
521
+ grid: o.grid ? {
522
+ x: o.grid.x,
523
+ y: o.grid.y,
524
+ cell: o.grid.cell,
525
+ radius: o.grid.radius,
526
+ ...o.grid.wideRoles ? { wideRoles: [...o.grid.wideRoles] } : {}
527
+ } : void 0,
528
+ interpolate: o.interpolate !== false,
529
+ predicted: o.predicted === true,
530
+ physics: o.physics ? compilePhysics(name, def.kind, fields, o.physics) : void 0
408
531
  };
409
532
  });
410
533
  const entityNames = new Set(collections.filter((c) => c.kind === "entity").map((c) => c.name));
@@ -513,7 +636,18 @@ function canonicalize(s) {
513
636
  fields: canonicalFields(c.fields),
514
637
  serverOwned: c.serverOwned,
515
638
  visibility: c.visibility,
516
- roles: c.roles ? [...c.roles] : null
639
+ roles: c.roles ? [...c.roles] : null,
640
+ // D23 is emitted only when declared so pre-AOI schemas retain byte-identical hashes.
641
+ grid: c.grid ? {
642
+ x: c.grid.x,
643
+ y: c.grid.y,
644
+ cell: c.grid.cell,
645
+ radius: c.grid.radius,
646
+ ...c.grid.wideRoles ? { wideRoles: [...c.grid.wideRoles] } : {}
647
+ } : void 0,
648
+ // Emitted only when declared: `stableStringify` drops `undefined`, so every schema written
649
+ // before D22 keeps the byte-identical canonical form — and therefore the hash — it had.
650
+ physics: c.physics ? canonicalPhysics(c.physics) : void 0
517
651
  })),
518
652
  rpcs: s.rpcs.map((r) => ({
519
653
  name: r.name,
@@ -545,9 +679,54 @@ function validateForDeploy(schema) {
545
679
  const issues = [];
546
680
  for (const c of schema.collections) {
547
681
  if (c.visibility === "spatial-grid") {
682
+ const grid = c.grid;
683
+ if (c.kind !== "entity") {
684
+ issues.push({
685
+ level: "error",
686
+ message: `${c.name}: spatial-grid visibility is entity-only`
687
+ });
688
+ }
689
+ if (!grid) {
690
+ issues.push({
691
+ level: "error",
692
+ message: `${c.name}: spatial-grid visibility requires grid: { x, y, cell, radius }`
693
+ });
694
+ } else {
695
+ const numeric = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
696
+ for (const axis of ["x", "y"]) {
697
+ const field = c.fields.find((f) => f.name === grid[axis]);
698
+ if (!field || !numeric.has(field.type.kind)) {
699
+ issues.push({
700
+ level: "error",
701
+ message: `${c.name}: grid.${axis} must name a numeric scalar field`
702
+ });
703
+ }
704
+ }
705
+ if (!Number.isFinite(grid.cell) || grid.cell <= 0) {
706
+ issues.push({
707
+ level: "error",
708
+ message: `${c.name}: grid.cell must be a positive finite number`
709
+ });
710
+ }
711
+ if (!Number.isInteger(grid.radius) || grid.radius < 0) {
712
+ issues.push({
713
+ level: "error",
714
+ message: `${c.name}: grid.radius must be a non-negative integer`
715
+ });
716
+ }
717
+ for (const role of grid.wideRoles ?? []) {
718
+ if (!schema.roles.includes(role)) {
719
+ issues.push({
720
+ level: "error",
721
+ message: `${c.name}: grid wide role ${JSON.stringify(role)} is not in schema roles`
722
+ });
723
+ }
724
+ }
725
+ }
726
+ } else if (c.grid) {
548
727
  issues.push({
549
728
  level: "error",
550
- message: `${c.name}: visibility 'spatial-grid' is not yet supported; use 'all' or 'role'`
729
+ message: `${c.name}: grid is only valid with visibility 'spatial-grid'`
551
730
  });
552
731
  }
553
732
  if (c.visibility === "role") {
@@ -708,10 +887,30 @@ function entityOptions(e, name) {
708
887
  throw new Error(`schemaFromCanonical: ${name}: roles must be strings`);
709
888
  return r;
710
889
  });
890
+ const physics = e.physics === void 0 || e.physics === null ? void 0 : physicsFromCanonical(`schemaFromCanonical: ${name}`, e.physics);
891
+ const grid = e.grid === void 0 || e.grid === null ? void 0 : gridFromCanonical(name, objectAt(e, "grid"));
711
892
  return {
712
893
  serverOwned: e.serverOwned === true,
713
894
  visibility,
714
- ...roles !== void 0 ? { roles } : {}
895
+ ...roles !== void 0 ? { roles } : {},
896
+ ...grid !== void 0 ? { grid } : {},
897
+ ...physics !== void 0 ? { physics } : {}
898
+ };
899
+ }
900
+ function gridFromCanonical(name, grid) {
901
+ const wideRolesRaw = grid.wideRoles;
902
+ const wideRoles = wideRolesRaw === void 0 ? void 0 : arrayAtRaw(grid, "wideRoles").map((role) => {
903
+ if (typeof role !== "string") {
904
+ throw new Error(`schemaFromCanonical: ${name}: grid.wideRoles must be strings`);
905
+ }
906
+ return role;
907
+ });
908
+ return {
909
+ x: stringAt(grid, "x"),
910
+ y: stringAt(grid, "y"),
911
+ cell: finiteNumberAt(grid, "cell"),
912
+ radius: numberAt(grid, "radius"),
913
+ ...wideRoles !== void 0 ? { wideRoles } : {}
715
914
  };
716
915
  }
717
916
  function stringAt(o, key) {
@@ -726,6 +925,13 @@ function numberAt(o, key) {
726
925
  }
727
926
  return v;
728
927
  }
928
+ function finiteNumberAt(o, key) {
929
+ const v = o[key];
930
+ if (typeof v !== "number" || !Number.isFinite(v)) {
931
+ throw new Error(`schemaFromCanonical: ${key} must be a finite number`);
932
+ }
933
+ return v;
934
+ }
729
935
  function objectAt(o, key) {
730
936
  const v = o[key];
731
937
  if (typeof v !== "object" || v === null || Array.isArray(v)) {
@@ -1348,8 +1554,15 @@ function writeSnapshot(w, schema, state, tick, options) {
1348
1554
  continue;
1349
1555
  }
1350
1556
  const coll = entityOf(state, c.name);
1351
- w.varint(coll.size);
1557
+ let count = 0;
1558
+ if (options?.entities) {
1559
+ for (const id of coll.ids()) if (options.entities(c, id)) count++;
1560
+ } else {
1561
+ count = coll.size;
1562
+ }
1563
+ w.varint(count);
1352
1564
  for (const id of coll.ids()) {
1565
+ if (options?.entities && !options.entities(c, id)) continue;
1353
1566
  const value = coll.get(id);
1354
1567
  if (value === void 0) throw new Error(`${c.name}: id ${JSON.stringify(id)} vanished`);
1355
1568
  w.str(id);
@@ -2175,6 +2388,14 @@ function diffCollections(oldCollections, newCollections, changes) {
2175
2388
  message: `${oldC.name}: visible roles changed from [${(oldC.roles ?? []).join(", ")}] to [${(newC.roles ?? []).join(", ")}] \u2014 changes who sees this collection`
2176
2389
  });
2177
2390
  }
2391
+ if (stableStringify(oldC.grid) !== stableStringify(newC.grid)) {
2392
+ changes.push({
2393
+ kind: "breaking",
2394
+ code: "entity.grid_changed",
2395
+ path: oldC.name,
2396
+ message: `${oldC.name}: spatial grid contract changed \u2014 changes per-client membership`
2397
+ });
2398
+ }
2178
2399
  diffFields(oldC.fields, newC.fields, oldC.name, changes);
2179
2400
  }
2180
2401
  }
@@ -2456,6 +2677,7 @@ export {
2456
2677
  CANONICAL_VERSION,
2457
2678
  EntityCollection,
2458
2679
  ID_MAX_BYTES,
2680
+ PHYSICS_BODY_CHANNELS,
2459
2681
  RESERVED_COLLECTION_NAMES,
2460
2682
  RESERVED_RPC_NAMES,
2461
2683
  SERVER_OWNER,
@@ -2463,10 +2685,12 @@ export {
2463
2685
  applyDelta,
2464
2686
  bool,
2465
2687
  bytesEqual,
2688
+ canonicalPhysics,
2466
2689
  canonicalType,
2467
2690
  client,
2468
2691
  cloneValue,
2469
2692
  collectionDirty,
2693
+ compilePhysics,
2470
2694
  computeDirty,
2471
2695
  createDirtySet,
2472
2696
  createFieldMask,
@@ -2503,6 +2727,7 @@ export {
2503
2727
  mergeMask,
2504
2728
  normalizeRecord,
2505
2729
  normalizeValue,
2730
+ physicsFromCanonical,
2506
2731
  readValue,
2507
2732
  ref,
2508
2733
  schemaFromCanonical,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/schema",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "irtio schema DSL, type inference, canonical hash, codec, change tracking, and schema diff",
5
5
  "license": "MIT",
6
6
  "publishConfig": {