@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,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crowdy Studio, embedded — the in-game IDE where players write SERVER and
|
|
3
|
+
* CLIENT Rust mods for the grid they stand on. A project's SERVER target is a
|
|
4
|
+
* ck-exec mod (`serverEngine: 'ck-exec'`): a `ckx-sdk` crate the platform
|
|
5
|
+
* builds, deploys to the grid and switches on, which players in the grid call
|
|
6
|
+
* as `mod:<name>`. The CLIENT target runs in the browser sandbox as before.
|
|
7
|
+
*
|
|
8
|
+
* The SDK's embed kit owns the chrome (dock, fullscreen fallback, Context
|
|
9
|
+
* drawer, focus trap, HUD sink). This service supplies what only the game
|
|
10
|
+
* knows:
|
|
11
|
+
* - the grid the player is on and their effective permissions on it,
|
|
12
|
+
* - the same-origin glue worker that runs CLIENT mods,
|
|
13
|
+
* - the allowlisted host-call router (what a mod may read),
|
|
14
|
+
* - input suppression and layout hooks,
|
|
15
|
+
* - the visitor lifecycle: when you stand on someone else's grid, their
|
|
16
|
+
* trusted CLIENT mods run for you after you approve them, and
|
|
17
|
+
* - the Agentic Crowdy Studio host (Ask / Build / Play) when the platform
|
|
18
|
+
* policy and `use_studio_agent` are armed.
|
|
19
|
+
*
|
|
20
|
+
* CLIENT mods need `crossOriginIsolated` (COOP + COEP headers on the host). The
|
|
21
|
+
* service checks that at construction and hides the CLIENT half when it is
|
|
22
|
+
* missing, saying so in the HUD, rather than letting a worker fail silently.
|
|
23
|
+
*/
|
|
24
|
+
import { createGridHostCalls, type PlayerCodeHostCall } from '@crowdedkingdoms/crowdyjs';
|
|
25
|
+
import {
|
|
26
|
+
CrowdyStudioEmbed,
|
|
27
|
+
CrowdyStudioTextHud,
|
|
28
|
+
type CrowdyStudioEmbedContext,
|
|
29
|
+
} from '@crowdedkingdoms/crowdyjs/crowdy-studio';
|
|
30
|
+
import glueWorkerAssetUrl from '@crowdedkingdoms/crowdyjs/player-glue-worker?worker&url';
|
|
31
|
+
|
|
32
|
+
import { API_HTTP_URL, API_WS_URL, CLIENT_MODS_ENABLED, GAME_NAME, STUDIO_ORIGIN } from '../config';
|
|
33
|
+
import { GridProgramRunner } from '../../grid/GridProgramRunner';
|
|
34
|
+
import type { GameSession } from '../GameSession';
|
|
35
|
+
import { messageOf } from '../network/NetworkManager';
|
|
36
|
+
import { isTextEntry } from '../../engine/Input';
|
|
37
|
+
import { chunkKey, worldToChunk, type ChunkCoord, type Vec3 } from '../realtime/space';
|
|
38
|
+
import { AgentLocomotion } from './agentLocomotion';
|
|
39
|
+
import { ClientModLifecycle, type ClientModScope } from './ClientModLifecycle';
|
|
40
|
+
import { ConstructPlayerHostAdapter } from './ConstructPlayerHostAdapter';
|
|
41
|
+
import { HumanInputMonitor } from './HumanInputMonitor';
|
|
42
|
+
import {
|
|
43
|
+
bytesToBase64,
|
|
44
|
+
DEFAULT_VOXEL_STATE,
|
|
45
|
+
routeClientHostCall,
|
|
46
|
+
routeWithFallback,
|
|
47
|
+
runConsentedGridMod,
|
|
48
|
+
type ClientModHostReads,
|
|
49
|
+
type ClientModHostWrites,
|
|
50
|
+
} from './clientModHost';
|
|
51
|
+
import { PointerClickBuffer } from './pointerClicks';
|
|
52
|
+
import { ModOverlayStore } from './modOverlay';
|
|
53
|
+
import { GridService, type GridSnapshot } from './GridService';
|
|
54
|
+
import { hasAnyStudioPermission, toBrokerBounds } from './permissions';
|
|
55
|
+
import { Emitter } from '../util/Emitter';
|
|
56
|
+
|
|
57
|
+
export interface StudioState {
|
|
58
|
+
open: boolean;
|
|
59
|
+
grid: GridSnapshot | null;
|
|
60
|
+
/** COOP/COEP present and the build flag on. */
|
|
61
|
+
clientModsAvailable: boolean;
|
|
62
|
+
clientModsRunning: number;
|
|
63
|
+
/** Why CLIENT mods are unavailable, when they are. */
|
|
64
|
+
clientModsReason: string | null;
|
|
65
|
+
/** Agent dock mounted (policy + permission + host). */
|
|
66
|
+
agentReady: boolean;
|
|
67
|
+
/** Why the agent dock is hidden or dead, when it is. */
|
|
68
|
+
agentReason: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface StudioHooks {
|
|
72
|
+
suppressGameplayInput(): () => void;
|
|
73
|
+
onLayoutChange(rightInsetPx: number): void;
|
|
74
|
+
notify(text: string, tone?: 'info' | 'warn' | 'error'): void;
|
|
75
|
+
/** Ask the player whether to trust an author's mods; defaults to `confirm`. */
|
|
76
|
+
confirmTrust?(summary: string): Promise<boolean>;
|
|
77
|
+
/** Screenshot hook for the agent pane. */
|
|
78
|
+
captureFrame?(): Promise<HTMLCanvasElement | ImageBitmap | Blob | null>;
|
|
79
|
+
describeView?(): string | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* CrowdyJS TextHud JSON.stringifies payloads and clips them at 200 characters,
|
|
84
|
+
* which turns an ASCII table into a one-line `{greeting:"BILL…`. Prefer a
|
|
85
|
+
* `greeting` string (still textContent, never HTML) and do not clip it.
|
|
86
|
+
*/
|
|
87
|
+
function createConstructTextHud(): CrowdyStudioTextHud {
|
|
88
|
+
const hud = new CrowdyStudioTextHud();
|
|
89
|
+
Object.assign(hud, {
|
|
90
|
+
describe(payload: unknown) {
|
|
91
|
+
if (typeof payload === 'string') return payload;
|
|
92
|
+
if (payload && typeof payload === 'object') {
|
|
93
|
+
const greeting = (payload as { greeting?: unknown }).greeting;
|
|
94
|
+
if (typeof greeting === 'string' && greeting.length > 0) return greeting;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
return JSON.stringify(payload);
|
|
98
|
+
} catch {
|
|
99
|
+
return '(unrenderable payload)';
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
return hud;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const GRID_REFRESH_MS = 8_000;
|
|
107
|
+
const MODS_REFRESH_MS = 10_000;
|
|
108
|
+
/**
|
|
109
|
+
* Presence is written by Buddy when the actor's chunk changes; the trust and
|
|
110
|
+
* artifact gates check it server-side. Asking within the first seconds of
|
|
111
|
+
* entering a grid races that write ("Grid author bundle not found" on
|
|
112
|
+
* 2026-09-07), so wait a moment before the first sync.
|
|
113
|
+
*/
|
|
114
|
+
const GRID_SETTLE_MS = 4_000;
|
|
115
|
+
|
|
116
|
+
export class StudioService {
|
|
117
|
+
readonly events = new Emitter<{ state: StudioState }>();
|
|
118
|
+
readonly grids: GridService;
|
|
119
|
+
/** Play locomotion wishes; HolodeckScene samples these each frame. */
|
|
120
|
+
readonly locomotion = new AgentLocomotion();
|
|
121
|
+
/** CLIENT overlay_draw gizmos; the holodeck merges these onto instances. */
|
|
122
|
+
readonly overlay = new ModOverlayStore();
|
|
123
|
+
private readonly hud = createConstructTextHud();
|
|
124
|
+
private readonly lifecycle = new ClientModLifecycle();
|
|
125
|
+
private readonly declinedAuthors = new Set<string>();
|
|
126
|
+
private readonly approvedAuthors = new Set<string>();
|
|
127
|
+
private readonly agentHost: ConstructPlayerHostAdapter;
|
|
128
|
+
private readonly humanInput = new HumanInputMonitor();
|
|
129
|
+
private readonly pointerClicks = new PointerClickBuffer();
|
|
130
|
+
/** JS grid programs the agent (or the player) runs on the current grid. */
|
|
131
|
+
readonly programs: GridProgramRunner;
|
|
132
|
+
private gridEnteredAt = 0;
|
|
133
|
+
private embed: CrowdyStudioEmbed | null = null;
|
|
134
|
+
private hooks: StudioHooks | null = null;
|
|
135
|
+
private state: StudioState;
|
|
136
|
+
private currentGrid: GridSnapshot | null = null;
|
|
137
|
+
private lastGridChunkKey: string | null = null;
|
|
138
|
+
private lastGridReadAt = 0;
|
|
139
|
+
private lastModsReadAt = 0;
|
|
140
|
+
private modsInFlight = false;
|
|
141
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
142
|
+
|
|
143
|
+
constructor(private readonly session: GameSession) {
|
|
144
|
+
this.grids = new GridService(session.network);
|
|
145
|
+
this.programs = new GridProgramRunner({
|
|
146
|
+
client: () => this.session.network.game,
|
|
147
|
+
appId: () => this.session.appId,
|
|
148
|
+
graphqlUrl: `${API_HTTP_URL}/graphql`,
|
|
149
|
+
graphqlWsUrl: `${API_WS_URL}/graphql`,
|
|
150
|
+
sandboxUrl: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}grid-program.html`,
|
|
151
|
+
owns: (gridId) => this.currentGrid?.gridId === gridId && this.currentGrid.owned,
|
|
152
|
+
});
|
|
153
|
+
this.agentHost = new ConstructPlayerHostAdapter({
|
|
154
|
+
frame: () => this.observationFrame(),
|
|
155
|
+
locomotion: this.locomotion,
|
|
156
|
+
sendChat: (text) => this.session.chat.send(text),
|
|
157
|
+
});
|
|
158
|
+
const isolated = typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;
|
|
159
|
+
this.state = {
|
|
160
|
+
open: false,
|
|
161
|
+
grid: null,
|
|
162
|
+
clientModsAvailable: CLIENT_MODS_ENABLED && isolated,
|
|
163
|
+
clientModsRunning: 0,
|
|
164
|
+
clientModsReason: !CLIENT_MODS_ENABLED
|
|
165
|
+
? 'CLIENT mods are disabled in this build (VITE_CONSTRUCT_CLIENT_MODS=0).'
|
|
166
|
+
: isolated
|
|
167
|
+
? null
|
|
168
|
+
: 'CLIENT mods are off: this page is not cross-origin isolated. The host must send ' +
|
|
169
|
+
'Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless ' +
|
|
170
|
+
'(see docs/HOSTING.md).',
|
|
171
|
+
agentReady: false,
|
|
172
|
+
agentReason: null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Grid the local player is standing on, if known. */
|
|
177
|
+
get grid(): GridSnapshot | null {
|
|
178
|
+
return this.currentGrid;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Wire the engine-side hooks; call once after the loop exists. */
|
|
182
|
+
attach(hooks: StudioHooks): void {
|
|
183
|
+
this.hooks = hooks;
|
|
184
|
+
const network = () => this.session.network;
|
|
185
|
+
this.embed = new CrowdyStudioEmbed({
|
|
186
|
+
// Resolved lazily: the game client exists only after enterApp, and is
|
|
187
|
+
// rebuilt if the player switches apps.
|
|
188
|
+
client: {
|
|
189
|
+
get crowdyStudio() {
|
|
190
|
+
return network().game.crowdyStudio;
|
|
191
|
+
},
|
|
192
|
+
// Still the CLIENT target's compile and artifact service.
|
|
193
|
+
get playerCompute() {
|
|
194
|
+
return network().game.playerCompute;
|
|
195
|
+
},
|
|
196
|
+
// The SERVER target's mod builds, deploys and switch (`serverEngine` below).
|
|
197
|
+
get exec() {
|
|
198
|
+
return network().game.exec;
|
|
199
|
+
},
|
|
200
|
+
get playerWallet() {
|
|
201
|
+
return network().game.playerWallet;
|
|
202
|
+
},
|
|
203
|
+
// The GitHub card rides the game's app token on purpose: the API
|
|
204
|
+
// scopes every crowdyStudioGitHub* working-tree field to projects
|
|
205
|
+
// this token's user owns, so no identity session is needed here
|
|
206
|
+
// (and this page never holds one — it signs in through hosted
|
|
207
|
+
// /authorize). Connect / Bind / Unbind are identity-only and happen
|
|
208
|
+
// in hosted Studio. GitHub is optional; a project saves in Studio
|
|
209
|
+
// either way.
|
|
210
|
+
get crowdyStudioGitHub() {
|
|
211
|
+
return network().game.crowdyStudioGitHub;
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
appId: () => this.session.appId,
|
|
215
|
+
gameName: GAME_NAME,
|
|
216
|
+
// A mod has no CLIENT pairing, so ck-api attaches no CLIENT half of such a project for
|
|
217
|
+
// visitors (docs/MODDING.md); the author still runs it in their own tab.
|
|
218
|
+
serverEngine: 'ck-exec',
|
|
219
|
+
closeKeyCode: 'KeyM',
|
|
220
|
+
dsh: {
|
|
221
|
+
graphql: network().game.graphql,
|
|
222
|
+
// Under Vite's base, so a build served from a sub-path (`vite build
|
|
223
|
+
// --base /the-construct/`) finds its own harness instead of the
|
|
224
|
+
// origin's root. The default base is '/', which keeps '/dsh/'.
|
|
225
|
+
webBase: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}dsh/`,
|
|
226
|
+
graphqlUrl: `${API_HTTP_URL}/graphql`,
|
|
227
|
+
apiOrigin: API_HTTP_URL,
|
|
228
|
+
getToken: () => network().game.getToken(),
|
|
229
|
+
persistScope: `${this.session.appId}/${this.session.selfUuid}`,
|
|
230
|
+
studioOrigin: STUDIO_ORIGIN ?? undefined,
|
|
231
|
+
openOnMount: true,
|
|
232
|
+
},
|
|
233
|
+
onAgentMounted: (_handle) => {
|
|
234
|
+
this.state = { ...this.state, agentReady: true, agentReason: null };
|
|
235
|
+
this.emit();
|
|
236
|
+
},
|
|
237
|
+
onAgentUnavailable: (message) => {
|
|
238
|
+
this.state = { ...this.state, agentReady: false, agentReason: message };
|
|
239
|
+
this.emit();
|
|
240
|
+
},
|
|
241
|
+
onAgentUnmounted: () => {
|
|
242
|
+
this.state = { ...this.state, agentReady: false, agentReason: null };
|
|
243
|
+
this.emit();
|
|
244
|
+
},
|
|
245
|
+
suppressGameplayInput: () => hooks.suppressGameplayInput(),
|
|
246
|
+
onLayoutChange: () => hooks.onLayoutChange(this.readRightInset()),
|
|
247
|
+
onClosed: () => {
|
|
248
|
+
if (this.currentGrid) {
|
|
249
|
+
const source = `studio:${this.currentGrid.gridId}`;
|
|
250
|
+
this.overlay.remove(source);
|
|
251
|
+
this.hud.remove(source);
|
|
252
|
+
}
|
|
253
|
+
this.setOpen(false);
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
if (!this.timer) this.timer = setInterval(() => void this.poll(), 2000);
|
|
257
|
+
this.pointerClicks.attach();
|
|
258
|
+
this.emit();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
get snapshot(): StudioState {
|
|
262
|
+
return this.state;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
get isOpen(): boolean {
|
|
266
|
+
return this.embed?.open ?? false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Claim the chunk under `position`, then open the studio on it. */
|
|
270
|
+
async claimHereAndOpen(position: Vec3): Promise<GridSnapshot> {
|
|
271
|
+
const chunk = worldToChunk(position);
|
|
272
|
+
this.hooks?.notify('Claiming this chunk…');
|
|
273
|
+
const grid = await this.grids.claimHere(chunk, this.session.displayName);
|
|
274
|
+
this.adoptGrid(grid, chunk);
|
|
275
|
+
if (!hasAnyStudioPermission(grid.permissions)) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Claimed grid ${grid.gridId}, but your access tier grants no code permissions. ` +
|
|
278
|
+
'Re-run Setup (it creates a Constructor tier with the code keys) or grant them in CK Studio.',
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
this.hooks?.notify(`Claimed grid ${grid.gridId}. Opening Crowdy Studio…`);
|
|
282
|
+
this.openOn(grid);
|
|
283
|
+
return grid;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** M key: toggle the studio on the grid under the player, if permitted. */
|
|
287
|
+
async toggle(position: Vec3): Promise<void> {
|
|
288
|
+
if (!this.embed) return;
|
|
289
|
+
if (this.embed.open) {
|
|
290
|
+
this.embed.close();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const chunk = worldToChunk(position);
|
|
294
|
+
let grid =
|
|
295
|
+
this.currentGrid && chunkKey(chunk) === this.lastGridChunkKey ? this.currentGrid : null;
|
|
296
|
+
if (!grid) {
|
|
297
|
+
grid = await this.grids.lookup(chunk);
|
|
298
|
+
this.adoptGrid(grid, chunk);
|
|
299
|
+
}
|
|
300
|
+
if (!grid || !hasAnyStudioPermission(grid.permissions)) {
|
|
301
|
+
this.hooks?.notify(
|
|
302
|
+
grid
|
|
303
|
+
? 'You have no code permission on this grid. Stand on your own claimed chunk.'
|
|
304
|
+
: 'Stand on a claimed chunk (use the Claim pad) to open Crowdy Studio.',
|
|
305
|
+
'warn',
|
|
306
|
+
);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
this.openOn(grid);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
close(): void {
|
|
313
|
+
this.embed?.close();
|
|
314
|
+
if (this.currentGrid) {
|
|
315
|
+
const source = `studio:${this.currentGrid.gridId}`;
|
|
316
|
+
this.overlay.remove(source);
|
|
317
|
+
this.hud.remove(source);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
dispose(): void {
|
|
322
|
+
if (this.timer) clearInterval(this.timer);
|
|
323
|
+
this.timer = null;
|
|
324
|
+
this.lifecycle.shutdown();
|
|
325
|
+
this.programs.stopAll();
|
|
326
|
+
this.embed?.destroy();
|
|
327
|
+
this.embed = null;
|
|
328
|
+
this.locomotion.clear();
|
|
329
|
+
this.humanInput.dispose();
|
|
330
|
+
this.pointerClicks.dispose();
|
|
331
|
+
this.hud.destroy();
|
|
332
|
+
this.overlay.clear();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
private openOn(grid: GridSnapshot): void {
|
|
338
|
+
if (!this.embed) throw new Error('Studio not attached');
|
|
339
|
+
this.embed.toggle(this.contextFor(grid));
|
|
340
|
+
this.setOpen(true);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private contextFor(grid: GridSnapshot): CrowdyStudioEmbedContext {
|
|
344
|
+
const bounds = toBrokerBounds(grid.bounds, grid.gridId);
|
|
345
|
+
const clientOk = this.state.clientModsAvailable;
|
|
346
|
+
const reads = this.reads();
|
|
347
|
+
const writes = this.writes();
|
|
348
|
+
const serverCalls = this.serverCallsFor(grid);
|
|
349
|
+
const overlaySource = `studio:${grid.gridId}`;
|
|
350
|
+
return {
|
|
351
|
+
gridId: grid.gridId,
|
|
352
|
+
grid: bounds,
|
|
353
|
+
targetPermissions: {
|
|
354
|
+
SERVER: grid.permissions.server,
|
|
355
|
+
// Keys say what you MAY do; isolation says what the browser CAN do.
|
|
356
|
+
CLIENT: clientOk
|
|
357
|
+
? grid.permissions.client
|
|
358
|
+
: { canWrite: grid.permissions.client.canWrite, canRun: false },
|
|
359
|
+
},
|
|
360
|
+
permissionsNote: clientOk
|
|
361
|
+
? 'Authoritative effective grid keys'
|
|
362
|
+
: `Authoritative effective grid keys. ${this.state.clientModsReason ?? ''}`.trim(),
|
|
363
|
+
...(clientOk
|
|
364
|
+
? {
|
|
365
|
+
workerUrl: glueWorkerAssetUrl,
|
|
366
|
+
onHostCall: (call: PlayerCodeHostCall) =>
|
|
367
|
+
routeWithFallback(
|
|
368
|
+
call,
|
|
369
|
+
() => routeClientHostCall(call, reads, bounds, writes, this.pointerClicks),
|
|
370
|
+
serverCalls,
|
|
371
|
+
),
|
|
372
|
+
onPresentation: (presentation) => {
|
|
373
|
+
if (presentation.channel === 'hud') {
|
|
374
|
+
this.hud.set({
|
|
375
|
+
source: overlaySource,
|
|
376
|
+
label: 'Crowdy Studio preview',
|
|
377
|
+
payload: presentation.payload,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (presentation.channel === 'overlay') {
|
|
381
|
+
this.overlay.apply(overlaySource, presentation.payload, bounds);
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
hud: this.hud,
|
|
385
|
+
}
|
|
386
|
+
: {}),
|
|
387
|
+
playerHost: this.agentHost,
|
|
388
|
+
dshHost: {
|
|
389
|
+
captureFrame: async () => (await this.hooks?.captureFrame?.()) ?? null,
|
|
390
|
+
describeView: () => this.hooks?.describeView?.(),
|
|
391
|
+
grid: this.programs,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The rest of the CLIENT host catalog for a mod on `grid` (DN-10): channels,
|
|
398
|
+
* the player-tier model, sessions and user state through CrowdyJS, confined
|
|
399
|
+
* to the grid. The local router still answers world reads and voxel writes.
|
|
400
|
+
*/
|
|
401
|
+
private serverCallsFor(grid: GridSnapshot): (call: PlayerCodeHostCall) => Promise<unknown> {
|
|
402
|
+
const game = this.session.network.game;
|
|
403
|
+
const scope = game.grid(this.session.appId, grid.gridId, toBrokerBounds(grid.bounds));
|
|
404
|
+
return createGridHostCalls({ scope, client: game });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private observationFrame() {
|
|
408
|
+
const pose = this.session.joined ? this.session.world.self.state : null;
|
|
409
|
+
const nearby = this.session.players().map((player) => ({
|
|
410
|
+
actorId: player.uuid,
|
|
411
|
+
position: { x: player.pose.x, y: player.pose.y, z: player.pose.z },
|
|
412
|
+
label: player.pose.name || undefined,
|
|
413
|
+
}));
|
|
414
|
+
return {
|
|
415
|
+
playerId: this.session.joined ? this.session.selfUuid : 'local',
|
|
416
|
+
position: pose ? { x: pose.x, y: pose.y, z: pose.z } : { x: 0, y: 0, z: 0 },
|
|
417
|
+
velocity: pose ? { x: pose.vx, y: pose.vy, z: pose.vz } : { x: 0, y: 0, z: 0 },
|
|
418
|
+
yaw: pose?.yaw ?? 0,
|
|
419
|
+
pitch: pose?.pitch ?? 0,
|
|
420
|
+
grid: this.currentGrid,
|
|
421
|
+
nearbyActors: nearby,
|
|
422
|
+
humanInputActive: this.humanInput.active(),
|
|
423
|
+
textInputFocused: isTextEntry(
|
|
424
|
+
typeof document === 'undefined' ? null : document.activeElement,
|
|
425
|
+
),
|
|
426
|
+
modalOpen: this.embed?.modal ?? false,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* What a CLIENT mod may read: players in a chunk (from the replicated lane
|
|
432
|
+
* plus ourselves) and the cached voxel grid of a chunk. Nothing else.
|
|
433
|
+
*/
|
|
434
|
+
private reads(): ClientModHostReads {
|
|
435
|
+
return {
|
|
436
|
+
actorsInChunk: (x, y, z) => {
|
|
437
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
438
|
+
const world = this.session.world;
|
|
439
|
+
const selfChunk = world.self.chunk;
|
|
440
|
+
if (
|
|
441
|
+
selfChunk &&
|
|
442
|
+
BigInt(selfChunk.x) === x &&
|
|
443
|
+
BigInt(selfChunk.y) === y &&
|
|
444
|
+
BigInt(selfChunk.z) === z
|
|
445
|
+
) {
|
|
446
|
+
const s = world.self.state;
|
|
447
|
+
rows.push({
|
|
448
|
+
uuid: world.self.uuid,
|
|
449
|
+
self: true,
|
|
450
|
+
name: s.name,
|
|
451
|
+
x: s.x,
|
|
452
|
+
y: s.y,
|
|
453
|
+
z: s.z,
|
|
454
|
+
program: s.program,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
for (const p of this.session.players()) {
|
|
458
|
+
if (BigInt(p.chunk.x) === x && BigInt(p.chunk.y) === y && BigInt(p.chunk.z) === z) {
|
|
459
|
+
rows.push({
|
|
460
|
+
uuid: p.uuid,
|
|
461
|
+
name: p.pose.name,
|
|
462
|
+
x: p.pose.x,
|
|
463
|
+
y: p.pose.y,
|
|
464
|
+
z: p.pose.z,
|
|
465
|
+
program: p.pose.program,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return rows;
|
|
470
|
+
},
|
|
471
|
+
chunkVoxels: (x, y, z) => {
|
|
472
|
+
const cached = this.session.world.chunks.get({ x: Number(x), y: Number(y), z: Number(z) });
|
|
473
|
+
if (!cached?.voxels) return { voxelsBase64: null };
|
|
474
|
+
return { voxelsBase64: bytesToBase64(cached.voxels) };
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private writes(): ClientModHostWrites {
|
|
480
|
+
return {
|
|
481
|
+
setVoxel: async (input) => {
|
|
482
|
+
const chunks = this.session.world.chunks;
|
|
483
|
+
const ok = await chunks.setVoxel({
|
|
484
|
+
chunk: input.chunk,
|
|
485
|
+
x: input.x,
|
|
486
|
+
y: input.y,
|
|
487
|
+
z: input.z,
|
|
488
|
+
voxelType: input.voxelType,
|
|
489
|
+
state: input.state ?? DEFAULT_VOXEL_STATE,
|
|
490
|
+
});
|
|
491
|
+
if (ok) chunks.markDirty(input.chunk);
|
|
492
|
+
return ok;
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private adoptGrid(grid: GridSnapshot | null, chunk: ChunkCoord): void {
|
|
498
|
+
this.currentGrid = grid;
|
|
499
|
+
this.lastGridChunkKey = chunkKey(chunk);
|
|
500
|
+
this.lastGridReadAt = Date.now();
|
|
501
|
+
if (this.lifecycle.enterGrid(grid?.gridId ?? null)) {
|
|
502
|
+
this.declinedAuthors.clear();
|
|
503
|
+
this.approvedAuthors.clear();
|
|
504
|
+
this.lastModsReadAt = 0;
|
|
505
|
+
this.gridEnteredAt = Date.now();
|
|
506
|
+
this.overlay.clear();
|
|
507
|
+
}
|
|
508
|
+
this.state = { ...this.state, grid };
|
|
509
|
+
this.emit();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private setOpen(open: boolean): void {
|
|
513
|
+
if (this.state.open === open) return;
|
|
514
|
+
this.state = { ...this.state, open };
|
|
515
|
+
this.session.studioOpen = open;
|
|
516
|
+
this.emit();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private readRightInset(): number {
|
|
520
|
+
const raw = document.body.style.getPropertyValue('--ck-game-right-inset');
|
|
521
|
+
const px = Number.parseFloat(raw);
|
|
522
|
+
return Number.isFinite(px) ? px : 0;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Periodic: which grid are we on, and are there mods to run for us? */
|
|
526
|
+
private async poll(): Promise<void> {
|
|
527
|
+
if (!this.session.joined || !this.session.network.hasGame) return;
|
|
528
|
+
const selfChunk = this.session.world.self.chunk;
|
|
529
|
+
if (!selfChunk) return;
|
|
530
|
+
const chunk = { x: Number(selfChunk.x), y: Number(selfChunk.y), z: Number(selfChunk.z) };
|
|
531
|
+
const key = chunkKey(chunk);
|
|
532
|
+
const now = Date.now();
|
|
533
|
+
if (key !== this.lastGridChunkKey || now - this.lastGridReadAt > GRID_REFRESH_MS) {
|
|
534
|
+
try {
|
|
535
|
+
const grid = await this.grids.lookup(chunk);
|
|
536
|
+
this.adoptGrid(grid, chunk);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
this.session.network.log(`grid lookup failed: ${messageOf(error)}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (
|
|
542
|
+
this.state.clientModsAvailable &&
|
|
543
|
+
this.currentGrid &&
|
|
544
|
+
!this.currentGrid.owned &&
|
|
545
|
+
now - this.gridEnteredAt >= GRID_SETTLE_MS
|
|
546
|
+
) {
|
|
547
|
+
await this.syncGridClientMods(this.currentGrid);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Visitor path: fetch the grid's attached CLIENT mods, ask once per author
|
|
553
|
+
* whether to trust them, and run the trusted/consented ones. Re-run on a
|
|
554
|
+
* cadence so a hash change stops the old worker.
|
|
555
|
+
*/
|
|
556
|
+
private async syncGridClientMods(grid: GridSnapshot): Promise<void> {
|
|
557
|
+
const now = Date.now();
|
|
558
|
+
if (this.modsInFlight || now - this.lastModsReadAt < MODS_REFRESH_MS) return;
|
|
559
|
+
const scope = this.lifecycle.scope(grid.gridId);
|
|
560
|
+
if (!scope) return;
|
|
561
|
+
this.lastModsReadAt = now;
|
|
562
|
+
this.modsInFlight = true;
|
|
563
|
+
try {
|
|
564
|
+
const client = this.session.network.game;
|
|
565
|
+
const appId = this.session.appId;
|
|
566
|
+
let mods = await client.marketplace.gridClientMods({ appId, gridId: grid.gridId });
|
|
567
|
+
if (!this.lifecycle.reconcile(scope, mods.map(descriptorOf))) return;
|
|
568
|
+
|
|
569
|
+
let trustedAny = false;
|
|
570
|
+
for (const mod of mods) {
|
|
571
|
+
if (mod.callerTrustsAuthor) continue;
|
|
572
|
+
const authorKey = `${mod.authorKind}:${mod.authorRef}:${mod.authorCapabilityHash}`;
|
|
573
|
+
if (this.declinedAuthors.has(authorKey)) continue;
|
|
574
|
+
if (!this.approvedAuthors.has(authorKey)) {
|
|
575
|
+
const approved = await this.confirmTrust(
|
|
576
|
+
`Trust ${String(mod.authorKind).toLowerCase()} ${mod.authorRef}'s mods on this grid?\n\n` +
|
|
577
|
+
'Their client code will run in a sandbox while you are here.\n\n' +
|
|
578
|
+
prettyCapabilities(mod.authorCapabilitySummaryJson),
|
|
579
|
+
);
|
|
580
|
+
if (!approved) {
|
|
581
|
+
this.declinedAuthors.add(authorKey);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
// Remembered so a transient refusal (presence not registered yet)
|
|
585
|
+
// retries silently instead of asking the player again.
|
|
586
|
+
this.approvedAuthors.add(authorKey);
|
|
587
|
+
}
|
|
588
|
+
await client.marketplace.trustGridAuthor({
|
|
589
|
+
appId,
|
|
590
|
+
gridId: grid.gridId,
|
|
591
|
+
authorKind: mod.authorKind,
|
|
592
|
+
authorRef: mod.authorRef,
|
|
593
|
+
consentCapabilityHash: mod.authorCapabilityHash,
|
|
594
|
+
});
|
|
595
|
+
if (!this.lifecycle.isCurrent(scope)) return;
|
|
596
|
+
trustedAny = true;
|
|
597
|
+
}
|
|
598
|
+
if (trustedAny) {
|
|
599
|
+
mods = await client.marketplace.gridClientMods({ appId, gridId: grid.gridId });
|
|
600
|
+
if (!this.lifecycle.isCurrent(scope)) return;
|
|
601
|
+
}
|
|
602
|
+
for (const mod of mods) {
|
|
603
|
+
if (!this.lifecycle.isCurrent(scope)) return;
|
|
604
|
+
if (
|
|
605
|
+
(mod.callerTrustsAuthor || mod.callerConsented) &&
|
|
606
|
+
!this.lifecycle.has(mod.attachmentId)
|
|
607
|
+
) {
|
|
608
|
+
await this.runGridMod(mod, grid, scope);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
} catch (error) {
|
|
612
|
+
this.session.network.log(`grid client-mod refresh failed: ${messageOf(error)}`);
|
|
613
|
+
} finally {
|
|
614
|
+
this.modsInFlight = false;
|
|
615
|
+
this.state = { ...this.state, clientModsRunning: this.lifecycle.runningCount };
|
|
616
|
+
this.emit();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private async runGridMod(
|
|
621
|
+
mod: {
|
|
622
|
+
attachmentId: string;
|
|
623
|
+
clientArtifactHash: string;
|
|
624
|
+
capabilityHash: string;
|
|
625
|
+
listingName?: string | null;
|
|
626
|
+
},
|
|
627
|
+
grid: GridSnapshot,
|
|
628
|
+
scope: ClientModScope,
|
|
629
|
+
): Promise<void> {
|
|
630
|
+
const handle = await runConsentedGridMod({
|
|
631
|
+
client: this.session.network.game,
|
|
632
|
+
appId: this.session.appId,
|
|
633
|
+
attachmentId: mod.attachmentId,
|
|
634
|
+
artifactCacheKey: mod.clientArtifactHash,
|
|
635
|
+
hudSource: `grid:${mod.attachmentId}`,
|
|
636
|
+
hudLabel: mod.listingName || 'Grid mod',
|
|
637
|
+
grid: toBrokerBounds(grid.bounds, grid.gridId),
|
|
638
|
+
workerUrl: glueWorkerAssetUrl,
|
|
639
|
+
serverCalls: this.serverCallsFor(grid),
|
|
640
|
+
reads: this.reads(),
|
|
641
|
+
writes: this.writes(),
|
|
642
|
+
hud: this.hud,
|
|
643
|
+
overlay: this.overlay,
|
|
644
|
+
input: this.pointerClicks,
|
|
645
|
+
});
|
|
646
|
+
if (handle) {
|
|
647
|
+
this.lifecycle.track(scope, descriptorOf(mod), handle);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private async confirmTrust(summary: string): Promise<boolean> {
|
|
652
|
+
if (this.hooks?.confirmTrust) return this.hooks.confirmTrust(summary);
|
|
653
|
+
return window.confirm(summary);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private emit(): void {
|
|
657
|
+
this.events.emit('state', this.state);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function descriptorOf(mod: {
|
|
662
|
+
attachmentId: string;
|
|
663
|
+
clientArtifactHash: string;
|
|
664
|
+
capabilityHash: string;
|
|
665
|
+
}) {
|
|
666
|
+
return {
|
|
667
|
+
attachmentId: mod.attachmentId,
|
|
668
|
+
artifactHash: mod.clientArtifactHash,
|
|
669
|
+
capabilityHash: mod.capabilityHash,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function prettyCapabilities(json: string | null | undefined): string {
|
|
674
|
+
if (!json) return '(no capability summary)';
|
|
675
|
+
try {
|
|
676
|
+
const parsed = JSON.parse(json) as unknown;
|
|
677
|
+
if (parsed && typeof parsed === 'object') {
|
|
678
|
+
return Object.entries(parsed as Record<string, unknown>)
|
|
679
|
+
.map(([k, v]) => `• ${k}: ${Array.isArray(v) ? v.join(', ') : String(v)}`)
|
|
680
|
+
.join('\n');
|
|
681
|
+
}
|
|
682
|
+
return String(parsed);
|
|
683
|
+
} catch {
|
|
684
|
+
return json;
|
|
685
|
+
}
|
|
686
|
+
}
|