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.
Files changed (84) hide show
  1. package/README.md +104 -76
  2. package/dist/global.d.ts +3 -5
  3. package/dist/host.d.ts +3 -0
  4. package/dist/types/inject.d.ts +4 -4
  5. package/dist/types/net/codec.d.ts +3 -2
  6. package/dist/types/net/core.d.ts +10 -1
  7. package/dist/types/net/index.d.ts +22 -5
  8. package/dist/types/net/replication.d.ts +23 -2
  9. package/dist/types/runtime/device.d.ts +7 -0
  10. package/dist/types/runtime/rpc.d.ts +11 -17
  11. package/dist/types/runtime/wire.d.ts +53 -0
  12. package/dist/types/server/auth/api.d.ts +42 -0
  13. package/dist/types/server/auth/appConfig.d.ts +1 -5
  14. package/dist/types/server/auth/models.d.ts +119 -70
  15. package/dist/types/server/auth/types.d.ts +19 -43
  16. package/dist/types/server/channel.d.ts +57 -19
  17. package/dist/types/server/context.d.ts +2 -2
  18. package/dist/types/server/db/defineDb.d.ts +10 -0
  19. package/dist/types/server/db/index.d.ts +1 -1
  20. package/dist/types/server/db/types.d.ts +76 -6
  21. package/dist/types/server/inject.d.ts +0 -1
  22. package/dist/types/ui/UINode.d.ts +19 -5
  23. package/dist/types/ui/UIScreen.d.ts +1 -0
  24. package/dist/types/ui/UITabs.d.ts +8 -6
  25. package/dist/types/ui/theme.d.ts +48 -13
  26. package/dist/types/version.d.ts +1 -1
  27. package/dist/types.json +1 -1
  28. package/package.json +4 -2
  29. package/prompts/README.md +1 -1
  30. package/prompts/design.md +19 -19
  31. package/prompts/dist/2d-game.md +45 -31
  32. package/prompts/dist/3d-app.md +45 -31
  33. package/prompts/dist/ar-app.md +45 -31
  34. package/prompts/dist/design.md +25 -24
  35. package/prompts/dist/ui-app.md +45 -31
  36. package/prompts/ui-design.md +6 -5
  37. package/prompts/ui.md +25 -22
  38. package/src/animate/tween/read.ts +146 -0
  39. package/src/bridges/device.d.ts +9 -0
  40. package/src/bridges/tree.d.ts +5 -0
  41. package/src/canvas/gen/cssColor.ts +1 -1
  42. package/src/canvas/gen/recorder.ts +1 -1
  43. package/src/canvas/gen/spec.ts +1 -1
  44. package/src/chisel.ts +1 -1
  45. package/src/compile/bundler.ts +6 -0
  46. package/src/compile/compileProject.ts +3 -1
  47. package/src/compile/index.ts +3 -1
  48. package/src/compile/serverSplit.ts +58 -11
  49. package/src/compile/serverTypes.ts +189 -8
  50. package/src/host.d.ts +3 -0
  51. package/src/inject.ts +7 -7
  52. package/src/net/codec.ts +19 -10
  53. package/src/net/core.ts +18 -5
  54. package/src/net/index.ts +30 -9
  55. package/src/net/replication.ts +63 -28
  56. package/src/runtime/device.ts +12 -0
  57. package/src/runtime/rpc.ts +101 -40
  58. package/src/runtime/wire.ts +35 -0
  59. package/src/server/auth/api.ts +94 -0
  60. package/src/server/auth/appConfig.ts +2 -3
  61. package/src/server/auth/host.ts +244 -174
  62. package/src/server/auth/models.ts +45 -62
  63. package/src/server/auth/types.ts +19 -34
  64. package/src/server/channel.ts +97 -29
  65. package/src/server/channelHub.ts +153 -0
  66. package/src/server/context.ts +2 -2
  67. package/src/server/db/defineDb.ts +96 -36
  68. package/src/server/db/index.ts +1 -1
  69. package/src/server/db/types.ts +76 -8
  70. package/src/server/host.ts +25 -10
  71. package/src/server/inject.ts +2 -2
  72. package/src/server/runtime.ts +34 -12
  73. package/src/ui/UINode.ts +22 -5
  74. package/src/ui/UIScreen.ts +5 -0
  75. package/src/ui/UITabs.ts +19 -17
  76. package/src/ui/styleColor.ts +10 -1
  77. package/src/ui/theme.ts +96 -41
  78. package/src/version.ts +1 -1
  79. package/tests/helpers/fakeTree.ts +1 -0
  80. package/dist/types/plugins/oauth.d.ts +0 -25
  81. package/dist/types/server/auth/global.d.ts +0 -56
  82. package/src/plugins/oauth.ts +0 -61
  83. package/src/server/auth/global.ts +0 -80
  84. 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
- listen(opts: { port?: number; maxClients?: number; name?: string; dedicated?: boolean } = {}): boolean {
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
- connect(target: { address: string }, opts: { name?: string } = {}): Promise<void> {
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
- kind<S extends object>(name: string, defaults: S, opts: { transform?: NetTransform; client?: (rep: NetEntity<S>, scene: any) => any } = {}): NetKind<S> {
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
 
@@ -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 (last 3 ticks)
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 × { u16 netId · u8 kind · state record } (every 3rd tick)
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
- /** Births the client-side proxy on spawn; the SDK attaches `Replicated` to what it returns. */
48
- readonly client?: (rep: NetEntity<S>, scene: Scene | null) => Node | void
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 VERTICAL_DEADBAND = 0.12 // metres — owner height disagreements below this are floor-snap noise
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: { transform?: NetTransform; client?: NetKind<S>["client"] } = {}): NetKind<S> => {
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 += 3 + e.kind.layout.bytes
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 = 3 + e.kind.layout.bytes
403
+ const rec = REC_HEAD + e.kind.layout.bytes
374
404
  if (o + rec > buf.byteLength) break
375
405
  samplePose(e)
376
- view.setUint16(o, e.netId, true); o += 2
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 + 3 > data.byteLength) break
443
- const netId = view.getUint16(o, true)
444
- const kind = clientKinds[view.getUint8(o + 2)]
445
- o += 3
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
- // Vertical deadband: a grounded character's height differs by a few cm between two machines
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) < VERTICAL_DEADBAND) dy = 0
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 >= SNAP_DISTANCE) e.correction.set(0, 0, 0)
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]) < SNAP_DISTANCE) { e.correction.x -= sh[0]; e.correction.y -= sh[1]; e.correction.z -= 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
- if (!inputDef || inputRing.size === 0) return
542
- const first = tick - 2
543
- const buf = new ArrayBuffer(6 + 3 * inputDef.layout.bytes)
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, 3)
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
@@ -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. */
@@ -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 frames: → {t:"hello",session} {t:"sub",id,ch,topic} {t:"unsub",id}
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 setSession = (serverUrl: string, token: string | null) => {
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
- sockets.get(serverUrl)?.onSessionChanged(token)
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 Handlers = Record<string, ((payload: any) => void) | undefined> & {
89
- /** The socket dropped and came back: subscriptions were re-sent, but events in between are lost — refetch. */
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 (`onJoin` threw) or the socket can't connect. */
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
- type Sub = { id: number, ch: string, topic: string, handlers: Handlers, ready: boolean }
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, topic: string, handlers: Handlers): { close(): void } {
113
- const sub: Sub = { id: this.nextId++, ch, topic, handlers, ready: false }
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.open) this.send({ t: "sub", id: sub.id, ch, topic })
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
- if (this.subs.size === 0) this.shutdown()
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) this.send({ t: "hello", session: token })
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
- this.send({ t: "hello", session: getSession(this.serverUrl) })
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
- sub.ready = false
144
- this.send({ t: "sub", id: sub.id, ch: sub.ch, topic: sub.topic })
145
- if (reconnected) sub.handlers.reconnect?.()
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: any
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 || this.subs.size === 0) return
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: any) {
203
+ private onFrame(msg: ServerFrame) {
167
204
  switch (msg?.t) {
168
- case "session": setSession(this.serverUrl, msg.session ?? null); break
169
- case "ok": { const s = this.subs.get(msg.id); if (s) s.ready = true; break }
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
- this.subs.delete(msg.id)
174
- s.handlers.error?.(new RpcError(msg.status ?? 0, msg.message ?? "subscription refused"))
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
- for (const s of this.subs.values()) {
179
- if (s.ch === msg.ch && s.topic === msg.topic) s.handlers[msg.event]?.(msg.data)
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: unknown) { this.ws?.send(JSON.stringify(frame)) }
229
+ private send(frame: ClientFrame) { this.ws?.send(JSON.stringify(frame)) }
187
230
 
188
- private shutdown() {
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
- sockets.delete(this.serverUrl)
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
- export interface ClientChannel<E extends Record<string, unknown> = Record<string, unknown>> {
206
- /** Subscribe to `topic`; handlers are keyed by event name (+ `reconnect`/`error`). Returns `{ close }`. */
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
- /** Build the client proxy of channel `id`. */
211
- export const __channel = (serverUrl: string, id: string): ClientChannel<any> => ({
212
- subscribe(topic, handlers) {
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
- return socketFor(serverUrl).subscribe(id, topic, handlers as Handlers)
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 }