lecodes-sdk 2.0.2 → 2.0.4

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/package.json CHANGED
@@ -20,6 +20,7 @@
20
20
  "./bridges/*": "./src/bridges/*.d.ts",
21
21
  "./ui/tree": "./src/ui/tree.ts",
22
22
  "./tween/easing": "./src/animate/tween/easing.ts",
23
+ "./tween/read": "./src/animate/tween/read.ts",
23
24
  "./testing": "./tests/helpers/index.ts",
24
25
  "./testing/preload": "./tests/helpers/preload.ts",
25
26
  "./version": "./src/version.ts"
@@ -42,7 +43,7 @@
42
43
  "typescript": "~5.8.3",
43
44
  "gl-matrix": "^3.4.4"
44
45
  },
45
- "version": "2.0.2",
46
+ "version": "2.0.4",
46
47
  "files": [
47
48
  "src",
48
49
  "dist",
@@ -0,0 +1,146 @@
1
+ // The READER of the tween blob: `buildBlob`'s layout (./spec.ts) back into tracks and keys. For a
2
+ // host that plays an animation with no creator-tween in it (Web Lite hands the tracks to the
3
+ // browser's animations) — creator-tween reads the same layout in C (blob.h).
4
+ //
5
+ // The reader is the builder's twin and lives beside it: tests/animate/blob.test.ts builds a spec,
6
+ // reads it back and holds the two against each other, so a change of the layout that reaches one
7
+ // side only fails there.
8
+
9
+ import { EASE_TABLE, EASE_TABLE_SIZE, evalEasing, type EasingSpec } from "./easing"
10
+
11
+ /** The version of the layout this reader knows (./spec.ts BLOB_VERSION). */
12
+ const VERSION = 1
13
+ // The kinds whose one lane is an index into the string table (./spec.ts KIND_*).
14
+ const KIND_TRANSFORM_MATRIX = 4, KIND_DISCRETE = 8, KIND_STRING = 9
15
+
16
+ export type BlobKey = {
17
+ /** 0..1 within the track. */
18
+ t: number
19
+ easing: EasingSpec
20
+ /** Absent = the target's value when the animation first plays. */
21
+ value?: { lanes: number[] } | { str: string }
22
+ }
23
+
24
+ export type BlobTrack = {
25
+ domain: number
26
+ /** An index into the `targets` handed over next to the blob (a role, in a transition's blob). */
27
+ target: number
28
+ prop: string
29
+ kind: number
30
+ lanes: number
31
+ /** The unit of a FLOAT_UNIT, the function list of a TRANSFORM. */
32
+ sig?: string
33
+ atMs: number
34
+ durMs: number
35
+ commit: boolean
36
+ keys: BlobKey[]
37
+ }
38
+
39
+ export type ReadBlob = {
40
+ clock: number
41
+ /** One iteration, ms. */
42
+ durationMs: number
43
+ delayMs: number
44
+ /** -1 = forever. */
45
+ iterations: number
46
+ pingPong: boolean
47
+ rate: number
48
+ /** Time-callbacks, ms; the index is what the host reports. */
49
+ calls: number[]
50
+ tracks: BlobTrack[]
51
+ }
52
+
53
+ /** The blob as tracks, or null for data that is not a blob of this version (or is cut short). */
54
+ export const readBlob = (data: ArrayLike<number>, strings: readonly string[]): ReadBlob | null => {
55
+ if (data.length < 10 || data[0] !== VERSION) return null
56
+ const nTracks = data[7]!, nCalls = data[8]!, nTables = data[9]!
57
+ let at = 10
58
+ if (at + nCalls + nTables * EASE_TABLE_SIZE > data.length) return null
59
+ const calls: number[] = []
60
+ for (let i = 0; i < nCalls; i++) calls.push(data[at++]!)
61
+ const tables: Float32Array[] = []
62
+ for (let i = 0; i < nTables; i++) {
63
+ const table = new Float32Array(EASE_TABLE_SIZE)
64
+ for (let k = 0; k < EASE_TABLE_SIZE; k++) table[k] = data[at++]!
65
+ tables.push(table)
66
+ }
67
+ const str = (index: number): string | undefined => (index >= 0 ? strings[index] : undefined)
68
+
69
+ const tracks: BlobTrack[] = []
70
+ for (let t = 0; t < nTracks; t++) {
71
+ if (at + 10 > data.length) return null
72
+ const kind = data[at + 3]!, lanes = data[at + 4]!, nKeys = data[at + 8]!
73
+ const track: BlobTrack = {
74
+ domain: data[at]!, target: data[at + 1]!, prop: str(data[at + 2]!) ?? "", kind, lanes,
75
+ sig: str(data[at + 9]!), atMs: data[at + 5]!, durMs: data[at + 6]!, commit: data[at + 7]! !== 0, keys: [],
76
+ }
77
+ at += 10
78
+ if (at + nKeys * (7 + lanes) > data.length) return null
79
+ const stringLane = kind === KIND_TRANSFORM_MATRIX || kind === KIND_DISCRETE || kind === KIND_STRING
80
+ for (let k = 0; k < nKeys; k++) {
81
+ const easeKind = data[at + 1]!
82
+ const p: [number, number, number, number] = [data[at + 2]!, data[at + 3]!, data[at + 4]!, data[at + 5]!]
83
+ const easing: EasingSpec = easeKind === EASE_TABLE ? { kind: easeKind, p, table: tables[p[0]] } : { kind: easeKind, p }
84
+ const key: BlobKey = { t: data[at]!, easing }
85
+ if (data[at + 6]! !== 0) {
86
+ if (stringLane) key.value = { str: str(data[at + 7]!) ?? "" }
87
+ else {
88
+ const values: number[] = []
89
+ for (let l = 0; l < lanes; l++) values.push(data[at + 7 + l]!)
90
+ key.value = { lanes: values }
91
+ }
92
+ }
93
+ at += 7 + lanes
94
+ track.keys.push(key)
95
+ }
96
+ tracks.push(track)
97
+ }
98
+ if (at !== data.length) return null
99
+ return { clock: data[1]!, durationMs: data[2]!, delayMs: data[3]!, iterations: data[4]!, pingPong: data[5]! !== 0, rate: data[6]!, calls, tracks }
100
+ }
101
+
102
+ // ---- a track's value at a time -------------------------------------------------------------------
103
+
104
+ const KIND_QUAT = 6, KIND_MAT4 = 11
105
+
106
+ const slerp = (out: number[], o: number, a: number[], b: number[], s: number): void => {
107
+ let bx = b[o] ?? 0, by = b[o + 1] ?? 0, bz = b[o + 2] ?? 0, bw = b[o + 3] ?? 1
108
+ const ax = a[o] ?? 0, ay = a[o + 1] ?? 0, az = a[o + 2] ?? 0, aw = a[o + 3] ?? 1
109
+ let cosom = ax * bx + ay * by + az * bz + aw * bw
110
+ if (cosom < 0) { cosom = -cosom; bx = -bx; by = -by; bz = -bz; bw = -bw }
111
+ let k0 = 1 - s, k1 = s
112
+ if (1 - cosom > 1e-6) {
113
+ const omega = Math.acos(Math.min(1, cosom)), sinom = Math.sin(omega)
114
+ k0 = Math.sin((1 - s) * omega) / sinom
115
+ k1 = Math.sin(s * omega) / sinom
116
+ }
117
+ out[o] = k0 * ax + k1 * bx; out[o + 1] = k0 * ay + k1 * by; out[o + 2] = k0 * az + k1 * bz; out[o + 3] = k0 * aw + k1 * bw
118
+ }
119
+
120
+ /** The two keys around the local time `ms` of a track and how far between them the track is,
121
+ * eased: the first key holds before the track, the last one after it. */
122
+ export const segmentAt = (track: BlobTrack, ms: number): { from: BlobKey, to: BlobKey, s: number } | null => {
123
+ const keys = track.keys
124
+ if (keys.length === 0) return null
125
+ const u = track.durMs <= 0 ? (ms >= track.atMs ? 1 : 0) : Math.min(1, Math.max(0, (ms - track.atMs) / track.durMs))
126
+ const first = keys[0]!, last = keys[keys.length - 1]!
127
+ if (keys.length === 1 || u <= first.t) return { from: first, to: first, s: 0 }
128
+ if (u >= last.t) return { from: last, to: last, s: 0 }
129
+ let i = 0
130
+ while (i + 1 < keys.length && keys[i + 1]!.t <= u) i++
131
+ const from = keys[i]!, to = keys[i + 1]!
132
+ const span = to.t - from.t
133
+ return { from, to, s: evalEasing(from.easing, span > 0 ? (u - from.t) / span : 1) }
134
+ }
135
+
136
+ /** The lanes of a numeric track at the local time `ms`, into `out` (a key with no value reads as zeros). */
137
+ export const sampleTrack = (track: BlobTrack, ms: number, out: number[]): void => {
138
+ const at = segmentAt(track, ms)
139
+ if (!at) return
140
+ const lanesOf = (key: BlobKey): number[] => (key.value && "lanes" in key.value ? key.value.lanes : [])
141
+ const a = lanesOf(at.from), b = lanesOf(at.to), s = at.s
142
+ const lerp = (from: number, to: number) => { for (let k = from; k < to; k++) out[k] = (a[k] ?? 0) + ((b[k] ?? 0) - (a[k] ?? 0)) * s }
143
+ if (track.kind === KIND_QUAT) { slerp(out, 0, a, b, s); return }
144
+ if (track.kind === KIND_MAT4) { lerp(0, 3); slerp(out, 3, a, b, s); lerp(7, 10); return }
145
+ lerp(0, track.lanes)
146
+ }
@@ -95,6 +95,10 @@ declare global {
95
95
  routerPop(index: i32, transition: i32): void
96
96
  routerHide(transition: i32): void
97
97
  routerRestore(transition: i32): void
98
+ // The ADDRESS of a destination (`UIScreen.url`): a route (`/product/42`) a host with an address
99
+ // bar shows while the destination is the one on screen; null takes it away. OPTIONAL: a host
100
+ // without an address bar has no such method (the SDK feature-detects) — Web Lite has it.
101
+ setDestUrl?(kind: u8, id: f64, url: string | null): void
98
102
  // Does the host registerView(name)? / the contract version its half was generated from (0 = no
99
103
  // such view; on a host that does not say, 1 for a view it has) / the NativeView method channel:
100
104
  // `args` is the array of the call's arguments, the callbacks settle exactly once through the
@@ -1,4 +1,4 @@
1
- // GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/cssColor.ts @ ae6bc30b952b — do not edit here.
1
+ // GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/cssColor.ts @ 72e545dab1f5 — do not edit here.
2
2
  // Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
3
3
  // The CSS color parser — the TypeScript twin of core/include/anycanvas/css_color.h: the same grammar,
4
4
  // the same name table, float32 emulated op for op (Math.fround, in the header's order), so both give
@@ -1,4 +1,4 @@
1
- // GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/recorder.ts @ ae6bc30b952b — do not edit here.
1
+ // GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/recorder.ts @ 72e545dab1f5 — do not edit here.
2
2
  // Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
3
3
  // The TypeScript recorder: a Canvas2D-shaped API whose calls WRITE the opcode stream of spec/ops.h.
4
4
  // Nothing is drawn here — the stream goes to the core interpreter (native, or wasm on the web), which
@@ -1,4 +1,4 @@
1
- // GENERATED by scripts/vendor-canvas.ts from engines/canvas/spec/gen/spec.ts @ ae6bc30b952b — do not edit here.
1
+ // GENERATED by scripts/vendor-canvas.ts from engines/canvas/spec/gen/spec.ts @ 72e545dab1f5 — do not edit here.
2
2
  // Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
3
3
  // GENERATED by spec/generate.ts from spec/*.h — do not edit.
4
4
 
package/src/chisel.ts CHANGED
@@ -128,7 +128,7 @@ export const chiselVocab: ChiselVocab = {
128
128
  "onScroll", "onScrollRelease", "onOverscroll", "onRefresh", "onSubmit",
129
129
  "onSelect", "onChange", "onFocus", "onBlur", "onOverlayTap", "onDetent",
130
130
  "onEndReached", "onStartReached",
131
- "append", "insert", "remove", "setContent", "keepAlive",
131
+ "append", "insert", "remove", "setContent", "keepAlive", "url",
132
132
  "attachTo", "detents", "dismissible", "transition", "setSourceRect",
133
133
  "push", "pop", "popToRoot", "replace", "select",
134
134
  // UITabs / UIVirtualizedList / the native views (NativeView, CameraView, MapView, QRScanner)
package/src/inject.ts CHANGED
@@ -39,7 +39,7 @@ export { device, type HapticStyle, type MotionOptions } from "./runtime/device"
39
39
  export { Input, InputChannel, type InputKeyEvent, type InputGamepadEvent, type InputEventName, type GamepadState, type GamepadAxisName } from "./runtime/input"
40
40
  export { WebSocket } from "./runtime/net"
41
41
  // Multiplayer (docs/multiplayer-plan.md): roles, players, JSON messages over the _creatorNet transport.
42
- export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform } from "./net"
42
+ export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform, type NetCorrection } from "./net"
43
43
  // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
44
44
  // only ever imports its server functions (docs/backend-plan.md §4).
45
45
  export { __rpc, __channel, __serverOnly, RpcError, type ClientChannel } from "./runtime/rpc"
package/src/net/codec.ts CHANGED
@@ -6,23 +6,31 @@
6
6
  // Nothing else: no strings, no nesting, no optionals — snapshot fields are flat and fixed-size by
7
7
  // design. Both sides run the same bundle, so the layout is derived, never transmitted; the `sig`
8
8
  // feeds the schema hash the handshake compares.
9
+ //
10
+ // A numeric field is INTERPOLATED between two snapshots unless the kind names it `discrete` (an
11
+ // index, a count, an id): a discrete field takes the later snapshot's value whole, as a boolean does.
12
+ // That is a rule of the reader — the wire is the same f32.
9
13
 
10
14
  export type FieldKind = 0 | 1 | 2 // f32 · bool · f32[]
11
- export type Field = { key: string; kind: FieldKind; len: number }
15
+ export type Field = { key: string; kind: FieldKind; len: number; step: boolean }
12
16
  export type Layout = { fields: Field[]; floats: number; bools: number; bytes: number; sig: string }
13
17
 
14
18
  const describe = (v: unknown): string => Array.isArray(v) ? `array of ${v.length}` : v === null ? "null" : typeof v
15
19
 
16
- export const makeLayout = (defaults: Record<string, unknown>, what: string): Layout => {
20
+ export const makeLayout = (defaults: Record<string, unknown>, what: string, discrete: readonly string[] = []): Layout => {
17
21
  const fields: Field[] = []
18
22
  let floats = 0
19
23
  let bools = 0
24
+ for (const key of discrete) {
25
+ if (!(key in defaults)) throw new Error(`${what}: discrete field '${key}' is not in the defaults`)
26
+ }
20
27
  for (const key of Object.keys(defaults)) {
21
28
  const v = defaults[key]
22
- if (typeof v === "number") { fields.push({ key, kind: 0, len: 1 }); floats++ }
23
- else if (typeof v === "boolean") { fields.push({ key, kind: 1, len: 1 }); bools++ }
29
+ const step = discrete.includes(key)
30
+ if (typeof v === "number") { fields.push({ key, kind: 0, len: 1, step }); floats++ }
31
+ else if (typeof v === "boolean") { fields.push({ key, kind: 1, len: 1, step: true }); bools++ }
24
32
  else if (Array.isArray(v) && v.length > 0 && v.length <= 64 && v.every((n) => typeof n === "number")) {
25
- fields.push({ key, kind: 2, len: v.length }); floats += v.length
33
+ fields.push({ key, kind: 2, len: v.length, step }); floats += v.length
26
34
  } else {
27
35
  throw new Error(`${what}: field '${key}' must be a number, a boolean or a fixed-length number[] (got ${describe(v)})`)
28
36
  }
@@ -97,14 +105,15 @@ export const unpackFrom = (view: DataView, offset: number, layout: Layout, into:
97
105
  return o
98
106
  }
99
107
 
100
- /** `out = a + (b − a) · t` for the numeric fields; booleans take `b`'s. */
108
+ /** `out = a + (b − a) · t` for the numeric fields; booleans and the discrete ones take `b`'s. */
101
109
  export const lerpState = (layout: Layout, a: any, b: any, t: number, out: any): void => {
102
110
  for (const f of layout.fields) {
103
- if (f.kind === 0) out[f.key] = a[f.key] + (b[f.key] - a[f.key]) * t
104
- else if (f.kind === 2) {
111
+ if (f.kind === 2) {
105
112
  const pa = a[f.key] as number[], pb = b[f.key] as number[], po = out[f.key] as number[]
106
- for (let i = 0; i < f.len; i++) po[i] = pa[i] + (pb[i] - pa[i]) * t
107
- } else out[f.key] = b[f.key]
113
+ const k = f.step ? 1 : t
114
+ for (let i = 0; i < f.len; i++) po[i] = pa[i] + (pb[i] - pa[i]) * k
115
+ } else if (f.step) out[f.key] = b[f.key]
116
+ else out[f.key] = a[f.key] + (b[f.key] - a[f.key]) * t
108
117
  }
109
118
  }
110
119
 
package/src/net/core.ts CHANGED
@@ -48,7 +48,7 @@ export type NetStats = { rtt: number; loss: number; sentKbps: number; receivedKb
48
48
 
49
49
  export const CH_RELIABLE = 0
50
50
  export const CH_UNRELIABLE = 1
51
- export const PROTO = 2
51
+ export const PROTO = 3
52
52
  export const LOCAL_SLOT = -1
53
53
 
54
54
  // Record kinds from creator-net (creator-net.h CNET_RECORD_*).
@@ -67,6 +67,9 @@ export class NetPlayer {
67
67
  /** A local bag for game facts (score, team, ready). NOT replicated — anything a client must see
68
68
  * goes through a message or replicated state. */
69
69
  data: any = {}
70
+ /** Server: what this player's `Net.connect(…, { hello })` carried (the game's own: a build, a
71
+ * token) — the value `Net.listen({ accept })` was asked about. Undefined everywhere else. */
72
+ hello: unknown = undefined
70
73
  /** @internal transport slot on the server; LOCAL_SLOT for the in-process player */
71
74
  _slot: number
72
75
  /** @internal server side: this player's input ring (replication.ts) */
@@ -129,6 +132,10 @@ export const state = {
129
132
  connectResolve: null as null | (() => void),
130
133
  connectReject: null as null | ((e: Error) => void),
131
134
  myName: "Player",
135
+ /** client: the game's own part of the `$hello` (`Net.connect(…, { hello })`), opaque to the SDK */
136
+ hello: undefined as unknown,
137
+ /** server: the game's say in the handshake (`Net.listen({ accept })`) — a reason refuses */
138
+ accept: null as null | ((hello: unknown, who: { name: string }) => string | null | void),
132
139
  }
133
140
 
134
141
  // Replication's seams into the roster / pump (set by replication.ts when a session starts — from a
@@ -255,6 +262,8 @@ export const resetToOffline = (): void => {
255
262
  state.nextId = 1
256
263
  state.connectResolve = null
257
264
  state.connectReject = null
265
+ state.hello = undefined
266
+ state.accept = null
258
267
  }
259
268
 
260
269
  // ---- server side ------------------------------------------------------------------------------------
@@ -283,13 +292,17 @@ const onServerRecord = (kind: number, slot: number, arg: number, payload: unknow
283
292
  const [name, data] = parsed
284
293
  if (name === "$hello") {
285
294
  if (playerBySlot(slot)) return
286
- const reason = !data || data.proto !== PROTO ? "version" : hooks.helloCheck?.(data) ?? null
295
+ const name = String(data?.name ?? `Player ${state.nextId}`)
296
+ // the SDK's own checks first, then the game's say (it never sees a client of another protocol)
297
+ const reason = !data || data.proto !== PROTO ? "version"
298
+ : hooks.helloCheck?.(data) ?? state.accept?.(data.hello, { name }) ?? null
287
299
  if (reason) {
288
- sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason }]))
300
+ sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason: String(reason) }]))
289
301
  b.kick(slot)
290
302
  return
291
303
  }
292
- const p = addPlayer(state.nextId++, String(data.name ?? `Player ${state.nextId - 1}`), slot)
304
+ const p = addPlayer(state.nextId++, name, slot)
305
+ p.hello = data.hello
293
306
  sendRaw(slot, CH_RELIABLE, JSON.stringify(["$welcome", {
294
307
  id: p.id,
295
308
  players: state.players.filter((q) => q !== p).map((q) => ({ id: q.id, name: q.name })),
@@ -310,7 +323,7 @@ const onServerRecord = (kind: number, slot: number, arg: number, payload: unknow
310
323
  const onClientRecord = (kind: number, slot: number, arg: number, payload: unknown): void => {
311
324
  const b = bridge()!
312
325
  if (kind === REC_CONNECTED) {
313
- sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, ...(hooks.helloExtra?.() ?? {}) }]))
326
+ sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, hello: state.hello, ...(hooks.helloExtra?.() ?? {}) }]))
314
327
  return
315
328
  }
316
329
  if (kind === REC_CONNECT_FAILED || kind === REC_DISCONNECTED) {
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