lecodes-sdk 2.0.3 → 2.0.5
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/README.md +104 -76
- package/dist/global.d.ts +3 -5
- package/dist/host.d.ts +3 -0
- package/dist/types/inject.d.ts +4 -4
- package/dist/types/net/codec.d.ts +3 -2
- package/dist/types/net/core.d.ts +10 -1
- package/dist/types/net/index.d.ts +22 -5
- package/dist/types/net/replication.d.ts +23 -2
- package/dist/types/runtime/device.d.ts +7 -0
- package/dist/types/runtime/rpc.d.ts +11 -17
- package/dist/types/runtime/wire.d.ts +53 -0
- package/dist/types/server/auth/api.d.ts +42 -0
- package/dist/types/server/auth/appConfig.d.ts +1 -5
- package/dist/types/server/auth/models.d.ts +119 -70
- package/dist/types/server/auth/types.d.ts +19 -43
- package/dist/types/server/channel.d.ts +57 -19
- package/dist/types/server/context.d.ts +2 -2
- package/dist/types/server/db/defineDb.d.ts +10 -0
- package/dist/types/server/db/index.d.ts +1 -1
- package/dist/types/server/db/types.d.ts +76 -6
- package/dist/types/server/inject.d.ts +0 -1
- package/dist/types/ui/UINode.d.ts +19 -5
- package/dist/types/ui/UIScreen.d.ts +1 -0
- package/dist/types/ui/UITabs.d.ts +8 -6
- package/dist/types/ui/theme.d.ts +48 -13
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +4 -2
- package/prompts/README.md +1 -1
- package/prompts/design.md +19 -19
- package/prompts/dist/2d-game.md +45 -31
- package/prompts/dist/3d-app.md +45 -31
- package/prompts/dist/ar-app.md +45 -31
- package/prompts/dist/design.md +25 -24
- package/prompts/dist/ui-app.md +45 -31
- package/prompts/ui-design.md +6 -5
- package/prompts/ui.md +25 -22
- package/src/animate/tween/read.ts +146 -0
- package/src/bridges/device.d.ts +9 -0
- package/src/bridges/tree.d.ts +5 -0
- package/src/canvas/gen/cssColor.ts +1 -1
- package/src/canvas/gen/recorder.ts +1 -1
- package/src/canvas/gen/spec.ts +1 -1
- package/src/chisel.ts +1 -1
- package/src/compile/bundler.ts +6 -0
- package/src/compile/compileProject.ts +3 -1
- package/src/compile/index.ts +3 -1
- package/src/compile/serverSplit.ts +58 -11
- package/src/compile/serverTypes.ts +189 -8
- package/src/host.d.ts +3 -0
- package/src/inject.ts +7 -7
- package/src/net/codec.ts +19 -10
- package/src/net/core.ts +18 -5
- package/src/net/index.ts +30 -9
- package/src/net/replication.ts +63 -28
- package/src/runtime/device.ts +12 -0
- package/src/runtime/rpc.ts +101 -40
- package/src/runtime/wire.ts +35 -0
- package/src/server/auth/api.ts +94 -0
- package/src/server/auth/appConfig.ts +2 -3
- package/src/server/auth/host.ts +244 -174
- package/src/server/auth/models.ts +45 -62
- package/src/server/auth/types.ts +19 -34
- package/src/server/channel.ts +97 -29
- package/src/server/channelHub.ts +153 -0
- package/src/server/context.ts +2 -2
- package/src/server/db/defineDb.ts +96 -36
- package/src/server/db/index.ts +1 -1
- package/src/server/db/types.ts +76 -8
- package/src/server/host.ts +25 -10
- package/src/server/inject.ts +2 -2
- package/src/server/runtime.ts +34 -12
- package/src/ui/UINode.ts +22 -5
- package/src/ui/UIScreen.ts +5 -0
- package/src/ui/UITabs.ts +19 -17
- package/src/ui/styleColor.ts +10 -1
- package/src/ui/theme.ts +96 -41
- package/src/version.ts +1 -1
- package/tests/helpers/fakeTree.ts +1 -0
- package/dist/types/plugins/oauth.d.ts +0 -25
- package/dist/types/server/auth/global.d.ts +0 -56
- package/src/plugins/oauth.ts +0 -61
- package/src/server/auth/global.ts +0 -80
- package/tests/helpers/memoryMarci.ts +0 -124
package/src/net/index.ts
CHANGED
|
@@ -10,12 +10,12 @@ import {
|
|
|
10
10
|
type Listener, type NetEvents, type NetLaunch, type NetMessage, type NetRole, type NetStatus,
|
|
11
11
|
} from "./core"
|
|
12
12
|
import {
|
|
13
|
-
NetEntity, Replicated, allEntities, currentTick, declareInput, declareKind, entityById, installReplication, setInput, stats,
|
|
14
|
-
type NetInput, type NetKind, type NetTransform,
|
|
13
|
+
NetEntity, Replicated, allEntities, currentTick, declareInput, declareKind, entityById, installReplication, setInput, stats, viewTick,
|
|
14
|
+
type NetCorrection, type NetInput, type NetKind, type NetTransform,
|
|
15
15
|
} from "./replication"
|
|
16
16
|
|
|
17
17
|
export { NetPlayer, NetEntity, Replicated }
|
|
18
|
-
export type { NetEvents, NetLaunch, NetMessage, NetRole, NetStatus, NetInput, NetKind, NetTransform }
|
|
18
|
+
export type { NetEvents, NetLaunch, NetMessage, NetRole, NetStatus, NetInput, NetKind, NetTransform, NetCorrection }
|
|
19
19
|
|
|
20
20
|
class NetImpl {
|
|
21
21
|
get role(): NetRole { return state.role }
|
|
@@ -34,12 +34,21 @@ class NetImpl {
|
|
|
34
34
|
/** The simulation tick — one per fixed step (`updateFixed`). The server's is THE tick; a client
|
|
35
35
|
* runs a few ticks ahead so its inputs arrive in time. */
|
|
36
36
|
get tick(): number { return currentTick() }
|
|
37
|
+
/** The server tick this machine is SHOWING the others at: on a client, its estimate of the server's
|
|
38
|
+
* clock less the interpolation delay (fractional); `tick` everywhere else, where nothing is
|
|
39
|
+
* interpolated. What a client names when it tells the server "as I saw it". */
|
|
40
|
+
get viewTick(): number { return viewTick() }
|
|
37
41
|
/** Live counters: snapshots, input lead / `ahead`, corrections, missed inputs, bytes out. */
|
|
38
42
|
get stats(): typeof stats { return stats }
|
|
39
43
|
|
|
40
44
|
/** Start serving on `port`. From offline this makes us the host (a player who is also the
|
|
41
|
-
* server); on the dedicated exe (`Net.launch.role === "server"`) there is no local player.
|
|
42
|
-
|
|
45
|
+
* server); on the dedicated exe (`Net.launch.role === "server"`) there is no local player.
|
|
46
|
+
* `accept` is the game's say in the handshake: it is given what the client's `connect` passed as
|
|
47
|
+
* `hello` and returns a reason to refuse (the client's `reject` event carries it), nothing to accept. */
|
|
48
|
+
listen(opts: {
|
|
49
|
+
port?: number; maxClients?: number; name?: string; dedicated?: boolean
|
|
50
|
+
accept?: (hello: unknown, who: { name: string }) => string | null | void
|
|
51
|
+
} = {}): boolean {
|
|
43
52
|
const b = bridge()
|
|
44
53
|
if (!b) { emit("error", "multiplayer is not supported on this host"); return false }
|
|
45
54
|
if (state.role !== "offline") this.disconnect()
|
|
@@ -56,6 +65,7 @@ class NetImpl {
|
|
|
56
65
|
state.isServer = true
|
|
57
66
|
state.isClient = !dedicated
|
|
58
67
|
state.status = "online"
|
|
68
|
+
state.accept = opts.accept ?? null
|
|
59
69
|
if (!dedicated) ensureMe()
|
|
60
70
|
installReplication()
|
|
61
71
|
startPhases()
|
|
@@ -63,12 +73,15 @@ class NetImpl {
|
|
|
63
73
|
return true
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
/** Connect to a host/server (`address` = "ip:port"). Resolves once the server welcomed us.
|
|
67
|
-
|
|
76
|
+
/** Connect to a host/server (`address` = "ip:port"). Resolves once the server welcomed us.
|
|
77
|
+
* `hello` = anything JSON the game wants the server to see before it lets us in (its
|
|
78
|
+
* `Net.listen({ accept })`, then `player.hello`); the SDK only carries it. */
|
|
79
|
+
connect(target: { address: string }, opts: { name?: string; hello?: unknown } = {}): Promise<void> {
|
|
68
80
|
const b = bridge()
|
|
69
81
|
if (!b) return Promise.reject(new Error("multiplayer is not supported on this host"))
|
|
70
82
|
if (state.role !== "offline") this.disconnect()
|
|
71
83
|
if (opts.name) state.myName = opts.name
|
|
84
|
+
state.hello = opts.hello
|
|
72
85
|
return new Promise<void>((resolve, reject) => {
|
|
73
86
|
if (!b.connect(target.address)) { reject(new Error(`bad address '${target.address}'`)); return }
|
|
74
87
|
state.role = "client"
|
|
@@ -163,8 +176,16 @@ class NetImpl {
|
|
|
163
176
|
|
|
164
177
|
/** A replicated entity type: the defaults are the snapshot layout (same field rules as `input`),
|
|
165
178
|
* `transform` tells the SDK which fields mirror the node's pose, `client` births the proxy on a
|
|
166
|
-
* client when the server spawns one (`new Node().aspect(Replicated, { kind, owner })`)
|
|
167
|
-
|
|
179
|
+
* client when the server spawns one (`new Node().aspect(Replicated, { kind, owner })`) — it may
|
|
180
|
+
* be set later, `Kind.client = …`, where the client's code lives. `discrete` names the numeric
|
|
181
|
+
* fields that are never interpolated (an index, a count); `correction` tunes how the owner's
|
|
182
|
+
* prediction is brought back to the server's state (all in the game's own units). */
|
|
183
|
+
kind<S extends object>(name: string, defaults: S, opts: {
|
|
184
|
+
transform?: NetTransform
|
|
185
|
+
client?: (rep: NetEntity<S>, scene: any) => any
|
|
186
|
+
discrete?: readonly (keyof S & string)[]
|
|
187
|
+
correction?: Partial<NetCorrection>
|
|
188
|
+
} = {}): NetKind<S> {
|
|
168
189
|
return declareKind(name, defaults, opts as any)
|
|
169
190
|
}
|
|
170
191
|
|
package/src/net/replication.ts
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
// offset `rep.correction` absorbs the jump and decays.
|
|
17
17
|
//
|
|
18
18
|
// Wire (channel 1, unreliable, binary; a leading type byte):
|
|
19
|
-
// 1 inputs C→S u8 1 · u32 firstTick · u8 count · count × input record (
|
|
19
|
+
// 1 inputs C→S u8 1 · u32 firstTick · u8 count · count × input record (the ticks since the
|
|
20
|
+
// last send + 2 older ones, 3 at least; sent when the tick moved)
|
|
20
21
|
// 2 snapshot S→C u8 2 · u32 tick · u32 lastInputTick · u8 missed · u16 count ·
|
|
21
|
-
// count × {
|
|
22
|
+
// count × { u32 netId · u8 kind · state record } (every 3rd tick)
|
|
22
23
|
// Channel 0 (reliable JSON): `$spawn {netId, kind, owner, state}` · `$despawn {netId}`.
|
|
23
24
|
|
|
24
25
|
import { Aspect } from "../core/Aspect"
|
|
@@ -31,6 +32,19 @@ import { CH_UNRELIABLE, CH_RELIABLE, LOCAL_SLOT, NetPlayer, bridge, broadcastRaw
|
|
|
31
32
|
|
|
32
33
|
export type NetTransform = "pos" | "pos+rot" | "none"
|
|
33
34
|
|
|
35
|
+
/** How an owner's prediction is brought back to the server's state. The SDK knows nothing of what
|
|
36
|
+
* the entity is, so the defaults are neutral: every disagreement counts, none is a teleport. */
|
|
37
|
+
export type NetCorrection = {
|
|
38
|
+
/** A vertical disagreement below this is ignored (world units). 0 = none ignored. A body that
|
|
39
|
+
* snaps to a floor differs by a little between two machines, and lifting it by that feeds itself. */
|
|
40
|
+
deadbandY: number
|
|
41
|
+
/** A disagreement of this much or more is a teleport: the body jumps and `correction` stays zero
|
|
42
|
+
* (world units). Infinity = always smoothed. */
|
|
43
|
+
snap: number
|
|
44
|
+
/** The decay time constant of the visual offset `correction` (seconds). 0 = no smoothing. */
|
|
45
|
+
tau: number
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
/** The per-tick input layout: `const PlayerInput = Net.input({ moveX: 0, moveY: 0, jump: false })`. */
|
|
35
49
|
export type NetInput<T extends object = any> = { readonly layout: Layout; readonly defaults: T; readonly __t?: T }
|
|
36
50
|
|
|
@@ -44,8 +58,10 @@ export type NetKind<S extends object = any> = {
|
|
|
44
58
|
/** Which state fields the SDK samples on the server and applies to a client proxy: `pos` (a
|
|
45
59
|
* `pos: [x,y,z]` field), `pos+rot` (+ a `rot: [x,y,z,w]` quaternion field), or `none`. */
|
|
46
60
|
readonly transform: NetTransform
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
readonly correction: NetCorrection
|
|
62
|
+
/** Births the client-side proxy on spawn; the SDK attaches `Replicated` to what it returns.
|
|
63
|
+
* Assignable: a declaration both ends share may leave it to the client's own module. */
|
|
64
|
+
client?: (rep: NetEntity<S>, scene: Scene | null) => Node | void
|
|
49
65
|
readonly __t?: S
|
|
50
66
|
/** @internal scratch state for snapshot decoding */
|
|
51
67
|
readonly _scratch: S
|
|
@@ -56,10 +72,10 @@ const SNAPSHOT_EVERY = 3 // 20 Hz
|
|
|
56
72
|
const INTERP_DELAY = 6 // ticks behind the estimated server tick (100 ms)
|
|
57
73
|
const LEAD_MIN = 2, LEAD_MAX = 8
|
|
58
74
|
const SNAPSHOT_BUDGET = 1000 // bytes — one unreliable datagram (creator-net caps messages at 1024)
|
|
59
|
-
const CORRECTION_TAU = 0.1 // seconds — the visual offset's decay time constant
|
|
60
|
-
const SNAP_DISTANCE = 2 // metres — a bigger disagreement is a teleport: no smoothing
|
|
75
|
+
const CORRECTION_TAU = 0.1 // seconds — the visual offset's decay time constant, unless the kind says
|
|
61
76
|
const NO_INPUT = 0xffffffff
|
|
62
|
-
const
|
|
77
|
+
const INPUT_MAX = 8 // input records in one packet (the ring keeps as many ticks)
|
|
78
|
+
const REC_HEAD = 5 // u32 netId · u8 kind
|
|
63
79
|
|
|
64
80
|
// ---- registries ---------------------------------------------------------------------------------------
|
|
65
81
|
|
|
@@ -72,6 +88,8 @@ let nextNetId = 1
|
|
|
72
88
|
|
|
73
89
|
let tick = 0
|
|
74
90
|
let localInput: any = null // what Net.setInput set this tick (every role but `server`)
|
|
91
|
+
let localSet = false // Net.setInput was called at all this session
|
|
92
|
+
let sentTick = -1 // client: the tick of the newest input sent
|
|
75
93
|
const inputRing = new Map<number, any>() // client: tick → values, resent with the next 2 sends
|
|
76
94
|
let estimate = -1 // client: the server's tick, fractional, advanced per frame
|
|
77
95
|
let leadCooldown = 0
|
|
@@ -147,6 +165,12 @@ export class NetEntity<S extends object = any> {
|
|
|
147
165
|
const p = this.owner
|
|
148
166
|
return (p?._in?.cur ?? inp.defaults) as T
|
|
149
167
|
}
|
|
168
|
+
/** `input()` is an input the owner gave — false until the first one exists (the server before
|
|
169
|
+
* the owner's first packet, the owner before its first `Net.setInput`), when it is the defaults. */
|
|
170
|
+
get hasInput(): boolean {
|
|
171
|
+
if (!state.isServer || this.isMine) return localSet
|
|
172
|
+
return !!this.owner?._in?.cur
|
|
173
|
+
}
|
|
150
174
|
|
|
151
175
|
/** Client: `state` was refreshed (a snapshot landed / the interpolation moved). */
|
|
152
176
|
on(event: "state", cb: (s: S) => void): this { if (!this._listeners.includes(cb)) this._listeners.push(cb); return this }
|
|
@@ -157,10 +181,13 @@ export class NetEntity<S extends object = any> {
|
|
|
157
181
|
|
|
158
182
|
// ---- declarations (Net.kind / Net.input) --------------------------------------------------------------
|
|
159
183
|
|
|
160
|
-
export const declareKind = <S extends object>(name: string, defaults: S, opts: {
|
|
184
|
+
export const declareKind = <S extends object>(name: string, defaults: S, opts: {
|
|
185
|
+
transform?: NetTransform; client?: NetKind<S>["client"]; discrete?: readonly string[]; correction?: Partial<NetCorrection>
|
|
186
|
+
} = {}): NetKind<S> => {
|
|
161
187
|
if (kinds.has(name)) throw new Error(`Net.kind('${name}') is already declared`)
|
|
162
188
|
if (kindList.length >= 255) throw new Error("Net.kind: at most 255 kinds")
|
|
163
|
-
const layout = makeLayout(defaults as Record<string, unknown>, `Net.kind('${name}')
|
|
189
|
+
const layout = makeLayout(defaults as Record<string, unknown>, `Net.kind('${name}')`, opts.discrete)
|
|
190
|
+
const correction: NetCorrection = { deadbandY: 0, snap: Infinity, tau: CORRECTION_TAU, ...opts.correction }
|
|
164
191
|
const transform = opts.transform ?? "none"
|
|
165
192
|
const need = transform === "pos" ? ["pos"] : transform === "pos+rot" ? ["pos", "rot"] : []
|
|
166
193
|
for (const key of need) {
|
|
@@ -168,7 +195,7 @@ export const declareKind = <S extends object>(name: string, defaults: S, opts: {
|
|
|
168
195
|
const len = key === "pos" ? 3 : 4
|
|
169
196
|
if (!f || f.kind !== 2 || f.len !== len) throw new Error(`Net.kind('${name}'): transform '${transform}' needs a '${key}' field of ${len} numbers`)
|
|
170
197
|
}
|
|
171
|
-
const kind: NetKind<S> = { name, id: kindList.length, layout, defaults: cloneDefaults(defaults), transform, client: opts.client, _scratch: cloneDefaults(defaults) }
|
|
198
|
+
const kind: NetKind<S> = { name, id: kindList.length, layout, defaults: cloneDefaults(defaults), transform, correction, client: opts.client, _scratch: cloneDefaults(defaults) }
|
|
172
199
|
kinds.set(name, kind)
|
|
173
200
|
kindList.push(kind)
|
|
174
201
|
return kind
|
|
@@ -187,6 +214,7 @@ export const setInput = <T extends object>(inp: NetInput<T>, values: Partial<T>)
|
|
|
187
214
|
const v = cloneDefaults(inp.defaults)
|
|
188
215
|
assignState(inp.layout, v, values)
|
|
189
216
|
localInput = v
|
|
217
|
+
localSet = true
|
|
190
218
|
if (state.role === "client") {
|
|
191
219
|
inputRing.set(tick, v)
|
|
192
220
|
for (const t of inputRing.keys()) if (t < tick - 8) inputRing.delete(t)
|
|
@@ -194,6 +222,7 @@ export const setInput = <T extends object>(inp: NetInput<T>, values: Partial<T>)
|
|
|
194
222
|
}
|
|
195
223
|
|
|
196
224
|
export const currentTick = (): number => tick
|
|
225
|
+
export const viewTick = (): number => (state.role === "client" && estimate >= 0 ? estimate - INTERP_DELAY : tick)
|
|
197
226
|
export const allEntities = (): NetEntity[] => [...entities.values()]
|
|
198
227
|
export const entityById = (netId: number): NetEntity | undefined => entities.get(netId)
|
|
199
228
|
|
|
@@ -314,6 +343,7 @@ export class Replicated extends Aspect<"replicated", Node> {
|
|
|
314
343
|
get correction(): Vec3 { return this._rec?.correction ?? ZERO }
|
|
315
344
|
/** The owner's input for the tick being simulated (see `NetEntity.input`). */
|
|
316
345
|
input<T extends object>(inp: NetInput<T>): T { return this._rec ? this._rec.input(inp) : (localInput ?? inp.defaults) }
|
|
346
|
+
get hasInput(): boolean { return this._rec ? this._rec.hasInput : localSet }
|
|
317
347
|
// state-change listeners live on the entity: `node.replicated.entity.on('state', cb)` (the aspect's
|
|
318
348
|
// own on/off are its aspect events)
|
|
319
349
|
}
|
|
@@ -358,7 +388,7 @@ const buildSnapshot = (forTick: number): void => {
|
|
|
358
388
|
const list = [...entities.values()]
|
|
359
389
|
if (list.length === 0) { pendingSnapshot = null; return }
|
|
360
390
|
let size = 0
|
|
361
|
-
for (const e of list) size +=
|
|
391
|
+
for (const e of list) size += REC_HEAD + e.kind.layout.bytes
|
|
362
392
|
const buf = new ArrayBuffer(Math.min(size, SNAPSHOT_BUDGET))
|
|
363
393
|
const view = new DataView(buf)
|
|
364
394
|
let o = 0
|
|
@@ -370,10 +400,10 @@ const buildSnapshot = (forTick: number): void => {
|
|
|
370
400
|
}
|
|
371
401
|
for (let i = 0; i < n; i++) {
|
|
372
402
|
const e = list[(snapshotStart + i) % n]
|
|
373
|
-
const rec =
|
|
403
|
+
const rec = REC_HEAD + e.kind.layout.bytes
|
|
374
404
|
if (o + rec > buf.byteLength) break
|
|
375
405
|
samplePose(e)
|
|
376
|
-
view.
|
|
406
|
+
view.setUint32(o, e.netId, true); o += 4
|
|
377
407
|
view.setUint8(o, e.kind.id); o += 1
|
|
378
408
|
o = packInto(view, o, e.kind.layout, e.state)
|
|
379
409
|
written++
|
|
@@ -439,10 +469,10 @@ const onClientBinary = (channel: number, data: ArrayBuffer): void => {
|
|
|
439
469
|
|
|
440
470
|
let o = 12
|
|
441
471
|
for (let i = 0; i < count; i++) {
|
|
442
|
-
if (o +
|
|
443
|
-
const netId = view.
|
|
444
|
-
const kind = clientKinds[view.getUint8(o +
|
|
445
|
-
o +=
|
|
472
|
+
if (o + REC_HEAD > data.byteLength) break
|
|
473
|
+
const netId = view.getUint32(o, true)
|
|
474
|
+
const kind = clientKinds[view.getUint8(o + 4)]
|
|
475
|
+
o += REC_HEAD
|
|
446
476
|
if (!kind) {
|
|
447
477
|
if (!warnedKind) { warnedKind = true; console.warn("Net: snapshot with an unknown kind id — dropped") }
|
|
448
478
|
return
|
|
@@ -478,11 +508,9 @@ const reconcileOwner = (e: NetEntity, T: number): void => {
|
|
|
478
508
|
if (!pred) return
|
|
479
509
|
const s = e.state as any
|
|
480
510
|
const dx = s.pos[0] - pred[0], dz = s.pos[2] - pred[2]
|
|
481
|
-
//
|
|
482
|
-
// (landing phase, floor snap) and lifting the body by that much only makes it fall again — an
|
|
483
|
-
// oscillation the correction itself feeds. Only a real height difference (a ledge, a jump) counts.
|
|
511
|
+
// the kind's vertical deadband (NetCorrection.deadbandY): what it calls noise is not corrected
|
|
484
512
|
let dy = s.pos[1] - pred[1]
|
|
485
|
-
if (Math.abs(dy) <
|
|
513
|
+
if (Math.abs(dy) < e.kind.correction.deadbandY) dy = 0
|
|
486
514
|
const d = Math.hypot(dx, dy, dz)
|
|
487
515
|
if (d < 0.005) return
|
|
488
516
|
stats.corrections++
|
|
@@ -491,7 +519,7 @@ const reconcileOwner = (e: NetEntity, T: number): void => {
|
|
|
491
519
|
for (const q of e._pred.values()) { q[0] += dx; q[1] += dy; q[2] += dz }
|
|
492
520
|
const sh = e._shift ?? (e._shift = [0, 0, 0])
|
|
493
521
|
sh[0] += dx; sh[1] += dy; sh[2] += dz
|
|
494
|
-
if (d >=
|
|
522
|
+
if (d >= e.kind.correction.snap) e.correction.set(0, 0, 0)
|
|
495
523
|
}
|
|
496
524
|
|
|
497
525
|
const applyOwnerShift = (e: NetEntity): void => {
|
|
@@ -502,7 +530,7 @@ const applyOwnerShift = (e: NetEntity): void => {
|
|
|
502
530
|
e.node.position = [p.x + sh[0], p.y + sh[1], p.z + sh[2]]
|
|
503
531
|
const rec = e._pred.get(tick - 1)
|
|
504
532
|
if (rec) { rec[0] += sh[0]; rec[1] += sh[1]; rec[2] += sh[2] }
|
|
505
|
-
if (Math.hypot(sh[0], sh[1], sh[2]) <
|
|
533
|
+
if (Math.hypot(sh[0], sh[1], sh[2]) < e.kind.correction.snap) { e.correction.x -= sh[0]; e.correction.y -= sh[1]; e.correction.z -= sh[2] }
|
|
506
534
|
}
|
|
507
535
|
|
|
508
536
|
const clientFrame = (): void => {
|
|
@@ -510,11 +538,12 @@ const clientFrame = (): void => {
|
|
|
510
538
|
if (estimate >= 0) estimate += dt * 60
|
|
511
539
|
stats.lead = estimate >= 0 ? tick - estimate : 0
|
|
512
540
|
const rt = estimate - INTERP_DELAY
|
|
513
|
-
const k = Math.exp(-dt / CORRECTION_TAU)
|
|
514
541
|
for (const e of entities.values()) {
|
|
515
542
|
if (e.isMine) {
|
|
516
543
|
const c = e.correction
|
|
517
544
|
if (c.x !== 0 || c.y !== 0 || c.z !== 0) {
|
|
545
|
+
const tau = e.kind.correction.tau
|
|
546
|
+
const k = tau > 0 ? Math.exp(-dt / tau) : 0
|
|
518
547
|
c.x *= k; c.y *= k; c.z *= k
|
|
519
548
|
if (Math.hypot(c.x, c.y, c.z) < 1e-3) c.set(0, 0, 0)
|
|
520
549
|
}
|
|
@@ -538,13 +567,17 @@ const clientFrame = (): void => {
|
|
|
538
567
|
}
|
|
539
568
|
|
|
540
569
|
const sendInputs = (): void => {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
570
|
+
// nothing new since the last send: a frame without a fixed step
|
|
571
|
+
if (!inputDef || inputRing.size === 0 || tick === sentTick) return
|
|
572
|
+
// every tick since the last send (a long frame steps more than once) + 2 older ones against loss
|
|
573
|
+
const count = Math.min(INPUT_MAX, Math.max(3, tick - sentTick + 2))
|
|
574
|
+
sentTick = tick
|
|
575
|
+
const first = tick - count + 1
|
|
576
|
+
const buf = new ArrayBuffer(6 + count * inputDef.layout.bytes)
|
|
544
577
|
const view = new DataView(buf)
|
|
545
578
|
view.setUint8(0, 1)
|
|
546
579
|
view.setUint32(1, first, true)
|
|
547
|
-
view.setUint8(5,
|
|
580
|
+
view.setUint8(5, count)
|
|
548
581
|
let o = 6
|
|
549
582
|
let last: any = localInput ?? inputDef.defaults
|
|
550
583
|
for (let t = first; t <= tick; t++) {
|
|
@@ -615,6 +648,8 @@ export const installReplication = (): void => {
|
|
|
615
648
|
estimate = -1
|
|
616
649
|
}
|
|
617
650
|
inputRing.clear()
|
|
651
|
+
localSet = false
|
|
652
|
+
sentTick = -1
|
|
618
653
|
pendingSnapshot = null
|
|
619
654
|
leadCooldown = 0
|
|
620
655
|
for (const p of state.players) p._in = null
|
package/src/runtime/device.ts
CHANGED
|
@@ -133,6 +133,18 @@ export const device = {
|
|
|
133
133
|
vibrate(style: HapticStyle = "medium"): void {
|
|
134
134
|
_creatorDevice.vibrate?.(style)
|
|
135
135
|
},
|
|
136
|
+
/** The host's performance overlay: one line over everything with the frames per second the
|
|
137
|
+
* display actually got, how evenly they came (`66 fps · 73% even` is judder, `60 fps · 100% even`
|
|
138
|
+
* is smooth), and the GPU's and the main thread's milliseconds per frame. The host measures and
|
|
139
|
+
* draws it itself about once a second — nothing of it runs in the app's JS, so it does not change
|
|
140
|
+
* what it measures. It belongs to the host, not to the project: it stays on when another project
|
|
141
|
+
* is opened. Reads `false`, and writing does nothing, on hosts without one (web, headless). */
|
|
142
|
+
get statsOverlay(): boolean {
|
|
143
|
+
return _creatorDevice.statsOverlay?.() ?? false
|
|
144
|
+
},
|
|
145
|
+
set statsOverlay(on: boolean) {
|
|
146
|
+
_creatorDevice.setStatsOverlay?.(!!on)
|
|
147
|
+
},
|
|
136
148
|
/** Device-orientation sensor (gyro + accelerometer, fused) for tilt/steering and magic-window /
|
|
137
149
|
* 360° panoramas. Poll `attitude` / `gravity` inside setLoop; they return the freshest fused
|
|
138
150
|
* sample, so the sensor rate need not match your frame rate. Host-gated: a no-op with no sensor. */
|
package/src/runtime/rpc.ts
CHANGED
|
@@ -10,10 +10,7 @@
|
|
|
10
10
|
* Wire (implemented by the runner):
|
|
11
11
|
* POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known
|
|
12
12
|
* → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
|
|
13
|
-
* WS <serverUrl>/ws
|
|
14
|
-
* ← {t:"session",session} {t:"ok",id} {t:"err",id,status,message} {t:"ev",ch,topic,event,data}
|
|
15
|
-
* (`ev` is addressed by channel + topic, not by subscription id, so the server can fan one publish out
|
|
16
|
-
* to every socket of a topic natively; the client delivers it to each matching subscription.)
|
|
13
|
+
* WS <serverUrl>/ws the channel socket — its frames are ./wire.ts
|
|
17
14
|
*
|
|
18
15
|
* The session token is transport-owned (cookie-like): stored under `lecodes.session:<serverUrl>` in
|
|
19
16
|
* localStorage, sent on every call and on the socket, replaced whenever a response carries `session`.
|
|
@@ -23,6 +20,8 @@
|
|
|
23
20
|
import { fetch } from "./fetch"
|
|
24
21
|
import { localStorage } from "./storage"
|
|
25
22
|
import { WebSocket } from "./net"
|
|
23
|
+
import type { ChannelGroup, ClientFrame, ServerFrame } from "./wire"
|
|
24
|
+
import type { ChannelSubscription } from "../server/channel"
|
|
26
25
|
|
|
27
26
|
/** Rejection value of a failed endpoint call: `status` mirrors the server's `ApiError`
|
|
28
27
|
* (0 = the request itself failed: no network / no server). */
|
|
@@ -41,12 +40,17 @@ const sessionKey = (serverUrl: string) => `lecodes.session:${serverUrl}`
|
|
|
41
40
|
const getSession = (serverUrl: string): string | null => {
|
|
42
41
|
try { return localStorage.getItem(sessionKey(serverUrl)) } catch { return null }
|
|
43
42
|
}
|
|
44
|
-
const
|
|
43
|
+
const storeSession = (serverUrl: string, token: string | null) => {
|
|
45
44
|
try {
|
|
46
45
|
if (token) localStorage.setItem(sessionKey(serverUrl), token)
|
|
47
46
|
else localStorage.removeItem(sessionKey(serverUrl))
|
|
48
47
|
} catch { /* storage-less host: the session lives for this run only */ }
|
|
49
|
-
|
|
48
|
+
}
|
|
49
|
+
/** A response carried a token: when it is another one, the channel socket is told (its subscriptions are the old session's). */
|
|
50
|
+
const setSession = (serverUrl: string, token: string | null) => {
|
|
51
|
+
const before = getSession(serverUrl)
|
|
52
|
+
storeSession(serverUrl, token)
|
|
53
|
+
if (token !== before) sockets.get(serverUrl)?.onSessionChanged(token)
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
// ───────────────────────────── endpoints ─────────────────────────────
|
|
@@ -57,6 +61,9 @@ const noServer = (id: string) => new RpcError(0, `Endpoint ${id}: the project ha
|
|
|
57
61
|
export const __rpc = (serverUrl: string, id: string) => {
|
|
58
62
|
const call = async (...args: unknown[]): Promise<any> => {
|
|
59
63
|
if (!serverUrl) throw noServer(id)
|
|
64
|
+
// an optional argument left out is `undefined` in its place — JSON has no such value (it would
|
|
65
|
+
// travel as null and fail the parameter's type), and it is the last ones
|
|
66
|
+
while (args.length && args[args.length - 1] === undefined) args.pop()
|
|
60
67
|
const headers: Record<string, string> = { "content-type": "application/json" }
|
|
61
68
|
const session = getSession(serverUrl)
|
|
62
69
|
if (session) headers.authorization = `Bearer ${session}`
|
|
@@ -85,15 +92,23 @@ export const __serverOnly = (id: string): any =>
|
|
|
85
92
|
|
|
86
93
|
// ───────────────────────────── channels ─────────────────────────────
|
|
87
94
|
|
|
88
|
-
type
|
|
89
|
-
/** The socket dropped and came back: subscriptions were re-sent, but
|
|
95
|
+
type Options = {
|
|
96
|
+
/** The socket dropped and came back: subscriptions were re-sent, but what was published in between is lost — read the state again. */
|
|
90
97
|
reconnect?: () => void
|
|
91
|
-
/** The subscription was refused (
|
|
98
|
+
/** The subscription was refused (a hook of the channel threw). It is asked again when the session changes. */
|
|
92
99
|
error?: (e: RpcError) => void
|
|
93
100
|
}
|
|
94
|
-
|
|
101
|
+
/** `group` is the server's answer (the `ok` frame): undefined until it came. `refused` = the server
|
|
102
|
+
* said no under that session: the subscription is kept and asked again under another one. */
|
|
103
|
+
type Sub = {
|
|
104
|
+
id: number, ch: string, args: unknown[], handler: (message: any) => void, options: Options,
|
|
105
|
+
group: ChannelGroup | null | undefined, refused: { session: string | null } | null,
|
|
106
|
+
}
|
|
95
107
|
|
|
96
108
|
const BACKOFF_MS = [1000, 2000, 5000, 10000, 30000]
|
|
109
|
+
/** How long the socket outlives its last subscription: one screen closing its own and the next
|
|
110
|
+
* opening its own is not a new connection. */
|
|
111
|
+
const LINGER_MS = 3000
|
|
97
112
|
|
|
98
113
|
/** One multiplexed socket per server URL, shared by every channel of the app. */
|
|
99
114
|
class ChannelSocket {
|
|
@@ -103,28 +118,45 @@ class ChannelSocket {
|
|
|
103
118
|
private nextId = 1
|
|
104
119
|
private attempts = 0
|
|
105
120
|
private timer: ReturnType<typeof setTimeout> | null = null
|
|
121
|
+
private linger: ReturnType<typeof setTimeout> | null = null
|
|
106
122
|
private closedByUs = false
|
|
107
123
|
private everOpened = false
|
|
108
124
|
private serverUrl: string
|
|
109
125
|
|
|
110
126
|
constructor(serverUrl: string) { this.serverUrl = serverUrl }
|
|
111
127
|
|
|
112
|
-
subscribe(ch: string,
|
|
113
|
-
const sub: Sub = { id: this.nextId++, ch,
|
|
128
|
+
subscribe(ch: string, args: unknown[], handler: (message: any) => void, options: Options): ChannelSubscription {
|
|
129
|
+
const sub: Sub = { id: this.nextId++, ch, args, handler, options, group: undefined, refused: null }
|
|
114
130
|
this.subs.set(sub.id, sub)
|
|
115
|
-
if (this.
|
|
131
|
+
if (this.linger !== null) { clearTimeout(this.linger); this.linger = null }
|
|
132
|
+
if (this.open) this.send({ t: "sub", id: sub.id, ch, args })
|
|
116
133
|
else this.connect()
|
|
117
134
|
return {
|
|
118
135
|
close: () => {
|
|
136
|
+
// the id changes when the subscription is sent again (a new session)
|
|
119
137
|
if (!this.subs.delete(sub.id)) return
|
|
120
|
-
if (this.open) this.send({ t: "unsub", id: sub.id })
|
|
121
|
-
|
|
138
|
+
if (this.open && !sub.refused) this.send({ t: "unsub", id: sub.id })
|
|
139
|
+
this.idle()
|
|
122
140
|
},
|
|
123
141
|
}
|
|
124
142
|
}
|
|
125
143
|
|
|
144
|
+
/** The session changed outside this socket (a sign-in, a sign-out): what the server granted was
|
|
145
|
+
* granted to someone else. It drops this socket's subscriptions on the `hello`; they are sent
|
|
146
|
+
* again under NEW ids, so an answer still on its way to an old one finds nothing. The refused
|
|
147
|
+
* ones are sent too: the new user may be let in. */
|
|
126
148
|
onSessionChanged(token: string | null) {
|
|
127
|
-
if (this.open)
|
|
149
|
+
if (!this.open) return
|
|
150
|
+
this.send({ t: "hello", session: token })
|
|
151
|
+
const subs = [...this.subs.values()]
|
|
152
|
+
this.subs.clear()
|
|
153
|
+
for (const sub of subs) {
|
|
154
|
+
sub.id = this.nextId++
|
|
155
|
+
sub.group = undefined
|
|
156
|
+
sub.refused = null
|
|
157
|
+
this.subs.set(sub.id, sub)
|
|
158
|
+
this.send({ t: "sub", id: sub.id, ch: sub.ch, args: sub.args })
|
|
159
|
+
}
|
|
128
160
|
}
|
|
129
161
|
|
|
130
162
|
private connect() {
|
|
@@ -136,18 +168,22 @@ class ChannelSocket {
|
|
|
136
168
|
ws.addEventListener("open", () => {
|
|
137
169
|
this.open = true
|
|
138
170
|
this.attempts = 0
|
|
139
|
-
|
|
171
|
+
const session = getSession(this.serverUrl)
|
|
172
|
+
this.send({ t: "hello", session })
|
|
140
173
|
const reconnected = this.everOpened
|
|
141
174
|
this.everOpened = true
|
|
142
175
|
for (const sub of this.subs.values()) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
176
|
+
// refused under this very session: the answer would be the same
|
|
177
|
+
if (sub.refused && sub.refused.session === session) continue
|
|
178
|
+
sub.group = undefined
|
|
179
|
+
sub.refused = null
|
|
180
|
+
this.send({ t: "sub", id: sub.id, ch: sub.ch, args: sub.args })
|
|
181
|
+
if (reconnected) sub.options.reconnect?.()
|
|
146
182
|
}
|
|
147
183
|
})
|
|
148
184
|
ws.addEventListener("message", (data) => {
|
|
149
185
|
if (typeof data !== "string") return
|
|
150
|
-
let msg:
|
|
186
|
+
let msg: ServerFrame
|
|
151
187
|
try { msg = JSON.parse(data) } catch { return }
|
|
152
188
|
this.onFrame(msg)
|
|
153
189
|
})
|
|
@@ -155,7 +191,8 @@ class ChannelSocket {
|
|
|
155
191
|
if (this.ws !== ws) return
|
|
156
192
|
this.ws = null
|
|
157
193
|
this.open = false
|
|
158
|
-
if (this.closedByUs
|
|
194
|
+
if (this.closedByUs) return
|
|
195
|
+
if (this.subs.size === 0) { this.shutdown(); return }
|
|
159
196
|
const delay = BACKOFF_MS[Math.min(this.attempts++, BACKOFF_MS.length - 1)]
|
|
160
197
|
this.timer = setTimeout(() => { this.timer = null; this.connect() }, delay)
|
|
161
198
|
}
|
|
@@ -163,35 +200,49 @@ class ChannelSocket {
|
|
|
163
200
|
ws.addEventListener("error", onDown)
|
|
164
201
|
}
|
|
165
202
|
|
|
166
|
-
private onFrame(msg:
|
|
203
|
+
private onFrame(msg: ServerFrame) {
|
|
167
204
|
switch (msg?.t) {
|
|
168
|
-
|
|
169
|
-
case "
|
|
205
|
+
// the server already serves this socket under the token it sends: stored, nothing re-sent
|
|
206
|
+
case "session": storeSession(this.serverUrl, msg.session ?? null); break
|
|
207
|
+
case "ok": { const s = this.subs.get(msg.id); if (s) s.group = msg.group ?? null; break }
|
|
170
208
|
case "err": {
|
|
171
209
|
const s = this.subs.get(msg.id)
|
|
172
210
|
if (!s) break
|
|
173
|
-
|
|
174
|
-
s.
|
|
211
|
+
// kept until close(): another session may be let in (onSessionChanged)
|
|
212
|
+
s.group = undefined
|
|
213
|
+
s.refused = { session: getSession(this.serverUrl) }
|
|
214
|
+
const e = new RpcError(msg.status ?? 0, msg.message ?? "subscription refused")
|
|
215
|
+
if (s.options.error) s.options.error(e)
|
|
216
|
+
else console.error(`Channel ${s.ch}: the subscription was refused (${e.status}) — ${e.message}`)
|
|
175
217
|
break
|
|
176
218
|
}
|
|
177
219
|
case "ev": {
|
|
178
|
-
|
|
179
|
-
|
|
220
|
+
const group = msg.group ?? null
|
|
221
|
+
for (const s of [...this.subs.values()]) {
|
|
222
|
+
if (s.ch === msg.ch && s.group === group) s.handler(msg.data)
|
|
180
223
|
}
|
|
181
224
|
break
|
|
182
225
|
}
|
|
183
226
|
}
|
|
184
227
|
}
|
|
185
228
|
|
|
186
|
-
private send(frame:
|
|
229
|
+
private send(frame: ClientFrame) { this.ws?.send(JSON.stringify(frame)) }
|
|
187
230
|
|
|
188
|
-
|
|
231
|
+
/** Nothing is subscribed any more: the socket goes after LINGER_MS, unless something subscribes. */
|
|
232
|
+
private idle() {
|
|
233
|
+
if (this.subs.size !== 0 || this.linger !== null) return
|
|
234
|
+
this.linger = setTimeout(() => { this.linger = null; if (this.subs.size === 0) this.shutdown() }, LINGER_MS)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
shutdown() {
|
|
189
238
|
this.closedByUs = true
|
|
190
239
|
if (this.timer !== null) { clearTimeout(this.timer); this.timer = null }
|
|
240
|
+
if (this.linger !== null) { clearTimeout(this.linger); this.linger = null }
|
|
191
241
|
this.ws?.close()
|
|
192
242
|
this.ws = null
|
|
193
243
|
this.open = false
|
|
194
|
-
|
|
244
|
+
this.subs.clear()
|
|
245
|
+
if (sockets.get(this.serverUrl) === this) sockets.delete(this.serverUrl)
|
|
195
246
|
}
|
|
196
247
|
}
|
|
197
248
|
|
|
@@ -202,15 +253,25 @@ const socketFor = (serverUrl: string) => {
|
|
|
202
253
|
return s
|
|
203
254
|
}
|
|
204
255
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
subscribe(topic: string, handlers: { [K in keyof E]?: (payload: E[K]) => void } & { reconnect?: () => void, error?: (e: RpcError) => void }): { close(): void }
|
|
208
|
-
}
|
|
256
|
+
/** @internal close every channel socket now (a test's teardown: no socket lingers into the next one). */
|
|
257
|
+
export const _closeChannelSockets = () => { for (const s of [...sockets.values()]) s.shutdown() }
|
|
209
258
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
212
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Build the client proxy of channel `id` — what a `channel()` export of a `*.server.ts` file is in
|
|
261
|
+
* the app (its type is the server's: src/server/channel.ts). `subscribe([...args,] handler, options?)`:
|
|
262
|
+
* what stands before the handler travels to the channel's `groupBy`.
|
|
263
|
+
*/
|
|
264
|
+
export const __channel = (serverUrl: string, id: string) => ({
|
|
265
|
+
subscribe(...all: unknown[]): ChannelSubscription {
|
|
213
266
|
if (!serverUrl) throw noServer(id)
|
|
214
|
-
|
|
267
|
+
const at = all.findIndex(a => typeof a === "function")
|
|
268
|
+
if (at < 0) throw new RpcError(0, `Channel ${id}: subscribe([...args,] handler) — no handler given`)
|
|
269
|
+
// an optional argument left out is `undefined` in its place — JSON has no such value, and it is the last ones
|
|
270
|
+
const args = all.slice(0, at)
|
|
271
|
+
while (args.length && args[args.length - 1] === undefined) args.pop()
|
|
272
|
+
return socketFor(serverUrl).subscribe(id, args, all[at] as (message: any) => void, (all[at + 1] as Options | undefined) ?? {})
|
|
273
|
+
},
|
|
274
|
+
publish(): never {
|
|
275
|
+
throw new RpcError(0, `Channel ${id}: the server publishes, the app subscribes — call a server function that publishes`)
|
|
215
276
|
},
|
|
216
277
|
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frames of the channel socket (`<serverUrl>/ws`) — the ONE description both ends are written
|
|
3
|
+
* against: the client transport (./rpc.ts) and the server's hub (../server/channelHub.ts, which the
|
|
4
|
+
* runner and the local backend of `lecodes dev` both run). JSON text frames, one per message.
|
|
5
|
+
*
|
|
6
|
+
* → hello who is on this socket. Sent first, and again whenever the session changes: the server
|
|
7
|
+
* then DROPS the socket's subscriptions (they were granted to someone else) and the
|
|
8
|
+
* client sends them again, under new ids.
|
|
9
|
+
* → sub subscribe to channel `ch`; `args` are what the app passed to `subscribe` before the
|
|
10
|
+
* handler — the channel's `groupBy` reads them.
|
|
11
|
+
* → unsub
|
|
12
|
+
* ← session a token minted while the socket was served (the first contact of a guest): the client
|
|
13
|
+
* stores it; nothing is dropped, the server already has it.
|
|
14
|
+
* ← ok the subscription stands; `group` is where the server put it (null = a channel without
|
|
15
|
+
* groups). The client learns its group only here — it never names one.
|
|
16
|
+
* ← err refused (a hook threw) — the subscription is gone.
|
|
17
|
+
* ← ev one published message, addressed by channel + group so the server sends one frame to
|
|
18
|
+
* every socket of a group; the client hands it to each of its subscriptions there.
|
|
19
|
+
*
|
|
20
|
+
* A group's value is not a secret: its subscriber is told it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** What `groupBy` may answer. `42` and `"42"` are two groups. */
|
|
24
|
+
export type ChannelGroup = string | number
|
|
25
|
+
|
|
26
|
+
export type ClientFrame =
|
|
27
|
+
| { t: "hello", session: string | null }
|
|
28
|
+
| { t: "sub", id: number, ch: string, args: unknown[] }
|
|
29
|
+
| { t: "unsub", id: number }
|
|
30
|
+
|
|
31
|
+
export type ServerFrame =
|
|
32
|
+
| { t: "session", session: string }
|
|
33
|
+
| { t: "ok", id: number, group: ChannelGroup | null }
|
|
34
|
+
| { t: "err", id: number, status: number, message: string }
|
|
35
|
+
| { t: "ev", ch: string, group: ChannelGroup | null, data: unknown }
|