@crowdedkingdoms/construct 0.3.7-test.1 → 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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/engine/Input.test.ts +26 -0
- package/src/engine/Input.ts +22 -2
- package/src/platform/realtime/actorCodec.test.ts +1 -0
- package/src/platform/realtime/actorCodec.ts +7 -2
- package/src/platform/studio/GridService.test.ts +17 -0
- package/src/platform/studio/GridService.ts +87 -3
- package/src/platform/studio/StudioService.ts +245 -14
- package/src/platform/studio/clientHalves.test.ts +6 -1
- package/src/platform/studio/clientHalves.ts +15 -2
- package/src/platform/studio/clientModHost.test.ts +204 -4
- package/src/platform/studio/clientModHost.ts +109 -17
- package/src/platform/studio/instanceSchema.test.ts +43 -0
- package/src/platform/studio/instanceSchema.ts +59 -0
- package/src/platform/studio/instanceStore.ts +3 -1
- package/src/platform/studio/modChunkRuntime.ts +753 -0
- package/src/platform/studio/modPose.test.ts +34 -0
- package/src/platform/studio/modPose.ts +111 -0
- package/src/platform/studio/modScene.test.ts +79 -0
- package/src/platform/studio/modScene.ts +349 -0
|
@@ -7,7 +7,9 @@ const {
|
|
|
7
7
|
bytesToBase64,
|
|
8
8
|
parseVoxelSetArgs,
|
|
9
9
|
routeClientHostCall,
|
|
10
|
+
voxelStateToWire,
|
|
10
11
|
voxelsFromBase64,
|
|
12
|
+
voxelsListRows,
|
|
11
13
|
} = await import('./clientModHost');
|
|
12
14
|
|
|
13
15
|
const grid = { low: { x: -1n, y: 0n, z: -1n }, high: { x: 1n, y: 0n, z: 1n } };
|
|
@@ -33,7 +35,7 @@ function sampleGrid(): Uint8Array {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
describe('routeClientHostCall', () => {
|
|
36
|
-
it('offers world_read
|
|
38
|
+
it('offers world_read, scene catalog, and chunk gameplay', () => {
|
|
37
39
|
expect([...OFFERED_HOST_CALLS]).toEqual([
|
|
38
40
|
'actors_list',
|
|
39
41
|
'actors_list_radius',
|
|
@@ -41,6 +43,28 @@ describe('routeClientHostCall', () => {
|
|
|
41
43
|
'voxels_list',
|
|
42
44
|
'voxel_set',
|
|
43
45
|
'pointer_clicks',
|
|
46
|
+
'input_axes',
|
|
47
|
+
'input_look',
|
|
48
|
+
'input_key',
|
|
49
|
+
'events_poll',
|
|
50
|
+
'voice_set',
|
|
51
|
+
'video_set',
|
|
52
|
+
'send_client_event',
|
|
53
|
+
'send_text',
|
|
54
|
+
'send_actor_message',
|
|
55
|
+
'send_channel_message',
|
|
56
|
+
'pose_get',
|
|
57
|
+
'pose_set',
|
|
58
|
+
'pose_release',
|
|
59
|
+
'actor_spawn',
|
|
60
|
+
'actor_pose',
|
|
61
|
+
'actor_despawn',
|
|
62
|
+
'avatar_appearance',
|
|
63
|
+
'avatar_state_set',
|
|
64
|
+
'teleport_request',
|
|
65
|
+
'scene_catalog',
|
|
66
|
+
'scene_instances',
|
|
67
|
+
'clock',
|
|
44
68
|
]);
|
|
45
69
|
});
|
|
46
70
|
|
|
@@ -66,7 +90,7 @@ describe('routeClientHostCall', () => {
|
|
|
66
90
|
expect(r.calls.every((c) => c.split(',').every((v) => Math.abs(Number(v)) <= 1))).toBe(true);
|
|
67
91
|
});
|
|
68
92
|
|
|
69
|
-
it('reads crowdy-client-sdk radii and
|
|
93
|
+
it('reads crowdy-client-sdk radii and keeps a claim-wide scan inside the grid', async () => {
|
|
70
94
|
const r = reads();
|
|
71
95
|
const wide = { low: { x: -9n, y: -9n, z: -9n }, high: { x: 9n, y: 9n, z: 9n } };
|
|
72
96
|
await routeClientHostCall(
|
|
@@ -77,10 +101,11 @@ describe('routeClientHostCall', () => {
|
|
|
77
101
|
r.reads,
|
|
78
102
|
wide,
|
|
79
103
|
);
|
|
80
|
-
|
|
104
|
+
// radiusXz 9 covers the grid (±9). Vertical radius stays 1.
|
|
105
|
+
expect(r.calls.length).toBe(19 * 3 * 19);
|
|
81
106
|
const ys = new Set(r.calls.map((c) => Number(c.split(',')[1])));
|
|
82
107
|
expect([...ys].sort()).toEqual([-1, 0, 1]);
|
|
83
|
-
expect(r.calls.every((c) => Math.abs(Number(c.split(',')[0])) <=
|
|
108
|
+
expect(r.calls.every((c) => Math.abs(Number(c.split(',')[0])) <= 9)).toBe(true);
|
|
84
109
|
});
|
|
85
110
|
|
|
86
111
|
it('serves chunk_get as base64 and voxels_list as sparse rows', async () => {
|
|
@@ -185,6 +210,28 @@ describe('routeClientHostCall', () => {
|
|
|
185
210
|
]);
|
|
186
211
|
});
|
|
187
212
|
|
|
213
|
+
it('parses the SDK voxel_set shape (voxelX/stateBase64)', () => {
|
|
214
|
+
expect(
|
|
215
|
+
parseVoxelSetArgs({
|
|
216
|
+
chunkX: -1,
|
|
217
|
+
chunkY: 0,
|
|
218
|
+
chunkZ: -1,
|
|
219
|
+
voxelX: 4,
|
|
220
|
+
voxelY: 15,
|
|
221
|
+
voxelZ: 15,
|
|
222
|
+
voxelType: 1,
|
|
223
|
+
stateBase64: '{"u":"abc","shot":1}',
|
|
224
|
+
}),
|
|
225
|
+
).toEqual({
|
|
226
|
+
chunk: { x: -1, y: 0, z: -1 },
|
|
227
|
+
x: 4,
|
|
228
|
+
y: 15,
|
|
229
|
+
z: 15,
|
|
230
|
+
voxelType: 1,
|
|
231
|
+
state: '{"u":"abc","shot":1}',
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
188
235
|
it('writes no voxel outside the grid, whatever second chunk the call carries', async () => {
|
|
189
236
|
const r = reads();
|
|
190
237
|
const written: Array<{ chunk: { x: number; y: number; z: number } }> = [];
|
|
@@ -255,6 +302,112 @@ describe('routeClientHostCall', () => {
|
|
|
255
302
|
});
|
|
256
303
|
});
|
|
257
304
|
|
|
305
|
+
it('refuses a pose outside the grid and returns input axes', async () => {
|
|
306
|
+
const { bindModGrid, bindModInput, releaseModChunk } = await import('./modChunkRuntime');
|
|
307
|
+
bindModGrid(grid);
|
|
308
|
+
bindModInput({ axes: () => ({ x: 1, y: 0 }) } as never);
|
|
309
|
+
const { bindModSession } = await import('./modChunkRuntime');
|
|
310
|
+
bindModSession({
|
|
311
|
+
client: {
|
|
312
|
+
udp: { sendClientEvent: async () => true },
|
|
313
|
+
kit: () => ({ inventory: {} }),
|
|
314
|
+
} as never,
|
|
315
|
+
appId: '1',
|
|
316
|
+
gridId: '1',
|
|
317
|
+
selfUuid: 'self',
|
|
318
|
+
userId: '1',
|
|
319
|
+
moveTo: async () => undefined,
|
|
320
|
+
players: () => [{ uuid: 'self', pose: { x: 0, y: 0, z: 0 } }],
|
|
321
|
+
voiceStart: async () => undefined,
|
|
322
|
+
voiceStop: () => undefined,
|
|
323
|
+
videoStart: async () => undefined,
|
|
324
|
+
videoStop: () => undefined,
|
|
325
|
+
});
|
|
326
|
+
const outside = await routeClientHostCall(
|
|
327
|
+
{ fn: 'pose_set', args: { x: 1000, y: 0, z: 0 } } as never,
|
|
328
|
+
reads().reads,
|
|
329
|
+
grid,
|
|
330
|
+
);
|
|
331
|
+
expect(outside).toEqual({ ok: false, error: 'outside the player grid' });
|
|
332
|
+
const axes = await routeClientHostCall(
|
|
333
|
+
{ fn: 'input_axes', args: {} } as never,
|
|
334
|
+
reads().reads,
|
|
335
|
+
grid,
|
|
336
|
+
);
|
|
337
|
+
expect(axes).toEqual({ x: 1, y: 0 });
|
|
338
|
+
const other = await routeClientHostCall(
|
|
339
|
+
{ fn: 'actor_spawn', args: { uuid: 'self', x: 0, y: 0, z: 0 } } as never,
|
|
340
|
+
reads().reads,
|
|
341
|
+
grid,
|
|
342
|
+
);
|
|
343
|
+
expect(other).toEqual({ ok: false, error: 'refusing a live player uuid' });
|
|
344
|
+
const kit = await routeClientHostCall(
|
|
345
|
+
{
|
|
346
|
+
fn: 'inventory_transfer',
|
|
347
|
+
args: { targetUuid: 'bob', fromStackId: 'a', toStackId: 'b' },
|
|
348
|
+
} as never,
|
|
349
|
+
reads().reads,
|
|
350
|
+
grid,
|
|
351
|
+
);
|
|
352
|
+
expect(kit).toEqual({ ok: false, error: 'target is outside the grid' });
|
|
353
|
+
const far = await routeClientHostCall(
|
|
354
|
+
{
|
|
355
|
+
fn: 'teleport_request',
|
|
356
|
+
args: { destChunkX: 9, destChunkY: 0, destChunkZ: 0, uuid: 'self' },
|
|
357
|
+
} as never,
|
|
358
|
+
reads().reads,
|
|
359
|
+
grid,
|
|
360
|
+
);
|
|
361
|
+
expect(far).toEqual({ ok: false, error: 'outside the player grid' });
|
|
362
|
+
const { modProjectiles } = await import('./modChunkRuntime');
|
|
363
|
+
await routeClientHostCall(
|
|
364
|
+
{
|
|
365
|
+
fn: 'send_client_event',
|
|
366
|
+
args: {
|
|
367
|
+
x: 0,
|
|
368
|
+
y: 0,
|
|
369
|
+
z: 0,
|
|
370
|
+
payloadBase64: btoa(
|
|
371
|
+
JSON.stringify({
|
|
372
|
+
kind: 'projectile',
|
|
373
|
+
origin: { x: 1, y: 1.7, z: 2 },
|
|
374
|
+
direction: { x: 0, y: 0, z: -1 },
|
|
375
|
+
speed: 40,
|
|
376
|
+
startMs: 0,
|
|
377
|
+
lifeMs: 1000,
|
|
378
|
+
}),
|
|
379
|
+
),
|
|
380
|
+
},
|
|
381
|
+
} as never,
|
|
382
|
+
reads().reads,
|
|
383
|
+
grid,
|
|
384
|
+
);
|
|
385
|
+
expect(modProjectiles(0)).toHaveLength(1);
|
|
386
|
+
releaseModChunk();
|
|
387
|
+
await routeClientHostCall(
|
|
388
|
+
{
|
|
389
|
+
fn: 'send_client_event',
|
|
390
|
+
args: {
|
|
391
|
+
chunkX: -2,
|
|
392
|
+
chunkY: 0,
|
|
393
|
+
chunkZ: -1,
|
|
394
|
+
payload: {
|
|
395
|
+
kind: 'projectile',
|
|
396
|
+
origin: { x: 4, y: 1.7, z: 5 },
|
|
397
|
+
direction: { x: 1, y: 0, z: 0 },
|
|
398
|
+
speed: 1320,
|
|
399
|
+
lifeMs: 1800,
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
} as never,
|
|
403
|
+
reads().reads,
|
|
404
|
+
grid,
|
|
405
|
+
);
|
|
406
|
+
expect(modProjectiles(0)).toHaveLength(1);
|
|
407
|
+
releaseModChunk();
|
|
408
|
+
bindModInput(null);
|
|
409
|
+
});
|
|
410
|
+
|
|
258
411
|
it('parses the voxel_set arguments crowdy-client-sdk sends', () => {
|
|
259
412
|
expect(
|
|
260
413
|
parseVoxelSetArgs({
|
|
@@ -281,4 +434,51 @@ describe('routeClientHostCall', () => {
|
|
|
281
434
|
expect(voxelsFromBase64(null)).toEqual([]);
|
|
282
435
|
expect(voxelsFromBase64(bytesToBase64(new Uint8Array(4096)))).toEqual([]);
|
|
283
436
|
});
|
|
437
|
+
|
|
438
|
+
it('attaches sparse voxel state onto voxels_list rows', () => {
|
|
439
|
+
const packed = new Uint8Array(4096);
|
|
440
|
+
packed[2 + 15 * 16 + 0 * 256] = 1;
|
|
441
|
+
const json = '{"players":[{"uuid":"fake1","name":"Alice"}]}';
|
|
442
|
+
expect(voxelStateToWire(json)).toBe(bytesToBase64(new TextEncoder().encode(json)));
|
|
443
|
+
expect(voxelStateToWire('AA==')).toBe('AA==');
|
|
444
|
+
expect(voxelsListRows(bytesToBase64(packed), [{ x: 2, y: 15, z: 0, state: json }])).toEqual([
|
|
445
|
+
{ x: 2, y: 15, z: 0, voxelType: 1, state: json },
|
|
446
|
+
]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('stores a scene catalog for the running module', async () => {
|
|
450
|
+
const { ModSceneStore } = await import('./modScene');
|
|
451
|
+
const store = new ModSceneStore();
|
|
452
|
+
const target = { source: 'mod', store };
|
|
453
|
+
const catalog = await routeClientHostCall(
|
|
454
|
+
{ fn: 'scene_catalog', args: { v: 1, revision: 3, nodes: [] } } as never,
|
|
455
|
+
reads().reads,
|
|
456
|
+
grid,
|
|
457
|
+
undefined,
|
|
458
|
+
undefined,
|
|
459
|
+
target,
|
|
460
|
+
);
|
|
461
|
+
expect(catalog).toEqual({ ok: true, revision: 3 });
|
|
462
|
+
const again = await routeClientHostCall(
|
|
463
|
+
{ fn: 'scene_catalog', args: { v: 1, revision: 3, nodes: [] } } as never,
|
|
464
|
+
reads().reads,
|
|
465
|
+
grid,
|
|
466
|
+
undefined,
|
|
467
|
+
undefined,
|
|
468
|
+
target,
|
|
469
|
+
);
|
|
470
|
+
expect(again).toEqual({ ok: true, revision: 3, ignored: true });
|
|
471
|
+
const instances = await routeClientHostCall(
|
|
472
|
+
{
|
|
473
|
+
fn: 'scene_instances',
|
|
474
|
+
args: { instances: [{ id: 'me', template: 'body' }] },
|
|
475
|
+
} as never,
|
|
476
|
+
reads().reads,
|
|
477
|
+
grid,
|
|
478
|
+
undefined,
|
|
479
|
+
undefined,
|
|
480
|
+
target,
|
|
481
|
+
);
|
|
482
|
+
expect(instances).toEqual({ ok: true, count: 1 });
|
|
483
|
+
});
|
|
284
484
|
});
|
|
@@ -5,17 +5,34 @@
|
|
|
5
5
|
* The SDK's `PlayerCodeBroker` has already validated every call against the
|
|
6
6
|
* platform allowlist, clamped chunk coordinates to the mod's grid AABB, rate-
|
|
7
7
|
* limited it, and answered `grid_info` / `hud_set` / `overlay_draw` itself.
|
|
8
|
-
* What reaches `routeClientHostCall` is world_read, `voxel_set`,
|
|
9
|
-
* `pointer_clicks`,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* What reaches `routeClientHostCall` is world_read, `voxel_set`,
|
|
9
|
+
* `pointer_clicks`, pose, input, and scene_catalog / scene_instances, in the
|
|
10
|
+
* argument shapes `crowdy-client-sdk` sends. Reads return only what the
|
|
11
|
+
* running player can already lawfully see. Writes go through
|
|
12
|
+
* ChunkStore.setVoxel (optimistic + UDP) and markDirty (durable write-back),
|
|
13
|
+
* the same route Paint uses. Never hand a mod the raw client.
|
|
13
14
|
*/
|
|
14
15
|
import type { PlayerCodeGridBounds, PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
|
|
16
|
+
import { routeModGameplay } from './modChunkRuntime';
|
|
17
|
+
import type { ModSceneStore } from './modScene';
|
|
18
|
+
|
|
19
|
+
export interface ClientVoxelState {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
z: number;
|
|
23
|
+
state: string;
|
|
24
|
+
}
|
|
15
25
|
|
|
16
26
|
export interface ClientModHostReads {
|
|
17
27
|
actorsInChunk(x: bigint, y: bigint, z: bigint): Array<Record<string, unknown>>;
|
|
18
|
-
chunkVoxels(
|
|
28
|
+
chunkVoxels(
|
|
29
|
+
x: bigint,
|
|
30
|
+
y: bigint,
|
|
31
|
+
z: bigint,
|
|
32
|
+
):
|
|
33
|
+
| { voxelsBase64: string | null; states?: ClientVoxelState[] }
|
|
34
|
+
| null
|
|
35
|
+
| Promise<{ voxelsBase64: string | null; states?: ClientVoxelState[] } | null>;
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
export interface ClientModHostWrites {
|
|
@@ -31,6 +48,9 @@ export interface ClientModHostWrites {
|
|
|
31
48
|
|
|
32
49
|
export interface ClientModHostInput {
|
|
33
50
|
drainPointerClicks(): unknown;
|
|
51
|
+
axes?: () => { x: number; y: number };
|
|
52
|
+
look?: () => { dx: number; dy: number };
|
|
53
|
+
keyDown?: (code: string) => boolean;
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
/** The host calls this game answers. Exported so the docs and tests agree. */
|
|
@@ -41,13 +61,35 @@ export const OFFERED_HOST_CALLS = [
|
|
|
41
61
|
'voxels_list',
|
|
42
62
|
'voxel_set',
|
|
43
63
|
'pointer_clicks',
|
|
64
|
+
'input_axes',
|
|
65
|
+
'input_look',
|
|
66
|
+
'input_key',
|
|
67
|
+
'events_poll',
|
|
68
|
+
'voice_set',
|
|
69
|
+
'video_set',
|
|
70
|
+
'send_client_event',
|
|
71
|
+
'send_text',
|
|
72
|
+
'send_actor_message',
|
|
73
|
+
'send_channel_message',
|
|
74
|
+
'pose_get',
|
|
75
|
+
'pose_set',
|
|
76
|
+
'pose_release',
|
|
77
|
+
'actor_spawn',
|
|
78
|
+
'actor_pose',
|
|
79
|
+
'actor_despawn',
|
|
80
|
+
'avatar_appearance',
|
|
81
|
+
'avatar_state_set',
|
|
82
|
+
'teleport_request',
|
|
83
|
+
'scene_catalog',
|
|
84
|
+
'scene_instances',
|
|
85
|
+
'clock',
|
|
44
86
|
] as const;
|
|
45
87
|
|
|
46
88
|
/** One zero byte, base64: the smallest non-empty voxel state the API accepts. */
|
|
47
89
|
export const DEFAULT_VOXEL_STATE = 'AA==';
|
|
48
90
|
|
|
49
|
-
/** `actors_list_radius` reaches this far
|
|
50
|
-
const MAX_RADIUS_XZ =
|
|
91
|
+
/** `actors_list_radius` reaches this far. The claim arena is wider than the platform default of 3. */
|
|
92
|
+
const MAX_RADIUS_XZ = 32;
|
|
51
93
|
const MAX_RADIUS_Y = 1;
|
|
52
94
|
|
|
53
95
|
function toBigInt(value: unknown): bigint {
|
|
@@ -66,12 +108,18 @@ export class HostCallRefusedError extends Error {
|
|
|
66
108
|
}
|
|
67
109
|
}
|
|
68
110
|
|
|
111
|
+
export interface ClientModSceneTarget {
|
|
112
|
+
source: string;
|
|
113
|
+
store: ModSceneStore;
|
|
114
|
+
}
|
|
115
|
+
|
|
69
116
|
export async function routeClientHostCall(
|
|
70
117
|
call: PlayerCodeHostCall,
|
|
71
118
|
reads: ClientModHostReads,
|
|
72
119
|
grid?: PlayerCodeGridBounds,
|
|
73
120
|
writes?: ClientModHostWrites,
|
|
74
121
|
input?: ClientModHostInput,
|
|
122
|
+
scene?: ClientModSceneTarget,
|
|
75
123
|
): Promise<unknown> {
|
|
76
124
|
const { fn, args } = call;
|
|
77
125
|
switch (fn) {
|
|
@@ -103,11 +151,11 @@ export async function routeClientHostCall(
|
|
|
103
151
|
}
|
|
104
152
|
case 'chunk_get':
|
|
105
153
|
case 'voxels_list': {
|
|
106
|
-
const chunk = reads.chunkVoxels(toBigInt(args.x), toBigInt(args.y), toBigInt(args.z));
|
|
154
|
+
const chunk = await reads.chunkVoxels(toBigInt(args.x), toBigInt(args.y), toBigInt(args.z));
|
|
107
155
|
if (!chunk) return fn === 'chunk_get' ? { voxelsBase64: null } : { voxels: [] };
|
|
108
156
|
return fn === 'chunk_get'
|
|
109
157
|
? { voxelsBase64: chunk.voxelsBase64 }
|
|
110
|
-
: { voxels:
|
|
158
|
+
: { voxels: voxelsListRows(chunk.voxelsBase64, chunk.states) };
|
|
111
159
|
}
|
|
112
160
|
case 'voxel_set': {
|
|
113
161
|
if (!writes || !grid) throw new HostCallRefusedError(fn);
|
|
@@ -128,8 +176,28 @@ export async function routeClientHostCall(
|
|
|
128
176
|
if (!input) throw new HostCallRefusedError(fn);
|
|
129
177
|
return input.drainPointerClicks();
|
|
130
178
|
}
|
|
131
|
-
|
|
132
|
-
|
|
179
|
+
case 'clock':
|
|
180
|
+
return { ms: Date.now() };
|
|
181
|
+
case 'input_key':
|
|
182
|
+
return { down: input?.keyDown?.(String(args.code ?? '')) ?? false };
|
|
183
|
+
case 'scene_catalog':
|
|
184
|
+
if (!scene) return { ok: false };
|
|
185
|
+
return scene.store.setCatalog(scene.source, args);
|
|
186
|
+
case 'scene_instances':
|
|
187
|
+
if (!scene) return { ok: false };
|
|
188
|
+
return scene.store.setInstances(scene.source, args);
|
|
189
|
+
default: {
|
|
190
|
+
const answered = await routeModGameplay(call);
|
|
191
|
+
if (
|
|
192
|
+
answered &&
|
|
193
|
+
typeof answered === 'object' &&
|
|
194
|
+
'error' in answered &&
|
|
195
|
+
String((answered as { error: unknown }).error).startsWith('unrouted')
|
|
196
|
+
) {
|
|
197
|
+
throw new HostCallRefusedError(fn);
|
|
198
|
+
}
|
|
199
|
+
return answered;
|
|
200
|
+
}
|
|
133
201
|
}
|
|
134
202
|
}
|
|
135
203
|
|
|
@@ -189,9 +257,9 @@ export function parseVoxelSetArgs(args: Record<string, unknown>): {
|
|
|
189
257
|
const voxelRaw =
|
|
190
258
|
asCoord3(args.voxel) ??
|
|
191
259
|
({
|
|
192
|
-
x: coordField(args, 'voxelX', 'x', 'vx'),
|
|
193
|
-
y: coordField(args, 'voxelY', 'y', 'vy'),
|
|
194
|
-
z: coordField(args, 'voxelZ', 'z', 'vz'),
|
|
260
|
+
x: coordField(args, 'voxelX', 'voxel_x', 'x', 'vx'),
|
|
261
|
+
y: coordField(args, 'voxelY', 'voxel_y', 'y', 'vy'),
|
|
262
|
+
z: coordField(args, 'voxelZ', 'voxel_z', 'z', 'vz'),
|
|
195
263
|
} as { x: unknown; y: unknown; z: unknown });
|
|
196
264
|
const cx = intInRange(chunkRaw.x, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
197
265
|
const cy = intInRange(chunkRaw.y, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
@@ -220,10 +288,10 @@ export function parseVoxelSetArgs(args: Record<string, unknown>): {
|
|
|
220
288
|
/** Sparse `{x,y,z,voxelType}` rows for the non-zero cells of a dense grid. */
|
|
221
289
|
export function voxelsFromBase64(
|
|
222
290
|
voxelsBase64: string | null,
|
|
223
|
-
): Array<{ x: number; y: number; z: number; voxelType: number }> {
|
|
291
|
+
): Array<{ x: number; y: number; z: number; voxelType: number; state?: string }> {
|
|
224
292
|
if (!voxelsBase64) return [];
|
|
225
293
|
const binary = atob(voxelsBase64);
|
|
226
|
-
const out: Array<{ x: number; y: number; z: number; voxelType: number }> = [];
|
|
294
|
+
const out: Array<{ x: number; y: number; z: number; voxelType: number; state?: string }> = [];
|
|
227
295
|
for (let i = 0; i < binary.length && i < 4096; i++) {
|
|
228
296
|
const type = binary.charCodeAt(i);
|
|
229
297
|
if (type === 0) continue;
|
|
@@ -232,6 +300,30 @@ export function voxelsFromBase64(
|
|
|
232
300
|
return out;
|
|
233
301
|
}
|
|
234
302
|
|
|
303
|
+
/** Dense types plus sparse per-cell state so mailbox voxels can carry JSON. */
|
|
304
|
+
export function voxelsListRows(
|
|
305
|
+
voxelsBase64: string | null,
|
|
306
|
+
states?: ClientVoxelState[],
|
|
307
|
+
): Array<{ x: number; y: number; z: number; voxelType: number; state?: string }> {
|
|
308
|
+
const rows = voxelsFromBase64(voxelsBase64);
|
|
309
|
+
if (!states?.length) return rows;
|
|
310
|
+
for (const extra of states) {
|
|
311
|
+
const row = rows.find((v) => v.x === extra.x && v.y === extra.y && v.z === extra.z);
|
|
312
|
+
if (row) row.state = extra.state;
|
|
313
|
+
else rows.push({ x: extra.x, y: extra.y, z: extra.z, voxelType: 0, state: extra.state });
|
|
314
|
+
}
|
|
315
|
+
return rows;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** JSON mailbox payloads become base64; values that are already base64 pass through. */
|
|
319
|
+
export function voxelStateToWire(state: string): string {
|
|
320
|
+
const trimmed = state.trimStart();
|
|
321
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
322
|
+
return bytesToBase64(new TextEncoder().encode(state));
|
|
323
|
+
}
|
|
324
|
+
return state;
|
|
325
|
+
}
|
|
326
|
+
|
|
235
327
|
export function bytesToBase64(bytes: Uint8Array): string {
|
|
236
328
|
let binary = '';
|
|
237
329
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
|
|
@@ -79,6 +79,49 @@ describe('parseSceneCatalog', () => {
|
|
|
79
79
|
expect(catalog?.nodes[0]?.mesh).toBe('tri');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it('keeps flat lambert shading and treats unlit as basic', () => {
|
|
83
|
+
const catalog = parseSceneCatalog({
|
|
84
|
+
v: 1,
|
|
85
|
+
revision: 1,
|
|
86
|
+
meshes: [],
|
|
87
|
+
nodes: [
|
|
88
|
+
{ id: 'body', x: 0, y: 0, z: 0, shading: 'lambert', flat: true },
|
|
89
|
+
{ id: 'bolt', x: 0, y: 0, z: 0, unlit: true },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
expect(catalog?.nodes[0]).toMatchObject({ shading: 'lambert', flat: true, unlit: false });
|
|
93
|
+
expect(catalog?.nodes[1]).toMatchObject({ shading: 'basic', unlit: true, fog: false });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('keeps additive, emissive, double-sided, and fog opt-out', () => {
|
|
97
|
+
const catalog = parseSceneCatalog({
|
|
98
|
+
v: 1,
|
|
99
|
+
revision: 1,
|
|
100
|
+
meshes: [],
|
|
101
|
+
nodes: [
|
|
102
|
+
{
|
|
103
|
+
id: 'shield',
|
|
104
|
+
x: 0,
|
|
105
|
+
y: 0,
|
|
106
|
+
z: 0,
|
|
107
|
+
shading: 'lambert',
|
|
108
|
+
blend: 'additive',
|
|
109
|
+
depthWrite: false,
|
|
110
|
+
emissive: 0x88ccff,
|
|
111
|
+
side: 'double',
|
|
112
|
+
fog: false,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
expect(catalog?.nodes[0]).toMatchObject({
|
|
117
|
+
blend: 'additive',
|
|
118
|
+
depthWrite: false,
|
|
119
|
+
emissive: 0x88ccff,
|
|
120
|
+
side: 'double',
|
|
121
|
+
fog: false,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
82
125
|
it('drops over-cap vertex lists and parent-less garbage', () => {
|
|
83
126
|
const positions = Array.from({ length: (MAX_VERTS + 1) * 3 }, () => 1);
|
|
84
127
|
expect(
|
|
@@ -27,6 +27,9 @@ export const MAX_EVENT_STATE_BYTES = 1022;
|
|
|
27
27
|
const CATALOG_CHUNK_HEADER = 8;
|
|
28
28
|
|
|
29
29
|
export type MeshKind = 'box' | 'sphere' | 'cylinder' | 'capsule' | 'plane' | 'mesh';
|
|
30
|
+
export type SceneShading = 'standard' | 'lambert' | 'basic';
|
|
31
|
+
export type SceneBlend = 'normal' | 'additive';
|
|
32
|
+
export type SceneSide = 'front' | 'double';
|
|
30
33
|
export type ModOverlayShape = MeshKind;
|
|
31
34
|
|
|
32
35
|
export interface SceneMesh {
|
|
@@ -56,6 +59,20 @@ export interface SceneNode {
|
|
|
56
59
|
color: number;
|
|
57
60
|
visible: boolean;
|
|
58
61
|
bindActor?: string;
|
|
62
|
+
/** Unlit bolt, matching Afterburn's MeshBasicMaterial streak. */
|
|
63
|
+
unlit?: boolean;
|
|
64
|
+
/** standard is the lit default. lambert + flat is the robot and jet. basic is unlit. */
|
|
65
|
+
shading?: SceneShading;
|
|
66
|
+
flat?: boolean;
|
|
67
|
+
opacity?: number;
|
|
68
|
+
/** additive brightens what is behind the part. depthWrite false keeps it from punching a hole. */
|
|
69
|
+
blend?: SceneBlend;
|
|
70
|
+
depthWrite?: boolean;
|
|
71
|
+
/** Lit glow. Ignored on basic, which is already unlit. */
|
|
72
|
+
emissive?: number;
|
|
73
|
+
side?: SceneSide;
|
|
74
|
+
/** false keeps a lit part from fading in fog. basic already skips fog unless set true. */
|
|
75
|
+
fog?: boolean;
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
/** Overlay objects are scene nodes; `shape` is an alias of `kind` for older payloads. */
|
|
@@ -92,6 +109,18 @@ export interface ComposedInstance {
|
|
|
92
109
|
sz: number;
|
|
93
110
|
color: number;
|
|
94
111
|
visible: boolean;
|
|
112
|
+
unlit?: boolean;
|
|
113
|
+
shading?: SceneShading;
|
|
114
|
+
flat?: boolean;
|
|
115
|
+
opacity?: number;
|
|
116
|
+
/** additive brightens what is behind the part. depthWrite false keeps it from punching a hole. */
|
|
117
|
+
blend?: SceneBlend;
|
|
118
|
+
depthWrite?: boolean;
|
|
119
|
+
/** Lit glow. Ignored on basic, which is already unlit. */
|
|
120
|
+
emissive?: number;
|
|
121
|
+
side?: SceneSide;
|
|
122
|
+
/** false keeps a lit part from fading in fog. basic already skips fog unless set true. */
|
|
123
|
+
fog?: boolean;
|
|
95
124
|
}
|
|
96
125
|
|
|
97
126
|
export interface WorldAabb {
|
|
@@ -144,6 +173,18 @@ export function parseColor(value: unknown): number {
|
|
|
144
173
|
return 0xf8fafc;
|
|
145
174
|
}
|
|
146
175
|
|
|
176
|
+
function parseFog(rec: Record<string, unknown>, shading: SceneShading): boolean {
|
|
177
|
+
if (rec.fog === false) return false;
|
|
178
|
+
if (rec.fog === true) return true;
|
|
179
|
+
return shading !== 'basic';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseShading(rec: Record<string, unknown>): SceneShading {
|
|
183
|
+
if (rec.unlit === true || rec.basic === true || rec.shading === 'basic') return 'basic';
|
|
184
|
+
if (rec.shading === 'lambert') return 'lambert';
|
|
185
|
+
return 'standard';
|
|
186
|
+
}
|
|
187
|
+
|
|
147
188
|
function parseKind(value: unknown): MeshKind {
|
|
148
189
|
if (
|
|
149
190
|
value === 'sphere' ||
|
|
@@ -290,6 +331,15 @@ function parseNode(
|
|
|
290
331
|
color: parseColor(rec.color ?? rec.colour),
|
|
291
332
|
visible,
|
|
292
333
|
bindActor: parseBindActor(rec.bindActor ?? rec.bind_actor ?? rec.actor),
|
|
334
|
+
unlit: rec.unlit === true || rec.basic === true || rec.shading === 'basic',
|
|
335
|
+
shading: parseShading(rec),
|
|
336
|
+
flat: rec.flat === true || rec.flatShading === true,
|
|
337
|
+
opacity: clamp(finiteNumber(rec.opacity, 1) ?? 1, 0, 1),
|
|
338
|
+
blend: rec.blend === 'additive' ? 'additive' : 'normal',
|
|
339
|
+
depthWrite: rec.depthWrite !== false && rec.depth_write !== false,
|
|
340
|
+
emissive: rec.emissive === undefined ? undefined : parseColor(rec.emissive),
|
|
341
|
+
side: rec.side === 'double' ? 'double' : 'front',
|
|
342
|
+
fog: parseFog(rec, parseShading(rec)),
|
|
293
343
|
};
|
|
294
344
|
}
|
|
295
345
|
|
|
@@ -538,6 +588,15 @@ export function composeScene(
|
|
|
538
588
|
sz,
|
|
539
589
|
color: node.color || mesh?.color || 0xf8fafc,
|
|
540
590
|
visible: true,
|
|
591
|
+
unlit: node.unlit,
|
|
592
|
+
shading: node.shading,
|
|
593
|
+
flat: node.flat,
|
|
594
|
+
opacity: node.opacity,
|
|
595
|
+
blend: node.blend,
|
|
596
|
+
depthWrite: node.depthWrite,
|
|
597
|
+
emissive: node.emissive,
|
|
598
|
+
side: node.side,
|
|
599
|
+
fog: node.fog,
|
|
541
600
|
};
|
|
542
601
|
world.set(id, composed);
|
|
543
602
|
return composed;
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ActorXform,
|
|
19
19
|
type ComposedInstance,
|
|
20
20
|
type SceneCatalog,
|
|
21
|
+
type SceneMesh,
|
|
21
22
|
type SceneNode,
|
|
22
23
|
} from './instanceSchema';
|
|
23
24
|
import type { ModOverlayObject } from './modOverlay';
|
|
@@ -31,6 +32,7 @@ export interface InstanceSnapshotInput {
|
|
|
31
32
|
overlay?: readonly ModOverlayObject[];
|
|
32
33
|
actors?: readonly ActorXform[];
|
|
33
34
|
grid?: PlayerCodeGridBounds;
|
|
35
|
+
meshes?: readonly SceneMesh[];
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export class InstanceStore {
|
|
@@ -71,7 +73,7 @@ export class InstanceStore {
|
|
|
71
73
|
const overlay = input.overlay ?? [];
|
|
72
74
|
const overlayIds = new Set(overlay.map((n) => n.id));
|
|
73
75
|
const nodes: SceneNode[] = [...catalogNodes.filter((n) => !overlayIds.has(n.id)), ...overlay];
|
|
74
|
-
const meshes = this.catalog?.meshes ?? [];
|
|
76
|
+
const meshes = [...(this.catalog?.meshes ?? []), ...(input.meshes ?? [])];
|
|
75
77
|
return {
|
|
76
78
|
revision: this.revisionValue,
|
|
77
79
|
instances: composeScene(nodes, meshes, input.actors ?? [], input.grid),
|