@irtio/server 0.7.0 → 0.8.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,7 +1,168 @@
1
- import { AnySchema, RoleOf, VisibleKeys, SchemaDefs, EntityDef, ReadonlyCollection, DeepReadonly, InferFields, Owned, SingletonDef, PhysicsKeys, InstanceOf, State, ServerMessageChannels, ServerCallProxy, SchemaRpc, BroadcastProxy, OwnableKeys, MessageNames, MessageValue, Implementations, ServerRpcs } from '@irtio/schema';
1
+ import * as _irtio_schema from '@irtio/schema';
2
+ import { SingletonDef, EntityDef, AnySchema, RoleOf, VisibleKeys, SchemaDefs, ReadonlyCollection, DeepReadonly, InferFields, Owned, PhysicsKeys, InstanceOf, State, ServerMessageChannels, ServerCallProxy, SchemaRpc, BroadcastProxy, OwnableKeys, MessageNames, MessageValue, Implementations, ServerRpcs } from '@irtio/schema';
3
+ import RAPIER2D from '@dimforge/rapier2d-compat';
2
4
  import RAPIER from '@dimforge/rapier3d-compat';
3
5
  import * as MATTER from 'matter-js';
4
6
 
7
+ /** The singleton the platform keeps the lobby's own state in. */
8
+ declare const LOBBY_STATE = "irtLobby";
9
+ /** The per-player collection the ready flag lives in, one instance per connected player. */
10
+ declare const LOBBY_MEMBERS = "irtLobbyMembers";
11
+ /** Phases a lobby room passes through. There are two, and it never goes back. */
12
+ type LobbyPhase = 'lobby' | 'started';
13
+ /**
14
+ * The lobby's own state, as every client sees it.
15
+ *
16
+ * `readyUi` is here rather than derived on the client because the start policy is the room's
17
+ * declaration and the element must not have to be told it twice. `present`, `ready` and `capacity`
18
+ * are the three numbers a lobby panel renders ("2/4 players, 1 ready"), published rather than
19
+ * counted client-side so that every client counts them the same way the room did.
20
+ */
21
+ declare const lobbyStateSingleton: SingletonDef<{
22
+ phase: _irtio_schema.Type<"lobby" | "started", false>;
23
+ /** Whether the room is currently in the public registry. The room owns this truth. */
24
+ public: _irtio_schema.Type<boolean, false>;
25
+ /** True only under `start: 'when-ready'`. The element draws readiness only when this is set. */
26
+ readyUi: _irtio_schema.Type<boolean, false>;
27
+ /** Connected players the lobby is counting. */
28
+ present: _irtio_schema.Type<number, false>;
29
+ /** How many of them are ready. Always 0 outside `'when-ready'`. */
30
+ ready: _irtio_schema.Type<number, false>;
31
+ /** The number `'when-full'` starts at: the room's `maxClients`. */
32
+ capacity: _irtio_schema.Type<number, false>;
33
+ }, {}>;
34
+ /** One player's lobby record, owned by that player so their own ready flag is an owner write. */
35
+ declare const lobbyMemberEntity: EntityDef<{
36
+ ready: _irtio_schema.Type<boolean, false>;
37
+ }, {}>;
38
+ /**
39
+ * Spread this into a schema to give the room a lobby.
40
+ *
41
+ * ```ts
42
+ * export const schema = defineSchema({ ...lobbyCollections, players: entity({ x: f32, y: f32 }) });
43
+ * ```
44
+ */
45
+ declare const lobbyCollections: {
46
+ readonly [LOBBY_STATE]: typeof lobbyStateSingleton;
47
+ readonly [LOBBY_MEMBERS]: typeof lobbyMemberEntity;
48
+ };
49
+ /**
50
+ * When a lobby room starts its game.
51
+ *
52
+ * - `'when-full'` (**default**) starts the moment the room reaches `maxClients`. There is no ready
53
+ * button: everyone who landed here asked to play, which is exactly true of the quick-match path
54
+ * and is why this is the default rather than the polite-looking one.
55
+ * - `'when-ready'` starts when every present player is ready and `min` is met. This is the one
56
+ * that surfaces a ready button.
57
+ * - `'manual'` never starts by itself. The room's own code calls `room.lobby.start()` on whatever
58
+ * it likes: a timer, a host's click, a paid-up check.
59
+ */
60
+ type LobbyStart = 'when-full' | 'when-ready' | 'manual';
61
+ /** The floor on a `min`, and on a `'when-full'` capacity. One player is not a lobby. */
62
+ declare const LOBBY_MIN_PLAYERS = 2;
63
+ interface LobbyConfig<S = unknown> {
64
+ /** Default `'when-full'`. */
65
+ readonly start?: LobbyStart;
66
+ /**
67
+ * The fewest players `'when-ready'` will start with. Default 2.
68
+ *
69
+ * Ignored by the other two policies: `'when-full'` has {@link LobbyConfig.size} and `'manual'`
70
+ * has the room's own judgement.
71
+ */
72
+ readonly min?: number;
73
+ /**
74
+ * How many players `'when-full'` counts as full. Defaults to the room's `maxClients`.
75
+ *
76
+ * The design (`docs/lobby-quick-match-plan.md` §5) words this as "`maxClients`, or the queue's
77
+ * party size when smaller", and the platform cannot supply the second half: a room is created by
78
+ * the ordinary join path and never learns which queue sent the player, by construction. That is
79
+ * the property the whole quick-match design rests on ("the room neither knows nor cares that a
80
+ * matchmaker filled it"), so making it knowable would cost more than this field does.
81
+ *
82
+ * So the room declares it. A game whose `duo` queue seats two but whose room type allows eight
83
+ * spectators writes `lobby: { size: 2 }`, and the lobby starts at two rather than at eight.
84
+ * Clamped to `maxClients`: a lobby cannot wait for more players than the room will admit.
85
+ */
86
+ readonly size?: number;
87
+ /**
88
+ * The queue this room is filed under when it is public. Default `'default'`.
89
+ *
90
+ * A public room has to be findable under some queue name, and the room is the only thing that
91
+ * knows which game it is. A room that came from `mode: "public"` keeps the queue it was minted
92
+ * under regardless of this — the players already inside came from that queue, and moving a
93
+ * half-full lobby elsewhere would advertise it to people the first ones did not ask to play
94
+ * with. This is what a room that went public any other way is filed as.
95
+ */
96
+ readonly queue?: string;
97
+ /**
98
+ * Called once, when the game starts, whichever policy started it.
99
+ *
100
+ * In the config rather than registered at runtime, for the reason `alarms` is: a room that
101
+ * hibernated and woke would have lost a registered callback, and this map is code.
102
+ */
103
+ onStart?(state: S, room: unknown): void;
104
+ /**
105
+ * The veto on `room.lobby.setPublic(value)`.
106
+ *
107
+ * Return `false` to refuse. The registry is not touched, the singleton keeps the value it had,
108
+ * and every client's panel goes on rendering the room's state rather than the click — which is
109
+ * the property §4 of the design asks for and the reason the toggle reads the room rather than
110
+ * its own button.
111
+ *
112
+ * A room with no hook accepts every toggle, and a handler that returns nothing accepts too: the
113
+ * convention is `onMessage`'s, where only an explicit `false` refuses. A handler that THROWS
114
+ * refuses, because a hook that said nothing at all has not agreed to expose the game.
115
+ *
116
+ * There is no client id here, and that is a consequence of there being no built-in RPC: the
117
+ * platform never receives a toggle from a client. A game that wants one declares its own RPC and
118
+ * calls `room.lobby.setPublic` from it, where it has `ctx.clientId` and can decide whether that
119
+ * player is the host. See the panel's `public` event.
120
+ */
121
+ onSetPublic?(state: S, value: boolean): boolean | undefined | void;
122
+ }
123
+ /**
124
+ * `room.lobby` — the room's half of the convention.
125
+ *
126
+ * Reading it in a room whose schema does not spread `lobbyCollections` throws and says so, the way
127
+ * `room.physics` does in a room with no physics: a lobby API that silently did nothing would be a
128
+ * ready button nobody could debug.
129
+ */
130
+ interface RoomLobby {
131
+ readonly phase: LobbyPhase;
132
+ /** Per-player readiness, by client id, in join order. */
133
+ readonly ready: readonly {
134
+ readonly clientId: string;
135
+ readonly ready: boolean;
136
+ }[];
137
+ /** Whether this room is currently in the public registry. */
138
+ readonly public: boolean;
139
+ /**
140
+ * Start the game now. Idempotent: a room already `'started'` is unchanged and no handler runs
141
+ * twice. Legal under every policy, not only `'manual'` — a `'when-ready'` room with a host
142
+ * button is an ordinary thing to build.
143
+ */
144
+ start(): void;
145
+ /**
146
+ * Register a start handler from `onCreate`. Additive to the declared `lobby.onStart`, and the
147
+ * declared one is the one that survives a hibernation; this is for a handler a room wires up
148
+ * itself and is dropped when the room sleeps.
149
+ */
150
+ onStart(cb: () => void): void;
151
+ /**
152
+ * Put this room in, or take it out of, the public registry.
153
+ *
154
+ * Server-authoritative: a client asks through the panel's toggle, the room's `onSetPublic` may
155
+ * refuse, and what every panel renders is the answer rather than the request. Deregistration on
156
+ * start is automatic and terminal — no backfill into started games in v1 — so this is only ever
157
+ * about a lobby.
158
+ */
159
+ setPublic(value: boolean): void;
160
+ }
161
+ /** Does this schema carry the lobby fragment? The runtime and `defineRoom` ask the same way. */
162
+ declare function schemaHasLobby(schema: unknown): boolean;
163
+ /** Validates a `lobby:` config on its way into `defineRoom`. Returns the problem, or `undefined`. */
164
+ declare function lobbyConfigProblem(value: unknown): string | undefined;
165
+
5
166
  /**
6
167
  * D44: scripted NPCs — the room-side surface.
7
168
  *
@@ -198,6 +359,17 @@ type RapierRigidBody = RAPIER.RigidBody;
198
359
  type RapierCollider = RAPIER.Collider;
199
360
  type RapierRigidBodyDesc = RAPIER.RigidBodyDesc;
200
361
  type RapierColliderDesc = RAPIER.ColliderDesc;
362
+ /**
363
+ * The 2D build of the same engine. Its namespace is a separate module with its own WASM, so the
364
+ * types are separate too: a `rapier2d` `World` is not a `rapier3d` `World` with a field removed,
365
+ * and letting the two share a name would make every accessor lie.
366
+ */
367
+ type Rapier2dModule = typeof RAPIER2D;
368
+ type Rapier2dWorld = RAPIER2D.World;
369
+ type Rapier2dRigidBody = RAPIER2D.RigidBody;
370
+ type Rapier2dCollider = RAPIER2D.Collider;
371
+ type Rapier2dRigidBodyDesc = RAPIER2D.RigidBodyDesc;
372
+ type Rapier2dColliderDesc = RAPIER2D.ColliderDesc;
201
373
  type MatterModule = typeof MATTER;
202
374
  type MatterEngine = MATTER.Engine;
203
375
  type MatterBody = MATTER.Body;
@@ -227,6 +399,16 @@ interface Matter2dBodySpec {
227
399
  /** Added to the world with the body, and removed with it. */
228
400
  readonly constraints?: readonly MatterConstraint[];
229
401
  }
402
+ /**
403
+ * What a rapier2d body factory returns. Structurally the 3D {@link BodySpec} with 2D descs: the
404
+ * engine is the same one, so a body still has colliders attached to it rather than *being* its
405
+ * geometry the way a matter.js body does.
406
+ */
407
+ interface Rapier2dBodySpec {
408
+ readonly body: Rapier2dRigidBodyDesc;
409
+ /** Attached to the body in order. A body with none is a valid (invisible) point mass. */
410
+ readonly colliders?: readonly Rapier2dColliderDesc[];
411
+ }
230
412
  type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf<SchemaDefs<S>[K]>;
231
413
  /**
232
414
  * `physics.bodies.<collection>` — how one instance becomes a rigid body. Called when the runtime
@@ -237,6 +419,10 @@ type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf
237
419
  type BodyFactories<S extends AnySchema> = {
238
420
  readonly [K in PhysicsKeys<S>]: (rapier: RapierModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => BodySpec;
239
421
  };
422
+ /** `physics.bodies.<collection>` for rapier2d. Same lifecycle as {@link BodyFactories}. */
423
+ type Rapier2dBodyFactories<S extends AnySchema> = {
424
+ readonly [K in PhysicsKeys<S>]: (rapier: Rapier2dModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => Rapier2dBodySpec;
425
+ };
240
426
  /** `physics.bodies.<collection>` for matter2d. Same lifecycle as {@link BodyFactories}. */
241
427
  type Matter2dBodyFactories<S extends AnySchema> = {
242
428
  readonly [K in PhysicsKeys<S>]: (matter: MatterModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => Matter2dBodySpec;
@@ -262,7 +448,7 @@ declare const HISTORY_MAX_TICKS = 240;
262
448
  /**
263
449
  * D72: what `room.rewind(tick, fn)` hands `fn`.
264
450
  *
265
- * Exactly one of `rapier` and `matter` is present, decided by the room's engine.
451
+ * Exactly one of `rapier`, `rapier2d` and `matter` is present, decided by the room's engine.
266
452
  *
267
453
  * What a rewound query **can** see: every tracked body's pose at that tick, and the world's static
268
454
  * geometry as it stands now. What it **cannot**: colliders as they were then (shapes are not
@@ -289,6 +475,15 @@ interface RewindView {
289
475
  readonly id: string;
290
476
  } | undefined;
291
477
  };
478
+ /** rapier2d rooms: a scratch world to `castRay` / `castShape` / `intersectionsWithShape` on. */
479
+ readonly rapier2d?: {
480
+ readonly world: Rapier2dWorld;
481
+ /** Which entity a collider in the scratch world belongs to; `undefined` for static geometry. */
482
+ who(collider: Rapier2dCollider): {
483
+ readonly collection: string;
484
+ readonly id: string;
485
+ } | undefined;
486
+ };
292
487
  /** matter2d rooms: the body array `Matter.Query.ray/point/region/collides` takes. */
293
488
  readonly matter?: {
294
489
  readonly bodies: readonly MatterBody[];
@@ -353,7 +548,66 @@ interface Matter2dPhysicsConfig<S extends AnySchema> {
353
548
  /** D72: how many ticks of body poses to keep for `room.rewind`. See {@link HISTORY_DOC}. */
354
549
  readonly history?: number;
355
550
  }
356
- type PhysicsConfig<S extends AnySchema> = RapierPhysicsConfig<S> | Matter2dPhysicsConfig<S>;
551
+ /**
552
+ * The rapier2d counterpart to {@link Matter2dIntentHook}: one step of steering for one body, from
553
+ * the fields a client may write.
554
+ *
555
+ * Same contract, different engine handles — and one real difference in what the hook has to do.
556
+ * A matter2d hook applies the room's gravity itself, because matter's `engine.gravity` is often
557
+ * not what a game wants per body; a rapier2d hook must not, because the world already did. See
558
+ * {@link Rapier2dPhysicsConfig}.
559
+ */
560
+ type Rapier2dIntentHook = {
561
+ hook(body: Rapier2dRigidBody, instance: Record<string, unknown>, rapier: Rapier2dModule, world: Rapier2dWorld, timestep: number): void;
562
+ }['hook'];
563
+ /**
564
+ * D45's third engine: Rapier, in the plane.
565
+ *
566
+ * It is `rapier3d` minus a dimension rather than `matter2d` with different names, and the
567
+ * difference matters in three places a room author will feel:
568
+ *
569
+ * - **The world applies gravity.** `gravity` goes into the `World` and every dynamic body falls
570
+ * without the room doing anything. matter2d rooms apply it per body from their own hooks;
571
+ * a rapier2d room that does the same falls twice as fast.
572
+ * - **Velocities are per second.** `body.linvel()` is metres per second, not matter's per-step
573
+ * displacement, so a hook writes the speed it means.
574
+ * - **The world snapshots.** `world.takeSnapshot()` rides inside the hibernation blob, so a woken
575
+ * room is the world that went to sleep, contacts included — a settled pile does not re-settle
576
+ * with a jolt the way a rebuilt matter2d one can.
577
+ *
578
+ * What it keeps from matter2d is the plane itself: `{ x, y }` gravity, no `z` channel, and the
579
+ * 2D shapes. The friction trap that made a 2D engine worth having (bug 6, the axis-lock recipe)
580
+ * cannot arise here — there is no third axis to lock.
581
+ */
582
+ interface Rapier2dPhysicsConfig<S extends AnySchema> {
583
+ readonly engine: 'rapier2d';
584
+ /** In the plane. Rapier's convention is y-up; irtio does not flip it for you. */
585
+ readonly gravity: Vector2;
586
+ /** Seconds per world step. Defaults to the tick interval; one step per tick, no substeps. */
587
+ readonly timestep?: number;
588
+ /** Static geometry, joints, world tuning. Same lifecycle rules as rapier3d's `setup`. */
589
+ setup?(world: Rapier2dWorld, rapier: Rapier2dModule, room: Room<S>): void;
590
+ readonly bodies: Rapier2dBodyFactories<S>;
591
+ /**
592
+ * The shared per-collection steering functions, alongside `bodies`. **The runtime does not call
593
+ * these** — see {@link Matter2dPhysicsConfig.intents} for the whole of what the field buys and
594
+ * what `defineRoom` checks about it. The rule is identical here.
595
+ */
596
+ readonly intents?: Readonly<Partial<Record<PhysicsKeys<S> & string, Rapier2dIntentHook>>>;
597
+ /** D72: how many ticks of body poses to keep for `room.rewind`. See {@link HISTORY_DOC}. */
598
+ readonly history?: number;
599
+ }
600
+ /** `room.physicsRapier2d` in a rapier2d room. */
601
+ interface Rapier2dRoomApi<S extends AnySchema> {
602
+ /** The `@dimforge/rapier2d-compat` namespace: descs, shapes, enums, `QueryFilterFlags`, … */
603
+ readonly rapier: Rapier2dModule;
604
+ /** The live world. Ray casts, static colliders added later, joints. */
605
+ readonly world: Rapier2dWorld;
606
+ /** Seconds per step (`world.timestep`). */
607
+ readonly timestep: number;
608
+ body(collection: PhysicsKeys<S> & string, id: string): Rapier2dRigidBody | undefined;
609
+ }
610
+ type PhysicsConfig<S extends AnySchema> = RapierPhysicsConfig<S> | Matter2dPhysicsConfig<S> | Rapier2dPhysicsConfig<S>;
357
611
  /**
358
612
  * `room.physics2d` in a matter2d room.
359
613
  *
@@ -503,6 +757,15 @@ interface Room<S extends AnySchema = AnySchema> {
503
757
  * reading `room.physics` here.
504
758
  */
505
759
  readonly physics2d: Matter2dRoomApi<S>;
760
+ /**
761
+ * The rapier2d world and the bodies behind physics entities, in a room whose config declares
762
+ * `engine: 'rapier2d'`. A third accessor rather than a narrowing of `physics2d`, for the same
763
+ * reason there were two: the two 2D engines hand back different namespaces (`.rapier`/`.world`
764
+ * against `.matter`/`.engine`), and a union would make every existing matter2d room narrow
765
+ * before it could read a field it has always read. Each of the three throws a sentence naming
766
+ * the right one.
767
+ */
768
+ readonly physicsRapier2d: Rapier2dRoomApi<S>;
506
769
  /**
507
770
  * D72: run `fn` against the world as it stood at `tick`.
508
771
  *
@@ -562,6 +825,15 @@ interface Room<S extends AnySchema = AnySchema> {
562
825
  * one.
563
826
  */
564
827
  readonly bus: RoomBus;
828
+ /**
829
+ * D75: the lobby convention — who is here, who is ready, when the game starts, and whether
830
+ * strangers can find this room.
831
+ *
832
+ * Present on every `Room` type, and reading it in a room whose schema does not spread
833
+ * `lobbyCollections` throws with that sentence, exactly as `room.physics` does in a room with no
834
+ * physics. A lobby API that silently did nothing would be a ready button nobody could debug.
835
+ */
836
+ readonly lobby: RoomLobby;
565
837
  }
566
838
  /**
567
839
  * D59: two verbs with deliberately unequal guarantees. Read them as a pair, because picking the
@@ -1003,6 +1275,17 @@ interface RoomConfigBase<S extends AnySchema> {
1003
1275
  * loop that awaits: `while (!npc.stopped) { …; await npc.wait(100) }`.
1004
1276
  */
1005
1277
  readonly npcs?: Readonly<Record<string, NpcScript<S>>>;
1278
+ /**
1279
+ * D75: this room holds players at a lobby until the game starts.
1280
+ *
1281
+ * The opt-in is the schema's — `defineSchema({ ...lobbyCollections, ... })`, so both sides of
1282
+ * the wire read the same fragment from the same module — and this is the room's policy for it:
1283
+ * when to start, how few players will do, which queue it is public under, and the two handlers.
1284
+ *
1285
+ * Declaring it against a schema that does not carry the fragment is a `defineRoom` error rather
1286
+ * than a room that quietly never starts.
1287
+ */
1288
+ readonly lobby?: LobbyConfig<State<S>>;
1006
1289
  onCreate?(state: State<S>, room: Room<S>): void;
1007
1290
  onJoin?(state: State<S>, ctx: Ctx<S>): void;
1008
1291
  onLeave?(state: State<S>, ctx: Ctx<S>, reason: LeaveReason): void;
@@ -1107,4 +1390,4 @@ declare function defineRoom<S extends AnySchema>(schema: S, config: RoomConfig<S
1107
1390
  /** Type guard for what a bundle's default export should be. */
1108
1391
  declare function isRoomDefinition(v: unknown): v is RoomDefinition;
1109
1392
 
1110
- export { type Behaviour, type BodyFactories, type BodySpec, type BusConfig, type BusEvent, type BusMessage, type ClientInfo, type Ctx, DEFAULTS, HISTORY_MAX_TICKS, type LeaderboardSubmitOptions, type LeaveReason, MAX_AWAKE_MAX, MEMORY_MB_MAX, MEMORY_MB_MIN, type Matter2dBodyFactories, type Matter2dBodySpec, type Matter2dIntentHook, type Matter2dPhysicsConfig, type Matter2dRoomApi, type MatterBody, type MatterConstraint, type MatterEngine, type MatterModule, type MessageTarget, type Npc, type NpcCollection, type NpcConfig, type NpcHandle, type NpcRoom, type NpcScript, type NpcScriptBrain, type NpcState, type PatrolStep, type PhysicsConfig, type PhysicsRoomApi, type PlayerKv, RETENTION_MAX_MS, RETENTION_MIN_MS, RETENTION_RE, ROOM_DEFINITION_VERSION, type RapierCollider, type RapierColliderDesc, type RapierModule, type RapierPhysicsConfig, type RapierRigidBody, type RapierRigidBodyDesc, type RapierWorld, type ResolvedRoomConfig, type RewindView, type Room, type RoomBackfill, type RoomBus, type RoomConfig, type RoomConfigBase, type RoomDefinition, type RoomLeaderboard, type RoomMode, type RoomRatingResult, type RoomRatings, type RpcImplementations, type TimerHandle, type TypedMessage, type Validators, type Vec2, type Vector2, type Vector3, type WanderState, arrive, choose, defineRoom, flee, isRoomDefinition, newWander, patrol, seek, wander };
1393
+ export { type Behaviour, type BodyFactories, type BodySpec, type BusConfig, type BusEvent, type BusMessage, type ClientInfo, type Ctx, DEFAULTS, HISTORY_MAX_TICKS, LOBBY_MEMBERS, LOBBY_MIN_PLAYERS, LOBBY_STATE, type LeaderboardSubmitOptions, type LeaveReason, type LobbyConfig, type LobbyPhase, type LobbyStart, 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 Rapier2dBodyFactories, type Rapier2dBodySpec, type Rapier2dCollider, type Rapier2dColliderDesc, type Rapier2dIntentHook, type Rapier2dModule, type Rapier2dPhysicsConfig, type Rapier2dRigidBody, type Rapier2dRigidBodyDesc, type Rapier2dRoomApi, type Rapier2dWorld, type RapierCollider, type RapierColliderDesc, type RapierModule, type RapierPhysicsConfig, type RapierRigidBody, type RapierRigidBodyDesc, type RapierWorld, type ResolvedRoomConfig, type RewindView, type Room, type RoomBackfill, type RoomBus, type RoomConfig, type RoomConfigBase, type RoomDefinition, type RoomLeaderboard, type RoomLobby, type RoomMode, type RoomRatingResult, type RoomRatings, type RpcImplementations, type TimerHandle, type TypedMessage, type Validators, type Vec2, type Vector2, type Vector3, type WanderState, arrive, choose, defineRoom, flee, isRoomDefinition, lobbyCollections, lobbyConfigProblem, lobbyMemberEntity, lobbyStateSingleton, newWander, patrol, schemaHasLobby, seek, wander };
package/dist/index.js CHANGED
@@ -1,3 +1,74 @@
1
+ // src/lobby.ts
2
+ import {
3
+ bool,
4
+ entity,
5
+ enumOf,
6
+ singleton,
7
+ u8
8
+ } from "@irtio/schema";
9
+ var LOBBY_STATE = "irtLobby";
10
+ var LOBBY_MEMBERS = "irtLobbyMembers";
11
+ var lobbyStateSingleton = singleton({
12
+ phase: enumOf("lobby", "started"),
13
+ /** Whether the room is currently in the public registry. The room owns this truth. */
14
+ public: bool,
15
+ /** True only under `start: 'when-ready'`. The element draws readiness only when this is set. */
16
+ readyUi: bool,
17
+ /** Connected players the lobby is counting. */
18
+ present: u8,
19
+ /** How many of them are ready. Always 0 outside `'when-ready'`. */
20
+ ready: u8,
21
+ /** The number `'when-full'` starts at: the room's `maxClients`. */
22
+ capacity: u8
23
+ });
24
+ var lobbyMemberEntity = entity({ ready: bool });
25
+ var lobbyCollections = {
26
+ [LOBBY_STATE]: lobbyStateSingleton,
27
+ [LOBBY_MEMBERS]: lobbyMemberEntity
28
+ };
29
+ var LOBBY_MIN_PLAYERS = 2;
30
+ function schemaHasLobby(schema) {
31
+ const collections = schema?.collections;
32
+ if (!Array.isArray(collections)) return false;
33
+ const names = new Set(collections.map((c) => c.name));
34
+ return names.has(LOBBY_STATE) && names.has(LOBBY_MEMBERS);
35
+ }
36
+ function lobbyConfigProblem(value) {
37
+ if (value === void 0) return void 0;
38
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
39
+ return 'lobby must be an object like { start: "when-ready" }';
40
+ }
41
+ const config = value;
42
+ if (config.start !== void 0 && config.start !== "when-full" && config.start !== "when-ready" && config.start !== "manual") {
43
+ return `lobby.start must be 'when-full', 'when-ready' or 'manual', got ${JSON.stringify(
44
+ config.start
45
+ )}`;
46
+ }
47
+ if (config.min !== void 0) {
48
+ if (!Number.isInteger(config.min) || config.min < LOBBY_MIN_PLAYERS || config.min > 255) {
49
+ return `lobby.min must be a whole number from ${LOBBY_MIN_PLAYERS} to 255, got ${String(
50
+ config.min
51
+ )}`;
52
+ }
53
+ }
54
+ if (config.size !== void 0) {
55
+ if (!Number.isInteger(config.size) || config.size < LOBBY_MIN_PLAYERS || config.size > 255) {
56
+ return `lobby.size must be a whole number from ${LOBBY_MIN_PLAYERS} to 255, got ${String(
57
+ config.size
58
+ )}`;
59
+ }
60
+ }
61
+ if (config.queue !== void 0 && !/^[a-z0-9][a-z0-9._-]{0,31}$/.test(config.queue)) {
62
+ return `lobby.queue ${JSON.stringify(config.queue)} must be 1-32 of a-z 0-9 . _ - and start with a letter or digit`;
63
+ }
64
+ for (const key of Object.keys(config)) {
65
+ if (key !== "start" && key !== "min" && key !== "size" && key !== "queue" && key !== "onStart" && key !== "onSetPublic") {
66
+ return `lobby has an unknown field ${JSON.stringify(key)}`;
67
+ }
68
+ }
69
+ return void 0;
70
+ }
71
+
1
72
  // src/physics.ts
2
73
  var HISTORY_MAX_TICKS = 240;
3
74
 
@@ -154,6 +225,7 @@ function defineRoom(schema, config) {
154
225
  }
155
226
  checkPhysics(schema, config, mode);
156
227
  checkNpcs(config);
228
+ checkLobby(schema, config);
157
229
  const resolved = {
158
230
  ...config,
159
231
  mode,
@@ -171,6 +243,24 @@ function defineRoom(schema, config) {
171
243
  config: Object.freeze(resolved)
172
244
  });
173
245
  }
246
+ function checkLobby(schema, config) {
247
+ const problem = lobbyConfigProblem(config.lobby);
248
+ if (problem) throw new Error(`defineRoom: ${problem}`);
249
+ const carried = schemaHasLobby(schema);
250
+ if (config.lobby !== void 0 && !carried) {
251
+ throw new Error(
252
+ "defineRoom: lobby: is configured but the schema does not carry the lobby collections \u2014 add them with defineSchema({ ...lobbyCollections, ... }) from '@irtio/server'. Both sides of the wire read the fragment from that module, which is what stops a client and a room disagreeing about the lobby state"
253
+ );
254
+ }
255
+ if (config.lobby === void 0 && carried) {
256
+ throw new Error(
257
+ "defineRoom: the schema spreads lobbyCollections but the room config has no lobby: \u2014 add lobby: true-equivalent config (lobby: {}) to take the defaults, or drop the spread"
258
+ );
259
+ }
260
+ const lobby = config.lobby;
261
+ assertSyncHandler(lobby?.onStart, "lobby.onStart");
262
+ assertSyncHandler(lobby?.onSetPublic, "lobby.onSetPublic");
263
+ }
174
264
  function checkNpcs(config) {
175
265
  const npcs = config.npcs;
176
266
  if (npcs === void 0) return;
@@ -205,15 +295,16 @@ function checkPhysics(schema, config, mode) {
205
295
  "defineRoom: physics needs mode: 'tick' \u2014 the world steps on the fixed timestep, and an event-mode room has no timestep to step on"
206
296
  );
207
297
  }
208
- if (physics.engine !== "rapier3d" && physics.engine !== "matter2d") {
298
+ const ENGINES = ["rapier3d", "matter2d", "rapier2d"];
299
+ if (!ENGINES.includes(physics.engine)) {
209
300
  throw new Error(
210
- `defineRoom: physics.engine must be 'rapier3d' or 'matter2d', got ${JSON.stringify(
301
+ `defineRoom: physics.engine must be one of ${ENGINES.map((e) => `'${e}'`).join(", ")}, got ${JSON.stringify(
211
302
  physics.engine
212
303
  )}`
213
304
  );
214
305
  }
215
306
  const g = physics.gravity;
216
- const planar = physics.engine === "matter2d";
307
+ const planar = physics.engine === "matter2d" || physics.engine === "rapier2d";
217
308
  if (!g || typeof g !== "object" || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !planar && !Number.isFinite(g.z)) {
218
309
  throw new Error(
219
310
  planar ? "defineRoom: physics.gravity must be { x, y } finite numbers" : "defineRoom: physics.gravity must be { x, y, z } finite numbers"
@@ -277,6 +368,9 @@ function isRoomDefinition(v) {
277
368
  export {
278
369
  DEFAULTS,
279
370
  HISTORY_MAX_TICKS,
371
+ LOBBY_MEMBERS,
372
+ LOBBY_MIN_PLAYERS,
373
+ LOBBY_STATE,
280
374
  MAX_AWAKE_MAX,
281
375
  MEMORY_MB_MAX,
282
376
  MEMORY_MB_MIN,
@@ -289,8 +383,13 @@ export {
289
383
  defineRoom,
290
384
  flee,
291
385
  isRoomDefinition,
386
+ lobbyCollections,
387
+ lobbyConfigProblem,
388
+ lobbyMemberEntity,
389
+ lobbyStateSingleton,
292
390
  newWander,
293
391
  patrol,
392
+ schemaHasLobby,
294
393
  seek,
295
394
  wander
296
395
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/server",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "irtio room-file API: defineRoom, handler and ctx types",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -20,13 +20,17 @@
20
20
  "dist"
21
21
  ],
22
22
  "dependencies": {
23
- "@irtio/schema": "0.7.0"
23
+ "@irtio/schema": "0.8.0"
24
24
  },
25
25
  "peerDependencies": {
26
+ "@dimforge/rapier2d-compat": ">=0.20.0",
26
27
  "@dimforge/rapier3d-compat": ">=0.20.0",
27
28
  "matter-js": ">=0.20.0"
28
29
  },
29
30
  "peerDependenciesMeta": {
31
+ "@dimforge/rapier2d-compat": {
32
+ "optional": true
33
+ },
30
34
  "@dimforge/rapier3d-compat": {
31
35
  "optional": true
32
36
  },
@@ -37,7 +41,8 @@
37
41
  "devDependencies": {
38
42
  "@dimforge/rapier3d-compat": "0.20.0",
39
43
  "@types/matter-js": "0.20.2",
40
- "matter-js": "0.20.0"
44
+ "matter-js": "0.20.0",
45
+ "@dimforge/rapier2d-compat": "0.20.0"
41
46
  },
42
47
  "scripts": {
43
48
  "build": "tsup",