@crowdedkingdoms/construct 0.3.7 → 0.3.8

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,753 @@
1
+ /**
2
+ * Chunk-mod state the holodeck draws and the page broker answers.
3
+ * The guest never receives the session. Calls arrive already allowlisted.
4
+ */
5
+ import type { CrowdyClient } from '@crowdedkingdoms/crowdyjs';
6
+ import type { PlayerCodeGridBounds, PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
7
+
8
+ import type { Input } from '../../engine/Input';
9
+ import type { Pose } from '../realtime/actorCodec';
10
+ import { worldToChunk } from '../realtime/space';
11
+ import { poseSnapshot } from './modPose';
12
+
13
+ export interface ModAppearance {
14
+ color: number;
15
+ }
16
+
17
+ export interface ModPoseHold {
18
+ x: number;
19
+ y: number;
20
+ z: number;
21
+ yaw: number;
22
+ pitch: number;
23
+ roll: number;
24
+ vx: number;
25
+ vy: number;
26
+ vz: number;
27
+ atMs: number;
28
+ }
29
+
30
+ export interface ModProjectile {
31
+ id: number;
32
+ x: number;
33
+ y: number;
34
+ z: number;
35
+ dx: number;
36
+ dy: number;
37
+ dz: number;
38
+ speed: number;
39
+ startMs: number;
40
+ lifeMs: number;
41
+ }
42
+
43
+ export interface ModOwnedActor {
44
+ uuid: string;
45
+ x: number;
46
+ y: number;
47
+ z: number;
48
+ yaw: number;
49
+ pitch: number;
50
+ roll: number;
51
+ }
52
+
53
+ export interface ModSessionPort {
54
+ client: CrowdyClient;
55
+ appId: string;
56
+ gridId: string;
57
+ selfUuid: string;
58
+ userId: string;
59
+ moveTo(chunk: { x: number; y: number; z: number }): Promise<void>;
60
+ players(): Array<{ uuid: string; pose: { x: number; y: number; z: number } }>;
61
+ voiceStart(): Promise<void>;
62
+ voiceStop(): void;
63
+ videoStart(): Promise<void>;
64
+ videoStop(): void;
65
+ }
66
+
67
+ const inbox: unknown[] = [];
68
+ let appearance: ModAppearance | null = null;
69
+ let grid: PlayerCodeGridBounds | null = null;
70
+ let hold: ModPoseHold | null = null;
71
+ let input: Input | null = null;
72
+ let port: ModSessionPort | null = null;
73
+ let nextProjectile = 1;
74
+ const projectiles: ModProjectile[] = [];
75
+ const owned = new Map<string, ModOwnedActor>();
76
+ let avatarSnapshot: string | null = null;
77
+ let avatarId: string | null = null;
78
+ let subscribed = false;
79
+
80
+ export function bindModGrid(bounds: PlayerCodeGridBounds): void {
81
+ grid = bounds;
82
+ }
83
+
84
+ export function bindModInput(next: Input | null): void {
85
+ input = next;
86
+ }
87
+
88
+ export function bindModSession(next: ModSessionPort | null): void {
89
+ port = next;
90
+ if (next && !subscribed && typeof next.client.udp.subscribe === 'function') {
91
+ subscribed = true;
92
+ next.client.udp.subscribe(
93
+ {
94
+ clientEvent: (event) => remember(event),
95
+ text: (event) => remember(event),
96
+ actorUpdate: (event) => remember(event),
97
+ voxelUpdate: (event) => remember(event),
98
+ },
99
+ next.appId,
100
+ );
101
+ }
102
+ }
103
+
104
+ export function releaseModChunk(): void {
105
+ appearance = null;
106
+ hold = null;
107
+ projectiles.length = 0;
108
+ owned.clear();
109
+ inbox.length = 0;
110
+ void restoreAvatar();
111
+ }
112
+
113
+ export function modAppearance(): ModAppearance | null {
114
+ return appearance;
115
+ }
116
+
117
+ export function modHoldsLocomotion(): boolean {
118
+ return hold !== null;
119
+ }
120
+
121
+ export function modPoseHold(): ModPoseHold | null {
122
+ return hold;
123
+ }
124
+
125
+ export function modProjectiles(nowMs: number): ModProjectile[] {
126
+ for (let i = projectiles.length - 1; i >= 0; i--) {
127
+ const shot = projectiles[i]!;
128
+ const t = (nowMs - shot.startMs) / 1000;
129
+ if (t * shot.speed > (shot.lifeMs / 1000) * shot.speed || nowMs - shot.startMs > shot.lifeMs) {
130
+ projectiles.splice(i, 1);
131
+ }
132
+ }
133
+ return projectiles;
134
+ }
135
+
136
+ export function modOwnedActors(): ModOwnedActor[] {
137
+ return [...owned.values()];
138
+ }
139
+
140
+ export function chunkInModGrid(x: number, y: number, z: number): boolean {
141
+ if (!grid) return false;
142
+ const cx = BigInt(x);
143
+ const cy = BigInt(y);
144
+ const cz = BigInt(z);
145
+ return (
146
+ cx >= grid.low.x &&
147
+ cx <= grid.high.x &&
148
+ cy >= grid.low.y &&
149
+ cy <= grid.high.y &&
150
+ cz >= grid.low.z &&
151
+ cz <= grid.high.z
152
+ );
153
+ }
154
+
155
+ export function positionInModGrid(x: number, y: number, z: number): boolean {
156
+ const chunk = worldToChunk({ x, y, z });
157
+ return chunkInModGrid(chunk.x, chunk.y, chunk.z);
158
+ }
159
+
160
+ /** Integrate the last pose_set. The frame loop calls this while the mod holds the body. */
161
+ export function stepModPose(dt: number): ModPoseHold | null {
162
+ if (!hold) return null;
163
+ hold.x += hold.vx * dt;
164
+ hold.y += hold.vy * dt;
165
+ hold.z += hold.vz * dt;
166
+ return hold;
167
+ }
168
+
169
+ export async function routeModGameplay(call: PlayerCodeHostCall): Promise<unknown> {
170
+ const { fn, args } = call;
171
+ switch (fn) {
172
+ case 'input_axes':
173
+ return input ? input.axes() : { x: 0, y: 0 };
174
+ case 'input_look': {
175
+ const delta = input?.takePointerDelta() ?? { dx: 0, dy: 0 };
176
+ return { dx: delta.dx, dy: delta.dy };
177
+ }
178
+ case 'events_poll': {
179
+ const batch = inbox.splice(0, inbox.length);
180
+ return { events: batch };
181
+ }
182
+ case 'voice_set':
183
+ if (!port) return { ok: false };
184
+ if (stopped(args)) port.voiceStop();
185
+ else await port.voiceStart();
186
+ return { ok: true };
187
+ case 'video_set':
188
+ if (!port) return { ok: false };
189
+ if (stopped(args)) port.videoStop();
190
+ else await port.videoStart();
191
+ return { ok: true };
192
+ case 'avatar_appearance': {
193
+ const color = Number(args.color ?? 0xff0000);
194
+ appearance = { color: Number.isFinite(color) ? color : 0xff0000 };
195
+ return { ok: true };
196
+ }
197
+ case 'pose_get':
198
+ // A hold is the mod's own body. Otherwise report the walker, so the mod
199
+ // can take over from where the player is standing instead of the origin.
200
+ if (hold) {
201
+ return {
202
+ held: true,
203
+ x: hold.x,
204
+ y: hold.y,
205
+ z: hold.z,
206
+ yaw: hold.yaw,
207
+ pitch: hold.pitch,
208
+ roll: hold.roll,
209
+ vx: hold.vx,
210
+ vy: hold.vy,
211
+ vz: hold.vz,
212
+ };
213
+ }
214
+ return poseSnapshot();
215
+ case 'pose_release':
216
+ hold = null;
217
+ return { ok: true };
218
+ case 'pose_set':
219
+ return poseSet(args);
220
+ case 'actor_spawn':
221
+ case 'actor_pose':
222
+ return actorPose(args, fn === 'actor_spawn');
223
+ case 'actor_despawn':
224
+ owned.delete(String(args.uuid ?? ''));
225
+ return { ok: true };
226
+ case 'send_client_event':
227
+ return sendClientEvent(args);
228
+ case 'send_text':
229
+ return sendText(args);
230
+ case 'send_actor_message':
231
+ return sendActorMessage(args);
232
+ case 'send_channel_message':
233
+ return sendChannelMessage(args);
234
+ case 'teleport_request':
235
+ return teleport(args);
236
+ case 'avatar_state_set':
237
+ return avatarStateSet(args);
238
+ default:
239
+ return passSdk(fn, args);
240
+ }
241
+ }
242
+
243
+ function poseSet(args: Record<string, unknown>): { ok: boolean; error?: string } {
244
+ const x = num(args.x);
245
+ const y = num(args.y);
246
+ const z = num(args.z);
247
+ if (!positionInModGrid(x, y, z)) return { ok: false, error: 'outside the player grid' };
248
+ const chunk = worldToChunk({ x, y, z });
249
+ const previous = hold ? worldToChunk({ x: hold.x, y: hold.y, z: hold.z }) : null;
250
+ hold = {
251
+ x,
252
+ y,
253
+ z,
254
+ yaw: num(args.yaw),
255
+ pitch: num(args.pitch),
256
+ roll: num(args.roll),
257
+ vx: num(args.vx),
258
+ vy: num(args.vy),
259
+ vz: num(args.vz),
260
+ atMs: performance.now(),
261
+ };
262
+ if (!previous || previous.x !== chunk.x || previous.y !== chunk.y || previous.z !== chunk.z) {
263
+ void port?.moveTo(chunk);
264
+ }
265
+ return { ok: true };
266
+ }
267
+
268
+ async function actorPose(args: Record<string, unknown>, spawn: boolean): Promise<unknown> {
269
+ const uuid = String(args.uuid ?? args.actorId ?? '');
270
+ if (!uuid || (port && uuid === port.selfUuid)) {
271
+ return { ok: false, error: 'refusing a live player uuid' };
272
+ }
273
+ const pose = record(args.pose) ?? args;
274
+ const x = num(pose.x);
275
+ const y = num(pose.y);
276
+ const z = num(pose.z);
277
+ if (
278
+ !positionInModGrid(x, y, z) &&
279
+ !chunkInModGrid(num(args.chunkX), num(args.chunkY), num(args.chunkZ))
280
+ ) {
281
+ return { ok: false, error: 'outside the player grid' };
282
+ }
283
+ const actor: ModOwnedActor = {
284
+ uuid,
285
+ x,
286
+ y,
287
+ z,
288
+ yaw: num(pose.yaw),
289
+ pitch: num(pose.pitch),
290
+ roll: num(pose.roll),
291
+ };
292
+ owned.set(uuid, actor);
293
+ if (port) {
294
+ const chunk = worldToChunk({ x, y, z });
295
+ await port.client.udp.sendActorUpdate({
296
+ appId: port.appId,
297
+ chunk: { x: String(chunk.x), y: String(chunk.y), z: String(chunk.z) },
298
+ uuid,
299
+ state: '',
300
+ distance: 8,
301
+ });
302
+ }
303
+ return { ok: true, spawned: spawn, uuid };
304
+ }
305
+
306
+ async function sendClientEvent(args: Record<string, unknown>): Promise<unknown> {
307
+ noteProjectile(args);
308
+ if (!port) return { ok: false };
309
+ await port.client.udp.sendClientEvent({
310
+ appId: port.appId,
311
+ chunk: chunkCoord(args),
312
+ uuid: port.selfUuid,
313
+ eventType: Number(args.eventType ?? 1),
314
+ state: encodeState(args),
315
+ distance: 8,
316
+ });
317
+ return { ok: true };
318
+ }
319
+
320
+ async function sendText(args: Record<string, unknown>): Promise<unknown> {
321
+ if (!port) return { ok: false };
322
+ await port.client.udp.sendTextPacket({
323
+ appId: port.appId,
324
+ chunk: chunkCoord(args),
325
+ uuid: port.selfUuid,
326
+ text: String(args.text ?? ''),
327
+ distance: 8,
328
+ });
329
+ return { ok: true };
330
+ }
331
+
332
+ async function sendActorMessage(args: Record<string, unknown>): Promise<unknown> {
333
+ if (!port) return { ok: false };
334
+ const target = String(args.targetUuid ?? args.actorId ?? '');
335
+ if (!actorInside(target)) return { ok: false, error: 'target is outside the grid' };
336
+ const chunk = messageChunk(target, args);
337
+ if (!chunk) return { ok: false, error: 'target chunk is unknown' };
338
+ await port.client.udp.sendSingleActorMessage({
339
+ appId: port.appId,
340
+ chunk,
341
+ targetUuid: target,
342
+ payload: encodeState(args),
343
+ });
344
+ return { ok: true };
345
+ }
346
+
347
+ async function sendChannelMessage(args: Record<string, unknown>): Promise<unknown> {
348
+ if (!port) return { ok: false };
349
+ await port.client.udp.sendChannelMessage({
350
+ channelId: String(args.channelId ?? ''),
351
+ uuid: port.selfUuid,
352
+ payload: String(args.payloadBase64 ?? ''),
353
+ });
354
+ return { ok: true };
355
+ }
356
+
357
+ async function teleport(args: Record<string, unknown>): Promise<unknown> {
358
+ if (!port) return { ok: false };
359
+ const uuid = String(args.uuid ?? port.selfUuid);
360
+ if (uuid !== port.selfUuid && !owned.has(uuid)) {
361
+ return { ok: false, error: 'refusing another player uuid' };
362
+ }
363
+ if (!chunkInModGrid(num(args.destChunkX), num(args.destChunkY), num(args.destChunkZ))) {
364
+ return { ok: false, error: 'outside the player grid' };
365
+ }
366
+ const response = await port.client.teleport.request({
367
+ appId: port.appId,
368
+ chunkAddress: {
369
+ x: String(args.destChunkX),
370
+ y: String(args.destChunkY),
371
+ z: String(args.destChunkZ),
372
+ },
373
+ voxelAddress: { x: 0, y: 0, z: 0 },
374
+ uuid,
375
+ });
376
+ if (response.success && uuid === port.selfUuid) {
377
+ const x = Number(args.destChunkX) * 16 + 8;
378
+ const y = Number(args.destChunkY) * 16;
379
+ const z = Number(args.destChunkZ) * 16 + 8;
380
+ hold = {
381
+ x,
382
+ y,
383
+ z,
384
+ yaw: hold?.yaw ?? 0,
385
+ pitch: hold?.pitch ?? 0,
386
+ roll: hold?.roll ?? 0,
387
+ vx: 0,
388
+ vy: 0,
389
+ vz: 0,
390
+ atMs: performance.now(),
391
+ };
392
+ await port.moveTo({
393
+ x: Number(args.destChunkX),
394
+ y: Number(args.destChunkY),
395
+ z: Number(args.destChunkZ),
396
+ });
397
+ }
398
+ return response;
399
+ }
400
+
401
+ async function avatarStateSet(args: Record<string, unknown>): Promise<unknown> {
402
+ if (!port) return { ok: false };
403
+ const id = String(args.avatarId ?? '');
404
+ if (!avatarId) {
405
+ avatarId = id;
406
+ const current = await port.client.avatars.appState(port.appId, id).catch(() => null);
407
+ avatarSnapshot = current?.state ?? null;
408
+ }
409
+ await port.client.avatars.updateAppState({
410
+ appId: port.appId,
411
+ avatarId: id,
412
+ state: String(args.stateBase64 ?? ''),
413
+ });
414
+ return { ok: true };
415
+ }
416
+
417
+ async function restoreAvatar(): Promise<void> {
418
+ if (!port || !avatarId || avatarSnapshot == null) {
419
+ avatarId = null;
420
+ avatarSnapshot = null;
421
+ return;
422
+ }
423
+ const id = avatarId;
424
+ const state = avatarSnapshot;
425
+ avatarId = null;
426
+ avatarSnapshot = null;
427
+ await port.client.avatars
428
+ .updateAppState({ appId: port.appId, avatarId: id, state })
429
+ .catch(() => undefined);
430
+ }
431
+
432
+ const PASS_SDK = new Set([
433
+ 'actors_create',
434
+ 'actors_update',
435
+ 'actors_delete',
436
+ 'actors_update_state',
437
+ 'chunk_update',
438
+ 'chunk_update_state',
439
+ 'chunk_lods',
440
+ 'voxels_history',
441
+ 'voxels_rollback',
442
+ 'channel_list',
443
+ 'channel_get',
444
+ 'channel_join',
445
+ 'channel_leave',
446
+ 'channel_create',
447
+ 'channel_update',
448
+ 'channel_remove',
449
+ 'channel_set_policy',
450
+ 'channel_members',
451
+ 'channel_add_member',
452
+ 'channel_remove_member',
453
+ 'channel_set_roles',
454
+ 'team_list',
455
+ 'team_get',
456
+ 'team_join',
457
+ 'team_leave',
458
+ 'team_create',
459
+ 'team_update',
460
+ 'team_remove',
461
+ 'team_set_policy',
462
+ 'inventory_ensure',
463
+ 'inventory_stacks',
464
+ 'inventory_grant',
465
+ 'inventory_consume',
466
+ 'inventory_move',
467
+ 'inventory_transfer',
468
+ 'inventory_craft',
469
+ 'inventory_barter',
470
+ 'session_create',
471
+ 'session_join',
472
+ 'session_turn',
473
+ 'model_traverse',
474
+ 'model_flow',
475
+ 'model_seed',
476
+ 'timer_schedule',
477
+ 'timer_cancel',
478
+ 'sessions_list',
479
+ 'container_get_batch',
480
+ 'edge_add',
481
+ 'edge_delete',
482
+ 'avatar_state_get',
483
+ 'player_containers_list',
484
+ 'player_container_create',
485
+ 'player_container_delete',
486
+ 'player_automations_list',
487
+ 'player_automation_create',
488
+ 'player_automation_set_enabled',
489
+ 'player_automation_delete',
490
+ 'server_module_invoke',
491
+ 'emit_channel',
492
+ 'emit_event',
493
+ ]);
494
+
495
+ async function passSdk(fn: string, args: Record<string, unknown>): Promise<unknown> {
496
+ if (!PASS_SDK.has(fn)) return { ok: false, error: `unrouted host call ${fn}` };
497
+ if (!port) return { ok: false, error: 'no session' };
498
+ const client = port.client;
499
+ switch (fn) {
500
+ case 'actors_create':
501
+ return client.actors.create({
502
+ appId: port.appId,
503
+ state: String(args.stateBase64 ?? ''),
504
+ } as never);
505
+ case 'actors_update':
506
+ return client.actors.update(String(args.uuid), {
507
+ publicState: String(args.stateBase64 ?? ''),
508
+ } as never);
509
+ case 'actors_delete':
510
+ return client.actors.delete(String(args.uuid) as never);
511
+ case 'actors_update_state':
512
+ return client.actors.updateState(
513
+ String(args.uuid) as never,
514
+ String(args.stateBase64 ?? '') as never,
515
+ );
516
+ case 'chunk_update':
517
+ return client.chunks.update({
518
+ appId: port.appId,
519
+ coordinates: coords(args),
520
+ voxels: args.stateBase64,
521
+ } as never);
522
+ case 'chunk_update_state':
523
+ return client.chunks.updateState({
524
+ appId: port.appId,
525
+ coordinates: coords(args),
526
+ chunkState: args.stateBase64,
527
+ } as never);
528
+ case 'chunk_lods':
529
+ return client.chunks.getLods({ appId: port.appId, coordinates: coords(args) } as never);
530
+ case 'voxels_history':
531
+ return client.voxels.history({ appId: port.appId } as never);
532
+ case 'voxels_rollback':
533
+ return client.voxels.rollback({
534
+ appId: port.appId,
535
+ voxelUpdateId: args.voxelUpdateId,
536
+ } as never);
537
+ case 'channel_list':
538
+ return client.channels.list(port.appId);
539
+ case 'channel_get':
540
+ return client.channels.get(String(args.id));
541
+ case 'channel_join':
542
+ return client.channels.join(String(args.id));
543
+ case 'channel_leave':
544
+ return client.channels.leave(String(args.id));
545
+ case 'channel_create':
546
+ return client.channels.create({
547
+ appId: port.appId,
548
+ name: String(args.name ?? 'mod'),
549
+ } as never);
550
+ case 'channel_update':
551
+ return client.channels.update({
552
+ groupId: String(args.id),
553
+ name: String(args.name ?? ''),
554
+ } as never);
555
+ case 'channel_remove':
556
+ return client.channels.remove(String(args.id));
557
+ case 'channel_set_policy':
558
+ return client.channels.setPolicy({ appId: port.appId, ...(args.policy as object) } as never);
559
+ case 'channel_members':
560
+ return client.channels.members(String(args.id));
561
+ case 'channel_add_member':
562
+ return client.channels.addMember(String(args.id), String(args.userId));
563
+ case 'channel_remove_member':
564
+ return client.channels.removeMember(String(args.id), String(args.userId));
565
+ case 'channel_set_roles':
566
+ return client.channels.setMemberRoles({
567
+ groupId: String(args.id),
568
+ userId: String(args.userId),
569
+ roleIds: args.roles,
570
+ } as never);
571
+ case 'team_list':
572
+ return client.teams.list(port.appId);
573
+ case 'team_get':
574
+ return client.teams.get(String(args.id));
575
+ case 'team_join':
576
+ return client.teams.join(String(args.id));
577
+ case 'team_leave':
578
+ return client.teams.leave(String(args.id));
579
+ case 'team_create':
580
+ return client.teams.create({ appId: port.appId, name: String(args.name ?? 'mod') } as never);
581
+ case 'team_update':
582
+ return client.teams.update({
583
+ groupId: String(args.id),
584
+ name: String(args.name ?? ''),
585
+ } as never);
586
+ case 'team_remove':
587
+ return client.teams.remove(String(args.id));
588
+ case 'team_set_policy':
589
+ return client.teams.setPolicy({ appId: port.appId, ...(args.policy as object) } as never);
590
+ case 'avatar_state_get':
591
+ return client.avatars.appState(port.appId, String(args.avatarId ?? ''));
592
+ // CrowdyJS 18 removed the game-model, player-model, kit inventory and
593
+ // player-compute clients. These names stay routed so a module that still
594
+ // asks gets a refusal instead of a missing property.
595
+ case 'inventory_transfer':
596
+ case 'inventory_barter':
597
+ if (args.targetUuid != null && !actorInside(String(args.targetUuid))) {
598
+ return { ok: false, error: 'target is outside the grid' };
599
+ }
600
+ return { ok: false, error: `${fn} is not offered on this client` };
601
+ case 'inventory_ensure':
602
+ case 'inventory_stacks':
603
+ case 'inventory_grant':
604
+ case 'inventory_consume':
605
+ case 'inventory_move':
606
+ case 'inventory_craft':
607
+ case 'session_create':
608
+ case 'session_join':
609
+ case 'session_turn':
610
+ case 'model_traverse':
611
+ case 'model_flow':
612
+ case 'model_seed':
613
+ case 'timer_schedule':
614
+ case 'timer_cancel':
615
+ case 'sessions_list':
616
+ case 'container_get_batch':
617
+ case 'edge_add':
618
+ case 'edge_delete':
619
+ case 'player_containers_list':
620
+ case 'player_container_create':
621
+ case 'player_container_delete':
622
+ case 'player_automations_list':
623
+ case 'player_automation_create':
624
+ case 'player_automation_set_enabled':
625
+ case 'player_automation_delete':
626
+ case 'server_module_invoke':
627
+ case 'emit_channel':
628
+ case 'emit_event':
629
+ return { ok: false, error: `${fn} is not offered on this client` };
630
+ default:
631
+ return { ok: false, error: `unrouted host call ${fn}` };
632
+ }
633
+ }
634
+
635
+ function actorInside(uuid: string): boolean {
636
+ if (!port) return false;
637
+ if (owned.has(uuid)) return true;
638
+ return port
639
+ .players()
640
+ .some(
641
+ (player) =>
642
+ player.uuid === uuid && positionInModGrid(player.pose.x, player.pose.y, player.pose.z),
643
+ );
644
+ }
645
+
646
+ /** `crowdy-client-sdk` says `enabled: false`. Older callers say `action: 'stop'`. */
647
+ function stopped(args: Record<string, unknown>): boolean {
648
+ return args.action === 'stop' || args.enabled === false;
649
+ }
650
+
651
+ function record(value: unknown): Record<string, unknown> | null {
652
+ return value && typeof value === 'object' && !Array.isArray(value)
653
+ ? (value as Record<string, unknown>)
654
+ : null;
655
+ }
656
+
657
+ /** Chunk from `x,y,z` or the SDK's `chunkX,chunkY,chunkZ`. */
658
+ function chunkCoord(args: Record<string, unknown>) {
659
+ return {
660
+ x: String(args.x ?? args.chunkX ?? 0),
661
+ y: String(args.y ?? args.chunkY ?? 0),
662
+ z: String(args.z ?? args.chunkZ ?? 0),
663
+ };
664
+ }
665
+
666
+ /** Wire state: an already-encoded `payloadBase64`, or the SDK's JSON `payload`. */
667
+ function encodeState(args: Record<string, unknown>): string {
668
+ if (typeof args.payloadBase64 === 'string' && args.payloadBase64.length > 0) {
669
+ return args.payloadBase64;
670
+ }
671
+ const payload = args.payload;
672
+ if (payload && typeof payload === 'object') return utf8Base64(JSON.stringify(payload));
673
+ if (typeof payload === 'string') return payload;
674
+ return '';
675
+ }
676
+
677
+ function utf8Base64(text: string): string {
678
+ const bytes = new TextEncoder().encode(text);
679
+ let binary = '';
680
+ for (const byte of bytes) binary += String.fromCharCode(byte);
681
+ return btoa(binary);
682
+ }
683
+
684
+ function messageChunk(target: string, args: Record<string, unknown>) {
685
+ if (args.targetChunkX != null) {
686
+ return {
687
+ x: String(args.targetChunkX),
688
+ y: String(args.targetChunkY ?? 0),
689
+ z: String(args.targetChunkZ ?? 0),
690
+ };
691
+ }
692
+ const mine = owned.get(target);
693
+ if (mine) {
694
+ const chunk = worldToChunk({ x: mine.x, y: mine.y, z: mine.z });
695
+ return { x: String(chunk.x), y: String(chunk.y), z: String(chunk.z) };
696
+ }
697
+ const player = port?.players().find((row) => row.uuid === target);
698
+ if (!player) return null;
699
+ const chunk = worldToChunk(player.pose);
700
+ return { x: String(chunk.x), y: String(chunk.y), z: String(chunk.z) };
701
+ }
702
+
703
+ function noteProjectile(args: Record<string, unknown>): void {
704
+ const raw = record(args.payload) ?? decodePayload(args.payloadBase64);
705
+ if (!raw || raw.kind !== 'projectile') return;
706
+ const origin = (raw.origin ?? {}) as Record<string, unknown>;
707
+ const direction = (raw.direction ?? { z: -1 }) as Record<string, unknown>;
708
+ projectiles.push({
709
+ id: nextProjectile++,
710
+ x: num(origin.x),
711
+ y: num(origin.y ?? 1.7),
712
+ z: num(origin.z),
713
+ dx: num(direction.x),
714
+ dy: num(direction.y),
715
+ dz: num(direction.z ?? -1),
716
+ speed: num(raw.speed ?? 40) || 40,
717
+ startMs: num(raw.startMs) || performance.now(),
718
+ lifeMs: num(raw.lifeMs ?? 2000) || 2000,
719
+ });
720
+ }
721
+
722
+ function remember(event: unknown): void {
723
+ inbox.push(event);
724
+ if (inbox.length > 200) inbox.shift();
725
+ if (event && typeof event === 'object' && 'state' in event) {
726
+ noteProjectile({ payloadBase64: (event as { state?: string }).state });
727
+ }
728
+ }
729
+
730
+ function decodePayload(value: unknown): Record<string, unknown> | null {
731
+ if (typeof value !== 'string' || value.length === 0) return null;
732
+ try {
733
+ const text = atob(value);
734
+ const parsed = JSON.parse(text) as unknown;
735
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
736
+ } catch {
737
+ return null;
738
+ }
739
+ }
740
+
741
+ function coords(args: Record<string, unknown>) {
742
+ return { x: String(args.x), y: String(args.y), z: String(args.z) };
743
+ }
744
+
745
+ function num(value: unknown): number {
746
+ const n = Number(value ?? 0);
747
+ return Number.isFinite(n) ? n : 0;
748
+ }
749
+
750
+ export function poseFromHold(base: Pose): Pose {
751
+ if (!hold) return base;
752
+ return { ...base, ...hold, roll: hold.roll };
753
+ }