@crowdedkingdoms/construct 0.3.11 → 0.4.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.
@@ -26,6 +26,16 @@ export class SceneRouter {
26
26
  this.factories.set(id, factory);
27
27
  }
28
28
 
29
+ /**
30
+ * Swap every factory for a new set, e.g. when the game's content changed.
31
+ * Mounted and cached scenes stay until `dispose()`; the next `load()` then
32
+ * builds from the new factories.
33
+ */
34
+ replace(factories: Readonly<Record<string, SceneFactory>>): void {
35
+ this.factories.clear();
36
+ for (const [id, factory] of Object.entries(factories)) this.factories.set(id, factory);
37
+ }
38
+
29
39
  ids(): string[] {
30
40
  return [...this.factories.keys()];
31
41
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
 
3
- import { WorldHub, configureWorldHub, worldHubAddress } from './worldHub';
3
+ import { WorldHub, configureWorldHub, localWorldHubReply, worldHubAddress } from './worldHub';
4
4
 
5
5
  function fakeClient(connectResults: Array<'fail' | 'ok'> = ['ok']) {
6
6
  const connection = {
@@ -16,6 +16,15 @@ function fakeClient(connectResults: Array<'fail' | 'ok'> = ['ok']) {
16
16
  return { client: { exec } as never, exec, connection };
17
17
  }
18
18
 
19
+ describe('local world hub mock', () => {
20
+ it('answers the reads the page polls without a hub', () => {
21
+ expect(localWorldHubReply('world')).toEqual({ pulses: 0 });
22
+ expect(localWorldHubReply('claims')).toEqual({ claims: [], total: 0 });
23
+ expect(localWorldHubReply('programs')).toEqual({ programs: [] });
24
+ expect(localWorldHubReply('status')).toMatchObject({ local: true });
25
+ });
26
+ });
27
+
19
28
  describe('WorldHub', () => {
20
29
  it('opens one connection, on the host running the hub, for every call', async () => {
21
30
  const { client, exec, connection } = fakeClient();
@@ -11,12 +11,34 @@
11
11
  */
12
12
  import type { CrowdyClient, ExecConnection } from '@crowdedkingdoms/crowdyjs';
13
13
 
14
+ import { localLiveCode } from '../localLiveCode';
15
+
14
16
  export interface WorldHubAddress {
15
17
  nodeType: string;
16
18
  key: string;
17
19
  }
18
20
 
19
21
  let address: WorldHubAddress = { nodeType: 'world', key: 'main' };
22
+ let announcedLocalHub = false;
23
+
24
+ /**
25
+ * Answers used when the page is in the local live-code session.
26
+ * Not a deployed hub: the log line says so.
27
+ */
28
+ export function localWorldHubReply(method: string): Record<string, unknown> {
29
+ switch (method) {
30
+ case 'world':
31
+ return { pulses: 0 };
32
+ case 'programs':
33
+ return { programs: [] };
34
+ case 'claims':
35
+ return { claims: [], total: 0 };
36
+ case 'progress':
37
+ return { player: 'local', xp: 0, level: 1, skillPoints: 0 };
38
+ default:
39
+ return { local: true, method };
40
+ }
41
+ }
20
42
 
21
43
  /** After a failed connect, calls fail with its error for this long instead of dialling again. */
22
44
  const REDIAL_AFTER_MS = 15_000;
@@ -46,6 +68,13 @@ export class WorldHub {
46
68
  * own refusals throw it with their status.
47
69
  */
48
70
  async call<T>(method: string, args?: Record<string, unknown>): Promise<T> {
71
+ if (localLiveCode()) {
72
+ if (!announcedLocalHub) {
73
+ announcedLocalHub = true;
74
+ console.info('local live-code mock: world hub (not ck-exec)');
75
+ }
76
+ return localWorldHubReply(method) as T;
77
+ }
49
78
  const { nodeType, key } = address;
50
79
  const connection = await this.connect();
51
80
  return connection.call<T>(nodeType, key, method, args);
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { GameScene } from '../../engine/GameScene';
4
+ import { CONTENT_API, GameContentHost, assertGameContent, previewErrorOf } from './content';
5
+
6
+ const scene = (id: string) => ({ id }) as unknown as GameScene;
7
+
8
+ function content(overrides: Record<string, unknown> = {}) {
9
+ return {
10
+ contentApi: CONTENT_API,
11
+ scenes: { home: () => scene('home'), paint: () => scene('paint') },
12
+ home: { sceneId: 'home', spawn: { x: 0, y: 0, z: 6 } },
13
+ programs: [{ programId: 1, sceneId: 'paint' }],
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ describe('assertGameContent', () => {
19
+ it('accepts a module object or its default export', () => {
20
+ expect(assertGameContent({ default: content() }).home.sceneId).toBe('home');
21
+ expect(assertGameContent(content()).programs).toHaveLength(1);
22
+ });
23
+
24
+ it('names the fix for a game older than the host', () => {
25
+ expect(() => assertGameContent(content({ contentApi: undefined }))).toThrow(
26
+ /contentApi undefined; this host loads contentApi 1\. Update the game from the template/,
27
+ );
28
+ });
29
+
30
+ it('refuses programs and a home that name unregistered scenes', () => {
31
+ expect(() =>
32
+ assertGameContent(content({ home: { sceneId: 'lobby', spawn: { x: 0, y: 0, z: 0 } } })),
33
+ ).toThrow(/home\.sceneId/);
34
+ expect(() =>
35
+ assertGameContent(content({ programs: [{ programId: 2, sceneId: 'race' }] })),
36
+ ).toThrow(/"race"/);
37
+ expect(() =>
38
+ assertGameContent(
39
+ content({
40
+ programs: [
41
+ { programId: 1, sceneId: 'paint' },
42
+ { programId: 1, sceneId: 'home' },
43
+ ],
44
+ }),
45
+ ),
46
+ ).toThrow(/used twice/);
47
+ });
48
+ });
49
+
50
+ describe('GameContentHost', () => {
51
+ it('swaps content, remounts, and reports the revision the world reflects', async () => {
52
+ const host = new GameContentHost(content());
53
+ const mounted: string[] = [];
54
+ host.bind({
55
+ remount: async (next) => void mounted.push(Object.keys(next.scenes).join(',')),
56
+ scene: () => 'home',
57
+ });
58
+ host.noteRevision(3);
59
+ const next = content({
60
+ scenes: { home: () => scene('home'), paint: () => scene('paint'), race: () => scene('race') },
61
+ });
62
+ expect(await host.replace({ default: next })).toBe(true);
63
+ expect(mounted).toEqual(['home,paint,race']);
64
+ expect(host.generation).toBe(1);
65
+ expect(await host.status({ afterRevision: 3, timeoutMs: 10 })).toEqual({
66
+ revision: 3,
67
+ ok: true,
68
+ scene: 'home',
69
+ errors: [],
70
+ });
71
+ });
72
+
73
+ it('keeps the old content on screen when the new one fails to mount', async () => {
74
+ const host = new GameContentHost(content());
75
+ const mounted: string[] = [];
76
+ host.bind({
77
+ remount: async (next) => {
78
+ mounted.push(next.home.sceneId);
79
+ if (next.home.sceneId === 'paint') throw new Error('Paint exploded');
80
+ },
81
+ scene: () => undefined,
82
+ });
83
+ const broken = content({ home: { sceneId: 'paint', spawn: { x: 0, y: 0, z: 0 } } });
84
+ expect(await host.replace(broken, 4)).toBe(false);
85
+ expect(host.current.home.sceneId).toBe('home');
86
+ expect(mounted).toEqual(['paint', 'home']);
87
+ const status = await host.status({ timeoutMs: 10 });
88
+ expect(status.ok).toBe(false);
89
+ expect(status.errors[0]?.message).toBe('Paint exploded');
90
+ });
91
+
92
+ it('waits for a pending revision and says so when the world never reloads', async () => {
93
+ const host = new GameContentHost(content());
94
+ host.noteRevision(7);
95
+ const pending = await host.status({ timeoutMs: 20 });
96
+ expect(pending.ok).toBe(false);
97
+ expect(pending.note).toMatch(/has not reloaded draft revision 7/);
98
+
99
+ const waiting = host.status({ timeoutMs: 1_000 });
100
+ host.settleUnloaded(7, 'README.md is not part of the running game.');
101
+ expect(await waiting).toMatchObject({
102
+ revision: 7,
103
+ ok: true,
104
+ note: 'README.md is not part of the running game.',
105
+ });
106
+ });
107
+
108
+ it('records a load that failed before there was content', async () => {
109
+ const host = new GameContentHost(content());
110
+ host.fail([{ path: 'src/scenes/race.ts', line: 3, column: 9, message: 'Unexpected token' }], 2);
111
+ expect(await host.status({ timeoutMs: 10 })).toMatchObject({
112
+ revision: 2,
113
+ ok: false,
114
+ errors: [{ path: 'src/scenes/race.ts', line: 3 }],
115
+ });
116
+ });
117
+ });
118
+
119
+ describe('previewErrorOf', () => {
120
+ it('finds the first repository frame in a draft or dev-server stack', () => {
121
+ const draft = new Error('x is not defined');
122
+ draft.stack =
123
+ 'ReferenceError: x is not defined\n at https://game.example/the-construct/__draft/g4/src/scenes/race.ts:12:7';
124
+ expect(previewErrorOf(draft)).toEqual({
125
+ path: 'src/scenes/race.ts',
126
+ line: 12,
127
+ column: 7,
128
+ message: 'x is not defined',
129
+ });
130
+ const dev = new Error('boom');
131
+ dev.stack = 'Error: boom\n at http://localhost:5175/src/game/programs.ts?t=1730:30:11';
132
+ expect(previewErrorOf(dev)).toMatchObject({
133
+ path: 'src/game/programs.ts',
134
+ line: 30,
135
+ column: 11,
136
+ });
137
+ expect(previewErrorOf('plain')).toEqual({ message: 'plain' });
138
+ });
139
+ });
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The game's content layer, as the host sees it.
3
+ *
4
+ * A game's `src/game/content.ts` default-exports a `GameContent`: its scenes,
5
+ * where players start, and the programs a scene can be. `main.ts` and the
6
+ * framework are the host; they load the content once from the build, and
7
+ * again whenever Crowdy Studio's game editor changes it: Vite HMR on a local
8
+ * dev server, the draft service worker on a hosted game (`./draftLoader`).
9
+ * Only what this module reaches is previewable without a rebuild.
10
+ *
11
+ * The host also answers `preview_status`: which draft revision the running
12
+ * world reflects, and the errors it hit loading it.
13
+ */
14
+ import type { SceneFactory } from '../../engine/GameScene';
15
+
16
+ /** The contract version `src/game/content.ts` declares as `contentApi`. */
17
+ export const CONTENT_API = 1;
18
+
19
+ export interface ContentSpawn {
20
+ x: number;
21
+ y: number;
22
+ z: number;
23
+ yaw?: number;
24
+ }
25
+
26
+ export interface ContentProgram {
27
+ /** The byte every pose carries while a player is in it (1-255; 0 is home). */
28
+ readonly programId: number;
29
+ /** The scene the router loads for it. */
30
+ readonly sceneId: string;
31
+ }
32
+
33
+ export interface GameContent {
34
+ readonly contentApi: typeof CONTENT_API;
35
+ /** Scene id -> a factory for a fresh scene. */
36
+ readonly scenes: Readonly<Record<string, SceneFactory>>;
37
+ /** The scene a player starts in, and where. */
38
+ readonly home: { readonly sceneId: string; readonly spawn: ContentSpawn };
39
+ /** The programs scenes can be, by the id every pose carries. */
40
+ readonly programs: readonly ContentProgram[];
41
+ }
42
+
43
+ export interface PreviewError {
44
+ /** Repository path, when the error names one. */
45
+ path?: string;
46
+ line?: number;
47
+ column?: number;
48
+ message: string;
49
+ }
50
+
51
+ /** What `preview_status` reports (the shape of the Studio bridge's `preview.status`). */
52
+ export interface PreviewStatus {
53
+ /** Draft revision the running world reflects. */
54
+ revision: number;
55
+ ok: boolean;
56
+ scene?: string;
57
+ errors: PreviewError[];
58
+ note?: string;
59
+ }
60
+
61
+ /** A module object or its default export, checked against the contract. */
62
+ export function assertGameContent(value: unknown, where = 'src/game/content.ts'): GameContent {
63
+ const candidate =
64
+ value && typeof value === 'object' && 'default' in value
65
+ ? (value as { default: unknown }).default
66
+ : value;
67
+ const fail = (why: string): never => {
68
+ throw new Error(`${where}: ${why}`);
69
+ };
70
+ if (!candidate || typeof candidate !== 'object') fail('must default-export the game content.');
71
+ const content = candidate as Partial<GameContent>;
72
+ if (content.contentApi !== CONTENT_API) {
73
+ fail(
74
+ `declares contentApi ${String(content.contentApi)}; this host loads contentApi ${CONTENT_API}. Update the game from the template.`,
75
+ );
76
+ }
77
+ const scenes = content.scenes;
78
+ if (!scenes || typeof scenes !== 'object') fail('has no scenes.');
79
+ for (const [id, factory] of Object.entries(scenes as Record<string, unknown>)) {
80
+ if (typeof factory !== 'function') fail(`scene "${id}" is not a factory function.`);
81
+ }
82
+ const home = content.home;
83
+ if (!home || typeof home.sceneId !== 'string' || !(home.sceneId in scenes!)) {
84
+ fail('home.sceneId must name one of its scenes.');
85
+ }
86
+ const spawn = home!.spawn;
87
+ if (!spawn || ![spawn.x, spawn.y, spawn.z].every((n) => typeof n === 'number')) {
88
+ fail('home.spawn needs numeric x, y and z.');
89
+ }
90
+ if (!Array.isArray(content.programs)) fail('programs must be a list.');
91
+ const ids = new Set<number>();
92
+ for (const program of content.programs as ContentProgram[]) {
93
+ if (!Number.isInteger(program.programId) || program.programId < 1 || program.programId > 255) {
94
+ fail(`program ${String(program.programId)} needs an id from 1 to 255.`);
95
+ }
96
+ if (ids.has(program.programId)) fail(`program id ${program.programId} is used twice.`);
97
+ ids.add(program.programId);
98
+ if (!(program.sceneId in scenes!)) {
99
+ fail(
100
+ `program ${program.programId} names scene "${program.sceneId}", which it does not register.`,
101
+ );
102
+ }
103
+ }
104
+ return content as GameContent;
105
+ }
106
+
107
+ const DRAFT_FRAME = /__draft\/g\d+\/([^\s?:)]+)(?:\?[^\s:)]*)?:(\d+):(\d+)/;
108
+ const DEV_FRAME =
109
+ /https?:\/\/[^/\s]+\/(?:[^\s:)]*?\/)?((?:src|model)\/[^\s?:)]+)(?:\?[^\s:)]*)?:(\d+):(\d+)/;
110
+
111
+ /** The first repository frame of an error, with its message. */
112
+ export function previewErrorOf(error: unknown): PreviewError {
113
+ const preview =
114
+ error && typeof error === 'object' && 'preview' in error
115
+ ? (error as { preview: unknown }).preview
116
+ : undefined;
117
+ if (
118
+ preview &&
119
+ typeof preview === 'object' &&
120
+ typeof (preview as PreviewError).message === 'string'
121
+ ) {
122
+ return { ...(preview as PreviewError) };
123
+ }
124
+ if (error && typeof error === 'object' && 'path' in error && 'message' in error) {
125
+ const known = error as PreviewError;
126
+ if (typeof known.message === 'string') return { ...known };
127
+ }
128
+ const message = error instanceof Error ? error.message : String(error);
129
+ const stack = error instanceof Error ? (error.stack ?? '') : '';
130
+ const match = DRAFT_FRAME.exec(`${message}\n${stack}`) ?? DEV_FRAME.exec(`${message}\n${stack}`);
131
+ if (!match) return { message };
132
+ return { path: match[1], line: Number(match[2]), column: Number(match[3]), message };
133
+ }
134
+
135
+ interface Outcome {
136
+ ok: boolean;
137
+ errors: PreviewError[];
138
+ note?: string;
139
+ }
140
+
141
+ /**
142
+ * The running game's content, swapped in place when the draft changes. The
143
+ * host calls `replace` with each new module; `main.ts` supplies the remount.
144
+ * A failed load or mount keeps the previous content on screen.
145
+ */
146
+ export class GameContentHost {
147
+ private content: GameContent;
148
+ private remount: ((content: GameContent) => Promise<void>) | null = null;
149
+ private sceneOf: () => string | undefined = () => undefined;
150
+ private latest = 0;
151
+ private settled = 0;
152
+ private outcome: Outcome = { ok: true, errors: [] };
153
+ private readonly waiters = new Set<() => void>();
154
+ private readonly listeners = new Set<(content: GameContent) => void>();
155
+ private chain: Promise<unknown> = Promise.resolve();
156
+ /** Bumps on every content swap. */
157
+ generation = 0;
158
+
159
+ constructor(initial: unknown) {
160
+ this.content = assertGameContent(initial);
161
+ }
162
+
163
+ get current(): GameContent {
164
+ return this.content;
165
+ }
166
+
167
+ /** `main.ts`: how new content reaches the screen, and which scene is showing. */
168
+ bind(options: {
169
+ remount: (content: GameContent) => Promise<void>;
170
+ scene: () => string | undefined;
171
+ }): void {
172
+ this.remount = options.remount;
173
+ this.sceneOf = options.scene;
174
+ }
175
+
176
+ programById(programId: number): ContentProgram | undefined {
177
+ return this.content.programs.find((program) => program.programId === programId);
178
+ }
179
+
180
+ /** Called after each swap, once the remount finished. */
181
+ onChange(listener: (content: GameContent) => void): () => void {
182
+ this.listeners.add(listener);
183
+ return () => this.listeners.delete(listener);
184
+ }
185
+
186
+ /** A draft revision the page now holds; `preview_status` waits for it. */
187
+ noteRevision(revision: number): void {
188
+ this.latest = Math.max(this.latest, revision);
189
+ }
190
+
191
+ /** The revision changed nothing the running world loads: settle it with a note. */
192
+ settleUnloaded(revision: number, note: string): void {
193
+ this.noteRevision(revision);
194
+ this.settle(revision, { ok: true, errors: [], note });
195
+ }
196
+
197
+ /** A load failed before there was content to swap (a transform or import error). */
198
+ fail(errors: PreviewError[], revision = this.latest): void {
199
+ this.noteRevision(revision);
200
+ this.settle(revision, { ok: false, errors });
201
+ }
202
+
203
+ /** Swap in a freshly loaded content module. Never throws: the outcome is the preview status. */
204
+ replace(next: unknown, revision = this.latest): Promise<boolean> {
205
+ const run = this.chain.then(async () => {
206
+ this.noteRevision(revision);
207
+ let content: GameContent;
208
+ try {
209
+ content = assertGameContent(next);
210
+ } catch (error) {
211
+ this.settle(revision, { ok: false, errors: [previewErrorOf(error)] });
212
+ return false;
213
+ }
214
+ const previous = this.content;
215
+ this.content = content;
216
+ this.generation += 1;
217
+ try {
218
+ await this.remount?.(content);
219
+ } catch (error) {
220
+ this.content = previous;
221
+ const errors = [previewErrorOf(error)];
222
+ try {
223
+ await this.remount?.(previous);
224
+ } catch (restoreError) {
225
+ errors.push(previewErrorOf(restoreError));
226
+ }
227
+ this.settle(revision, { ok: false, errors });
228
+ return false;
229
+ }
230
+ this.settle(revision, { ok: true, errors: [] });
231
+ for (const listener of this.listeners) listener(content);
232
+ return true;
233
+ });
234
+ this.chain = run.catch(() => undefined);
235
+ return run;
236
+ }
237
+
238
+ /** What the running world reflects, once it caught up with `afterRevision` (or the newest known). */
239
+ async status(
240
+ params: { afterRevision?: number; timeoutMs?: number } = {},
241
+ ): Promise<PreviewStatus> {
242
+ const target = Math.max(params.afterRevision ?? 0, this.latest);
243
+ const timeoutMs = Math.min(Math.max(params.timeoutMs ?? 10_000, 0), 60_000);
244
+ if (this.settled < target) {
245
+ await new Promise<void>((resolve) => {
246
+ const done = () => {
247
+ if (this.settled < target) return;
248
+ clearTimeout(timer);
249
+ this.waiters.delete(done);
250
+ resolve();
251
+ };
252
+ const timer = setTimeout(() => {
253
+ this.waiters.delete(done);
254
+ resolve();
255
+ }, timeoutMs);
256
+ this.waiters.add(done);
257
+ });
258
+ }
259
+ const scene = this.sceneOf();
260
+ if (this.settled < target) {
261
+ return {
262
+ revision: this.settled,
263
+ ok: false,
264
+ ...(scene ? { scene } : {}),
265
+ errors: [],
266
+ note: `The world has not reloaded draft revision ${target} yet. A file outside the content layer (src/game/content.ts and what it imports) needs a rebuild, or the reload is still running.`,
267
+ };
268
+ }
269
+ return {
270
+ revision: this.settled,
271
+ ok: this.outcome.ok,
272
+ ...(scene ? { scene } : {}),
273
+ errors: this.outcome.errors,
274
+ ...(this.outcome.note ? { note: this.outcome.note } : {}),
275
+ };
276
+ }
277
+
278
+ private settle(revision: number, outcome: Outcome): void {
279
+ if (revision < this.settled) return;
280
+ this.settled = revision;
281
+ this.outcome = outcome;
282
+ for (const waiter of [...this.waiters]) waiter();
283
+ }
284
+ }