@crowdedkingdoms/construct 0.3.11-test.1 → 0.4.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.
@@ -44,6 +44,10 @@ import { messageOf } from '../network/NetworkManager';
44
44
  import { isTextEntry } from '../../engine/Input';
45
45
  import { chunkKey, worldToChunk, type ChunkCoord, type Vec3 } from '../realtime/space';
46
46
  import { AgentLocomotion } from './agentLocomotion';
47
+ import { wantsStudioOpen } from './studioQuery';
48
+ import type { GameContentHost, PreviewStatus } from '../live/content';
49
+ import { DevServerDraftStore, devServerDraftToken } from '../live/devServerDraftStore';
50
+ import { liveCodeSdk, type DraftProject, type DraftStore } from '../live/sdk';
47
51
  import { describeClientHalfPrompt, GridClientHalves, studioPreviewModName } from './clientHalves';
48
52
  import { ConstructPlayerHostAdapter } from './ConstructPlayerHostAdapter';
49
53
  import { HumanInputMonitor } from './HumanInputMonitor';
@@ -119,6 +123,26 @@ export interface StudioHooks {
119
123
  /** Screenshot hook for the agent pane. */
120
124
  captureFrame?(): Promise<HTMLCanvasElement | ImageBitmap | Blob | null>;
121
125
  describeView?(): string | undefined;
126
+ /**
127
+ * The game's content layer. With it, the game editor previews the draft in
128
+ * the running world (Vite HMR on a dev server, the draft service worker on
129
+ * a hosted page) and answers the agent's `preview_status`.
130
+ */
131
+ content?: GameContentHost;
132
+ }
133
+
134
+ /** The context the game editor opens in when no grid is open (mods cannot be built from it). */
135
+ const GAME_EDITOR_GRID = 'game-editor';
136
+
137
+ function baseUrl(): string {
138
+ return import.meta.env.BASE_URL.replace(/\/?$/, '/');
139
+ }
140
+
141
+ /** A Vite error's file, as a repository path (the dev server's root is not known here). */
142
+ function repoPathOf(file: string | undefined): string | undefined {
143
+ if (!file) return undefined;
144
+ const match = /(?:^|\/)((?:src|model|packages)\/.+)$/.exec(file.replace(/\\/g, '/'));
145
+ return match?.[1];
122
146
  }
123
147
 
124
148
  /**
@@ -293,6 +317,24 @@ export class StudioService {
293
317
  attach(hooks: StudioHooks): void {
294
318
  this.hooks = hooks;
295
319
  const network = () => this.session.network;
320
+ const dsh = {
321
+ graphql: network().game.graphql,
322
+ // Under Vite's base, so a build served from a sub-path (`vite build
323
+ // --base /the-construct/`) finds its own harness instead of the
324
+ // origin's root. The default base is '/', which keeps '/dsh/'.
325
+ webBase: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}dsh/`,
326
+ graphqlUrl: `${API_HTTP_URL}/graphql`,
327
+ apiOrigin: API_HTTP_URL,
328
+ getToken: () => network().game.getToken(),
329
+ persistScope: `${this.session.appId}/${this.session.selfUuid}`,
330
+ studioOrigin: STUDIO_ORIGIN ?? undefined,
331
+ openOnMount: wantsStudioOpen(globalThis.location?.search ?? ''),
332
+ };
333
+ // The game editor (CrowdyJS 18.23+), for a project bound to a Construct
334
+ // game the player owns: the controller asks for the draft when it opens one.
335
+ const gameEditor: { draftStore?: (project: DraftProject) => DraftStore | null } = hooks.content
336
+ ? { draftStore: (project) => this.draftStoreFor(project) }
337
+ : {};
296
338
  this.embed = new CrowdyStudioEmbed({
297
339
  // Resolved lazily: the game client exists only after enterApp, and is
298
340
  // rebuilt if the player switches apps.
@@ -323,19 +365,8 @@ export class StudioService {
323
365
  appId: () => this.session.appId,
324
366
  gameName: GAME_NAME,
325
367
  closeKeyCode: 'KeyM',
326
- dsh: {
327
- graphql: network().game.graphql,
328
- // Under Vite's base, so a build served from a sub-path (`vite build
329
- // --base /the-construct/`) finds its own harness instead of the
330
- // origin's root. The default base is '/', which keeps '/dsh/'.
331
- webBase: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}dsh/`,
332
- graphqlUrl: `${API_HTTP_URL}/graphql`,
333
- apiOrigin: API_HTTP_URL,
334
- getToken: () => network().game.getToken(),
335
- persistScope: `${this.session.appId}/${this.session.selfUuid}`,
336
- studioOrigin: STUDIO_ORIGIN ?? undefined,
337
- openOnMount: true,
338
- },
368
+ dsh,
369
+ ...gameEditor,
339
370
  onAgentMounted: (handle) => {
340
371
  this.watchStudio(handle.controller);
341
372
  this.state = { ...this.state, agentReady: true, agentReason: null };
@@ -360,6 +391,7 @@ export class StudioService {
360
391
  this.hud.remove(source);
361
392
  }
362
393
  this.studioGridId = null;
394
+ this.stopPreview();
363
395
  this.setOpen(false);
364
396
  void this.refreshClientHalves();
365
397
  },
@@ -377,6 +409,25 @@ export class StudioService {
377
409
  return this.embed?.open ?? false;
378
410
  }
379
411
 
412
+ /**
413
+ * Open Crowdy Studio on `projectId` (Crowdy Studio's Live code link names
414
+ * one): on the grid underfoot when the player may edit it, otherwise in the
415
+ * game editor alone. A project bound to a Construct game opens its editor
416
+ * either way; the controller decides from the repository.
417
+ */
418
+ openProject(projectId: string): void {
419
+ if (!this.embed) return;
420
+ if (this.embed.open) this.embed.close();
421
+ const grid =
422
+ this.currentGrid && hasAnyStudioPermission(this.currentGrid.permissions)
423
+ ? this.currentGrid
424
+ : null;
425
+ const opened: { projectId: string } = { projectId };
426
+ this.embed.toggle({ ...(grid ? this.contextFor(grid) : this.gameEditorContext()), ...opened });
427
+ this.studioGridId = grid && this.embed.open ? grid.gridId : null;
428
+ this.setOpen(true);
429
+ }
430
+
380
431
  /** Claim the chunk under `position`, then open the studio on it. */
381
432
  async claimHereAndOpen(position: Vec3): Promise<GridSnapshot> {
382
433
  const chunk = worldToChunk(position);
@@ -462,6 +513,7 @@ export class StudioService {
462
513
  }
463
514
 
464
515
  dispose(): void {
516
+ this.stopPreview();
465
517
  if (this.timer) clearInterval(this.timer);
466
518
  this.timer = null;
467
519
  this.halves?.stop();
@@ -539,12 +591,175 @@ export class StudioService {
539
591
  }
540
592
  : {}),
541
593
  playerHost: this.agentHost,
542
- dshHost: {
543
- captureFrame: async () => (await this.hooks?.captureFrame?.()) ?? null,
544
- describeView: () => this.hooks?.describeView?.(),
545
- grid: this.programs,
594
+ dshHost: this.dshHost(),
595
+ };
596
+ }
597
+
598
+ /** The game editor without a grid: no mod can be built or run from here. */
599
+ private gameEditorContext(): CrowdyStudioEmbedContext {
600
+ const none = { canWrite: false, canRun: false };
601
+ return {
602
+ gridId: GAME_EDITOR_GRID,
603
+ targetPermissions: { SERVER: none, CLIENT: none },
604
+ permissionsNote:
605
+ 'Game editor: no grid is open. Stand on your claimed chunk and press M to build mods.',
606
+ playerHost: this.agentHost,
607
+ dshHost: this.dshHost(),
608
+ };
609
+ }
610
+
611
+ private dshHost(): NonNullable<CrowdyStudioEmbedContext['dshHost']> {
612
+ // CrowdyJS 18.23 reads these (the playbook, live-modding checks, the
613
+ // preview status); the template's SDK pin may not list them yet.
614
+ const extras: {
615
+ playbookBase: string;
616
+ authorizeLiveModding(action: 'draft_test' | 'deploy_live'): Promise<void>;
617
+ previewStatus?(params: {
618
+ afterRevision?: number;
619
+ timeoutMs?: number;
620
+ }): Promise<PreviewStatus>;
621
+ } = {
622
+ playbookBase: `${baseUrl()}playbook/`,
623
+ authorizeLiveModding: async (action) => {
624
+ await this.session.network.game.graphql.query(
625
+ `query CrowdyStudioLiveModding($appId: BigInt!, $action: String!) {
626
+ crowdyStudioLiveModding(appId: $appId, action: $action)
627
+ }`,
628
+ { appId: this.session.appId, action },
629
+ );
546
630
  },
631
+ ...(this.hooks?.content
632
+ ? { previewStatus: (params) => this.hooks!.content!.status(params) }
633
+ : {}),
547
634
  };
635
+ return {
636
+ captureFrame: async () => (await this.hooks?.captureFrame?.()) ?? null,
637
+ describeView: () => this.hooks?.describeView?.(),
638
+ grid: this.programs,
639
+ ...extras,
640
+ };
641
+ }
642
+
643
+ /**
644
+ * The draft of a game project: the dev server's checkout when this page is
645
+ * served by one with the live draft on, otherwise the hosted store (an
646
+ * overlay over GitHub in this browser). Starts the matching preview.
647
+ */
648
+ private draftStoreFor(project: DraftProject): DraftStore | null {
649
+ const sdk = liveCodeSdk();
650
+ if (!sdk) {
651
+ this.hooks?.notify(
652
+ 'This game’s CrowdyJS predates the game editor; the project opens in the mod editor.',
653
+ 'warn',
654
+ );
655
+ return null;
656
+ }
657
+ const transport = this.session.network.game.crowdyStudioGitHub;
658
+ const token = import.meta.env.DEV ? devServerDraftToken() : null;
659
+ const store: DraftStore = token
660
+ ? new DevServerDraftStore({
661
+ base: baseUrl(),
662
+ token,
663
+ appId: project.appId,
664
+ projectId: project.projectId,
665
+ transport,
666
+ sdk,
667
+ ...(import.meta.hot ? { hot: import.meta.hot } : {}),
668
+ })
669
+ : new sdk.GitHubDraftStore({
670
+ transport,
671
+ appId: project.appId,
672
+ projectId: project.projectId,
673
+ baseCommitSha: project.githubSha,
674
+ storage: new sdk.IndexedDbDraftOverlayStorage(),
675
+ });
676
+ this.startPreview(store, token ? 'hmr' : 'service-worker');
677
+ return store;
678
+ }
679
+
680
+ private preview: { dispose(): void } | null = null;
681
+
682
+ private startPreview(store: DraftStore, mode: 'hmr' | 'service-worker'): void {
683
+ this.stopPreview();
684
+ const content = this.hooks?.content;
685
+ if (!content) return;
686
+ if (mode === 'hmr') {
687
+ // main.ts's accept boundary swaps the content; this records what it did not see.
688
+ const hot = import.meta.hot;
689
+ const off = store.subscribe((event) => content.noteRevision(event.revision));
690
+ const onPreview = (data: {
691
+ path: string;
692
+ kind: 'not-loaded' | 'needs-reload';
693
+ revision: number;
694
+ }) => {
695
+ content.settleUnloaded(
696
+ data.revision,
697
+ data.kind === 'needs-reload'
698
+ ? `${data.path} is outside the content layer (src/game/content.ts and what it imports): reload the page to see it.`
699
+ : `${data.path} is not part of the running game; nothing to reload.`,
700
+ );
701
+ };
702
+ const onError = (payload: {
703
+ err: {
704
+ message: string;
705
+ loc?: { file?: string; line?: number; column?: number };
706
+ id?: string;
707
+ };
708
+ }) => {
709
+ const path = repoPathOf(payload.err.loc?.file ?? payload.err.id);
710
+ content.fail([
711
+ {
712
+ ...(path ? { path } : {}),
713
+ ...(payload.err.loc?.line ? { line: payload.err.loc.line } : {}),
714
+ ...(payload.err.loc?.column ? { column: payload.err.loc.column } : {}),
715
+ message: payload.err.message,
716
+ },
717
+ ]);
718
+ };
719
+ hot?.on('construct:preview', onPreview);
720
+ hot?.on('vite:error', onError);
721
+ this.preview = {
722
+ dispose: () => {
723
+ off();
724
+ hot?.off?.('construct:preview', onPreview);
725
+ hot?.off?.('vite:error', onError);
726
+ },
727
+ };
728
+ return;
729
+ }
730
+ let loader: { dispose(): void } | null = null;
731
+ let stopped = false;
732
+ this.preview = {
733
+ dispose: () => {
734
+ stopped = true;
735
+ loader?.dispose();
736
+ },
737
+ };
738
+ void import('../live/draftLoader')
739
+ .then(({ DraftLoader }) =>
740
+ DraftLoader.start({
741
+ base: baseUrl(),
742
+ store,
743
+ content,
744
+ env: { ...import.meta.env },
745
+ log: (line) => this.session.network.log(line),
746
+ }),
747
+ )
748
+ .then(
749
+ (started) => {
750
+ if (stopped) started.dispose();
751
+ else loader = started;
752
+ },
753
+ (error: unknown) => {
754
+ content.fail([{ message: messageOf(error) }]);
755
+ this.hooks?.notify(`The draft preview could not start: ${messageOf(error)}`, 'warn');
756
+ },
757
+ );
758
+ }
759
+
760
+ private stopPreview(): void {
761
+ this.preview?.dispose();
762
+ this.preview = null;
548
763
  }
549
764
 
550
765
  /**
@@ -0,0 +1,65 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { envScopedKey } from '../envScope';
3
+ import { rememberRequestedProject, requestedProject, wantsStudioOpen } from './studioQuery';
4
+
5
+ const KEPT = envScopedKey('construct:requested-project');
6
+
7
+ describe('wantsStudioOpen', () => {
8
+ it('opens for the live-code query and for a page that says nothing', () => {
9
+ expect(wantsStudioOpen('?app=1&studio=open')).toBe(true);
10
+ expect(wantsStudioOpen('')).toBe(true);
11
+ expect(wantsStudioOpen('?studio=closed')).toBe(false);
12
+ });
13
+ });
14
+
15
+ describe('requestedProject', () => {
16
+ beforeEach(() => sessionStorage.clear());
17
+
18
+ it('reads an id-shaped project and ignores anything else', () => {
19
+ expect(requestedProject('?app=1&studio=open&project=129481')).toBe('129481');
20
+ expect(requestedProject('?project=')).toBeNull();
21
+ expect(requestedProject('?project=../../etc')).toBeNull();
22
+ expect(requestedProject('')).toBeNull();
23
+ });
24
+
25
+ it('opens the remembered project once after a sign-in returns to the bare address', () => {
26
+ rememberRequestedProject('?app=1&studio=open&project=129481');
27
+ // The sign-in return: the address carries only the code and state.
28
+ rememberRequestedProject('?code=c&state=s');
29
+ expect(requestedProject('?code=c&state=s')).toBe('129481');
30
+ expect(requestedProject('')).toBeNull();
31
+ });
32
+
33
+ it('prefers the address to a remembered project, and uses the remembered one up', () => {
34
+ rememberRequestedProject('?project=older');
35
+ expect(requestedProject('?project=newer')).toBe('newer');
36
+ expect(sessionStorage.getItem(KEPT)).toBeNull();
37
+ });
38
+
39
+ it('keeps only an id-shaped project, and never opens a tampered one', () => {
40
+ rememberRequestedProject('?project=..%2F..%2Fetc');
41
+ rememberRequestedProject('?project=');
42
+ expect(sessionStorage.getItem(KEPT)).toBeNull();
43
+ sessionStorage.setItem(KEPT, '<img src=x>');
44
+ expect(requestedProject('')).toBeNull();
45
+ expect(sessionStorage.getItem(KEPT)).toBeNull();
46
+ });
47
+
48
+ it('carries on without storage', () => {
49
+ const denied = {
50
+ getItem: () => {
51
+ throw new Error('denied');
52
+ },
53
+ setItem: () => {
54
+ throw new Error('denied');
55
+ },
56
+ removeItem: () => {
57
+ throw new Error('denied');
58
+ },
59
+ };
60
+ expect(() => rememberRequestedProject('?project=129481', denied)).not.toThrow();
61
+ expect(requestedProject('?project=129481', denied)).toBe('129481');
62
+ expect(requestedProject('', denied)).toBeNull();
63
+ expect(requestedProject('?project=129481', undefined)).toBe('129481');
64
+ });
65
+ });
@@ -0,0 +1,74 @@
1
+ import { envScopedKey } from '../envScope';
2
+
3
+ /**
4
+ * Whether the dock should open when the page loads.
5
+ *
6
+ * `studio=open` asks for it. No `studio` parameter keeps today's behavior,
7
+ * which is open. Any other value leaves it closed.
8
+ */
9
+ export function wantsStudioOpen(search: string): boolean {
10
+ const value = new URLSearchParams(search.replace(/^\?/, '')).get('studio');
11
+ return value === null || value === 'open';
12
+ }
13
+
14
+ const PROJECT_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
15
+ const REMEMBERED_PROJECT_KEY = 'construct:requested-project';
16
+
17
+ type TabStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
18
+
19
+ function tabStorage(): TabStorage | undefined {
20
+ try {
21
+ return globalThis.sessionStorage;
22
+ } catch {
23
+ // A frame denied storage throws on the read itself.
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ function projectIn(search: string): string | null {
29
+ const value = new URLSearchParams(search.replace(/^\?/, '')).get('project')?.trim();
30
+ return value && PROJECT_PATTERN.test(value) ? value : null;
31
+ }
32
+
33
+ /**
34
+ * Keep the project the page was asked to open for the rest of this tab's
35
+ * session. A hosted sign-in returns to the bare page address, without the
36
+ * query that named it, so call this before anything can send the page there.
37
+ */
38
+ export function rememberRequestedProject(
39
+ search: string,
40
+ storage: TabStorage | undefined = tabStorage(),
41
+ ): void {
42
+ const project = projectIn(search);
43
+ if (!project) return;
44
+ try {
45
+ storage?.setItem(envScopedKey(REMEMBERED_PROJECT_KEY), project);
46
+ } catch {
47
+ // Storage may be unavailable; after a sign-in the editor then opens no project.
48
+ }
49
+ }
50
+
51
+ function takeRemembered(storage: TabStorage | undefined): string | null {
52
+ const key = envScopedKey(REMEMBERED_PROJECT_KEY);
53
+ try {
54
+ const kept = storage?.getItem(key) ?? null;
55
+ storage?.removeItem(key);
56
+ return kept !== null && PROJECT_PATTERN.test(kept) ? kept : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The Studio project the page was asked to open (`project=<id>`, from Crowdy
64
+ * Studio's Live code link), or null. Only an id-shaped value counts. Without
65
+ * one on the address, the one `rememberRequestedProject` kept before a sign-in.
66
+ * Either way the kept one is used up, so it opens once.
67
+ */
68
+ export function requestedProject(
69
+ search: string,
70
+ storage: TabStorage | undefined = tabStorage(),
71
+ ): string | null {
72
+ const kept = takeRemembered(storage);
73
+ return projectIn(search) ?? kept;
74
+ }