@crowdedkingdoms/crowdyjs 8.3.0 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,378 @@
1
+ /**
2
+ * ChunkStore — the SDK-managed chunk/voxel cache: bulk loading, typed
3
+ * per-voxel and per-chunk state, realtime merge of voxel notifications,
4
+ * optimistic local edits, and the deterministic-worldgen write-back pattern.
5
+ * Replaces the WorldStreamer + WorldState + codec plumbing every voxel game
6
+ * hand-writes (~860 LOC in Blocks with Friends).
7
+ */
8
+ import { generateCrowdyUuid, decodeBase64, encodeBase64 } from '../utils.js';
9
+ import { rawCodec } from './codec.js';
10
+ import { CHUNK_VOLUME, chunkDistance, chunkKey, chunksAround, fromChunkInput, toChunkInput, voxelIndex, } from './keys.js';
11
+ /**
12
+ * The SDK-managed **chunk/voxel cache** — the client-side source of truth
13
+ * for terrain:
14
+ *
15
+ * - `ensureAround(center, radius)` bulk-loads via `chunks.byDistance`
16
+ * (in-flight deduped), hydrates sparse voxel states, marks chunks the
17
+ * server never stored as `missing`, and hands them to your `onMissing`
18
+ * worldgen hook.
19
+ * - Realtime `voxelUpdate` notifications merge into the cache automatically
20
+ * (dense grid write + typed state decode + revision bump + change event).
21
+ * - `setVoxel` applies locally (optimistic) and replicates via the UDP path.
22
+ * - `seed`/`flush` implement deterministic-worldgen write-back through
23
+ * `chunks.update`, one throttled chunk at a time.
24
+ *
25
+ * All reads are synchronous; writes land on WebSocket events, so render
26
+ * loops and background tabs behave (see the module docs).
27
+ */
28
+ export class ChunkStore {
29
+ constructor(ctx, config = {}) {
30
+ this.ctx = ctx;
31
+ this.config = config;
32
+ this.chunks = new Map();
33
+ this.inFlight = new Set();
34
+ this.writeBackQueue = [];
35
+ this.changeListeners = new Set();
36
+ this.fallbackUuid = generateCrowdyUuid();
37
+ this.revisionValue = 0;
38
+ this.sequence = 0;
39
+ this.voxelStateCodec =
40
+ config.voxelStateCodec ?? rawCodec;
41
+ this.chunkStateCodec =
42
+ config.chunkStateCodec ?? rawCodec;
43
+ this.hydrateStates = config.hydrateVoxelStates ?? config.voxelStateCodec !== undefined;
44
+ this.now = config.now ?? Date.now;
45
+ // Realtime merge: live edits land in the cache as they replicate.
46
+ ctx.onDispose(ctx.on('voxelUpdate', (notification) => {
47
+ const coord = {
48
+ x: Number(notification.chunkX),
49
+ y: Number(notification.chunkY),
50
+ z: Number(notification.chunkZ),
51
+ };
52
+ const chunk = this.chunks.get(chunkKey(coord));
53
+ if (!chunk)
54
+ return; // only merge into chunks we track
55
+ this.applyVoxel(chunk, notification.voxelX, notification.voxelY, notification.voxelZ, notification.voxelType, notification.voxelState || undefined);
56
+ }));
57
+ const writeBackInterval = config.writeBackIntervalMs ?? 700;
58
+ if (writeBackInterval !== false && writeBackInterval > 0) {
59
+ ctx.onDispose(ctx.ticker.every(writeBackInterval, () => {
60
+ void this.persistNext();
61
+ }));
62
+ }
63
+ }
64
+ /** Bumped on every cache change — poll it cheaply from a render loop. */
65
+ get revision() {
66
+ return this.revisionValue;
67
+ }
68
+ /** The cached chunk at a coordinate (any load state), if tracked. */
69
+ get(coord) {
70
+ return this.chunks.get(chunkKey(coord));
71
+ }
72
+ /** Every tracked chunk (any load state). */
73
+ list() {
74
+ return [...this.chunks.values()];
75
+ }
76
+ /** The dense voxel type at a within-chunk coordinate (0 when unknown). */
77
+ voxelTypeAt(coord, x, y, z) {
78
+ const chunk = this.chunks.get(chunkKey(coord));
79
+ return chunk?.voxels?.[voxelIndex(x, y, z)] ?? 0;
80
+ }
81
+ /** The typed per-voxel state at a within-chunk coordinate, if any. */
82
+ voxelStateAt(coord, x, y, z) {
83
+ return this.chunks.get(chunkKey(coord))?.voxelStates.get(voxelIndex(x, y, z));
84
+ }
85
+ /** Subscribe to per-chunk changes (loads, merges, edits). @returns off. */
86
+ onChunkChanged(listener) {
87
+ this.changeListeners.add(listener);
88
+ return () => this.changeListeners.delete(listener);
89
+ }
90
+ /**
91
+ * Ensure every chunk within `radius` (Chebyshev, 1-8) of `center` is
92
+ * tracked: bulk-loads untracked ones, hydrates sparse voxel states when
93
+ * configured, marks server-unknown chunks `missing`, and seeds them via
94
+ * `onMissing`. In-flight requests are deduped; safe to call every time the
95
+ * player crosses a chunk boundary.
96
+ */
97
+ async ensureAround(center, radius) {
98
+ const wanted = chunksAround(center, radius).filter((coord) => {
99
+ const key = chunkKey(coord);
100
+ return !this.chunks.has(key) && !this.inFlight.has(key);
101
+ });
102
+ if (wanted.length === 0)
103
+ return;
104
+ for (const coord of wanted)
105
+ this.inFlight.add(chunkKey(coord));
106
+ try {
107
+ const response = await this.ctx.client.chunks.byDistance({
108
+ appId: this.ctx.appId,
109
+ centerCoordinate: toChunkInput(center),
110
+ maxDistance: Math.max(1, Math.min(8, radius)),
111
+ limit: (2 * radius + 1) ** 3,
112
+ });
113
+ const returned = new Set();
114
+ for (const chunk of response.chunks) {
115
+ const coord = fromChunkInput(chunk.coordinates);
116
+ returned.add(chunkKey(coord));
117
+ this.applyServerChunk(coord, chunk.voxels ?? null, chunk.chunkState ?? null);
118
+ }
119
+ // Requested-but-absent chunks have never been stored server-side.
120
+ for (const coord of wanted) {
121
+ if (returned.has(chunkKey(coord)))
122
+ continue;
123
+ this.markMissing(coord);
124
+ }
125
+ if (this.hydrateStates) {
126
+ await Promise.all(wanted
127
+ .filter((coord) => returned.has(chunkKey(coord)))
128
+ .map((coord) => this.hydrate(coord)));
129
+ }
130
+ }
131
+ catch (error) {
132
+ for (const coord of wanted) {
133
+ const key = chunkKey(coord);
134
+ if (!this.chunks.has(key)) {
135
+ const chunk = this.ensureEntry(coord);
136
+ chunk.loadState = 'failed';
137
+ this.touch(chunk);
138
+ }
139
+ }
140
+ throw error;
141
+ }
142
+ finally {
143
+ for (const coord of wanted)
144
+ this.inFlight.delete(chunkKey(coord));
145
+ }
146
+ }
147
+ /**
148
+ * Hydrate one chunk's sparse voxel states (and chunk state) via a
149
+ * single-chunk fetch — bulk loads omit them.
150
+ */
151
+ async hydrate(coord) {
152
+ const full = await this.ctx.client.chunks.get({
153
+ appId: this.ctx.appId,
154
+ coordinates: toChunkInput(coord),
155
+ });
156
+ if (!full) {
157
+ this.markMissing(coord);
158
+ return;
159
+ }
160
+ const chunk = this.ensureEntry(coord);
161
+ if (full.voxels != null)
162
+ chunk.voxels = decodeBase64(full.voxels);
163
+ chunk.chunkState = this.decodeChunkState(full.chunkState ?? null);
164
+ for (const entry of full.voxelStates ?? []) {
165
+ const index = voxelIndex(entry.voxelCoord.x, entry.voxelCoord.y, entry.voxelCoord.z);
166
+ if (chunk.voxels)
167
+ chunk.voxels[index] = entry.voxelType;
168
+ if (entry.state) {
169
+ try {
170
+ chunk.voxelStates.set(index, this.voxelStateCodec.decode(entry.state));
171
+ }
172
+ catch {
173
+ // Foreign/legacy blobs skip silently; the dense type still applied.
174
+ }
175
+ }
176
+ }
177
+ chunk.loadState = 'loaded';
178
+ chunk.hydrated = true;
179
+ this.touch(chunk);
180
+ }
181
+ /**
182
+ * Edit one voxel: applies to the cache immediately (optimistic) and
183
+ * replicates via the realtime voxel path. Resolves with the send
184
+ * acceptance.
185
+ */
186
+ async setVoxel(input) {
187
+ const chunk = this.ensureEntry(input.chunk);
188
+ if (input.optimistic ?? true) {
189
+ this.applyVoxel(chunk, input.x, input.y, input.z, input.voxelType, undefined, input.state);
190
+ }
191
+ const sequenceNumber = this.nextSequence();
192
+ this.ctx.trackSend({
193
+ kind: 'voxelUpdate',
194
+ sequenceNumber,
195
+ sentAt: this.now(),
196
+ uuid: this.senderUuid(),
197
+ detail: { chunk: input.chunk, x: input.x, y: input.y, z: input.z },
198
+ });
199
+ return this.ctx.client.udp.sendVoxelUpdate({
200
+ appId: this.ctx.appId,
201
+ chunk: toChunkInput(input.chunk),
202
+ uuid: this.senderUuid(),
203
+ voxel: { x: input.x, y: input.y, z: input.z },
204
+ voxelType: input.voxelType,
205
+ voxelState: input.state !== undefined ? this.voxelStateCodec.encode(input.state) : '',
206
+ sequenceNumber,
207
+ ...(this.config.distance !== undefined ? { distance: this.config.distance } : {}),
208
+ ...(this.config.decayRate !== undefined
209
+ ? { decayRate: this.config.decayRate }
210
+ : {}),
211
+ });
212
+ }
213
+ /**
214
+ * Seed a locally generated chunk (deterministic worldgen) and queue it for
215
+ * write-back so the server copy exists for everyone.
216
+ */
217
+ seed(coord, voxels, options = {}) {
218
+ if (voxels.length !== CHUNK_VOLUME) {
219
+ throw new Error(`seed() needs a ${CHUNK_VOLUME}-byte dense grid, got ${voxels.length}`);
220
+ }
221
+ const chunk = this.ensureEntry(coord);
222
+ chunk.voxels = voxels;
223
+ chunk.loadState = 'seeded';
224
+ if (options.writeBack ?? true)
225
+ this.markDirty(coord);
226
+ this.touch(chunk);
227
+ }
228
+ /** Queue a tracked chunk's dense grid for (throttled) write-back. */
229
+ markDirty(coord) {
230
+ const key = chunkKey(coord);
231
+ const chunk = this.chunks.get(key);
232
+ if (!chunk)
233
+ return;
234
+ chunk.dirty = true;
235
+ if (!this.writeBackQueue.includes(key))
236
+ this.writeBackQueue.push(key);
237
+ }
238
+ /** Chunks currently queued for write-back. */
239
+ get pendingWriteBacks() {
240
+ return this.writeBackQueue.length;
241
+ }
242
+ /** Persist every queued chunk now (awaits all writes). */
243
+ async flush() {
244
+ while (this.writeBackQueue.length > 0) {
245
+ await this.persistNext();
246
+ }
247
+ }
248
+ /** Drop tracked chunks farther than `radius` from `center` (dirty ones kept). */
249
+ pruneBeyond(center, radius) {
250
+ for (const [key, chunk] of this.chunks) {
251
+ if (chunk.dirty)
252
+ continue;
253
+ if (chunkDistance(chunk.coord, center) > radius) {
254
+ this.chunks.delete(key);
255
+ this.revisionValue += 1;
256
+ }
257
+ }
258
+ }
259
+ // -- internals --------------------------------------------------------------
260
+ async persistNext() {
261
+ const key = this.writeBackQueue.shift();
262
+ if (!key)
263
+ return;
264
+ const chunk = this.chunks.get(key);
265
+ if (!chunk || !chunk.voxels)
266
+ return;
267
+ try {
268
+ await this.ctx.client.chunks.update({
269
+ appId: this.ctx.appId,
270
+ coordinates: toChunkInput(chunk.coord),
271
+ voxels: encodeBase64(chunk.voxels),
272
+ });
273
+ chunk.dirty = false;
274
+ if (chunk.loadState === 'seeded')
275
+ chunk.loadState = 'loaded';
276
+ this.touch(chunk);
277
+ }
278
+ catch {
279
+ // Requeue at the back; the next tick retries.
280
+ chunk.dirty = true;
281
+ this.writeBackQueue.push(key);
282
+ }
283
+ }
284
+ applyServerChunk(coord, voxels, chunkState) {
285
+ const chunk = this.ensureEntry(coord);
286
+ if (voxels != null)
287
+ chunk.voxels = decodeBase64(voxels);
288
+ chunk.chunkState = this.decodeChunkState(chunkState);
289
+ chunk.loadState = 'loaded';
290
+ this.touch(chunk);
291
+ }
292
+ markMissing(coord) {
293
+ const chunk = this.ensureEntry(coord);
294
+ if (chunk.loadState === 'loaded' || chunk.loadState === 'seeded')
295
+ return;
296
+ chunk.loadState = 'missing';
297
+ this.touch(chunk);
298
+ const generated = this.config.onMissing?.(coord);
299
+ if (generated)
300
+ this.seed(coord, generated);
301
+ }
302
+ applyVoxel(chunk, x, y, z, voxelType, encodedState, decodedState) {
303
+ if (!chunk.voxels)
304
+ chunk.voxels = new Uint8Array(CHUNK_VOLUME);
305
+ const index = voxelIndex(x, y, z);
306
+ chunk.voxels[index] = voxelType;
307
+ let state = decodedState;
308
+ if (state === undefined && encodedState) {
309
+ try {
310
+ state = this.voxelStateCodec.decode(encodedState);
311
+ }
312
+ catch {
313
+ state = undefined;
314
+ }
315
+ }
316
+ if (state !== undefined) {
317
+ chunk.voxelStates.set(index, state);
318
+ }
319
+ else {
320
+ chunk.voxelStates.delete(index);
321
+ }
322
+ this.touch(chunk);
323
+ }
324
+ ensureEntry(coord) {
325
+ const key = chunkKey(coord);
326
+ let chunk = this.chunks.get(key);
327
+ if (!chunk) {
328
+ chunk = {
329
+ key,
330
+ coord,
331
+ voxels: null,
332
+ voxelStates: new Map(),
333
+ chunkState: null,
334
+ loadState: 'loading',
335
+ revision: 0,
336
+ updatedAt: this.now(),
337
+ hydrated: false,
338
+ dirty: false,
339
+ };
340
+ this.chunks.set(key, chunk);
341
+ }
342
+ return chunk;
343
+ }
344
+ decodeChunkState(encoded) {
345
+ if (encoded == null || encoded === '')
346
+ return null;
347
+ try {
348
+ return this.chunkStateCodec.decode(encoded);
349
+ }
350
+ catch {
351
+ return null;
352
+ }
353
+ }
354
+ touch(chunk) {
355
+ chunk.revision += 1;
356
+ chunk.updatedAt = this.now();
357
+ this.revisionValue += 1;
358
+ for (const listener of [...this.changeListeners])
359
+ listener(chunk);
360
+ }
361
+ senderUuid() {
362
+ const configured = typeof this.config.actorUuid === 'function'
363
+ ? this.config.actorUuid()
364
+ : this.config.actorUuid;
365
+ return configured ?? this.fallbackUuid;
366
+ }
367
+ nextSequence() {
368
+ this.sequence = (this.sequence + 1) % 256;
369
+ return this.sequence;
370
+ }
371
+ }
372
+ /**
373
+ * Attach a {@link ChunkStore} to a world session context. Prefer the
374
+ * `chunks` key of `createWorldSession`'s config.
375
+ */
376
+ export function attachChunkStore(ctx, config = {}) {
377
+ return new ChunkStore(ctx, config);
378
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Typed state codecs — the foundation of the World Stores layer.
3
+ *
4
+ * Every opaque base64 blob on the platform (actor replication `state`, voxel
5
+ * `voxelState`, chunk `chunkState`, client event `state`, channel/actor
6
+ * message `payload`, `UserAppState.state`, avatar public/private/app state)
7
+ * is app-defined. A {@link StateCodec} names that definition ONCE: the dev
8
+ * registers their custom type + encoder/decoder with a store, and the store
9
+ * speaks typed values everywhere else.
10
+ */
11
+ /**
12
+ * A two-way codec between a typed value and the platform's base64 wire form.
13
+ * Implement your own, or build one with {@link jsonCodec} (compact JSON),
14
+ * {@link rawCodec} (pass-through base64), or {@link structCodec} (fixed-layout
15
+ * binary — the right choice for high-rate replication state).
16
+ */
17
+ export interface StateCodec<T> {
18
+ /** Encode a typed value into the base64 wire form. */
19
+ encode(value: T): string;
20
+ /** Decode the base64 wire form back into the typed value. */
21
+ decode(data: string): T;
22
+ }
23
+ /**
24
+ * JSON codec: `JSON.stringify` → UTF-8 → base64. Convenient for low-rate,
25
+ * structured state (save blobs, avatar profiles, channel payloads). Do NOT
26
+ * use it for per-tick actor replication — spatial packets have a ~1.1 KB
27
+ * budget and JSON wastes most of it; use {@link structCodec} there.
28
+ */
29
+ export declare function jsonCodec<T>(): StateCodec<T>;
30
+ /**
31
+ * Identity codec: the typed value IS the base64 string. Use it when the app
32
+ * already has its own encoding pipeline and just wants the stores' lifecycle
33
+ * management.
34
+ */
35
+ export declare const rawCodec: StateCodec<string>;
36
+ /**
37
+ * UTF-8 text codec: plain strings ↔ base64 (chat payloads, simple messages).
38
+ */
39
+ export declare const textCodec: StateCodec<string>;
40
+ /**
41
+ * One field of a {@link structCodec} layout. Build fields with the factory
42
+ * helpers ({@link f32}, {@link u8}, …) rather than by hand.
43
+ */
44
+ export interface StructField<V> {
45
+ /** Bytes this field occupies. */
46
+ size: number;
47
+ read(view: DataView, offset: number, littleEndian: boolean): V;
48
+ write(view: DataView, offset: number, value: V, littleEndian: boolean): void;
49
+ /** True for {@link reserved} padding — excluded from the value type. */
50
+ skip?: boolean;
51
+ }
52
+ /** A struct layout: ordered named fields (insertion order = byte order). */
53
+ export type StructSpec = Record<string, StructField<unknown>>;
54
+ /** The typed value a {@link StructSpec} encodes (reserved fields omitted). */
55
+ export type StructValue<S extends StructSpec> = {
56
+ [K in keyof S as S[K]['skip'] extends true ? never : K]: S[K] extends StructField<infer V> ? V : never;
57
+ };
58
+ /** 32-bit float field. */
59
+ export declare function f32(): StructField<number>;
60
+ /** 64-bit float field (e.g. epoch-milliseconds timestamps). */
61
+ export declare function f64(): StructField<number>;
62
+ /** Unsigned 8-bit int field (flags, small ids). */
63
+ export declare function u8(): StructField<number>;
64
+ /** Unsigned 16-bit int field. */
65
+ export declare function u16(): StructField<number>;
66
+ /** Unsigned 32-bit int field. */
67
+ export declare function u32(): StructField<number>;
68
+ /** Signed 8-bit int field. */
69
+ export declare function i8(): StructField<number>;
70
+ /** Signed 16-bit int field. */
71
+ export declare function i16(): StructField<number>;
72
+ /** Signed 32-bit int field. */
73
+ export declare function i32(): StructField<number>;
74
+ /** Boolean stored as one byte (0/1). */
75
+ export declare function bool8(): StructField<boolean>;
76
+ /** Fixed-length raw bytes field. */
77
+ export declare function bytes(length: number): StructField<Uint8Array>;
78
+ /** Reserved padding bytes — occupies layout space, absent from the value type. */
79
+ export declare function reserved(length: number): StructField<undefined> & {
80
+ skip: true;
81
+ };
82
+ /** A {@link StateCodec} produced by {@link structCodec}, exposing its byte size. */
83
+ export interface StructCodec<T> extends StateCodec<T> {
84
+ /** The fixed encoded size in bytes (before base64). */
85
+ readonly byteLength: number;
86
+ }
87
+ /**
88
+ * Build a fixed-layout binary codec from a declarative field spec — the
89
+ * replication-state workhorse. Fields are laid out in declaration order,
90
+ * little-endian by default (matching the platform's wire conventions).
91
+ *
92
+ * The Blocks-with-Friends 48-byte pose, declaratively:
93
+ *
94
+ * ```ts
95
+ * const poseCodec = structCodec({
96
+ * x: f32(), y: f32(), z: f32(),
97
+ * yaw: f32(), pitch: f32(),
98
+ * vx: f32(), vy: f32(), vz: f32(),
99
+ * flags: u8(), heldBlockId: u8(), _r0: reserved(2),
100
+ * updatedAt: f64(), _r1: reserved(4),
101
+ * }); // poseCodec.byteLength === 48
102
+ * ```
103
+ *
104
+ * @throws {Error} at decode time when the payload is shorter than the layout.
105
+ */
106
+ export declare function structCodec<S extends StructSpec>(spec: S, options?: {
107
+ littleEndian?: boolean;
108
+ }): StructCodec<StructValue<S>>;
109
+ //# sourceMappingURL=codec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codec.d.ts","sourceRoot":"","sources":["../../src/stores/codec.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Typed state codecs — the foundation of the World Stores layer.
3
+ *
4
+ * Every opaque base64 blob on the platform (actor replication `state`, voxel
5
+ * `voxelState`, chunk `chunkState`, client event `state`, channel/actor
6
+ * message `payload`, `UserAppState.state`, avatar public/private/app state)
7
+ * is app-defined. A {@link StateCodec} names that definition ONCE: the dev
8
+ * registers their custom type + encoder/decoder with a store, and the store
9
+ * speaks typed values everywhere else.
10
+ */
11
+ import { decodeBase64, encodeBase64 } from '../utils.js';
12
+ /**
13
+ * JSON codec: `JSON.stringify` → UTF-8 → base64. Convenient for low-rate,
14
+ * structured state (save blobs, avatar profiles, channel payloads). Do NOT
15
+ * use it for per-tick actor replication — spatial packets have a ~1.1 KB
16
+ * budget and JSON wastes most of it; use {@link structCodec} there.
17
+ */
18
+ export function jsonCodec() {
19
+ return {
20
+ encode: (value) => encodeBase64(new TextEncoder().encode(JSON.stringify(value))),
21
+ decode: (data) => JSON.parse(new TextDecoder().decode(decodeBase64(data))),
22
+ };
23
+ }
24
+ /**
25
+ * Identity codec: the typed value IS the base64 string. Use it when the app
26
+ * already has its own encoding pipeline and just wants the stores' lifecycle
27
+ * management.
28
+ */
29
+ export const rawCodec = {
30
+ encode: (value) => value,
31
+ decode: (data) => data,
32
+ };
33
+ /**
34
+ * UTF-8 text codec: plain strings ↔ base64 (chat payloads, simple messages).
35
+ */
36
+ export const textCodec = {
37
+ encode: (value) => encodeBase64(new TextEncoder().encode(value)),
38
+ decode: (data) => new TextDecoder().decode(decodeBase64(data)),
39
+ };
40
+ /** 32-bit float field. */
41
+ export function f32() {
42
+ return {
43
+ size: 4,
44
+ read: (v, o, le) => v.getFloat32(o, le),
45
+ write: (v, o, value, le) => v.setFloat32(o, value, le),
46
+ };
47
+ }
48
+ /** 64-bit float field (e.g. epoch-milliseconds timestamps). */
49
+ export function f64() {
50
+ return {
51
+ size: 8,
52
+ read: (v, o, le) => v.getFloat64(o, le),
53
+ write: (v, o, value, le) => v.setFloat64(o, value, le),
54
+ };
55
+ }
56
+ /** Unsigned 8-bit int field (flags, small ids). */
57
+ export function u8() {
58
+ return {
59
+ size: 1,
60
+ read: (v, o) => v.getUint8(o),
61
+ write: (v, o, value) => v.setUint8(o, value),
62
+ };
63
+ }
64
+ /** Unsigned 16-bit int field. */
65
+ export function u16() {
66
+ return {
67
+ size: 2,
68
+ read: (v, o, le) => v.getUint16(o, le),
69
+ write: (v, o, value, le) => v.setUint16(o, value, le),
70
+ };
71
+ }
72
+ /** Unsigned 32-bit int field. */
73
+ export function u32() {
74
+ return {
75
+ size: 4,
76
+ read: (v, o, le) => v.getUint32(o, le),
77
+ write: (v, o, value, le) => v.setUint32(o, value, le),
78
+ };
79
+ }
80
+ /** Signed 8-bit int field. */
81
+ export function i8() {
82
+ return {
83
+ size: 1,
84
+ read: (v, o) => v.getInt8(o),
85
+ write: (v, o, value) => v.setInt8(o, value),
86
+ };
87
+ }
88
+ /** Signed 16-bit int field. */
89
+ export function i16() {
90
+ return {
91
+ size: 2,
92
+ read: (v, o, le) => v.getInt16(o, le),
93
+ write: (v, o, value, le) => v.setInt16(o, value, le),
94
+ };
95
+ }
96
+ /** Signed 32-bit int field. */
97
+ export function i32() {
98
+ return {
99
+ size: 4,
100
+ read: (v, o, le) => v.getInt32(o, le),
101
+ write: (v, o, value, le) => v.setInt32(o, value, le),
102
+ };
103
+ }
104
+ /** Boolean stored as one byte (0/1). */
105
+ export function bool8() {
106
+ return {
107
+ size: 1,
108
+ read: (v, o) => v.getUint8(o) !== 0,
109
+ write: (v, o, value) => v.setUint8(o, value ? 1 : 0),
110
+ };
111
+ }
112
+ /** Fixed-length raw bytes field. */
113
+ export function bytes(length) {
114
+ return {
115
+ size: length,
116
+ read: (v, o) => new Uint8Array(v.buffer, v.byteOffset + o, length).slice(),
117
+ write: (v, o, value) => {
118
+ const target = new Uint8Array(v.buffer, v.byteOffset + o, length);
119
+ target.set(value.subarray(0, length));
120
+ },
121
+ };
122
+ }
123
+ /** Reserved padding bytes — occupies layout space, absent from the value type. */
124
+ export function reserved(length) {
125
+ return {
126
+ size: length,
127
+ skip: true,
128
+ read: () => undefined,
129
+ write: () => { },
130
+ };
131
+ }
132
+ /**
133
+ * Build a fixed-layout binary codec from a declarative field spec — the
134
+ * replication-state workhorse. Fields are laid out in declaration order,
135
+ * little-endian by default (matching the platform's wire conventions).
136
+ *
137
+ * The Blocks-with-Friends 48-byte pose, declaratively:
138
+ *
139
+ * ```ts
140
+ * const poseCodec = structCodec({
141
+ * x: f32(), y: f32(), z: f32(),
142
+ * yaw: f32(), pitch: f32(),
143
+ * vx: f32(), vy: f32(), vz: f32(),
144
+ * flags: u8(), heldBlockId: u8(), _r0: reserved(2),
145
+ * updatedAt: f64(), _r1: reserved(4),
146
+ * }); // poseCodec.byteLength === 48
147
+ * ```
148
+ *
149
+ * @throws {Error} at decode time when the payload is shorter than the layout.
150
+ */
151
+ export function structCodec(spec, options = {}) {
152
+ const littleEndian = options.littleEndian ?? true;
153
+ const fields = Object.entries(spec);
154
+ const byteLength = fields.reduce((sum, [, f]) => sum + f.size, 0);
155
+ return {
156
+ byteLength,
157
+ encode(value) {
158
+ const buffer = new Uint8Array(byteLength);
159
+ const view = new DataView(buffer.buffer);
160
+ let offset = 0;
161
+ for (const [name, field] of fields) {
162
+ if (!field.skip) {
163
+ field.write(view, offset, value[name], littleEndian);
164
+ }
165
+ offset += field.size;
166
+ }
167
+ return encodeBase64(buffer);
168
+ },
169
+ decode(data) {
170
+ const buffer = decodeBase64(data);
171
+ if (buffer.byteLength < byteLength) {
172
+ throw new Error(`structCodec: payload is ${buffer.byteLength} bytes, layout needs ${byteLength}`);
173
+ }
174
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
175
+ const out = {};
176
+ let offset = 0;
177
+ for (const [name, field] of fields) {
178
+ if (!field.skip) {
179
+ out[name] = field.read(view, offset, littleEndian);
180
+ }
181
+ offset += field.size;
182
+ }
183
+ return out;
184
+ },
185
+ };
186
+ }