@crowdedkingdoms/construct 0.3.7 → 0.3.9-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 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 # X.Y.Z on latest
33
+ npm install --save-exact @crowdedkingdoms/construct@dev # an X.Y.Z-dev.N build
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.7",
3
+ "version": "0.3.9-dev.1",
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
  }
@@ -5,8 +5,16 @@ vi.mock('@crowdedkingdoms/crowdyjs', () => ({
5
5
  CROWDY_DEFAULT_TIER: 'test',
6
6
  }));
7
7
 
8
- const { ENV_HANDLE, envHandleFor, envScopedKey, ensureEnvScope, readScoped, writeScoped } =
9
- await import('./envScope');
8
+ const {
9
+ ENV_HANDLE,
10
+ appTokenStorageKey,
11
+ envHandleFor,
12
+ envScopedKey,
13
+ ensureEnvScope,
14
+ perGameBase,
15
+ readScoped,
16
+ writeScoped,
17
+ } = await import('./envScope');
10
18
 
11
19
  describe('envScope', () => {
12
20
  beforeEach(() => localStorage.clear());
@@ -23,6 +31,19 @@ describe('envScope', () => {
23
31
  expect(envScopedKey('x', 'other')).toBe('x:other');
24
32
  });
25
33
 
34
+ it('keys a saved app token by game path and API host', () => {
35
+ expect(appTokenStorageKey('/matchstick/')).toBe('crowdyjs:app:/matchstick/:ck.test.example');
36
+ expect(appTokenStorageKey('/blocks-with-friends/', 'ck.dev.example')).toBe(
37
+ 'crowdyjs:app:/blocks-with-friends/:ck.dev.example',
38
+ );
39
+ expect(appTokenStorageKey('/matchstick/', 'ck.dev.example')).not.toBe(
40
+ appTokenStorageKey('/other-game/', 'ck.dev.example'),
41
+ );
42
+ expect(perGameBase('construct:app-route', '/matchstick/')).toBe(
43
+ 'construct:app-route:/matchstick/',
44
+ );
45
+ });
46
+
26
47
  it('drops per-environment values when the environment changes', () => {
27
48
  localStorage.setItem('construct:env-handle', 'ck.old.example');
28
49
  localStorage.setItem('construct:app-id:ck.old.example', '111');
@@ -30,6 +30,29 @@ export function envScopedKey(base: string, handle: string = ENV_HANDLE): string
30
30
  return `${base}:${handle}`;
31
31
  }
32
32
 
33
+ /**
34
+ * `<base>:<game-path>`, for `readScoped` / `writeScoped`, which append the host.
35
+ *
36
+ * The host alone is one key for every Construct game on a games host. Blocks
37
+ * with Friends keys its app token by `import.meta.env.BASE_URL` so two games
38
+ * do not share a session. Pass that base here.
39
+ */
40
+ export function perGameBase(base: string, gamePath: string): string {
41
+ return `${base}:${gamePath}`;
42
+ }
43
+
44
+ /**
45
+ * localStorage key for the saved app token.
46
+ *
47
+ * Shape is `BrowserLocalStorageTokenStore.appKey(gamePath)` plus the API-host
48
+ * handle: `crowdyjs:app:/matchstick/:ck.dev.crowdedkingdoms.com`. The path
49
+ * keeps a second Construct game from restoring Matchstick's token. The handle
50
+ * keeps a localhost build pointed at another tier from restoring the first.
51
+ */
52
+ export function appTokenStorageKey(gamePath: string, handle: string = ENV_HANDLE): string {
53
+ return envScopedKey(`crowdyjs:app:${gamePath}`, handle);
54
+ }
55
+
33
56
  /** Keys that describe the previous environment and must not survive a switch. */
34
57
  const SCOPED_LOCAL_PREFIXES = ['construct:app-id', 'construct:last-program'];
35
58
 
@@ -40,7 +40,13 @@ import {
40
40
  APP_TOKEN_REFRESH_RETRY_MS,
41
41
  AUTHORIZE_URL,
42
42
  } from '../config';
43
- import { envScopedKey, readScoped, writeScoped } from '../envScope';
43
+ import {
44
+ appTokenStorageKey,
45
+ envScopedKey,
46
+ perGameBase,
47
+ readScoped,
48
+ writeScoped,
49
+ } from '../envScope';
44
50
  import { WorldHub } from '../exec/worldHub';
45
51
  import { Emitter } from '../util/Emitter';
46
52
 
@@ -58,11 +64,13 @@ export interface AppRoute {
58
64
  expiresAt: string;
59
65
  }
60
66
 
61
- /** Where the entered app's route is remembered across a reload (env-scoped). */
67
+ /** Where the entered app's route is remembered across a reload (per game path, per API host). */
62
68
  export const APP_ROUTE_STORAGE_KEY = 'construct:app-route';
63
69
 
64
- /** The token store key for the app token (env-scoped). One app at a time. */
65
- const APP_TOKEN_STORE_KEY = 'construct:app-token';
70
+ /** Route key for this game's Vite base, before the API-host suffix `readScoped` adds. */
71
+ function savedRouteBase(): string {
72
+ return perGameBase(APP_ROUTE_STORAGE_KEY, import.meta.env.BASE_URL);
73
+ }
66
74
 
67
75
  export interface BootstrapInfo {
68
76
  minimumClientVersion?: string;
@@ -125,7 +133,7 @@ export class NetworkManager {
125
133
  this.platform = createCrowdyClient({
126
134
  httpUrl: API_HTTP_URL,
127
135
  wsUrl: API_WS_URL,
128
- tokenStore: new BrowserLocalStorageTokenStore(envScopedKey(APP_TOKEN_STORE_KEY)),
136
+ tokenStore: new BrowserLocalStorageTokenStore(appTokenStorageKey(import.meta.env.BASE_URL)),
129
137
  });
130
138
  }
131
139
 
@@ -183,7 +191,7 @@ export class NetworkManager {
183
191
  const minted = await this.platform.portal.handleSignInCallback();
184
192
  if (!minted) return null;
185
193
  const route = this.routeFrom(minted, minted.appId);
186
- writeScoped(APP_ROUTE_STORAGE_KEY, JSON.stringify(route));
194
+ writeScoped(savedRouteBase(), JSON.stringify(route));
187
195
  await this.hydrateUser();
188
196
  this.log(`Hosted sign-in complete for app ${route.appId}`);
189
197
  return route;
@@ -200,7 +208,7 @@ export class NetworkManager {
200
208
  async restore(): Promise<AppRoute | null> {
201
209
  await this.platform.session.restore();
202
210
  if (!this.platform.session.getToken()) return null;
203
- const raw = readScoped(APP_ROUTE_STORAGE_KEY);
211
+ const raw = readScoped(savedRouteBase());
204
212
  let route: AppRoute | null;
205
213
  try {
206
214
  route = raw ? (JSON.parse(raw) as AppRoute) : null;
@@ -231,7 +239,7 @@ export class NetworkManager {
231
239
 
232
240
  private forgetCredentials(): void {
233
241
  this.platform.session.setToken(null);
234
- writeScoped(APP_ROUTE_STORAGE_KEY, null);
242
+ writeScoped(savedRouteBase(), null);
235
243
  }
236
244
 
237
245
  private async hydrateUser(): Promise<SessionUser | null> {
@@ -346,7 +354,7 @@ export class NetworkManager {
346
354
  }
347
355
  this.gameClient.setToken(token);
348
356
  this.route = route;
349
- writeScoped(APP_ROUTE_STORAGE_KEY, JSON.stringify(route));
357
+ writeScoped(savedRouteBase(), JSON.stringify(route));
350
358
  this.refreshFailures = 0;
351
359
  this.scheduleRefresh(APP_TOKEN_REFRESH_MS);
352
360
  this.log(`Entered app ${route.appId} via ${httpUrl}`);
@@ -417,7 +425,7 @@ export class NetworkManager {
417
425
  const refreshed = await this.gameClient.refreshGameplayToken();
418
426
  this.platform.session.setToken(refreshed.token);
419
427
  this.route = { ...this.route, expiresAt: refreshed.expiresAt };
420
- writeScoped(APP_ROUTE_STORAGE_KEY, JSON.stringify(this.route));
428
+ writeScoped(savedRouteBase(), JSON.stringify(this.route));
421
429
  this.refreshFailures = 0;
422
430
  this.log('App token rotated');
423
431
  this.scheduleRefresh(APP_TOKEN_REFRESH_MS);
@@ -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
+ }