@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 +127 -4
- package/dist/index.js +230 -5
- package/package.json +1 -1
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'` (
|
|
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
|
|
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}:
|
|
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
|
-
|
|
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,
|