@crowdedkingdoms/construct 0.0.0 → 0.2.0-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.
Files changed (69) hide show
  1. package/README.md +76 -1
  2. package/node/copy-dsh-web.mjs +71 -0
  3. package/node/security-headers.d.mts +23 -0
  4. package/node/security-headers.mjs +243 -0
  5. package/node/vite-plugins.d.mts +6 -0
  6. package/node/vite-plugins.mjs +102 -0
  7. package/package.json +46 -3
  8. package/sandbox/boot.ts +74 -0
  9. package/src/engine/GameLoop.ts +106 -0
  10. package/src/engine/GameScene.ts +74 -0
  11. package/src/engine/Input.test.ts +129 -0
  12. package/src/engine/Input.ts +257 -0
  13. package/src/engine/SceneRouter.test.ts +80 -0
  14. package/src/engine/SceneRouter.ts +113 -0
  15. package/src/engine/controls.test.ts +26 -0
  16. package/src/engine/controls.ts +63 -0
  17. package/src/grid/GridProgramRunner.ts +205 -0
  18. package/src/grid/sandboxProtocol.ts +9 -0
  19. package/src/index.ts +88 -0
  20. package/src/platform/GameSession.ts +239 -0
  21. package/src/platform/auth/AuthService.ts +45 -0
  22. package/src/platform/config.test.ts +110 -0
  23. package/src/platform/config.ts +212 -0
  24. package/src/platform/envScope.test.ts +42 -0
  25. package/src/platform/envScope.ts +76 -0
  26. package/src/platform/exec/worldHub.test.ts +61 -0
  27. package/src/platform/exec/worldHub.ts +86 -0
  28. package/src/platform/media/VoiceService.ts +423 -0
  29. package/src/platform/media/WebcamService.ts +346 -0
  30. package/src/platform/media/mulaw.test.ts +20 -0
  31. package/src/platform/media/mulaw.ts +56 -0
  32. package/src/platform/media/videoFrames.test.ts +197 -0
  33. package/src/platform/media/voiceFrames.test.ts +82 -0
  34. package/src/platform/model/ModelService.test.ts +59 -0
  35. package/src/platform/model/ModelService.ts +124 -0
  36. package/src/platform/network/NetworkManager.ts +519 -0
  37. package/src/platform/onboarding/steps.d.mts +154 -0
  38. package/src/platform/onboarding/steps.mjs +616 -0
  39. package/src/platform/onboarding/steps.test.ts +486 -0
  40. package/src/platform/realtime/WorldStores.ts +153 -0
  41. package/src/platform/realtime/actorCodec.test.ts +62 -0
  42. package/src/platform/realtime/actorCodec.ts +142 -0
  43. package/src/platform/realtime/space.test.ts +33 -0
  44. package/src/platform/realtime/space.ts +63 -0
  45. package/src/platform/social/ChatService.ts +92 -0
  46. package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
  47. package/src/platform/studio/ClientModLifecycle.ts +102 -0
  48. package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
  49. package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
  50. package/src/platform/studio/GridService.test.ts +131 -0
  51. package/src/platform/studio/GridService.ts +295 -0
  52. package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
  53. package/src/platform/studio/HumanInputMonitor.ts +48 -0
  54. package/src/platform/studio/StudioService.ts +686 -0
  55. package/src/platform/studio/agentLocomotion.test.ts +31 -0
  56. package/src/platform/studio/agentLocomotion.ts +97 -0
  57. package/src/platform/studio/clientModHost.test.ts +199 -0
  58. package/src/platform/studio/clientModHost.ts +369 -0
  59. package/src/platform/studio/instanceSchema.test.ts +280 -0
  60. package/src/platform/studio/instanceSchema.ts +746 -0
  61. package/src/platform/studio/instanceStore.test.ts +97 -0
  62. package/src/platform/studio/instanceStore.ts +120 -0
  63. package/src/platform/studio/modOverlay.test.ts +56 -0
  64. package/src/platform/studio/modOverlay.ts +51 -0
  65. package/src/platform/studio/permissions.test.ts +51 -0
  66. package/src/platform/studio/permissions.ts +134 -0
  67. package/src/platform/studio/pointerClicks.test.ts +23 -0
  68. package/src/platform/studio/pointerClicks.ts +132 -0
  69. package/src/platform/util/Emitter.ts +32 -0
@@ -0,0 +1,486 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('@crowdedkingdoms/crowdyjs', () => ({
4
+ progressionBlueprint: (o: unknown) => ({ name: 'progression', options: o }),
5
+ leaderboardsBlueprint: (o: unknown) => ({ name: 'leaderboards', options: o }),
6
+ }));
7
+
8
+ const steps = await import('./steps.mjs');
9
+
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ type Any = any;
12
+
13
+ function identityStub(overrides: Any = {}): Any {
14
+ const created: Any[] = [];
15
+ return {
16
+ created,
17
+ organizations: {
18
+ mine: vi.fn(async () => overrides.orgs ?? []),
19
+ create: vi.fn(async (input: Any) => {
20
+ created.push(['org', input]);
21
+ return { orgId: '1', slug: input.slug, name: input.name };
22
+ }),
23
+ },
24
+ apps: {
25
+ forOrg: vi.fn(async () => overrides.apps ?? []),
26
+ placeableDatacenters: vi.fn(async () => ({
27
+ datacenters: overrides.datacenters ?? [
28
+ { code: 'va', placeable: true, serving: true, appShardCount: 9 },
29
+ { code: 'or', placeable: true, serving: true, appShardCount: 2 },
30
+ { code: 'zz', placeable: false, serving: true, appShardCount: 0 },
31
+ ],
32
+ })),
33
+ create: vi.fn(async (input: Any) => {
34
+ created.push(['app', input]);
35
+ return { appId: '77', name: input.name, slug: input.slug };
36
+ }),
37
+ },
38
+ appAccess: {
39
+ tiers: vi.fn(async () => overrides.tiers ?? []),
40
+ createTier: vi.fn(async (input: Any) => {
41
+ created.push(['tier', input]);
42
+ return { tierId: 't1', name: input.name, permissionKeys: input.permissionKeys };
43
+ }),
44
+ updateTier: vi.fn(async (id: string, input: Any) => {
45
+ created.push(['tierUpdate', id, input]);
46
+ return { tierId: id, name: 'Constructor', permissionKeys: input.permissionKeys };
47
+ }),
48
+ myAccess: vi.fn(async () => overrides.myAccess ?? null),
49
+ grant: vi.fn(async (input: Any) => {
50
+ created.push(['grant', input]);
51
+ return { tierId: input.tierId };
52
+ }),
53
+ },
54
+ };
55
+ }
56
+
57
+ describe('onboarding steps', () => {
58
+ it('slugifies names conservatively', () => {
59
+ expect(steps.slugify("Neo's Studio!")).toBe('neo-s-studio');
60
+ expect(steps.slugify(' ')).toBe('construct');
61
+ });
62
+
63
+ it('creates an org only when the account has none', async () => {
64
+ const fresh = identityStub();
65
+ const a = await steps.ensureOrganization(fresh as never, { name: 'My studio' });
66
+ expect(a.created).toBe(true);
67
+ expect(fresh.organizations.create).toHaveBeenCalledWith({
68
+ name: 'My studio',
69
+ slug: 'my-studio',
70
+ });
71
+
72
+ const existing = identityStub({
73
+ orgs: [{ org: { orgId: '5', slug: 'x', name: 'X' }, permissions: ['manage_apps'] }],
74
+ });
75
+ const b = await steps.ensureOrganization(existing as never, { name: 'ignored' });
76
+ expect(b.created).toBe(false);
77
+ expect(b.org.orgId).toBe('5');
78
+ expect(existing.organizations.create).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('reuses an app by slug and otherwise picks the least-loaded placeable datacenter', async () => {
82
+ const reuse = identityStub({ apps: [{ appId: '9', slug: 'the-construct', name: 'Old' }] });
83
+ const a = await steps.ensureApp(reuse as never, {
84
+ orgId: '1',
85
+ orgSlug: 'o',
86
+ name: 'The Construct',
87
+ slug: 'the-construct',
88
+ });
89
+ expect(a.created).toBe(false);
90
+ expect(reuse.apps.create).not.toHaveBeenCalled();
91
+
92
+ const fresh = identityStub();
93
+ const b = await steps.ensureApp(fresh as never, {
94
+ orgId: '1',
95
+ orgSlug: 'o',
96
+ name: 'The Construct',
97
+ slug: 'the-construct',
98
+ });
99
+ expect(b.created).toBe(true);
100
+ expect(fresh.apps.create.mock.calls[0][0].datacenter).toBe('or');
101
+ });
102
+
103
+ it('refuses when no datacenter can place an app', async () => {
104
+ const none = identityStub({ datacenters: [{ code: 'or', placeable: false, serving: true }] });
105
+ await expect(
106
+ steps.ensureApp(none as never, { orgId: '1', orgSlug: 'o', name: 'A', slug: 'a' }),
107
+ ).rejects.toThrow(/No datacenter/);
108
+ });
109
+
110
+ it('creates the Constructor tier with the code keys, widens the default tier, and grants once', async () => {
111
+ const fresh = identityStub({
112
+ tiers: [
113
+ {
114
+ tierId: 'd',
115
+ name: 'Default',
116
+ isDefault: true,
117
+ status: 'active',
118
+ permissionKeys: ['access'],
119
+ },
120
+ ],
121
+ });
122
+ const a = await steps.ensureConstructorTier(fresh as never, { appId: '77', userId: '42' });
123
+ expect(a.created).toBe(true);
124
+ expect(a.granted).toBe(true);
125
+ const tierInput = fresh.appAccess.createTier.mock.calls[0][0];
126
+ expect(tierInput.permissionKeys).toEqual(
127
+ expect.arrayContaining([...steps.CONSTRUCTOR_TIER_KEYS]),
128
+ );
129
+ expect(tierInput.isDefault).toBe(false);
130
+ const defaultUpdate = (fresh.appAccess.updateTier.mock.calls as Any[][]).find(
131
+ (c) => c[0] === 'd',
132
+ );
133
+ expect(defaultUpdate?.[1].permissionKeys).toEqual(
134
+ expect.arrayContaining([...steps.VISITOR_RUN_KEYS]),
135
+ );
136
+ expect(fresh.appAccess.grant).toHaveBeenCalledWith({ appId: '77', userId: '42', tierId: 't1' });
137
+
138
+ const already = identityStub({
139
+ tiers: [
140
+ {
141
+ tierId: 'd',
142
+ name: 'Default',
143
+ isDefault: true,
144
+ status: 'active',
145
+ permissionKeys: ['access', ...steps.VISITOR_RUN_KEYS],
146
+ },
147
+ {
148
+ tierId: 't1',
149
+ name: 'Constructor',
150
+ status: 'active',
151
+ permissionKeys: [...steps.CONSTRUCTOR_TIER_KEYS],
152
+ },
153
+ ],
154
+ myAccess: { tierId: 't1', status: 'active' },
155
+ });
156
+ const b = await steps.ensureConstructorTier(already as never, { appId: '77', userId: '42' });
157
+ expect(b.created).toBe(false);
158
+ expect(b.granted).toBe(false);
159
+ expect(already.appAccess.grant).not.toHaveBeenCalled();
160
+ expect(already.appAccess.updateTier).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it('only sets the claim policy when it differs', async () => {
164
+ const game = {
165
+ marketplace: {
166
+ gridClaimPolicy: vi.fn(async () => 'SELF_CLAIM'),
167
+ setGridClaimPolicy: vi.fn(),
168
+ },
169
+ };
170
+ expect(await steps.ensureSelfClaimPolicy(game as never, { appId: '77' })).toEqual({
171
+ policy: 'SELF_CLAIM',
172
+ changed: false,
173
+ });
174
+ expect(game.marketplace.setGridClaimPolicy).not.toHaveBeenCalled();
175
+ game.marketplace.gridClaimPolicy = vi.fn(async () => 'APPROVAL');
176
+ expect(await steps.ensureSelfClaimPolicy(game as never, { appId: '77' })).toEqual({
177
+ policy: 'SELF_CLAIM',
178
+ changed: true,
179
+ });
180
+ });
181
+
182
+ it('does not re-seed containers that already exist', async () => {
183
+ const deploy = vi.fn(async () => ({
184
+ seed: { containersCreated: 0 },
185
+ automations: [],
186
+ warnings: [],
187
+ }));
188
+ const game = {
189
+ kit: () => ({ deploy }),
190
+ gameModel: {
191
+ containers: vi.fn(async ({ typeName }: Any) =>
192
+ typeName === 'WorldState' ? [{ displayName: 'The Construct' }] : [],
193
+ ),
194
+ },
195
+ };
196
+ await steps.deployModel(game as never, {
197
+ appId: '77',
198
+ blueprints: [
199
+ {
200
+ name: 'construct-world',
201
+ containers: [
202
+ { typeName: 'WorldState', displayName: 'The Construct' },
203
+ { typeName: 'Program', displayName: 'Paint' },
204
+ ],
205
+ },
206
+ ],
207
+ });
208
+ const blueprints = (deploy.mock.calls as Any[][])[0]![0] as Any[];
209
+ const world = blueprints.find((b) => b.name === 'construct-world')!;
210
+ expect(world.containers.map((c: Any) => c.typeName)).toEqual(['Program']);
211
+ });
212
+
213
+ const execManifest = {
214
+ root: 'construct',
215
+ types: {
216
+ construct: { kind: 'hub', crate: 'construct', client: true },
217
+ world: { kind: 'hub', parent: 'construct', crate: 'construct', client: true },
218
+ },
219
+ };
220
+ const execCrates = [{ name: 'construct', files: { 'Cargo.toml': '', 'src/lib.rs': '' } }];
221
+
222
+ function execStub(build: Any = { status: 'succeeded', log: '' }) {
223
+ const calls: string[] = [];
224
+ const exec = {
225
+ build: vi.fn(async () => {
226
+ calls.push('build');
227
+ return { buildId: 'b1', status: 'queued' };
228
+ }),
229
+ waitForBuild: vi.fn(async () => {
230
+ calls.push('wait');
231
+ return {
232
+ buildId: 'b1',
233
+ artifacts: [{ crate: 'construct', sizeBytes: 10, digest: 'd' }],
234
+ ...build,
235
+ };
236
+ }),
237
+ deploy: vi.fn(async () => {
238
+ calls.push('deploy');
239
+ return { version: 3 };
240
+ }),
241
+ setEnabled: vi.fn(async (_appId: string, on: boolean, nodeType: string) => {
242
+ calls.push(`${nodeType}:${on ? 'on' : 'off'}`);
243
+ }),
244
+ };
245
+ return { client: { exec, close: vi.fn() }, exec, calls };
246
+ }
247
+
248
+ it('builds the ck-exec crates on the platform and deploys that build', async () => {
249
+ const { client, exec, calls } = execStub();
250
+ const result = await steps.deployExec(client as never, {
251
+ appId: '77',
252
+ manifest: execManifest as never,
253
+ crates: execCrates,
254
+ });
255
+ expect(result).toEqual({ buildId: 'b1', version: 3, restarted: [] });
256
+ expect(exec.build).toHaveBeenCalledWith('77', execCrates);
257
+ expect(exec.deploy).toHaveBeenCalledWith({
258
+ appId: '77',
259
+ root: 'construct',
260
+ types: execManifest.types,
261
+ buildId: 'b1',
262
+ });
263
+ expect(calls).toEqual(['build', 'wait', 'deploy']);
264
+ });
265
+
266
+ it('switches the named types off and on after the deploy when asked to restart', async () => {
267
+ const { client, calls } = execStub();
268
+ let retried = 0;
269
+ await steps.deployExec(client as never, {
270
+ appId: '77',
271
+ manifest: execManifest as never,
272
+ crates: execCrates,
273
+ restart: ['construct', 'world'],
274
+ restartPauseMs: 0,
275
+ retry: async (request) => {
276
+ retried += 1;
277
+ return request();
278
+ },
279
+ });
280
+ expect(calls).toEqual([
281
+ 'build',
282
+ 'wait',
283
+ 'deploy',
284
+ 'construct:off',
285
+ 'world:off',
286
+ 'construct:on',
287
+ 'world:on',
288
+ ]);
289
+ expect(retried).toBe(7);
290
+ });
291
+
292
+ it('refuses to deploy a failed build and shows why', async () => {
293
+ const { client, exec } = execStub({ status: 'failed', log: 'error[E0425]: cannot find value' });
294
+ await expect(
295
+ steps.deployExec(client as never, {
296
+ appId: '77',
297
+ manifest: execManifest as never,
298
+ crates: execCrates,
299
+ }),
300
+ ).rejects.toThrow(/build b1 failed:\nerror\[E0425\]/);
301
+ expect(exec.deploy).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it('runs the ck-exec step, on its own client, where a game has no model', async () => {
305
+ const identity = identityStub();
306
+ identity.marketplace = {
307
+ gridClaimPolicy: vi.fn(async () => 'SELF_CLAIM'),
308
+ setGridClaimPolicy: vi.fn(),
309
+ };
310
+ identity.graphql = {
311
+ query: vi.fn(async () => {
312
+ throw new Error('manage_compute required');
313
+ }),
314
+ };
315
+ const game = { crowdyStudio: { listCommonFiles: vi.fn(async () => []) }, kit: vi.fn() };
316
+ const { client, calls } = execStub();
317
+ const clientFor = vi.fn(async () => client);
318
+ const done: string[] = [];
319
+ const report = await steps.runOnboarding({
320
+ identity,
321
+ userId: '42',
322
+ enterApp: async () => game as never,
323
+ orgName: 'My studio',
324
+ appName: 'The Construct',
325
+ exec: { manifest: execManifest as never, crates: execCrates, client: clientFor as never },
326
+ onStep: (event) => {
327
+ if (event.status === 'done') done.push(event.id);
328
+ },
329
+ });
330
+ expect(report.appId).toBe('77');
331
+ expect(done).toEqual([
332
+ 'org',
333
+ 'app',
334
+ 'tier',
335
+ 'enter',
336
+ 'claims',
337
+ 'exec',
338
+ 'studio',
339
+ 'agent',
340
+ 'github',
341
+ ]);
342
+ expect(clientFor).toHaveBeenCalledWith('77');
343
+ expect(calls).toEqual(['build', 'wait', 'deploy']);
344
+ expect(client.close).toHaveBeenCalled();
345
+ expect(game.kit).not.toHaveBeenCalled();
346
+ });
347
+
348
+ it('keeps use_studio_agent on Constructor and off the visitor default', () => {
349
+ expect(steps.CONSTRUCTOR_TIER_KEYS).toContain('use_studio_agent');
350
+ expect(steps.VISITOR_RUN_KEYS).not.toContain('use_studio_agent');
351
+ });
352
+
353
+ it('writes only the app agent policy and never calls cp* fields', async () => {
354
+ const query = vi.fn(async (document: string, _variables?: Any) => {
355
+ expect(document).not.toMatch(/\bcp[A-Z]/);
356
+ if (document.includes('ConstructSetAgentPolicy')) {
357
+ return {
358
+ setCrowdyStudioAgentPolicy: {
359
+ enabled: true,
360
+ killSwitch: false,
361
+ allowedModes: [...steps.STUDIO_AGENT_MODES],
362
+ revision: '1',
363
+ },
364
+ };
365
+ }
366
+ return {
367
+ crowdyStudioAgentPolicy: { enabled: false, revision: '0' },
368
+ crowdyStudioAgentEffectivePolicy: {
369
+ enabled: false,
370
+ killSwitch: false,
371
+ allowedModelIds: [...steps.STUDIO_AGENT_MODELS],
372
+ allowedModes: [],
373
+ },
374
+ };
375
+ });
376
+ const result = await steps.ensureAgentPolicy({ graphql: { query } } as never, { appId: '77' });
377
+ expect(result).toEqual({ enabled: true, skipped: false });
378
+ const appInput = (
379
+ query.mock.calls.find((c: Any[]) => String(c[0]).includes('ConstructSetAgentPolicy'))?.[1] as
380
+ { input: Any } | undefined
381
+ )?.input;
382
+ expect(appInput).toEqual(
383
+ expect.objectContaining({
384
+ appId: '77',
385
+ enabled: true,
386
+ allowedModes: [...steps.STUDIO_AGENT_MODES],
387
+ allowedModelIds: [...steps.STUDIO_AGENT_MODELS],
388
+ idempotencyKey: 'construct-agent-app-77-v5',
389
+ }),
390
+ );
391
+ expect(appInput?.allowedModelIds).toEqual([...steps.STUDIO_AGENT_MODELS]);
392
+ expect(appInput?.allowedToolNames).toBeUndefined();
393
+ expect(appInput?.allowedRiskClasses).toBeUndefined();
394
+ });
395
+
396
+ it('skips writes when effective policy already allows Ask/Build', async () => {
397
+ const ready = {
398
+ enabled: true,
399
+ killSwitch: false,
400
+ allowedModelIds: [steps.STUDIO_AGENT_MODEL],
401
+ allowedModes: [...steps.STUDIO_AGENT_MODES],
402
+ };
403
+ const query = vi.fn(async () => ({
404
+ crowdyStudioAgentPolicy: ready,
405
+ crowdyStudioAgentEffectivePolicy: ready,
406
+ }));
407
+ const result = await steps.ensureAgentPolicy({ graphql: { query } } as never, { appId: '77' });
408
+ expect(result).toEqual({ enabled: true, skipped: true });
409
+ expect(query.mock.calls.some((c: Any[]) => String(c[0]).includes('mutation'))).toBe(false);
410
+ });
411
+
412
+ it('stays green when the caller cannot read app policy', async () => {
413
+ const query = vi.fn(async () => {
414
+ throw new Error('manage_compute required');
415
+ });
416
+ const lines: string[] = [];
417
+ const result = await steps.ensureAgentPolicy(
418
+ { graphql: { query } } as never,
419
+ { appId: '77' },
420
+ (line) => lines.push(line),
421
+ );
422
+ expect(result).toEqual({ enabled: false, skipped: true });
423
+ expect(lines.some((line) => /Studio → your app → Agent/.test(line))).toBe(true);
424
+ });
425
+
426
+ it('stays green and logs when the platform catalog is unpublished after the app write', async () => {
427
+ const query = vi.fn(async (document: string) => {
428
+ expect(document).not.toMatch(/\bcp[A-Z]/);
429
+ if (document.includes('ConstructSetAgentPolicy')) {
430
+ return {
431
+ setCrowdyStudioAgentPolicy: {
432
+ enabled: true,
433
+ killSwitch: false,
434
+ allowedModes: [...steps.STUDIO_AGENT_MODES],
435
+ },
436
+ };
437
+ }
438
+ return {
439
+ crowdyStudioAgentPolicy: { enabled: false, revision: '0' },
440
+ crowdyStudioAgentEffectivePolicy: {
441
+ enabled: false,
442
+ killSwitch: false,
443
+ allowedModelIds: [],
444
+ allowedModes: [],
445
+ },
446
+ };
447
+ });
448
+ const lines: string[] = [];
449
+ const result = await steps.ensureAgentPolicy(
450
+ { graphql: { query } } as never,
451
+ { appId: '77' },
452
+ (line) => lines.push(line),
453
+ );
454
+ expect(result).toEqual({ enabled: true, skipped: false });
455
+ expect(lines.some((line) => /platform catalog not published/.test(line))).toBe(true);
456
+ });
457
+
458
+ it('checks GitHub integration status and logs advisory when unconnected', async () => {
459
+ const lines: string[] = [];
460
+ const unconnectedGame = {
461
+ crowdyStudioGitHub: {
462
+ status: async () => ({ connected: false, owner: null, repo: null }),
463
+ },
464
+ };
465
+ const resUnconnected = await steps.checkGitHubIntegration(
466
+ unconnectedGame as never,
467
+ { appId: '77' },
468
+ (l) => lines.push(l),
469
+ );
470
+ expect(resUnconnected.connected).toBe(false);
471
+ expect(resUnconnected.skipped).toBe(true);
472
+ expect(lines.some((l) => /Advisory: No GitHub repository bound/.test(l))).toBe(true);
473
+
474
+ const connectedGame = {
475
+ crowdyStudioGitHub: {
476
+ status: async () => ({ connected: true, owner: 'modder', repo: 'my-mod' }),
477
+ },
478
+ };
479
+ const resConnected = await steps.checkGitHubIntegration(connectedGame as never, {
480
+ appId: '77',
481
+ });
482
+ expect(resConnected.connected).toBe(true);
483
+ expect(resConnected.skipped).toBe(false);
484
+ expect(resConnected.repo).toBe('modder/my-mod');
485
+ });
486
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The game's single World Stores session: SDK-managed state over ONE shared
3
+ * `udpNotifications` subscription.
4
+ *
5
+ * - `self` — the local actor: stable uuid, typed pose, 5 Hz send loop with
6
+ * send-on-change dedup and keyframes. Scenes feed it a pose.
7
+ * - `actors` — everyone else, decoded once and routed into lanes. Both scenes
8
+ * render from `actors.lane('players')`; program-specific actors
9
+ * would get their own lane.
10
+ * - `chunks` — the voxel cache Paint draws in 2D and the holodeck draws as
11
+ * cubes. CLIENT `voxel_set` writes here so other players see
12
+ * the same blocks.
13
+ * - `errors` — server-reported send failures attributed to what we sent.
14
+ * - `events` — SERVER `emit_spatial("server_event")` catalog/pose packets
15
+ * for the replicated instance layer (construct.scene.v1).
16
+ * - `host` — actor heartbeats (keeps presence fresh for server-side gates
17
+ * such as player-compute occupancy; the elected host is
18
+ * informational).
19
+ * - `save` — the typed per-user save blob, autosaved.
20
+ *
21
+ * Created lazily so it binds the ROUTED game client and the app the player
22
+ * actually entered; disposed when the player leaves the app.
23
+ */
24
+ import {
25
+ createWorldSession,
26
+ jsonCodec,
27
+ localStorageUuidStore,
28
+ type WorldSession,
29
+ } from '@crowdedkingdoms/crowdyjs/stores';
30
+
31
+ import { ACTOR_SYNC_INTERVAL_MS, REPLICATION_DISTANCE, STALE_ACTOR_TIMEOUT_MS } from '../config';
32
+ import { envScopedKey } from '../envScope';
33
+ import { NetworkManager } from '../network/NetworkManager';
34
+ import { NEUTRAL_POSE, poseCodec, type Pose } from './actorCodec';
35
+ import { InstanceStore } from '../studio/instanceStore';
36
+
37
+ /** What survives between sessions. Deliberately small; add fields freely. */
38
+ export interface SaveState {
39
+ version: 1;
40
+ position?: { x: number; y: number; z: number; yaw: number };
41
+ lastProgram?: number;
42
+ visits: number;
43
+ tint?: number;
44
+ }
45
+
46
+ export const EMPTY_SAVE: SaveState = { version: 1, visits: 0 };
47
+
48
+ function buildConfig() {
49
+ return {
50
+ self: {
51
+ codec: poseCodec,
52
+ initialState: NEUTRAL_POSE,
53
+ uuidStore: localStorageUuidStore(envScopedKey('construct:actor-uuid')),
54
+ sendIntervalMs: ACTOR_SYNC_INTERVAL_MS,
55
+ distance: REPLICATION_DISTANCE,
56
+ },
57
+ actors: {
58
+ codec: poseCodec,
59
+ staleAfterMs: STALE_ACTOR_TIMEOUT_MS,
60
+ historySize: 3,
61
+ lanes: {
62
+ players: () => true,
63
+ },
64
+ },
65
+ chunks: {
66
+ distance: REPLICATION_DISTANCE,
67
+ // The Construct has no terrain to generate: a chunk the server has never
68
+ // stored is simply empty. Seed it locally (no write-back) so the paint
69
+ // program and CLIENT mods always see a dense grid.
70
+ onMissing: () => ({ voxels: new Uint8Array(4096), writeBack: false }),
71
+ },
72
+ errors: true as const,
73
+ events: true as const,
74
+ host: {
75
+ intervalMs: 3_000,
76
+ myUserId: () => NetworkManager.instance.user?.userId ?? null,
77
+ },
78
+ save: {
79
+ codec: jsonCodec<SaveState>(),
80
+ autosaveMs: 10_000,
81
+ },
82
+ };
83
+ }
84
+
85
+ export type ConstructWorldSession = WorldSession<ReturnType<typeof buildConfig>>;
86
+
87
+ let session: ConstructWorldSession | null = null;
88
+ let sessionAppId: string | null = null;
89
+ let instances: InstanceStore | null = null;
90
+
91
+ /** The lazily created shared session for the CURRENT app. */
92
+ export function worldSession(): ConstructWorldSession {
93
+ const network = NetworkManager.instance;
94
+ const appId = network.appId;
95
+ if (!appId) throw new Error('worldSession() needs an entered app');
96
+ if (session && sessionAppId !== appId) disposeWorldSession();
97
+ if (!session) {
98
+ session = createWorldSession(network.game, appId, buildConfig());
99
+ sessionAppId = appId;
100
+ instances = new InstanceStore(session.events);
101
+ session.errors.onError((error) => {
102
+ network.log(`UDP send error ${error.errorCode}${error.send ? ` (${error.send.kind})` : ''}`);
103
+ });
104
+ }
105
+ return session;
106
+ }
107
+
108
+ export function hasWorldSession(): boolean {
109
+ return session !== null;
110
+ }
111
+
112
+ export function disposeWorldSession(): void {
113
+ instances?.dispose();
114
+ instances = null;
115
+ session?.dispose();
116
+ session = null;
117
+ sessionAppId = null;
118
+ }
119
+
120
+ /** Replicated construct.scene.v1 instances for the current app. */
121
+ export function instanceStore(): InstanceStore {
122
+ worldSession();
123
+ if (!instances) throw new Error('instanceStore() needs a world session');
124
+ return instances;
125
+ }
126
+
127
+ /** Remote players as the scenes want them: pose + chunk + freshness. */
128
+ export interface RemotePlayer {
129
+ uuid: string;
130
+ pose: Pose;
131
+ /** Newest-first samples for interpolation. */
132
+ samples: Array<{ pose: Pose; epochMillis: number; receivedAt: number }>;
133
+ chunk: { x: number; y: number; z: number };
134
+ receivedAt: number;
135
+ }
136
+
137
+ export function remotePlayers(): RemotePlayer[] {
138
+ if (!session) return [];
139
+ return session.actors
140
+ .lane('players')
141
+ .list()
142
+ .map((actor) => ({
143
+ uuid: actor.uuid,
144
+ pose: actor.state,
145
+ samples: actor.samples.map((s) => ({
146
+ pose: s.state,
147
+ epochMillis: s.epochMillis,
148
+ receivedAt: s.receivedAt,
149
+ })),
150
+ chunk: { x: Number(actor.chunk.x), y: Number(actor.chunk.y), z: Number(actor.chunk.z) },
151
+ receivedAt: actor.receivedAt,
152
+ }));
153
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ FLAG_IN_PROGRAM,
5
+ FLAG_STUDIO_OPEN,
6
+ NAME_BYTES,
7
+ NEUTRAL_POSE,
8
+ POSE_BYTES,
9
+ decodeName,
10
+ encodeName,
11
+ poseCodec,
12
+ type Pose,
13
+ } from './actorCodec';
14
+
15
+ describe('poseCodec', () => {
16
+ it('is a fixed 64-byte layout', () => {
17
+ expect(POSE_BYTES).toBe(64);
18
+ const bytes = atob(poseCodec.encode(NEUTRAL_POSE)).length;
19
+ expect(bytes).toBe(64);
20
+ });
21
+
22
+ it('round-trips a pose (floats to f32 precision)', () => {
23
+ const pose: Pose = {
24
+ x: 12.5,
25
+ y: 0.25,
26
+ z: -7.75,
27
+ yaw: 1.5,
28
+ pitch: -0.125,
29
+ vx: 3,
30
+ vy: 0,
31
+ vz: -1.5,
32
+ flags: FLAG_IN_PROGRAM | FLAG_STUDIO_OPEN,
33
+ program: 1,
34
+ tint: 5,
35
+ name: 'neo',
36
+ };
37
+ const decoded = poseCodec.decode(poseCodec.encode(pose));
38
+ expect(decoded).toEqual(pose);
39
+ });
40
+
41
+ it('truncates names to the byte budget without splitting a character', () => {
42
+ const long = 'ünïcödé-name-that-is-far-too-long-for-the-wire';
43
+ const encoded = encodeName(long);
44
+ expect(encoded.length).toBe(NAME_BYTES);
45
+ const back = decodeName(encoded);
46
+ expect(long.startsWith(back)).toBe(true);
47
+ expect(new TextEncoder().encode(back).length).toBeLessThanOrEqual(NAME_BYTES);
48
+ });
49
+
50
+ it('masks flag/program/tint to one byte', () => {
51
+ const decoded = poseCodec.decode(
52
+ poseCodec.encode({ ...NEUTRAL_POSE, flags: 0x1ff, program: 300, tint: 264 }),
53
+ );
54
+ expect(decoded.flags).toBe(0xff);
55
+ expect(decoded.program).toBe(300 & 0xff);
56
+ expect(decoded.tint).toBe(264 & 0xff);
57
+ });
58
+
59
+ it('refuses a payload shorter than the layout', () => {
60
+ expect(() => poseCodec.decode('AA==')).toThrow(/structCodec/);
61
+ });
62
+ });