@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.
- 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 +46 -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,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Construct implementation of `crowdy.player-host/1`.
|
|
3
|
+
*
|
|
4
|
+
* Ask/Play may observe nearby players and the current grid. Play may walk,
|
|
5
|
+
* look, stop, and send proximity chat — the same paths a human uses. No
|
|
6
|
+
* inventory, combat, craft, mount, or teleport: this starter does not have
|
|
7
|
+
* those services. Effects never touch the token, GraphQL, or the mod worker.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
CrowdyAgentError,
|
|
11
|
+
toAgentError,
|
|
12
|
+
type CrowdyAgentPreemptionReason,
|
|
13
|
+
type GameCommandResultV1,
|
|
14
|
+
type GameCommandV1,
|
|
15
|
+
type GameObservationV1,
|
|
16
|
+
type ObserveRequestV1,
|
|
17
|
+
type PlayerHostAdapterV1,
|
|
18
|
+
type PlayerHostCapabilitiesV1,
|
|
19
|
+
type PlayerHostCommandCapabilityV1,
|
|
20
|
+
type PlayerHostLeaseScope,
|
|
21
|
+
type ValidatedGateV1,
|
|
22
|
+
} from '@crowdedkingdoms/crowdyjs/player-host';
|
|
23
|
+
|
|
24
|
+
import type { AgentLocomotion } from './agentLocomotion';
|
|
25
|
+
import type { GridSnapshot } from './GridService';
|
|
26
|
+
|
|
27
|
+
export const CONSTRUCT_AGENT_TOOL_NAMES = Object.freeze([
|
|
28
|
+
'game.capabilities.get',
|
|
29
|
+
'game.observe',
|
|
30
|
+
'game.control.move',
|
|
31
|
+
'game.control.look',
|
|
32
|
+
'game.control.stop',
|
|
33
|
+
'game.chat.send',
|
|
34
|
+
] as const);
|
|
35
|
+
|
|
36
|
+
export const CONSTRUCT_OBSERVATION_MAX_AGE_MS = 1_500;
|
|
37
|
+
export const CONSTRUCT_MAX_NEARBY_ACTORS = 32;
|
|
38
|
+
export const CONSTRUCT_MAX_NEARBY_VOXELS = 0;
|
|
39
|
+
|
|
40
|
+
const COMMAND_CAPABILITIES = Object.freeze([
|
|
41
|
+
command('MOVE', 'game.control.move', 'locomotion', 8),
|
|
42
|
+
command('LOOK', 'game.control.look', 'locomotion', 12),
|
|
43
|
+
command('STOP', 'game.control.stop', undefined, 100),
|
|
44
|
+
command('CHAT_SEND', 'game.chat.send', 'communicate', 2),
|
|
45
|
+
] satisfies readonly PlayerHostCommandCapabilityV1[]);
|
|
46
|
+
|
|
47
|
+
export interface ConstructObservedActor {
|
|
48
|
+
readonly actorId: string;
|
|
49
|
+
readonly position: { x: number; y: number; z: number };
|
|
50
|
+
readonly label?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConstructObservationFrame {
|
|
54
|
+
readonly playerId: string;
|
|
55
|
+
readonly position: { x: number; y: number; z: number };
|
|
56
|
+
readonly velocity: { x: number; y: number; z: number };
|
|
57
|
+
readonly yaw: number;
|
|
58
|
+
readonly pitch: number;
|
|
59
|
+
readonly grid: GridSnapshot | null;
|
|
60
|
+
readonly nearbyActors: readonly ConstructObservedActor[];
|
|
61
|
+
readonly humanInputActive: boolean;
|
|
62
|
+
readonly textInputFocused: boolean;
|
|
63
|
+
readonly modalOpen: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ConstructPlayerHostAdapterOptions {
|
|
67
|
+
readonly frame: () => ConstructObservationFrame;
|
|
68
|
+
readonly locomotion: AgentLocomotion;
|
|
69
|
+
readonly sendChat: (text: string) => Promise<void>;
|
|
70
|
+
readonly now?: () => number;
|
|
71
|
+
readonly maxRememberedObservations?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface RememberedObservation {
|
|
75
|
+
readonly observation: GameObservationV1;
|
|
76
|
+
readonly frame: ConstructObservationFrame;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class ConstructPlayerHostAdapter implements PlayerHostAdapterV1 {
|
|
80
|
+
readonly contractVersion = 'crowdy.player-host/1' as const;
|
|
81
|
+
private readonly observations = new Map<string, RememberedObservation>();
|
|
82
|
+
private sequence = 0;
|
|
83
|
+
private readonly now: () => number;
|
|
84
|
+
|
|
85
|
+
constructor(private readonly options: ConstructPlayerHostAdapterOptions) {
|
|
86
|
+
this.now = options.now ?? Date.now;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async capabilities(): Promise<PlayerHostCapabilitiesV1> {
|
|
90
|
+
const frame = this.options.frame();
|
|
91
|
+
return capabilitiesFor(frame, this.now());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async observe(request: ObserveRequestV1): Promise<GameObservationV1> {
|
|
95
|
+
const frame = this.options.frame();
|
|
96
|
+
const capabilities = capabilitiesFor(frame, this.now());
|
|
97
|
+
const observedAtMs = this.now();
|
|
98
|
+
const observationId = `construct-obs-${observedAtMs}-${++this.sequence}`;
|
|
99
|
+
const maxActors = Math.min(
|
|
100
|
+
CONSTRUCT_MAX_NEARBY_ACTORS,
|
|
101
|
+
clampInt(request.maxNearbyActors, 0, CONSTRUCT_MAX_NEARBY_ACTORS),
|
|
102
|
+
);
|
|
103
|
+
const nearbyActors = [...frame.nearbyActors]
|
|
104
|
+
.map((actor) => ({
|
|
105
|
+
actor,
|
|
106
|
+
distance: distance(frame.position, actor.position),
|
|
107
|
+
}))
|
|
108
|
+
.sort(
|
|
109
|
+
(left, right) =>
|
|
110
|
+
left.distance - right.distance || left.actor.actorId.localeCompare(right.actor.actorId),
|
|
111
|
+
)
|
|
112
|
+
.slice(0, maxActors)
|
|
113
|
+
.map(({ actor, distance: d }) => ({
|
|
114
|
+
actorId: actor.actorId.slice(0, 128),
|
|
115
|
+
kind: 'PLAYER' as const,
|
|
116
|
+
position: vec(actor.position),
|
|
117
|
+
distance: dec(d),
|
|
118
|
+
disposition: 'NEUTRAL' as const,
|
|
119
|
+
...(actor.label ? { label: actor.label.slice(0, 128) } : {}),
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
const observation: GameObservationV1 = {
|
|
123
|
+
contractVersion: 'crowdy.game-observation/1',
|
|
124
|
+
observationId,
|
|
125
|
+
capabilityRevision: capabilities.revision,
|
|
126
|
+
controlledEntityId: frame.playerId.slice(0, 128),
|
|
127
|
+
observedAt: new Date(observedAtMs).toISOString(),
|
|
128
|
+
expiresAt: new Date(observedAtMs + CONSTRUCT_OBSERVATION_MAX_AGE_MS).toISOString(),
|
|
129
|
+
player: {
|
|
130
|
+
position: vec(frame.position),
|
|
131
|
+
velocity: vec(frame.velocity),
|
|
132
|
+
look: { yaw: dec(frame.yaw), pitch: dec(frame.pitch) },
|
|
133
|
+
health: '1',
|
|
134
|
+
alive: true,
|
|
135
|
+
},
|
|
136
|
+
controlledEntity: {
|
|
137
|
+
kind: 'PLAYER',
|
|
138
|
+
position: vec(frame.position),
|
|
139
|
+
velocity: vec(frame.velocity),
|
|
140
|
+
},
|
|
141
|
+
...(frame.grid
|
|
142
|
+
? {
|
|
143
|
+
grid: {
|
|
144
|
+
gridRef: frame.grid.gridId,
|
|
145
|
+
low: vec({
|
|
146
|
+
x: Number(frame.grid.bounds.low.x),
|
|
147
|
+
y: Number(frame.grid.bounds.low.y),
|
|
148
|
+
z: Number(frame.grid.bounds.low.z),
|
|
149
|
+
}),
|
|
150
|
+
high: vec({
|
|
151
|
+
x: Number(frame.grid.bounds.high.x),
|
|
152
|
+
y: Number(frame.grid.bounds.high.y),
|
|
153
|
+
z: Number(frame.grid.bounds.high.z),
|
|
154
|
+
}),
|
|
155
|
+
effectiveScopes: scopesFor(frame.grid),
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
: {}),
|
|
159
|
+
nearbyActors,
|
|
160
|
+
nearbyVoxels: [],
|
|
161
|
+
inputState: {
|
|
162
|
+
modalOpen: frame.modalOpen,
|
|
163
|
+
textInputFocused: frame.textInputFocused,
|
|
164
|
+
humanInputActive: frame.humanInputActive,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
this.observations.set(observationId, { observation, frame });
|
|
168
|
+
while (this.observations.size > (this.options.maxRememberedObservations ?? 32)) {
|
|
169
|
+
const oldest = this.observations.keys().next().value;
|
|
170
|
+
if (!oldest) break;
|
|
171
|
+
this.observations.delete(oldest);
|
|
172
|
+
}
|
|
173
|
+
return observation;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async dispatch(command: GameCommandV1, gate: ValidatedGateV1): Promise<GameCommandResultV1> {
|
|
177
|
+
if (command.kind === 'STOP') {
|
|
178
|
+
this.clearAgentIntent('HUMAN_STOP');
|
|
179
|
+
return success(command);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
this.assertGate(command, gate);
|
|
183
|
+
const remembered = this.observations.get(command.observationId);
|
|
184
|
+
if (!remembered) {
|
|
185
|
+
throw new CrowdyAgentError(
|
|
186
|
+
'AGENT_OBSERVATION_STALE',
|
|
187
|
+
'The Construct no longer retains that observation',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const current = this.options.frame();
|
|
191
|
+
const revision = capabilityRevisionFor(current);
|
|
192
|
+
if (
|
|
193
|
+
command.capabilityRevision !== remembered.observation.capabilityRevision ||
|
|
194
|
+
command.controlledEntityId !== remembered.observation.controlledEntityId
|
|
195
|
+
) {
|
|
196
|
+
throw new CrowdyAgentError(
|
|
197
|
+
'AGENT_CONTROL_TARGET_CHANGED',
|
|
198
|
+
'Command capability or controlled entity differs from its observation',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
revision !== command.capabilityRevision ||
|
|
203
|
+
current.playerId !== command.controlledEntityId
|
|
204
|
+
) {
|
|
205
|
+
this.clearAgentIntent('CONTROL_TARGET_CHANGED');
|
|
206
|
+
throw new CrowdyAgentError(
|
|
207
|
+
'AGENT_CONTROL_TARGET_CHANGED',
|
|
208
|
+
'Host capability or controlled entity changed before dispatch',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (Date.parse(remembered.observation.expiresAt) <= this.now()) {
|
|
212
|
+
throw new CrowdyAgentError(
|
|
213
|
+
'AGENT_OBSERVATION_STALE',
|
|
214
|
+
'Observation expired before dispatch',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (current.humanInputActive) {
|
|
218
|
+
this.clearAgentIntent('HUMAN_INPUT');
|
|
219
|
+
throw new CrowdyAgentError('AGENT_PREEMPTED', 'Human input took control');
|
|
220
|
+
}
|
|
221
|
+
if (current.modalOpen || current.textInputFocused) {
|
|
222
|
+
throw new CrowdyAgentError(
|
|
223
|
+
'AGENT_CONTEXT_CHANGED',
|
|
224
|
+
'A modal or text field currently blocks agent control',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return await this.route(command);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return denied(command, error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
clearAgentIntent(_reason: CrowdyAgentPreemptionReason): void {
|
|
234
|
+
this.options.locomotion.clear();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
invalidateObservations(): void {
|
|
238
|
+
this.observations.clear();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async route(
|
|
242
|
+
command: Exclude<GameCommandV1, { kind: 'STOP' }>,
|
|
243
|
+
): Promise<GameCommandResultV1> {
|
|
244
|
+
if (command.kind === 'MOVE') {
|
|
245
|
+
this.options.locomotion.applyMove({
|
|
246
|
+
direction: command.direction,
|
|
247
|
+
intensity: command.intensity,
|
|
248
|
+
durationMs: command.durationMs,
|
|
249
|
+
nowMs: this.now(),
|
|
250
|
+
});
|
|
251
|
+
return success(command);
|
|
252
|
+
}
|
|
253
|
+
if (command.kind === 'LOOK') {
|
|
254
|
+
this.options.locomotion.applyLook({
|
|
255
|
+
deltaYaw: command.deltaYaw,
|
|
256
|
+
deltaPitch: command.deltaPitch,
|
|
257
|
+
});
|
|
258
|
+
return success(command);
|
|
259
|
+
}
|
|
260
|
+
if (command.kind === 'CHAT_SEND') {
|
|
261
|
+
if (command.channel !== 'LOCAL') {
|
|
262
|
+
throw new CrowdyAgentError(
|
|
263
|
+
'AGENT_SCOPE_DENIED',
|
|
264
|
+
'The Construct only offers proximity (LOCAL) chat',
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
await this.options.sendChat(command.text);
|
|
268
|
+
return success(command);
|
|
269
|
+
}
|
|
270
|
+
throw new CrowdyAgentError(
|
|
271
|
+
'AGENT_HOST_UNAVAILABLE',
|
|
272
|
+
`The Construct does not advertise ${command.kind}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private assertGate(
|
|
277
|
+
command: Exclude<GameCommandV1, { kind: 'STOP' }>,
|
|
278
|
+
gate: ValidatedGateV1,
|
|
279
|
+
): void {
|
|
280
|
+
if (
|
|
281
|
+
gate.contractVersion !== 'crowdy.validated-gate/1' ||
|
|
282
|
+
gate.observationId !== command.observationId
|
|
283
|
+
) {
|
|
284
|
+
throw new CrowdyAgentError('AGENT_CONTEXT_STALE', 'Mismatched validated control gate');
|
|
285
|
+
}
|
|
286
|
+
const capability = COMMAND_CAPABILITIES.find((row) => row.kind === command.kind);
|
|
287
|
+
if (!capability) {
|
|
288
|
+
throw new CrowdyAgentError('AGENT_HOST_UNAVAILABLE', `Not advertised: ${command.kind}`);
|
|
289
|
+
}
|
|
290
|
+
if (capability.requiredScope && !gate.scopes.includes(capability.requiredScope)) {
|
|
291
|
+
throw new CrowdyAgentError(
|
|
292
|
+
'AGENT_LEASE_SCOPE_MISSING',
|
|
293
|
+
`${command.kind} requires ${capability.requiredScope}`,
|
|
294
|
+
{ requiredScope: capability.requiredScope },
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function capabilityRevisionFor(frame: ConstructObservationFrame): string {
|
|
301
|
+
const payload = [
|
|
302
|
+
'construct-host-v1',
|
|
303
|
+
frame.playerId,
|
|
304
|
+
frame.grid?.gridId ?? 'no-grid',
|
|
305
|
+
...(frame.grid ? scopesFor(frame.grid) : []),
|
|
306
|
+
].join('|');
|
|
307
|
+
let hash = 0xcbf29ce484222325n;
|
|
308
|
+
for (const byte of new TextEncoder().encode(payload)) {
|
|
309
|
+
hash ^= BigInt(byte);
|
|
310
|
+
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
|
311
|
+
}
|
|
312
|
+
return `construct-host-v1-${hash.toString(16).padStart(16, '0')}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function capabilitiesFor(frame: ConstructObservationFrame, atMs: number): PlayerHostCapabilitiesV1 {
|
|
316
|
+
return {
|
|
317
|
+
contractVersion: 'crowdy.player-host/1',
|
|
318
|
+
gameId: 'the-construct',
|
|
319
|
+
revision: capabilityRevisionFor(frame),
|
|
320
|
+
controlledEntityId: frame.playerId.slice(0, 128),
|
|
321
|
+
commands: COMMAND_CAPABILITIES,
|
|
322
|
+
observation: {
|
|
323
|
+
maxAgeMs: CONSTRUCT_OBSERVATION_MAX_AGE_MS,
|
|
324
|
+
maxNearbyActors: CONSTRUCT_MAX_NEARBY_ACTORS,
|
|
325
|
+
maxNearbyVoxels: CONSTRUCT_MAX_NEARBY_VOXELS,
|
|
326
|
+
},
|
|
327
|
+
advertisedAt: new Date(atMs).toISOString(),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function scopesFor(grid: GridSnapshot): PlayerHostLeaseScope[] {
|
|
332
|
+
const scopes: PlayerHostLeaseScope[] = ['observe'];
|
|
333
|
+
if (grid.owned || grid.permissions.server.canWrite || grid.permissions.client.canWrite) {
|
|
334
|
+
scopes.push('locomotion', 'communicate');
|
|
335
|
+
}
|
|
336
|
+
return scopes;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function command(
|
|
340
|
+
kind: PlayerHostCommandCapabilityV1['kind'],
|
|
341
|
+
toolName: string,
|
|
342
|
+
requiredScope: PlayerHostLeaseScope | undefined,
|
|
343
|
+
rateLimitPerSecond: number,
|
|
344
|
+
): PlayerHostCommandCapabilityV1 {
|
|
345
|
+
return {
|
|
346
|
+
kind,
|
|
347
|
+
toolName,
|
|
348
|
+
...(requiredScope ? { requiredScope } : {}),
|
|
349
|
+
risk: 'WORLD_CONTROL',
|
|
350
|
+
approval: 'NONE',
|
|
351
|
+
rateLimitPerSecond,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function success(command: GameCommandV1): GameCommandResultV1 {
|
|
356
|
+
return {
|
|
357
|
+
contractVersion: 'crowdy.game-command-result/1',
|
|
358
|
+
status: 'SUCCEEDED',
|
|
359
|
+
commandKind: command.kind,
|
|
360
|
+
...('observationId' in command ? { observationId: command.observationId } : {}),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function denied(command: GameCommandV1, error: unknown): GameCommandResultV1 {
|
|
365
|
+
return {
|
|
366
|
+
contractVersion: 'crowdy.game-command-result/1',
|
|
367
|
+
status: 'DENIED',
|
|
368
|
+
commandKind: command.kind,
|
|
369
|
+
...('observationId' in command ? { observationId: command.observationId } : {}),
|
|
370
|
+
error: toAgentError(error, 'AGENT_TOOL_FAILED'),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function vec(value: { x: number; y: number; z: number }) {
|
|
375
|
+
return { x: dec(value.x), y: dec(value.y), z: dec(value.z) };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function dec(value: number): string {
|
|
379
|
+
return Number.isFinite(value) ? String(value) : '0';
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function distance(
|
|
383
|
+
a: { x: number; y: number; z: number },
|
|
384
|
+
b: { x: number; y: number; z: number },
|
|
385
|
+
): number {
|
|
386
|
+
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function clampInt(value: number, min: number, max: number): number {
|
|
390
|
+
if (!Number.isFinite(value)) return min;
|
|
391
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
392
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { beforeEach, 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 { GridService } = await import('./GridService');
|
|
10
|
+
|
|
11
|
+
interface HubClaim {
|
|
12
|
+
gridId: string;
|
|
13
|
+
chunk: { x: number; y: number; z: number };
|
|
14
|
+
ownerUserId: string;
|
|
15
|
+
ownerName: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A network whose world hub keeps a registry the way `exec/construct` does. */
|
|
19
|
+
function network(userId = '42') {
|
|
20
|
+
const claims: HubClaim[] = [];
|
|
21
|
+
const logs: string[] = [];
|
|
22
|
+
const hub = {
|
|
23
|
+
call: vi.fn(async (method: string, args: Record<string, unknown> = {}) => {
|
|
24
|
+
if (method === 'claims') {
|
|
25
|
+
const at = args.chunk as HubClaim['chunk'];
|
|
26
|
+
const rows = claims.filter((c) => !at || (c.chunk.x === at.x && c.chunk.z === at.z));
|
|
27
|
+
return { claims: rows, total: claims.length };
|
|
28
|
+
}
|
|
29
|
+
if (method === 'record_claim') {
|
|
30
|
+
if (claims.some((c) => c.gridId === args.gridId && c.ownerUserId !== userId))
|
|
31
|
+
throw new Error('AppError: grid is recorded for another player');
|
|
32
|
+
if (!claims.some((c) => c.gridId === args.gridId)) {
|
|
33
|
+
claims.push({
|
|
34
|
+
gridId: String(args.gridId),
|
|
35
|
+
chunk: args.chunk as HubClaim['chunk'],
|
|
36
|
+
ownerUserId: userId,
|
|
37
|
+
ownerName: String(args.ownerName),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return { recorded: true };
|
|
41
|
+
}
|
|
42
|
+
if (method === 'release_claim') {
|
|
43
|
+
const i = claims.findIndex((c) => c.gridId === args.gridId);
|
|
44
|
+
if (i >= 0 && claims[i]!.ownerUserId !== userId)
|
|
45
|
+
throw new Error("AppError: only the claim's owner may release it");
|
|
46
|
+
if (i >= 0) claims.splice(i, 1);
|
|
47
|
+
return { released: i >= 0 };
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`unexpected ${method}`);
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
const marketplace = {
|
|
53
|
+
claimGridChunk: vi.fn(async () => ({
|
|
54
|
+
gridId: '91159989710848',
|
|
55
|
+
lowChunk: { x: '3', y: '0', z: '-2' },
|
|
56
|
+
highChunk: { x: '3', y: '0', z: '-2' },
|
|
57
|
+
effectivePermissionKeys: ['write_server_code', 'run_server_code'],
|
|
58
|
+
policy: 'SELF_CLAIM',
|
|
59
|
+
})),
|
|
60
|
+
releaseClaimedGrid: vi.fn(async () => true),
|
|
61
|
+
};
|
|
62
|
+
const gameApps = {
|
|
63
|
+
nearbyPermissions: vi.fn(async () => {
|
|
64
|
+
throw new Error('manage_apps required');
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
const net = {
|
|
68
|
+
appId: '77',
|
|
69
|
+
user: { userId, gamertag: 'neo' },
|
|
70
|
+
game: { marketplace, gameApps },
|
|
71
|
+
worldHub: hub,
|
|
72
|
+
log: (line: string) => logs.push(line),
|
|
73
|
+
};
|
|
74
|
+
return { net: net as never, hub, claims, logs, marketplace };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const AT = { x: 3, y: 0, z: -2 };
|
|
78
|
+
|
|
79
|
+
describe('GridService on the world hub registry', () => {
|
|
80
|
+
beforeEach(() => localStorage.clear());
|
|
81
|
+
|
|
82
|
+
it('records a claim for the caller and finds it again', async () => {
|
|
83
|
+
const { net, hub, claims } = network();
|
|
84
|
+
const grids = new GridService(net);
|
|
85
|
+
const snapshot = await grids.claimHere(AT, 'neo the one with a very long name indeed');
|
|
86
|
+
expect(snapshot).toMatchObject({ gridId: '91159989710848', owned: true });
|
|
87
|
+
expect(hub.call).toHaveBeenCalledWith('record_claim', {
|
|
88
|
+
gridId: '91159989710848',
|
|
89
|
+
chunk: AT,
|
|
90
|
+
ownerName: 'neo the one with a very long nam',
|
|
91
|
+
});
|
|
92
|
+
expect(claims).toHaveLength(1);
|
|
93
|
+
expect(await grids.lookup(AT)).toMatchObject({ gridId: '91159989710848', owned: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('shows a visitor the owner from the registry, with no code keys', async () => {
|
|
97
|
+
const owner = network('42');
|
|
98
|
+
await new GridService(owner.net).claimHere(AT, 'neo');
|
|
99
|
+
localStorage.clear();
|
|
100
|
+
const visitor = network('43');
|
|
101
|
+
visitor.claims.push(...owner.claims);
|
|
102
|
+
const grid = await new GridService(visitor.net).lookup(AT);
|
|
103
|
+
expect(grid).toMatchObject({ gridId: '91159989710848', owned: false, ownerName: 'neo' });
|
|
104
|
+
expect(grid?.permissions.server.canWrite).toBe(false);
|
|
105
|
+
expect(await new GridService(visitor.net).lookup({ x: 9, y: 0, z: 9 })).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('caches a chunk briefly and releases through the platform, then the registry', async () => {
|
|
109
|
+
const { net, hub, claims, marketplace } = network();
|
|
110
|
+
const grids = new GridService(net);
|
|
111
|
+
await grids.claimHere(AT, 'neo');
|
|
112
|
+
await grids.lookup(AT);
|
|
113
|
+
await grids.lookup(AT);
|
|
114
|
+
expect(hub.call.mock.calls.filter(([m]) => m === 'claims')).toHaveLength(1);
|
|
115
|
+
await grids.release('91159989710848');
|
|
116
|
+
expect(marketplace.releaseClaimedGrid).toHaveBeenCalledWith({
|
|
117
|
+
appId: '77',
|
|
118
|
+
gridId: '91159989710848',
|
|
119
|
+
});
|
|
120
|
+
expect(claims).toHaveLength(0);
|
|
121
|
+
expect(grids.ownedGrids()).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps a claim working when the registry refuses the row, and says why', async () => {
|
|
125
|
+
const { net, claims, logs } = network('42');
|
|
126
|
+
claims.push({ gridId: '91159989710848', chunk: AT, ownerUserId: '43', ownerName: 'smith' });
|
|
127
|
+
const snapshot = await new GridService(net).claimHere(AT, 'neo');
|
|
128
|
+
expect(snapshot.owned).toBe(true);
|
|
129
|
+
expect(logs.some((line) => /claim registry write failed: AppError/.test(line))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|