@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 +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/envScope.test.ts +23 -2
- package/src/platform/envScope.ts +23 -0
- package/src/platform/network/NetworkManager.ts +18 -10
- 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 # X.Y.Z
|
|
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.
|
|
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": {
|
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
|
}
|
|
@@ -5,8 +5,16 @@ vi.mock('@crowdedkingdoms/crowdyjs', () => ({
|
|
|
5
5
|
CROWDY_DEFAULT_TIER: 'test',
|
|
6
6
|
}));
|
|
7
7
|
|
|
8
|
-
const {
|
|
9
|
-
|
|
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');
|
package/src/platform/envScope.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
-
/**
|
|
65
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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);
|
|
@@ -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
|
+
}
|