@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,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-service onboarding: from "I have an account" to "my app is ready to
|
|
3
|
+
* play and mod", using only the public API a signed-in developer can call.
|
|
4
|
+
*
|
|
5
|
+
* Every step is idempotent and reports whether it changed anything, so the
|
|
6
|
+
* whole sequence can be re-run after editing the model or the templates, and
|
|
7
|
+
* so a step that fails half-way can be retried without cleanup. Plain ES
|
|
8
|
+
* module: the browser Setup wizard and `scripts/setup.mjs` run the same code.
|
|
9
|
+
*
|
|
10
|
+
* Tokens: steps 1-3, the tier grant, and the Studio Agent policy use the
|
|
11
|
+
* IDENTITY session (org and app administration). Everything on the game plane
|
|
12
|
+
* (claim policy, model, Studio common files) uses an APP-SCOPED token minted
|
|
13
|
+
* for the new app — the caller supplies `enterApp(appId)` which returns a game
|
|
14
|
+
* client holding one. The ck-exec deploy uses the identity session again, on
|
|
15
|
+
* the app's own datacenter: the caller supplies `exec.client(appId)`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Seeding a model twice reports "created 0" the second time; that is the
|
|
20
|
+
* idempotency working, not a failure. The counts are for the log.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const CONSTRUCTOR_TIER_NAME = 'Constructor';
|
|
24
|
+
|
|
25
|
+
/** Default free-tier keys, the webcam key, and the four Crowdy Studio code keys. */
|
|
26
|
+
export const CONSTRUCTOR_TIER_KEYS = [
|
|
27
|
+
'access',
|
|
28
|
+
'teleport',
|
|
29
|
+
'update_voxel_data',
|
|
30
|
+
'use_voice_chat',
|
|
31
|
+
'use_video_chat',
|
|
32
|
+
'write_server_code',
|
|
33
|
+
'run_server_code',
|
|
34
|
+
'write_client_code',
|
|
35
|
+
'run_client_code',
|
|
36
|
+
'use_studio_agent',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Hosted OpenRouter ZDR catalog. App policy must name every id; empty denies all. */
|
|
40
|
+
export const STUDIO_AGENT_MODELS = [
|
|
41
|
+
'deepseek/deepseek-v4-flash',
|
|
42
|
+
'z-ai/glm-5.3-flash',
|
|
43
|
+
'deepseek/deepseek-v4-pro',
|
|
44
|
+
'deepseek/deepseek-chat',
|
|
45
|
+
'openai/gpt-oss-120b',
|
|
46
|
+
];
|
|
47
|
+
export const STUDIO_AGENT_MODEL = STUDIO_AGENT_MODELS[0];
|
|
48
|
+
export const STUDIO_AGENT_MODES = ['ASK', 'BUILD'];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What every visitor gets: the default free tier's keys plus permission to RUN
|
|
52
|
+
* mods others wrote. Fetching a grid-attached CLIENT artifact is gated on the
|
|
53
|
+
* VISITOR holding `run_client_code` (measured 2026-09-07: "Attachment not
|
|
54
|
+
* found" without it), so a default tier without it means nobody ever sees
|
|
55
|
+
* anyone's mod. Writing code stays on the Constructor tier.
|
|
56
|
+
*/
|
|
57
|
+
export const VISITOR_RUN_KEYS = ['run_server_code', 'run_client_code'];
|
|
58
|
+
|
|
59
|
+
export function slugify(value) {
|
|
60
|
+
return (
|
|
61
|
+
String(value)
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.normalize('NFKD')
|
|
64
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
65
|
+
.replace(/^-+|-+$/g, '')
|
|
66
|
+
.slice(0, 64) || 'construct'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 1. Use the caller's first org, or create one. */
|
|
71
|
+
export async function ensureOrganization(identity, { name, slug }, log = noop) {
|
|
72
|
+
const mine = await identity.organizations.mine();
|
|
73
|
+
const owned =
|
|
74
|
+
mine.find((m) => Array.isArray(m.permissions) && m.permissions.includes('manage_apps')) ??
|
|
75
|
+
mine[0];
|
|
76
|
+
if (owned) {
|
|
77
|
+
log(`Using organization "${owned.org.name}" (${owned.org.slug})`);
|
|
78
|
+
return { org: owned.org, created: false, permissions: owned.permissions ?? [] };
|
|
79
|
+
}
|
|
80
|
+
const org = await identity.organizations.create({ name, slug: slugify(slug ?? name) });
|
|
81
|
+
log(`Created organization "${org.name}" (${org.slug})`);
|
|
82
|
+
return { org, created: true, permissions: ['manage_apps', 'manage_access_tiers'] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 2. Use the org's app with this slug, or create it on a placeable datacenter. */
|
|
86
|
+
export async function ensureApp(identity, { orgId, orgSlug, name, slug, datacenter }, log = noop) {
|
|
87
|
+
const existing = await identity.apps.forOrg(orgSlug);
|
|
88
|
+
const match = existing.find((app) => app.slug === slug) ?? null;
|
|
89
|
+
if (match) {
|
|
90
|
+
log(`Using app "${match.name}" (${match.appId})`);
|
|
91
|
+
return { app: match, created: false };
|
|
92
|
+
}
|
|
93
|
+
const placement = await identity.apps.placeableDatacenters();
|
|
94
|
+
const choices = (placement.datacenters ?? []).filter((dc) => dc.placeable && dc.serving);
|
|
95
|
+
const pick = datacenter
|
|
96
|
+
? choices.find((dc) => dc.code === datacenter)
|
|
97
|
+
: choices.sort((a, b) => Number(a.appShardCount ?? 0) - Number(b.appShardCount ?? 0))[0];
|
|
98
|
+
if (!pick) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`No datacenter can place an app right now${datacenter ? ` (asked for "${datacenter}")` : ''}. ` +
|
|
101
|
+
`Placeable: ${choices.map((dc) => dc.code).join(', ') || 'none'}.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const app = await identity.apps.create({
|
|
105
|
+
orgId,
|
|
106
|
+
name,
|
|
107
|
+
slug,
|
|
108
|
+
datacenter: pick.code,
|
|
109
|
+
description: 'Created by The Construct starter.',
|
|
110
|
+
});
|
|
111
|
+
log(`Created app "${app.name}" (${app.appId}) in datacenter ${pick.code}`);
|
|
112
|
+
return { app, created: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 3. A "Constructor" access tier carrying the Crowdy Studio code keys, and a
|
|
117
|
+
* grant of that tier to the developer. Visitors keep the default free tier the
|
|
118
|
+
* app was created with (access/teleport/voxels/voice), so they can play and
|
|
119
|
+
* paint but not deploy code on grids they do not own.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* Register the origins this game is served from as the app's redirect URIs.
|
|
123
|
+
*
|
|
124
|
+
* Since ck-api v1.88.0 this list is load-bearing twice over: it is where the
|
|
125
|
+
* hosted sign-in may send a player back, AND it is the API's CORS allow-list
|
|
126
|
+
* for the app. A game on an origin that is not here gets `HOSTED_SIGN_IN_REQUIRED`
|
|
127
|
+
* on the return leg and no CORS headers on anything else. Idempotent: existing
|
|
128
|
+
* entries are kept, the given ones added; origin-matched by the server, so the
|
|
129
|
+
* path does not matter but `/` keeps the list readable.
|
|
130
|
+
*/
|
|
131
|
+
export async function ensureRedirectUris(identity, { appId, origins }, log = noop) {
|
|
132
|
+
const app = await identity.apps.app(appId);
|
|
133
|
+
const existing = Array.isArray(app?.redirectUris) ? app.redirectUris : [];
|
|
134
|
+
const have = new Set(existing.map((u) => originOf(u)).filter(Boolean));
|
|
135
|
+
const wanted = origins.map((o) => originOf(o)).filter(Boolean);
|
|
136
|
+
const missing = [...new Set(wanted)].filter((o) => !have.has(o));
|
|
137
|
+
if (missing.length === 0) {
|
|
138
|
+
log(`Redirect URIs already cover ${wanted.join(', ')}`);
|
|
139
|
+
return { redirectUris: existing, added: [] };
|
|
140
|
+
}
|
|
141
|
+
const redirectUris = [...existing, ...missing.map((o) => `${o}/`)];
|
|
142
|
+
await identity.portal.setAppClientSettings({ appId, redirectUris });
|
|
143
|
+
log(`Registered redirect origin(s) ${missing.join(', ')}`);
|
|
144
|
+
return { redirectUris, added: missing };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function originOf(value) {
|
|
148
|
+
try {
|
|
149
|
+
return new URL(value).origin;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function ensureConstructorTier(identity, { appId, userId }, log = noop) {
|
|
156
|
+
const tiers = await identity.appAccess.tiers(appId);
|
|
157
|
+
|
|
158
|
+
// Visitors (default tier) may run mods; only Constructors may write them.
|
|
159
|
+
const defaultTier = tiers.find((t) => t.isDefault && t.status !== 'archived');
|
|
160
|
+
if (defaultTier) {
|
|
161
|
+
const have = new Set((defaultTier.permissionKeys ?? []).map(String));
|
|
162
|
+
if (!VISITOR_RUN_KEYS.every((key) => have.has(key))) {
|
|
163
|
+
await identity.appAccess.updateTier(defaultTier.tierId, {
|
|
164
|
+
permissionKeys: [...new Set([...have, ...VISITOR_RUN_KEYS])],
|
|
165
|
+
});
|
|
166
|
+
log(
|
|
167
|
+
`Default tier "${defaultTier.name}" now lets visitors run mods (${VISITOR_RUN_KEYS.join(', ')})`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let tier = tiers.find((t) => t.name === CONSTRUCTOR_TIER_NAME && t.status !== 'archived') ?? null;
|
|
173
|
+
let created = false;
|
|
174
|
+
let updated = false;
|
|
175
|
+
if (!tier) {
|
|
176
|
+
tier = await identity.appAccess.createTier({
|
|
177
|
+
appId,
|
|
178
|
+
name: CONSTRUCTOR_TIER_NAME,
|
|
179
|
+
isFree: true,
|
|
180
|
+
isDefault: false,
|
|
181
|
+
description:
|
|
182
|
+
'The Construct developer tier: play, paint, write SERVER + CLIENT mods, and use Crowdy Agent.',
|
|
183
|
+
permissionKeys: CONSTRUCTOR_TIER_KEYS,
|
|
184
|
+
});
|
|
185
|
+
created = true;
|
|
186
|
+
log(`Created access tier "${CONSTRUCTOR_TIER_NAME}" (${tier.tierId})`);
|
|
187
|
+
} else {
|
|
188
|
+
const have = new Set((tier.permissionKeys ?? []).map(String));
|
|
189
|
+
if (!CONSTRUCTOR_TIER_KEYS.every((key) => have.has(key))) {
|
|
190
|
+
tier = await identity.appAccess.updateTier(tier.tierId, {
|
|
191
|
+
permissionKeys: [...new Set([...have, ...CONSTRUCTOR_TIER_KEYS])],
|
|
192
|
+
});
|
|
193
|
+
updated = true;
|
|
194
|
+
log(`Updated access tier "${CONSTRUCTOR_TIER_NAME}" with the code keys`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Check before granting rather than replaying an idempotency key: on
|
|
198
|
+
// 2026-09-07 the replay path of grantAppAccess answered a DateTime
|
|
199
|
+
// serialization error on dev, and "already on this tier" is the honest
|
|
200
|
+
// idempotent answer anyway.
|
|
201
|
+
const mine = await identity.appAccess.myAccess(appId).catch(() => null);
|
|
202
|
+
if (mine && String(mine.tierId) === String(tier.tierId) && mine.status !== 'revoked') {
|
|
203
|
+
log(`User ${userId} already holds "${CONSTRUCTOR_TIER_NAME}"`);
|
|
204
|
+
return { tier, created, updated, granted: false };
|
|
205
|
+
}
|
|
206
|
+
await identity.appAccess.grant({ appId, userId, tierId: tier.tierId });
|
|
207
|
+
log(`Granted "${CONSTRUCTOR_TIER_NAME}" to user ${userId}`);
|
|
208
|
+
return { tier, created, updated, granted: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 4. Players may claim an unowned chunk for themselves (the Claim pad). */
|
|
212
|
+
export async function ensureSelfClaimPolicy(client, { appId }, log = noop) {
|
|
213
|
+
const current = await client.marketplace.gridClaimPolicy({ appId }).catch(() => null);
|
|
214
|
+
const normalized = String(current ?? '')
|
|
215
|
+
.toUpperCase()
|
|
216
|
+
.replace(/-/g, '_');
|
|
217
|
+
if (normalized === 'SELF_CLAIM') {
|
|
218
|
+
log('Grid claim policy already SELF_CLAIM');
|
|
219
|
+
return { policy: 'SELF_CLAIM', changed: false };
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await client.marketplace.setGridClaimPolicy({ appId, policy: 'SELF_CLAIM' });
|
|
223
|
+
} catch {
|
|
224
|
+
// Local/single-node ck-api exposes this as manage_apps on the identity
|
|
225
|
+
// session (`setAppGridClaimPolicy`); an app-scoped token is refused.
|
|
226
|
+
await client.graphql.query(
|
|
227
|
+
`mutation ConstructSetClaimPolicy($appId: BigInt!, $policy: GridClaimPolicy!) {
|
|
228
|
+
setAppGridClaimPolicy(appId: $appId, policy: $policy)
|
|
229
|
+
}`,
|
|
230
|
+
{ appId, policy: 'SELF_CLAIM' },
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
log(`Grid claim policy set to SELF_CLAIM${current ? ` (was ${current})` : ''}`);
|
|
234
|
+
return { policy: 'SELF_CLAIM', changed: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 5. Build and deploy the game's ck-exec code. The crates go to `execBuild` as
|
|
239
|
+
* sources (the platform compiles them, so no Rust toolchain is needed here) and
|
|
240
|
+
* the manifest to `execDeploy` with that build, which makes it the app's active
|
|
241
|
+
* version. Deploying the same sources again takes the build from the platform's
|
|
242
|
+
* cache and makes an identical version active.
|
|
243
|
+
*
|
|
244
|
+
* `exec` must hold the developer's own session (the org's `manage_compute`), not
|
|
245
|
+
* an app token, and point at the app's own datacenter: deploys go to that
|
|
246
|
+
* datacenter's execution manager.
|
|
247
|
+
*
|
|
248
|
+
* A running hub keeps the version it started with until it stops, and one whose
|
|
249
|
+
* timer is pending does not stop while players are in the app. `restart` names
|
|
250
|
+
* the types to switch off and on again after the deploy: they persist and stop,
|
|
251
|
+
* and their next call starts them on the new version from their snapshots.
|
|
252
|
+
* `retry` wraps each request (the scripts retry a busy game API).
|
|
253
|
+
*/
|
|
254
|
+
export async function deployExec(
|
|
255
|
+
exec,
|
|
256
|
+
{ appId, manifest, crates, restart = [], retry = (fn) => fn(), restartPauseMs = 5_000 },
|
|
257
|
+
log = noop,
|
|
258
|
+
) {
|
|
259
|
+
const queued = await retry(() => exec.exec.build(appId, crates));
|
|
260
|
+
log(`Building ${crates.map((c) => c.name).join(', ')} on the platform (build ${queued.buildId})`);
|
|
261
|
+
const build = await retry(() =>
|
|
262
|
+
exec.exec.waitForBuild(appId, queued.buildId, { timeoutMs: 900_000 }),
|
|
263
|
+
);
|
|
264
|
+
if (build.status !== 'succeeded') {
|
|
265
|
+
throw new Error(`ck-exec build ${build.buildId} ${build.status}:\n${build.log ?? ''}`.trim());
|
|
266
|
+
}
|
|
267
|
+
for (const a of build.artifacts ?? []) log(` ${a.crate}: ${a.sizeBytes} bytes`);
|
|
268
|
+
const { version } = await retry(() =>
|
|
269
|
+
exec.exec.deploy({
|
|
270
|
+
appId,
|
|
271
|
+
root: manifest.root,
|
|
272
|
+
types: manifest.types,
|
|
273
|
+
buildId: build.buildId,
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
log(`ck-exec version ${version} is active (${Object.keys(manifest.types).join(', ')})`);
|
|
277
|
+
if (restart.length > 0) {
|
|
278
|
+
for (const nodeType of restart) await retry(() => exec.exec.setEnabled(appId, false, nodeType));
|
|
279
|
+
// Stopping persists first; hosts read the switch on their next poll.
|
|
280
|
+
await new Promise((resolve) => setTimeout(resolve, restartPauseMs));
|
|
281
|
+
for (const nodeType of restart) await retry(() => exec.exec.setEnabled(appId, true, nodeType));
|
|
282
|
+
log(
|
|
283
|
+
`Switched ${restart.join(', ')} off and on: the next call starts them on version ${version}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return { buildId: build.buildId, version, restarted: restart };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Deploy a Game Model (kit blueprints), for a game that keeps one. The Construct
|
|
291
|
+
* starter keeps its state in its ck-exec world hub instead (`deployExec`).
|
|
292
|
+
*
|
|
293
|
+
* `gameModelSeed` upserts DEFINITIONS (types, properties, functions) by name,
|
|
294
|
+
* but seed CONTAINERS are instances and are created every time they are sent.
|
|
295
|
+
* Re-running a seed that lists containers therefore duplicates them — measured
|
|
296
|
+
* on 2026-09-07: two runs, two `WorldState`s. So before deploying, drop every
|
|
297
|
+
* seed container whose type already has an instance with the same display
|
|
298
|
+
* name. Definitions still refresh; existing data is left alone.
|
|
299
|
+
*/
|
|
300
|
+
export async function deployModel(game, { appId, blueprints }, log = noop) {
|
|
301
|
+
if (!Array.isArray(blueprints)) {
|
|
302
|
+
throw new Error("deployModel needs the game's blueprints (e.g. constructBlueprints())");
|
|
303
|
+
}
|
|
304
|
+
for (const blueprint of blueprints) {
|
|
305
|
+
if (!blueprint.containers?.length) continue;
|
|
306
|
+
const keep = [];
|
|
307
|
+
const seenTypes = new Map();
|
|
308
|
+
for (const container of blueprint.containers) {
|
|
309
|
+
let existing = seenTypes.get(container.typeName);
|
|
310
|
+
if (!existing) {
|
|
311
|
+
existing = await game.gameModel
|
|
312
|
+
.containers({ appId, typeName: container.typeName })
|
|
313
|
+
.catch(() => []);
|
|
314
|
+
seenTypes.set(container.typeName, existing);
|
|
315
|
+
}
|
|
316
|
+
if (existing.some((row) => row.displayName === container.displayName)) {
|
|
317
|
+
log(
|
|
318
|
+
`Container "${container.displayName}" (${container.typeName}) already exists; not re-seeding`,
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
keep.push(container);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
blueprint.containers = keep;
|
|
325
|
+
}
|
|
326
|
+
const result = await game.kit(appId).deploy(blueprints);
|
|
327
|
+
const seed = result.seed ?? {};
|
|
328
|
+
log(
|
|
329
|
+
`Model deployed: ${seed.containerTypesCreated ?? 0} new types, ` +
|
|
330
|
+
`${seed.functionsCreated ?? 0} new functions, ${seed.containersCreated ?? 0} new containers, ` +
|
|
331
|
+
`${result.automations?.length ?? 0} automations upserted`,
|
|
332
|
+
);
|
|
333
|
+
for (const warning of result.warnings ?? seed.warnings ?? []) log(` seed warning: ${warning}`);
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const PUBLISH_COMMON = `mutation PublishCommon($input: PublishCrowdyStudioCommonFileInput!) {
|
|
338
|
+
crowdyStudioCommonPublish(input: $input) { commonFileId slug versionId versionNo }
|
|
339
|
+
}`;
|
|
340
|
+
|
|
341
|
+
/** 6. Publish the starter templates' entrypoints to the Studio's Common Files. */
|
|
342
|
+
export async function publishStarterFiles(game, { appId, commonFiles = [] }, log = noop) {
|
|
343
|
+
const existing = await game.crowdyStudio.listCommonFiles({ appId, gridId: '' });
|
|
344
|
+
const results = [];
|
|
345
|
+
for (const common of commonFiles) {
|
|
346
|
+
const current = existing.find(
|
|
347
|
+
(file) => file.title === common.title && file.content === common.content,
|
|
348
|
+
);
|
|
349
|
+
if (current) {
|
|
350
|
+
log(`Common file "${common.slug}" already current`);
|
|
351
|
+
results.push({ slug: common.slug, status: 'current' });
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const data = await game.graphql.query(PUBLISH_COMMON, {
|
|
355
|
+
input: {
|
|
356
|
+
appId,
|
|
357
|
+
slug: common.slug,
|
|
358
|
+
title: common.title,
|
|
359
|
+
description: common.description,
|
|
360
|
+
path: common.path,
|
|
361
|
+
target: common.target,
|
|
362
|
+
tags: common.tags,
|
|
363
|
+
content: common.content,
|
|
364
|
+
idempotencyKey: `construct-common-${common.slug}-${simpleHash(common.content)}`,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
const version = data?.crowdyStudioCommonPublish?.versionNo;
|
|
368
|
+
log(`Common file "${common.slug}" published (v${version ?? '?'})`);
|
|
369
|
+
results.push({ slug: common.slug, status: 'published', versionNo: version });
|
|
370
|
+
}
|
|
371
|
+
return results;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const AGENT_POLICY_CORE = `
|
|
375
|
+
revision enabled killSwitch allowedModelIds allowedModes disableReasonCode
|
|
376
|
+
`;
|
|
377
|
+
|
|
378
|
+
const AGENT_APP_POLICY_QUERY = `query ConstructAgentPolicy($appId: BigInt!) {
|
|
379
|
+
crowdyStudioAgentPolicy(appId: $appId) { ${AGENT_POLICY_CORE} }
|
|
380
|
+
crowdyStudioAgentEffectivePolicy(appId: $appId) { ${AGENT_POLICY_CORE} }
|
|
381
|
+
}`;
|
|
382
|
+
|
|
383
|
+
const SET_AGENT_APP_POLICY = `mutation ConstructSetAgentPolicy($input: SetCrowdyStudioAgentAppPolicyInput!) {
|
|
384
|
+
setCrowdyStudioAgentPolicy(input: $input) { ${AGENT_POLICY_CORE} }
|
|
385
|
+
}`;
|
|
386
|
+
|
|
387
|
+
/** App row is on and allows Ask/Build. Models inherit the platform catalog. */
|
|
388
|
+
function appPolicyArmed(policy) {
|
|
389
|
+
if (!policy || policy.enabled !== true || policy.killSwitch === true) return false;
|
|
390
|
+
const modes = (policy.allowedModes ?? []).map(String);
|
|
391
|
+
return STUDIO_AGENT_MODES.every((m) => modes.includes(m));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function catalogVisible(policy) {
|
|
395
|
+
if (!policy || policy.killSwitch === true) return false;
|
|
396
|
+
return (policy.allowedModelIds ?? []).length > 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Arm Agentic Crowdy Studio for this app only.
|
|
401
|
+
*
|
|
402
|
+
* The dock stays fail-closed until the *app* policy is enabled and the
|
|
403
|
+
* player's Constructor tier holds `use_studio_agent`. The platform catalog is
|
|
404
|
+
* an operator concern — this starter never reads or writes `cp*` fields.
|
|
405
|
+
* Agent tokens are platform-funded; this does not touch a player wallet or an
|
|
406
|
+
* OpenRouter key in the game.
|
|
407
|
+
*/
|
|
408
|
+
export async function ensureAgentPolicy(identity, { appId }, log = noop) {
|
|
409
|
+
let app;
|
|
410
|
+
let effective;
|
|
411
|
+
try {
|
|
412
|
+
const data = await identity.graphql.query(AGENT_APP_POLICY_QUERY, { appId });
|
|
413
|
+
app = data?.crowdyStudioAgentPolicy;
|
|
414
|
+
effective = data?.crowdyStudioAgentEffectivePolicy;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
log(
|
|
417
|
+
`Studio Agent app policy is not readable (${messageOf(error)}). ` +
|
|
418
|
+
'Needs manage_compute. Enable it in Studio → your app → Agent.',
|
|
419
|
+
);
|
|
420
|
+
return { enabled: false, skipped: true };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (appPolicyArmed(effective) || appPolicyArmed(app)) {
|
|
424
|
+
if (effective && !catalogVisible(effective)) {
|
|
425
|
+
log(
|
|
426
|
+
'Studio Agent app policy is on, but the platform catalog is not published. ' +
|
|
427
|
+
'The dock stays closed until an operator publishes it. Studio → your app → Agent.',
|
|
428
|
+
);
|
|
429
|
+
} else {
|
|
430
|
+
log('Studio Agent app policy already enabled');
|
|
431
|
+
}
|
|
432
|
+
return { enabled: true, skipped: true };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const updated = (
|
|
437
|
+
await identity.graphql.query(SET_AGENT_APP_POLICY, {
|
|
438
|
+
input: {
|
|
439
|
+
appId,
|
|
440
|
+
enabled: true,
|
|
441
|
+
killSwitch: false,
|
|
442
|
+
// Explicit on purpose: unlike tools and risk classes, modes have no
|
|
443
|
+
// inherit-on-omit at the app layer ("there is no inherit-on-empty for
|
|
444
|
+
// modes" -- SetCrowdyStudioAgentPolicyInput). The live platform
|
|
445
|
+
// catalog is ASK + BUILD; PLAY is refused ("can only narrow the
|
|
446
|
+
// platform list").
|
|
447
|
+
allowedModes: STUDIO_AGENT_MODES,
|
|
448
|
+
// Empty allowedModelIds denies every model at both layers. Name the
|
|
449
|
+
// hosted ZDR catalog so DeepSeek / GLM / gpt-oss are all selectable.
|
|
450
|
+
allowedModelIds: STUDIO_AGENT_MODELS,
|
|
451
|
+
expectedRevision: app?.revision ?? '0',
|
|
452
|
+
idempotencyKey: `construct-agent-app-${appId}-v5`,
|
|
453
|
+
},
|
|
454
|
+
})
|
|
455
|
+
)?.setCrowdyStudioAgentPolicy;
|
|
456
|
+
let latestEffective = effective;
|
|
457
|
+
try {
|
|
458
|
+
latestEffective = (await identity.graphql.query(AGENT_APP_POLICY_QUERY, { appId }))
|
|
459
|
+
?.crowdyStudioAgentEffectivePolicy;
|
|
460
|
+
} catch {
|
|
461
|
+
// The write is what matters; effective is best-effort for the log.
|
|
462
|
+
}
|
|
463
|
+
const armed = appPolicyArmed(updated) || updated?.enabled === true;
|
|
464
|
+
if (!armed) {
|
|
465
|
+
log(
|
|
466
|
+
`Studio Agent app policy written but not yet effective (${updated?.disableReasonCode ?? 'unknown'}). ` +
|
|
467
|
+
'Studio → your app → Agent. The dock stays hidden until that row is live.',
|
|
468
|
+
);
|
|
469
|
+
return { enabled: false, skipped: false };
|
|
470
|
+
}
|
|
471
|
+
if (latestEffective && !catalogVisible(latestEffective)) {
|
|
472
|
+
log(
|
|
473
|
+
'Studio Agent app policy enabled; platform catalog not published — ' +
|
|
474
|
+
'Studio → your app → Agent; dock stays closed.',
|
|
475
|
+
);
|
|
476
|
+
} else {
|
|
477
|
+
log('Studio Agent app policy enabled (platform-funded; no player OpenRouter key)');
|
|
478
|
+
}
|
|
479
|
+
return { enabled: true, skipped: false };
|
|
480
|
+
} catch (error) {
|
|
481
|
+
log(
|
|
482
|
+
`Could not enable the Studio Agent app policy (${messageOf(error)}). ` +
|
|
483
|
+
'Studio → your app → Agent. The dock stays hidden until that row is live.',
|
|
484
|
+
);
|
|
485
|
+
return { enabled: false, skipped: true };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Best practice: verify whether the app is connected to GitHub so players know
|
|
491
|
+
* GitHub can be their source of truth for files. Advisory only.
|
|
492
|
+
*/
|
|
493
|
+
export async function checkGitHubIntegration(game, { appId }, log = noop) {
|
|
494
|
+
try {
|
|
495
|
+
const status = await game?.crowdyStudioGitHub?.status?.({ appId });
|
|
496
|
+
if (status?.connected && status?.owner && status?.repo) {
|
|
497
|
+
const repo = `${status.owner}/${status.repo}`;
|
|
498
|
+
log(`GitHub repository connected: ${repo}`);
|
|
499
|
+
return { connected: true, skipped: false, repo };
|
|
500
|
+
}
|
|
501
|
+
log(
|
|
502
|
+
'Advisory: No GitHub repository bound. In Crowdy Studio, bind a GitHub repository ' +
|
|
503
|
+
'so GitHub serves as your source of truth for files.',
|
|
504
|
+
);
|
|
505
|
+
return { connected: false, skipped: true };
|
|
506
|
+
} catch (err) {
|
|
507
|
+
log(`GitHub integration check skipped (${messageOf(err)}).`);
|
|
508
|
+
return { connected: false, skipped: true };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function messageOf(error) {
|
|
513
|
+
return error instanceof Error ? error.message : String(error);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The whole sequence. `enterApp(appId)` must return a client holding an
|
|
518
|
+
* app-scoped token for `appId` (browser: NetworkManager.enterApp; Node: mint
|
|
519
|
+
* and build a client). `exec` (the game's ck-exec code) and `blueprints` (a
|
|
520
|
+
* Game Model) are each deployed when given. Returns a report the UI renders.
|
|
521
|
+
*/
|
|
522
|
+
export async function runOnboarding(options) {
|
|
523
|
+
const {
|
|
524
|
+
identity,
|
|
525
|
+
userId,
|
|
526
|
+
enterApp,
|
|
527
|
+
orgName,
|
|
528
|
+
appName,
|
|
529
|
+
appSlug,
|
|
530
|
+
datacenter,
|
|
531
|
+
redirectOrigins = [],
|
|
532
|
+
blueprints,
|
|
533
|
+
exec,
|
|
534
|
+
commonFiles = [],
|
|
535
|
+
log = noop,
|
|
536
|
+
onStep = noop,
|
|
537
|
+
} = options;
|
|
538
|
+
const report = { steps: [] };
|
|
539
|
+
const step = async (id, label, fn) => {
|
|
540
|
+
onStep({ id, label, status: 'running' });
|
|
541
|
+
try {
|
|
542
|
+
const value = await fn();
|
|
543
|
+
report.steps.push({ id, label, status: 'done', value });
|
|
544
|
+
onStep({ id, label, status: 'done', value });
|
|
545
|
+
return value;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
report.steps.push({ id, label, status: 'failed', error });
|
|
548
|
+
onStep({ id, label, status: 'failed', error });
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const { org } = await step('org', 'Organization', () =>
|
|
554
|
+
ensureOrganization(identity, { name: orgName, slug: orgName }, log),
|
|
555
|
+
);
|
|
556
|
+
const { app } = await step('app', 'App', () =>
|
|
557
|
+
ensureApp(
|
|
558
|
+
identity,
|
|
559
|
+
{
|
|
560
|
+
orgId: org.orgId,
|
|
561
|
+
orgSlug: org.slug,
|
|
562
|
+
name: appName,
|
|
563
|
+
slug: appSlug ?? slugify(appName),
|
|
564
|
+
datacenter,
|
|
565
|
+
},
|
|
566
|
+
log,
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
const appId = String(app.appId);
|
|
570
|
+
await step('tier', 'Constructor access tier', () =>
|
|
571
|
+
ensureConstructorTier(identity, { appId, userId }, log),
|
|
572
|
+
);
|
|
573
|
+
if (redirectOrigins.length > 0) {
|
|
574
|
+
await step('redirects', 'Sign-in redirect URIs', () =>
|
|
575
|
+
ensureRedirectUris(identity, { appId, origins: redirectOrigins }, log),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const game = await step('enter', 'App token', () => enterApp(appId));
|
|
579
|
+
await step('claims', 'Grid claim policy', () => ensureSelfClaimPolicy(identity, { appId }, log));
|
|
580
|
+
if (blueprints) {
|
|
581
|
+
await step('model', 'Game model', () => deployModel(game, { appId, blueprints }, log));
|
|
582
|
+
}
|
|
583
|
+
if (exec) {
|
|
584
|
+
await step('exec', 'ck-exec code', async () => {
|
|
585
|
+
const client = await exec.client(appId);
|
|
586
|
+
try {
|
|
587
|
+
const { manifest, crates, restart, retry } = exec;
|
|
588
|
+
return await deployExec(client, { appId, manifest, crates, restart, retry }, log);
|
|
589
|
+
} finally {
|
|
590
|
+
client.close?.();
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
await step('studio', 'Crowdy Studio starter files', () =>
|
|
595
|
+
publishStarterFiles(game, { appId, commonFiles }, log),
|
|
596
|
+
);
|
|
597
|
+
await step('agent', 'Crowdy Agent policy', () => ensureAgentPolicy(identity, { appId }, log));
|
|
598
|
+
await step('github', 'GitHub repository integration', () =>
|
|
599
|
+
checkGitHubIntegration(game, { appId }, log),
|
|
600
|
+
);
|
|
601
|
+
report.org = org;
|
|
602
|
+
report.app = app;
|
|
603
|
+
report.appId = appId;
|
|
604
|
+
return report;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function simpleHash(text) {
|
|
608
|
+
let h = 2166136261;
|
|
609
|
+
for (let i = 0; i < text.length; i++) {
|
|
610
|
+
h ^= text.charCodeAt(i);
|
|
611
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
612
|
+
}
|
|
613
|
+
return h.toString(16);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function noop() {}
|