@crowdedkingdoms/construct 0.0.0 → 0.2.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.
- package/README.md +76 -1
- package/node/copy-dsh-web.mjs +71 -0
- package/node/security-headers.d.mts +23 -0
- package/node/security-headers.mjs +243 -0
- package/node/vite-plugins.d.mts +6 -0
- package/node/vite-plugins.mjs +102 -0
- package/package.json +48 -3
- package/sandbox/boot.ts +74 -0
- package/src/engine/GameLoop.ts +106 -0
- package/src/engine/GameScene.ts +74 -0
- package/src/engine/Input.test.ts +129 -0
- package/src/engine/Input.ts +257 -0
- package/src/engine/SceneRouter.test.ts +80 -0
- package/src/engine/SceneRouter.ts +113 -0
- package/src/engine/controls.test.ts +26 -0
- package/src/engine/controls.ts +63 -0
- package/src/grid/GridProgramRunner.ts +205 -0
- package/src/grid/sandboxProtocol.ts +9 -0
- package/src/index.ts +88 -0
- package/src/platform/GameSession.ts +239 -0
- package/src/platform/auth/AuthService.ts +45 -0
- package/src/platform/config.test.ts +110 -0
- package/src/platform/config.ts +212 -0
- package/src/platform/envScope.test.ts +42 -0
- package/src/platform/envScope.ts +76 -0
- package/src/platform/exec/worldHub.test.ts +61 -0
- package/src/platform/exec/worldHub.ts +86 -0
- package/src/platform/media/VoiceService.ts +423 -0
- package/src/platform/media/WebcamService.ts +346 -0
- package/src/platform/media/mulaw.test.ts +20 -0
- package/src/platform/media/mulaw.ts +56 -0
- package/src/platform/media/videoFrames.test.ts +197 -0
- package/src/platform/media/voiceFrames.test.ts +82 -0
- package/src/platform/model/ModelService.test.ts +59 -0
- package/src/platform/model/ModelService.ts +124 -0
- package/src/platform/network/NetworkManager.ts +519 -0
- package/src/platform/onboarding/steps.d.mts +154 -0
- package/src/platform/onboarding/steps.mjs +616 -0
- package/src/platform/onboarding/steps.test.ts +486 -0
- package/src/platform/realtime/WorldStores.ts +153 -0
- package/src/platform/realtime/actorCodec.test.ts +62 -0
- package/src/platform/realtime/actorCodec.ts +142 -0
- package/src/platform/realtime/space.test.ts +33 -0
- package/src/platform/realtime/space.ts +63 -0
- package/src/platform/social/ChatService.ts +92 -0
- package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
- package/src/platform/studio/ClientModLifecycle.ts +102 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
- package/src/platform/studio/GridService.test.ts +131 -0
- package/src/platform/studio/GridService.ts +295 -0
- package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
- package/src/platform/studio/HumanInputMonitor.ts +48 -0
- package/src/platform/studio/StudioService.ts +686 -0
- package/src/platform/studio/agentLocomotion.test.ts +31 -0
- package/src/platform/studio/agentLocomotion.ts +97 -0
- package/src/platform/studio/clientModHost.test.ts +199 -0
- package/src/platform/studio/clientModHost.ts +369 -0
- package/src/platform/studio/instanceSchema.test.ts +280 -0
- package/src/platform/studio/instanceSchema.ts +746 -0
- package/src/platform/studio/instanceStore.test.ts +97 -0
- package/src/platform/studio/instanceStore.ts +120 -0
- package/src/platform/studio/modOverlay.test.ts +56 -0
- package/src/platform/studio/modOverlay.ts +51 -0
- package/src/platform/studio/permissions.test.ts +51 -0
- package/src/platform/studio/permissions.ts +134 -0
- package/src/platform/studio/pointerClicks.test.ts +23 -0
- package/src/platform/studio/pointerClicks.ts +132 -0
- package/src/platform/util/Emitter.ts +32 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { EventRouter, StateCodec, TypedEvent } from '@crowdedkingdoms/crowdyjs/stores';
|
|
3
|
+
import {
|
|
4
|
+
EVENT_CATALOG,
|
|
5
|
+
EVENT_POSES,
|
|
6
|
+
bytesToBase64,
|
|
7
|
+
encodeCatalogChunks,
|
|
8
|
+
encodePosePacket,
|
|
9
|
+
parseSceneCatalog,
|
|
10
|
+
type SceneNode,
|
|
11
|
+
} from './instanceSchema';
|
|
12
|
+
import { InstanceStore } from './instanceStore';
|
|
13
|
+
|
|
14
|
+
function fakeEvents(): EventRouter & { emit: (type: number, bytes: Uint8Array) => void } {
|
|
15
|
+
const handlers = new Map<number, (event: { value: string }) => void>();
|
|
16
|
+
return {
|
|
17
|
+
on<T>(eventType: number, codec: StateCodec<T>, handler: (event: TypedEvent<T>) => void) {
|
|
18
|
+
handlers.set(eventType, (event) =>
|
|
19
|
+
handler({ ...event, value: codec.decode(event.value) } as TypedEvent<T>),
|
|
20
|
+
);
|
|
21
|
+
return () => handlers.delete(eventType);
|
|
22
|
+
},
|
|
23
|
+
emit(type: number, bytes: Uint8Array) {
|
|
24
|
+
handlers.get(type)?.({ value: bytesToBase64(bytes) });
|
|
25
|
+
},
|
|
26
|
+
} as unknown as EventRouter & { emit: (type: number, bytes: Uint8Array) => void };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('InstanceStore', () => {
|
|
30
|
+
it('renders SERVER catalog plus packed poses without CLIENT overlay', () => {
|
|
31
|
+
const events = fakeEvents();
|
|
32
|
+
const store = new InstanceStore(events);
|
|
33
|
+
const catalog = {
|
|
34
|
+
v: 1,
|
|
35
|
+
revision: 3,
|
|
36
|
+
meshes: [],
|
|
37
|
+
nodes: [
|
|
38
|
+
{ id: 'table', kind: 'box', x: 8, y: 0.8, z: 8, sx: 2, sy: 0.2, sz: 1 },
|
|
39
|
+
{ id: 'cue', kind: 'box', x: 8, y: 1, z: 8, qx: 0, qy: 0, qz: 0, qw: 1 },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
const utf8 = new TextEncoder().encode(JSON.stringify(catalog));
|
|
43
|
+
for (const chunk of encodeCatalogChunks(3, utf8)) events.emit(EVENT_CATALOG, chunk);
|
|
44
|
+
const parsed = parseSceneCatalog(catalog)!;
|
|
45
|
+
const posed: SceneNode[] = parsed.nodes.map((n) =>
|
|
46
|
+
n.id === 'cue' ? { ...n, qy: 0.7071, qw: 0.7071 } : n,
|
|
47
|
+
);
|
|
48
|
+
events.emit(EVENT_POSES, encodePosePacket(3, posed));
|
|
49
|
+
const snap = store.snapshot();
|
|
50
|
+
const cue = snap.instances.find((i) => i.id === 'cue');
|
|
51
|
+
expect(cue?.qy).toBeCloseTo(0.7071, 2);
|
|
52
|
+
expect(snap.instances.some((i) => i.id === 'table')).toBe(true);
|
|
53
|
+
|
|
54
|
+
for (const chunk of encodeCatalogChunks(3, utf8)) events.emit(EVENT_CATALOG, chunk);
|
|
55
|
+
const afterKeepalive = store.snapshot();
|
|
56
|
+
expect(afterKeepalive.instances.find((i) => i.id === 'cue')?.qy).toBeCloseTo(0.7071, 2);
|
|
57
|
+
store.dispose();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('lets local overlay ids replace replicated ones in this tab only', () => {
|
|
61
|
+
const events = fakeEvents();
|
|
62
|
+
const store = new InstanceStore(events);
|
|
63
|
+
const catalog = {
|
|
64
|
+
v: 1,
|
|
65
|
+
revision: 1,
|
|
66
|
+
nodes: [{ id: 'marker', kind: 'box', x: 0, y: 1, z: 0 }],
|
|
67
|
+
};
|
|
68
|
+
for (const chunk of encodeCatalogChunks(1, new TextEncoder().encode(JSON.stringify(catalog)))) {
|
|
69
|
+
events.emit(EVENT_CATALOG, chunk);
|
|
70
|
+
}
|
|
71
|
+
const snap = store.snapshot({
|
|
72
|
+
overlay: [
|
|
73
|
+
{
|
|
74
|
+
id: 'marker',
|
|
75
|
+
shape: 'sphere',
|
|
76
|
+
kind: 'sphere',
|
|
77
|
+
x: 4,
|
|
78
|
+
y: 1,
|
|
79
|
+
z: 4,
|
|
80
|
+
qx: 0,
|
|
81
|
+
qy: 0,
|
|
82
|
+
qz: 0,
|
|
83
|
+
qw: 1,
|
|
84
|
+
sx: 0.3,
|
|
85
|
+
sy: 0.3,
|
|
86
|
+
sz: 0.3,
|
|
87
|
+
color: 0xff0000,
|
|
88
|
+
visible: true,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
expect(snap.instances).toHaveLength(1);
|
|
93
|
+
expect(snap.instances[0]?.kind).toBe('sphere');
|
|
94
|
+
expect(snap.instances[0]?.x).toBe(4);
|
|
95
|
+
store.dispose();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SERVER-authoritative instances: catalog + packed poses arriving on the
|
|
3
|
+
* World Stores EventRouter. Overlay nodes from CLIENT mods merge on top for
|
|
4
|
+
* the local tab only.
|
|
5
|
+
*/
|
|
6
|
+
import { rawCodec, type EventRouter } from '@crowdedkingdoms/crowdyjs/stores';
|
|
7
|
+
import type { PlayerCodeGridBounds } from '@crowdedkingdoms/crowdyjs';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CatalogAssembler,
|
|
11
|
+
EVENT_CATALOG,
|
|
12
|
+
EVENT_POSES,
|
|
13
|
+
applyPoses,
|
|
14
|
+
bytesFromBase64,
|
|
15
|
+
composeScene,
|
|
16
|
+
decodePosePacket,
|
|
17
|
+
parseSceneCatalog,
|
|
18
|
+
type ActorXform,
|
|
19
|
+
type ComposedInstance,
|
|
20
|
+
type SceneCatalog,
|
|
21
|
+
type SceneNode,
|
|
22
|
+
} from './instanceSchema';
|
|
23
|
+
import type { ModOverlayObject } from './modOverlay';
|
|
24
|
+
|
|
25
|
+
export interface InstanceSnapshot {
|
|
26
|
+
revision: number;
|
|
27
|
+
instances: readonly ComposedInstance[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface InstanceSnapshotInput {
|
|
31
|
+
overlay?: readonly ModOverlayObject[];
|
|
32
|
+
actors?: readonly ActorXform[];
|
|
33
|
+
grid?: PlayerCodeGridBounds;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class InstanceStore {
|
|
37
|
+
private catalog: SceneCatalog | null = null;
|
|
38
|
+
private poses = new Map<string, SceneNode>();
|
|
39
|
+
private revisionValue = 0;
|
|
40
|
+
private readonly assembler = new CatalogAssembler();
|
|
41
|
+
private readonly offs: Array<() => void> = [];
|
|
42
|
+
|
|
43
|
+
constructor(private readonly events: EventRouter) {
|
|
44
|
+
this.offs.push(
|
|
45
|
+
events.on(EVENT_CATALOG, rawCodec, (event) => {
|
|
46
|
+
this.ingestCatalog(event.value);
|
|
47
|
+
}),
|
|
48
|
+
events.on(EVENT_POSES, rawCodec, (event) => {
|
|
49
|
+
this.ingestPoses(event.value);
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get revision(): number {
|
|
55
|
+
return this.revisionValue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get catalogRevision(): number {
|
|
59
|
+
return this.catalog?.revision ?? 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
dispose(): void {
|
|
63
|
+
for (const off of this.offs) off();
|
|
64
|
+
this.offs.length = 0;
|
|
65
|
+
this.catalog = null;
|
|
66
|
+
this.poses.clear();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
snapshot(input: InstanceSnapshotInput = {}): InstanceSnapshot {
|
|
70
|
+
const catalogNodes = this.catalog ? applyPoses(this.catalog.nodes, this.poses) : [];
|
|
71
|
+
const overlay = input.overlay ?? [];
|
|
72
|
+
const overlayIds = new Set(overlay.map((n) => n.id));
|
|
73
|
+
const nodes: SceneNode[] = [...catalogNodes.filter((n) => !overlayIds.has(n.id)), ...overlay];
|
|
74
|
+
const meshes = this.catalog?.meshes ?? [];
|
|
75
|
+
return {
|
|
76
|
+
revision: this.revisionValue,
|
|
77
|
+
instances: composeScene(nodes, meshes, input.actors ?? [], input.grid),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private ingestCatalog(stateBase64: string): void {
|
|
82
|
+
let bytes: Uint8Array;
|
|
83
|
+
try {
|
|
84
|
+
bytes = bytesFromBase64(stateBase64);
|
|
85
|
+
} catch {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const joined = this.assembler.push(bytes);
|
|
89
|
+
if (!joined) return;
|
|
90
|
+
let parsed: unknown;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(new TextDecoder().decode(joined));
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const catalog = parseSceneCatalog(parsed);
|
|
97
|
+
if (!catalog) return;
|
|
98
|
+
if (this.catalog && this.catalog.revision === catalog.revision) return;
|
|
99
|
+
this.catalog = catalog;
|
|
100
|
+
this.poses.clear();
|
|
101
|
+
this.revisionValue += 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private ingestPoses(stateBase64: string): void {
|
|
105
|
+
if (!this.catalog) return;
|
|
106
|
+
let bytes: Uint8Array;
|
|
107
|
+
try {
|
|
108
|
+
bytes = bytesFromBase64(stateBase64);
|
|
109
|
+
} catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const decoded = decodePosePacket(
|
|
113
|
+
bytes,
|
|
114
|
+
this.catalog.nodes.map((n) => n.id),
|
|
115
|
+
);
|
|
116
|
+
if (!decoded || decoded.catalogRev !== this.catalog.revision) return;
|
|
117
|
+
this.poses = decoded.poses;
|
|
118
|
+
this.revisionValue += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { MAX_OVERLAY_OBJECTS, ModOverlayStore, parseOverlayPayload } from './modOverlay';
|
|
4
|
+
|
|
5
|
+
const grid = { low: { x: 3n, y: 0n, z: 3n }, high: { x: 3n, y: 0n, z: 3n } };
|
|
6
|
+
|
|
7
|
+
describe('parseOverlayPayload', () => {
|
|
8
|
+
it('reads an objects list and clamps to the grid AABB', () => {
|
|
9
|
+
const rows = parseOverlayPayload(
|
|
10
|
+
{
|
|
11
|
+
objects: [
|
|
12
|
+
{ id: 'cue', shape: 'sphere', x: 56, y: 0.5, z: 56, r: 0.08, color: '#f8fafc' },
|
|
13
|
+
{ id: 'far', x: 0, y: 0, z: 0 },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
grid,
|
|
17
|
+
);
|
|
18
|
+
expect(rows).toHaveLength(2);
|
|
19
|
+
expect(rows[0]).toMatchObject({ id: 'cue', shape: 'sphere', x: 56, z: 56 });
|
|
20
|
+
expect(rows[0]?.sx).toBeCloseTo(0.08);
|
|
21
|
+
expect(rows[1]?.x).toBe(48); // 3 * 16
|
|
22
|
+
expect(rows[1]?.z).toBe(48);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('caps the list and ignores duplicates', () => {
|
|
26
|
+
const objects = Array.from({ length: MAX_OVERLAY_OBJECTS + 5 }, (_, i) => ({
|
|
27
|
+
id: i < 2 ? 'same' : `b${i}`,
|
|
28
|
+
x: 50,
|
|
29
|
+
z: 50,
|
|
30
|
+
}));
|
|
31
|
+
expect(parseOverlayPayload({ objects })).toHaveLength(MAX_OVERLAY_OBJECTS);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('never treats a string as HTML', () => {
|
|
35
|
+
expect(parseOverlayPayload('<div id="x">nope</div>')).toEqual([]);
|
|
36
|
+
expect(parseOverlayPayload({ objects: [{ x: 1, z: 1, color: '<script>' }] })[0]?.color).toBe(
|
|
37
|
+
0xf8fafc,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('ModOverlayStore', () => {
|
|
43
|
+
it('merges sources and drops one on remove', () => {
|
|
44
|
+
const store = new ModOverlayStore();
|
|
45
|
+
store.apply('a', { objects: [{ id: 'one', x: 1, z: 1 }] });
|
|
46
|
+
store.apply('b', { objects: [{ id: 'two', x: 2, z: 2 }] });
|
|
47
|
+
expect(
|
|
48
|
+
store
|
|
49
|
+
.snapshot()
|
|
50
|
+
.objects.map((o) => o.id)
|
|
51
|
+
.sort(),
|
|
52
|
+
).toEqual(['one', 'two']);
|
|
53
|
+
store.remove('a');
|
|
54
|
+
expect(store.snapshot().objects.map((o) => o.id)).toEqual(['two']);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLIENT `overlay_draw` is local presentation on the same construct.scene.v1
|
|
3
|
+
* nodes the replicated instance layer uses. The mod never touches the DOM.
|
|
4
|
+
*/
|
|
5
|
+
import type { PlayerCodeGridBounds } from '@crowdedkingdoms/crowdyjs';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
MAX_OVERLAY_OBJECTS,
|
|
9
|
+
parseOverlayPayload,
|
|
10
|
+
type ModOverlayObject,
|
|
11
|
+
type ModOverlayShape,
|
|
12
|
+
} from './instanceSchema';
|
|
13
|
+
|
|
14
|
+
export { MAX_OVERLAY_OBJECTS, parseOverlayPayload, type ModOverlayObject, type ModOverlayShape };
|
|
15
|
+
|
|
16
|
+
export interface ModOverlaySnapshot {
|
|
17
|
+
revision: number;
|
|
18
|
+
objects: readonly ModOverlayObject[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Latest overlay from every running CLIENT source, keyed by source id. */
|
|
22
|
+
export class ModOverlayStore {
|
|
23
|
+
private readonly bySource = new Map<string, ModOverlayObject[]>();
|
|
24
|
+
private revisionValue = 0;
|
|
25
|
+
|
|
26
|
+
get revision(): number {
|
|
27
|
+
return this.revisionValue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
snapshot(): ModOverlaySnapshot {
|
|
31
|
+
const objects: ModOverlayObject[] = [];
|
|
32
|
+
for (const rows of this.bySource.values()) objects.push(...rows);
|
|
33
|
+
return { revision: this.revisionValue, objects };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
apply(source: string, payload: unknown, grid?: PlayerCodeGridBounds): void {
|
|
37
|
+
this.bySource.set(source, parseOverlayPayload(payload, grid));
|
|
38
|
+
this.revisionValue += 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
remove(source: string): void {
|
|
42
|
+
if (!this.bySource.delete(source)) return;
|
|
43
|
+
this.revisionValue += 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clear(): void {
|
|
47
|
+
if (this.bySource.size === 0) return;
|
|
48
|
+
this.bySource.clear();
|
|
49
|
+
this.revisionValue += 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
gridBoundsContain,
|
|
5
|
+
gridBoundsFrom,
|
|
6
|
+
hasAnyStudioPermission,
|
|
7
|
+
singleChunkBounds,
|
|
8
|
+
studioPermissions,
|
|
9
|
+
toBrokerBounds,
|
|
10
|
+
} from './permissions';
|
|
11
|
+
|
|
12
|
+
describe('studioPermissions', () => {
|
|
13
|
+
it('derives SERVER and CLIENT gates independently from the exact keys', () => {
|
|
14
|
+
const p = studioPermissions(['write_server_code', 'run_client_code', 'access', 'bogus']);
|
|
15
|
+
expect(p.effectiveKeys).toEqual(['write_server_code', 'run_client_code']);
|
|
16
|
+
expect(p.server).toEqual({ canWrite: true, canRun: false });
|
|
17
|
+
expect(p.client).toEqual({ canWrite: false, canRun: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('never inflates a missing or empty key list', () => {
|
|
21
|
+
expect(hasAnyStudioPermission(studioPermissions(undefined))).toBe(false);
|
|
22
|
+
expect(hasAnyStudioPermission(studioPermissions([]))).toBe(false);
|
|
23
|
+
expect(hasAnyStudioPermission(studioPermissions(['run_server_code']))).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('grid bounds', () => {
|
|
28
|
+
it('parses authoritative bounds from strings, numbers and bigints', () => {
|
|
29
|
+
expect(gridBoundsFrom({ x: '-1', y: 0, z: 2n }, { x: '3', y: '0', z: '2' })).toEqual({
|
|
30
|
+
low: { x: '-1', y: '0', z: '2' },
|
|
31
|
+
high: { x: '3', y: '0', z: '2' },
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects malformed or inverted bounds', () => {
|
|
36
|
+
expect(gridBoundsFrom(null, { x: '1', y: '1', z: '1' })).toBeNull();
|
|
37
|
+
expect(gridBoundsFrom({ x: '2', y: '0', z: '0' }, { x: '1', y: '0', z: '0' })).toBeNull();
|
|
38
|
+
expect(gridBoundsFrom({ x: '1.5', y: '0', z: '0' }, { x: '1', y: '0', z: '0' })).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('tests containment inclusively and converts for the broker', () => {
|
|
42
|
+
const bounds = gridBoundsFrom({ x: '-1', y: '0', z: '-1' }, { x: '1', y: '0', z: '1' })!;
|
|
43
|
+
expect(gridBoundsContain(bounds, { x: 1, y: 0, z: -1 })).toBe(true);
|
|
44
|
+
expect(gridBoundsContain(bounds, { x: 2, y: 0, z: 0 })).toBe(false);
|
|
45
|
+
expect(gridBoundsContain(bounds, { x: 0, y: 1, z: 0 })).toBe(false);
|
|
46
|
+
expect(toBrokerBounds(singleChunkBounds({ x: 5, y: 0, z: -3 }))).toEqual({
|
|
47
|
+
low: { x: 5n, y: 0n, z: -3n },
|
|
48
|
+
high: { x: 5n, y: 0n, z: -3n },
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crowdy Studio permissions and grid bounds, derived from what the platform
|
|
3
|
+
* says and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* A claim (or a nearby-grid read) returns the caller's EFFECTIVE permission
|
|
6
|
+
* keys on that grid. SERVER and CLIENT write/run gates come from those exact
|
|
7
|
+
* keys, independently; nothing here infers one target from the other or
|
|
8
|
+
* inflates a partial key list.
|
|
9
|
+
*/
|
|
10
|
+
export const PLAYER_CODE_PERMISSION_KEYS = [
|
|
11
|
+
'write_server_code',
|
|
12
|
+
'run_server_code',
|
|
13
|
+
'write_client_code',
|
|
14
|
+
'run_client_code',
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type PlayerCodePermissionKey = (typeof PLAYER_CODE_PERMISSION_KEYS)[number];
|
|
18
|
+
|
|
19
|
+
export interface TargetPermission {
|
|
20
|
+
canWrite: boolean;
|
|
21
|
+
canRun: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StudioPermissions {
|
|
25
|
+
effectiveKeys: PlayerCodePermissionKey[];
|
|
26
|
+
server: TargetPermission;
|
|
27
|
+
client: TargetPermission;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const NO_STUDIO_PERMISSIONS: StudioPermissions = {
|
|
31
|
+
effectiveKeys: [],
|
|
32
|
+
server: { canWrite: false, canRun: false },
|
|
33
|
+
client: { canWrite: false, canRun: false },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function isPlayerCodeKey(value: string): value is PlayerCodePermissionKey {
|
|
37
|
+
return (PLAYER_CODE_PERMISSION_KEYS as readonly string[]).includes(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function studioPermissions(
|
|
41
|
+
effectiveKeys: readonly unknown[] | null | undefined,
|
|
42
|
+
): StudioPermissions {
|
|
43
|
+
const keys = new Set((effectiveKeys ?? []).map(String).filter(isPlayerCodeKey));
|
|
44
|
+
return {
|
|
45
|
+
effectiveKeys: PLAYER_CODE_PERMISSION_KEYS.filter((key) => keys.has(key)),
|
|
46
|
+
server: { canWrite: keys.has('write_server_code'), canRun: keys.has('run_server_code') },
|
|
47
|
+
client: { canWrite: keys.has('write_client_code'), canRun: keys.has('run_client_code') },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function hasAnyStudioPermission(permissions: StudioPermissions | null | undefined): boolean {
|
|
52
|
+
return Boolean(
|
|
53
|
+
permissions &&
|
|
54
|
+
(permissions.server.canWrite ||
|
|
55
|
+
permissions.server.canRun ||
|
|
56
|
+
permissions.client.canWrite ||
|
|
57
|
+
permissions.client.canRun),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A grid's chunk AABB, as decimal strings (int64 on the wire). */
|
|
62
|
+
export interface GridBounds {
|
|
63
|
+
low: { x: string; y: string; z: string };
|
|
64
|
+
high: { x: string; y: string; z: string };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function decimalInteger(value: unknown): string | null {
|
|
68
|
+
if (typeof value === 'bigint') return value.toString();
|
|
69
|
+
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : null;
|
|
70
|
+
if (typeof value !== 'string' || !/^-?\d+$/u.test(value)) return null;
|
|
71
|
+
return BigInt(value).toString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseCorner(value: unknown): { x: string; y: string; z: string } | null {
|
|
75
|
+
if (!value || typeof value !== 'object') return null;
|
|
76
|
+
const record = value as Record<string, unknown>;
|
|
77
|
+
const x = decimalInteger(record.x);
|
|
78
|
+
const y = decimalInteger(record.y);
|
|
79
|
+
const z = decimalInteger(record.z);
|
|
80
|
+
return x !== null && y !== null && z !== null ? { x, y, z } : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Authoritative bounds from a claim/nearby response; null when malformed. */
|
|
84
|
+
export function gridBoundsFrom(low: unknown, high: unknown): GridBounds | null {
|
|
85
|
+
const l = parseCorner(low);
|
|
86
|
+
const h = parseCorner(high);
|
|
87
|
+
if (!l || !h) return null;
|
|
88
|
+
if (BigInt(l.x) > BigInt(h.x) || BigInt(l.y) > BigInt(h.y) || BigInt(l.z) > BigInt(h.z))
|
|
89
|
+
return null;
|
|
90
|
+
return { low: l, high: h };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function singleChunkBounds(chunk: {
|
|
94
|
+
x: number | string;
|
|
95
|
+
y: number | string;
|
|
96
|
+
z: number | string;
|
|
97
|
+
}): GridBounds {
|
|
98
|
+
const corner = { x: String(chunk.x), y: String(chunk.y), z: String(chunk.z) };
|
|
99
|
+
return { low: corner, high: { ...corner } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function gridBoundsContain(
|
|
103
|
+
bounds: GridBounds,
|
|
104
|
+
chunk: { x: number | string; y: number | string; z: number | string },
|
|
105
|
+
): boolean {
|
|
106
|
+
const x = BigInt(chunk.x);
|
|
107
|
+
const y = BigInt(chunk.y);
|
|
108
|
+
const z = BigInt(chunk.z);
|
|
109
|
+
return (
|
|
110
|
+
x >= BigInt(bounds.low.x) &&
|
|
111
|
+
x <= BigInt(bounds.high.x) &&
|
|
112
|
+
y >= BigInt(bounds.low.y) &&
|
|
113
|
+
y <= BigInt(bounds.high.y) &&
|
|
114
|
+
z >= BigInt(bounds.low.z) &&
|
|
115
|
+
z <= BigInt(bounds.high.z)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The bigint form the SDK's broker and embed expect. */
|
|
120
|
+
export function toBrokerBounds(
|
|
121
|
+
bounds: GridBounds,
|
|
122
|
+
gridId?: string,
|
|
123
|
+
): {
|
|
124
|
+
low: { x: bigint; y: bigint; z: bigint };
|
|
125
|
+
high: { x: bigint; y: bigint; z: bigint };
|
|
126
|
+
gridId?: string;
|
|
127
|
+
} {
|
|
128
|
+
return {
|
|
129
|
+
low: { x: BigInt(bounds.low.x), y: BigInt(bounds.low.y), z: BigInt(bounds.low.z) },
|
|
130
|
+
high: { x: BigInt(bounds.high.x), y: BigInt(bounds.high.y), z: BigInt(bounds.high.z) },
|
|
131
|
+
// With the id the broker joins the page-local grid event bus (DN-10).
|
|
132
|
+
...(gridId ? { gridId } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { PointerClickBuffer } from './pointerClicks';
|
|
4
|
+
|
|
5
|
+
describe('PointerClickBuffer', () => {
|
|
6
|
+
it('drains queued clicks and reports live hold time', () => {
|
|
7
|
+
let now = 1_000;
|
|
8
|
+
const buf = new PointerClickBuffer(() => now);
|
|
9
|
+
buf.push({ t: 'down', button: 0, atMs: 1_000, nx: 0.2, ny: -0.1 }, 1);
|
|
10
|
+
now = 1_080;
|
|
11
|
+
const held = buf.drainPointerClicks();
|
|
12
|
+
expect(held.clicks).toEqual([{ t: 'down', button: 0, atMs: 1_000, nx: 0.2, ny: -0.1 }]);
|
|
13
|
+
expect(held.buttons).toBe(1);
|
|
14
|
+
expect(held.holdingMs).toEqual({ '0': 80 });
|
|
15
|
+
now = 1_200;
|
|
16
|
+
buf.push({ t: 'up', button: 0, atMs: 1_200, heldMs: 200, nx: 0.2, ny: -0.1 }, 0);
|
|
17
|
+
const released = buf.drainPointerClicks();
|
|
18
|
+
expect(released.clicks[0]?.heldMs).toBe(200);
|
|
19
|
+
expect(released.buttons).toBe(0);
|
|
20
|
+
expect(released.holdingMs).toEqual({});
|
|
21
|
+
expect(buf.drainPointerClicks().clicks).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holodeck mouse clicks for CLIENT mods. DSH builds click-to-charge from
|
|
3
|
+
* `host_call("pointer_clicks")` / `crowdy::api::pointer_clicks()`: drain the
|
|
4
|
+
* queue every tick, read `holdingMs["0"]` while LMB is down, fire on `up`.
|
|
5
|
+
*
|
|
6
|
+
* Only `.scene-canvas` counts. Studio chrome, text fields, and Monaco are
|
|
7
|
+
* omitted even while Studio is open, so authors can click the world to test.
|
|
8
|
+
*/
|
|
9
|
+
import { isTextEntry } from '../../engine/Input';
|
|
10
|
+
|
|
11
|
+
export interface PointerClickEvent {
|
|
12
|
+
t: 'down' | 'up';
|
|
13
|
+
button: number;
|
|
14
|
+
atMs: number;
|
|
15
|
+
heldMs?: number;
|
|
16
|
+
nx: number;
|
|
17
|
+
ny: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PointerClicksSnapshot {
|
|
21
|
+
nowMs: number;
|
|
22
|
+
buttons: number;
|
|
23
|
+
holdingMs: Record<string, number>;
|
|
24
|
+
clicks: PointerClickEvent[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_QUEUE = 32;
|
|
28
|
+
const CANVAS_SELECTOR = '.scene-canvas';
|
|
29
|
+
|
|
30
|
+
function canvasOf(target: EventTarget | null): HTMLCanvasElement | null {
|
|
31
|
+
if (!(target instanceof Element)) return null;
|
|
32
|
+
return target.closest(CANVAS_SELECTOR);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ndcFor(event: PointerEvent, canvas: HTMLCanvasElement | null): { nx: number; ny: number } {
|
|
36
|
+
if (typeof document !== 'undefined' && document.pointerLockElement) {
|
|
37
|
+
return { nx: 0, ny: 0 };
|
|
38
|
+
}
|
|
39
|
+
if (!canvas) return { nx: 0, ny: 0 };
|
|
40
|
+
const rect = canvas.getBoundingClientRect();
|
|
41
|
+
const w = rect.width || 1;
|
|
42
|
+
const h = rect.height || 1;
|
|
43
|
+
return {
|
|
44
|
+
nx: ((event.clientX - rect.left) / w) * 2 - 1,
|
|
45
|
+
ny: -(((event.clientY - rect.top) / h) * 2 - 1),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class PointerClickBuffer {
|
|
50
|
+
private clicks: PointerClickEvent[] = [];
|
|
51
|
+
private buttons = 0;
|
|
52
|
+
private readonly downAt = new Map<number, number>();
|
|
53
|
+
private lastCanvas: HTMLCanvasElement | null = null;
|
|
54
|
+
private detach: (() => void) | null = null;
|
|
55
|
+
private readonly now: () => number;
|
|
56
|
+
|
|
57
|
+
constructor(now: () => number = () => Date.now()) {
|
|
58
|
+
this.now = now;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Listen on `window` (capture). Safe to call twice. */
|
|
62
|
+
attach(target: Window | null = typeof window === 'undefined' ? null : window): void {
|
|
63
|
+
if (this.detach || !target) return;
|
|
64
|
+
const onDown = (event: PointerEvent) => this.onPointer(event, 'down');
|
|
65
|
+
const onUp = (event: PointerEvent) => this.onPointer(event, 'up');
|
|
66
|
+
target.addEventListener('pointerdown', onDown, true);
|
|
67
|
+
target.addEventListener('pointerup', onUp, true);
|
|
68
|
+
this.detach = () => {
|
|
69
|
+
target.removeEventListener('pointerdown', onDown, true);
|
|
70
|
+
target.removeEventListener('pointerup', onUp, true);
|
|
71
|
+
this.detach = null;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
dispose(): void {
|
|
76
|
+
this.detach?.();
|
|
77
|
+
this.clicks = [];
|
|
78
|
+
this.buttons = 0;
|
|
79
|
+
this.downAt.clear();
|
|
80
|
+
this.lastCanvas = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Test helper: enqueue as if the holodeck canvas saw the event. */
|
|
84
|
+
push(event: PointerClickEvent, buttons = this.buttons): void {
|
|
85
|
+
if (event.t === 'down') this.downAt.set(event.button, event.atMs);
|
|
86
|
+
if (event.t === 'up') this.downAt.delete(event.button);
|
|
87
|
+
this.buttons = buttons;
|
|
88
|
+
this.clicks.push(event);
|
|
89
|
+
if (this.clicks.length > MAX_QUEUE) this.clicks.splice(0, this.clicks.length - MAX_QUEUE);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
drainPointerClicks(): PointerClicksSnapshot {
|
|
93
|
+
const nowMs = this.now();
|
|
94
|
+
const clicks = this.clicks;
|
|
95
|
+
this.clicks = [];
|
|
96
|
+
const holdingMs: Record<string, number> = {};
|
|
97
|
+
for (const [button, started] of this.downAt) {
|
|
98
|
+
holdingMs[String(button)] = Math.max(0, nowMs - started);
|
|
99
|
+
}
|
|
100
|
+
return { nowMs, buttons: this.buttons, holdingMs, clicks };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private onPointer(event: PointerEvent, phase: 'down' | 'up'): void {
|
|
104
|
+
if (isTextEntry(event.target)) return;
|
|
105
|
+
const canvas =
|
|
106
|
+
canvasOf(event.target) ??
|
|
107
|
+
(phase === 'up' && this.downAt.has(event.button) ? this.lastCanvas : null);
|
|
108
|
+
if (phase === 'down') {
|
|
109
|
+
if (!canvas) return;
|
|
110
|
+
this.lastCanvas = canvas;
|
|
111
|
+
} else if (!this.downAt.has(event.button)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const atMs = this.now();
|
|
115
|
+
const { nx, ny } = ndcFor(event, canvas);
|
|
116
|
+
const heldMs =
|
|
117
|
+
phase === 'up' && this.downAt.has(event.button)
|
|
118
|
+
? Math.max(0, atMs - (this.downAt.get(event.button) ?? atMs))
|
|
119
|
+
: undefined;
|
|
120
|
+
this.push(
|
|
121
|
+
{
|
|
122
|
+
t: phase,
|
|
123
|
+
button: event.button,
|
|
124
|
+
atMs,
|
|
125
|
+
...(heldMs !== undefined ? { heldMs } : {}),
|
|
126
|
+
nx,
|
|
127
|
+
ny,
|
|
128
|
+
},
|
|
129
|
+
event.buttons,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|