@crowdedkingdoms/construct 0.3.7 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdedkingdoms/construct",
3
- "version": "0.3.7",
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": {
@@ -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 }));
@@ -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
- this.pointerState.dx += event.movementX;
85
- this.pointerState.dy += event.movementY;
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
  }
@@ -26,6 +26,7 @@ describe('poseCodec', () => {
26
26
  z: -7.75,
27
27
  yaw: 1.5,
28
28
  pitch: -0.125,
29
+ roll: 0.5,
29
30
  vx: 3,
30
31
  vy: 0,
31
32
  vz: -1.5,
@@ -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 reserved
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
- pad1: reserved(4),
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
+ }