@irtio/schema 3.1.1 → 4.1.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/README.md +56 -0
- package/dist/index.d.ts +84 -10
- package/dist/index.js +120 -2
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @irtio/schema
|
|
2
|
+
|
|
3
|
+
Define shared multiplayer state, typed RPCs, and messages. The same schema supplies TypeScript
|
|
4
|
+
types and binary encoding for your room and its clients.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @irtio/schema
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Define state and an RPC
|
|
11
|
+
|
|
12
|
+
Create `irtio/schema.ts`:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { defineSchema, entity, f32, server, singleton, u32 } from '@irtio/schema';
|
|
16
|
+
|
|
17
|
+
export const schema = defineSchema(
|
|
18
|
+
{
|
|
19
|
+
players: entity({ x: f32, y: f32 }),
|
|
20
|
+
match: singleton({ round: u32 }),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
roles: ['player'] as const,
|
|
24
|
+
rpc: {
|
|
25
|
+
nextRound: server({}),
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`players` contains records addressed by id. `match` is one server-owned object. `nextRound`
|
|
32
|
+
declares a client-to-server call; implement it with `defineRoom` from `@irtio/server`.
|
|
33
|
+
For a deployed project, use the project id generated by `irtio init` in the schema options.
|
|
34
|
+
|
|
35
|
+
## Choose a schema primitive
|
|
36
|
+
|
|
37
|
+
| Export | Use |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `entity(fields, options?)` | Records with ids, ownership, and optional visibility rules |
|
|
40
|
+
| `singleton(fields, options?)` | One object, written by the server |
|
|
41
|
+
| `bool`, `u8`, `u16`, `u32`, `i32`, `f32`, `f64` | Boolean and numeric fields |
|
|
42
|
+
| `str(maxBytes)` | A UTF-8 string with a byte limit |
|
|
43
|
+
| `enumOf(...)`, `struct(...)`, `list(...)`, `ref(...)` | Enums, nested values, bounded lists, and references |
|
|
44
|
+
| `server(spec)`, `client(spec)` | RPC declarations in either direction |
|
|
45
|
+
| `State<typeof schema>` | The inferred server state type |
|
|
46
|
+
|
|
47
|
+
A schema validates shape and bounds. Authorize actions and validate gameplay in room handlers.
|
|
48
|
+
Append new RPCs and messages to their declaration maps to preserve existing wire ids.
|
|
49
|
+
|
|
50
|
+
For codec and tooling integrations, the package also exports state creation, snapshot and delta
|
|
51
|
+
codecs, change tracking, canonical schema reconstruction, and schema diffing. Use the client SDK
|
|
52
|
+
and room API to manage these during normal gameplay.
|
|
53
|
+
|
|
54
|
+
See the [schema reference](https://irt.io/docs/reference/schema) for field signatures, collection
|
|
55
|
+
options, defaults, and migration rules, or start with the
|
|
56
|
+
[quickstart](https://irt.io/docs/getting-started).
|
package/dist/index.d.ts
CHANGED
|
@@ -123,6 +123,11 @@ declare function normalizeValue(d: TypeDesc, v: unknown, path?: string): unknown
|
|
|
123
123
|
* physics entity is client-writable, so a client's write history for one of these entities is
|
|
124
124
|
* exactly its intent frames — which is what week 10's client-side re-stepping replays.
|
|
125
125
|
*
|
|
126
|
+
* That makes `intents` the owner-write list rather than a force list, which surprises people: a
|
|
127
|
+
* field the owner writes and the step never reads — a cosmetic `yaw`, a held emote — still goes
|
|
128
|
+
* in `intents`, because that is the only way to make a field of a body-backed collection writable
|
|
129
|
+
* at all. Listing a non-force there is correct, not a misuse.
|
|
130
|
+
*
|
|
126
131
|
* Unlike week 8's `interpolate` (a client-side rendering choice, deliberately outside the schema
|
|
127
132
|
* hash), physics options are **server shape**: a room and a client that disagree about which
|
|
128
133
|
* fields the simulation owns disagree about the world. They go in the canonical form and
|
|
@@ -143,7 +148,12 @@ type PhysicsBodyMap = {
|
|
|
143
148
|
interface EntityPhysics {
|
|
144
149
|
/** Fields the simulation owns: translation (`x`/`y`/`z`), rotation quaternion (`q*`), velocities (`v*`/`w*`). */
|
|
145
150
|
readonly body: PhysicsBodyMap;
|
|
146
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* The owner-write list: every field the owning client may write. Usually inputs the step
|
|
153
|
+
* consumes, but not only — a purely cosmetic owned field (a `yaw` nothing simulates, a chosen
|
|
154
|
+
* colour) belongs here too, because this list *is* how a field on a body-backed collection
|
|
155
|
+
* becomes writable. Declaration order is the canonical order.
|
|
156
|
+
*/
|
|
147
157
|
readonly intents?: readonly string[];
|
|
148
158
|
}
|
|
149
159
|
/** Compiled form on `CollectionDesc.physics`. */
|
|
@@ -282,10 +292,22 @@ declare function ownershipFromCanonical(at: string, raw: unknown): OwnershipOpti
|
|
|
282
292
|
|
|
283
293
|
/**
|
|
284
294
|
* `'server'` is the room's own private state: no client role sees it, no client may write it, and
|
|
285
|
-
*
|
|
286
|
-
*
|
|
295
|
+
* its rows are never encoded into a snapshot or a delta. The collection's definition — its name
|
|
296
|
+
* and field names — is still part of the schema the server and every client share, so the names
|
|
297
|
+
* are public and only the values are private. It is the first-class spelling of what
|
|
298
|
+
* `visibility: 'role'` with an empty `roles` list has always done by accident, and it takes no
|
|
299
|
+
* `roles`.
|
|
287
300
|
*/
|
|
288
|
-
|
|
301
|
+
/**
|
|
302
|
+
* `'owner'` is per-record, not per-role: an entity record is visible to the one client that owns
|
|
303
|
+
* it — the first-class ownership every entity record already carries, the thing `perPlayer`,
|
|
304
|
+
* `requestOwnership` and `ctx.owner` all speak about — and to the server. Its adds and removes are
|
|
305
|
+
* scoped the same way, so a client never learns another client's record exists, and a transfer of
|
|
306
|
+
* ownership reaches the old owner as a `remove` and the new one as an `add`. A record left
|
|
307
|
+
* server-owned (`SERVER_OWNER`, the empty string) is owned by no client and is therefore visible
|
|
308
|
+
* to none. It is entity-only — a singleton has no owner — and takes no `roles` and no `grid`.
|
|
309
|
+
*/
|
|
310
|
+
type Visibility = 'all' | 'role' | 'spatial-grid' | 'server' | 'owner';
|
|
289
311
|
interface GridOptions<K extends string = string> {
|
|
290
312
|
/** Numeric fields containing the entity's world position. */
|
|
291
313
|
readonly x: K;
|
|
@@ -304,7 +326,8 @@ interface EntityOptions {
|
|
|
304
326
|
readonly serverOwned?: boolean;
|
|
305
327
|
/**
|
|
306
328
|
* `'all'` (default) | `'role'` (per-role views, needs `roles`) | `'spatial-grid'` (per-client
|
|
307
|
-
* AOI) | `'server'` (room-private: no client sees or writes it)
|
|
329
|
+
* AOI) | `'server'` (room-private: no client sees or writes it) | `'owner'` (per-record: the
|
|
330
|
+
* client that owns the record, and the server).
|
|
308
331
|
*/
|
|
309
332
|
readonly visibility?: Visibility;
|
|
310
333
|
/** With `visibility: 'role'`: the roles that see this collection. */
|
|
@@ -361,6 +384,17 @@ interface EntityOptions {
|
|
|
361
384
|
* Server behaviour, so it is part of the canonical form and the hash — unlike `predicted`.
|
|
362
385
|
*/
|
|
363
386
|
readonly perPlayer?: boolean;
|
|
387
|
+
/**
|
|
388
|
+
* M8 proposal 13: the fields of this per-player record that outlive the room.
|
|
389
|
+
*
|
|
390
|
+
* On a relay-with-state project a client that joins with a verified identity assertion has these
|
|
391
|
+
* fields hydrated from a per-(project, subject) blob, and they are written back when the player
|
|
392
|
+
* leaves. Anonymous joins get schema defaults and write nothing. Requires `perPlayer: true`, the
|
|
393
|
+
* named fields must exist, and their worst-case encoded size must fit `PERSIST_BLOB_BYTES`.
|
|
394
|
+
*
|
|
395
|
+
* Server behaviour — hydration shape — so it is part of the canonical form and the hash.
|
|
396
|
+
*/
|
|
397
|
+
readonly persist?: readonly string[];
|
|
364
398
|
/**
|
|
365
399
|
* D77: clients may `add` records to this collection (the adder becomes the owner, with the id
|
|
366
400
|
* it chose) and the owner may `remove` one. Off by default, which is what every collection
|
|
@@ -387,8 +421,10 @@ interface EntityOptions {
|
|
|
387
421
|
* only when declared, so every schema written before this keeps the hash it had.
|
|
388
422
|
*
|
|
389
423
|
* Entity-only, and meaningless on a `serverOwned` collection (where every field already is);
|
|
390
|
-
* both are refused.
|
|
391
|
-
*
|
|
424
|
+
* both are refused. Refused on a `physics` collection too, and for the same reason: the owner
|
|
425
|
+
* writes only the fields in `physics.intents`, so every other field is already server-only.
|
|
426
|
+
* (`physics.intents` is the owner-write list, not a force list — an owned field the step never
|
|
427
|
+
* reads, a cosmetic `yaw` say, goes in `intents` too. That is how it becomes writable.)
|
|
392
428
|
*
|
|
393
429
|
* There is deliberately no dynamic form. A per-field *owner id* would have to ride the wire per
|
|
394
430
|
* field per record, and the case that needs one is served by a second collection.
|
|
@@ -413,6 +449,21 @@ declare function entity<F extends Fields, const O extends EntityOptions>(fields:
|
|
|
413
449
|
/** Exactly one instance; same `serverOwned`/`visibility` options. */
|
|
414
450
|
declare function singleton<F extends Fields>(fields: F): SingletonDef<F, {}>;
|
|
415
451
|
declare function singleton<F extends Fields, const O extends EntityOptions>(fields: F, options: O): SingletonDef<F, O>;
|
|
452
|
+
/**
|
|
453
|
+
* The hard cap on one player's persistence blob, in encoded bytes.
|
|
454
|
+
*
|
|
455
|
+
* A compile that could exceed it is refused here rather than at the first leave that would have
|
|
456
|
+
* written an oversize blob: a cap discovered in production is a cap nobody can act on.
|
|
457
|
+
*/
|
|
458
|
+
declare const PERSIST_BLOB_BYTES: number;
|
|
459
|
+
/**
|
|
460
|
+
* The worst-case encoded size of `names` out of `fields`, in bytes.
|
|
461
|
+
*
|
|
462
|
+
* Static and deliberately pessimistic: every bound is the largest value the declared type can
|
|
463
|
+
* hold, so a schema that compiles can never write a blob over the cap. Mirrors `codec.ts`'s
|
|
464
|
+
* writers — LEB128 varints for list and string lengths, one presence byte for `.opt`.
|
|
465
|
+
*/
|
|
466
|
+
declare function maxEncodedBytes(fields: readonly FieldDesc[], names: readonly string[]): number;
|
|
416
467
|
type RpcDirection = 'server' | 'client';
|
|
417
468
|
interface RpcDef<Dir extends RpcDirection = RpcDirection, P extends Fields = Fields, R extends Fields | undefined = Fields | undefined> {
|
|
418
469
|
readonly kind: 'rpc';
|
|
@@ -484,6 +535,11 @@ interface CollectionDesc {
|
|
|
484
535
|
readonly physics: PhysicsDesc | undefined;
|
|
485
536
|
/** D77: one record per joined client, owned by them. In the hash. */
|
|
486
537
|
readonly perPlayer: boolean;
|
|
538
|
+
/**
|
|
539
|
+
* M8 proposal 13: the per-player fields that outlive the room, sorted, or `undefined` when none
|
|
540
|
+
* were declared. In the hash when present.
|
|
541
|
+
*/
|
|
542
|
+
readonly persist: readonly string[] | undefined;
|
|
487
543
|
/** D77: clients may add records here and their owner may remove them. In the hash. */
|
|
488
544
|
readonly clientCreate: boolean;
|
|
489
545
|
/** D77: the compiled transfer and leave rules. `undefined` when none were declared. In the hash. */
|
|
@@ -793,12 +849,30 @@ type State<S> = {
|
|
|
793
849
|
type RolesOf<O extends EntityOptions> = O extends {
|
|
794
850
|
roles: readonly (infer R)[];
|
|
795
851
|
} ? R : never;
|
|
796
|
-
/**
|
|
852
|
+
/**
|
|
853
|
+
* Is a collection with options `O` visible to `Role`?
|
|
854
|
+
*
|
|
855
|
+
* `server` is never visible. A `role` collection is visible when `Role` **overlaps** its roles,
|
|
856
|
+
* not when `Role` is wholly contained in them — and that distinction is the whole point.
|
|
857
|
+
*
|
|
858
|
+
* The default `Role` on {@link ClientState} is the union of every role the schema declares. Under
|
|
859
|
+
* containment, `['host' | 'guest'] extends ['host']` is false, so a bare `ClientState<S>` dropped
|
|
860
|
+
* *every* role-visible collection and reading one needed a cast — even though a hidden collection
|
|
861
|
+
* is present at runtime (the wire has a slot for every collection; a client without the role just
|
|
862
|
+
* receives it empty or default-valued). Overlap gives the bare default all of them, which is what
|
|
863
|
+
* the runtime hands you, while `ClientState<S, 'hunter'>` still excludes anything scoped only to
|
|
864
|
+
* other roles, which is what a role-narrowed view is for.
|
|
865
|
+
*
|
|
866
|
+
* `spatial-grid` and `owner` are `true` for every role, for the same reason: they scope *entities*,
|
|
867
|
+
* not collections. The collection is present in every client's state — empty, or holding only the
|
|
868
|
+
* ids that client's AOI or ownership entitles it to — and the filtering happens at runtime, which
|
|
869
|
+
* no role-indexed type can express.
|
|
870
|
+
*/
|
|
797
871
|
type VisibleTo<O extends EntityOptions, Role extends string> = O extends {
|
|
798
872
|
visibility: 'server';
|
|
799
873
|
} ? false : O extends {
|
|
800
874
|
visibility: 'role';
|
|
801
|
-
} ? [Role] extends [
|
|
875
|
+
} ? [Extract<Role, RolesOf<O>>] extends [never] ? false : true : true;
|
|
802
876
|
type VisibleKeys<S, Role extends string> = {
|
|
803
877
|
[K in keyof SchemaDefs<S>]: VisibleTo<DefOptions<SchemaDefs<S>[K]>, Role> extends true ? K : never;
|
|
804
878
|
}[keyof SchemaDefs<S>];
|
|
@@ -1258,4 +1332,4 @@ declare function diffSchemas(oldSchema: AnySchema, newSchema: AnySchema): Schema
|
|
|
1258
1332
|
*/
|
|
1259
1333
|
declare function breakingIsWireOnly(changes: readonly SchemaChange[]): boolean;
|
|
1260
1334
|
|
|
1261
|
-
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 DecodeSnapshotOptions, 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, MAX_USER_RPCS, 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 ReadOptions, 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, reconcileCollection, ref, schemaFromCanonical, server, sha256, sha256Hex, singleton, stableStringify, str, struct, toHex, track, u16, u32, u8, utf8Length, validateForDeploy, validateValue, walkDelta, walkSnapshot, writeValue };
|
|
1335
|
+
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 DecodeSnapshotOptions, 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, MAX_USER_RPCS, 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, PERSIST_BLOB_BYTES, PHYSICS_BODY_CHANNELS, type PhysicsBodyChannel, type PhysicsBodyMap, type PhysicsDesc, type PhysicsKeys, type PlainState, RESERVED_COLLECTION_NAMES, RESERVED_RPC_NAMES, RESERVED_RPC_PREFIX, type ReadOptions, 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, maxEncodedBytes, mergeDirty, mergeMask, normalizeRecord, normalizeValue, ownershipFromCanonical, parseHold, physicsFromCanonical, readValue, reconcileCollection, ref, schemaFromCanonical, server, sha256, sha256Hex, singleton, stableStringify, str, struct, toHex, track, u16, u32, u8, utf8Length, validateForDeploy, validateValue, walkDelta, walkSnapshot, writeValue };
|
package/dist/index.js
CHANGED
|
@@ -699,11 +699,95 @@ function compileServerFields(name, kind, fields, o) {
|
|
|
699
699
|
}
|
|
700
700
|
if (o.physics) {
|
|
701
701
|
throw new Error(
|
|
702
|
-
`${name}: serverFields
|
|
702
|
+
`${name}: serverFields is redundant on a physics collection \u2014 the owner writes only the fields in physics.intents, so every other field is already server-only. Remove serverFields`
|
|
703
703
|
);
|
|
704
704
|
}
|
|
705
705
|
return new Set([...out].sort());
|
|
706
706
|
}
|
|
707
|
+
var PERSIST_BLOB_BYTES = 4 * 1024;
|
|
708
|
+
function maxEncodedBytes(fields, names) {
|
|
709
|
+
const byName = new Map(fields.map((f) => [f.name, f]));
|
|
710
|
+
let total = 0;
|
|
711
|
+
for (const n of names) {
|
|
712
|
+
const f = byName.get(n);
|
|
713
|
+
if (f) total += worstCase(f.type);
|
|
714
|
+
}
|
|
715
|
+
return total;
|
|
716
|
+
}
|
|
717
|
+
function varintBytes(max) {
|
|
718
|
+
let bytes = 1;
|
|
719
|
+
let v = Math.max(0, Math.floor(max));
|
|
720
|
+
while (v > 127) {
|
|
721
|
+
v = Math.floor(v / 128);
|
|
722
|
+
bytes++;
|
|
723
|
+
}
|
|
724
|
+
return bytes;
|
|
725
|
+
}
|
|
726
|
+
function worstCase(d) {
|
|
727
|
+
const presence = d.opt ? 1 : 0;
|
|
728
|
+
switch (d.kind) {
|
|
729
|
+
case "bool":
|
|
730
|
+
case "u8":
|
|
731
|
+
case "enum":
|
|
732
|
+
return presence + 1;
|
|
733
|
+
case "u16":
|
|
734
|
+
return presence + 2;
|
|
735
|
+
case "u32":
|
|
736
|
+
case "i32":
|
|
737
|
+
case "f32":
|
|
738
|
+
return presence + 4;
|
|
739
|
+
case "f64":
|
|
740
|
+
return presence + 8;
|
|
741
|
+
case "str":
|
|
742
|
+
return presence + varintBytes(d.max) + d.max;
|
|
743
|
+
case "ref":
|
|
744
|
+
return presence + varintBytes(ID_MAX_BYTES) + ID_MAX_BYTES;
|
|
745
|
+
case "list":
|
|
746
|
+
return presence + varintBytes(d.max) + d.max * worstCase(d.item);
|
|
747
|
+
case "struct": {
|
|
748
|
+
let n = presence;
|
|
749
|
+
for (const fd of Object.values(d.fields)) n += worstCase(fd);
|
|
750
|
+
return n;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
function compilePersist(name, fields, perPlayer, declared) {
|
|
755
|
+
if (declared === void 0) return void 0;
|
|
756
|
+
if (!Array.isArray(declared)) {
|
|
757
|
+
throw new Error(`${name}: persist must be an array of field names`);
|
|
758
|
+
}
|
|
759
|
+
if (!perPlayer) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
`${name}: persist requires perPlayer \u2014 only a per-player record has a player to follow`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
if (declared.length === 0) {
|
|
765
|
+
throw new Error(`${name}: persist is empty; leave it off instead`);
|
|
766
|
+
}
|
|
767
|
+
const known = new Map(fields.map((f) => [f.name, f]));
|
|
768
|
+
const out = [];
|
|
769
|
+
for (const field of declared) {
|
|
770
|
+
if (typeof field !== "string" || !known.has(field)) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
`${name}: persist names ${JSON.stringify(field)}, which is not a field of ${name}`
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
if (out.includes(field)) throw new Error(`${name}: persist names ${field} twice`);
|
|
776
|
+
if (known.get(field).type.kind === "ref") {
|
|
777
|
+
throw new Error(
|
|
778
|
+
`${name}: persist names ${field}, an entity ref \u2014 a ref is an id inside one room and means nothing in the next one`
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
out.push(field);
|
|
782
|
+
}
|
|
783
|
+
const bytes = maxEncodedBytes(fields, out);
|
|
784
|
+
if (bytes > PERSIST_BLOB_BYTES) {
|
|
785
|
+
throw new Error(
|
|
786
|
+
`${name}: persist fields can encode up to ${bytes} bytes, over the 4 KB persistence cap`
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
return out.sort();
|
|
790
|
+
}
|
|
707
791
|
function checkFields(fields) {
|
|
708
792
|
const names = Object.keys(fields);
|
|
709
793
|
if (names.length === 0) throw new Error("entity/singleton needs at least one field");
|
|
@@ -762,6 +846,7 @@ function defineSchema(defs, options = {}) {
|
|
|
762
846
|
);
|
|
763
847
|
}
|
|
764
848
|
const ownership = o.ownership || perPlayer ? compileOwnership(name, def.kind, fields, o.ownership ?? {}, perPlayer) : void 0;
|
|
849
|
+
const persist = compilePersist(name, fields, perPlayer, o.persist);
|
|
765
850
|
const serverFields = compileServerFields(name, def.kind, fields, o);
|
|
766
851
|
return {
|
|
767
852
|
name,
|
|
@@ -772,6 +857,7 @@ function defineSchema(defs, options = {}) {
|
|
|
772
857
|
serverOwned: o.serverOwned === true,
|
|
773
858
|
serverFields,
|
|
774
859
|
perPlayer,
|
|
860
|
+
persist,
|
|
775
861
|
clientCreate: o.clientCreate === true,
|
|
776
862
|
ownership,
|
|
777
863
|
visibility: o.visibility ?? "all",
|
|
@@ -982,6 +1068,10 @@ function canonicalize(s) {
|
|
|
982
1068
|
// writes `upgrades` disagree about the world.
|
|
983
1069
|
serverFields: c.serverFields ? [...c.serverFields] : void 0,
|
|
984
1070
|
perPlayer: c.perPlayer ? true : void 0,
|
|
1071
|
+
// M8 proposal 13: the same only-when-declared rule again. In the hash when present because
|
|
1072
|
+
// it is hydration shape: a room that fills a field from a blob and a client that does not
|
|
1073
|
+
// expect it to be filled disagree about the world.
|
|
1074
|
+
persist: c.persist ? [...c.persist] : void 0,
|
|
985
1075
|
clientCreate: c.clientCreate ? true : void 0,
|
|
986
1076
|
ownership: c.ownership ? canonicalOwnership(c.ownership) : void 0
|
|
987
1077
|
// ---- end M6 lane K ----
|
|
@@ -1075,6 +1165,26 @@ function validateForDeploy(schema) {
|
|
|
1075
1165
|
message: `${c.name}: grid is only valid with visibility 'spatial-grid'`
|
|
1076
1166
|
});
|
|
1077
1167
|
}
|
|
1168
|
+
if (c.visibility === "owner") {
|
|
1169
|
+
if (c.kind !== "entity") {
|
|
1170
|
+
issues.push({
|
|
1171
|
+
level: "error",
|
|
1172
|
+
message: `${c.name}: owner visibility is entity-only \u2014 a singleton has no owner`
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
if (c.roles && c.roles.length > 0) {
|
|
1176
|
+
issues.push({
|
|
1177
|
+
level: "error",
|
|
1178
|
+
message: `${c.name}: visibility 'owner' takes no roles \u2014 it is scoped by record ownership, not by role`
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
if (c.serverOwned) {
|
|
1182
|
+
issues.push({
|
|
1183
|
+
level: "warning",
|
|
1184
|
+
message: `${c.name}: visibility 'owner' on a serverOwned collection \u2014 every record stays server-owned, so no client ever sees one`
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1078
1188
|
if (c.visibility === "server" && c.roles && c.roles.length > 0) {
|
|
1079
1189
|
issues.push({
|
|
1080
1190
|
level: "error",
|
|
@@ -1236,7 +1346,7 @@ function typeOf(t, at) {
|
|
|
1236
1346
|
}
|
|
1237
1347
|
function entityOptions(e, name) {
|
|
1238
1348
|
const visibility = stringAt(e, "visibility");
|
|
1239
|
-
if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid" && visibility !== "server") {
|
|
1349
|
+
if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid" && visibility !== "server" && visibility !== "owner") {
|
|
1240
1350
|
throw new Error(
|
|
1241
1351
|
`schemaFromCanonical: ${name}: unknown visibility ${JSON.stringify(visibility)}`
|
|
1242
1352
|
);
|
|
@@ -1250,6 +1360,11 @@ function entityOptions(e, name) {
|
|
|
1250
1360
|
const physics = e.physics === void 0 || e.physics === null ? void 0 : physicsFromCanonical(`schemaFromCanonical: ${name}`, e.physics);
|
|
1251
1361
|
const grid = e.grid === void 0 || e.grid === null ? void 0 : gridFromCanonical(name, objectAt(e, "grid"));
|
|
1252
1362
|
const ownership = e.ownership === void 0 || e.ownership === null ? void 0 : ownershipFromCanonical(`schemaFromCanonical: ${name}`, e.ownership);
|
|
1363
|
+
const persist = e.persist === void 0 || e.persist === null ? void 0 : arrayAtRaw(e, "persist").map((f) => {
|
|
1364
|
+
if (typeof f !== "string")
|
|
1365
|
+
throw new Error(`schemaFromCanonical: ${name}: persist must be strings`);
|
|
1366
|
+
return f;
|
|
1367
|
+
});
|
|
1253
1368
|
const serverFields = e.serverFields === void 0 || e.serverFields === null ? void 0 : arrayAtRaw(e, "serverFields").map((f) => {
|
|
1254
1369
|
if (typeof f !== "string")
|
|
1255
1370
|
throw new Error(`schemaFromCanonical: ${name}: serverFields must be strings`);
|
|
@@ -1263,6 +1378,7 @@ function entityOptions(e, name) {
|
|
|
1263
1378
|
...physics !== void 0 ? { physics } : {},
|
|
1264
1379
|
// ---- M6 lane K: stateful relay ----
|
|
1265
1380
|
...e.perPlayer === true ? { perPlayer: true } : {},
|
|
1381
|
+
...persist !== void 0 ? { persist } : {},
|
|
1266
1382
|
...e.clientCreate === true ? { clientCreate: true } : {},
|
|
1267
1383
|
...ownership !== void 0 ? { ownership } : {},
|
|
1268
1384
|
// ---- end M6 lane K ----
|
|
@@ -3348,6 +3464,7 @@ export {
|
|
|
3348
3464
|
ID_MAX_BYTES,
|
|
3349
3465
|
MAX_MESSAGES,
|
|
3350
3466
|
MAX_USER_RPCS,
|
|
3467
|
+
PERSIST_BLOB_BYTES,
|
|
3351
3468
|
PHYSICS_BODY_CHANNELS,
|
|
3352
3469
|
RESERVED_COLLECTION_NAMES,
|
|
3353
3470
|
RESERVED_RPC_NAMES,
|
|
@@ -3401,6 +3518,7 @@ export {
|
|
|
3401
3518
|
markOwner,
|
|
3402
3519
|
markPath,
|
|
3403
3520
|
markRemove,
|
|
3521
|
+
maxEncodedBytes,
|
|
3404
3522
|
mergeDirty,
|
|
3405
3523
|
mergeMask,
|
|
3406
3524
|
normalizeRecord,
|