@crowdedkingdoms/construct 0.3.7-test.1 → 0.3.8
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 +1 -1
- package/package.json +2 -2
- package/src/engine/Input.test.ts +26 -0
- package/src/engine/Input.ts +22 -2
- package/src/platform/realtime/actorCodec.test.ts +1 -0
- package/src/platform/realtime/actorCodec.ts +7 -2
- package/src/platform/studio/GridService.test.ts +17 -0
- package/src/platform/studio/GridService.ts +87 -3
- package/src/platform/studio/StudioService.ts +245 -14
- package/src/platform/studio/clientHalves.test.ts +6 -1
- package/src/platform/studio/clientHalves.ts +15 -2
- package/src/platform/studio/clientModHost.test.ts +204 -4
- package/src/platform/studio/clientModHost.ts +109 -17
- package/src/platform/studio/instanceSchema.test.ts +43 -0
- package/src/platform/studio/instanceSchema.ts +59 -0
- package/src/platform/studio/instanceStore.ts +3 -1
- package/src/platform/studio/modChunkRuntime.ts +753 -0
- package/src/platform/studio/modPose.test.ts +34 -0
- package/src/platform/studio/modPose.ts +111 -0
- package/src/platform/studio/modScene.test.ts +79 -0
- package/src/platform/studio/modScene.ts +349 -0
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Releases are on npm under one dist-tag per tier, and a game pins the exact
|
|
|
30
30
|
version of its own tier, as it pins CrowdyJS:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
npm install --save-exact @crowdedkingdoms/construct
|
|
33
|
+
npm install --save-exact @crowdedkingdoms/construct # X.Y.Z on latest
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
`dev` is `X.Y.Z-dev.N`, `test` is `X.Y.Z-test.N`, and `prod` is `X.Y.Z` on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/construct",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "The Construct framework: the Crowded Kingdoms game platform layer (hosted sign-in, World Stores, voice/webcam/chat, Crowdy Studio dock, app and grid mods, grid programs) and engine loop, shared by the-construct starter and the games built on it. Ships TypeScript source for Vite.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"./grid/*": "./src/grid/*.ts"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@crowdedkingdoms/crowdyjs": ">=18.0.4-dev.0 <19 || >=18.1.0-dev.0 <19 || >=18.2.0-dev.0 <19",
|
|
48
|
+
"@crowdedkingdoms/crowdyjs": ">=18.0.4-dev.0 <19 || >=18.1.0-dev.0 <19 || >=18.2.0-dev.0 <19 || >=18.3.0-dev.0 <19 || >=18.4.0-dev.0 <19",
|
|
49
49
|
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1 || >=0.4.2-dev.0 <1 || >=0.4.3-dev.0 <1 || >=0.4.4-dev.0 <1 || >=0.4.5-dev.0 <1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
package/src/engine/Input.test.ts
CHANGED
|
@@ -86,6 +86,32 @@ describe('Input', () => {
|
|
|
86
86
|
expect(input.pointer.locked).toBe(false);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
it('aims from cursor movement on the canvas when movementX is stuck at 0', () => {
|
|
90
|
+
const { input, canvas } = attach();
|
|
91
|
+
canvas.className = 'scene-canvas';
|
|
92
|
+
window.dispatchEvent(
|
|
93
|
+
new PointerEvent('pointermove', {
|
|
94
|
+
clientX: 10,
|
|
95
|
+
clientY: 10,
|
|
96
|
+
movementX: 0,
|
|
97
|
+
movementY: 0,
|
|
98
|
+
bubbles: true,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
Object.defineProperty(canvas, 'closest', { value: () => canvas });
|
|
102
|
+
canvas.dispatchEvent(
|
|
103
|
+
new PointerEvent('pointermove', {
|
|
104
|
+
clientX: 30,
|
|
105
|
+
clientY: 14,
|
|
106
|
+
movementX: 0,
|
|
107
|
+
movementY: 0,
|
|
108
|
+
bubbles: true,
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
expect(input.isLooking()).toBe(true);
|
|
112
|
+
expect(input.takePointerDelta()).toEqual({ dx: 20, dy: 4 });
|
|
113
|
+
});
|
|
114
|
+
|
|
89
115
|
it('looks while RMB is held and cancels on exitLook', () => {
|
|
90
116
|
const { input } = attach();
|
|
91
117
|
window.dispatchEvent(new PointerEvent('pointerdown', { button: 2, buttons: 2 }));
|
package/src/engine/Input.ts
CHANGED
|
@@ -60,6 +60,10 @@ export class Input {
|
|
|
60
60
|
private wheelY = 0;
|
|
61
61
|
/** Escape cancels RMB look until the button is released. */
|
|
62
62
|
private lookCancelled = false;
|
|
63
|
+
/** Pointer is over the holodeck canvas, so mouse movement aims. */
|
|
64
|
+
private overScene = false;
|
|
65
|
+
/** A client position has been seen, so the next move can be a delta. */
|
|
66
|
+
private hasClient = false;
|
|
63
67
|
|
|
64
68
|
attach(target: HTMLElement): void {
|
|
65
69
|
if (this.detach) return;
|
|
@@ -81,8 +85,23 @@ export class Input {
|
|
|
81
85
|
const onBlur = () => this.keys.clear();
|
|
82
86
|
const onPointerMove = (event: PointerEvent) => {
|
|
83
87
|
if (this.suppressed) return;
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
const overScene =
|
|
89
|
+
event.target instanceof Element && Boolean(event.target.closest('.scene-canvas'));
|
|
90
|
+
this.overScene = overScene;
|
|
91
|
+
let dx = event.movementX;
|
|
92
|
+
let dy = event.movementY;
|
|
93
|
+
// The IDE browser reports movementX/Y as 0 unless the pointer is locked.
|
|
94
|
+
// Fall back to the cursor position so dragging on the canvas still aims.
|
|
95
|
+
if (!this.pointerState.locked && dx === 0 && dy === 0 && this.hasClient) {
|
|
96
|
+
dx = event.clientX - this.pointerState.x;
|
|
97
|
+
dy = event.clientY - this.pointerState.y;
|
|
98
|
+
}
|
|
99
|
+
this.hasClient = true;
|
|
100
|
+
const aiming = overScene || this.pointerState.locked || (this.pointerState.buttons & 2) !== 0;
|
|
101
|
+
if (aiming) {
|
|
102
|
+
this.pointerState.dx += dx;
|
|
103
|
+
this.pointerState.dy += dy;
|
|
104
|
+
}
|
|
86
105
|
this.pointerState.x = event.clientX;
|
|
87
106
|
this.pointerState.y = event.clientY;
|
|
88
107
|
this.pointerState.buttons = event.buttons;
|
|
@@ -198,6 +217,7 @@ export class Input {
|
|
|
198
217
|
isLooking(): boolean {
|
|
199
218
|
if (this.suppressed) return false;
|
|
200
219
|
if (this.pointerState.locked) return true;
|
|
220
|
+
if (this.overScene) return true;
|
|
201
221
|
if (this.lookCancelled) return false;
|
|
202
222
|
return (this.pointerState.buttons & 2) !== 0;
|
|
203
223
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* 34 tint u8 (avatar colour index)
|
|
18
18
|
* 35 reserved u8
|
|
19
19
|
* 36-59 name 24 bytes UTF-8, NUL padded
|
|
20
|
-
* 60-63
|
|
20
|
+
* 60-63 roll f32
|
|
21
21
|
*/
|
|
22
22
|
import {
|
|
23
23
|
bytes,
|
|
@@ -40,6 +40,8 @@ export interface Pose {
|
|
|
40
40
|
z: number;
|
|
41
41
|
yaw: number;
|
|
42
42
|
pitch: number;
|
|
43
|
+
/** Bank. Zero for the ground walker. A chunk mod in flight writes it. */
|
|
44
|
+
roll?: number;
|
|
43
45
|
vx: number;
|
|
44
46
|
vy: number;
|
|
45
47
|
vz: number;
|
|
@@ -55,6 +57,7 @@ export const NEUTRAL_POSE: Pose = {
|
|
|
55
57
|
z: 0,
|
|
56
58
|
yaw: 0,
|
|
57
59
|
pitch: 0,
|
|
60
|
+
roll: 0,
|
|
58
61
|
vx: 0,
|
|
59
62
|
vy: 0,
|
|
60
63
|
vz: 0,
|
|
@@ -78,7 +81,7 @@ const wire = structCodec({
|
|
|
78
81
|
tint: u8(),
|
|
79
82
|
pad0: reserved(1),
|
|
80
83
|
name: bytes(NAME_BYTES),
|
|
81
|
-
|
|
84
|
+
roll: f32(),
|
|
82
85
|
});
|
|
83
86
|
|
|
84
87
|
const encoder = new TextEncoder();
|
|
@@ -112,6 +115,7 @@ export const poseCodec: StateCodec<Pose> = {
|
|
|
112
115
|
z: pose.z,
|
|
113
116
|
yaw: pose.yaw,
|
|
114
117
|
pitch: pose.pitch,
|
|
118
|
+
roll: pose.roll ?? 0,
|
|
115
119
|
vx: pose.vx,
|
|
116
120
|
vy: pose.vy,
|
|
117
121
|
vz: pose.vz,
|
|
@@ -128,6 +132,7 @@ export const poseCodec: StateCodec<Pose> = {
|
|
|
128
132
|
z: raw.z,
|
|
129
133
|
yaw: raw.yaw,
|
|
130
134
|
pitch: raw.pitch,
|
|
135
|
+
roll: raw.roll,
|
|
131
136
|
vx: raw.vx,
|
|
132
137
|
vy: raw.vy,
|
|
133
138
|
vz: raw.vz,
|
|
@@ -105,6 +105,23 @@ describe('GridService on the world hub registry', () => {
|
|
|
105
105
|
expect(await new GridService(visitor.net).lookup({ x: 9, y: 0, z: 9 })).toBeNull();
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
it("asks a player's session for the admin-only grid read once, then stops", async () => {
|
|
109
|
+
const owner = network('42');
|
|
110
|
+
const grids = new GridService(owner.net);
|
|
111
|
+
await grids.claimHere(AT, 'neo');
|
|
112
|
+
const probe = (
|
|
113
|
+
owner.net as unknown as {
|
|
114
|
+
game: { gameApps: { nearbyPermissions: ReturnType<typeof vi.fn> } };
|
|
115
|
+
}
|
|
116
|
+
).game.gameApps.nearbyPermissions;
|
|
117
|
+
const before = probe.mock.calls.length;
|
|
118
|
+
await grids.lookup(AT);
|
|
119
|
+
await grids.lookup({ x: 9, y: 0, z: 9 });
|
|
120
|
+
await grids.lookup(AT);
|
|
121
|
+
expect(probe.mock.calls.length - before).toBeLessThanOrEqual(1);
|
|
122
|
+
expect((await grids.lookup(AT))?.owned).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
108
125
|
it('caches a chunk briefly and releases through the platform, then the registry', async () => {
|
|
109
126
|
const { net, hub, claims, marketplace } = network();
|
|
110
127
|
const grids = new GridService(net);
|
|
@@ -75,6 +75,7 @@ export class GridService {
|
|
|
75
75
|
private registryInFlight = new Map<string, Promise<ClaimRecord | null>>();
|
|
76
76
|
/** Grids this session already tried to put in the registry from `lookup`. */
|
|
77
77
|
private backfilled = new Set<string>();
|
|
78
|
+
private adminReadRefused = false;
|
|
78
79
|
|
|
79
80
|
constructor(private readonly network: NetworkManager) {
|
|
80
81
|
this.loadOwned();
|
|
@@ -122,7 +123,7 @@ export class GridService {
|
|
|
122
123
|
|
|
123
124
|
/** The grid at `chunk` as this player may know it, or null for open world. */
|
|
124
125
|
async lookup(chunk: ChunkCoord): Promise<GridSnapshot | null> {
|
|
125
|
-
const mine = this.ownedAt(chunk);
|
|
126
|
+
const mine = this.ownedAt(chunk) ?? (await this.discoverOwned(chunk));
|
|
126
127
|
if (mine) {
|
|
127
128
|
const refreshed = await this.refreshOwnedKeys(chunk, mine);
|
|
128
129
|
// A grid claimed before the registry existed (or from a browser whose registry write
|
|
@@ -176,6 +177,63 @@ export class GridService {
|
|
|
176
177
|
|
|
177
178
|
// ---------------------------------------------------------------------------
|
|
178
179
|
|
|
180
|
+
/**
|
|
181
|
+
* A fresh browser (another device, another origin) has no remembered claim.
|
|
182
|
+
* App admins can read the grid under their feet, including a claim wider
|
|
183
|
+
* than the single chunk stored in the Claim registry.
|
|
184
|
+
*/
|
|
185
|
+
private async discoverOwned(chunk: ChunkCoord): Promise<OwnedRecord | null> {
|
|
186
|
+
const userId = this.network.user?.userId;
|
|
187
|
+
if (!userId || this.adminReadRefused) return null;
|
|
188
|
+
try {
|
|
189
|
+
const rows = (await this.network.game.gameApps.nearbyPermissions({
|
|
190
|
+
appId: this.requireAppId(),
|
|
191
|
+
userId,
|
|
192
|
+
lowChunk: chunkInput(chunk),
|
|
193
|
+
highChunk: chunkInput(chunk),
|
|
194
|
+
})) as Array<{
|
|
195
|
+
gridId?: unknown;
|
|
196
|
+
permissionKeys?: unknown;
|
|
197
|
+
lowChunk?: unknown;
|
|
198
|
+
highChunk?: unknown;
|
|
199
|
+
}>;
|
|
200
|
+
let best: OwnedRecord | null = null;
|
|
201
|
+
let bestVolume = 0n;
|
|
202
|
+
for (const row of rows) {
|
|
203
|
+
const gridId = String(row.gridId ?? '');
|
|
204
|
+
const bounds = gridBoundsFrom(row.lowChunk, row.highChunk);
|
|
205
|
+
if (!gridId || !bounds || !gridBoundsContain(bounds, chunk)) continue;
|
|
206
|
+
const keys = row.permissionKeys;
|
|
207
|
+
const effectiveKeys = studioPermissions(Array.isArray(keys) ? keys : null).effectiveKeys;
|
|
208
|
+
if (effectiveKeys.length === 0) continue;
|
|
209
|
+
const volume = boundsVolume(bounds);
|
|
210
|
+
if (best && volume >= bestVolume) continue;
|
|
211
|
+
best = { gridId, bounds, effectiveKeys };
|
|
212
|
+
bestVolume = volume;
|
|
213
|
+
}
|
|
214
|
+
if (!best) return null;
|
|
215
|
+
this.owned.set(chunkKey(chunk), best);
|
|
216
|
+
this.saveOwned();
|
|
217
|
+
return best;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
this.noteAdminReadRefusal(error);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* `nearbyGridPermissions` is app-admin only, so every ordinary player is refused it.
|
|
226
|
+
* After the first refusal this session asks no more, rather than logging a refusal on
|
|
227
|
+
* every lookup (OI-2026-10-07-008); an admin is never refused and keeps asking.
|
|
228
|
+
*/
|
|
229
|
+
private noteAdminReadRefusal(error: unknown): void {
|
|
230
|
+
const e = error as { code?: unknown; message?: unknown } | null;
|
|
231
|
+
const message = typeof e?.message === 'string' ? e.message : '';
|
|
232
|
+
if (e?.code === 'FORBIDDEN' || /manage_apps|Missing app permission/i.test(message)) {
|
|
233
|
+
this.adminReadRefused = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
179
237
|
private ownedAt(chunk: ChunkCoord): OwnedRecord | null {
|
|
180
238
|
const direct = this.owned.get(chunkKey(chunk));
|
|
181
239
|
if (direct) return direct;
|
|
@@ -190,7 +248,7 @@ export class GridService {
|
|
|
190
248
|
record: OwnedRecord,
|
|
191
249
|
): Promise<PlayerCodePermissionKey[] | null> {
|
|
192
250
|
const userId = this.network.user?.userId;
|
|
193
|
-
if (!userId) return null;
|
|
251
|
+
if (!userId || this.adminReadRefused) return null;
|
|
194
252
|
try {
|
|
195
253
|
const rows = (await this.network.game.gameApps.nearbyPermissions({
|
|
196
254
|
appId: this.requireAppId(),
|
|
@@ -200,13 +258,30 @@ export class GridService {
|
|
|
200
258
|
})) as Array<{ gridId?: unknown; permissionKeys?: unknown }>;
|
|
201
259
|
const row = rows.find((r) => String(r.gridId) === record.gridId);
|
|
202
260
|
if (!row || !Array.isArray(row.permissionKeys)) return null;
|
|
261
|
+
const bounds = gridBoundsFrom(
|
|
262
|
+
(row as { lowChunk?: unknown }).lowChunk,
|
|
263
|
+
(row as { highChunk?: unknown }).highChunk,
|
|
264
|
+
);
|
|
265
|
+
if (
|
|
266
|
+
bounds &&
|
|
267
|
+
(bounds.low.x !== record.bounds.low.x ||
|
|
268
|
+
bounds.low.y !== record.bounds.low.y ||
|
|
269
|
+
bounds.low.z !== record.bounds.low.z ||
|
|
270
|
+
bounds.high.x !== record.bounds.high.x ||
|
|
271
|
+
bounds.high.y !== record.bounds.high.y ||
|
|
272
|
+
bounds.high.z !== record.bounds.high.z)
|
|
273
|
+
) {
|
|
274
|
+
record.bounds = bounds;
|
|
275
|
+
this.saveOwned();
|
|
276
|
+
}
|
|
203
277
|
const keys = studioPermissions(row.permissionKeys).effectiveKeys;
|
|
204
278
|
if (keys.join() !== record.effectiveKeys.join()) {
|
|
205
279
|
record.effectiveKeys = keys;
|
|
206
280
|
this.saveOwned();
|
|
207
281
|
}
|
|
208
282
|
return keys;
|
|
209
|
-
} catch {
|
|
283
|
+
} catch (error) {
|
|
284
|
+
this.noteAdminReadRefusal(error);
|
|
210
285
|
return null; // not an admin: the claim-time keys stand
|
|
211
286
|
}
|
|
212
287
|
}
|
|
@@ -293,3 +368,12 @@ export class GridService {
|
|
|
293
368
|
writeScoped(OWNED_KEY, JSON.stringify(Object.fromEntries(this.owned)));
|
|
294
369
|
}
|
|
295
370
|
}
|
|
371
|
+
|
|
372
|
+
function boundsVolume(bounds: GridBounds): bigint {
|
|
373
|
+
const span = (low: string, high: string) => BigInt(high) - BigInt(low) + 1n;
|
|
374
|
+
return (
|
|
375
|
+
span(bounds.low.x, bounds.high.x) *
|
|
376
|
+
span(bounds.low.y, bounds.high.y) *
|
|
377
|
+
span(bounds.low.z, bounds.high.z)
|
|
378
|
+
);
|
|
379
|
+
}
|