@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,82 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+
3
+ import { encodeSamples } from './mulaw';
4
+ import { RecordingPlayback, VoiceService, type VoiceTransport } from './VoiceService';
5
+
6
+ type AudioN = { uuid: string; audioData: string; sequenceNumber?: number };
7
+ type Handler<T> = (n: T) => void;
8
+
9
+ function bytesToBase64(bytes: Uint8Array): string {
10
+ let binary = '';
11
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
12
+ return btoa(binary);
13
+ }
14
+
15
+ function fakeTransport(self = 'self-uuid') {
16
+ const audio = new Set<Handler<AudioN>>();
17
+ const left = new Set<Handler<{ uuid: string }>>();
18
+ const lane = new Set<Handler<string>>();
19
+ const transport: VoiceTransport & {
20
+ emitAudio(n: AudioN): void;
21
+ emitLeft(uuid: string): void;
22
+ } = {
23
+ onAudio: (h) => {
24
+ audio.add(h);
25
+ return () => audio.delete(h);
26
+ },
27
+ onActorLeft: (h) => {
28
+ left.add(h);
29
+ return () => left.delete(h);
30
+ },
31
+ onLaneLeave: (h) => {
32
+ lane.add(h);
33
+ return () => lane.delete(h);
34
+ },
35
+ selfUuid: () => self,
36
+ selfChunk: () => ({ x: '0', y: '0', z: '0' }),
37
+ selfPosition: () => ({ x: 0, y: 0, z: 0 }),
38
+ remotePositions: () => [{ uuid: 'other', x: 4, y: 0, z: 0 }],
39
+ appId: () => '1',
40
+ sendAudioPacket: async () => true,
41
+ log: () => undefined,
42
+ emitAudio(n) {
43
+ for (const h of audio) h(n);
44
+ },
45
+ emitLeft(uuid) {
46
+ for (const h of left) h({ uuid });
47
+ },
48
+ };
49
+ return transport;
50
+ }
51
+
52
+ describe('VoiceService receive', () => {
53
+ const services: VoiceService[] = [];
54
+
55
+ afterEach(() => {
56
+ for (const service of services) service.dispose();
57
+ services.length = 0;
58
+ });
59
+
60
+ function start() {
61
+ const transport = fakeTransport();
62
+ const playback = new RecordingPlayback();
63
+ const voice = new VoiceService(transport, { playback, now: () => 1_000 });
64
+ services.push(voice);
65
+ voice.listen();
66
+ const payload = bytesToBase64(encodeSamples(new Float32Array(8).fill(0.2)));
67
+ return { transport, playback, voice, payload };
68
+ }
69
+
70
+ it('ignores self, plays others, drops a duplicate sequence, and tears down on actorLeft', () => {
71
+ const { transport, playback, payload } = start();
72
+ transport.emitAudio({ uuid: 'self-uuid', audioData: payload, sequenceNumber: 1 });
73
+ expect(playback.played).toHaveLength(0);
74
+ transport.emitAudio({ uuid: 'other', audioData: payload, sequenceNumber: 1 });
75
+ expect(playback.played).toHaveLength(1);
76
+ expect(playback.played[0]!.uuid).toBe('other');
77
+ transport.emitAudio({ uuid: 'other', audioData: payload, sequenceNumber: 1 });
78
+ expect(playback.played).toHaveLength(1);
79
+ transport.emitLeft('other');
80
+ expect(playback.forgotten).toContain('other');
81
+ });
82
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // The SDK root barrel drags Monaco into a unit test; the network layer only needs the constants.
4
+ vi.mock('@crowdedkingdoms/crowdyjs', () => ({
5
+ CROWDY_DEFAULT_HTTP_ORIGIN: 'https://ck.test.example',
6
+ CROWDY_DEFAULT_TIER: 'test',
7
+ }));
8
+
9
+ const { ModelService } = await import('./ModelService');
10
+
11
+ function withHub(replies: Record<string, unknown>) {
12
+ const logs: string[] = [];
13
+ const hub = {
14
+ call: vi.fn(async (method: string) => {
15
+ const reply = replies[method];
16
+ if (reply instanceof Error) throw reply;
17
+ return reply;
18
+ }),
19
+ };
20
+ const session = { network: { worldHub: hub, log: (line: string) => logs.push(line) } };
21
+ return { model: new ModelService(session as never), hub, logs };
22
+ }
23
+
24
+ describe('ModelService on the world hub', () => {
25
+ it('reads the pulse count and caches it for the HUD poll', async () => {
26
+ const { model, hub } = withHub({
27
+ world: { pulses: 7, lastPulseAt: 1, pulseEveryMs: 60_000 },
28
+ });
29
+ expect(await model.world()).toEqual({ pulses: 7, seeded: true });
30
+ await model.world();
31
+ expect(hub.call).toHaveBeenCalledTimes(1);
32
+ model.invalidate();
33
+ await model.world();
34
+ expect(hub.call).toHaveBeenCalledTimes(2);
35
+ });
36
+
37
+ it('says the hub is missing instead of throwing', async () => {
38
+ const { model, logs } = withHub({
39
+ world: new Error('NotFound: no such type'),
40
+ progress: new Error('x'),
41
+ });
42
+ expect(await model.world()).toEqual({ pulses: 0, seeded: false });
43
+ expect(await model.progress()).toBeNull();
44
+ expect(logs.some((line) => /world state unavailable: NotFound/.test(line))).toBe(true);
45
+ });
46
+
47
+ it('maps the catalog and the player progression to their records', async () => {
48
+ const { model } = withHub({
49
+ programs: {
50
+ programs: [{ programId: 1, sceneId: 'paint', name: 'Paint', description: 'd' }],
51
+ },
52
+ progress: { player: '42', xp: 3, level: 2, skillPoints: 1 },
53
+ });
54
+ expect(await model.programs()).toEqual([
55
+ { containerId: 'program-1', programId: 1, sceneId: 'paint', name: 'Paint', description: 'd' },
56
+ ]);
57
+ expect(await model.progress()).toEqual({ containerId: '42', xp: 3, level: 2, skillPoints: 1 });
58
+ });
59
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The game-state facade: what the client reads from the server that keeps the game's rules,
3
+ * in game terms.
4
+ *
5
+ * That server is the app's world hub on ck-exec (the starter's `exec/construct`, deployed by
6
+ * `npm run setup` / `npm run deploy:exec`), reached over the one connection
7
+ * `NetworkManager.worldHub` holds. The hub decides; this class only asks, as the signed-in
8
+ * player: it cannot grant itself XP, and it never names another player. Reads are cached
9
+ * briefly because the HUD polls them.
10
+ */
11
+ import type { GameSession } from '../GameSession';
12
+ import { messageOf } from '../network/NetworkManager';
13
+
14
+ export interface ProgramRecord {
15
+ /** `program-<programId>`. */
16
+ containerId: string;
17
+ programId: number;
18
+ sceneId: string;
19
+ name: string;
20
+ description: string;
21
+ }
22
+
23
+ export interface ProgressSummary {
24
+ /** The progression record's id: the player's user id, on the world hub. */
25
+ containerId: string;
26
+ xp: number;
27
+ level: number;
28
+ skillPoints: number;
29
+ }
30
+
31
+ export interface WorldSummary {
32
+ pulses: number;
33
+ /** The world hub answered: it is deployed and reachable. */
34
+ seeded: boolean;
35
+ }
36
+
37
+ interface HubProgram {
38
+ programId: number;
39
+ sceneId: string;
40
+ name: string;
41
+ description: string;
42
+ }
43
+
44
+ interface HubProgress {
45
+ player: string;
46
+ xp: number;
47
+ level: number;
48
+ skillPoints: number;
49
+ }
50
+
51
+ const CACHE_MS = 15_000;
52
+
53
+ export class ModelService {
54
+ private programsCache: { at: number; value: ProgramRecord[] } | null = null;
55
+ private worldCache: { at: number; value: WorldSummary } | null = null;
56
+ private progressCache: { at: number; value: ProgressSummary } | null = null;
57
+
58
+ constructor(private readonly session: GameSession) {}
59
+
60
+ private get hub() {
61
+ return this.session.network.worldHub;
62
+ }
63
+
64
+ /** The program catalog the hub serves; empty when the hub cannot be reached. */
65
+ async programs(): Promise<ProgramRecord[]> {
66
+ if (this.programsCache && Date.now() - this.programsCache.at < CACHE_MS)
67
+ return this.programsCache.value;
68
+ let value: ProgramRecord[] = [];
69
+ try {
70
+ const { programs } = await this.hub.call<{ programs: HubProgram[] }>('programs');
71
+ value = programs.map((program) => ({
72
+ containerId: `program-${program.programId}`,
73
+ programId: Number(program.programId),
74
+ sceneId: String(program.sceneId),
75
+ name: String(program.name),
76
+ description: String(program.description),
77
+ }));
78
+ } catch (error) {
79
+ this.session.network.log(`program catalog unavailable: ${messageOf(error)}`);
80
+ }
81
+ this.programsCache = { at: Date.now(), value };
82
+ return value;
83
+ }
84
+
85
+ /** The world's pulse count: proof the hub's minute timer runs while someone is here. */
86
+ async world(): Promise<WorldSummary> {
87
+ if (this.worldCache && Date.now() - this.worldCache.at < CACHE_MS) return this.worldCache.value;
88
+ let value: WorldSummary = { pulses: 0, seeded: false };
89
+ try {
90
+ const { pulses } = await this.hub.call<{ pulses: number }>('world');
91
+ value = { pulses: Number(pulses), seeded: true };
92
+ } catch (error) {
93
+ this.session.network.log(`world state unavailable: ${messageOf(error)}`);
94
+ }
95
+ this.worldCache = { at: Date.now(), value };
96
+ return value;
97
+ }
98
+
99
+ /** The player's progression, which the hub creates on first read. */
100
+ async progress(): Promise<ProgressSummary | null> {
101
+ if (this.progressCache && Date.now() - this.progressCache.at < CACHE_MS)
102
+ return this.progressCache.value;
103
+ try {
104
+ const progress = await this.hub.call<HubProgress>('progress');
105
+ const value: ProgressSummary = {
106
+ containerId: String(progress.player),
107
+ xp: Number(progress.xp),
108
+ level: Number(progress.level),
109
+ skillPoints: Number(progress.skillPoints),
110
+ };
111
+ this.progressCache = { at: Date.now(), value };
112
+ return value;
113
+ } catch (error) {
114
+ this.session.network.log(`progression unavailable: ${messageOf(error)}`);
115
+ return null;
116
+ }
117
+ }
118
+
119
+ invalidate(): void {
120
+ this.programsCache = null;
121
+ this.worldCache = null;
122
+ this.progressCache = null;
123
+ }
124
+ }