@irtio/server 0.6.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 +437 -8
- package/dist/index.js +114 -3
- package/package.json +8 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,168 @@
|
|
|
1
|
-
import
|
|
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
|
*
|
|
@@ -195,8 +356,20 @@ declare function choose<B extends Behaviour>(behaviours: readonly B[]): B | unde
|
|
|
195
356
|
type RapierModule = typeof RAPIER;
|
|
196
357
|
type RapierWorld = RAPIER.World;
|
|
197
358
|
type RapierRigidBody = RAPIER.RigidBody;
|
|
359
|
+
type RapierCollider = RAPIER.Collider;
|
|
198
360
|
type RapierRigidBodyDesc = RAPIER.RigidBodyDesc;
|
|
199
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;
|
|
200
373
|
type MatterModule = typeof MATTER;
|
|
201
374
|
type MatterEngine = MATTER.Engine;
|
|
202
375
|
type MatterBody = MATTER.Body;
|
|
@@ -226,6 +399,16 @@ interface Matter2dBodySpec {
|
|
|
226
399
|
/** Added to the world with the body, and removed with it. */
|
|
227
400
|
readonly constraints?: readonly MatterConstraint[];
|
|
228
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
|
+
}
|
|
229
412
|
type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf<SchemaDefs<S>[K]>;
|
|
230
413
|
/**
|
|
231
414
|
* `physics.bodies.<collection>` — how one instance becomes a rigid body. Called when the runtime
|
|
@@ -236,6 +419,10 @@ type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf
|
|
|
236
419
|
type BodyFactories<S extends AnySchema> = {
|
|
237
420
|
readonly [K in PhysicsKeys<S>]: (rapier: RapierModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => BodySpec;
|
|
238
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
|
+
};
|
|
239
426
|
/** `physics.bodies.<collection>` for matter2d. Same lifecycle as {@link BodyFactories}. */
|
|
240
427
|
type Matter2dBodyFactories<S extends AnySchema> = {
|
|
241
428
|
readonly [K in PhysicsKeys<S>]: (matter: MatterModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => Matter2dBodySpec;
|
|
@@ -253,6 +440,59 @@ interface RapierPhysicsConfig<S extends AnySchema> {
|
|
|
253
440
|
*/
|
|
254
441
|
setup?(world: RapierWorld, rapier: RapierModule, room: Room<S>): void;
|
|
255
442
|
readonly bodies: BodyFactories<S>;
|
|
443
|
+
/** D72: how many ticks of body poses to keep for `room.rewind`. See {@link HISTORY_DOC}. */
|
|
444
|
+
readonly history?: number;
|
|
445
|
+
}
|
|
446
|
+
/** D72: the deepest history a room may declare, in ticks. One second at the maximum tick rate. */
|
|
447
|
+
declare const HISTORY_MAX_TICKS = 240;
|
|
448
|
+
/**
|
|
449
|
+
* D72: what `room.rewind(tick, fn)` hands `fn`.
|
|
450
|
+
*
|
|
451
|
+
* Exactly one of `rapier`, `rapier2d` and `matter` is present, decided by the room's engine.
|
|
452
|
+
*
|
|
453
|
+
* What a rewound query **can** see: every tracked body's pose at that tick, and the world's static
|
|
454
|
+
* geometry as it stands now. What it **cannot**: colliders as they were then (shapes are not
|
|
455
|
+
* historied, only poses), joints and contacts, bodies created since that tick, and anything the
|
|
456
|
+
* caller had not been told about yet.
|
|
457
|
+
*/
|
|
458
|
+
interface RewindView {
|
|
459
|
+
/** The tick actually answered from, after clamping. */
|
|
460
|
+
readonly tick: number;
|
|
461
|
+
/** The tick that was asked for, unclamped, so a room can log or refuse the difference. */
|
|
462
|
+
readonly requested: number;
|
|
463
|
+
/**
|
|
464
|
+
* `true` when `requested` fell outside the buffer and was pulled to its nearest edge. Not an
|
|
465
|
+
* error: the oldest pose the room still holds is the honest answer to "further back than I
|
|
466
|
+
* remember", and a room that would rather refuse a clamped answer can read this and do so.
|
|
467
|
+
*/
|
|
468
|
+
readonly clamped: boolean;
|
|
469
|
+
/** rapier3d rooms: a scratch world to `castRay` / `castShape` / `intersectionsWithShape` on. */
|
|
470
|
+
readonly rapier?: {
|
|
471
|
+
readonly world: RapierWorld;
|
|
472
|
+
/** Which entity a collider in the scratch world belongs to; `undefined` for static geometry. */
|
|
473
|
+
who(collider: RapierCollider): {
|
|
474
|
+
readonly collection: string;
|
|
475
|
+
readonly id: string;
|
|
476
|
+
} | undefined;
|
|
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
|
+
};
|
|
487
|
+
/** matter2d rooms: the body array `Matter.Query.ray/point/region/collides` takes. */
|
|
488
|
+
readonly matter?: {
|
|
489
|
+
readonly bodies: readonly MatterBody[];
|
|
490
|
+
/** Which entity a body in the array belongs to; `undefined` for the world's static bodies. */
|
|
491
|
+
who(body: MatterBody): {
|
|
492
|
+
readonly collection: string;
|
|
493
|
+
readonly id: string;
|
|
494
|
+
} | undefined;
|
|
495
|
+
};
|
|
256
496
|
}
|
|
257
497
|
/**
|
|
258
498
|
* D57: the matter-flavoured counterpart to `@irtio/client`'s `ClientIntent2dHook` — one step of
|
|
@@ -305,8 +545,69 @@ interface Matter2dPhysicsConfig<S extends AnySchema> {
|
|
|
305
545
|
* with no declared intents, which is the check the type cannot make.
|
|
306
546
|
*/
|
|
307
547
|
readonly intents?: Readonly<Partial<Record<PhysicsKeys<S> & string, Matter2dIntentHook>>>;
|
|
548
|
+
/** D72: how many ticks of body poses to keep for `room.rewind`. See {@link HISTORY_DOC}. */
|
|
549
|
+
readonly history?: number;
|
|
550
|
+
}
|
|
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;
|
|
308
609
|
}
|
|
309
|
-
type PhysicsConfig<S extends AnySchema> = RapierPhysicsConfig<S> | Matter2dPhysicsConfig<S>;
|
|
610
|
+
type PhysicsConfig<S extends AnySchema> = RapierPhysicsConfig<S> | Matter2dPhysicsConfig<S> | Rapier2dPhysicsConfig<S>;
|
|
310
611
|
/**
|
|
311
612
|
* `room.physics2d` in a matter2d room.
|
|
312
613
|
*
|
|
@@ -351,6 +652,17 @@ interface PhysicsRoomApi<S extends AnySchema> {
|
|
|
351
652
|
type MessageTarget = 'all' | string | {
|
|
352
653
|
readonly role: string;
|
|
353
654
|
};
|
|
655
|
+
/**
|
|
656
|
+
* D70: what `onMessage`'s sixth argument carries for a typed message, and nothing at all for a
|
|
657
|
+
* raw one. A discriminated union over the declared names, so `if (typed?.name === 'emote')`
|
|
658
|
+
* narrows `typed.value` to that shape.
|
|
659
|
+
*/
|
|
660
|
+
type TypedMessage<S> = {
|
|
661
|
+
[K in MessageNames<S>]: {
|
|
662
|
+
readonly name: K;
|
|
663
|
+
readonly value: MessageValue<S, K>;
|
|
664
|
+
};
|
|
665
|
+
}[MessageNames<S>];
|
|
354
666
|
interface ClientInfo {
|
|
355
667
|
readonly clientId: string;
|
|
356
668
|
readonly role: string;
|
|
@@ -372,6 +684,16 @@ interface Room<S extends AnySchema = AnySchema> {
|
|
|
372
684
|
/** Seeded, recorded for replay. */
|
|
373
685
|
random(): number;
|
|
374
686
|
send(target: MessageTarget, bytes: Uint8Array): void;
|
|
687
|
+
/**
|
|
688
|
+
* D70: `room.messages.<name>.send(target, value)` — one of the shapes the schema declares,
|
|
689
|
+
* encoded for you. `{}` on a schema that declares none, so game code can be written before the
|
|
690
|
+
* schema has anything to say.
|
|
691
|
+
*
|
|
692
|
+
* There is no `on` here: a room observes messages through `onMessage`, which is also where it
|
|
693
|
+
* can drop one. A second way in would be a second place to see a message the room had already
|
|
694
|
+
* declined.
|
|
695
|
+
*/
|
|
696
|
+
readonly messages: ServerMessageChannels<S, MessageTarget>;
|
|
375
697
|
setRole(clientId: string, role: RoleOf<S> & string): void;
|
|
376
698
|
kick(clientId: string, reason?: string): void;
|
|
377
699
|
close(reason?: string): void;
|
|
@@ -435,6 +757,47 @@ interface Room<S extends AnySchema = AnySchema> {
|
|
|
435
757
|
* reading `room.physics` here.
|
|
436
758
|
*/
|
|
437
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>;
|
|
769
|
+
/**
|
|
770
|
+
* D72: run `fn` against the world as it stood at `tick`.
|
|
771
|
+
*
|
|
772
|
+
* A shot fired at 200 ms of latency was aimed at where the target was drawn on the shooter's
|
|
773
|
+
* screen, a round trip ago. `ctx.clientTick` says which tick that was; this puts every tracked
|
|
774
|
+
* body back where it was then, on a scratch world, and lets the room's own query answer there:
|
|
775
|
+
*
|
|
776
|
+
* ```ts
|
|
777
|
+
* rpc: {
|
|
778
|
+
* fire(state, { dx, dy, dz }, ctx) {
|
|
779
|
+
* const { rapier, world } = ctx.room.physics;
|
|
780
|
+
* const from = ctx.room.physics.body('players', ctx.clientId)?.translation();
|
|
781
|
+
* if (!from) return { hit: false };
|
|
782
|
+
* return ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => {
|
|
783
|
+
* const hit = past.rapier?.world.castRay(new rapier.Ray(from, { x: dx, y: dy, z: dz }), 100, true);
|
|
784
|
+
* return { hit: hit ? past.rapier?.who(hit.collider) !== undefined : false };
|
|
785
|
+
* });
|
|
786
|
+
* },
|
|
787
|
+
* }
|
|
788
|
+
* ```
|
|
789
|
+
*
|
|
790
|
+
* `fn` runs synchronously and whatever it returns is returned. The live world is never touched:
|
|
791
|
+
* this is stored poses on a second world, not a re-simulation, and nothing a player can see is
|
|
792
|
+
* stepped. A tick outside the buffer is clamped to its nearest edge and `past.clamped` says so,
|
|
793
|
+
* which is a fallback rather than an error.
|
|
794
|
+
*
|
|
795
|
+
* Throws when the room declares no `physics.history`, when the buffer is still empty (a room
|
|
796
|
+
* that has just woken), and when called from inside another `rewind`.
|
|
797
|
+
*
|
|
798
|
+
* @see irt.io/docs/concepts/lag-compensation
|
|
799
|
+
*/
|
|
800
|
+
rewind<T>(tick: number, fn: (past: RewindView) => T): T;
|
|
438
801
|
/**
|
|
439
802
|
* D44: spawn a scripted NPC. `config.brain.script` names an entry in the room definition's
|
|
440
803
|
* `npcs` map; the session it opens is an ordinary client session — it appears in
|
|
@@ -462,6 +825,15 @@ interface Room<S extends AnySchema = AnySchema> {
|
|
|
462
825
|
* one.
|
|
463
826
|
*/
|
|
464
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;
|
|
465
837
|
}
|
|
466
838
|
/**
|
|
467
839
|
* D59: two verbs with deliberately unequal guarantees. Read them as a pair, because picking the
|
|
@@ -703,6 +1075,19 @@ interface RoomRatings {
|
|
|
703
1075
|
readonly deviation?: number;
|
|
704
1076
|
}): Promise<void>;
|
|
705
1077
|
}
|
|
1078
|
+
/** D69-c: what a submit may say about the board beyond the score. One field, and it is optional. */
|
|
1079
|
+
interface LeaderboardSubmitOptions {
|
|
1080
|
+
/**
|
|
1081
|
+
* Which cohort of a bucketed board this score belongs to: a region, a platform, a league —
|
|
1082
|
+
* whatever your game means by it. 1 to 64 characters of `a-z 0-9 . _ -`.
|
|
1083
|
+
*
|
|
1084
|
+
* Required on a board configured with `buckets: true`, and refused on a board that is not
|
|
1085
|
+
* (`E_LB_BUCKET_REQUIRED` / `E_LB_NO_BUCKETS`). Neither is guessed at: a bucket that was
|
|
1086
|
+
* silently dropped would put a cohort's scores on the wrong ranking, and a read that silently
|
|
1087
|
+
* merged every cohort would be a ranking nobody asked for.
|
|
1088
|
+
*/
|
|
1089
|
+
readonly bucket?: string;
|
|
1090
|
+
}
|
|
706
1091
|
interface RoomLeaderboard {
|
|
707
1092
|
/**
|
|
708
1093
|
* Post `score` for `playerId` on `board`.
|
|
@@ -710,10 +1095,15 @@ interface RoomLeaderboard {
|
|
|
710
1095
|
* `playerId` must be a player currently in this room — `ctx.playerId` is the id to pass, and
|
|
711
1096
|
* anything else rejects with `E_LB_NOT_IN_ROOM`. Board names are 1-64 of `a-z 0-9 . _ -`.
|
|
712
1097
|
* 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
|
|
714
|
-
* continuation runs as its own
|
|
1098
|
+
* `E_LB_PROJECT_FULL`, `E_LB_UNAVAILABLE`, `E_LB_BAD_BUCKET`, `E_LB_BUCKET_REQUIRED`,
|
|
1099
|
+
* `E_LB_NO_BUCKETS`. Like every promise-returning room API, the continuation runs as its own
|
|
1100
|
+
* event between ticks.
|
|
1101
|
+
*
|
|
1102
|
+
* D69: there is no period argument and there will not be one. A rotating board's current period
|
|
1103
|
+
* is computed on the control plane from its own clock at the moment the score lands, so a room
|
|
1104
|
+
* needs no change to start rotating and cannot write into a period that has closed.
|
|
715
1105
|
*/
|
|
716
|
-
submit(board: string, playerId: string, score: number): Promise<void>;
|
|
1106
|
+
submit(board: string, playerId: string, score: number, options?: LeaderboardSubmitOptions): Promise<void>;
|
|
717
1107
|
}
|
|
718
1108
|
interface Ctx<S extends AnySchema = AnySchema> {
|
|
719
1109
|
readonly clientId: string;
|
|
@@ -730,6 +1120,23 @@ interface Ctx<S extends AnySchema = AnySchema> {
|
|
|
730
1120
|
readonly name: string;
|
|
731
1121
|
/** Server tick this join/call/write is applied at. */
|
|
732
1122
|
readonly tick: number;
|
|
1123
|
+
/**
|
|
1124
|
+
* D72: RPCs only. The newest authoritative tick the caller had applied when it sent the `CALL`,
|
|
1125
|
+
* which is the tick whose world it was looking at. `undefined` for a join, a write, or a `CALL`
|
|
1126
|
+
* from a client that sends no stamp.
|
|
1127
|
+
*
|
|
1128
|
+
* It is what `room.rewind` is meant to be given:
|
|
1129
|
+
*
|
|
1130
|
+
* ```ts
|
|
1131
|
+
* const hit = ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => …);
|
|
1132
|
+
* ```
|
|
1133
|
+
*
|
|
1134
|
+
* **It is a number a client chose.** The history depth bounds how far into the past a lie can
|
|
1135
|
+
* reach and `rewind` clamps anything outside the buffer, but a room that wants to distrust it
|
|
1136
|
+
* compares it with `ctx.tick` and refuses a gap it does not like. See
|
|
1137
|
+
* irt.io/docs/concepts/lag-compensation.
|
|
1138
|
+
*/
|
|
1139
|
+
readonly clientTick: number | undefined;
|
|
733
1140
|
/** Joins only: a resumed session. */
|
|
734
1141
|
readonly reconnecting: boolean;
|
|
735
1142
|
readonly room: Room<S>;
|
|
@@ -868,6 +1275,17 @@ interface RoomConfigBase<S extends AnySchema> {
|
|
|
868
1275
|
* loop that awaits: `while (!npc.stopped) { …; await npc.wait(100) }`.
|
|
869
1276
|
*/
|
|
870
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>>;
|
|
871
1289
|
onCreate?(state: State<S>, room: Room<S>): void;
|
|
872
1290
|
onJoin?(state: State<S>, ctx: Ctx<S>): void;
|
|
873
1291
|
onLeave?(state: State<S>, ctx: Ctx<S>, reason: LeaveReason): void;
|
|
@@ -878,8 +1296,19 @@ interface RoomConfigBase<S extends AnySchema> {
|
|
|
878
1296
|
readonly validate?: Validators<S>;
|
|
879
1297
|
/** Implements the built-in `requestOwnership` RPC. Default: grant if unowned. */
|
|
880
1298
|
onOwnershipRequest?(state: State<S>, entity: OwnableKeys<S> & string, id: string, ctx: Ctx<S>): boolean;
|
|
881
|
-
/**
|
|
882
|
-
|
|
1299
|
+
/**
|
|
1300
|
+
* Peer messages, raw and typed alike; return `false` to drop.
|
|
1301
|
+
*
|
|
1302
|
+
* `bytes` is what the peer sent: opaque for `room.message(target, bytes)`, and the encoded
|
|
1303
|
+
* payload for a typed message. D70 adds the sixth argument: present, with the declared name and
|
|
1304
|
+
* the decoded value, exactly when the message was typed. The decode happens **before** this
|
|
1305
|
+
* handler runs, so a malformed payload never arrives here at all — it is counted and dropped —
|
|
1306
|
+
* and `typed.value` is always a well-formed value of a shape this schema declares.
|
|
1307
|
+
*
|
|
1308
|
+
* Returning `false` drops a typed message exactly as it drops a raw one. That is the room's
|
|
1309
|
+
* veto, and it is the only one it needs.
|
|
1310
|
+
*/
|
|
1311
|
+
onMessage?(state: State<S>, from: string, target: MessageTarget, bytes: Uint8Array, ctx: Ctx<S>, typed?: TypedMessage<S>): boolean | undefined | void;
|
|
883
1312
|
}
|
|
884
1313
|
type RoomConfig<S extends AnySchema> = RoomConfigBase<S> & RpcConfig<S>;
|
|
885
1314
|
/** Config with defaults filled and `rpc` always present. */
|
|
@@ -961,4 +1390,4 @@ declare function defineRoom<S extends AnySchema>(schema: S, config: RoomConfig<S
|
|
|
961
1390
|
/** Type guard for what a bundle's default export should be. */
|
|
962
1391
|
declare function isRoomDefinition(v: unknown): v is RoomDefinition;
|
|
963
1392
|
|
|
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 };
|
|
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,77 @@
|
|
|
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
|
+
|
|
72
|
+
// src/physics.ts
|
|
73
|
+
var HISTORY_MAX_TICKS = 240;
|
|
74
|
+
|
|
1
75
|
// src/npc.ts
|
|
2
76
|
var ZERO = { x: 0, y: 0 };
|
|
3
77
|
function scaleTo(dx, dy, speed) {
|
|
@@ -151,6 +225,7 @@ function defineRoom(schema, config) {
|
|
|
151
225
|
}
|
|
152
226
|
checkPhysics(schema, config, mode);
|
|
153
227
|
checkNpcs(config);
|
|
228
|
+
checkLobby(schema, config);
|
|
154
229
|
const resolved = {
|
|
155
230
|
...config,
|
|
156
231
|
mode,
|
|
@@ -168,6 +243,24 @@ function defineRoom(schema, config) {
|
|
|
168
243
|
config: Object.freeze(resolved)
|
|
169
244
|
});
|
|
170
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
|
+
}
|
|
171
264
|
function checkNpcs(config) {
|
|
172
265
|
const npcs = config.npcs;
|
|
173
266
|
if (npcs === void 0) return;
|
|
@@ -202,15 +295,16 @@ function checkPhysics(schema, config, mode) {
|
|
|
202
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"
|
|
203
296
|
);
|
|
204
297
|
}
|
|
205
|
-
|
|
298
|
+
const ENGINES = ["rapier3d", "matter2d", "rapier2d"];
|
|
299
|
+
if (!ENGINES.includes(physics.engine)) {
|
|
206
300
|
throw new Error(
|
|
207
|
-
`defineRoom: physics.engine must be
|
|
301
|
+
`defineRoom: physics.engine must be one of ${ENGINES.map((e) => `'${e}'`).join(", ")}, got ${JSON.stringify(
|
|
208
302
|
physics.engine
|
|
209
303
|
)}`
|
|
210
304
|
);
|
|
211
305
|
}
|
|
212
306
|
const g = physics.gravity;
|
|
213
|
-
const planar = physics.engine === "matter2d";
|
|
307
|
+
const planar = physics.engine === "matter2d" || physics.engine === "rapier2d";
|
|
214
308
|
if (!g || typeof g !== "object" || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !planar && !Number.isFinite(g.z)) {
|
|
215
309
|
throw new Error(
|
|
216
310
|
planar ? "defineRoom: physics.gravity must be { x, y } finite numbers" : "defineRoom: physics.gravity must be { x, y, z } finite numbers"
|
|
@@ -221,6 +315,14 @@ function checkPhysics(schema, config, mode) {
|
|
|
221
315
|
`defineRoom: physics.timestep must be a positive number of seconds, got ${String(physics.timestep)}`
|
|
222
316
|
);
|
|
223
317
|
}
|
|
318
|
+
const history = physics.history;
|
|
319
|
+
if (history !== void 0) {
|
|
320
|
+
if (!Number.isInteger(history) || history < 0 || history > HISTORY_MAX_TICKS) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`defineRoom: physics.history must be an integer in 0..${HISTORY_MAX_TICKS} ticks (0 or absent means no history and no cost), got ${String(history)}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
224
326
|
assertSyncHandler(physics.setup, "physics.setup");
|
|
225
327
|
const bodies = physics.bodies ?? {};
|
|
226
328
|
for (const [k, fn] of Object.entries(bodies)) assertSyncHandler(fn, `physics.bodies.${k}`);
|
|
@@ -265,6 +367,10 @@ function isRoomDefinition(v) {
|
|
|
265
367
|
}
|
|
266
368
|
export {
|
|
267
369
|
DEFAULTS,
|
|
370
|
+
HISTORY_MAX_TICKS,
|
|
371
|
+
LOBBY_MEMBERS,
|
|
372
|
+
LOBBY_MIN_PLAYERS,
|
|
373
|
+
LOBBY_STATE,
|
|
268
374
|
MAX_AWAKE_MAX,
|
|
269
375
|
MEMORY_MB_MAX,
|
|
270
376
|
MEMORY_MB_MIN,
|
|
@@ -277,8 +383,13 @@ export {
|
|
|
277
383
|
defineRoom,
|
|
278
384
|
flee,
|
|
279
385
|
isRoomDefinition,
|
|
386
|
+
lobbyCollections,
|
|
387
|
+
lobbyConfigProblem,
|
|
388
|
+
lobbyMemberEntity,
|
|
389
|
+
lobbyStateSingleton,
|
|
280
390
|
newWander,
|
|
281
391
|
patrol,
|
|
392
|
+
schemaHasLobby,
|
|
282
393
|
seek,
|
|
283
394
|
wander
|
|
284
395
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/server",
|
|
3
|
-
"version": "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.
|
|
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",
|