@irtio/schema 4.0.0 → 4.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/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
- /** Owner-written input fields the step consumes. Declaration order is the canonical order. */
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
- * it never reaches the wire. It is the first-class spelling of what `visibility: 'role'` with an
286
- * empty `roles` list has always done by accident, and it takes no `roles`.
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`.
300
+ */
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`.
287
309
  */
288
- type Visibility = 'all' | 'role' | 'spatial-grid' | 'server';
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. */
@@ -398,8 +421,10 @@ interface EntityOptions {
398
421
  * only when declared, so every schema written before this keeps the hash it had.
399
422
  *
400
423
  * Entity-only, and meaningless on a `serverOwned` collection (where every field already is);
401
- * both are refused. On a `physics` collection the intent list already answers the question, so a
402
- * field named by both is refused too.
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.)
403
428
  *
404
429
  * There is deliberately no dynamic form. A per-field *owner id* would have to ride the wire per
405
430
  * field per record, and the case that needs one is served by a second collection.
@@ -824,12 +849,30 @@ type State<S> = {
824
849
  type RolesOf<O extends EntityOptions> = O extends {
825
850
  roles: readonly (infer R)[];
826
851
  } ? R : never;
827
- /** Is collection with options `O` visible to `Role`? */
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
+ */
828
871
  type VisibleTo<O extends EntityOptions, Role extends string> = O extends {
829
872
  visibility: 'server';
830
873
  } ? false : O extends {
831
874
  visibility: 'role';
832
- } ? [Role] extends [RolesOf<O>] ? true : false : true;
875
+ } ? [Extract<Role, RolesOf<O>>] extends [never] ? false : true : true;
833
876
  type VisibleKeys<S, Role extends string> = {
834
877
  [K in keyof SchemaDefs<S>]: VisibleTo<DefOptions<SchemaDefs<S>[K]>, Role> extends true ? K : never;
835
878
  }[keyof SchemaDefs<S>];
package/dist/index.js CHANGED
@@ -699,7 +699,7 @@ function compileServerFields(name, kind, fields, o) {
699
699
  }
700
700
  if (o.physics) {
701
701
  throw new Error(
702
- `${name}: serverFields says nothing on a physics collection \u2014 the owner of a body-backed record writes its declared intents and nothing else. Take the field out of physics.intents instead`
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());
@@ -1165,6 +1165,26 @@ function validateForDeploy(schema) {
1165
1165
  message: `${c.name}: grid is only valid with visibility 'spatial-grid'`
1166
1166
  });
1167
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
+ }
1168
1188
  if (c.visibility === "server" && c.roles && c.roles.length > 0) {
1169
1189
  issues.push({
1170
1190
  level: "error",
@@ -1326,7 +1346,7 @@ function typeOf(t, at) {
1326
1346
  }
1327
1347
  function entityOptions(e, name) {
1328
1348
  const visibility = stringAt(e, "visibility");
1329
- if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid" && visibility !== "server") {
1349
+ if (visibility !== "all" && visibility !== "role" && visibility !== "spatial-grid" && visibility !== "server" && visibility !== "owner") {
1330
1350
  throw new Error(
1331
1351
  `schemaFromCanonical: ${name}: unknown visibility ${JSON.stringify(visibility)}`
1332
1352
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/schema",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "irtio schema DSL, type inference, canonical hash, codec, change tracking, and schema diff",
5
5
  "license": "MIT",
6
6
  "repository": {