@irtio/server 0.5.1 → 0.6.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, ServerCallProxy, SchemaRpc, BroadcastProxy, OwnableKeys, 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
@@ -21,6 +197,14 @@ type RapierWorld = RAPIER.World;
21
197
  type RapierRigidBody = RAPIER.RigidBody;
22
198
  type RapierRigidBodyDesc = RAPIER.RigidBodyDesc;
23
199
  type RapierColliderDesc = RAPIER.ColliderDesc;
200
+ type MatterModule = typeof MATTER;
201
+ type MatterEngine = MATTER.Engine;
202
+ type MatterBody = MATTER.Body;
203
+ type MatterConstraint = MATTER.Constraint;
204
+ interface Vector2 {
205
+ readonly x: number;
206
+ readonly y: number;
207
+ }
24
208
  interface Vector3 {
25
209
  readonly x: number;
26
210
  readonly y: number;
@@ -32,6 +216,16 @@ interface BodySpec {
32
216
  /** Attached to the body in order. A body with none is a valid (invisible) point mass. */
33
217
  readonly colliders?: readonly RapierColliderDesc[];
34
218
  }
219
+ /**
220
+ * D45: what a matter2d body factory returns. matter.js has no separate collider concept — a body
221
+ * *is* its geometry — so this is one body plus any constraints pinning it, and there is no second
222
+ * shape list to keep in step with the first.
223
+ */
224
+ interface Matter2dBodySpec {
225
+ readonly body: MatterBody;
226
+ /** Added to the world with the body, and removed with it. */
227
+ readonly constraints?: readonly MatterConstraint[];
228
+ }
35
229
  type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf<SchemaDefs<S>[K]>;
36
230
  /**
37
231
  * `physics.bodies.<collection>` — how one instance becomes a rigid body. Called when the runtime
@@ -42,8 +236,12 @@ type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf
42
236
  type BodyFactories<S extends AnySchema> = {
43
237
  readonly [K in PhysicsKeys<S>]: (rapier: RapierModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => BodySpec;
44
238
  };
45
- interface PhysicsConfig<S extends AnySchema> {
46
- /** The one blessed engine in M2 (D22). */
239
+ /** `physics.bodies.<collection>` for matter2d. Same lifecycle as {@link BodyFactories}. */
240
+ type Matter2dBodyFactories<S extends AnySchema> = {
241
+ readonly [K in PhysicsKeys<S>]: (matter: MatterModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => Matter2dBodySpec;
242
+ };
243
+ interface RapierPhysicsConfig<S extends AnySchema> {
244
+ /** The 3D engine (D22). */
47
245
  readonly engine: 'rapier3d';
48
246
  readonly gravity: Vector3;
49
247
  /** Seconds per world step. Defaults to the tick interval; one step per tick, no substeps. */
@@ -56,7 +254,79 @@ interface PhysicsConfig<S extends AnySchema> {
56
254
  setup?(world: RapierWorld, rapier: RapierModule, room: Room<S>): void;
57
255
  readonly bodies: BodyFactories<S>;
58
256
  }
59
- /** `room.physics` — what handlers use to turn intents into forces. */
257
+ /**
258
+ * D57: the matter-flavoured counterpart to `@irtio/client`'s `ClientIntent2dHook` — one step of
259
+ * steering for one body, from the fields a client may write.
260
+ *
261
+ * A room's `tick()` already calls a per-collection steering function of exactly this shape; see
262
+ * `games/dive/irtio/world.ts`'s `intents` and `room.ts`'s `tick()`. Naming the type here, and
263
+ * letting the config carry the map, is what lets a client-side matter2d predictor pull the very
264
+ * same functions out of the shared world module and replay them, instead of the two sides wiring
265
+ * up their own imports and drifting.
266
+ *
267
+ * Method syntax, so a hook written against its own instance type is accepted: the values really
268
+ * passed are the schema's records for that collection.
269
+ */
270
+ type Matter2dIntentHook = {
271
+ hook(body: MatterBody, instance: Record<string, unknown>, matter: MatterModule, engine: MatterEngine, timestep: number): void;
272
+ }['hook'];
273
+ /**
274
+ * D45: the second blessed engine. `matter-js` is 2D, pure JavaScript, and blessed on the same
275
+ * terms Rapier is — room code gets the real `Matter` namespace and the real `Engine`, so every
276
+ * matter.js tutorial applies unchanged, and irtio invents no syntax for a shape.
277
+ *
278
+ * The reason to have it at all is that the documented way to hold Rapier in a plane has a trap in
279
+ * it (locking one translation axis and the two rotations across that plane silently removes
280
+ * friction on box-shaped bodies), and a 2D engine makes the whole recipe unnecessary.
281
+ *
282
+ * Its determinism story is weaker than Rapier's and the measurement is in the docs rather than an
283
+ * adjective. See the physics reference for what that means for client-side prediction.
284
+ */
285
+ interface Matter2dPhysicsConfig<S extends AnySchema> {
286
+ readonly engine: 'matter2d';
287
+ /** In the plane. matter.js's own convention is y-down; irtio does not flip it for you. */
288
+ readonly gravity: Vector2;
289
+ /** Seconds per world step. Defaults to the tick interval; one step per tick, no substeps. */
290
+ readonly timestep?: number;
291
+ /** Static geometry, constraints, world tuning. Same lifecycle rules as Rapier's `setup`. */
292
+ setup?(engine: MatterEngine, matter: MatterModule, room: Room<S>): void;
293
+ readonly bodies: Matter2dBodyFactories<S>;
294
+ /**
295
+ * D57: the shared per-collection steering functions, alongside `bodies`.
296
+ *
297
+ * **The runtime does not call these.** Declaring a hook here does not make the room run it; the
298
+ * room's `tick()` runs it, exactly as it did before this field existed, and `RapierPhysicsConfig`
299
+ * has no equivalent. What the field buys is a single naming: `joinRoom({ physics2d: { intents } })`
300
+ * on the client and `physics: { intents }` here point at the same function in the same shared
301
+ * module, so "same code both sides" is checkable rather than a convention.
302
+ *
303
+ * Partial, unlike `bodies`: every physics collection needs a shape, and only the ones whose
304
+ * schema declares `intents` can be steered. `defineRoom` refuses a key that names a collection
305
+ * with no declared intents, which is the check the type cannot make.
306
+ */
307
+ readonly intents?: Readonly<Partial<Record<PhysicsKeys<S> & string, Matter2dIntentHook>>>;
308
+ }
309
+ type PhysicsConfig<S extends AnySchema> = RapierPhysicsConfig<S> | Matter2dPhysicsConfig<S>;
310
+ /**
311
+ * `room.physics2d` in a matter2d room.
312
+ *
313
+ * Two accessors rather than one narrowing union, and the reason is compatibility rather than
314
+ * taste: `room.physics` is D22's surface and every Rapier room in existence reads through it
315
+ * without a narrowing step. A union would have made all of them stop compiling to serve a room
316
+ * that has not been written yet. Each accessor throws a sentence naming the other when the room's
317
+ * engine is the other one, so the mistake costs one line of reading rather than a debugging
318
+ * session.
319
+ */
320
+ interface Matter2dRoomApi<S extends AnySchema> {
321
+ /** The `matter-js` namespace: `Bodies`, `Body`, `Composite`, `Constraint`, `Vector`, … */
322
+ readonly matter: MatterModule;
323
+ /** The live `Matter.Engine`. `engine.world` is the composite everything lives in. */
324
+ readonly engine: MatterEngine;
325
+ /** Seconds per step. */
326
+ readonly timestep: number;
327
+ body(collection: PhysicsKeys<S> & string, id: string): MatterBody | undefined;
328
+ }
329
+ /** `room.physics` — what handlers use to turn intents into forces (rapier3d rooms). */
60
330
  interface PhysicsRoomApi<S extends AnySchema> {
61
331
  /** The `@dimforge/rapier3d-compat` namespace: descs, shapes, enums, `QueryFilterFlags`, … */
62
332
  readonly rapier: RapierModule;
@@ -86,6 +356,8 @@ interface ClientInfo {
86
356
  readonly role: string;
87
357
  readonly name: string;
88
358
  readonly connected: boolean;
359
+ /** D44: a scripted NPC spawned by this room, rather than a session dialled from outside. */
360
+ readonly npc: boolean;
89
361
  }
90
362
  type TimerHandle = number;
91
363
  interface Room<S extends AnySchema = AnySchema> {
@@ -126,6 +398,14 @@ interface Room<S extends AnySchema = AnySchema> {
126
398
  save(): Promise<string>;
127
399
  /** D25: per-player key/value storage that outlives the room. */
128
400
  readonly kv: PlayerKv;
401
+ /** D53: post scores to this project's leaderboards. Server-authoritative by construction —
402
+ * this is the only path a score can take. */
403
+ readonly leaderboard: RoomLeaderboard;
404
+ /** D63: report match results to this project's skill ratings. Like the leaderboard, this is
405
+ * the only path a rating can move on, and a client cannot reach it. */
406
+ readonly ratings: RoomRatings;
407
+ /** D63-e: open or close this room to backfill from the matchmaker, right now. */
408
+ readonly backfill: RoomBackfill;
129
409
  /**
130
410
  * D26: arm a durable alarm named `name` to fire at or after `atMs`, **on `room.now`'s clock** —
131
411
  * `room.alarm('round', room.now + 15_000)` is the shape to write. (The host translates that to
@@ -149,6 +429,127 @@ interface Room<S extends AnySchema = AnySchema> {
149
429
  * config declares no `physics:` throws — that is a mistake worth naming at the call site.
150
430
  */
151
431
  readonly physics: PhysicsRoomApi<S>;
432
+ /**
433
+ * D45: the matter.js engine and the bodies behind physics entities, in a room whose config
434
+ * declares `engine: 'matter2d'`. Reading it in a Rapier room throws and says so, and so does
435
+ * reading `room.physics` here.
436
+ */
437
+ readonly physics2d: Matter2dRoomApi<S>;
438
+ /**
439
+ * D44: spawn a scripted NPC. `config.brain.script` names an entry in the room definition's
440
+ * `npcs` map; the session it opens is an ordinary client session — it appears in
441
+ * `room.clients`, owns entities the way a client does, is judged by the same validators, and
442
+ * counts toward `maxClients`. It skips only the per-IP connection bucket, which exists to stop
443
+ * strangers and would otherwise make every NPC room rate-limit itself.
444
+ *
445
+ * The handle's `clientId` is available immediately; the session settles a tick or two later, so
446
+ * an NPC is not in `room.clients` on the line after the call. A spawn into a full room is
447
+ * refused exactly as a player's join is, with `E_ROOM_FULL` on the room's log.
448
+ *
449
+ * NPCs do not keep a room awake: a room whose only occupants are NPCs hibernates as an empty
450
+ * room does, and room code respawns them in `onWake`.
451
+ */
452
+ spawnNPC(config: NpcConfig): NpcHandle;
453
+ /** D44: despawn by client id. Unknown ids are a warning, not a throw. */
454
+ despawnNPC(clientId: string): void;
455
+ /**
456
+ * D59: the tenant-local bus. Lets one of this project's rooms reach another without bouncing
457
+ * the message off a client, which was the only way before and is both a latency tax and a trust
458
+ * bug (a client can lie, drop, or die mid-relay).
459
+ *
460
+ * Scoped to this project, always. There is no way to name another project from here, at any
461
+ * depth: it is not that a cross-project message is refused, it is that the API cannot express
462
+ * one.
463
+ */
464
+ readonly bus: RoomBus;
465
+ }
466
+ /**
467
+ * D59: two verbs with deliberately unequal guarantees. Read them as a pair, because picking the
468
+ * wrong one is the mistake this API invites.
469
+ *
470
+ * `publish` is the broadcast tier: cheap, best-effort, and it reaches only rooms that happen to be
471
+ * awake and subscribed right now. `send` is the delivery tier: durable, at-least-once, and it
472
+ * wakes a hibernated room to run its handler.
473
+ *
474
+ * Both are weak on purpose, and will stay weak. Sharding turns `send` into a network hop and
475
+ * `publish` into a cross-shard fan-out, and a stronger promise made now would be one the platform
476
+ * had to break later.
477
+ */
478
+ interface RoomBus {
479
+ /**
480
+ * Fan out to every awake room of this project currently subscribed to `channel`. The sender is
481
+ * never delivered its own publish.
482
+ *
483
+ * Nothing queues and nothing wakes. A subscriber that is hibernated simply misses the message,
484
+ * the same way it misses wall-clock time, and resyncs from state when it wakes. There is no
485
+ * ordering promise across channels, and none is coming.
486
+ *
487
+ * Returns nothing and never throws: a refused publish (an oversized payload, a rate limit) lands
488
+ * on the room's log. A room that needs to know its message arrived wants `send`.
489
+ */
490
+ publish(channel: string, payload: string): void;
491
+ /**
492
+ * Join `channel`'s subscriber set. Messages are delivered to the handler declared for that
493
+ * channel in the room definition's `bus.channels` map; publishing to a channel this room has no
494
+ * handler for is legal and does nothing here.
495
+ *
496
+ * Every declared channel is subscribed automatically when the room starts *and* when it wakes,
497
+ * so a room that only wants its declared channels never calls this. Call it to leave and rejoin
498
+ * a channel during a room's life.
499
+ */
500
+ subscribe(channel: string): void;
501
+ /** Leave `channel`'s subscriber set. Leaving one this room is not in is a no-op. */
502
+ unsubscribe(channel: string): void;
503
+ /**
504
+ * Deliver `payload` to one named room of this project, waking it from hibernation if it is
505
+ * asleep. Resolves when the message is accepted for delivery, and rejects with a named error
506
+ * when the room does not exist, its mailbox is full, the payload is too big, or this room has
507
+ * outrun its bus budget.
508
+ *
509
+ * **At-least-once, which means the handler can run twice for one send.** Write it idempotent.
510
+ * The resolve says the message was accepted, not that it was handled.
511
+ *
512
+ * The handler is `bus.onMessage` in the target room definition's config, and it is told which
513
+ * room the message came from. That `from` is stamped by the platform from the sending room's own
514
+ * record, so a sender cannot forge it.
515
+ */
516
+ send(roomId: string, payload: string): Promise<void>;
517
+ }
518
+ /** D59: what a directed `room.bus.send` delivers to the target's `bus.onMessage`. */
519
+ interface BusMessage {
520
+ /**
521
+ * The roomId that sent this, stamped by the platform from the sending room's own record. A
522
+ * sender cannot set it, so a receiver may trust it as far as it trusts its own project.
523
+ */
524
+ readonly from: string;
525
+ readonly payload: string;
526
+ }
527
+ /** D59: what a `publish` delivers to the subscribing room's channel handler. */
528
+ interface BusEvent {
529
+ readonly channel: string;
530
+ /** The publishing room. Platform-stamped, like `BusMessage.from`. */
531
+ readonly from: string;
532
+ readonly payload: string;
533
+ }
534
+ /** D59: the declared half of the bus. See `RoomConfigBase.bus`. */
535
+ interface BusConfig<S extends AnySchema> {
536
+ /**
537
+ * Channel handlers, by channel name. Declaring one subscribes the room to that channel on start
538
+ * and on every wake.
539
+ */
540
+ readonly channels?: Readonly<Record<string, (state: State<S>, event: BusEvent, room: Room<S>) => void>>;
541
+ /**
542
+ * The handler for a directed `room.bus.send` to this room.
543
+ *
544
+ * **Delivery is at-least-once, so this can run twice for one send.** Write it idempotent: check
545
+ * before you add, and treat a repeat as normal rather than as a bug. A room with no handler
546
+ * declared drops what it is sent, with a warning, the same way an alarm with no handler is lost.
547
+ *
548
+ * A throw here counts toward the room's crash threshold, as every handler throw does. That is
549
+ * deliberate and it composes with the delivery bound: a message that always throws is retried a
550
+ * few times, logged, and dropped, rather than closing the room on every wake forever.
551
+ */
552
+ onMessage?(state: State<S>, message: BusMessage, room: Room<S>): void;
152
553
  }
153
554
  /**
154
555
  * D25: player key/value storage, scoped to the project and keyed by a player identity the room
@@ -175,6 +576,145 @@ interface PlayerKv {
175
576
  /** Idempotent: deleting a key that is not there still resolves. */
176
577
  delete(playerId: string, key: string): Promise<void>;
177
578
  }
579
+ /**
580
+ * D53: the room's half of the leaderboard primitive — one method, and no read.
581
+ *
582
+ * ## Why there is no client-side submit, ever
583
+ *
584
+ * A score that a client can post is a score a client can forge, and no amount of signing fixes
585
+ * that: the client is the attacker's machine. So the only path to a board is this one — the room
586
+ * calls it, the supervisor checks the player is actually in the room, the host agent adds the
587
+ * project confinement from its own token, and control writes the row. A game that wants a score
588
+ * on a board computes it in room code.
589
+ *
590
+ * What that does and does not buy you is stated plainly, here and in the docs: it means a score
591
+ * can only come from *your server code*, so a player cannot post 999999 from the console. It does
592
+ * not mean cheat-proof. If your room code trusts a number a client wrote into its own owned
593
+ * entity, the board will faithfully record it. Anti-abuse on this platform is server authority
594
+ * plus rate limits, and there are no hidden heuristics behind that sentence.
595
+ *
596
+ * ## Semantics
597
+ *
598
+ * **Best score wins**, in the direction the board is configured with (`higher` by default,
599
+ * `lower` for times and stroke counts). A submit worse than the player's stored score is accepted
600
+ * and changes nothing; a submit *equal* to it also changes nothing, which is what makes a
601
+ * duplicate or replayed submit idempotent rather than a fresh tie-break position.
602
+ *
603
+ * Scores are whole numbers. Reads are public HTTP on the control plane (top-N and around-me),
604
+ * so there is deliberately no read here to keep in step with them.
605
+ */
606
+ /**
607
+ * D63-e: whether this room is currently willing to take a late joiner from the matchmaker.
608
+ *
609
+ * Two states and one verb, because there is nothing else to say. The room type declares
610
+ * `backfill: true` to be eligible at all; this is how a room that is eligible says "not during a
611
+ * round". A room that never declared it can call `set(true)` all it likes and will still never be
612
+ * offered — the declaration is the ceiling, and the report says so.
613
+ *
614
+ * The pattern, written out:
615
+ *
616
+ * ```ts
617
+ * // an arena: always open
618
+ * defineRoom(schema, { backfill: true })
619
+ *
620
+ * // a versus game: open between rounds
621
+ * defineRoom(schema, {
622
+ * backfill: true,
623
+ * rpc: {
624
+ * startRound(state, _params, ctx) { ctx.room.backfill.set(false); ... },
625
+ * },
626
+ * alarms: { endRound(state, room) { room.backfill.set(true); ... } },
627
+ * })
628
+ * ```
629
+ *
630
+ * The value rides the room list the host agent already polls, so it reaches the matchmaker within
631
+ * one poll interval (3 s by default) rather than instantly. That lag is why a backfilled join can
632
+ * still lose the race for the last seat, and why the client retries the queue once when it does.
633
+ */
634
+ interface RoomBackfill {
635
+ /** Open (`true`) or close (`false`) this room to backfill. Idempotent. */
636
+ set(open: boolean): void;
637
+ /** The current value. Starts at the declared `backfill` and survives hibernation. */
638
+ readonly open: boolean;
639
+ }
640
+ interface RoomRatingResult {
641
+ /** The player id the room was handed (`ctx.playerId`). Anything else is refused. */
642
+ readonly playerId: string;
643
+ /** 1-based finishing position. Equal places are a draw; places need not be dense. */
644
+ readonly place: number;
645
+ }
646
+ /**
647
+ * D63: the room's half of skill ratings — report a result, or set a number you computed yourself.
648
+ *
649
+ * ## Why there is no client-side report, ever
650
+ *
651
+ * The same sentence `RoomLeaderboard` opens with. A result a client can post is a result a client
652
+ * can forge, so the only path to a rating is this one: the room calls it, the supervisor checks
653
+ * every named player is one this room has actually held, the host agent adds the project
654
+ * confinement from its own token, and control applies the update.
655
+ *
656
+ * What that does and does not buy you, said plainly: it means a rating can only move because
657
+ * *your server code* said a match happened. It does not mean unriggable. A room that decides the
658
+ * loser won will be believed, because deciding who won is your game's job and the platform has no
659
+ * view into it. Anti-abuse here is server authority, and there are no hidden heuristics behind
660
+ * that sentence.
661
+ *
662
+ * ## Semantics
663
+ *
664
+ * One `report` is one **rating period**. The placements decompose into every unordered pair
665
+ * exactly once, each pair reading as a win, a loss or a draw, and every player is updated against
666
+ * the field as it stood *before* the report — so the answer does not depend on the order you list
667
+ * them in. The algorithm is Glicko-2 with the paper's seed for a player who has never played
668
+ * (1500, deviation 350).
669
+ *
670
+ * A player whose id is not namespaced (`irt:<subject>` from a platform identity, or `<iss>:<sub>`
671
+ * from your own JWT) cannot carry a rating: a key-join client id is gone with the socket. Such a
672
+ * player is accepted in the report and then ignored — they get no rating and nobody is rated
673
+ * against them.
674
+ */
675
+ interface RoomRatings {
676
+ /**
677
+ * Report one match. `results` names between 2 and 64 players with their finishing places.
678
+ *
679
+ * Every `playerId` must be one this room has held since it was created — `ctx.playerId` is the
680
+ * id to pass. A loser who has already left still counts, which is why the check is "has held"
681
+ * rather than "currently holds". Anything else rejects with `E_RATING_NOT_IN_ROOM`.
682
+ *
683
+ * A malformed report is refused whole rather than in part: `E_RATING_BAD_RESULTS` for a
684
+ * duplicate player, an out-of-range place, or a list that is too short or too long, and
685
+ * `E_RATING_BAD_QUEUE` for a queue name that is not one. Like every promise-returning room API,
686
+ * the continuation runs as its own event between ticks.
687
+ */
688
+ report(queue: string, results: readonly RoomRatingResult[]): Promise<void>;
689
+ /**
690
+ * Set a rating your own code computed, for a player this room has held.
691
+ *
692
+ * `deviation` is optional. Omit it and a player who already has a rating **keeps the deviation
693
+ * they had**, while a player who does not gets the seed's 350. Volatility is never taken from
694
+ * here at all.
695
+ *
696
+ * Both of those are properties of how a player's results have actually gone, which code handing
697
+ * over a rating has no view of. Resetting a settled player's deviation to 350 because you did
698
+ * not mention it would quietly make them provisional again and change who the queue is willing
699
+ * to match them with. Pass one when you mean to change it.
700
+ */
701
+ set(queue: string, playerId: string, value: {
702
+ readonly rating: number;
703
+ readonly deviation?: number;
704
+ }): Promise<void>;
705
+ }
706
+ interface RoomLeaderboard {
707
+ /**
708
+ * Post `score` for `playerId` on `board`.
709
+ *
710
+ * `playerId` must be a player currently in this room — `ctx.playerId` is the id to pass, and
711
+ * anything else rejects with `E_LB_NOT_IN_ROOM`. Board names are 1-64 of `a-z 0-9 . _ -`.
712
+ * Rejections name what they hit: `E_LB_BAD_BOARD`, `E_LB_BAD_SCORE`, `E_LB_NOT_IN_ROOM`,
713
+ * `E_LB_PROJECT_FULL`, `E_LB_UNAVAILABLE`. Like every promise-returning room API, the
714
+ * continuation runs as its own event between ticks.
715
+ */
716
+ submit(board: string, playerId: string, score: number): Promise<void>;
717
+ }
178
718
  interface Ctx<S extends AnySchema = AnySchema> {
179
719
  readonly clientId: string;
180
720
  /**
@@ -218,6 +758,76 @@ interface RoomConfigBase<S extends AnySchema> {
218
758
  readonly reconnectGraceMs?: number;
219
759
  /** Default 64. */
220
760
  readonly maxClients?: number;
761
+ /**
762
+ * D63-e: may the matchmaker send a late joiner into this room while it is already running?
763
+ *
764
+ * Default false, which is what every room that predates this option is and stays. A room that
765
+ * says nothing is never offered to a queue, because "a stranger may walk into your game
766
+ * halfway through" is not a thing to opt anybody into by accident.
767
+ *
768
+ * This is the room type's *declaration*. Whether the room is accepting one right now is
769
+ * `room.backfill.set(open)`, which a versus game closes at round start and opens at round end.
770
+ * The declaration is the ceiling: a room that never declared it cannot open itself.
771
+ *
772
+ * What this flag does NOT do is admit anybody. `maxClients` is still enforced in the worker,
773
+ * so a backfilled join that loses the race for the last seat is refused with `E_ROOM_FULL`
774
+ * exactly as any other join would be, and the client retries the queue once. There is no
775
+ * reservation and no admission hook; the room's own state is the room's business.
776
+ */
777
+ readonly backfill?: boolean;
778
+ /**
779
+ * D58: how much worker heap this room type declares it needs, in MB.
780
+ *
781
+ * Declared rather than inferred. A tenant VM's memory has to be divided between the rooms that
782
+ * are awake in it, and without a declaration the supervisor can only give every room the same
783
+ * derived cap, which means a notifications room that holds a few hundred bytes is sized like the
784
+ * physics room next to it. With one, the VM budget becomes arithmetic the supervisor can check
785
+ * at boot: the declared budgets of the awake mix, plus its own baseline, plus a native reserve.
786
+ *
787
+ * Omit it and the room keeps the cap derived from the VM (or the generous dev default). Declare
788
+ * it and this is the worker's old-generation limit, which is a hard ceiling: a room that exceeds
789
+ * it dies as a room crash rather than taking its siblings' VM down with it.
790
+ */
791
+ readonly memoryMb?: number;
792
+ /**
793
+ * M5 part 3.5: how many rooms of this type may be awake at the same time in one tenant.
794
+ *
795
+ * This is the second half of `memoryMb`, and without it the first half cannot size anything. A
796
+ * declared heap says what one room costs; a tenant VM has to hold every room that is awake in
797
+ * it, and until this existed nothing could turn "96 MB per arena" into "how big is the machine".
798
+ * Part 2 summed every declared type exactly once and wrote the pessimism down as a debt; this is
799
+ * the number that discharges it.
800
+ *
801
+ * It is **enforced, not advisory**: the supervisor refuses to start room N+1 of a type with an
802
+ * error naming the type, the limit and this field. That refusal is what makes the arithmetic
803
+ * true rather than optimistic. Raising it is a declaration change, so it takes effect at the
804
+ * tenant's next placement rather than immediately, and it grows the VM.
805
+ *
806
+ * Omit it and the type is counted at one awake room, which is the conservative reading and what
807
+ * an undeclared project has always effectively been given.
808
+ */
809
+ readonly maxAwake?: number;
810
+ /**
811
+ * How long a room of this type outlives its last activity, as `'<n>m'`, `'<n>h'` or `'<n>d'`.
812
+ *
813
+ * Omit it and the room's state is kept forever, which is what every room has always had and
814
+ * stays the default. Declare it and the platform deletes the room's stored state once that long
815
+ * has passed since the room last went to sleep: its snapshot, its save generations and its
816
+ * durable alarms. Player KV and leaderboards are keyed to the player rather than to the room and
817
+ * are never touched.
818
+ *
819
+ * This is the one declaration in a room file that deletes data, so it is deliberately awkward to
820
+ * write by accident: the value is a duration string rather than a number of milliseconds, the
821
+ * grammar takes one integer and one unit (no `'1h30m'`, no seconds, no fractions), and the floor
822
+ * is one minute. Anything else is refused here by name rather than rounded into something
823
+ * plausible.
824
+ *
825
+ * The window is a floor, not a deadline. The platform sweeps for expired rooms on a fixed
826
+ * cadence, so `'10m'` means "at least ten minutes, then deleted on the next sweep". Declare it
827
+ * on room types whose state is worth nothing once the players have gone: a match, a lobby, a
828
+ * draft. Do not declare it on a room holding anything a player expects to come back to.
829
+ */
830
+ readonly retention?: string;
221
831
  /**
222
832
  * D22: run a Rapier world on the fixed timestep. Tick mode only. Every collection whose schema
223
833
  * declares `physics` needs an entry in `bodies`, and vice versa.
@@ -233,6 +843,31 @@ interface RoomConfigBase<S extends AnySchema> {
233
843
  * surviving hibernation is the entire point of D26. This map is code, so it is always there.
234
844
  */
235
845
  readonly alarms?: Readonly<Record<string, (state: State<S>, room: Room<S>) => void>>;
846
+ /**
847
+ * D59: the room's bus handlers.
848
+ *
849
+ * They live in the config for the same reason `alarms` does, and it is the load-bearing reason
850
+ * rather than a stylistic one: a `send` wakes a hibernated room to deliver, and a handler
851
+ * registered at runtime would not have survived the hibernation. This map is code, so it is
852
+ * always there, including on the first tick after a wake.
853
+ *
854
+ * Declaring a channel in `channels` also subscribes the room to it, on start and on every wake.
855
+ * That is what makes the common case require no `room.bus.subscribe` call and what makes the
856
+ * subscription come back after a hibernation the bus deliberately dropped it across.
857
+ */
858
+ readonly bus?: BusConfig<S>;
859
+ /**
860
+ * D44: NPC brains, by name. `room.spawnNPC({ brain: { kind: 'script', script: 'chaser' } })`
861
+ * runs the entry called `chaser`.
862
+ *
863
+ * They live in the config, like `alarms`, for a sharper reason than symmetry: the script runs in
864
+ * the **supervisor** process, driving a real client session, and the only thing that crosses the
865
+ * worker boundary is its name. A callback passed to `spawnNPC` could not make that trip.
866
+ *
867
+ * These are the only handlers in a room definition that may be async, because a bot script is a
868
+ * loop that awaits: `while (!npc.stopped) { …; await npc.wait(100) }`.
869
+ */
870
+ readonly npcs?: Readonly<Record<string, NpcScript<S>>>;
236
871
  onCreate?(state: State<S>, room: Room<S>): void;
237
872
  onJoin?(state: State<S>, ctx: Ctx<S>): void;
238
873
  onLeave?(state: State<S>, ctx: Ctx<S>, reason: LeaveReason): void;
@@ -254,6 +889,7 @@ interface ResolvedRoomConfig<S extends AnySchema> extends RoomConfigBase<S> {
254
889
  readonly idleMs: number;
255
890
  readonly reconnectGraceMs: number;
256
891
  readonly maxClients: number;
892
+ readonly backfill: boolean;
257
893
  readonly rpc: RpcImplementations<S>;
258
894
  }
259
895
  interface RoomDefinition<S extends AnySchema = AnySchema> {
@@ -269,7 +905,52 @@ declare const DEFAULTS: {
269
905
  readonly idleMs: 30000;
270
906
  readonly reconnectGraceMs: 30000;
271
907
  readonly maxClients: 64;
908
+ readonly backfill: false;
272
909
  };
910
+ /**
911
+ * The ceiling on a declared `maxAwake`.
912
+ *
913
+ * Kept as a literal here rather than imported from `@irtio/protocol`'s `MAX_AWAKE_MAX`, which is
914
+ * the same number for the same reason. `@irtio/server` is on the room-bundle side of the fence:
915
+ * everything it imports is bundled into every deployed room, and taking a dependency on the fleet
916
+ * protocol module to read one integer would put the whole of it there. The two are pinned equal by
917
+ * `packages/protocol/test/sizing.test.ts`'s companion assertion in the server's own tests, so a
918
+ * drift is a failing test rather than a silently different limit.
919
+ */
920
+ declare const MAX_AWAKE_MAX = 256;
921
+ /**
922
+ * The bounds on a declared `memoryMb`.
923
+ *
924
+ * Literals here for the same fence reason as {@link MAX_AWAKE_MAX}: `@irtio/server` is on the
925
+ * room-bundle side, so it may not import `@irtio/protocol`'s `ROOM_MEMORY_MB_MIN` /
926
+ * `ROOM_MEMORY_MB_MAX` to read two integers. `packages/supervisor/test/declaration-doors.test.ts`
927
+ * asserts the pairs equal from a package that legitimately depends on both, so a drift is a failing
928
+ * test rather than two doors with different opinions about the same number.
929
+ *
930
+ * The floor is the one `deriveWorkerHeapCaps` uses: below it a worker cannot boot a room at all, so
931
+ * accepting a smaller declaration would only move the failure later. The ceiling is M5 part 3.6's:
932
+ * 1024 is Large's advertised and priced 1 GiB basis, and a declaration above it would be a room
933
+ * paying Large's rate for memory no class covers.
934
+ */
935
+ declare const MEMORY_MB_MIN = 32;
936
+ declare const MEMORY_MB_MAX = 1024;
937
+ /**
938
+ * The grammar and the bounds on a declared `retention`.
939
+ *
940
+ * Literals here for the same fence reason as {@link MEMORY_MB_MIN}: `@irtio/server` is bundled
941
+ * into every deployed room, so it may not import `@irtio/protocol`'s `RETENTION_RE`,
942
+ * `RETENTION_MIN_MS` and `RETENTION_MAX_MS` to read one pattern and two integers.
943
+ * `packages/supervisor/test/declaration-doors.test.ts` asserts the pairs equal from a package that
944
+ * legitimately depends on both, so a drift is a failing test rather than two doors with different
945
+ * opinions about which declarations delete data.
946
+ *
947
+ * A whole minute is the floor because the platform sweeps for expired rooms on a cadence measured
948
+ * in minutes: a shorter window would be a number the platform could not honour. Ten years is the
949
+ * ceiling because a longer one is asking for "forever", which is written by leaving the field out.
950
+ */
951
+ declare const RETENTION_RE: RegExp;
952
+ declare const RETENTION_MIN_MS = 60000;
953
+ declare const RETENTION_MAX_MS: number;
273
954
  /**
274
955
  * Validates a room config and returns the definition the runtime loads. Throws on: unknown
275
956
  * mode, bad tickRate, `tick` missing in tick mode or present in event mode, `async`/generator
@@ -280,4 +961,4 @@ declare function defineRoom<S extends AnySchema>(schema: S, config: RoomConfig<S
280
961
  /** Type guard for what a bundle's default export should be. */
281
962
  declare function isRoomDefinition(v: unknown): v is RoomDefinition;
282
963
 
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 };
964
+ export { type Behaviour, type BodyFactories, type BodySpec, type BusConfig, type BusEvent, type BusMessage, type ClientInfo, type Ctx, DEFAULTS, 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 RapierColliderDesc, type RapierModule, type RapierPhysicsConfig, type RapierRigidBody, type RapierRigidBodyDesc, type RapierWorld, type ResolvedRoomConfig, 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 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,47 @@
1
+ // src/npc.ts
2
+ var ZERO = { x: 0, y: 0 };
3
+ function scaleTo(dx, dy, speed) {
4
+ const len = Math.hypot(dx, dy);
5
+ if (len === 0 || speed === 0) return ZERO;
6
+ return { x: dx / len * speed, y: dy / len * speed };
7
+ }
8
+ function seek(from, to, speed) {
9
+ return scaleTo(to.x - from.x, to.y - from.y, speed);
10
+ }
11
+ function flee(from, threat, speed) {
12
+ return scaleTo(from.x - threat.x, from.y - threat.y, speed);
13
+ }
14
+ function arrive(from, to, speed, slowRadius) {
15
+ const dx = to.x - from.x;
16
+ const dy = to.y - from.y;
17
+ const dist = Math.hypot(dx, dy);
18
+ if (dist === 0) return ZERO;
19
+ const wanted = slowRadius > 0 && dist < slowRadius ? speed * dist / slowRadius : speed;
20
+ return scaleTo(dx, dy, wanted);
21
+ }
22
+ function newWander(random) {
23
+ return { angle: random() * Math.PI * 2 };
24
+ }
25
+ function wander(state, speed, turn, random) {
26
+ state.angle += (random() * 2 - 1) * turn;
27
+ return { x: Math.cos(state.angle) * speed, y: Math.sin(state.angle) * speed };
28
+ }
29
+ function patrol(from, waypoints, index, speed, reachedRadius) {
30
+ if (waypoints.length === 0) return { velocity: ZERO, index: 0 };
31
+ let at = (index % waypoints.length + waypoints.length) % waypoints.length;
32
+ const target = waypoints[at];
33
+ if (Math.hypot(target.x - from.x, target.y - from.y) <= reachedRadius) {
34
+ at = (at + 1) % waypoints.length;
35
+ }
36
+ return { velocity: seek(from, waypoints[at], speed), index: at };
37
+ }
38
+ function choose(behaviours) {
39
+ for (const b of behaviours) {
40
+ if (b.when()) return b;
41
+ }
42
+ return void 0;
43
+ }
44
+
1
45
  // src/index.ts
2
46
  var ROOM_DEFINITION_VERSION = 1;
3
47
  var DEFAULTS = {
@@ -5,8 +49,29 @@ var DEFAULTS = {
5
49
  tickRate: 20,
6
50
  idleMs: 3e4,
7
51
  reconnectGraceMs: 3e4,
8
- maxClients: 64
52
+ maxClients: 64,
53
+ // D63-e: off. A room is not offered to strangers unless it says so.
54
+ backfill: false
55
+ };
56
+ var MAX_AWAKE_MAX = 256;
57
+ var MEMORY_MB_MIN = 32;
58
+ var MEMORY_MB_MAX = 1024;
59
+ var RETENTION_RE = /^[1-9][0-9]{0,4}(m|h|d)$/;
60
+ var RETENTION_MIN_MS = 6e4;
61
+ var RETENTION_MAX_MS = 3650 * 24 * 60 * 60 * 1e3;
62
+ var RETENTION_UNIT_MS = {
63
+ m: 6e4,
64
+ h: 60 * 60 * 1e3,
65
+ d: 24 * 60 * 60 * 1e3
9
66
  };
67
+ function parseRetentionDeclaration(raw) {
68
+ if (typeof raw !== "string" || !RETENTION_RE.test(raw)) return void 0;
69
+ const scale = RETENTION_UNIT_MS[raw.slice(-1)];
70
+ if (scale === void 0) return void 0;
71
+ const ms = Number(raw.slice(0, -1)) * scale;
72
+ if (ms < RETENTION_MIN_MS || ms > RETENTION_MAX_MS) return void 0;
73
+ return ms;
74
+ }
10
75
  var HANDLER_KEYS = [
11
76
  "onCreate",
12
77
  "onJoin",
@@ -29,6 +94,28 @@ function defineRoom(schema, config) {
29
94
  if (!Number.isInteger(tickRate) || tickRate < 1 || tickRate > 240) {
30
95
  throw new Error(`defineRoom: tickRate must be an integer in 1..240, got ${String(tickRate)}`);
31
96
  }
97
+ if (config.memoryMb !== void 0) {
98
+ if (!Number.isInteger(config.memoryMb) || config.memoryMb < MEMORY_MB_MIN || config.memoryMb > MEMORY_MB_MAX) {
99
+ throw new Error(
100
+ `defineRoom: memoryMb must be an integer in ${MEMORY_MB_MIN}..${MEMORY_MB_MAX} MB, got ${String(config.memoryMb)}`
101
+ );
102
+ }
103
+ }
104
+ if (config.maxAwake !== void 0) {
105
+ if (!Number.isInteger(config.maxAwake) || config.maxAwake < 1 || config.maxAwake > MAX_AWAKE_MAX) {
106
+ throw new Error(
107
+ `defineRoom: maxAwake must be an integer in 1..${MAX_AWAKE_MAX}, got ${String(config.maxAwake)}`
108
+ );
109
+ }
110
+ }
111
+ if (config.retention !== void 0) {
112
+ const ms = parseRetentionDeclaration(config.retention);
113
+ if (ms === void 0) {
114
+ throw new Error(
115
+ `defineRoom: retention must be a duration like '10m', '6h' or '30d' between 1m and 3650d, got ${JSON.stringify(config.retention)}`
116
+ );
117
+ }
118
+ }
32
119
  for (const k of ["idleMs", "reconnectGraceMs", "maxClients"]) {
33
120
  const v = config[k];
34
121
  if (v !== void 0 && (!Number.isFinite(v) || v < 0)) {
@@ -63,6 +150,7 @@ function defineRoom(schema, config) {
63
150
  }
64
151
  }
65
152
  checkPhysics(schema, config, mode);
153
+ checkNpcs(config);
66
154
  const resolved = {
67
155
  ...config,
68
156
  mode,
@@ -70,6 +158,7 @@ function defineRoom(schema, config) {
70
158
  idleMs: config.idleMs ?? DEFAULTS.idleMs,
71
159
  reconnectGraceMs: config.reconnectGraceMs ?? DEFAULTS.reconnectGraceMs,
72
160
  maxClients: config.maxClients ?? DEFAULTS.maxClients,
161
+ backfill: config.backfill === true,
73
162
  rpc
74
163
  };
75
164
  return Object.freeze({
@@ -79,6 +168,19 @@ function defineRoom(schema, config) {
79
168
  config: Object.freeze(resolved)
80
169
  });
81
170
  }
171
+ function checkNpcs(config) {
172
+ const npcs = config.npcs;
173
+ if (npcs === void 0) return;
174
+ if (typeof npcs !== "object" || npcs === null || Array.isArray(npcs)) {
175
+ throw new Error("defineRoom: npcs must be an object mapping a name to a script function");
176
+ }
177
+ for (const [name, fn] of Object.entries(npcs)) {
178
+ if (name === "") throw new Error("defineRoom: an npcs key must not be empty");
179
+ if (typeof fn !== "function") {
180
+ throw new Error(`defineRoom: npcs.${name} must be a function taking the npc object`);
181
+ }
182
+ }
183
+ }
82
184
  function checkPhysics(schema, config, mode) {
83
185
  const declared = schema.collections.filter((c) => c.physics !== void 0).map((c) => c.name);
84
186
  const physics = config.physics;
@@ -100,14 +202,19 @@ function checkPhysics(schema, config, mode) {
100
202
  "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
203
  );
102
204
  }
103
- if (physics.engine !== "rapier3d") {
205
+ if (physics.engine !== "rapier3d" && physics.engine !== "matter2d") {
104
206
  throw new Error(
105
- `defineRoom: physics.engine must be 'rapier3d' (got ${JSON.stringify(physics.engine)}); matter.js is not in M2`
207
+ `defineRoom: physics.engine must be 'rapier3d' or 'matter2d', got ${JSON.stringify(
208
+ physics.engine
209
+ )}`
106
210
  );
107
211
  }
108
212
  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");
213
+ const planar = physics.engine === "matter2d";
214
+ if (!g || typeof g !== "object" || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !planar && !Number.isFinite(g.z)) {
215
+ throw new Error(
216
+ planar ? "defineRoom: physics.gravity must be { x, y } finite numbers" : "defineRoom: physics.gravity must be { x, y, z } finite numbers"
217
+ );
111
218
  }
112
219
  if (physics.timestep !== void 0 && (!Number.isFinite(physics.timestep) || physics.timestep <= 0)) {
113
220
  throw new Error(
@@ -128,6 +235,20 @@ function checkPhysics(schema, config, mode) {
128
235
  parts.push(`physics.bodies names collections without schema physics: ${extra.join(", ")}`);
129
236
  throw new Error(`defineRoom: ${parts.join("; ")}`);
130
237
  }
238
+ const intents = physics.intents;
239
+ if (intents !== void 0) {
240
+ if (typeof intents !== "object" || intents === null) {
241
+ throw new Error("defineRoom: physics.intents must be an object of collection -> hook");
242
+ }
243
+ for (const [k, fn] of Object.entries(intents)) assertSyncHandler(fn, `physics.intents.${k}`);
244
+ const steerable = schema.collections.filter((c) => c.physics !== void 0 && c.physics.intents.length > 0).map((c) => c.name);
245
+ const unsteerable = Object.keys(intents).filter((n) => !steerable.includes(n));
246
+ if (unsteerable.length) {
247
+ throw new Error(
248
+ `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"}`
249
+ );
250
+ }
251
+ }
131
252
  }
132
253
  function assertSyncHandler(fn, name) {
133
254
  if (fn === void 0) return;
@@ -144,7 +265,20 @@ function isRoomDefinition(v) {
144
265
  }
145
266
  export {
146
267
  DEFAULTS,
268
+ MAX_AWAKE_MAX,
269
+ MEMORY_MB_MAX,
270
+ MEMORY_MB_MIN,
271
+ RETENTION_MAX_MS,
272
+ RETENTION_MIN_MS,
273
+ RETENTION_RE,
147
274
  ROOM_DEFINITION_VERSION,
275
+ arrive,
276
+ choose,
148
277
  defineRoom,
149
- isRoomDefinition
278
+ flee,
279
+ isRoomDefinition,
280
+ newWander,
281
+ patrol,
282
+ seek,
283
+ wander
150
284
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/server",
3
- "version": "0.5.1",
3
+ "version": "0.6.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.1"
23
+ "@irtio/schema": "0.6.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",