@irtio/server 0.1.0 → 0.3.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
@@ -1,4 +1,75 @@
1
- import { AnySchema, RoleOf, ServerCallProxy, SchemaRpc, BroadcastProxy, State, OwnableKeys, DeepReadonly, SchemaDefs, InstanceOf, Implementations, ServerRpcs } from '@irtio/schema';
1
+ import { AnySchema, PhysicsKeys, DeepReadonly, SchemaDefs, InstanceOf, RoleOf, ServerCallProxy, SchemaRpc, BroadcastProxy, State, OwnableKeys, Implementations, ServerRpcs } from '@irtio/schema';
2
+ import RAPIER from '@dimforge/rapier3d-compat';
3
+
4
+ /**
5
+ * The room-side physics surface (D22): `physics:` on the room config, and `room.physics` inside
6
+ * handlers.
7
+ *
8
+ * Two rules shape this file. **Colliders are code, not config** (D15's spirit): static geometry
9
+ * is built by `setup(world, rapier, room)` and a body's shape comes from a per-collection factory
10
+ * — irtio never invents a schema syntax for a capsule. And **the engine is blessed, not
11
+ * abstracted**: room code gets the real `@dimforge/rapier3d-compat` `World` and the real
12
+ * namespace, so every Rapier tutorial on the internet applies unchanged.
13
+ *
14
+ * The rapier import here is `import type` only — it erases at build time, so `@irtio/server`
15
+ * carries no runtime dependency on it and a non-physics game never installs it (the package is an
16
+ * *optional* peer dependency; `skipLibCheck` keeps the unresolved type quiet when it is absent).
17
+ */
18
+
19
+ type RapierModule = typeof RAPIER;
20
+ type RapierWorld = RAPIER.World;
21
+ type RapierRigidBody = RAPIER.RigidBody;
22
+ type RapierRigidBodyDesc = RAPIER.RigidBodyDesc;
23
+ type RapierColliderDesc = RAPIER.ColliderDesc;
24
+ interface Vector3 {
25
+ readonly x: number;
26
+ readonly y: number;
27
+ readonly z: number;
28
+ }
29
+ /** What a body factory returns: the rigid body, plus the colliders attached to it. */
30
+ interface BodySpec {
31
+ readonly body: RapierRigidBodyDesc;
32
+ /** Attached to the body in order. A body with none is a valid (invisible) point mass. */
33
+ readonly colliders?: readonly RapierColliderDesc[];
34
+ }
35
+ type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf<SchemaDefs<S>[K]>;
36
+ /**
37
+ * `physics.bodies.<collection>` — how one instance becomes a rigid body. Called when the runtime
38
+ * first sees an instance (and again after a wake that had to rebuild the world). The pose and
39
+ * velocity the schema's body fields already hold are applied *after* the desc, so `add()` places
40
+ * the body: a factory only has to describe shape and material.
41
+ */
42
+ type BodyFactories<S extends AnySchema> = {
43
+ readonly [K in PhysicsKeys<S>]: (rapier: RapierModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => BodySpec;
44
+ };
45
+ interface PhysicsConfig<S extends AnySchema> {
46
+ /** The one blessed engine in M2 (D22). */
47
+ readonly engine: 'rapier3d';
48
+ readonly gravity: Vector3;
49
+ /** Seconds per world step. Defaults to the tick interval; one step per tick, no substeps. */
50
+ readonly timestep?: number;
51
+ /**
52
+ * Static geometry, joints, world tuning. Runs once per world — at room create, and on a wake
53
+ * that had to rebuild (a pre-physics snapshot, or a schema migration). It does **not** run on
54
+ * an ordinary wake: the world came back from the snapshot with its static colliders in it.
55
+ */
56
+ setup?(world: RapierWorld, rapier: RapierModule, room: Room<S>): void;
57
+ readonly bodies: BodyFactories<S>;
58
+ }
59
+ /** `room.physics` — what handlers use to turn intents into forces. */
60
+ interface PhysicsRoomApi<S extends AnySchema> {
61
+ /** The `@dimforge/rapier3d-compat` namespace: descs, shapes, enums, `QueryFilterFlags`, … */
62
+ readonly rapier: RapierModule;
63
+ /** The live world. Ray casts, static colliders added later, joints. */
64
+ readonly world: RapierWorld;
65
+ /** Seconds per step (`world.timestep`). */
66
+ readonly timestep: number;
67
+ /**
68
+ * The rigid body backing an instance. Created on demand, so an entity added earlier in this
69
+ * same handler already has one. `undefined` only when the instance does not exist.
70
+ */
71
+ body(collection: PhysicsKeys<S> & string, id: string): RapierRigidBody | undefined;
72
+ }
2
73
 
3
74
  /**
4
75
  * `@irtio/server` — what `irtio/room.ts` imports. `defineRoom(schema, config)` validates the
@@ -44,9 +115,77 @@ interface Room<S extends AnySchema = AnySchema> {
44
115
  call(clientId: string): ServerCallProxy<SchemaRpc<S>>;
45
116
  /** Server → client void RPCs to every connected client. */
46
117
  readonly broadcast: BroadcastProxy<SchemaRpc<S>>;
118
+ /**
119
+ * D24: write a retained, addressable snapshot generation of this room right now and resolve
120
+ * with its save id. Like every promise-returning room API, the continuation runs as its own
121
+ * event between ticks — see `PlayerKv` for what that costs you.
122
+ *
123
+ * A failed save rejects and changes nothing: the room's live hibernation snapshot is never
124
+ * touched by `save()`, so a room can never be left worse off than if it had not saved.
125
+ */
126
+ save(): Promise<string>;
127
+ /** D25: per-player key/value storage that outlives the room. */
128
+ readonly kv: PlayerKv;
129
+ /**
130
+ * D26: arm a durable alarm named `name` to fire at or after `atMs`, **on `room.now`'s clock** —
131
+ * `room.alarm('round', room.now + 15_000)` is the shape to write. (The host translates that to
132
+ * wall clock on the way out, because an alarm outlives the process `room.now` is monotonic
133
+ * within; a room never has to think about it, but it does have to compute `atMs` from
134
+ * `room.now` rather than from anything else.) Arming a name that is already armed replaces its
135
+ * due time, which is what makes re-arming from inside an alarm handler the way to build a
136
+ * repeating timer.
137
+ *
138
+ * The handler is the entry for `name` in the room definition's `alarms` map — arming a name
139
+ * with no handler is a warning and does nothing. Alarms survive hibernation *and* a tenant
140
+ * stop; the guaranteed resolution is seconds, and an alarm fires at-or-after `atMs`, never
141
+ * before. See the room reference at irt.io/docs/reference/room for the worst-case lateness a
142
+ * game should design for.
143
+ */
144
+ alarm(name: string, atMs: number): void;
145
+ /** D26: cancel the alarm named `name`. Cancelling an unarmed name is a no-op. */
146
+ cancelAlarm(name: string): void;
147
+ /**
148
+ * D22: the Rapier world and the bodies behind physics entities. Reading it in a room whose
149
+ * config declares no `physics:` throws — that is a mistake worth naming at the call site.
150
+ */
151
+ readonly physics: PhysicsRoomApi<S>;
152
+ }
153
+ /**
154
+ * D25: player key/value storage, scoped to the project and keyed by a player identity the room
155
+ * chooses. It is the first state irtio keeps that is *not* room state: it lives in the control
156
+ * plane, it outlives the room, and another room of the same project reads back what this one
157
+ * wrote.
158
+ *
159
+ * **Every method returns a Promise whose continuation runs as its own event between ticks** —
160
+ * exactly the shape `room.call(clientId)` already has. Room handlers are synchronous and there
161
+ * is no way to block one on I/O, so a KV read is *not* available in the handler that asked for
162
+ * it. State mutated in the continuation is tracked and flushed normally.
163
+ *
164
+ * Confinement is per **project**, not per player: a room may pass any `playerId` and can read
165
+ * another player's row on purpose. A cross-project read is refused by the gateway.
166
+ *
167
+ * Values are strings (JSON-encode structured data yourself), at most 16 KiB of UTF-8. Rejections
168
+ * name the limit they hit — `E_KV_VALUE_TOO_LARGE`, `E_KV_TOO_MANY_KEYS`,
169
+ * `E_KV_PROJECT_FULL`, `E_KV_BAD_KEY`, `E_KV_UNAVAILABLE` — and the room keeps running.
170
+ */
171
+ interface PlayerKv {
172
+ /** Resolves `undefined` for a key that was never written — a miss is not an error. */
173
+ get(playerId: string, key: string): Promise<string | undefined>;
174
+ set(playerId: string, key: string, value: string): Promise<void>;
175
+ /** Idempotent: deleting a key that is not there still resolves. */
176
+ delete(playerId: string, key: string): Promise<void>;
47
177
  }
48
178
  interface Ctx<S extends AnySchema = AnySchema> {
49
179
  readonly clientId: string;
180
+ /**
181
+ * D25: the identity `room.kv` is meant to be keyed by. For a **key join** it is the
182
+ * resume-token identity — the client id a resume token carries across a reconnect — so it is
183
+ * stable only as long as that token is, and a player who comes back after it expires is a new
184
+ * `playerId`. For a **JWT join** (D27, week 13) it is the durable `"<iss>:<sub>"` the verified
185
+ * token asserted, namespaced by issuer so two issuers can never collide. Adopting JWT starts a
186
+ * player's storage over: old client-id-keyed rows stay where they are, by decision.
187
+ */
188
+ readonly playerId: string;
50
189
  readonly role: RoleOf<S> & string;
51
190
  readonly name: string;
52
191
  /** Server tick this join/call/write is applied at. */
@@ -79,6 +218,21 @@ interface RoomConfigBase<S extends AnySchema> {
79
218
  readonly reconnectGraceMs?: number;
80
219
  /** Default 64. */
81
220
  readonly maxClients?: number;
221
+ /**
222
+ * D22: run a Rapier world on the fixed timestep. Tick mode only. Every collection whose schema
223
+ * declares `physics` needs an entry in `bodies`, and vice versa.
224
+ */
225
+ readonly physics?: PhysicsConfig<S>;
226
+ /**
227
+ * D26: durable alarm handlers, by name. `room.alarm(name, atMs)` arms one; the entry here runs
228
+ * when it fires, as its own event between ticks — the same scheduling class as an RPC, so a
229
+ * tick-mode room never runs a half-alarmed `tick`.
230
+ *
231
+ * Handlers live in the **config**, not in a runtime registration, precisely because an alarm
232
+ * outlives the room: a callback handed to the runtime would be gone after a hibernation, and
233
+ * surviving hibernation is the entire point of D26. This map is code, so it is always there.
234
+ */
235
+ readonly alarms?: Readonly<Record<string, (state: State<S>, room: Room<S>) => void>>;
82
236
  onCreate?(state: State<S>, room: Room<S>): void;
83
237
  onJoin?(state: State<S>, ctx: Ctx<S>): void;
84
238
  onLeave?(state: State<S>, ctx: Ctx<S>, reason: LeaveReason): void;
@@ -126,4 +280,4 @@ declare function defineRoom<S extends AnySchema>(schema: S, config: RoomConfig<S
126
280
  /** Type guard for what a bundle's default export should be. */
127
281
  declare function isRoomDefinition(v: unknown): v is RoomDefinition;
128
282
 
129
- export { type ClientInfo, type Ctx, DEFAULTS, type LeaveReason, type MessageTarget, ROOM_DEFINITION_VERSION, type ResolvedRoomConfig, type Room, type RoomConfig, type RoomConfigBase, type RoomDefinition, type RoomMode, type RpcImplementations, type TimerHandle, type Validators, defineRoom, isRoomDefinition };
283
+ export { type BodyFactories, type BodySpec, type ClientInfo, type Ctx, DEFAULTS, type LeaveReason, type MessageTarget, type PhysicsConfig, type PhysicsRoomApi, type PlayerKv, ROOM_DEFINITION_VERSION, type RapierColliderDesc, type RapierModule, type RapierRigidBody, type RapierRigidBodyDesc, type RapierWorld, type ResolvedRoomConfig, type Room, type RoomConfig, type RoomConfigBase, type RoomDefinition, type RoomMode, type RpcImplementations, type TimerHandle, type Validators, type Vector3, defineRoom, isRoomDefinition };
package/dist/index.js CHANGED
@@ -62,6 +62,7 @@ function defineRoom(schema, config) {
62
62
  throw new Error(`defineRoom: validate.${k} must name an instance-owned entity collection`);
63
63
  }
64
64
  }
65
+ checkPhysics(schema, config, mode);
65
66
  const resolved = {
66
67
  ...config,
67
68
  mode,
@@ -78,6 +79,56 @@ function defineRoom(schema, config) {
78
79
  config: Object.freeze(resolved)
79
80
  });
80
81
  }
82
+ function checkPhysics(schema, config, mode) {
83
+ const declared = schema.collections.filter((c) => c.physics !== void 0).map((c) => c.name);
84
+ const physics = config.physics;
85
+ if (!physics) {
86
+ if (declared.length > 0) {
87
+ throw new Error(
88
+ `defineRoom: ${declared.join(", ")} declare${declared.length === 1 ? "s" : ""} physics in the schema, but the room config has no physics: { engine: 'rapier3d', gravity, bodies } \u2014 body fields would never move`
89
+ );
90
+ }
91
+ return;
92
+ }
93
+ if (declared.length === 0) {
94
+ throw new Error(
95
+ "defineRoom: physics: is configured but no collection declares physics in its schema \u2014 add entity(fields, { physics: { body: { x: 'x', \u2026 } } }) to the collection the world simulates"
96
+ );
97
+ }
98
+ if (mode !== "tick") {
99
+ throw new Error(
100
+ "defineRoom: physics needs mode: 'tick' \u2014 the world steps on the fixed timestep, and an event-mode room has no timestep to step on"
101
+ );
102
+ }
103
+ if (physics.engine !== "rapier3d") {
104
+ throw new Error(
105
+ `defineRoom: physics.engine must be 'rapier3d' (got ${JSON.stringify(physics.engine)}); matter.js is not in M2`
106
+ );
107
+ }
108
+ const g = physics.gravity;
109
+ if (!g || typeof g !== "object" || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !Number.isFinite(g.z)) {
110
+ throw new Error("defineRoom: physics.gravity must be { x, y, z } finite numbers");
111
+ }
112
+ if (physics.timestep !== void 0 && (!Number.isFinite(physics.timestep) || physics.timestep <= 0)) {
113
+ throw new Error(
114
+ `defineRoom: physics.timestep must be a positive number of seconds, got ${String(physics.timestep)}`
115
+ );
116
+ }
117
+ assertSyncHandler(physics.setup, "physics.setup");
118
+ const bodies = physics.bodies ?? {};
119
+ for (const [k, fn] of Object.entries(bodies)) assertSyncHandler(fn, `physics.bodies.${k}`);
120
+ const given = Object.keys(bodies).sort();
121
+ const expected = [...declared].sort();
122
+ const missing = expected.filter((n) => !given.includes(n));
123
+ const extra = given.filter((n) => !expected.includes(n));
124
+ if (missing.length || extra.length) {
125
+ const parts = [];
126
+ if (missing.length) parts.push(`missing body factories: ${missing.join(", ")}`);
127
+ if (extra.length)
128
+ parts.push(`physics.bodies names collections without schema physics: ${extra.join(", ")}`);
129
+ throw new Error(`defineRoom: ${parts.join("; ")}`);
130
+ }
131
+ }
81
132
  function assertSyncHandler(fn, name) {
82
133
  if (fn === void 0) return;
83
134
  if (typeof fn !== "function") throw new Error(`defineRoom: ${name} must be a function`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/server",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "irtio room-file API: defineRoom, handler and ctx types",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -20,7 +20,18 @@
20
20
  "dist"
21
21
  ],
22
22
  "dependencies": {
23
- "@irtio/schema": "0.1.0"
23
+ "@irtio/schema": "0.3.0"
24
+ },
25
+ "peerDependencies": {
26
+ "@dimforge/rapier3d-compat": ">=0.20.0"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "@dimforge/rapier3d-compat": {
30
+ "optional": true
31
+ }
32
+ },
33
+ "devDependencies": {
34
+ "@dimforge/rapier3d-compat": "0.20.0"
24
35
  },
25
36
  "scripts": {
26
37
  "build": "tsup",