@irtio/server 0.1.0 → 0.3.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 +156 -2
- package/dist/index.js +51 -0
- package/package.json +13 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,75 @@
|
|
|
1
|
-
import { AnySchema, RoleOf, ServerCallProxy, SchemaRpc, BroadcastProxy, State, OwnableKeys,
|
|
1
|
+
import { AnySchema, PhysicsKeys, DeepReadonly, SchemaDefs, InstanceOf, RoleOf, ServerCallProxy, SchemaRpc, BroadcastProxy, State, OwnableKeys, Implementations, ServerRpcs } from '@irtio/schema';
|
|
2
|
+
import RAPIER from '@dimforge/rapier3d-compat';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The room-side physics surface (D22): `physics:` on the room config, and `room.physics` inside
|
|
6
|
+
* handlers.
|
|
7
|
+
*
|
|
8
|
+
* Two rules shape this file. **Colliders are code, not config** (D15's spirit): static geometry
|
|
9
|
+
* is built by `setup(world, rapier, room)` and a body's shape comes from a per-collection factory
|
|
10
|
+
* — irtio never invents a schema syntax for a capsule. And **the engine is blessed, not
|
|
11
|
+
* abstracted**: room code gets the real `@dimforge/rapier3d-compat` `World` and the real
|
|
12
|
+
* namespace, so every Rapier tutorial on the internet applies unchanged.
|
|
13
|
+
*
|
|
14
|
+
* The rapier import here is `import type` only — it erases at build time, so `@irtio/server`
|
|
15
|
+
* carries no runtime dependency on it and a non-physics game never installs it (the package is an
|
|
16
|
+
* *optional* peer dependency; `skipLibCheck` keeps the unresolved type quiet when it is absent).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type RapierModule = typeof RAPIER;
|
|
20
|
+
type RapierWorld = RAPIER.World;
|
|
21
|
+
type RapierRigidBody = RAPIER.RigidBody;
|
|
22
|
+
type RapierRigidBodyDesc = RAPIER.RigidBodyDesc;
|
|
23
|
+
type RapierColliderDesc = RAPIER.ColliderDesc;
|
|
24
|
+
interface Vector3 {
|
|
25
|
+
readonly x: number;
|
|
26
|
+
readonly y: number;
|
|
27
|
+
readonly z: number;
|
|
28
|
+
}
|
|
29
|
+
/** What a body factory returns: the rigid body, plus the colliders attached to it. */
|
|
30
|
+
interface BodySpec {
|
|
31
|
+
readonly body: RapierRigidBodyDesc;
|
|
32
|
+
/** Attached to the body in order. A body with none is a valid (invisible) point mass. */
|
|
33
|
+
readonly colliders?: readonly RapierColliderDesc[];
|
|
34
|
+
}
|
|
35
|
+
type Instance$1<S extends AnySchema, K extends keyof SchemaDefs<S>> = InstanceOf<SchemaDefs<S>[K]>;
|
|
36
|
+
/**
|
|
37
|
+
* `physics.bodies.<collection>` — how one instance becomes a rigid body. Called when the runtime
|
|
38
|
+
* first sees an instance (and again after a wake that had to rebuild the world). The pose and
|
|
39
|
+
* velocity the schema's body fields already hold are applied *after* the desc, so `add()` places
|
|
40
|
+
* the body: a factory only has to describe shape and material.
|
|
41
|
+
*/
|
|
42
|
+
type BodyFactories<S extends AnySchema> = {
|
|
43
|
+
readonly [K in PhysicsKeys<S>]: (rapier: RapierModule, instance: DeepReadonly<Instance$1<S, K & keyof SchemaDefs<S>>>, id: string) => BodySpec;
|
|
44
|
+
};
|
|
45
|
+
interface PhysicsConfig<S extends AnySchema> {
|
|
46
|
+
/** The one blessed engine in M2 (D22). */
|
|
47
|
+
readonly engine: 'rapier3d';
|
|
48
|
+
readonly gravity: Vector3;
|
|
49
|
+
/** Seconds per world step. Defaults to the tick interval; one step per tick, no substeps. */
|
|
50
|
+
readonly timestep?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Static geometry, joints, world tuning. Runs once per world — at room create, and on a wake
|
|
53
|
+
* that had to rebuild (a pre-physics snapshot, or a schema migration). It does **not** run on
|
|
54
|
+
* an ordinary wake: the world came back from the snapshot with its static colliders in it.
|
|
55
|
+
*/
|
|
56
|
+
setup?(world: RapierWorld, rapier: RapierModule, room: Room<S>): void;
|
|
57
|
+
readonly bodies: BodyFactories<S>;
|
|
58
|
+
}
|
|
59
|
+
/** `room.physics` — what handlers use to turn intents into forces. */
|
|
60
|
+
interface PhysicsRoomApi<S extends AnySchema> {
|
|
61
|
+
/** The `@dimforge/rapier3d-compat` namespace: descs, shapes, enums, `QueryFilterFlags`, … */
|
|
62
|
+
readonly rapier: RapierModule;
|
|
63
|
+
/** The live world. Ray casts, static colliders added later, joints. */
|
|
64
|
+
readonly world: RapierWorld;
|
|
65
|
+
/** Seconds per step (`world.timestep`). */
|
|
66
|
+
readonly timestep: number;
|
|
67
|
+
/**
|
|
68
|
+
* The rigid body backing an instance. Created on demand, so an entity added earlier in this
|
|
69
|
+
* same handler already has one. `undefined` only when the instance does not exist.
|
|
70
|
+
*/
|
|
71
|
+
body(collection: PhysicsKeys<S> & string, id: string): RapierRigidBody | undefined;
|
|
72
|
+
}
|
|
2
73
|
|
|
3
74
|
/**
|
|
4
75
|
* `@irtio/server` — what `irtio/room.ts` imports. `defineRoom(schema, config)` validates the
|
|
@@ -44,9 +115,77 @@ interface Room<S extends AnySchema = AnySchema> {
|
|
|
44
115
|
call(clientId: string): ServerCallProxy<SchemaRpc<S>>;
|
|
45
116
|
/** Server → client void RPCs to every connected client. */
|
|
46
117
|
readonly broadcast: BroadcastProxy<SchemaRpc<S>>;
|
|
118
|
+
/**
|
|
119
|
+
* D24: write a retained, addressable snapshot generation of this room right now and resolve
|
|
120
|
+
* with its save id. Like every promise-returning room API, the continuation runs as its own
|
|
121
|
+
* event between ticks — see `PlayerKv` for what that costs you.
|
|
122
|
+
*
|
|
123
|
+
* A failed save rejects and changes nothing: the room's live hibernation snapshot is never
|
|
124
|
+
* touched by `save()`, so a room can never be left worse off than if it had not saved.
|
|
125
|
+
*/
|
|
126
|
+
save(): Promise<string>;
|
|
127
|
+
/** D25: per-player key/value storage that outlives the room. */
|
|
128
|
+
readonly kv: PlayerKv;
|
|
129
|
+
/**
|
|
130
|
+
* D26: arm a durable alarm named `name` to fire at or after `atMs`, **on `room.now`'s clock** —
|
|
131
|
+
* `room.alarm('round', room.now + 15_000)` is the shape to write. (The host translates that to
|
|
132
|
+
* wall clock on the way out, because an alarm outlives the process `room.now` is monotonic
|
|
133
|
+
* within; a room never has to think about it, but it does have to compute `atMs` from
|
|
134
|
+
* `room.now` rather than from anything else.) Arming a name that is already armed replaces its
|
|
135
|
+
* due time, which is what makes re-arming from inside an alarm handler the way to build a
|
|
136
|
+
* repeating timer.
|
|
137
|
+
*
|
|
138
|
+
* The handler is the entry for `name` in the room definition's `alarms` map — arming a name
|
|
139
|
+
* with no handler is a warning and does nothing. Alarms survive hibernation *and* a tenant
|
|
140
|
+
* stop; the guaranteed resolution is seconds, and an alarm fires at-or-after `atMs`, never
|
|
141
|
+
* before. See the room reference at irt.io/docs/reference/room for the worst-case lateness a
|
|
142
|
+
* game should design for.
|
|
143
|
+
*/
|
|
144
|
+
alarm(name: string, atMs: number): void;
|
|
145
|
+
/** D26: cancel the alarm named `name`. Cancelling an unarmed name is a no-op. */
|
|
146
|
+
cancelAlarm(name: string): void;
|
|
147
|
+
/**
|
|
148
|
+
* D22: the Rapier world and the bodies behind physics entities. Reading it in a room whose
|
|
149
|
+
* config declares no `physics:` throws — that is a mistake worth naming at the call site.
|
|
150
|
+
*/
|
|
151
|
+
readonly physics: PhysicsRoomApi<S>;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* D25: player key/value storage, scoped to the project and keyed by a player identity the room
|
|
155
|
+
* chooses. It is the first state irtio keeps that is *not* room state: it lives in the control
|
|
156
|
+
* plane, it outlives the room, and another room of the same project reads back what this one
|
|
157
|
+
* wrote.
|
|
158
|
+
*
|
|
159
|
+
* **Every method returns a Promise whose continuation runs as its own event between ticks** —
|
|
160
|
+
* exactly the shape `room.call(clientId)` already has. Room handlers are synchronous and there
|
|
161
|
+
* is no way to block one on I/O, so a KV read is *not* available in the handler that asked for
|
|
162
|
+
* it. State mutated in the continuation is tracked and flushed normally.
|
|
163
|
+
*
|
|
164
|
+
* Confinement is per **project**, not per player: a room may pass any `playerId` and can read
|
|
165
|
+
* another player's row on purpose. A cross-project read is refused by the gateway.
|
|
166
|
+
*
|
|
167
|
+
* Values are strings (JSON-encode structured data yourself), at most 16 KiB of UTF-8. Rejections
|
|
168
|
+
* name the limit they hit — `E_KV_VALUE_TOO_LARGE`, `E_KV_TOO_MANY_KEYS`,
|
|
169
|
+
* `E_KV_PROJECT_FULL`, `E_KV_BAD_KEY`, `E_KV_UNAVAILABLE` — and the room keeps running.
|
|
170
|
+
*/
|
|
171
|
+
interface PlayerKv {
|
|
172
|
+
/** Resolves `undefined` for a key that was never written — a miss is not an error. */
|
|
173
|
+
get(playerId: string, key: string): Promise<string | undefined>;
|
|
174
|
+
set(playerId: string, key: string, value: string): Promise<void>;
|
|
175
|
+
/** Idempotent: deleting a key that is not there still resolves. */
|
|
176
|
+
delete(playerId: string, key: string): Promise<void>;
|
|
47
177
|
}
|
|
48
178
|
interface Ctx<S extends AnySchema = AnySchema> {
|
|
49
179
|
readonly clientId: string;
|
|
180
|
+
/**
|
|
181
|
+
* D25: the identity `room.kv` is meant to be keyed by. For a **key join** it is the
|
|
182
|
+
* resume-token identity — the client id a resume token carries across a reconnect — so it is
|
|
183
|
+
* stable only as long as that token is, and a player who comes back after it expires is a new
|
|
184
|
+
* `playerId`. For a **JWT join** (D27, week 13) it is the durable `"<iss>:<sub>"` the verified
|
|
185
|
+
* token asserted, namespaced by issuer so two issuers can never collide. Adopting JWT starts a
|
|
186
|
+
* player's storage over: old client-id-keyed rows stay where they are, by decision.
|
|
187
|
+
*/
|
|
188
|
+
readonly playerId: string;
|
|
50
189
|
readonly role: RoleOf<S> & string;
|
|
51
190
|
readonly name: string;
|
|
52
191
|
/** Server tick this join/call/write is applied at. */
|
|
@@ -79,6 +218,21 @@ interface RoomConfigBase<S extends AnySchema> {
|
|
|
79
218
|
readonly reconnectGraceMs?: number;
|
|
80
219
|
/** Default 64. */
|
|
81
220
|
readonly maxClients?: number;
|
|
221
|
+
/**
|
|
222
|
+
* D22: run a Rapier world on the fixed timestep. Tick mode only. Every collection whose schema
|
|
223
|
+
* declares `physics` needs an entry in `bodies`, and vice versa.
|
|
224
|
+
*/
|
|
225
|
+
readonly physics?: PhysicsConfig<S>;
|
|
226
|
+
/**
|
|
227
|
+
* D26: durable alarm handlers, by name. `room.alarm(name, atMs)` arms one; the entry here runs
|
|
228
|
+
* when it fires, as its own event between ticks — the same scheduling class as an RPC, so a
|
|
229
|
+
* tick-mode room never runs a half-alarmed `tick`.
|
|
230
|
+
*
|
|
231
|
+
* Handlers live in the **config**, not in a runtime registration, precisely because an alarm
|
|
232
|
+
* outlives the room: a callback handed to the runtime would be gone after a hibernation, and
|
|
233
|
+
* surviving hibernation is the entire point of D26. This map is code, so it is always there.
|
|
234
|
+
*/
|
|
235
|
+
readonly alarms?: Readonly<Record<string, (state: State<S>, room: Room<S>) => void>>;
|
|
82
236
|
onCreate?(state: State<S>, room: Room<S>): void;
|
|
83
237
|
onJoin?(state: State<S>, ctx: Ctx<S>): void;
|
|
84
238
|
onLeave?(state: State<S>, ctx: Ctx<S>, reason: LeaveReason): void;
|
|
@@ -126,4 +280,4 @@ declare function defineRoom<S extends AnySchema>(schema: S, config: RoomConfig<S
|
|
|
126
280
|
/** Type guard for what a bundle's default export should be. */
|
|
127
281
|
declare function isRoomDefinition(v: unknown): v is RoomDefinition;
|
|
128
282
|
|
|
129
|
-
export { type ClientInfo, type Ctx, DEFAULTS, type LeaveReason, type MessageTarget, ROOM_DEFINITION_VERSION, type ResolvedRoomConfig, type Room, type RoomConfig, type RoomConfigBase, type RoomDefinition, type RoomMode, type RpcImplementations, type TimerHandle, type Validators, defineRoom, isRoomDefinition };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -62,6 +62,7 @@ function defineRoom(schema, config) {
|
|
|
62
62
|
throw new Error(`defineRoom: validate.${k} must name an instance-owned entity collection`);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
checkPhysics(schema, config, mode);
|
|
65
66
|
const resolved = {
|
|
66
67
|
...config,
|
|
67
68
|
mode,
|
|
@@ -78,6 +79,56 @@ function defineRoom(schema, config) {
|
|
|
78
79
|
config: Object.freeze(resolved)
|
|
79
80
|
});
|
|
80
81
|
}
|
|
82
|
+
function checkPhysics(schema, config, mode) {
|
|
83
|
+
const declared = schema.collections.filter((c) => c.physics !== void 0).map((c) => c.name);
|
|
84
|
+
const physics = config.physics;
|
|
85
|
+
if (!physics) {
|
|
86
|
+
if (declared.length > 0) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`defineRoom: ${declared.join(", ")} declare${declared.length === 1 ? "s" : ""} physics in the schema, but the room config has no physics: { engine: 'rapier3d', gravity, bodies } \u2014 body fields would never move`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (declared.length === 0) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"defineRoom: physics: is configured but no collection declares physics in its schema \u2014 add entity(fields, { physics: { body: { x: 'x', \u2026 } } }) to the collection the world simulates"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (mode !== "tick") {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"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
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (physics.engine !== "rapier3d") {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`defineRoom: physics.engine must be 'rapier3d' (got ${JSON.stringify(physics.engine)}); matter.js is not in M2`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const g = physics.gravity;
|
|
109
|
+
if (!g || typeof g !== "object" || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !Number.isFinite(g.z)) {
|
|
110
|
+
throw new Error("defineRoom: physics.gravity must be { x, y, z } finite numbers");
|
|
111
|
+
}
|
|
112
|
+
if (physics.timestep !== void 0 && (!Number.isFinite(physics.timestep) || physics.timestep <= 0)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`defineRoom: physics.timestep must be a positive number of seconds, got ${String(physics.timestep)}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
assertSyncHandler(physics.setup, "physics.setup");
|
|
118
|
+
const bodies = physics.bodies ?? {};
|
|
119
|
+
for (const [k, fn] of Object.entries(bodies)) assertSyncHandler(fn, `physics.bodies.${k}`);
|
|
120
|
+
const given = Object.keys(bodies).sort();
|
|
121
|
+
const expected = [...declared].sort();
|
|
122
|
+
const missing = expected.filter((n) => !given.includes(n));
|
|
123
|
+
const extra = given.filter((n) => !expected.includes(n));
|
|
124
|
+
if (missing.length || extra.length) {
|
|
125
|
+
const parts = [];
|
|
126
|
+
if (missing.length) parts.push(`missing body factories: ${missing.join(", ")}`);
|
|
127
|
+
if (extra.length)
|
|
128
|
+
parts.push(`physics.bodies names collections without schema physics: ${extra.join(", ")}`);
|
|
129
|
+
throw new Error(`defineRoom: ${parts.join("; ")}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
81
132
|
function assertSyncHandler(fn, name) {
|
|
82
133
|
if (fn === void 0) return;
|
|
83
134
|
if (typeof fn !== "function") throw new Error(`defineRoom: ${name} must be a function`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "irtio room-file API: defineRoom, handler and ctx types",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -20,7 +20,18 @@
|
|
|
20
20
|
"dist"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@irtio/schema": "0.
|
|
23
|
+
"@irtio/schema": "0.3.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@dimforge/rapier3d-compat": ">=0.20.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"@dimforge/rapier3d-compat": {
|
|
30
|
+
"optional": true
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@dimforge/rapier3d-compat": "0.20.0"
|
|
24
35
|
},
|
|
25
36
|
"scripts": {
|
|
26
37
|
"build": "tsup",
|