@crowdedkingdoms/construct 0.0.0 → 0.2.1-dev.1
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 +76 -1
- package/node/copy-dsh-web.mjs +71 -0
- package/node/security-headers.d.mts +23 -0
- package/node/security-headers.mjs +243 -0
- package/node/vite-plugins.d.mts +6 -0
- package/node/vite-plugins.mjs +102 -0
- package/package.json +48 -3
- package/sandbox/boot.ts +74 -0
- package/src/engine/GameLoop.ts +106 -0
- package/src/engine/GameScene.ts +74 -0
- package/src/engine/Input.test.ts +129 -0
- package/src/engine/Input.ts +257 -0
- package/src/engine/SceneRouter.test.ts +80 -0
- package/src/engine/SceneRouter.ts +113 -0
- package/src/engine/controls.test.ts +26 -0
- package/src/engine/controls.ts +63 -0
- package/src/grid/GridProgramRunner.ts +205 -0
- package/src/grid/sandboxProtocol.ts +9 -0
- package/src/index.ts +88 -0
- package/src/platform/GameSession.ts +239 -0
- package/src/platform/auth/AuthService.ts +45 -0
- package/src/platform/config.test.ts +110 -0
- package/src/platform/config.ts +212 -0
- package/src/platform/envScope.test.ts +42 -0
- package/src/platform/envScope.ts +76 -0
- package/src/platform/exec/worldHub.test.ts +61 -0
- package/src/platform/exec/worldHub.ts +86 -0
- package/src/platform/media/VoiceService.ts +423 -0
- package/src/platform/media/WebcamService.ts +346 -0
- package/src/platform/media/mulaw.test.ts +20 -0
- package/src/platform/media/mulaw.ts +56 -0
- package/src/platform/media/videoFrames.test.ts +197 -0
- package/src/platform/media/voiceFrames.test.ts +82 -0
- package/src/platform/model/ModelService.test.ts +59 -0
- package/src/platform/model/ModelService.ts +124 -0
- package/src/platform/network/NetworkManager.ts +519 -0
- package/src/platform/onboarding/steps.d.mts +154 -0
- package/src/platform/onboarding/steps.mjs +616 -0
- package/src/platform/onboarding/steps.test.ts +486 -0
- package/src/platform/realtime/WorldStores.ts +153 -0
- package/src/platform/realtime/actorCodec.test.ts +62 -0
- package/src/platform/realtime/actorCodec.ts +142 -0
- package/src/platform/realtime/space.test.ts +33 -0
- package/src/platform/realtime/space.ts +63 -0
- package/src/platform/social/ChatService.ts +92 -0
- package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
- package/src/platform/studio/ClientModLifecycle.ts +102 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
- package/src/platform/studio/GridService.test.ts +131 -0
- package/src/platform/studio/GridService.ts +295 -0
- package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
- package/src/platform/studio/HumanInputMonitor.ts +48 -0
- package/src/platform/studio/StudioService.ts +686 -0
- package/src/platform/studio/agentLocomotion.test.ts +31 -0
- package/src/platform/studio/agentLocomotion.ts +97 -0
- package/src/platform/studio/clientModHost.test.ts +199 -0
- package/src/platform/studio/clientModHost.ts +369 -0
- package/src/platform/studio/instanceSchema.test.ts +280 -0
- package/src/platform/studio/instanceSchema.ts +746 -0
- package/src/platform/studio/instanceStore.test.ts +97 -0
- package/src/platform/studio/instanceStore.ts +120 -0
- package/src/platform/studio/modOverlay.test.ts +56 -0
- package/src/platform/studio/modOverlay.ts +51 -0
- package/src/platform/studio/permissions.test.ts +51 -0
- package/src/platform/studio/permissions.ts +134 -0
- package/src/platform/studio/pointerClicks.test.ts +23 -0
- package/src/platform/studio/pointerClicks.ts +132 -0
- package/src/platform/util/Emitter.ts +32 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { AgentLocomotion } from './agentLocomotion';
|
|
4
|
+
|
|
5
|
+
describe('AgentLocomotion', () => {
|
|
6
|
+
it('maps MOVE directions onto Input.axes and expires them', () => {
|
|
7
|
+
const loco = new AgentLocomotion();
|
|
8
|
+
loco.applyMove({ direction: 'FORWARD', intensity: 1, durationMs: 200, nowMs: 1_000 });
|
|
9
|
+
expect(loco.sample(1_050)).toMatchObject({ x: 0, y: 1, yaw: 0, pitch: 0 });
|
|
10
|
+
loco.applyMove({ direction: 'LEFT', intensity: 0.5, durationMs: 200, nowMs: 1_100 });
|
|
11
|
+
expect(loco.sample(1_150).x).toBe(-0.5);
|
|
12
|
+
expect(loco.sample(1_400)).toMatchObject({ x: 0, y: 0 });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('converts LOOK degrees to radians once, then clears', () => {
|
|
16
|
+
const loco = new AgentLocomotion();
|
|
17
|
+
loco.applyLook({ deltaYaw: 90, deltaPitch: -45 });
|
|
18
|
+
const first = loco.sample(0);
|
|
19
|
+
expect(first.yaw).toBeCloseTo(Math.PI / 2, 5);
|
|
20
|
+
expect(first.pitch).toBeCloseTo(-Math.PI / 4, 5);
|
|
21
|
+
expect(loco.sample(1)).toMatchObject({ yaw: 0, pitch: 0 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('clear drops both wishes', () => {
|
|
25
|
+
const loco = new AgentLocomotion();
|
|
26
|
+
loco.applyMove({ direction: 'RIGHT', intensity: 1, durationMs: 1_000, nowMs: 0 });
|
|
27
|
+
loco.applyLook({ deltaYaw: 10, deltaPitch: 0 });
|
|
28
|
+
loco.clear();
|
|
29
|
+
expect(loco.sample(10)).toEqual({ x: 0, y: 0, yaw: 0, pitch: 0 });
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Play locomotion, consumed by scenes the same way they consume WASD.
|
|
3
|
+
* StudioService writes wishes; HolodeckScene samples them each frame.
|
|
4
|
+
* Human input (the control gate) calls `clear`.
|
|
5
|
+
*/
|
|
6
|
+
export interface AgentMoveWish {
|
|
7
|
+
readonly direction: 'FORWARD' | 'BACKWARD' | 'LEFT' | 'RIGHT' | 'UP' | 'DOWN';
|
|
8
|
+
readonly intensity: number;
|
|
9
|
+
readonly untilMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AgentLookWish {
|
|
13
|
+
readonly yawDelta: number;
|
|
14
|
+
readonly pitchDelta: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AgentLocomotionSample {
|
|
18
|
+
/** Camera-relative axes, same convention as `Input.axes()` (−1..1). */
|
|
19
|
+
readonly x: number;
|
|
20
|
+
readonly y: number;
|
|
21
|
+
/** Radians to add this frame (already converted from the LOOK command). */
|
|
22
|
+
readonly yaw: number;
|
|
23
|
+
readonly pitch: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
27
|
+
|
|
28
|
+
export class AgentLocomotion {
|
|
29
|
+
private move: AgentMoveWish | null = null;
|
|
30
|
+
private look: AgentLookWish | null = null;
|
|
31
|
+
|
|
32
|
+
applyMove(input: {
|
|
33
|
+
direction: AgentMoveWish['direction'];
|
|
34
|
+
intensity: number;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
nowMs: number;
|
|
37
|
+
}): void {
|
|
38
|
+
const intensity = clamp01(input.intensity);
|
|
39
|
+
const durationMs = Math.max(16, Math.min(2_000, input.durationMs));
|
|
40
|
+
this.move = {
|
|
41
|
+
direction: input.direction,
|
|
42
|
+
intensity,
|
|
43
|
+
untilMs: input.nowMs + durationMs,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
applyLook(input: { deltaYaw: number; deltaPitch: number }): void {
|
|
48
|
+
this.look = {
|
|
49
|
+
yawDelta: finite(input.deltaYaw) * DEG_TO_RAD,
|
|
50
|
+
pitchDelta: finite(input.deltaPitch) * DEG_TO_RAD,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
clear(): void {
|
|
55
|
+
this.move = null;
|
|
56
|
+
this.look = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
sample(nowMs: number): AgentLocomotionSample {
|
|
60
|
+
let x = 0;
|
|
61
|
+
let y = 0;
|
|
62
|
+
if (this.move && nowMs < this.move.untilMs) {
|
|
63
|
+
const i = this.move.intensity;
|
|
64
|
+
switch (this.move.direction) {
|
|
65
|
+
case 'FORWARD':
|
|
66
|
+
y = i;
|
|
67
|
+
break;
|
|
68
|
+
case 'BACKWARD':
|
|
69
|
+
y = -i;
|
|
70
|
+
break;
|
|
71
|
+
case 'RIGHT':
|
|
72
|
+
x = i;
|
|
73
|
+
break;
|
|
74
|
+
case 'LEFT':
|
|
75
|
+
x = -i;
|
|
76
|
+
break;
|
|
77
|
+
default:
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
this.move = null;
|
|
82
|
+
}
|
|
83
|
+
const yaw = this.look?.yawDelta ?? 0;
|
|
84
|
+
const pitch = this.look?.pitchDelta ?? 0;
|
|
85
|
+
this.look = null;
|
|
86
|
+
return { x, y, yaw, pitch };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function clamp01(value: number): number {
|
|
91
|
+
if (!Number.isFinite(value)) return 0;
|
|
92
|
+
return Math.min(1, Math.max(0, value));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function finite(value: number): number {
|
|
96
|
+
return Number.isFinite(value) ? value : 0;
|
|
97
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('@crowdedkingdoms/crowdyjs', () => ({ PlayerCodeBroker: class {} }));
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
DEFAULT_VOXEL_STATE,
|
|
7
|
+
HostCallRefusedError,
|
|
8
|
+
OFFERED_HOST_CALLS,
|
|
9
|
+
bytesToBase64,
|
|
10
|
+
parseVoxelSetArgs,
|
|
11
|
+
routeClientHostCall,
|
|
12
|
+
voxelsFromBase64,
|
|
13
|
+
} = await import('./clientModHost');
|
|
14
|
+
|
|
15
|
+
const grid = { low: { x: -1n, y: 0n, z: -1n }, high: { x: 1n, y: 0n, z: 1n } };
|
|
16
|
+
|
|
17
|
+
function reads() {
|
|
18
|
+
const calls: string[] = [];
|
|
19
|
+
return {
|
|
20
|
+
calls,
|
|
21
|
+
reads: {
|
|
22
|
+
actorsInChunk: (x: bigint, y: bigint, z: bigint) => {
|
|
23
|
+
calls.push(`${x},${y},${z}`);
|
|
24
|
+
return x === 0n && z === 0n ? [{ uuid: 'me', name: 'neo' }] : [];
|
|
25
|
+
},
|
|
26
|
+
chunkVoxels: (x: bigint) => (x === 0n ? { voxelsBase64: bytesToBase64(sampleGrid()) } : null),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sampleGrid(): Uint8Array {
|
|
32
|
+
const g = new Uint8Array(4096);
|
|
33
|
+
g[1 + 0 * 16 + 2 * 256] = 7; // x=1, y=0, z=2
|
|
34
|
+
return g;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('routeClientHostCall', () => {
|
|
38
|
+
it('offers world_read plus voxel_set and pointer_clicks', () => {
|
|
39
|
+
expect([...OFFERED_HOST_CALLS]).toEqual([
|
|
40
|
+
'actors_list',
|
|
41
|
+
'actors_list_radius',
|
|
42
|
+
'chunk_get',
|
|
43
|
+
'voxels_list',
|
|
44
|
+
'voxel_set',
|
|
45
|
+
'pointer_clicks',
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('answers actors_list for one chunk', async () => {
|
|
50
|
+
const r = reads();
|
|
51
|
+
const out = await routeClientHostCall(
|
|
52
|
+
{ fn: 'actors_list', args: { x: '0', y: '0', z: '0' } } as never,
|
|
53
|
+
r.reads,
|
|
54
|
+
grid,
|
|
55
|
+
);
|
|
56
|
+
expect(out).toEqual({ actors: [{ uuid: 'me', name: 'neo' }] });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('clamps actors_list_radius to the grid and to radius 8', async () => {
|
|
60
|
+
const r = reads();
|
|
61
|
+
await routeClientHostCall(
|
|
62
|
+
{ fn: 'actors_list_radius', args: { x: '0', y: '0', z: '0', radius: 50 } } as never,
|
|
63
|
+
r.reads,
|
|
64
|
+
grid,
|
|
65
|
+
);
|
|
66
|
+
// 3x1x3 chunks inside the grid, never beyond it.
|
|
67
|
+
expect(r.calls.length).toBe(9);
|
|
68
|
+
expect(r.calls.every((c) => c.split(',').every((v) => Math.abs(Number(v)) <= 1))).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('serves chunk_get as base64 and voxels_list as sparse rows', async () => {
|
|
72
|
+
const r = reads();
|
|
73
|
+
const dense = (await routeClientHostCall(
|
|
74
|
+
{ fn: 'chunk_get', args: { x: 0, y: 0, z: 0 } } as never,
|
|
75
|
+
r.reads,
|
|
76
|
+
grid,
|
|
77
|
+
)) as {
|
|
78
|
+
voxelsBase64: string;
|
|
79
|
+
};
|
|
80
|
+
expect(atob(dense.voxelsBase64).length).toBe(4096);
|
|
81
|
+
const sparse = await routeClientHostCall(
|
|
82
|
+
{ fn: 'voxels_list', args: { x: 0, y: 0, z: 0 } } as never,
|
|
83
|
+
r.reads,
|
|
84
|
+
grid,
|
|
85
|
+
);
|
|
86
|
+
expect(sparse).toEqual({ voxels: [{ x: 1, y: 0, z: 2, voxelType: 7 }] });
|
|
87
|
+
expect(
|
|
88
|
+
await routeClientHostCall(
|
|
89
|
+
{ fn: 'voxels_list', args: { x: 5, y: 0, z: 0 } } as never,
|
|
90
|
+
r.reads,
|
|
91
|
+
grid,
|
|
92
|
+
),
|
|
93
|
+
).toEqual({
|
|
94
|
+
voxels: [],
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('refuses host calls it does not offer', async () => {
|
|
99
|
+
const r = reads();
|
|
100
|
+
for (const fn of ['model_invoke', 'emit_spatial', 'fetch', 'eval']) {
|
|
101
|
+
await expect(
|
|
102
|
+
routeClientHostCall({ fn, args: {} } as never, r.reads, grid),
|
|
103
|
+
).rejects.toBeInstanceOf(HostCallRefusedError);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('drains pointer_clicks when the game supplies an input sink', async () => {
|
|
108
|
+
const r = reads();
|
|
109
|
+
await expect(
|
|
110
|
+
routeClientHostCall({ fn: 'pointer_clicks', args: {} } as never, r.reads, grid),
|
|
111
|
+
).rejects.toBeInstanceOf(HostCallRefusedError);
|
|
112
|
+
const snapshot = {
|
|
113
|
+
nowMs: 50,
|
|
114
|
+
buttons: 1,
|
|
115
|
+
holdingMs: { '0': 40 },
|
|
116
|
+
clicks: [{ t: 'down' as const, button: 0, atMs: 10, nx: 0, ny: 0 }],
|
|
117
|
+
};
|
|
118
|
+
expect(
|
|
119
|
+
await routeClientHostCall(
|
|
120
|
+
{ fn: 'pointer_clicks', args: {} } as never,
|
|
121
|
+
r.reads,
|
|
122
|
+
grid,
|
|
123
|
+
undefined,
|
|
124
|
+
{ drainPointerClicks: () => snapshot },
|
|
125
|
+
),
|
|
126
|
+
).toEqual(snapshot);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('refuses voxel_set when the game did not supply a write sink', async () => {
|
|
130
|
+
const r = reads();
|
|
131
|
+
await expect(
|
|
132
|
+
routeClientHostCall(
|
|
133
|
+
{
|
|
134
|
+
fn: 'voxel_set',
|
|
135
|
+
args: { chunkX: 0, chunkY: 0, chunkZ: 0, x: 1, y: 0, z: 2, voxelType: 3 },
|
|
136
|
+
} as never,
|
|
137
|
+
r.reads,
|
|
138
|
+
grid,
|
|
139
|
+
),
|
|
140
|
+
).rejects.toBeInstanceOf(HostCallRefusedError);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('writes a voxel through the Paint-equivalent sink', async () => {
|
|
144
|
+
const r = reads();
|
|
145
|
+
const written: unknown[] = [];
|
|
146
|
+
const out = await routeClientHostCall(
|
|
147
|
+
{
|
|
148
|
+
fn: 'voxel_set',
|
|
149
|
+
args: { chunk: { x: '0', y: '0', z: '0' }, voxel: [1, 0, 2], voxel_type: 7 },
|
|
150
|
+
} as never,
|
|
151
|
+
r.reads,
|
|
152
|
+
grid,
|
|
153
|
+
{
|
|
154
|
+
setVoxel: async (input) => {
|
|
155
|
+
written.push(input);
|
|
156
|
+
return true;
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
expect(out).toEqual({ ok: true });
|
|
161
|
+
expect(written).toEqual([
|
|
162
|
+
{
|
|
163
|
+
chunk: { x: 0, y: 0, z: 0 },
|
|
164
|
+
x: 1,
|
|
165
|
+
y: 0,
|
|
166
|
+
z: 2,
|
|
167
|
+
voxelType: 7,
|
|
168
|
+
state: DEFAULT_VOXEL_STATE,
|
|
169
|
+
},
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('parses flattened chunkX host-call args the broker clamps', () => {
|
|
174
|
+
expect(
|
|
175
|
+
parseVoxelSetArgs({
|
|
176
|
+
chunkX: 3,
|
|
177
|
+
chunkY: 0,
|
|
178
|
+
chunkZ: 3,
|
|
179
|
+
x: 8,
|
|
180
|
+
y: 1,
|
|
181
|
+
z: 4,
|
|
182
|
+
voxelType: 2,
|
|
183
|
+
state_base64: 'AA==',
|
|
184
|
+
}),
|
|
185
|
+
).toEqual({
|
|
186
|
+
chunk: { x: 3, y: 0, z: 3 },
|
|
187
|
+
x: 8,
|
|
188
|
+
y: 1,
|
|
189
|
+
z: 4,
|
|
190
|
+
voxelType: 2,
|
|
191
|
+
state: 'AA==',
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('decodes an empty grid to no rows', () => {
|
|
196
|
+
expect(voxelsFromBase64(null)).toEqual([]);
|
|
197
|
+
expect(voxelsFromBase64(bytesToBase64(new Uint8Array(4096)))).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host side of CLIENT mods: what a sandboxed player module may ask the
|
|
3
|
+
* game for, and how a consented grid-attached mod is started for a visitor.
|
|
4
|
+
*
|
|
5
|
+
* The SDK's `PlayerCodeBroker` has already validated every call against the
|
|
6
|
+
* platform allowlist, clamped chunk coordinates to the mod's grid AABB, rate-
|
|
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`. Reads return only what the running player can already
|
|
10
|
+
* lawfully see. Writes go through ChunkStore.setVoxel (optimistic + UDP) and
|
|
11
|
+
* markDirty (durable write-back), the same route Paint uses. Never hand a mod
|
|
12
|
+
* the raw client.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
PlayerCodeBroker,
|
|
16
|
+
type CrowdyClient,
|
|
17
|
+
type PlayerCodeGridBounds,
|
|
18
|
+
type PlayerCodeHostCall,
|
|
19
|
+
type PlayerCodePresentation,
|
|
20
|
+
} from '@crowdedkingdoms/crowdyjs';
|
|
21
|
+
import type { CrowdyStudioTextHud } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
|
|
22
|
+
import type { ModOverlayStore } from './modOverlay';
|
|
23
|
+
|
|
24
|
+
export interface ClientModHostReads {
|
|
25
|
+
actorsInChunk(x: bigint, y: bigint, z: bigint): Array<Record<string, unknown>>;
|
|
26
|
+
chunkVoxels(x: bigint, y: bigint, z: bigint): { voxelsBase64: string | null } | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ClientModHostWrites {
|
|
30
|
+
setVoxel(input: {
|
|
31
|
+
chunk: { x: number; y: number; z: number };
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
z: number;
|
|
35
|
+
voxelType: number;
|
|
36
|
+
state?: string;
|
|
37
|
+
}): Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ClientModHostInput {
|
|
41
|
+
drainPointerClicks(): unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The host calls this game answers. Exported so the docs and tests agree. */
|
|
45
|
+
export const OFFERED_HOST_CALLS = [
|
|
46
|
+
'actors_list',
|
|
47
|
+
'actors_list_radius',
|
|
48
|
+
'chunk_get',
|
|
49
|
+
'voxels_list',
|
|
50
|
+
'voxel_set',
|
|
51
|
+
'pointer_clicks',
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
/** One zero byte, base64: the smallest non-empty voxel state the API accepts. */
|
|
55
|
+
export const DEFAULT_VOXEL_STATE = 'AA==';
|
|
56
|
+
|
|
57
|
+
const MAX_RADIUS = 8;
|
|
58
|
+
|
|
59
|
+
function toBigInt(value: unknown): bigint {
|
|
60
|
+
return BigInt(value as string | number | bigint);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class HostCallRefusedError extends Error {
|
|
64
|
+
constructor(readonly fn: string) {
|
|
65
|
+
super(`host call '${fn}' is not offered by this game`);
|
|
66
|
+
this.name = 'HostCallRefusedError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function routeClientHostCall(
|
|
71
|
+
call: PlayerCodeHostCall,
|
|
72
|
+
reads: ClientModHostReads,
|
|
73
|
+
grid?: PlayerCodeGridBounds,
|
|
74
|
+
writes?: ClientModHostWrites,
|
|
75
|
+
input?: ClientModHostInput,
|
|
76
|
+
): Promise<unknown> {
|
|
77
|
+
const { fn, args } = call;
|
|
78
|
+
switch (fn) {
|
|
79
|
+
case 'actors_list':
|
|
80
|
+
return { actors: reads.actorsInChunk(toBigInt(args.x), toBigInt(args.y), toBigInt(args.z)) };
|
|
81
|
+
case 'actors_list_radius': {
|
|
82
|
+
const origin = { x: toBigInt(args.x), y: toBigInt(args.y), z: toBigInt(args.z) };
|
|
83
|
+
const requested = Number(args.radius ?? args.r ?? 0);
|
|
84
|
+
const radius = BigInt(
|
|
85
|
+
Number.isFinite(requested) ? Math.max(0, Math.min(MAX_RADIUS, Math.floor(requested))) : 0,
|
|
86
|
+
);
|
|
87
|
+
const low = grid
|
|
88
|
+
? {
|
|
89
|
+
x: maxBig(grid.low.x, origin.x - radius),
|
|
90
|
+
y: maxBig(grid.low.y, origin.y - radius),
|
|
91
|
+
z: maxBig(grid.low.z, origin.z - radius),
|
|
92
|
+
}
|
|
93
|
+
: origin;
|
|
94
|
+
const high = grid
|
|
95
|
+
? {
|
|
96
|
+
x: minBig(grid.high.x, origin.x + radius),
|
|
97
|
+
y: minBig(grid.high.y, origin.y + radius),
|
|
98
|
+
z: minBig(grid.high.z, origin.z + radius),
|
|
99
|
+
}
|
|
100
|
+
: origin;
|
|
101
|
+
const actors: Array<Record<string, unknown>> = [];
|
|
102
|
+
for (let x = low.x; x <= high.x; x++)
|
|
103
|
+
for (let y = low.y; y <= high.y; y++)
|
|
104
|
+
for (let z = low.z; z <= high.z; z++) actors.push(...reads.actorsInChunk(x, y, z));
|
|
105
|
+
return { actors };
|
|
106
|
+
}
|
|
107
|
+
case 'chunk_get':
|
|
108
|
+
case 'voxels_list': {
|
|
109
|
+
const chunk = reads.chunkVoxels(toBigInt(args.x), toBigInt(args.y), toBigInt(args.z));
|
|
110
|
+
if (!chunk) return fn === 'chunk_get' ? { voxelsBase64: null } : { voxels: [] };
|
|
111
|
+
return fn === 'chunk_get'
|
|
112
|
+
? { voxelsBase64: chunk.voxelsBase64 }
|
|
113
|
+
: { voxels: voxelsFromBase64(chunk.voxelsBase64) };
|
|
114
|
+
}
|
|
115
|
+
case 'voxel_set': {
|
|
116
|
+
if (!writes) throw new HostCallRefusedError(fn);
|
|
117
|
+
const parsed = parseVoxelSetArgs(args);
|
|
118
|
+
if (!parsed) return { ok: false, error: 'invalid voxel_set arguments' };
|
|
119
|
+
const ok = await writes.setVoxel({
|
|
120
|
+
chunk: parsed.chunk,
|
|
121
|
+
x: parsed.x,
|
|
122
|
+
y: parsed.y,
|
|
123
|
+
z: parsed.z,
|
|
124
|
+
voxelType: parsed.voxelType,
|
|
125
|
+
state: parsed.state,
|
|
126
|
+
});
|
|
127
|
+
return { ok };
|
|
128
|
+
}
|
|
129
|
+
case 'pointer_clicks': {
|
|
130
|
+
if (!input) throw new HostCallRefusedError(fn);
|
|
131
|
+
return input.drainPointerClicks();
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
throw new HostCallRefusedError(fn);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function intInRange(value: unknown, min: number, max: number): number | null {
|
|
139
|
+
const n =
|
|
140
|
+
typeof value === 'number'
|
|
141
|
+
? value
|
|
142
|
+
: typeof value === 'string' && /^-?\d+$/.test(value)
|
|
143
|
+
? Number(value)
|
|
144
|
+
: typeof value === 'bigint'
|
|
145
|
+
? Number(value)
|
|
146
|
+
: NaN;
|
|
147
|
+
if (!Number.isInteger(n) || n < min || n > max) return null;
|
|
148
|
+
return n;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function coordField(record: Record<string, unknown>, ...keys: string[]): unknown {
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
if (record[key] !== undefined) return record[key];
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function asCoord3(value: unknown): { x: unknown; y: unknown; z: unknown } | null {
|
|
159
|
+
if (Array.isArray(value) && value.length >= 3) {
|
|
160
|
+
return { x: value[0], y: value[1], z: value[2] };
|
|
161
|
+
}
|
|
162
|
+
if (value && typeof value === 'object') {
|
|
163
|
+
const rec = value as Record<string, unknown>;
|
|
164
|
+
return { x: rec.x, y: rec.y, z: rec.z };
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Accept the SDK tuple shape and the flattened host-call JSON the broker clamps. */
|
|
170
|
+
export function parseVoxelSetArgs(args: Record<string, unknown>): {
|
|
171
|
+
chunk: { x: number; y: number; z: number };
|
|
172
|
+
x: number;
|
|
173
|
+
y: number;
|
|
174
|
+
z: number;
|
|
175
|
+
voxelType: number;
|
|
176
|
+
state: string;
|
|
177
|
+
} | null {
|
|
178
|
+
const chunkRaw =
|
|
179
|
+
asCoord3(args.chunk) ??
|
|
180
|
+
({
|
|
181
|
+
x: coordField(args, 'chunkX', 'chunk_x'),
|
|
182
|
+
y: coordField(args, 'chunkY', 'chunk_y'),
|
|
183
|
+
z: coordField(args, 'chunkZ', 'chunk_z'),
|
|
184
|
+
} as { x: unknown; y: unknown; z: unknown });
|
|
185
|
+
const voxelRaw =
|
|
186
|
+
asCoord3(args.voxel) ??
|
|
187
|
+
({
|
|
188
|
+
x: coordField(args, 'x', 'vx'),
|
|
189
|
+
y: coordField(args, 'y', 'vy'),
|
|
190
|
+
z: coordField(args, 'z', 'vz'),
|
|
191
|
+
} as { x: unknown; y: unknown; z: unknown });
|
|
192
|
+
const cx = intInRange(chunkRaw.x, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
193
|
+
const cy = intInRange(chunkRaw.y, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
194
|
+
const cz = intInRange(chunkRaw.z, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
195
|
+
const x = intInRange(voxelRaw.x, 0, 15);
|
|
196
|
+
const y = intInRange(voxelRaw.y, 0, 15);
|
|
197
|
+
const z = intInRange(voxelRaw.z, 0, 15);
|
|
198
|
+
const voxelType = intInRange(coordField(args, 'voxelType', 'voxel_type', 'type'), 0, 255);
|
|
199
|
+
if (
|
|
200
|
+
cx === null ||
|
|
201
|
+
cy === null ||
|
|
202
|
+
cz === null ||
|
|
203
|
+
x === null ||
|
|
204
|
+
y === null ||
|
|
205
|
+
z === null ||
|
|
206
|
+
voxelType === null
|
|
207
|
+
) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const stateRaw = coordField(args, 'state', 'state_base64', 'stateBase64');
|
|
211
|
+
const state =
|
|
212
|
+
typeof stateRaw === 'string' && stateRaw.length > 0 ? stateRaw : DEFAULT_VOXEL_STATE;
|
|
213
|
+
return { chunk: { x: cx, y: cy, z: cz }, x, y, z, voxelType, state };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Sparse `{x,y,z,voxelType}` rows for the non-zero cells of a dense grid. */
|
|
217
|
+
export function voxelsFromBase64(
|
|
218
|
+
voxelsBase64: string | null,
|
|
219
|
+
): Array<{ x: number; y: number; z: number; voxelType: number }> {
|
|
220
|
+
if (!voxelsBase64) return [];
|
|
221
|
+
const binary = atob(voxelsBase64);
|
|
222
|
+
const out: Array<{ x: number; y: number; z: number; voxelType: number }> = [];
|
|
223
|
+
for (let i = 0; i < binary.length && i < 4096; i++) {
|
|
224
|
+
const type = binary.charCodeAt(i);
|
|
225
|
+
if (type === 0) continue;
|
|
226
|
+
out.push({ x: i & 15, y: (i >> 4) & 15, z: (i >> 8) & 15, voxelType: type });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
232
|
+
let binary = '';
|
|
233
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
|
|
234
|
+
return btoa(binary);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface CachedArtifact {
|
|
238
|
+
bytes: Uint8Array;
|
|
239
|
+
artifactHash?: string;
|
|
240
|
+
fuelPerDispatch?: string | bigint;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const artifactCache = new Map<string, CachedArtifact>();
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Fetch a consented grid-attached client mod's artifact and run it in a
|
|
247
|
+
* hash-verified broker with the same allowlist, grid clamp, rate caps and
|
|
248
|
+
* circuit breaker as the Studio's own Test/Run path. Its HUD output lands in
|
|
249
|
+
* the persistent text HUD. Returns null (fails closed) when the artifact
|
|
250
|
+
* cannot be fetched.
|
|
251
|
+
*/
|
|
252
|
+
export async function runConsentedGridMod(options: {
|
|
253
|
+
client: CrowdyClient;
|
|
254
|
+
appId: string;
|
|
255
|
+
attachmentId: string;
|
|
256
|
+
artifactCacheKey: string;
|
|
257
|
+
hudSource: string;
|
|
258
|
+
hudLabel: string;
|
|
259
|
+
/** The module's name, which addressed grid events target (default `hudLabel`). */
|
|
260
|
+
moduleName?: string;
|
|
261
|
+
grid: PlayerCodeGridBounds;
|
|
262
|
+
workerUrl: string;
|
|
263
|
+
reads: ClientModHostReads;
|
|
264
|
+
writes?: ClientModHostWrites;
|
|
265
|
+
hud: CrowdyStudioTextHud;
|
|
266
|
+
overlay?: ModOverlayStore;
|
|
267
|
+
input?: ClientModHostInput;
|
|
268
|
+
/**
|
|
269
|
+
* Answers the host calls this game does not route itself (DN-10: the rest
|
|
270
|
+
* of the client catalog, through CrowdyJS `createGridHostCalls`).
|
|
271
|
+
*/
|
|
272
|
+
serverCalls?: (call: PlayerCodeHostCall) => Promise<unknown>;
|
|
273
|
+
/**
|
|
274
|
+
* How often the worker self-drives `on_tick`. Omitted/0 means invoke-only
|
|
275
|
+
* and a HUD mod then never runs (measured 2026-09-07: broker started, HUD
|
|
276
|
+
* stayed empty). Studio Test/Deploy reads `[package.metadata.crowdy]
|
|
277
|
+
* tick_interval_ms` from the CLIENT Cargo.toml (default 1000, clamped
|
|
278
|
+
* 16–1000). Pass this only for grid-attached visitors until that field is
|
|
279
|
+
* stored on the compiled version.
|
|
280
|
+
*/
|
|
281
|
+
tickIntervalMs?: number;
|
|
282
|
+
}): Promise<{ stop: () => void } | null> {
|
|
283
|
+
let fetched: { bytes: ArrayBuffer; artifactHash?: string; fuelPerDispatch?: string | bigint };
|
|
284
|
+
const cached = artifactCache.get(options.artifactCacheKey);
|
|
285
|
+
if (cached) {
|
|
286
|
+
fetched = {
|
|
287
|
+
bytes: cached.bytes.slice().buffer,
|
|
288
|
+
artifactHash: cached.artifactHash,
|
|
289
|
+
fuelPerDispatch: cached.fuelPerDispatch,
|
|
290
|
+
};
|
|
291
|
+
} else {
|
|
292
|
+
try {
|
|
293
|
+
fetched = await options.client.marketplace.clientArtifactBytes({
|
|
294
|
+
appId: options.appId,
|
|
295
|
+
attachmentId: options.attachmentId,
|
|
296
|
+
});
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
artifactCache.set(options.artifactCacheKey, {
|
|
301
|
+
bytes: new Uint8Array(fetched.bytes).slice(),
|
|
302
|
+
artifactHash: fetched.artifactHash,
|
|
303
|
+
fuelPerDispatch: fetched.fuelPerDispatch,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const broker = new PlayerCodeBroker({
|
|
307
|
+
workerUrl: options.workerUrl,
|
|
308
|
+
grid: options.grid,
|
|
309
|
+
artifactHash: fetched.artifactHash,
|
|
310
|
+
fuelPerDispatch: fetched.fuelPerDispatch != null ? BigInt(fetched.fuelPerDispatch) : undefined,
|
|
311
|
+
tickIntervalMs: options.tickIntervalMs ?? 1000,
|
|
312
|
+
moduleName: options.moduleName ?? options.hudLabel,
|
|
313
|
+
onHostCall: (call) =>
|
|
314
|
+
routeWithFallback(
|
|
315
|
+
call,
|
|
316
|
+
() => routeClientHostCall(call, options.reads, options.grid, options.writes, options.input),
|
|
317
|
+
options.serverCalls,
|
|
318
|
+
),
|
|
319
|
+
onPresentation: (presentation: PlayerCodePresentation) => {
|
|
320
|
+
if (presentation.channel === 'hud') {
|
|
321
|
+
options.hud.set({
|
|
322
|
+
source: options.hudSource,
|
|
323
|
+
label: options.hudLabel,
|
|
324
|
+
payload: presentation.payload,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (presentation.channel === 'overlay') {
|
|
328
|
+
options.overlay?.apply(options.hudSource, presentation.payload, options.grid);
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
onCircuitOpen: () => {
|
|
332
|
+
options.hud.remove(options.hudSource);
|
|
333
|
+
options.overlay?.remove(options.hudSource);
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
await broker.start(fetched.bytes);
|
|
337
|
+
return {
|
|
338
|
+
stop: () => {
|
|
339
|
+
broker.stop();
|
|
340
|
+
options.hud.remove(options.hudSource);
|
|
341
|
+
options.overlay?.remove(options.hudSource);
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The game's own router first (local reads, optimistic writes); anything it
|
|
348
|
+
* does not offer goes to `serverCalls` when the game supplied it.
|
|
349
|
+
*/
|
|
350
|
+
export async function routeWithFallback(
|
|
351
|
+
call: PlayerCodeHostCall,
|
|
352
|
+
local: () => Promise<unknown>,
|
|
353
|
+
serverCalls?: (call: PlayerCodeHostCall) => Promise<unknown>,
|
|
354
|
+
): Promise<unknown> {
|
|
355
|
+
try {
|
|
356
|
+
return await local();
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error instanceof HostCallRefusedError && serverCalls) return serverCalls(call);
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function minBig(a: bigint, b: bigint): bigint {
|
|
364
|
+
return a < b ? a : b;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function maxBig(a: bigint, b: bigint): bigint {
|
|
368
|
+
return a > b ? a : b;
|
|
369
|
+
}
|