@irtio/server 0.5.2 → 0.7.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,5 +1,181 @@
1
- import { AnySchema, PhysicsKeys, DeepReadonly, SchemaDefs, InstanceOf, RoleOf, ServerCallProxy, SchemaRpc, BroadcastProxy, State, OwnableKeys, Implementations, ServerRpcs } from '@irtio/schema';
1
+ import { AnySchema, RoleOf, VisibleKeys, SchemaDefs, EntityDef, ReadonlyCollection, DeepReadonly, InferFields, Owned, SingletonDef, PhysicsKeys, InstanceOf, State, ServerMessageChannels, ServerCallProxy, SchemaRpc, BroadcastProxy, OwnableKeys, MessageNames, MessageValue, Implementations, ServerRpcs } from '@irtio/schema';
2
2
  import RAPIER from '@dimforge/rapier3d-compat';
3
+ import * as MATTER from 'matter-js';
4
+
5
+ /**
6
+ * D44: scripted NPCs — the room-side surface.
7
+ *
8
+ * **One runtime, two hats.** An NPC is the bot runtime (`@irtio/bots`) wearing its server hat: the
9
+ * script it runs is the same shape a simulation script has, and the session it drives is an
10
+ * ordinary client session at the protocol level. Nothing here executes; this file is the types
11
+ * room code writes against, plus the steering maths so an author never writes a normalize.
12
+ *
13
+ * ## Where the script actually runs
14
+ *
15
+ * Not in the room worker. `spawnNPC` is called from room code, which runs inside the worker
16
+ * sandbox, and a script running there would sit on the authoritative side of the seam — it could
17
+ * touch room state without a session, which is exactly what "a real client session" forbids. So
18
+ * the brain is declared as a **named entry in the room definition's `npcs` map** and `spawnNPC`
19
+ * names it. The supervisor already imports the room bundle (it reads the config out of it), so the
20
+ * function is right there on the other side of the boundary, and the session it drives enters
21
+ * beside the WebSocket accept.
22
+ *
23
+ * That is why `brain.script` is a name and not a function: a function would have to cross a
24
+ * `postMessage`, and functions do not.
25
+ */
26
+
27
+ /**
28
+ * The client-side state shape, mirrored here so room code can name it without importing
29
+ * `@irtio/client` — which is not on the room bundle's import menu, and should not be: a room file
30
+ * that could import the SDK could open a socket.
31
+ *
32
+ * It is character for character `@irtio/client`'s `ClientState`, and both are built out of
33
+ * `@irtio/schema` exports only, so the real object stays structurally assignable to this one. The
34
+ * load-bearing part is the ownership split: instances of an instance-owned collection are
35
+ * **writable** (whether this session owns a given instance is a runtime fact the compiler cannot
36
+ * decide), while `serverOwned` collections and singletons are read-only.
37
+ */
38
+ type NpcCollection<T> = ReadonlyCollection<T> & {
39
+ readonly [id: string]: T | undefined;
40
+ };
41
+ type NpcState<S, Role extends string = RoleOf<S> & string> = {
42
+ [K in VisibleKeys<S, Role>]: SchemaDefs<S>[K] extends EntityDef<infer F, infer O> ? O extends {
43
+ serverOwned: true;
44
+ } ? NpcCollection<DeepReadonly<InferFields<F>>> : NpcCollection<Owned<InferFields<F>>> : SchemaDefs<S>[K] extends SingletonDef<infer F, any> ? DeepReadonly<InferFields<F>> : never;
45
+ };
46
+ /**
47
+ * What an NPC script sees: the client-side room, structurally. The real object is
48
+ * `@irtio/client`'s `Room<S>` — the supervisor hands the script the very object a browser gets —
49
+ * and this is the subset room code can name without importing the client SDK, which is not on the
50
+ * room bundle's import menu.
51
+ *
52
+ * Reads go through `state` exactly as they do in a browser, and so do writes: an NPC owns its
53
+ * entities the way a client owns them, and the server validates them the way it validates a
54
+ * player's.
55
+ */
56
+ interface NpcRoom<S extends AnySchema> {
57
+ /** This NPC's client id. */
58
+ readonly me: string;
59
+ readonly id: string;
60
+ /** The last server tick this session saw. */
61
+ readonly tick: number;
62
+ readonly state: NpcState<S, RoleOf<S> & string>;
63
+ /** Sends pending owned writes now instead of at the next flush window. */
64
+ flush(): void;
65
+ }
66
+ /**
67
+ * The object an NPC script receives. `@irtio/bots`' `Bot<S>` satisfies it structurally, because it
68
+ * *is* a `Bot<S>`: the members left out here are the ones that only mean something inside a
69
+ * simulation report (traces, injected network conditions, shot records), and an NPC has no report.
70
+ */
71
+ interface Npc<S extends AnySchema> {
72
+ /** 0 for every NPC: each one is its own single-bot runner. */
73
+ readonly index: number;
74
+ readonly room: NpcRoom<S>;
75
+ /** The client id, repeated because scripts reach for it constantly. */
76
+ readonly id: string;
77
+ readonly role: string;
78
+ /** Seeded per NPC, so a spawn order replays the same way. */
79
+ random(): number;
80
+ /** Sleeps, or returns immediately once the NPC has been asked to stop. */
81
+ wait(ms: number): Promise<void>;
82
+ /** Polls; never a fixed sleep. Returns early once the NPC is stopped. */
83
+ until(predicate: () => boolean | Promise<boolean>, options?: {
84
+ label?: string;
85
+ timeoutMs?: number;
86
+ everyMs?: number;
87
+ }): Promise<void>;
88
+ /** Asks this script to wind down. `despawn()` sets it. */
89
+ stop(): void;
90
+ readonly stopped: boolean;
91
+ }
92
+ /** An entry in the room definition's `npcs` map. Async, and expected to loop until `stopped`. */
93
+ type NpcScript<S extends AnySchema> = (npc: Npc<S>) => void | Promise<void>;
94
+ /**
95
+ * `config.brain` (D44). One shape in v1, and the discriminant exists so act two's LLM and voiced
96
+ * brains have somewhere to land without a breaking change. Anything other than `'script'` is
97
+ * refused at the call, by name.
98
+ */
99
+ interface NpcScriptBrain {
100
+ readonly kind: 'script';
101
+ /** A key of the room definition's `npcs` map. */
102
+ readonly script: string;
103
+ }
104
+ interface NpcConfig {
105
+ /** Presence name, as a player's. Defaults to the script name. */
106
+ readonly name?: string;
107
+ /** Joins with this role, as a player does. */
108
+ readonly role?: string;
109
+ readonly brain: NpcScriptBrain;
110
+ /** Seeds `npc.random()`. Defaults to a draw from the room's own seeded rng, so runs replay. */
111
+ readonly seed?: number;
112
+ }
113
+ /** What `room.spawnNPC` hands back. */
114
+ interface NpcHandle {
115
+ /** The client id this NPC will hold. Present immediately; the session settles a tick or two later. */
116
+ readonly clientId: string;
117
+ /** Stops the script and closes the session. Idempotent. */
118
+ despawn(): void;
119
+ }
120
+ /**
121
+ * The steering primitives, in the plane. They are pure functions returning a **velocity**, so a
122
+ * script's whole movement step is `p.vx = v.x` — there is no controller object to own, nothing to
123
+ * tick, and nothing that has to survive a hibernation.
124
+ *
125
+ * Two dimensions on purpose. A 3D game steers on the ground plane and keeps its own vertical, and
126
+ * a Vec3 variant of each of these would double the surface to serve the case that already works.
127
+ */
128
+ interface Vec2 {
129
+ readonly x: number;
130
+ readonly y: number;
131
+ }
132
+ /** Straight at the target, at full speed. */
133
+ declare function seek(from: Vec2, to: Vec2, speed: number): Vec2;
134
+ /** Straight away from it. */
135
+ declare function flee(from: Vec2, threat: Vec2, speed: number): Vec2;
136
+ /**
137
+ * Seek, but easing to a stop inside `slowRadius`. Without it a chaser at speed orbits its target
138
+ * forever, which reads as a bug in the game and is a bug in the steering.
139
+ */
140
+ declare function arrive(from: Vec2, to: Vec2, speed: number, slowRadius: number): Vec2;
141
+ /** The `wander` state a script keeps between steps: one angle, in radians. */
142
+ interface WanderState {
143
+ angle: number;
144
+ }
145
+ /** A fresh wander heading. */
146
+ declare function newWander(random: () => number): WanderState;
147
+ /**
148
+ * A random walk that looks like a walk: the heading turns by at most `turn` radians per step
149
+ * rather than being redrawn, so the path curves instead of jittering on the spot. Mutates `state`.
150
+ */
151
+ declare function wander(state: WanderState, speed: number, turn: number, random: () => number): Vec2;
152
+ /** What `patrol` returns: where to go now, and the waypoint index to keep for the next step. */
153
+ interface PatrolStep {
154
+ readonly velocity: Vec2;
155
+ readonly index: number;
156
+ }
157
+ /**
158
+ * Walks a waypoint ring. `index` is the waypoint being walked to; feed back the one this returns.
159
+ * An empty list stands still rather than throwing, because a patrol with no waypoints is a level
160
+ * that has not been finished, not a crash worth taking the room down for.
161
+ */
162
+ declare function patrol(from: Vec2, waypoints: readonly Vec2[], index: number, speed: number, reachedRadius: number): PatrolStep;
163
+ /** One named behaviour in a priority list. */
164
+ interface Behaviour {
165
+ readonly name: string;
166
+ /** Chosen when this returns true and nothing earlier did. */
167
+ when(): boolean;
168
+ }
169
+ /**
170
+ * A priority selector: the first behaviour whose `when()` holds, or `undefined`.
171
+ *
172
+ * This is deliberately a helper and not an engine. "Flee if hurt, else chase if close, else
173
+ * patrol" is what NPC scripts actually write, it is four lines of `if` without this, and a
174
+ * behaviour tree with decorators and blackboards would be a second product hiding inside the
175
+ * first one. The value here is the *name*: a script can log or publish which behaviour is running
176
+ * without keeping a parallel string.
177
+ */
178
+ declare function choose<B extends Behaviour>(behaviours: readonly B[]): B | undefined;
3
179
 
4
180
  /**
5
181
  * The room-side physics surface (D22): `physics:` on the room config, and `room.physics` inside
@@ -19,8 +195,17 @@ import RAPIER from '@dimforge/rapier3d-compat';
19
195
  type RapierModule = typeof RAPIER;
20
196
  type RapierWorld = RAPIER.World;
21
197
  type RapierRigidBody = RAPIER.RigidBody;
198
+ type RapierCollider = RAPIER.Collider;
22
199
  type RapierRigidBodyDesc = RAPIER.RigidBodyDesc;
23
200
  type RapierColliderDesc = RAPIER.ColliderDesc;
201
+ type MatterModule = typeof MATTER;
202
+ type MatterEngine = MATTER.Engine;
203
+ type MatterBody = MATTER.Body;
204
+ type MatterConstraint = MATTER.Constraint;
205
+ interface Vector2 {
206
+ readonly x: number;
207
+ readonly y: number;
208
+ }
24
209
  interface Vector3 {
25
210
  readonly x: number;
26
211
  readonly y: number;
@@ -32,6 +217,16 @@ interface BodySpec {
32
217
  /** Attached to the body in order. A body with none is a valid (invisible) point mass. */
33
218
  readonly colliders?: readonly RapierColliderDesc[];
34
219
  }
220
+ /**
221
+ * D45: what a matter2d body factory returns. matter.js has no separate collider concept — a body
222
+ * *is* its geometry — so this is one body plus any constraints pinning it, and there is no second
223
+ * shape list to keep in step with the first.
224
+ */
225
+ interface Matter2dBodySpec {
226
+ readonly body: MatterBody;
227
+ /** Added to the world with the body, and removed with it. */
228
+ readonly constraints?: readonly MatterConstraint[];
229
+ }
35
230
  type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf<SchemaDefs<S>[K]>;
36
231
  /**
37
232
  * `physics.bodies.<collection>` — how one instance becomes a rigid body. Called when the runtime
@@ -42,8 +237,12 @@ type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf
42
237
  type BodyFactories<S extends AnySchema> = {
43
238
  readonly [K in PhysicsKeys<S>]: (rapier: RapierModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => BodySpec;
44
239
  };
45
- interface PhysicsConfig<S extends AnySchema> {
46
- /** The one blessed engine in M2 (D22). */
240
+ /** `physics.bodies.<collection>` for matter2d. Same lifecycle as {@link BodyFactories}. */
241
+ type Matter2dBodyFactories<S extends AnySchema> = {
242
+ readonly [K in PhysicsKeys<S>]: (matter: MatterModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => Matter2dBodySpec;
243
+ };
244
+ interface RapierPhysicsConfig<S extends AnySchema> {
245
+ /** The 3D engine (D22). */
47
246
  readonly engine: 'rapier3d';
48
247
  readonly gravity: Vector3;
49
248
  /** Seconds per world step. Defaults to the tick interval; one step per tick, no substeps. */
@@ -55,8 +254,126 @@ interface PhysicsConfig<S extends AnySchema> {
55
254
  */
56
255
  setup?(world: RapierWorld, rapier: RapierModule, room: Room<S>): void;
57
256
  readonly bodies: BodyFactories<S>;
257
+ /** D72: how many ticks of body poses to keep for `room.rewind`. See {@link HISTORY_DOC}. */
258
+ readonly history?: number;
259
+ }
260
+ /** D72: the deepest history a room may declare, in ticks. One second at the maximum tick rate. */
261
+ declare const HISTORY_MAX_TICKS = 240;
262
+ /**
263
+ * D72: what `room.rewind(tick, fn)` hands `fn`.
264
+ *
265
+ * Exactly one of `rapier` and `matter` is present, decided by the room's engine.
266
+ *
267
+ * What a rewound query **can** see: every tracked body's pose at that tick, and the world's static
268
+ * geometry as it stands now. What it **cannot**: colliders as they were then (shapes are not
269
+ * historied, only poses), joints and contacts, bodies created since that tick, and anything the
270
+ * caller had not been told about yet.
271
+ */
272
+ interface RewindView {
273
+ /** The tick actually answered from, after clamping. */
274
+ readonly tick: number;
275
+ /** The tick that was asked for, unclamped, so a room can log or refuse the difference. */
276
+ readonly requested: number;
277
+ /**
278
+ * `true` when `requested` fell outside the buffer and was pulled to its nearest edge. Not an
279
+ * error: the oldest pose the room still holds is the honest answer to "further back than I
280
+ * remember", and a room that would rather refuse a clamped answer can read this and do so.
281
+ */
282
+ readonly clamped: boolean;
283
+ /** rapier3d rooms: a scratch world to `castRay` / `castShape` / `intersectionsWithShape` on. */
284
+ readonly rapier?: {
285
+ readonly world: RapierWorld;
286
+ /** Which entity a collider in the scratch world belongs to; `undefined` for static geometry. */
287
+ who(collider: RapierCollider): {
288
+ readonly collection: string;
289
+ readonly id: string;
290
+ } | undefined;
291
+ };
292
+ /** matter2d rooms: the body array `Matter.Query.ray/point/region/collides` takes. */
293
+ readonly matter?: {
294
+ readonly bodies: readonly MatterBody[];
295
+ /** Which entity a body in the array belongs to; `undefined` for the world's static bodies. */
296
+ who(body: MatterBody): {
297
+ readonly collection: string;
298
+ readonly id: string;
299
+ } | undefined;
300
+ };
301
+ }
302
+ /**
303
+ * D57: the matter-flavoured counterpart to `@irtio/client`'s `ClientIntent2dHook` — one step of
304
+ * steering for one body, from the fields a client may write.
305
+ *
306
+ * A room's `tick()` already calls a per-collection steering function of exactly this shape; see
307
+ * `games/dive/irtio/world.ts`'s `intents` and `room.ts`'s `tick()`. Naming the type here, and
308
+ * letting the config carry the map, is what lets a client-side matter2d predictor pull the very
309
+ * same functions out of the shared world module and replay them, instead of the two sides wiring
310
+ * up their own imports and drifting.
311
+ *
312
+ * Method syntax, so a hook written against its own instance type is accepted: the values really
313
+ * passed are the schema's records for that collection.
314
+ */
315
+ type Matter2dIntentHook = {
316
+ hook(body: MatterBody, instance: Record<string, unknown>, matter: MatterModule, engine: MatterEngine, timestep: number): void;
317
+ }['hook'];
318
+ /**
319
+ * D45: the second blessed engine. `matter-js` is 2D, pure JavaScript, and blessed on the same
320
+ * terms Rapier is — room code gets the real `Matter` namespace and the real `Engine`, so every
321
+ * matter.js tutorial applies unchanged, and irtio invents no syntax for a shape.
322
+ *
323
+ * The reason to have it at all is that the documented way to hold Rapier in a plane has a trap in
324
+ * it (locking one translation axis and the two rotations across that plane silently removes
325
+ * friction on box-shaped bodies), and a 2D engine makes the whole recipe unnecessary.
326
+ *
327
+ * Its determinism story is weaker than Rapier's and the measurement is in the docs rather than an
328
+ * adjective. See the physics reference for what that means for client-side prediction.
329
+ */
330
+ interface Matter2dPhysicsConfig<S extends AnySchema> {
331
+ readonly engine: 'matter2d';
332
+ /** In the plane. matter.js's own convention is y-down; irtio does not flip it for you. */
333
+ readonly gravity: Vector2;
334
+ /** Seconds per world step. Defaults to the tick interval; one step per tick, no substeps. */
335
+ readonly timestep?: number;
336
+ /** Static geometry, constraints, world tuning. Same lifecycle rules as Rapier's `setup`. */
337
+ setup?(engine: MatterEngine, matter: MatterModule, room: Room<S>): void;
338
+ readonly bodies: Matter2dBodyFactories<S>;
339
+ /**
340
+ * D57: the shared per-collection steering functions, alongside `bodies`.
341
+ *
342
+ * **The runtime does not call these.** Declaring a hook here does not make the room run it; the
343
+ * room's `tick()` runs it, exactly as it did before this field existed, and `RapierPhysicsConfig`
344
+ * has no equivalent. What the field buys is a single naming: `joinRoom({ physics2d: { intents } })`
345
+ * on the client and `physics: { intents }` here point at the same function in the same shared
346
+ * module, so "same code both sides" is checkable rather than a convention.
347
+ *
348
+ * Partial, unlike `bodies`: every physics collection needs a shape, and only the ones whose
349
+ * schema declares `intents` can be steered. `defineRoom` refuses a key that names a collection
350
+ * with no declared intents, which is the check the type cannot make.
351
+ */
352
+ readonly intents?: Readonly<Partial<Record<PhysicsKeys<S> & string, Matter2dIntentHook>>>;
353
+ /** D72: how many ticks of body poses to keep for `room.rewind`. See {@link HISTORY_DOC}. */
354
+ readonly history?: number;
355
+ }
356
+ type PhysicsConfig<S extends AnySchema> = RapierPhysicsConfig<S> | Matter2dPhysicsConfig<S>;
357
+ /**
358
+ * `room.physics2d` in a matter2d room.
359
+ *
360
+ * Two accessors rather than one narrowing union, and the reason is compatibility rather than
361
+ * taste: `room.physics` is D22's surface and every Rapier room in existence reads through it
362
+ * without a narrowing step. A union would have made all of them stop compiling to serve a room
363
+ * that has not been written yet. Each accessor throws a sentence naming the other when the room's
364
+ * engine is the other one, so the mistake costs one line of reading rather than a debugging
365
+ * session.
366
+ */
367
+ interface Matter2dRoomApi<S extends AnySchema> {
368
+ /** The `matter-js` namespace: `Bodies`, `Body`, `Composite`, `Constraint`, `Vector`, … */
369
+ readonly matter: MatterModule;
370
+ /** The live `Matter.Engine`. `engine.world` is the composite everything lives in. */
371
+ readonly engine: MatterEngine;
372
+ /** Seconds per step. */
373
+ readonly timestep: number;
374
+ body(collection: PhysicsKeys<S> & string, id: string): MatterBody | undefined;
58
375
  }
59
- /** `room.physics` — what handlers use to turn intents into forces. */
376
+ /** `room.physics` — what handlers use to turn intents into forces (rapier3d rooms). */
60
377
  interface PhysicsRoomApi<S extends AnySchema> {
61
378
  /** The `@dimforge/rapier3d-compat` namespace: descs, shapes, enums, `QueryFilterFlags`, … */
62
379
  readonly rapier: RapierModule;
@@ -81,11 +398,24 @@ interface PhysicsRoomApi<S extends AnySchema> {
81
398
  type MessageTarget = 'all' | string | {
82
399
  readonly role: string;
83
400
  };
401
+ /**
402
+ * D70: what `onMessage`'s sixth argument carries for a typed message, and nothing at all for a
403
+ * raw one. A discriminated union over the declared names, so `if (typed?.name === 'emote')`
404
+ * narrows `typed.value` to that shape.
405
+ */
406
+ type TypedMessage<S> = {
407
+ [K in MessageNames<S>]: {
408
+ readonly name: K;
409
+ readonly value: MessageValue<S, K>;
410
+ };
411
+ }[MessageNames<S>];
84
412
  interface ClientInfo {
85
413
  readonly clientId: string;
86
414
  readonly role: string;
87
415
  readonly name: string;
88
416
  readonly connected: boolean;
417
+ /** D44: a scripted NPC spawned by this room, rather than a session dialled from outside. */
418
+ readonly npc: boolean;
89
419
  }
90
420
  type TimerHandle = number;
91
421
  interface Room<S extends AnySchema = AnySchema> {
@@ -100,6 +430,16 @@ interface Room<S extends AnySchema = AnySchema> {
100
430
  /** Seeded, recorded for replay. */
101
431
  random(): number;
102
432
  send(target: MessageTarget, bytes: Uint8Array): void;
433
+ /**
434
+ * D70: `room.messages.<name>.send(target, value)` — one of the shapes the schema declares,
435
+ * encoded for you. `{}` on a schema that declares none, so game code can be written before the
436
+ * schema has anything to say.
437
+ *
438
+ * There is no `on` here: a room observes messages through `onMessage`, which is also where it
439
+ * can drop one. A second way in would be a second place to see a message the room had already
440
+ * declined.
441
+ */
442
+ readonly messages: ServerMessageChannels<S, MessageTarget>;
103
443
  setRole(clientId: string, role: RoleOf<S> & string): void;
104
444
  kick(clientId: string, reason?: string): void;
105
445
  close(reason?: string): void;
@@ -126,6 +466,14 @@ interface Room<S extends AnySchema = AnySchema> {
126
466
  save(): Promise<string>;
127
467
  /** D25: per-player key/value storage that outlives the room. */
128
468
  readonly kv: PlayerKv;
469
+ /** D53: post scores to this project's leaderboards. Server-authoritative by construction —
470
+ * this is the only path a score can take. */
471
+ readonly leaderboard: RoomLeaderboard;
472
+ /** D63: report match results to this project's skill ratings. Like the leaderboard, this is
473
+ * the only path a rating can move on, and a client cannot reach it. */
474
+ readonly ratings: RoomRatings;
475
+ /** D63-e: open or close this room to backfill from the matchmaker, right now. */
476
+ readonly backfill: RoomBackfill;
129
477
  /**
130
478
  * D26: arm a durable alarm named `name` to fire at or after `atMs`, **on `room.now`'s clock** —
131
479
  * `room.alarm('round', room.now + 15_000)` is the shape to write. (The host translates that to
@@ -149,6 +497,159 @@ interface Room<S extends AnySchema = AnySchema> {
149
497
  * config declares no `physics:` throws — that is a mistake worth naming at the call site.
150
498
  */
151
499
  readonly physics: PhysicsRoomApi<S>;
500
+ /**
501
+ * D45: the matter.js engine and the bodies behind physics entities, in a room whose config
502
+ * declares `engine: 'matter2d'`. Reading it in a Rapier room throws and says so, and so does
503
+ * reading `room.physics` here.
504
+ */
505
+ readonly physics2d: Matter2dRoomApi<S>;
506
+ /**
507
+ * D72: run `fn` against the world as it stood at `tick`.
508
+ *
509
+ * A shot fired at 200 ms of latency was aimed at where the target was drawn on the shooter's
510
+ * screen, a round trip ago. `ctx.clientTick` says which tick that was; this puts every tracked
511
+ * body back where it was then, on a scratch world, and lets the room's own query answer there:
512
+ *
513
+ * ```ts
514
+ * rpc: {
515
+ * fire(state, { dx, dy, dz }, ctx) {
516
+ * const { rapier, world } = ctx.room.physics;
517
+ * const from = ctx.room.physics.body('players', ctx.clientId)?.translation();
518
+ * if (!from) return { hit: false };
519
+ * return ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => {
520
+ * const hit = past.rapier?.world.castRay(new rapier.Ray(from, { x: dx, y: dy, z: dz }), 100, true);
521
+ * return { hit: hit ? past.rapier?.who(hit.collider) !== undefined : false };
522
+ * });
523
+ * },
524
+ * }
525
+ * ```
526
+ *
527
+ * `fn` runs synchronously and whatever it returns is returned. The live world is never touched:
528
+ * this is stored poses on a second world, not a re-simulation, and nothing a player can see is
529
+ * stepped. A tick outside the buffer is clamped to its nearest edge and `past.clamped` says so,
530
+ * which is a fallback rather than an error.
531
+ *
532
+ * Throws when the room declares no `physics.history`, when the buffer is still empty (a room
533
+ * that has just woken), and when called from inside another `rewind`.
534
+ *
535
+ * @see irt.io/docs/concepts/lag-compensation
536
+ */
537
+ rewind<T>(tick: number, fn: (past: RewindView) => T): T;
538
+ /**
539
+ * D44: spawn a scripted NPC. `config.brain.script` names an entry in the room definition's
540
+ * `npcs` map; the session it opens is an ordinary client session — it appears in
541
+ * `room.clients`, owns entities the way a client does, is judged by the same validators, and
542
+ * counts toward `maxClients`. It skips only the per-IP connection bucket, which exists to stop
543
+ * strangers and would otherwise make every NPC room rate-limit itself.
544
+ *
545
+ * The handle's `clientId` is available immediately; the session settles a tick or two later, so
546
+ * an NPC is not in `room.clients` on the line after the call. A spawn into a full room is
547
+ * refused exactly as a player's join is, with `E_ROOM_FULL` on the room's log.
548
+ *
549
+ * NPCs do not keep a room awake: a room whose only occupants are NPCs hibernates as an empty
550
+ * room does, and room code respawns them in `onWake`.
551
+ */
552
+ spawnNPC(config: NpcConfig): NpcHandle;
553
+ /** D44: despawn by client id. Unknown ids are a warning, not a throw. */
554
+ despawnNPC(clientId: string): void;
555
+ /**
556
+ * D59: the tenant-local bus. Lets one of this project's rooms reach another without bouncing
557
+ * the message off a client, which was the only way before and is both a latency tax and a trust
558
+ * bug (a client can lie, drop, or die mid-relay).
559
+ *
560
+ * Scoped to this project, always. There is no way to name another project from here, at any
561
+ * depth: it is not that a cross-project message is refused, it is that the API cannot express
562
+ * one.
563
+ */
564
+ readonly bus: RoomBus;
565
+ }
566
+ /**
567
+ * D59: two verbs with deliberately unequal guarantees. Read them as a pair, because picking the
568
+ * wrong one is the mistake this API invites.
569
+ *
570
+ * `publish` is the broadcast tier: cheap, best-effort, and it reaches only rooms that happen to be
571
+ * awake and subscribed right now. `send` is the delivery tier: durable, at-least-once, and it
572
+ * wakes a hibernated room to run its handler.
573
+ *
574
+ * Both are weak on purpose, and will stay weak. Sharding turns `send` into a network hop and
575
+ * `publish` into a cross-shard fan-out, and a stronger promise made now would be one the platform
576
+ * had to break later.
577
+ */
578
+ interface RoomBus {
579
+ /**
580
+ * Fan out to every awake room of this project currently subscribed to `channel`. The sender is
581
+ * never delivered its own publish.
582
+ *
583
+ * Nothing queues and nothing wakes. A subscriber that is hibernated simply misses the message,
584
+ * the same way it misses wall-clock time, and resyncs from state when it wakes. There is no
585
+ * ordering promise across channels, and none is coming.
586
+ *
587
+ * Returns nothing and never throws: a refused publish (an oversized payload, a rate limit) lands
588
+ * on the room's log. A room that needs to know its message arrived wants `send`.
589
+ */
590
+ publish(channel: string, payload: string): void;
591
+ /**
592
+ * Join `channel`'s subscriber set. Messages are delivered to the handler declared for that
593
+ * channel in the room definition's `bus.channels` map; publishing to a channel this room has no
594
+ * handler for is legal and does nothing here.
595
+ *
596
+ * Every declared channel is subscribed automatically when the room starts *and* when it wakes,
597
+ * so a room that only wants its declared channels never calls this. Call it to leave and rejoin
598
+ * a channel during a room's life.
599
+ */
600
+ subscribe(channel: string): void;
601
+ /** Leave `channel`'s subscriber set. Leaving one this room is not in is a no-op. */
602
+ unsubscribe(channel: string): void;
603
+ /**
604
+ * Deliver `payload` to one named room of this project, waking it from hibernation if it is
605
+ * asleep. Resolves when the message is accepted for delivery, and rejects with a named error
606
+ * when the room does not exist, its mailbox is full, the payload is too big, or this room has
607
+ * outrun its bus budget.
608
+ *
609
+ * **At-least-once, which means the handler can run twice for one send.** Write it idempotent.
610
+ * The resolve says the message was accepted, not that it was handled.
611
+ *
612
+ * The handler is `bus.onMessage` in the target room definition's config, and it is told which
613
+ * room the message came from. That `from` is stamped by the platform from the sending room's own
614
+ * record, so a sender cannot forge it.
615
+ */
616
+ send(roomId: string, payload: string): Promise<void>;
617
+ }
618
+ /** D59: what a directed `room.bus.send` delivers to the target's `bus.onMessage`. */
619
+ interface BusMessage {
620
+ /**
621
+ * The roomId that sent this, stamped by the platform from the sending room's own record. A
622
+ * sender cannot set it, so a receiver may trust it as far as it trusts its own project.
623
+ */
624
+ readonly from: string;
625
+ readonly payload: string;
626
+ }
627
+ /** D59: what a `publish` delivers to the subscribing room's channel handler. */
628
+ interface BusEvent {
629
+ readonly channel: string;
630
+ /** The publishing room. Platform-stamped, like `BusMessage.from`. */
631
+ readonly from: string;
632
+ readonly payload: string;
633
+ }
634
+ /** D59: the declared half of the bus. See `RoomConfigBase.bus`. */
635
+ interface BusConfig<S extends AnySchema> {
636
+ /**
637
+ * Channel handlers, by channel name. Declaring one subscribes the room to that channel on start
638
+ * and on every wake.
639
+ */
640
+ readonly channels?: Readonly<Record<string, (state: State<S>, event: BusEvent, room: Room<S>) => void>>;
641
+ /**
642
+ * The handler for a directed `room.bus.send` to this room.
643
+ *
644
+ * **Delivery is at-least-once, so this can run twice for one send.** Write it idempotent: check
645
+ * before you add, and treat a repeat as normal rather than as a bug. A room with no handler
646
+ * declared drops what it is sent, with a warning, the same way an alarm with no handler is lost.
647
+ *
648
+ * A throw here counts toward the room's crash threshold, as every handler throw does. That is
649
+ * deliberate and it composes with the delivery bound: a message that always throws is retried a
650
+ * few times, logged, and dropped, rather than closing the room on every wake forever.
651
+ */
652
+ onMessage?(state: State<S>, message: BusMessage, room: Room<S>): void;
152
653
  }
153
654
  /**
154
655
  * D25: player key/value storage, scoped to the project and keyed by a player identity the room
@@ -175,6 +676,163 @@ interface PlayerKv {
175
676
  /** Idempotent: deleting a key that is not there still resolves. */
176
677
  delete(playerId: string, key: string): Promise<void>;
177
678
  }
679
+ /**
680
+ * D53: the room's half of the leaderboard primitive — one method, and no read.
681
+ *
682
+ * ## Why there is no client-side submit, ever
683
+ *
684
+ * A score that a client can post is a score a client can forge, and no amount of signing fixes
685
+ * that: the client is the attacker's machine. So the only path to a board is this one — the room
686
+ * calls it, the supervisor checks the player is actually in the room, the host agent adds the
687
+ * project confinement from its own token, and control writes the row. A game that wants a score
688
+ * on a board computes it in room code.
689
+ *
690
+ * What that does and does not buy you is stated plainly, here and in the docs: it means a score
691
+ * can only come from *your server code*, so a player cannot post 999999 from the console. It does
692
+ * not mean cheat-proof. If your room code trusts a number a client wrote into its own owned
693
+ * entity, the board will faithfully record it. Anti-abuse on this platform is server authority
694
+ * plus rate limits, and there are no hidden heuristics behind that sentence.
695
+ *
696
+ * ## Semantics
697
+ *
698
+ * **Best score wins**, in the direction the board is configured with (`higher` by default,
699
+ * `lower` for times and stroke counts). A submit worse than the player's stored score is accepted
700
+ * and changes nothing; a submit *equal* to it also changes nothing, which is what makes a
701
+ * duplicate or replayed submit idempotent rather than a fresh tie-break position.
702
+ *
703
+ * Scores are whole numbers. Reads are public HTTP on the control plane (top-N and around-me),
704
+ * so there is deliberately no read here to keep in step with them.
705
+ */
706
+ /**
707
+ * D63-e: whether this room is currently willing to take a late joiner from the matchmaker.
708
+ *
709
+ * Two states and one verb, because there is nothing else to say. The room type declares
710
+ * `backfill: true` to be eligible at all; this is how a room that is eligible says "not during a
711
+ * round". A room that never declared it can call `set(true)` all it likes and will still never be
712
+ * offered — the declaration is the ceiling, and the report says so.
713
+ *
714
+ * The pattern, written out:
715
+ *
716
+ * ```ts
717
+ * // an arena: always open
718
+ * defineRoom(schema, { backfill: true })
719
+ *
720
+ * // a versus game: open between rounds
721
+ * defineRoom(schema, {
722
+ * backfill: true,
723
+ * rpc: {
724
+ * startRound(state, _params, ctx) { ctx.room.backfill.set(false); ... },
725
+ * },
726
+ * alarms: { endRound(state, room) { room.backfill.set(true); ... } },
727
+ * })
728
+ * ```
729
+ *
730
+ * The value rides the room list the host agent already polls, so it reaches the matchmaker within
731
+ * one poll interval (3 s by default) rather than instantly. That lag is why a backfilled join can
732
+ * still lose the race for the last seat, and why the client retries the queue once when it does.
733
+ */
734
+ interface RoomBackfill {
735
+ /** Open (`true`) or close (`false`) this room to backfill. Idempotent. */
736
+ set(open: boolean): void;
737
+ /** The current value. Starts at the declared `backfill` and survives hibernation. */
738
+ readonly open: boolean;
739
+ }
740
+ interface RoomRatingResult {
741
+ /** The player id the room was handed (`ctx.playerId`). Anything else is refused. */
742
+ readonly playerId: string;
743
+ /** 1-based finishing position. Equal places are a draw; places need not be dense. */
744
+ readonly place: number;
745
+ }
746
+ /**
747
+ * D63: the room's half of skill ratings — report a result, or set a number you computed yourself.
748
+ *
749
+ * ## Why there is no client-side report, ever
750
+ *
751
+ * The same sentence `RoomLeaderboard` opens with. A result a client can post is a result a client
752
+ * can forge, so the only path to a rating is this one: the room calls it, the supervisor checks
753
+ * every named player is one this room has actually held, the host agent adds the project
754
+ * confinement from its own token, and control applies the update.
755
+ *
756
+ * What that does and does not buy you, said plainly: it means a rating can only move because
757
+ * *your server code* said a match happened. It does not mean unriggable. A room that decides the
758
+ * loser won will be believed, because deciding who won is your game's job and the platform has no
759
+ * view into it. Anti-abuse here is server authority, and there are no hidden heuristics behind
760
+ * that sentence.
761
+ *
762
+ * ## Semantics
763
+ *
764
+ * One `report` is one **rating period**. The placements decompose into every unordered pair
765
+ * exactly once, each pair reading as a win, a loss or a draw, and every player is updated against
766
+ * the field as it stood *before* the report — so the answer does not depend on the order you list
767
+ * them in. The algorithm is Glicko-2 with the paper's seed for a player who has never played
768
+ * (1500, deviation 350).
769
+ *
770
+ * A player whose id is not namespaced (`irt:<subject>` from a platform identity, or `<iss>:<sub>`
771
+ * from your own JWT) cannot carry a rating: a key-join client id is gone with the socket. Such a
772
+ * player is accepted in the report and then ignored — they get no rating and nobody is rated
773
+ * against them.
774
+ */
775
+ interface RoomRatings {
776
+ /**
777
+ * Report one match. `results` names between 2 and 64 players with their finishing places.
778
+ *
779
+ * Every `playerId` must be one this room has held since it was created — `ctx.playerId` is the
780
+ * id to pass. A loser who has already left still counts, which is why the check is "has held"
781
+ * rather than "currently holds". Anything else rejects with `E_RATING_NOT_IN_ROOM`.
782
+ *
783
+ * A malformed report is refused whole rather than in part: `E_RATING_BAD_RESULTS` for a
784
+ * duplicate player, an out-of-range place, or a list that is too short or too long, and
785
+ * `E_RATING_BAD_QUEUE` for a queue name that is not one. Like every promise-returning room API,
786
+ * the continuation runs as its own event between ticks.
787
+ */
788
+ report(queue: string, results: readonly RoomRatingResult[]): Promise<void>;
789
+ /**
790
+ * Set a rating your own code computed, for a player this room has held.
791
+ *
792
+ * `deviation` is optional. Omit it and a player who already has a rating **keeps the deviation
793
+ * they had**, while a player who does not gets the seed's 350. Volatility is never taken from
794
+ * here at all.
795
+ *
796
+ * Both of those are properties of how a player's results have actually gone, which code handing
797
+ * over a rating has no view of. Resetting a settled player's deviation to 350 because you did
798
+ * not mention it would quietly make them provisional again and change who the queue is willing
799
+ * to match them with. Pass one when you mean to change it.
800
+ */
801
+ set(queue: string, playerId: string, value: {
802
+ readonly rating: number;
803
+ readonly deviation?: number;
804
+ }): Promise<void>;
805
+ }
806
+ /** D69-c: what a submit may say about the board beyond the score. One field, and it is optional. */
807
+ interface LeaderboardSubmitOptions {
808
+ /**
809
+ * Which cohort of a bucketed board this score belongs to: a region, a platform, a league —
810
+ * whatever your game means by it. 1 to 64 characters of `a-z 0-9 . _ -`.
811
+ *
812
+ * Required on a board configured with `buckets: true`, and refused on a board that is not
813
+ * (`E_LB_BUCKET_REQUIRED` / `E_LB_NO_BUCKETS`). Neither is guessed at: a bucket that was
814
+ * silently dropped would put a cohort's scores on the wrong ranking, and a read that silently
815
+ * merged every cohort would be a ranking nobody asked for.
816
+ */
817
+ readonly bucket?: string;
818
+ }
819
+ interface RoomLeaderboard {
820
+ /**
821
+ * Post `score` for `playerId` on `board`.
822
+ *
823
+ * `playerId` must be a player currently in this room — `ctx.playerId` is the id to pass, and
824
+ * anything else rejects with `E_LB_NOT_IN_ROOM`. Board names are 1-64 of `a-z 0-9 . _ -`.
825
+ * Rejections name what they hit: `E_LB_BAD_BOARD`, `E_LB_BAD_SCORE`, `E_LB_NOT_IN_ROOM`,
826
+ * `E_LB_PROJECT_FULL`, `E_LB_UNAVAILABLE`, `E_LB_BAD_BUCKET`, `E_LB_BUCKET_REQUIRED`,
827
+ * `E_LB_NO_BUCKETS`. Like every promise-returning room API, the continuation runs as its own
828
+ * event between ticks.
829
+ *
830
+ * D69: there is no period argument and there will not be one. A rotating board's current period
831
+ * is computed on the control plane from its own clock at the moment the score lands, so a room
832
+ * needs no change to start rotating and cannot write into a period that has closed.
833
+ */
834
+ submit(board: string, playerId: string, score: number, options?: LeaderboardSubmitOptions): Promise<void>;
835
+ }
178
836
  interface Ctx<S extends AnySchema = AnySchema> {
179
837
  readonly clientId: string;
180
838
  /**
@@ -190,6 +848,23 @@ interface Ctx<S extends AnySchema = AnySchema> {
190
848
  readonly name: string;
191
849
  /** Server tick this join/call/write is applied at. */
192
850
  readonly tick: number;
851
+ /**
852
+ * D72: RPCs only. The newest authoritative tick the caller had applied when it sent the `CALL`,
853
+ * which is the tick whose world it was looking at. `undefined` for a join, a write, or a `CALL`
854
+ * from a client that sends no stamp.
855
+ *
856
+ * It is what `room.rewind` is meant to be given:
857
+ *
858
+ * ```ts
859
+ * const hit = ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => …);
860
+ * ```
861
+ *
862
+ * **It is a number a client chose.** The history depth bounds how far into the past a lie can
863
+ * reach and `rewind` clamps anything outside the buffer, but a room that wants to distrust it
864
+ * compares it with `ctx.tick` and refuses a gap it does not like. See
865
+ * irt.io/docs/concepts/lag-compensation.
866
+ */
867
+ readonly clientTick: number | undefined;
193
868
  /** Joins only: a resumed session. */
194
869
  readonly reconnecting: boolean;
195
870
  readonly room: Room<S>;
@@ -218,6 +893,76 @@ interface RoomConfigBase<S extends AnySchema> {
218
893
  readonly reconnectGraceMs?: number;
219
894
  /** Default 64. */
220
895
  readonly maxClients?: number;
896
+ /**
897
+ * D63-e: may the matchmaker send a late joiner into this room while it is already running?
898
+ *
899
+ * Default false, which is what every room that predates this option is and stays. A room that
900
+ * says nothing is never offered to a queue, because "a stranger may walk into your game
901
+ * halfway through" is not a thing to opt anybody into by accident.
902
+ *
903
+ * This is the room type's *declaration*. Whether the room is accepting one right now is
904
+ * `room.backfill.set(open)`, which a versus game closes at round start and opens at round end.
905
+ * The declaration is the ceiling: a room that never declared it cannot open itself.
906
+ *
907
+ * What this flag does NOT do is admit anybody. `maxClients` is still enforced in the worker,
908
+ * so a backfilled join that loses the race for the last seat is refused with `E_ROOM_FULL`
909
+ * exactly as any other join would be, and the client retries the queue once. There is no
910
+ * reservation and no admission hook; the room's own state is the room's business.
911
+ */
912
+ readonly backfill?: boolean;
913
+ /**
914
+ * D58: how much worker heap this room type declares it needs, in MB.
915
+ *
916
+ * Declared rather than inferred. A tenant VM's memory has to be divided between the rooms that
917
+ * are awake in it, and without a declaration the supervisor can only give every room the same
918
+ * derived cap, which means a notifications room that holds a few hundred bytes is sized like the
919
+ * physics room next to it. With one, the VM budget becomes arithmetic the supervisor can check
920
+ * at boot: the declared budgets of the awake mix, plus its own baseline, plus a native reserve.
921
+ *
922
+ * Omit it and the room keeps the cap derived from the VM (or the generous dev default). Declare
923
+ * it and this is the worker's old-generation limit, which is a hard ceiling: a room that exceeds
924
+ * it dies as a room crash rather than taking its siblings' VM down with it.
925
+ */
926
+ readonly memoryMb?: number;
927
+ /**
928
+ * M5 part 3.5: how many rooms of this type may be awake at the same time in one tenant.
929
+ *
930
+ * This is the second half of `memoryMb`, and without it the first half cannot size anything. A
931
+ * declared heap says what one room costs; a tenant VM has to hold every room that is awake in
932
+ * it, and until this existed nothing could turn "96 MB per arena" into "how big is the machine".
933
+ * Part 2 summed every declared type exactly once and wrote the pessimism down as a debt; this is
934
+ * the number that discharges it.
935
+ *
936
+ * It is **enforced, not advisory**: the supervisor refuses to start room N+1 of a type with an
937
+ * error naming the type, the limit and this field. That refusal is what makes the arithmetic
938
+ * true rather than optimistic. Raising it is a declaration change, so it takes effect at the
939
+ * tenant's next placement rather than immediately, and it grows the VM.
940
+ *
941
+ * Omit it and the type is counted at one awake room, which is the conservative reading and what
942
+ * an undeclared project has always effectively been given.
943
+ */
944
+ readonly maxAwake?: number;
945
+ /**
946
+ * How long a room of this type outlives its last activity, as `'<n>m'`, `'<n>h'` or `'<n>d'`.
947
+ *
948
+ * Omit it and the room's state is kept forever, which is what every room has always had and
949
+ * stays the default. Declare it and the platform deletes the room's stored state once that long
950
+ * has passed since the room last went to sleep: its snapshot, its save generations and its
951
+ * durable alarms. Player KV and leaderboards are keyed to the player rather than to the room and
952
+ * are never touched.
953
+ *
954
+ * This is the one declaration in a room file that deletes data, so it is deliberately awkward to
955
+ * write by accident: the value is a duration string rather than a number of milliseconds, the
956
+ * grammar takes one integer and one unit (no `'1h30m'`, no seconds, no fractions), and the floor
957
+ * is one minute. Anything else is refused here by name rather than rounded into something
958
+ * plausible.
959
+ *
960
+ * The window is a floor, not a deadline. The platform sweeps for expired rooms on a fixed
961
+ * cadence, so `'10m'` means "at least ten minutes, then deleted on the next sweep". Declare it
962
+ * on room types whose state is worth nothing once the players have gone: a match, a lobby, a
963
+ * draft. Do not declare it on a room holding anything a player expects to come back to.
964
+ */
965
+ readonly retention?: string;
221
966
  /**
222
967
  * D22: run a Rapier world on the fixed timestep. Tick mode only. Every collection whose schema
223
968
  * declares `physics` needs an entry in `bodies`, and vice versa.
@@ -233,6 +978,31 @@ interface RoomConfigBase<S extends AnySchema> {
233
978
  * surviving hibernation is the entire point of D26. This map is code, so it is always there.
234
979
  */
235
980
  readonly alarms?: Readonly<Record<string, (state: State<S>, room: Room<S>) => void>>;
981
+ /**
982
+ * D59: the room's bus handlers.
983
+ *
984
+ * They live in the config for the same reason `alarms` does, and it is the load-bearing reason
985
+ * rather than a stylistic one: a `send` wakes a hibernated room to deliver, and a handler
986
+ * registered at runtime would not have survived the hibernation. This map is code, so it is
987
+ * always there, including on the first tick after a wake.
988
+ *
989
+ * Declaring a channel in `channels` also subscribes the room to it, on start and on every wake.
990
+ * That is what makes the common case require no `room.bus.subscribe` call and what makes the
991
+ * subscription come back after a hibernation the bus deliberately dropped it across.
992
+ */
993
+ readonly bus?: BusConfig<S>;
994
+ /**
995
+ * D44: NPC brains, by name. `room.spawnNPC({ brain: { kind: 'script', script: 'chaser' } })`
996
+ * runs the entry called `chaser`.
997
+ *
998
+ * They live in the config, like `alarms`, for a sharper reason than symmetry: the script runs in
999
+ * the **supervisor** process, driving a real client session, and the only thing that crosses the
1000
+ * worker boundary is its name. A callback passed to `spawnNPC` could not make that trip.
1001
+ *
1002
+ * These are the only handlers in a room definition that may be async, because a bot script is a
1003
+ * loop that awaits: `while (!npc.stopped) { …; await npc.wait(100) }`.
1004
+ */
1005
+ readonly npcs?: Readonly<Record<string, NpcScript<S>>>;
236
1006
  onCreate?(state: State<S>, room: Room<S>): void;
237
1007
  onJoin?(state: State<S>, ctx: Ctx<S>): void;
238
1008
  onLeave?(state: State<S>, ctx: Ctx<S>, reason: LeaveReason): void;
@@ -243,8 +1013,19 @@ interface RoomConfigBase<S extends AnySchema> {
243
1013
  readonly validate?: Validators<S>;
244
1014
  /** Implements the built-in `requestOwnership` RPC. Default: grant if unowned. */
245
1015
  onOwnershipRequest?(state: State<S>, entity: OwnableKeys<S> & string, id: string, ctx: Ctx<S>): boolean;
246
- /** Raw relay messages; return `false` to drop. */
247
- onMessage?(state: State<S>, from: string, target: MessageTarget, bytes: Uint8Array, ctx: Ctx<S>): boolean | undefined | void;
1016
+ /**
1017
+ * Peer messages, raw and typed alike; return `false` to drop.
1018
+ *
1019
+ * `bytes` is what the peer sent: opaque for `room.message(target, bytes)`, and the encoded
1020
+ * payload for a typed message. D70 adds the sixth argument: present, with the declared name and
1021
+ * the decoded value, exactly when the message was typed. The decode happens **before** this
1022
+ * handler runs, so a malformed payload never arrives here at all — it is counted and dropped —
1023
+ * and `typed.value` is always a well-formed value of a shape this schema declares.
1024
+ *
1025
+ * Returning `false` drops a typed message exactly as it drops a raw one. That is the room's
1026
+ * veto, and it is the only one it needs.
1027
+ */
1028
+ onMessage?(state: State<S>, from: string, target: MessageTarget, bytes: Uint8Array, ctx: Ctx<S>, typed?: TypedMessage<S>): boolean | undefined | void;
248
1029
  }
249
1030
  type RoomConfig<S extends AnySchema> = RoomConfigBase<S> & RpcConfig<S>;
250
1031
  /** Config with defaults filled and `rpc` always present. */
@@ -254,6 +1035,7 @@ interface ResolvedRoomConfig<S extends AnySchema> extends RoomConfigBase<S> {
254
1035
  readonly idleMs: number;
255
1036
  readonly reconnectGraceMs: number;
256
1037
  readonly maxClients: number;
1038
+ readonly backfill: boolean;
257
1039
  readonly rpc: RpcImplementations<S>;
258
1040
  }
259
1041
  interface RoomDefinition<S extends AnySchema = AnySchema> {
@@ -269,7 +1051,52 @@ declare const DEFAULTS: {
269
1051
  readonly idleMs: 30000;
270
1052
  readonly reconnectGraceMs: 30000;
271
1053
  readonly maxClients: 64;
1054
+ readonly backfill: false;
272
1055
  };
1056
+ /**
1057
+ * The ceiling on a declared `maxAwake`.
1058
+ *
1059
+ * Kept as a literal here rather than imported from `@irtio/protocol`'s `MAX_AWAKE_MAX`, which is
1060
+ * the same number for the same reason. `@irtio/server` is on the room-bundle side of the fence:
1061
+ * everything it imports is bundled into every deployed room, and taking a dependency on the fleet
1062
+ * protocol module to read one integer would put the whole of it there. The two are pinned equal by
1063
+ * `packages/protocol/test/sizing.test.ts`'s companion assertion in the server's own tests, so a
1064
+ * drift is a failing test rather than a silently different limit.
1065
+ */
1066
+ declare const MAX_AWAKE_MAX = 256;
1067
+ /**
1068
+ * The bounds on a declared `memoryMb`.
1069
+ *
1070
+ * Literals here for the same fence reason as {@link MAX_AWAKE_MAX}: `@irtio/server` is on the
1071
+ * room-bundle side, so it may not import `@irtio/protocol`'s `ROOM_MEMORY_MB_MIN` /
1072
+ * `ROOM_MEMORY_MB_MAX` to read two integers. `packages/supervisor/test/declaration-doors.test.ts`
1073
+ * asserts the pairs equal from a package that legitimately depends on both, so a drift is a failing
1074
+ * test rather than two doors with different opinions about the same number.
1075
+ *
1076
+ * The floor is the one `deriveWorkerHeapCaps` uses: below it a worker cannot boot a room at all, so
1077
+ * accepting a smaller declaration would only move the failure later. The ceiling is M5 part 3.6's:
1078
+ * 1024 is Large's advertised and priced 1 GiB basis, and a declaration above it would be a room
1079
+ * paying Large's rate for memory no class covers.
1080
+ */
1081
+ declare const MEMORY_MB_MIN = 32;
1082
+ declare const MEMORY_MB_MAX = 1024;
1083
+ /**
1084
+ * The grammar and the bounds on a declared `retention`.
1085
+ *
1086
+ * Literals here for the same fence reason as {@link MEMORY_MB_MIN}: `@irtio/server` is bundled
1087
+ * into every deployed room, so it may not import `@irtio/protocol`'s `RETENTION_RE`,
1088
+ * `RETENTION_MIN_MS` and `RETENTION_MAX_MS` to read one pattern and two integers.
1089
+ * `packages/supervisor/test/declaration-doors.test.ts` asserts the pairs equal from a package that
1090
+ * legitimately depends on both, so a drift is a failing test rather than two doors with different
1091
+ * opinions about which declarations delete data.
1092
+ *
1093
+ * A whole minute is the floor because the platform sweeps for expired rooms on a cadence measured
1094
+ * in minutes: a shorter window would be a number the platform could not honour. Ten years is the
1095
+ * ceiling because a longer one is asking for "forever", which is written by leaving the field out.
1096
+ */
1097
+ declare const RETENTION_RE: RegExp;
1098
+ declare const RETENTION_MIN_MS = 60000;
1099
+ declare const RETENTION_MAX_MS: number;
273
1100
  /**
274
1101
  * Validates a room config and returns the definition the runtime loads. Throws on: unknown
275
1102
  * mode, bad tickRate, `tick` missing in tick mode or present in event mode, `async`/generator
@@ -280,4 +1107,4 @@ declare function defineRoom<S extends AnySchema>(schema: S, config: RoomConfig<S
280
1107
  /** Type guard for what a bundle's default export should be. */
281
1108
  declare function isRoomDefinition(v: unknown): v is RoomDefinition;
282
1109
 
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 };
1110
+ export { type Behaviour, type BodyFactories, type BodySpec, type BusConfig, type BusEvent, type BusMessage, type ClientInfo, type Ctx, DEFAULTS, HISTORY_MAX_TICKS, type LeaderboardSubmitOptions, type LeaveReason, MAX_AWAKE_MAX, MEMORY_MB_MAX, MEMORY_MB_MIN, type Matter2dBodyFactories, type Matter2dBodySpec, type Matter2dIntentHook, type Matter2dPhysicsConfig, type Matter2dRoomApi, type MatterBody, type MatterConstraint, type MatterEngine, type MatterModule, type MessageTarget, type Npc, type NpcCollection, type NpcConfig, type NpcHandle, type NpcRoom, type NpcScript, type NpcScriptBrain, type NpcState, type PatrolStep, type PhysicsConfig, type PhysicsRoomApi, type PlayerKv, RETENTION_MAX_MS, RETENTION_MIN_MS, RETENTION_RE, ROOM_DEFINITION_VERSION, type RapierCollider, type RapierColliderDesc, type RapierModule, type RapierPhysicsConfig, type RapierRigidBody, type RapierRigidBodyDesc, type RapierWorld, type ResolvedRoomConfig, type RewindView, type Room, type RoomBackfill, type RoomBus, type RoomConfig, type RoomConfigBase, type RoomDefinition, type RoomLeaderboard, type RoomMode, type RoomRatingResult, type RoomRatings, type RpcImplementations, type TimerHandle, type TypedMessage, type Validators, type Vec2, type Vector2, type Vector3, type WanderState, arrive, choose, defineRoom, flee, isRoomDefinition, newWander, patrol, seek, wander };
package/dist/index.js CHANGED
@@ -1,3 +1,50 @@
1
+ // src/physics.ts
2
+ var HISTORY_MAX_TICKS = 240;
3
+
4
+ // src/npc.ts
5
+ var ZERO = { x: 0, y: 0 };
6
+ function scaleTo(dx, dy, speed) {
7
+ const len = Math.hypot(dx, dy);
8
+ if (len === 0 || speed === 0) return ZERO;
9
+ return { x: dx / len * speed, y: dy / len * speed };
10
+ }
11
+ function seek(from, to, speed) {
12
+ return scaleTo(to.x - from.x, to.y - from.y, speed);
13
+ }
14
+ function flee(from, threat, speed) {
15
+ return scaleTo(from.x - threat.x, from.y - threat.y, speed);
16
+ }
17
+ function arrive(from, to, speed, slowRadius) {
18
+ const dx = to.x - from.x;
19
+ const dy = to.y - from.y;
20
+ const dist = Math.hypot(dx, dy);
21
+ if (dist === 0) return ZERO;
22
+ const wanted = slowRadius > 0 && dist < slowRadius ? speed * dist / slowRadius : speed;
23
+ return scaleTo(dx, dy, wanted);
24
+ }
25
+ function newWander(random) {
26
+ return { angle: random() * Math.PI * 2 };
27
+ }
28
+ function wander(state, speed, turn, random) {
29
+ state.angle += (random() * 2 - 1) * turn;
30
+ return { x: Math.cos(state.angle) * speed, y: Math.sin(state.angle) * speed };
31
+ }
32
+ function patrol(from, waypoints, index, speed, reachedRadius) {
33
+ if (waypoints.length === 0) return { velocity: ZERO, index: 0 };
34
+ let at = (index % waypoints.length + waypoints.length) % waypoints.length;
35
+ const target = waypoints[at];
36
+ if (Math.hypot(target.x - from.x, target.y - from.y) <= reachedRadius) {
37
+ at = (at + 1) % waypoints.length;
38
+ }
39
+ return { velocity: seek(from, waypoints[at], speed), index: at };
40
+ }
41
+ function choose(behaviours) {
42
+ for (const b of behaviours) {
43
+ if (b.when()) return b;
44
+ }
45
+ return void 0;
46
+ }
47
+
1
48
  // src/index.ts
2
49
  var ROOM_DEFINITION_VERSION = 1;
3
50
  var DEFAULTS = {
@@ -5,8 +52,29 @@ var DEFAULTS = {
5
52
  tickRate: 20,
6
53
  idleMs: 3e4,
7
54
  reconnectGraceMs: 3e4,
8
- maxClients: 64
55
+ maxClients: 64,
56
+ // D63-e: off. A room is not offered to strangers unless it says so.
57
+ backfill: false
9
58
  };
59
+ var MAX_AWAKE_MAX = 256;
60
+ var MEMORY_MB_MIN = 32;
61
+ var MEMORY_MB_MAX = 1024;
62
+ var RETENTION_RE = /^[1-9][0-9]{0,4}(m|h|d)$/;
63
+ var RETENTION_MIN_MS = 6e4;
64
+ var RETENTION_MAX_MS = 3650 * 24 * 60 * 60 * 1e3;
65
+ var RETENTION_UNIT_MS = {
66
+ m: 6e4,
67
+ h: 60 * 60 * 1e3,
68
+ d: 24 * 60 * 60 * 1e3
69
+ };
70
+ function parseRetentionDeclaration(raw) {
71
+ if (typeof raw !== "string" || !RETENTION_RE.test(raw)) return void 0;
72
+ const scale = RETENTION_UNIT_MS[raw.slice(-1)];
73
+ if (scale === void 0) return void 0;
74
+ const ms = Number(raw.slice(0, -1)) * scale;
75
+ if (ms < RETENTION_MIN_MS || ms > RETENTION_MAX_MS) return void 0;
76
+ return ms;
77
+ }
10
78
  var HANDLER_KEYS = [
11
79
  "onCreate",
12
80
  "onJoin",
@@ -29,6 +97,28 @@ function defineRoom(schema, config) {
29
97
  if (!Number.isInteger(tickRate) || tickRate < 1 || tickRate > 240) {
30
98
  throw new Error(`defineRoom: tickRate must be an integer in 1..240, got ${String(tickRate)}`);
31
99
  }
100
+ if (config.memoryMb !== void 0) {
101
+ if (!Number.isInteger(config.memoryMb) || config.memoryMb < MEMORY_MB_MIN || config.memoryMb > MEMORY_MB_MAX) {
102
+ throw new Error(
103
+ `defineRoom: memoryMb must be an integer in ${MEMORY_MB_MIN}..${MEMORY_MB_MAX} MB, got ${String(config.memoryMb)}`
104
+ );
105
+ }
106
+ }
107
+ if (config.maxAwake !== void 0) {
108
+ if (!Number.isInteger(config.maxAwake) || config.maxAwake < 1 || config.maxAwake > MAX_AWAKE_MAX) {
109
+ throw new Error(
110
+ `defineRoom: maxAwake must be an integer in 1..${MAX_AWAKE_MAX}, got ${String(config.maxAwake)}`
111
+ );
112
+ }
113
+ }
114
+ if (config.retention !== void 0) {
115
+ const ms = parseRetentionDeclaration(config.retention);
116
+ if (ms === void 0) {
117
+ throw new Error(
118
+ `defineRoom: retention must be a duration like '10m', '6h' or '30d' between 1m and 3650d, got ${JSON.stringify(config.retention)}`
119
+ );
120
+ }
121
+ }
32
122
  for (const k of ["idleMs", "reconnectGraceMs", "maxClients"]) {
33
123
  const v = config[k];
34
124
  if (v !== void 0 && (!Number.isFinite(v) || v < 0)) {
@@ -63,6 +153,7 @@ function defineRoom(schema, config) {
63
153
  }
64
154
  }
65
155
  checkPhysics(schema, config, mode);
156
+ checkNpcs(config);
66
157
  const resolved = {
67
158
  ...config,
68
159
  mode,
@@ -70,6 +161,7 @@ function defineRoom(schema, config) {
70
161
  idleMs: config.idleMs ?? DEFAULTS.idleMs,
71
162
  reconnectGraceMs: config.reconnectGraceMs ?? DEFAULTS.reconnectGraceMs,
72
163
  maxClients: config.maxClients ?? DEFAULTS.maxClients,
164
+ backfill: config.backfill === true,
73
165
  rpc
74
166
  };
75
167
  return Object.freeze({
@@ -79,6 +171,19 @@ function defineRoom(schema, config) {
79
171
  config: Object.freeze(resolved)
80
172
  });
81
173
  }
174
+ function checkNpcs(config) {
175
+ const npcs = config.npcs;
176
+ if (npcs === void 0) return;
177
+ if (typeof npcs !== "object" || npcs === null || Array.isArray(npcs)) {
178
+ throw new Error("defineRoom: npcs must be an object mapping a name to a script function");
179
+ }
180
+ for (const [name, fn] of Object.entries(npcs)) {
181
+ if (name === "") throw new Error("defineRoom: an npcs key must not be empty");
182
+ if (typeof fn !== "function") {
183
+ throw new Error(`defineRoom: npcs.${name} must be a function taking the npc object`);
184
+ }
185
+ }
186
+ }
82
187
  function checkPhysics(schema, config, mode) {
83
188
  const declared = schema.collections.filter((c) => c.physics !== void 0).map((c) => c.name);
84
189
  const physics = config.physics;
@@ -100,20 +205,33 @@ function checkPhysics(schema, config, mode) {
100
205
  "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
206
  );
102
207
  }
103
- if (physics.engine !== "rapier3d") {
208
+ if (physics.engine !== "rapier3d" && physics.engine !== "matter2d") {
104
209
  throw new Error(
105
- `defineRoom: physics.engine must be 'rapier3d' (got ${JSON.stringify(physics.engine)}); matter.js is not in M2`
210
+ `defineRoom: physics.engine must be 'rapier3d' or 'matter2d', got ${JSON.stringify(
211
+ physics.engine
212
+ )}`
106
213
  );
107
214
  }
108
215
  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");
216
+ const planar = physics.engine === "matter2d";
217
+ if (!g || typeof g !== "object" || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !planar && !Number.isFinite(g.z)) {
218
+ throw new Error(
219
+ planar ? "defineRoom: physics.gravity must be { x, y } finite numbers" : "defineRoom: physics.gravity must be { x, y, z } finite numbers"
220
+ );
111
221
  }
112
222
  if (physics.timestep !== void 0 && (!Number.isFinite(physics.timestep) || physics.timestep <= 0)) {
113
223
  throw new Error(
114
224
  `defineRoom: physics.timestep must be a positive number of seconds, got ${String(physics.timestep)}`
115
225
  );
116
226
  }
227
+ const history = physics.history;
228
+ if (history !== void 0) {
229
+ if (!Number.isInteger(history) || history < 0 || history > HISTORY_MAX_TICKS) {
230
+ throw new Error(
231
+ `defineRoom: physics.history must be an integer in 0..${HISTORY_MAX_TICKS} ticks (0 or absent means no history and no cost), got ${String(history)}`
232
+ );
233
+ }
234
+ }
117
235
  assertSyncHandler(physics.setup, "physics.setup");
118
236
  const bodies = physics.bodies ?? {};
119
237
  for (const [k, fn] of Object.entries(bodies)) assertSyncHandler(fn, `physics.bodies.${k}`);
@@ -128,6 +246,20 @@ function checkPhysics(schema, config, mode) {
128
246
  parts.push(`physics.bodies names collections without schema physics: ${extra.join(", ")}`);
129
247
  throw new Error(`defineRoom: ${parts.join("; ")}`);
130
248
  }
249
+ const intents = physics.intents;
250
+ if (intents !== void 0) {
251
+ if (typeof intents !== "object" || intents === null) {
252
+ throw new Error("defineRoom: physics.intents must be an object of collection -> hook");
253
+ }
254
+ for (const [k, fn] of Object.entries(intents)) assertSyncHandler(fn, `physics.intents.${k}`);
255
+ const steerable = schema.collections.filter((c) => c.physics !== void 0 && c.physics.intents.length > 0).map((c) => c.name);
256
+ const unsteerable = Object.keys(intents).filter((n) => !steerable.includes(n));
257
+ if (unsteerable.length) {
258
+ throw new Error(
259
+ `defineRoom: physics.intents names ${unsteerable.join(", ")}, which declare no intents in the schema. A steering hook turns the fields a client may write into forces, so the collection has to declare them: entity(fields, { physics: { body, intents: [...] } }). Collections that do: ${steerable.length ? steerable.join(", ") : "none"}`
260
+ );
261
+ }
262
+ }
131
263
  }
132
264
  function assertSyncHandler(fn, name) {
133
265
  if (fn === void 0) return;
@@ -144,7 +276,21 @@ function isRoomDefinition(v) {
144
276
  }
145
277
  export {
146
278
  DEFAULTS,
279
+ HISTORY_MAX_TICKS,
280
+ MAX_AWAKE_MAX,
281
+ MEMORY_MB_MAX,
282
+ MEMORY_MB_MIN,
283
+ RETENTION_MAX_MS,
284
+ RETENTION_MIN_MS,
285
+ RETENTION_RE,
147
286
  ROOM_DEFINITION_VERSION,
287
+ arrive,
288
+ choose,
148
289
  defineRoom,
149
- isRoomDefinition
290
+ flee,
291
+ isRoomDefinition,
292
+ newWander,
293
+ patrol,
294
+ seek,
295
+ wander
150
296
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/server",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "irtio room-file API: defineRoom, handler and ctx types",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -20,18 +20,24 @@
20
20
  "dist"
21
21
  ],
22
22
  "dependencies": {
23
- "@irtio/schema": "0.5.2"
23
+ "@irtio/schema": "0.7.0"
24
24
  },
25
25
  "peerDependencies": {
26
- "@dimforge/rapier3d-compat": ">=0.20.0"
26
+ "@dimforge/rapier3d-compat": ">=0.20.0",
27
+ "matter-js": ">=0.20.0"
27
28
  },
28
29
  "peerDependenciesMeta": {
29
30
  "@dimforge/rapier3d-compat": {
30
31
  "optional": true
32
+ },
33
+ "matter-js": {
34
+ "optional": true
31
35
  }
32
36
  },
33
37
  "devDependencies": {
34
- "@dimforge/rapier3d-compat": "0.20.0"
38
+ "@dimforge/rapier3d-compat": "0.20.0",
39
+ "@types/matter-js": "0.20.2",
40
+ "matter-js": "0.20.0"
35
41
  },
36
42
  "scripts": {
37
43
  "build": "tsup",