@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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * The game editor's draft on a local dev server: the game's own checkout,
3
+ * through `liveDraftPlugin` (node/live-draft.mjs). Vite HMR previews each
4
+ * change through the content layer; edits made on disk arrive as
5
+ * `construct:draft` events. Commit publishes the dirty files as one GitHub
6
+ * commit through the SDK's transport (as the hosted store does), then moves
7
+ * the checkout's HEAD to it.
8
+ */
9
+ import type {
10
+ DraftChangeEvent,
11
+ DraftCommitResult,
12
+ DraftCommitTransport,
13
+ DraftConflict,
14
+ DraftDeleteResult,
15
+ DraftErrorCode,
16
+ DraftPrecondition,
17
+ DraftReadResult,
18
+ DraftSnapshot,
19
+ DraftStore,
20
+ DraftWriteResult,
21
+ LiveCodeSdk,
22
+ } from './sdk';
23
+
24
+ const TOKEN_META = 'construct-live-draft';
25
+ const READ_BATCH = 100;
26
+
27
+ /** The token a dev server with the live draft on puts in this page, or null. */
28
+ export function devServerDraftToken(doc: Document = document): string | null {
29
+ return doc.querySelector<HTMLMetaElement>(`meta[name="${TOKEN_META}"]`)?.content || null;
30
+ }
31
+
32
+ interface ServerDraftEvent {
33
+ revision: number;
34
+ changes: Array<{ path: string; hash: string | null }>;
35
+ source: DraftChangeEvent['source'];
36
+ writer?: string;
37
+ baseCommitSha?: string;
38
+ }
39
+
40
+ /** The slice of Vite's `import.meta.hot` the store listens on. */
41
+ export interface DraftHotChannel {
42
+ on(event: 'construct:draft', listener: (data: ServerDraftEvent) => void): void;
43
+ off?(event: 'construct:draft', listener: (data: ServerDraftEvent) => void): void;
44
+ }
45
+
46
+ export interface DevServerDraftStoreOptions {
47
+ /** The dev server's base path, with a trailing slash. */
48
+ base: string;
49
+ token: string;
50
+ appId: string;
51
+ projectId: string;
52
+ /** The SDK's GitHub transport (`game.crowdyStudioGitHub`); its `commitFiles` arrived in CrowdyJS 18.23. */
53
+ transport: object;
54
+ sdk: Pick<LiveCodeSdk, 'DraftError'>;
55
+ hot?: DraftHotChannel;
56
+ fetch?: typeof fetch;
57
+ }
58
+
59
+ export class DevServerDraftStore implements DraftStore {
60
+ readonly kind = 'dev-server';
61
+ private state: DraftSnapshot = {
62
+ baseCommitSha: null,
63
+ revision: 0,
64
+ entries: [],
65
+ deleted: [],
66
+ truncated: false,
67
+ };
68
+ private readonly listeners = new Set<(event: DraftChangeEvent) => void>();
69
+ private readonly writer = Math.random().toString(36).slice(2);
70
+ private readonly onServer = (data: ServerDraftEvent): void => {
71
+ void this.follow(data);
72
+ };
73
+ private disposed = false;
74
+
75
+ constructor(private readonly options: DevServerDraftStoreOptions) {
76
+ options.hot?.on('construct:draft', this.onServer);
77
+ }
78
+
79
+ private error(
80
+ code: DraftErrorCode,
81
+ message: string,
82
+ conflicts: readonly DraftConflict[] = [],
83
+ ): Error {
84
+ return new this.options.sdk.DraftError(code, message, conflicts);
85
+ }
86
+
87
+ private async api<T>(action: string, body?: Record<string, unknown>): Promise<T> {
88
+ const doFetch = this.options.fetch ?? fetch;
89
+ let response: Response;
90
+ try {
91
+ response = await doFetch(`${this.options.base}__live-draft/${action}`, {
92
+ method: body === undefined ? 'GET' : 'POST',
93
+ credentials: 'same-origin',
94
+ headers: {
95
+ 'X-Construct-Draft-Token': this.options.token,
96
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
97
+ },
98
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
99
+ });
100
+ } catch (error) {
101
+ throw this.error(
102
+ 'DRAFT_UNAVAILABLE',
103
+ `The dev server is unreachable (${(error as Error).message}).`,
104
+ );
105
+ }
106
+ const payload = (await response.json().catch(() => ({}))) as {
107
+ code?: DraftErrorCode;
108
+ message?: string;
109
+ };
110
+ if (!response.ok) {
111
+ throw this.error(
112
+ payload.code ?? 'DRAFT_UNAVAILABLE',
113
+ payload.message ?? `The dev server answered ${response.status}.`,
114
+ );
115
+ }
116
+ return payload as T;
117
+ }
118
+
119
+ private emit(event: DraftChangeEvent): void {
120
+ for (const listener of this.listeners) listener(event);
121
+ }
122
+
123
+ async load(): Promise<DraftSnapshot> {
124
+ this.state = await this.api<DraftSnapshot>('tree');
125
+ return this.state;
126
+ }
127
+
128
+ snapshot(): DraftSnapshot {
129
+ return this.state;
130
+ }
131
+
132
+ async read(paths: readonly string[]): Promise<DraftReadResult> {
133
+ const out: DraftReadResult = { files: [], missing: [], binary: [] };
134
+ for (let i = 0; i < paths.length; i += READ_BATCH) {
135
+ const part = await this.api<DraftReadResult>('read', {
136
+ paths: paths.slice(i, i + READ_BATCH),
137
+ });
138
+ out.files.push(...part.files);
139
+ out.missing.push(...part.missing);
140
+ out.binary.push(...part.binary);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ async write(
146
+ path: string,
147
+ content: string,
148
+ options: { source: 'player' | 'agent'; precondition?: DraftPrecondition },
149
+ ): Promise<DraftWriteResult> {
150
+ const result = await this.api<DraftWriteResult & { dirty: boolean }>('write', {
151
+ path,
152
+ content,
153
+ source: options.source,
154
+ writer: this.writer,
155
+ ...(options.precondition ? { precondition: options.precondition } : {}),
156
+ });
157
+ const size = new TextEncoder().encode(content).byteLength;
158
+ this.state = {
159
+ ...this.state,
160
+ revision: result.revision,
161
+ entries: [
162
+ ...this.state.entries.filter((entry) => entry.path !== path),
163
+ { path, hash: result.hash, size, binary: false, dirty: result.dirty },
164
+ ].sort((a, b) => (a.path < b.path ? -1 : 1)),
165
+ deleted: this.state.deleted.filter((deleted) => deleted !== path),
166
+ };
167
+ this.emit({
168
+ revision: result.revision,
169
+ baseCommitSha: this.state.baseCommitSha,
170
+ changes: [{ path, hash: result.hash }],
171
+ source: options.source,
172
+ });
173
+ return {
174
+ path: result.path,
175
+ hash: result.hash,
176
+ previousHash: result.previousHash,
177
+ revision: result.revision,
178
+ };
179
+ }
180
+
181
+ async remove(
182
+ path: string,
183
+ options: { source: 'player' | 'agent'; precondition?: { kind: 'ifHash'; hash: string } },
184
+ ): Promise<DraftDeleteResult> {
185
+ const result = await this.api<DraftDeleteResult & { inHead: boolean }>('delete', {
186
+ path,
187
+ source: options.source,
188
+ writer: this.writer,
189
+ ...(options.precondition ? { precondition: options.precondition } : {}),
190
+ });
191
+ this.state = {
192
+ ...this.state,
193
+ revision: result.revision,
194
+ entries: this.state.entries.filter((entry) => entry.path !== path),
195
+ deleted: result.inHead
196
+ ? [...new Set([...this.state.deleted, path])].sort()
197
+ : this.state.deleted,
198
+ };
199
+ this.emit({
200
+ revision: result.revision,
201
+ baseCommitSha: this.state.baseCommitSha,
202
+ changes: [{ path, hash: null }],
203
+ source: options.source,
204
+ });
205
+ return { path: result.path, previousHash: result.previousHash, revision: result.revision };
206
+ }
207
+
208
+ async discard(paths?: readonly string[]): Promise<void> {
209
+ await this.api('discard', { writer: this.writer, ...(paths ? { paths: [...paths] } : {}) });
210
+ await this.load();
211
+ this.emit({
212
+ revision: this.state.revision,
213
+ baseCommitSha: this.state.baseCommitSha,
214
+ changes: [],
215
+ source: 'reload',
216
+ });
217
+ }
218
+
219
+ async commit(request: {
220
+ message: string;
221
+ paths?: readonly string[];
222
+ }): Promise<DraftCommitResult> {
223
+ const transport = this.options.transport as Partial<DraftCommitTransport>;
224
+ const commitFiles = transport.commitFiles?.bind(transport);
225
+ if (!commitFiles) {
226
+ throw this.error(
227
+ 'DRAFT_UNAVAILABLE',
228
+ 'This page’s CrowdyJS cannot commit several files at once; update it to 18.23 or later.',
229
+ );
230
+ }
231
+ const tree = await this.load();
232
+ const wanted = (path: string) => !request.paths || request.paths.includes(path);
233
+ const written = tree.entries.filter((entry) => entry.dirty && wanted(entry.path));
234
+ const deleted = tree.deleted.filter(wanted);
235
+ if (written.length + deleted.length === 0)
236
+ throw this.error('DRAFT_NOT_FOUND', 'The draft has nothing to commit.');
237
+ const binary = written.filter((entry) => entry.binary).map((entry) => entry.path);
238
+ if (binary.length > 0) {
239
+ throw this.error(
240
+ 'DRAFT_PATH_REFUSED',
241
+ `Studio commits text only; commit ${binary.join(', ')} with git.`,
242
+ );
243
+ }
244
+ if (!tree.baseCommitSha)
245
+ throw this.error('DRAFT_UNAVAILABLE', 'The checkout has no commit to build on.');
246
+ const texts = await this.read(written.map((entry) => entry.path));
247
+ let result: Awaited<ReturnType<DraftCommitTransport['commitFiles']>>;
248
+ try {
249
+ result = await commitFiles({
250
+ appId: this.options.appId,
251
+ projectId: this.options.projectId,
252
+ message: request.message,
253
+ expectedCommitSha: tree.baseCommitSha,
254
+ changes: [
255
+ ...texts.files.map((file) => ({ path: file.path, content: file.content })),
256
+ ...deleted.map((path) => ({ path, delete: true })),
257
+ ],
258
+ });
259
+ } catch (error) {
260
+ const code =
261
+ (error as { extensions?: { code?: string }; code?: string }).code ??
262
+ (error as { errors?: Array<{ extensions?: { code?: string } }> }).errors?.[0]?.extensions
263
+ ?.code;
264
+ if (
265
+ code === 'GITHUB_STALE_SHA' ||
266
+ /GITHUB_STALE_SHA/.test(String((error as Error).message))
267
+ ) {
268
+ throw this.error(
269
+ 'DRAFT_CONFLICT',
270
+ 'The bound branch on GitHub moved past this checkout. Pull it (git pull), then commit again.',
271
+ );
272
+ }
273
+ throw error;
274
+ }
275
+ try {
276
+ await this.api('sync', { commitSha: result.commitSha, writer: this.writer });
277
+ } catch (error) {
278
+ console.warn(
279
+ `Committed ${result.commitSha.slice(0, 7)}; the checkout did not follow: ${(error as Error).message}`,
280
+ );
281
+ }
282
+ await this.load();
283
+ const committed = [...result.written, ...result.deleted];
284
+ this.emit({
285
+ revision: this.state.revision,
286
+ baseCommitSha: this.state.baseCommitSha,
287
+ changes: committed.map((path) => ({
288
+ path,
289
+ hash: this.state.entries.find((entry) => entry.path === path)?.hash ?? null,
290
+ })),
291
+ source: 'commit',
292
+ });
293
+ return { commitSha: result.commitSha, committed, baseCommitSha: result.commitSha };
294
+ }
295
+
296
+ conflicts(): readonly DraftConflict[] {
297
+ return [];
298
+ }
299
+
300
+ async resolveConflict(): Promise<void> {
301
+ throw this.error(
302
+ 'DRAFT_UNAVAILABLE',
303
+ 'A local checkout settles conflicts with git (pull, then commit again).',
304
+ );
305
+ }
306
+
307
+ subscribe(listener: (event: DraftChangeEvent) => void): () => void {
308
+ this.listeners.add(listener);
309
+ return () => this.listeners.delete(listener);
310
+ }
311
+
312
+ dispose(): void {
313
+ this.disposed = true;
314
+ this.options.hot?.off?.('construct:draft', this.onServer);
315
+ this.listeners.clear();
316
+ }
317
+
318
+ /** A change the dev server announced: another tab's write, an edit on disk, a commit or a discard. */
319
+ private async follow(data: ServerDraftEvent): Promise<void> {
320
+ if (this.disposed || data.writer === this.writer) return;
321
+ await this.load().catch(() => undefined);
322
+ if (this.disposed) return;
323
+ this.emit({
324
+ revision: this.state.revision,
325
+ baseCommitSha: this.state.baseCommitSha,
326
+ changes: data.changes,
327
+ source: data.changes.length === 0 ? 'reload' : data.source,
328
+ });
329
+ }
330
+ }
@@ -0,0 +1,166 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('virtual:construct-draft-host', () => ({
4
+ default: { three: async () => ({ Scene: class {}, default: 'three' }) },
5
+ }));
6
+
7
+ import type { GameScene } from '../../engine/GameScene';
8
+ import { CONTENT_API, GameContentHost } from './content';
9
+ import { DraftLoader } from './draftLoader';
10
+ import type { DraftChangeEvent, DraftEntry } from './sdk';
11
+
12
+ const scene = () => ({ id: 'home' }) as unknown as GameScene;
13
+
14
+ function host() {
15
+ return new GameContentHost({
16
+ contentApi: CONTENT_API,
17
+ scenes: { home: scene },
18
+ home: { sceneId: 'home', spawn: { x: 0, y: 0, z: 0 } },
19
+ programs: [],
20
+ });
21
+ }
22
+
23
+ class FakeStore {
24
+ entries: DraftEntry[] = [];
25
+ revision = 0;
26
+ readonly texts = new Map<string, string>();
27
+ private readonly listeners = new Set<(event: DraftChangeEvent) => void>();
28
+ snapshot() {
29
+ return {
30
+ baseCommitSha: 'b'.repeat(40),
31
+ revision: this.revision,
32
+ entries: this.entries,
33
+ deleted: [],
34
+ truncated: false,
35
+ };
36
+ }
37
+ subscribe(listener: (event: DraftChangeEvent) => void) {
38
+ this.listeners.add(listener);
39
+ return () => this.listeners.delete(listener);
40
+ }
41
+ async read(paths: readonly string[]) {
42
+ return {
43
+ files: paths
44
+ .filter((p) => this.texts.has(p))
45
+ .map((p) => ({ path: p, hash: 'h', content: this.texts.get(p)! })),
46
+ missing: paths.filter((p) => !this.texts.has(p) && !this.entries.some((e) => e.path === p)),
47
+ binary: paths.filter((p) => this.entries.some((e) => e.path === p && e.binary)),
48
+ };
49
+ }
50
+ list(files: Record<string, string>, binary: string[] = []) {
51
+ this.entries = [
52
+ ...Object.keys(files).map((path) => ({
53
+ path,
54
+ hash: 'h',
55
+ size: 1,
56
+ binary: false,
57
+ dirty: false,
58
+ })),
59
+ ...binary.map((path) => ({ path, hash: 'h', size: 1, binary: true, dirty: false })),
60
+ ];
61
+ for (const [path, text] of Object.entries(files)) this.texts.set(path, text);
62
+ this.revision += 1;
63
+ for (const listener of this.listeners)
64
+ listener({ revision: this.revision, baseCommitSha: null, changes: [], source: 'reload' });
65
+ }
66
+ }
67
+
68
+ function fakeServiceWorker() {
69
+ const worker = {};
70
+ let onMessage: ((event: unknown) => void) | undefined;
71
+ const unregister = vi.fn(async () => true);
72
+ const container = {
73
+ controller: worker,
74
+ ready: Promise.resolve({ active: worker }),
75
+ register: vi.fn(async () => ({ active: worker, unregister })),
76
+ addEventListener: (type: string, listener: (event: unknown) => void) => {
77
+ if (type === 'message') onMessage = listener;
78
+ },
79
+ removeEventListener: vi.fn(),
80
+ startMessages: vi.fn(),
81
+ };
82
+ Object.defineProperty(navigator, 'serviceWorker', { value: container, configurable: true });
83
+ const ask = (data: Record<string, unknown>) =>
84
+ new Promise<Record<string, unknown>>((resolve) => {
85
+ const channel = new MessageChannel();
86
+ channel.port1.onmessage = (event) => {
87
+ channel.port1.close();
88
+ resolve(event.data as Record<string, unknown>);
89
+ };
90
+ onMessage?.({
91
+ source: worker,
92
+ data: { type: 'construct-draft', ...data },
93
+ ports: [channel.port2],
94
+ });
95
+ });
96
+ return { container, ask, unregister };
97
+ }
98
+
99
+ const settle = (ms = 400) => new Promise((resolve) => setTimeout(resolve, ms));
100
+
101
+ afterEach(() => {
102
+ Reflect.deleteProperty(navigator, 'serviceWorker');
103
+ });
104
+
105
+ describe('DraftLoader', () => {
106
+ it('registers the worker at the base and waits for the store to list files before loading', async () => {
107
+ const sw = fakeServiceWorker();
108
+ const store = new FakeStore();
109
+ const content = host();
110
+ const loader = await DraftLoader.start({
111
+ base: '/game/',
112
+ store: store as never,
113
+ content,
114
+ env: {},
115
+ });
116
+ expect(sw.container.register).toHaveBeenCalledWith('/game/construct-draft-sw.js', {
117
+ scope: '/game/',
118
+ updateViaCache: 'none',
119
+ });
120
+ await settle();
121
+ expect((await content.status({ timeoutMs: 0 })).ok).toBe(true);
122
+
123
+ store.list({ 'README.md': '# Game' });
124
+ await settle();
125
+ const status = await content.status({ timeoutMs: 0 });
126
+ expect(status.ok).toBe(false);
127
+ expect(status.errors[0]?.message).toMatch(/has no src\/game\/content\.ts/);
128
+ loader.dispose();
129
+ expect(sw.unregister).toHaveBeenCalled();
130
+ });
131
+
132
+ it("answers the worker's questions from the draft and the build", async () => {
133
+ const sw = fakeServiceWorker();
134
+ const store = new FakeStore();
135
+ store.list(
136
+ { 'src/game/content.ts': 'export default {}', 'model/catalog.mjs': 'export const A = 1' },
137
+ ['art/logo.png'],
138
+ );
139
+ const loader = await DraftLoader.start({
140
+ base: '/',
141
+ store: store as never,
142
+ content: host(),
143
+ env: {},
144
+ });
145
+ expect(await sw.ask({ request: 'manifest', generation: 1 })).toEqual({
146
+ ok: true,
147
+ paths: ['src/game/content.ts', 'model/catalog.mjs'],
148
+ });
149
+ expect(await sw.ask({ request: 'source', generation: 1, path: 'model/catalog.mjs' })).toEqual({
150
+ ok: true,
151
+ source: 'export const A = 1',
152
+ });
153
+ expect(await sw.ask({ request: 'source', generation: 1, path: 'art/logo.png' })).toEqual({
154
+ ok: false,
155
+ message: 'art/logo.png is not a text file.',
156
+ });
157
+ expect(await sw.ask({ request: 'host', specifier: 'three' })).toEqual({
158
+ ok: true,
159
+ names: ['Scene', 'default'],
160
+ });
161
+ const unknown = await sw.ask({ request: 'host', specifier: 'left-pad' });
162
+ expect(unknown.ok).toBe(false);
163
+ expect(String(unknown.message)).toMatch(/not in this build of the game/);
164
+ loader.dispose();
165
+ });
166
+ });