@irtio/schema 4.0.0 → 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 +52 -9
- package/dist/index.js +22 -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`.
|
|
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.
|
|
402
|
-
*
|
|
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
|
-
/**
|
|
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 [
|
|
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
|
|
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
|
);
|