@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 +686 -5
- package/dist/index.js +140 -6
- package/package.json +10 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,181 @@
|
|
|
1
|
-
import { AnySchema,
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
/**
|
|
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'
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|