@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.
@@ -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 plus voxel_set and pointer_clicks', () => {
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 clamps them as the platform does (3 across, 1 up)', async () => {
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
- expect(r.calls.length).toBe(7 * 3 * 7);
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])) <= 3)).toBe(true);
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`, and
9
- * `pointer_clicks`, in the argument shapes `crowdy-client-sdk` sends. Reads
10
- * return only what the running player can already lawfully see. Writes go
11
- * through ChunkStore.setVoxel (optimistic + UDP) and markDirty (durable
12
- * write-back), the same route Paint uses. Never hand a mod the raw client.
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(x: bigint, y: bigint, z: bigint): { voxelsBase64: string | null } | null;
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, as the platform clamps it. */
50
- const MAX_RADIUS_XZ = 3;
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: voxelsFromBase64(chunk.voxelsBase64) };
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
- default:
132
- throw new HostCallRefusedError(fn);
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),