@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,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Construct's replication state: what every player broadcasts about
|
|
3
|
+
* itself a few times a second, and what every scene renders for everyone else.
|
|
4
|
+
*
|
|
5
|
+
* Declared ONCE as a binary struct (spatial packets have a ~1.1 KB budget, so
|
|
6
|
+
* poses never travel as JSON). Both scenes consume the same `Pose`; the
|
|
7
|
+
* `program` byte says which scene the actor is standing in so the holodeck can
|
|
8
|
+
* show who is "inside a program" and the program can hide people who are not.
|
|
9
|
+
*
|
|
10
|
+
* Layout (little-endian, 64 bytes):
|
|
11
|
+
* 0-11 position xyz f32
|
|
12
|
+
* 12-15 yaw f32
|
|
13
|
+
* 16-19 pitch f32
|
|
14
|
+
* 20-31 velocity xyz f32
|
|
15
|
+
* 32 flags u8 (bit0 moving, bit1 in-program, bit2 studio-open)
|
|
16
|
+
* 33 program u8 (0 = holodeck, 1.. = program id)
|
|
17
|
+
* 34 tint u8 (avatar colour index)
|
|
18
|
+
* 35 reserved u8
|
|
19
|
+
* 36-59 name 24 bytes UTF-8, NUL padded
|
|
20
|
+
* 60-63 reserved
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
bytes,
|
|
24
|
+
f32,
|
|
25
|
+
reserved,
|
|
26
|
+
structCodec,
|
|
27
|
+
u8,
|
|
28
|
+
type StateCodec,
|
|
29
|
+
} from '@crowdedkingdoms/crowdyjs/stores';
|
|
30
|
+
|
|
31
|
+
export const FLAG_MOVING = 1 << 0;
|
|
32
|
+
export const FLAG_IN_PROGRAM = 1 << 1;
|
|
33
|
+
export const FLAG_STUDIO_OPEN = 1 << 2;
|
|
34
|
+
|
|
35
|
+
export const NAME_BYTES = 24;
|
|
36
|
+
|
|
37
|
+
export interface Pose {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
z: number;
|
|
41
|
+
yaw: number;
|
|
42
|
+
pitch: number;
|
|
43
|
+
vx: number;
|
|
44
|
+
vy: number;
|
|
45
|
+
vz: number;
|
|
46
|
+
flags: number;
|
|
47
|
+
program: number;
|
|
48
|
+
tint: number;
|
|
49
|
+
name: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const NEUTRAL_POSE: Pose = {
|
|
53
|
+
x: 0,
|
|
54
|
+
y: 0,
|
|
55
|
+
z: 0,
|
|
56
|
+
yaw: 0,
|
|
57
|
+
pitch: 0,
|
|
58
|
+
vx: 0,
|
|
59
|
+
vy: 0,
|
|
60
|
+
vz: 0,
|
|
61
|
+
flags: 0,
|
|
62
|
+
program: 0,
|
|
63
|
+
tint: 0,
|
|
64
|
+
name: '',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const wire = structCodec({
|
|
68
|
+
x: f32(),
|
|
69
|
+
y: f32(),
|
|
70
|
+
z: f32(),
|
|
71
|
+
yaw: f32(),
|
|
72
|
+
pitch: f32(),
|
|
73
|
+
vx: f32(),
|
|
74
|
+
vy: f32(),
|
|
75
|
+
vz: f32(),
|
|
76
|
+
flags: u8(),
|
|
77
|
+
program: u8(),
|
|
78
|
+
tint: u8(),
|
|
79
|
+
pad0: reserved(1),
|
|
80
|
+
name: bytes(NAME_BYTES),
|
|
81
|
+
pad1: reserved(4),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const encoder = new TextEncoder();
|
|
85
|
+
const decoder = new TextDecoder();
|
|
86
|
+
|
|
87
|
+
export function encodeName(name: string, length: number = NAME_BYTES): Uint8Array {
|
|
88
|
+
const out = new Uint8Array(length);
|
|
89
|
+
// Trim by bytes, never splitting a multi-byte sequence: encode progressively.
|
|
90
|
+
let cut = name;
|
|
91
|
+
let encoded = encoder.encode(cut);
|
|
92
|
+
while (encoded.length > length && cut.length > 0) {
|
|
93
|
+
cut = cut.slice(0, -1);
|
|
94
|
+
encoded = encoder.encode(cut);
|
|
95
|
+
}
|
|
96
|
+
out.set(encoded.subarray(0, length));
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function decodeName(raw: Uint8Array): string {
|
|
101
|
+
let end = raw.indexOf(0);
|
|
102
|
+
if (end < 0) end = raw.length;
|
|
103
|
+
return decoder.decode(raw.subarray(0, end));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The typed codec World Stores use for `self` and `actors`. */
|
|
107
|
+
export const poseCodec: StateCodec<Pose> = {
|
|
108
|
+
encode: (pose) =>
|
|
109
|
+
wire.encode({
|
|
110
|
+
x: pose.x,
|
|
111
|
+
y: pose.y,
|
|
112
|
+
z: pose.z,
|
|
113
|
+
yaw: pose.yaw,
|
|
114
|
+
pitch: pose.pitch,
|
|
115
|
+
vx: pose.vx,
|
|
116
|
+
vy: pose.vy,
|
|
117
|
+
vz: pose.vz,
|
|
118
|
+
flags: pose.flags & 0xff,
|
|
119
|
+
program: pose.program & 0xff,
|
|
120
|
+
tint: pose.tint & 0xff,
|
|
121
|
+
name: encodeName(pose.name),
|
|
122
|
+
}),
|
|
123
|
+
decode: (data) => {
|
|
124
|
+
const raw = wire.decode(data);
|
|
125
|
+
return {
|
|
126
|
+
x: raw.x,
|
|
127
|
+
y: raw.y,
|
|
128
|
+
z: raw.z,
|
|
129
|
+
yaw: raw.yaw,
|
|
130
|
+
pitch: raw.pitch,
|
|
131
|
+
vx: raw.vx,
|
|
132
|
+
vy: raw.vy,
|
|
133
|
+
vz: raw.vz,
|
|
134
|
+
flags: raw.flags,
|
|
135
|
+
program: raw.program,
|
|
136
|
+
tint: raw.tint,
|
|
137
|
+
name: decodeName(raw.name),
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const POSE_BYTES: number = wire.byteLength;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
chunkDistance,
|
|
5
|
+
chunkInput,
|
|
6
|
+
chunkKey,
|
|
7
|
+
sameChunk,
|
|
8
|
+
worldToChunk,
|
|
9
|
+
worldToVoxel,
|
|
10
|
+
} from './space';
|
|
11
|
+
|
|
12
|
+
describe('space', () => {
|
|
13
|
+
it('maps world positions to 16-unit chunks with floor semantics', () => {
|
|
14
|
+
expect(worldToChunk({ x: 0, y: 0, z: 0 })).toEqual({ x: 0, y: 0, z: 0 });
|
|
15
|
+
expect(worldToChunk({ x: 15.9, y: 1, z: -0.1 })).toEqual({ x: 0, y: 0, z: -1 });
|
|
16
|
+
expect(worldToChunk({ x: -16, y: 32, z: 17 })).toEqual({ x: -1, y: 2, z: 1 });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('maps world positions to in-chunk voxel cells, including negatives', () => {
|
|
20
|
+
expect(worldToVoxel({ x: 3.2, y: 0, z: 17 })).toEqual({ x: 3, y: 0, z: 1 });
|
|
21
|
+
expect(worldToVoxel({ x: -1, y: 0, z: -16 })).toEqual({ x: 15, y: 0, z: 0 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('produces the string wire form and stable keys', () => {
|
|
25
|
+
expect(chunkInput({ x: -1, y: 0, z: 7 })).toEqual({ x: '-1', y: '0', z: '7' });
|
|
26
|
+
expect(chunkKey({ x: -1, y: 0, z: 7 })).toBe('-1,0,7');
|
|
27
|
+
expect(sameChunk({ x: 1, y: 2, z: 3 }, { x: '1', y: '2', z: '3' })).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('uses Chebyshev distance like the platform fan-out', () => {
|
|
31
|
+
expect(chunkDistance({ x: 0, y: 0, z: 0 }, { x: 3, y: -1, z: 2 })).toBe(3);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* World <-> chunk math. Everything realtime on Crowded Kingdoms is addressed
|
|
3
|
+
* to a chunk and fanned out within `distance` chunks; the game decides what a
|
|
4
|
+
* chunk means in world units. The Construct uses 16 units per chunk on all
|
|
5
|
+
* three axes and keeps the holodeck floor at world y in [0, 16), i.e. chunk
|
|
6
|
+
* layer 0.
|
|
7
|
+
*/
|
|
8
|
+
import type { ChunkCoordinatesInput } from '@crowdedkingdoms/crowdyjs';
|
|
9
|
+
|
|
10
|
+
import { CHUNK_SIZE } from '../config';
|
|
11
|
+
|
|
12
|
+
export interface Vec3 {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
z: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChunkCoord {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
z: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function worldToChunk(position: Vec3, size: number = CHUNK_SIZE): ChunkCoord {
|
|
25
|
+
return {
|
|
26
|
+
x: Math.floor(position.x / size),
|
|
27
|
+
y: Math.floor(position.y / size),
|
|
28
|
+
z: Math.floor(position.z / size),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** World position -> the voxel cell inside its chunk (0..size-1 on each axis). */
|
|
33
|
+
export function worldToVoxel(position: Vec3, size: number = CHUNK_SIZE): ChunkCoord {
|
|
34
|
+
const mod = (v: number) => ((Math.floor(v) % size) + size) % size;
|
|
35
|
+
return { x: mod(position.x), y: mod(position.y), z: mod(position.z) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The wire form: chunk coordinates travel as decimal strings (int64). */
|
|
39
|
+
export function chunkInput(chunk: ChunkCoord | ChunkCoordinatesInput): ChunkCoordinatesInput {
|
|
40
|
+
return { x: String(chunk.x), y: String(chunk.y), z: String(chunk.z) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function chunkFromInput(
|
|
44
|
+
chunk: ChunkCoordinatesInput | { x: unknown; y: unknown; z: unknown },
|
|
45
|
+
): ChunkCoord {
|
|
46
|
+
return { x: Number(chunk.x), y: Number(chunk.y), z: Number(chunk.z) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function chunkKey(chunk: ChunkCoord | ChunkCoordinatesInput): string {
|
|
50
|
+
return `${chunk.x},${chunk.y},${chunk.z}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sameChunk(
|
|
54
|
+
a: ChunkCoord | ChunkCoordinatesInput,
|
|
55
|
+
b: ChunkCoord | ChunkCoordinatesInput,
|
|
56
|
+
): boolean {
|
|
57
|
+
return String(a.x) === String(b.x) && String(a.y) === String(b.y) && String(a.z) === String(b.z);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Chebyshev distance in chunks — the metric the platform fans out with. */
|
|
61
|
+
export function chunkDistance(a: ChunkCoord, b: ChunkCoord): number {
|
|
62
|
+
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z));
|
|
63
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proximity text chat over the realtime plane.
|
|
3
|
+
*
|
|
4
|
+
* A text packet is addressed to the sender's chunk and fanned out within
|
|
5
|
+
* `distance` chunks — the same spatial delivery as poses, which is why chat
|
|
6
|
+
* "just works" in whichever scene the player is standing in. Names are not on
|
|
7
|
+
* the wire: the receiver looks the sender's uuid up in the players lane.
|
|
8
|
+
*/
|
|
9
|
+
import { REPLICATION_DISTANCE } from '../config';
|
|
10
|
+
import { NetworkManager } from '../network/NetworkManager';
|
|
11
|
+
import { chunkInput, type ChunkCoord } from '../realtime/space';
|
|
12
|
+
import { remotePlayers, worldSession } from '../realtime/WorldStores';
|
|
13
|
+
import { Emitter } from '../util/Emitter';
|
|
14
|
+
|
|
15
|
+
export interface ChatMessage {
|
|
16
|
+
id: number;
|
|
17
|
+
uuid: string;
|
|
18
|
+
name: string;
|
|
19
|
+
text: string;
|
|
20
|
+
at: number;
|
|
21
|
+
self: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const MAX_CHAT_TEXT = 240;
|
|
25
|
+
|
|
26
|
+
export class ChatService {
|
|
27
|
+
readonly events = new Emitter<{ message: ChatMessage }>();
|
|
28
|
+
private readonly history: ChatMessage[] = [];
|
|
29
|
+
private nextId = 1;
|
|
30
|
+
private sequence = 0;
|
|
31
|
+
private unsubscribe: (() => void) | null = null;
|
|
32
|
+
|
|
33
|
+
constructor(private readonly network: NetworkManager = NetworkManager.instance) {}
|
|
34
|
+
|
|
35
|
+
start(): void {
|
|
36
|
+
if (this.unsubscribe) return;
|
|
37
|
+
this.unsubscribe = this.network.on('text', (n) => {
|
|
38
|
+
const session = worldSession();
|
|
39
|
+
const self = n.uuid === session.self.uuid;
|
|
40
|
+
const name = self
|
|
41
|
+
? session.self.state.name || 'you'
|
|
42
|
+
: remotePlayers().find((p) => p.uuid === n.uuid)?.pose.name || shortUuid(n.uuid);
|
|
43
|
+
this.push({ uuid: n.uuid, name, text: n.text, self });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
stop(): void {
|
|
48
|
+
this.unsubscribe?.();
|
|
49
|
+
this.unsubscribe = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
messages(): readonly ChatMessage[] {
|
|
53
|
+
return this.history;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Send from the chunk the local actor currently stands in. */
|
|
57
|
+
async send(text: string, chunk?: ChunkCoord): Promise<void> {
|
|
58
|
+
const trimmed = text.trim().slice(0, MAX_CHAT_TEXT);
|
|
59
|
+
if (!trimmed) return;
|
|
60
|
+
const session = worldSession();
|
|
61
|
+
const at = chunk ? chunkInput(chunk) : session.self.chunk;
|
|
62
|
+
if (!at) throw new Error('Join the world before chatting');
|
|
63
|
+
this.sequence = (this.sequence + 1) % 256;
|
|
64
|
+
await this.network.game.udp.sendTextPacket({
|
|
65
|
+
appId: this.network.appId!,
|
|
66
|
+
chunk: at,
|
|
67
|
+
uuid: session.self.uuid,
|
|
68
|
+
text: trimmed,
|
|
69
|
+
sequenceNumber: this.sequence,
|
|
70
|
+
distance: REPLICATION_DISTANCE,
|
|
71
|
+
});
|
|
72
|
+
// The platform echoes our own text back as a notification, which is what
|
|
73
|
+
// appends it to history — no optimistic insert, so what you see is what
|
|
74
|
+
// everyone else saw.
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Local-only line (system notices), never sent. */
|
|
78
|
+
system(text: string): void {
|
|
79
|
+
this.push({ uuid: '', name: 'construct', text, self: false });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private push(entry: Omit<ChatMessage, 'id' | 'at'>): void {
|
|
83
|
+
const message: ChatMessage = { id: this.nextId++, at: Date.now(), ...entry };
|
|
84
|
+
this.history.push(message);
|
|
85
|
+
if (this.history.length > 200) this.history.shift();
|
|
86
|
+
this.events.emit('message', message);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function shortUuid(uuid: string): string {
|
|
91
|
+
return `player-${uuid.slice(0, 6)}`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ClientModLifecycle } from './ClientModLifecycle';
|
|
4
|
+
|
|
5
|
+
const d = (id: string, artifactHash = 'a', capabilityHash = 'c') => ({
|
|
6
|
+
attachmentId: id,
|
|
7
|
+
artifactHash,
|
|
8
|
+
capabilityHash,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('ClientModLifecycle', () => {
|
|
12
|
+
it('stops every worker when the grid changes', () => {
|
|
13
|
+
const life = new ClientModLifecycle();
|
|
14
|
+
expect(life.enterGrid('g1')).toBe(true);
|
|
15
|
+
const scope = life.scope('g1')!;
|
|
16
|
+
const stop = vi.fn();
|
|
17
|
+
expect(life.track(scope, d('m1'), { stop })).toBe(true);
|
|
18
|
+
expect(life.enterGrid('g1')).toBe(false);
|
|
19
|
+
expect(life.enterGrid('g2')).toBe(true);
|
|
20
|
+
expect(stop).toHaveBeenCalledTimes(1);
|
|
21
|
+
expect(life.runningCount).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects a worker that finished starting after the grid changed', () => {
|
|
25
|
+
const life = new ClientModLifecycle();
|
|
26
|
+
life.enterGrid('g1');
|
|
27
|
+
const stale = life.scope('g1')!;
|
|
28
|
+
life.enterGrid('g2');
|
|
29
|
+
const stop = vi.fn();
|
|
30
|
+
expect(life.track(stale, d('m1'), { stop })).toBe(false);
|
|
31
|
+
expect(stop).toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('reconciles by immutable hashes', () => {
|
|
35
|
+
const life = new ClientModLifecycle();
|
|
36
|
+
life.enterGrid('g1');
|
|
37
|
+
const scope = life.scope('g1')!;
|
|
38
|
+
const stopA = vi.fn();
|
|
39
|
+
const stopB = vi.fn();
|
|
40
|
+
life.track(scope, d('a'), { stop: stopA });
|
|
41
|
+
life.track(scope, d('b'), { stop: stopB });
|
|
42
|
+
expect(life.reconcile(scope, [d('a'), d('b', 'a2')])).toBe(true);
|
|
43
|
+
expect(stopA).not.toHaveBeenCalled();
|
|
44
|
+
expect(stopB).toHaveBeenCalled();
|
|
45
|
+
expect(life.has('a')).toBe(true);
|
|
46
|
+
expect(life.has('b')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is inert after shutdown', () => {
|
|
50
|
+
const life = new ClientModLifecycle();
|
|
51
|
+
life.enterGrid('g1');
|
|
52
|
+
life.shutdown();
|
|
53
|
+
expect(life.enterGrid('g2')).toBe(false);
|
|
54
|
+
expect(life.scope('g2')).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the worker handles of grid-attached CLIENT mods independently of any
|
|
3
|
+
* scene. Every metadata refresh reconciles immutable hashes; changing grids
|
|
4
|
+
* synchronously tears everything down; a worker that finishes starting after
|
|
5
|
+
* a grid change (or shutdown) is stopped instead of attached stale.
|
|
6
|
+
*/
|
|
7
|
+
export interface ClientModDescriptor {
|
|
8
|
+
attachmentId: string;
|
|
9
|
+
artifactHash: string;
|
|
10
|
+
capabilityHash: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ClientModHandle {
|
|
14
|
+
stop: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ClientModScope {
|
|
18
|
+
gridId: string;
|
|
19
|
+
generation: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RunningClientMod extends ClientModDescriptor, ClientModHandle {}
|
|
23
|
+
|
|
24
|
+
export class ClientModLifecycle {
|
|
25
|
+
private readonly running = new Map<string, RunningClientMod>();
|
|
26
|
+
private activeGridId: string | null = null;
|
|
27
|
+
private generation = 0;
|
|
28
|
+
private stopped = false;
|
|
29
|
+
|
|
30
|
+
/** Returns true when the grid actually changed (callers reset their caches). */
|
|
31
|
+
enterGrid(gridId: string | null): boolean {
|
|
32
|
+
if (this.stopped) return false;
|
|
33
|
+
if (gridId === this.activeGridId) return false;
|
|
34
|
+
this.stopAll();
|
|
35
|
+
this.activeGridId = gridId;
|
|
36
|
+
this.generation++;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get currentGridId(): string | null {
|
|
41
|
+
return this.activeGridId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
has(attachmentId: string): boolean {
|
|
45
|
+
return this.running.has(attachmentId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get runningCount(): number {
|
|
49
|
+
return this.running.size;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
scope(gridId: string): ClientModScope | null {
|
|
53
|
+
if (this.stopped || gridId !== this.activeGridId) return null;
|
|
54
|
+
return { gridId, generation: this.generation };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
isCurrent(scope: ClientModScope): boolean {
|
|
58
|
+
return (
|
|
59
|
+
!this.stopped && scope.gridId === this.activeGridId && scope.generation === this.generation
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
track(scope: ClientModScope, descriptor: ClientModDescriptor, handle: ClientModHandle): boolean {
|
|
64
|
+
if (!this.isCurrent(scope) || this.running.has(descriptor.attachmentId)) {
|
|
65
|
+
handle.stop();
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
this.running.set(descriptor.attachmentId, { ...descriptor, ...handle });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Stop mods that disappeared or changed hash; returns false if scope is stale. */
|
|
73
|
+
reconcile(scope: ClientModScope, descriptors: readonly ClientModDescriptor[]): boolean {
|
|
74
|
+
if (!this.isCurrent(scope)) return false;
|
|
75
|
+
const current = new Map(descriptors.map((d) => [d.attachmentId, d]));
|
|
76
|
+
for (const [attachmentId, running] of this.running) {
|
|
77
|
+
const next = current.get(attachmentId);
|
|
78
|
+
if (
|
|
79
|
+
!next ||
|
|
80
|
+
next.artifactHash !== running.artifactHash ||
|
|
81
|
+
next.capabilityHash !== running.capabilityHash
|
|
82
|
+
) {
|
|
83
|
+
running.stop();
|
|
84
|
+
this.running.delete(attachmentId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private stopAll(): void {
|
|
91
|
+
for (const running of this.running.values()) running.stop();
|
|
92
|
+
this.running.clear();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
shutdown(): void {
|
|
96
|
+
if (this.stopped) return;
|
|
97
|
+
this.stopped = true;
|
|
98
|
+
this.activeGridId = null;
|
|
99
|
+
this.generation++;
|
|
100
|
+
this.stopAll();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { AgentLocomotion } from './agentLocomotion';
|
|
4
|
+
import {
|
|
5
|
+
CONSTRUCT_AGENT_TOOL_NAMES,
|
|
6
|
+
ConstructPlayerHostAdapter,
|
|
7
|
+
capabilityRevisionFor,
|
|
8
|
+
type ConstructObservationFrame,
|
|
9
|
+
} from './ConstructPlayerHostAdapter';
|
|
10
|
+
import { studioPermissions } from './permissions';
|
|
11
|
+
import type { GridSnapshot } from './GridService';
|
|
12
|
+
|
|
13
|
+
function grid(owned = true): GridSnapshot {
|
|
14
|
+
return {
|
|
15
|
+
gridId: '99',
|
|
16
|
+
bounds: { low: { x: '0', y: '0', z: '0' }, high: { x: '0', y: '0', z: '0' } },
|
|
17
|
+
permissions: studioPermissions(['write_server_code', 'run_server_code']),
|
|
18
|
+
owned,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function frame(over: Partial<ConstructObservationFrame> = {}): ConstructObservationFrame {
|
|
23
|
+
return {
|
|
24
|
+
playerId: 'player-1',
|
|
25
|
+
position: { x: 1, y: 0, z: 2 },
|
|
26
|
+
velocity: { x: 0, y: 0, z: 0 },
|
|
27
|
+
yaw: 0.5,
|
|
28
|
+
pitch: -0.1,
|
|
29
|
+
grid: grid(),
|
|
30
|
+
nearbyActors: [{ actorId: 'p2', position: { x: 3, y: 0, z: 2 }, label: 'Ada' }],
|
|
31
|
+
humanInputActive: false,
|
|
32
|
+
textInputFocused: false,
|
|
33
|
+
modalOpen: false,
|
|
34
|
+
...over,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setup(over: Partial<ConstructObservationFrame> = {}) {
|
|
39
|
+
const current = { value: frame(over) };
|
|
40
|
+
const locomotion = new AgentLocomotion();
|
|
41
|
+
const sendChat = vi.fn(async () => undefined);
|
|
42
|
+
const adapter = new ConstructPlayerHostAdapter({
|
|
43
|
+
frame: () => current.value,
|
|
44
|
+
locomotion,
|
|
45
|
+
sendChat,
|
|
46
|
+
now: () => 1_000,
|
|
47
|
+
});
|
|
48
|
+
return { adapter, current, locomotion, sendChat };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('ConstructPlayerHostAdapter', () => {
|
|
52
|
+
it('advertises only observe, move, look, stop, and local chat', async () => {
|
|
53
|
+
const { adapter } = setup();
|
|
54
|
+
const capabilities = await adapter.capabilities();
|
|
55
|
+
expect(capabilities.gameId).toBe('the-construct');
|
|
56
|
+
expect(capabilities.commands.map((c) => c.toolName)).toEqual(
|
|
57
|
+
CONSTRUCT_AGENT_TOOL_NAMES.slice(2),
|
|
58
|
+
);
|
|
59
|
+
expect(
|
|
60
|
+
capabilities.commands.some((c) => /inventory|combat|craft|mount|teleport/i.test(c.toolName)),
|
|
61
|
+
).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('observes nearby players and the current grid', async () => {
|
|
65
|
+
const { adapter } = setup();
|
|
66
|
+
const observation = await adapter.observe({
|
|
67
|
+
detail: 'STANDARD',
|
|
68
|
+
maxNearbyActors: 8,
|
|
69
|
+
maxNearbyVoxels: 8,
|
|
70
|
+
});
|
|
71
|
+
expect(observation.contractVersion).toBe('crowdy.game-observation/1');
|
|
72
|
+
expect(observation.nearbyActors).toHaveLength(1);
|
|
73
|
+
expect(observation.nearbyActors[0]?.label).toBe('Ada');
|
|
74
|
+
expect(observation.grid?.gridRef).toBe('99');
|
|
75
|
+
expect(observation.nearbyVoxels).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('applies MOVE to locomotion and denies GROUP chat', async () => {
|
|
79
|
+
const { adapter, locomotion, sendChat } = setup();
|
|
80
|
+
const observation = await adapter.observe({
|
|
81
|
+
detail: 'MINIMAL',
|
|
82
|
+
maxNearbyActors: 0,
|
|
83
|
+
maxNearbyVoxels: 0,
|
|
84
|
+
});
|
|
85
|
+
const gate = {
|
|
86
|
+
contractVersion: 'crowdy.validated-gate/1' as const,
|
|
87
|
+
clientEpoch: '1',
|
|
88
|
+
leaseId: 'lease-1',
|
|
89
|
+
scopes: ['locomotion', 'communicate'] as const,
|
|
90
|
+
contextVersion: '1',
|
|
91
|
+
observationId: observation.observationId,
|
|
92
|
+
validatedAt: new Date().toISOString(),
|
|
93
|
+
};
|
|
94
|
+
const moved = await adapter.dispatch(
|
|
95
|
+
{
|
|
96
|
+
kind: 'MOVE',
|
|
97
|
+
observationId: observation.observationId,
|
|
98
|
+
capabilityRevision: observation.capabilityRevision,
|
|
99
|
+
controlledEntityId: observation.controlledEntityId,
|
|
100
|
+
direction: 'FORWARD',
|
|
101
|
+
intensity: 1,
|
|
102
|
+
durationMs: 200,
|
|
103
|
+
},
|
|
104
|
+
gate,
|
|
105
|
+
);
|
|
106
|
+
expect(moved.status).toBe('SUCCEEDED');
|
|
107
|
+
expect(locomotion.sample(1_050).y).toBe(1);
|
|
108
|
+
|
|
109
|
+
const chat = await adapter.dispatch(
|
|
110
|
+
{
|
|
111
|
+
kind: 'CHAT_SEND',
|
|
112
|
+
observationId: observation.observationId,
|
|
113
|
+
capabilityRevision: observation.capabilityRevision,
|
|
114
|
+
controlledEntityId: observation.controlledEntityId,
|
|
115
|
+
channel: 'GROUP',
|
|
116
|
+
text: 'nope',
|
|
117
|
+
},
|
|
118
|
+
{ ...gate, scopes: ['communicate'] },
|
|
119
|
+
);
|
|
120
|
+
expect(chat.status).toBe('DENIED');
|
|
121
|
+
expect(sendChat).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('preempts when human input is active', async () => {
|
|
125
|
+
const { adapter, current } = setup();
|
|
126
|
+
const observation = await adapter.observe({
|
|
127
|
+
detail: 'MINIMAL',
|
|
128
|
+
maxNearbyActors: 0,
|
|
129
|
+
maxNearbyVoxels: 0,
|
|
130
|
+
});
|
|
131
|
+
current.value = { ...current.value, humanInputActive: true };
|
|
132
|
+
const result = await adapter.dispatch(
|
|
133
|
+
{
|
|
134
|
+
kind: 'LOOK',
|
|
135
|
+
observationId: observation.observationId,
|
|
136
|
+
capabilityRevision: observation.capabilityRevision,
|
|
137
|
+
controlledEntityId: observation.controlledEntityId,
|
|
138
|
+
deltaYaw: 10,
|
|
139
|
+
deltaPitch: 0,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
contractVersion: 'crowdy.validated-gate/1',
|
|
143
|
+
clientEpoch: '1',
|
|
144
|
+
scopes: ['locomotion'],
|
|
145
|
+
contextVersion: '1',
|
|
146
|
+
observationId: observation.observationId,
|
|
147
|
+
validatedAt: new Date().toISOString(),
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
expect(result.status).toBe('DENIED');
|
|
151
|
+
expect(result.error?.code).toBe('AGENT_PREEMPTED');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('changes capability revision when the grid changes', () => {
|
|
155
|
+
const a = capabilityRevisionFor(frame());
|
|
156
|
+
const b = capabilityRevisionFor(frame({ grid: null }));
|
|
157
|
+
expect(a).not.toBe(b);
|
|
158
|
+
});
|
|
159
|
+
});
|