@crowdedkingdoms/construct 0.3.2-dev.1 → 0.3.4-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/package.json +3 -3
- package/src/index.ts +10 -0
- package/src/platform/GameSession.ts +17 -0
- package/src/platform/realtime/writeBackGuard.test.ts +145 -0
- package/src/platform/realtime/writeBackGuard.ts +101 -0
- package/src/platform/studio/StudioService.ts +57 -1
- package/src/platform/studio/clientHalves.test.ts +42 -0
- package/src/platform/studio/clientHalves.ts +40 -1
- package/src/platform/studio/clientModHost.test.ts +48 -0
- package/src/platform/studio/clientModHost.ts +33 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/construct",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4-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,8 +45,8 @@
|
|
|
45
45
|
"./grid/*": "./src/grid/*.ts"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@crowdedkingdoms/crowdyjs": ">=18.0.
|
|
49
|
-
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1 || >=0.4.2-dev.0 <1"
|
|
48
|
+
"@crowdedkingdoms/crowdyjs": ">=18.0.4-dev.0 <19",
|
|
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"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
52
|
"@crowdedkingdoms/crowdy-dsh": {
|
package/src/index.ts
CHANGED
|
@@ -92,4 +92,14 @@ export {
|
|
|
92
92
|
type GridClientHalvesOptions,
|
|
93
93
|
} from './platform/studio/clientHalves';
|
|
94
94
|
export { gridServerCalls, type GridServerCallsOptions } from './platform/studio/gridServerCalls';
|
|
95
|
+
export {
|
|
96
|
+
EXHAUSTED_MESSAGE,
|
|
97
|
+
REFUSED_MESSAGE,
|
|
98
|
+
describeWriteBackFailure,
|
|
99
|
+
guardWriteBacks,
|
|
100
|
+
reloadChunk,
|
|
101
|
+
type GuardedChunkStore,
|
|
102
|
+
type WriteBackGuardOptions,
|
|
103
|
+
type WriteBackTone,
|
|
104
|
+
} from './platform/realtime/writeBackGuard';
|
|
95
105
|
export { GridProgramRunner, type GridProgramRunnerOptions } from './grid/GridProgramRunner';
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type RemotePlayer,
|
|
19
19
|
type SaveState,
|
|
20
20
|
} from './realtime/WorldStores';
|
|
21
|
+
import { guardWriteBacks, type WriteBackTone } from './realtime/writeBackGuard';
|
|
21
22
|
import { WebcamService } from './media/WebcamService';
|
|
22
23
|
import { VoiceService } from './media/VoiceService';
|
|
23
24
|
import { ModelService } from './model/ModelService';
|
|
@@ -125,6 +126,22 @@ export class GameSession {
|
|
|
125
126
|
return this.joinedFlag;
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Undo each chunk write-back the server refuses (or never accepts) and tell
|
|
131
|
+
* the player through `notify`. @returns off.
|
|
132
|
+
*/
|
|
133
|
+
guardWriteBacks(notify: (text: string, tone: WriteBackTone) => void): () => void {
|
|
134
|
+
return guardWriteBacks(this.world.chunks, {
|
|
135
|
+
onServer: async (coord) =>
|
|
136
|
+
(await this.network.game.chunks.get({
|
|
137
|
+
appId: this.appId,
|
|
138
|
+
coordinates: chunkInput(coord),
|
|
139
|
+
})) != null,
|
|
140
|
+
notify,
|
|
141
|
+
log: (message) => this.network.log(message),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
128
145
|
/** The World Stores session for this app (lazily created). */
|
|
129
146
|
get world(): ConstructWorldSession {
|
|
130
147
|
return worldSession();
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ChunkStore } from '@crowdedkingdoms/crowdyjs/stores';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
EXHAUSTED_MESSAGE,
|
|
6
|
+
REFUSED_MESSAGE,
|
|
7
|
+
describeWriteBackFailure,
|
|
8
|
+
guardWriteBacks,
|
|
9
|
+
} from './writeBackGuard';
|
|
10
|
+
|
|
11
|
+
const COORD = { x: 2, y: 0, z: 3 };
|
|
12
|
+
|
|
13
|
+
function base64(bytes: Uint8Array): string {
|
|
14
|
+
return Buffer.from(bytes).toString('base64');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function forbidden(): Error {
|
|
18
|
+
return Object.assign(new Error('You cannot edit this chunk'), {
|
|
19
|
+
extensions: { code: 'FORBIDDEN' },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The real SDK store over a fake client: `serverVoxels` is the server's copy (null: never stored). */
|
|
24
|
+
function storeWith(update: () => Promise<unknown>, serverVoxels: Uint8Array | null) {
|
|
25
|
+
const client = {
|
|
26
|
+
chunks: {
|
|
27
|
+
update: vi.fn(update),
|
|
28
|
+
get: vi.fn(async () =>
|
|
29
|
+
serverVoxels ? { voxels: base64(serverVoxels), voxelStates: [] } : null,
|
|
30
|
+
),
|
|
31
|
+
byDistance: vi.fn(async () => ({ chunks: [] })),
|
|
32
|
+
},
|
|
33
|
+
udp: { sendVoxelUpdate: vi.fn(async () => true) },
|
|
34
|
+
};
|
|
35
|
+
const ctx = {
|
|
36
|
+
appId: '1',
|
|
37
|
+
client,
|
|
38
|
+
ticker: { every: () => () => undefined },
|
|
39
|
+
on: () => () => undefined,
|
|
40
|
+
trackSend: () => undefined,
|
|
41
|
+
setSendTracker: () => undefined,
|
|
42
|
+
onDispose: () => undefined,
|
|
43
|
+
};
|
|
44
|
+
const store = new ChunkStore(ctx as never, { writeBackIntervalMs: false });
|
|
45
|
+
return { store, client };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function paint(store: ChunkStore, voxelType: number): Promise<void> {
|
|
49
|
+
await store.setVoxel({ chunk: COORD, x: 1, y: 0, z: 1, voxelType, state: 'AA==' });
|
|
50
|
+
store.markDirty(COORD);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('guardWriteBacks', () => {
|
|
54
|
+
it('undoes a refused paint on a chunk the server never stored, once, and says so', async () => {
|
|
55
|
+
const { store, client } = storeWith(() => Promise.reject(forbidden()), null);
|
|
56
|
+
store.seed(COORD, new Uint8Array(4096), { writeBack: false });
|
|
57
|
+
const notify = vi.fn();
|
|
58
|
+
const off = guardWriteBacks(store, {
|
|
59
|
+
onServer: async () => (await client.chunks.get()) != null,
|
|
60
|
+
notify,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await paint(store, 5);
|
|
64
|
+
expect(store.voxelTypeAt(COORD, 1, 0, 1)).toBe(5);
|
|
65
|
+
|
|
66
|
+
const dropped = await store.flush();
|
|
67
|
+
expect(dropped).toHaveLength(1);
|
|
68
|
+
expect(dropped[0]?.reason).toBe('refused');
|
|
69
|
+
// Sent once, not retried.
|
|
70
|
+
expect(client.chunks.update).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(notify).toHaveBeenCalledWith(REFUSED_MESSAGE, 'warn');
|
|
72
|
+
|
|
73
|
+
await vi.waitFor(() => expect(store.voxelTypeAt(COORD, 1, 0, 1)).toBe(0));
|
|
74
|
+
expect(store.voxelStateAt(COORD, 1, 0, 1)).toBeUndefined();
|
|
75
|
+
expect(store.get(COORD)?.dirty).toBe(false);
|
|
76
|
+
expect(store.pendingWriteBacks).toBe(0);
|
|
77
|
+
off();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("puts a refused chunk back to the server's copy", async () => {
|
|
81
|
+
const server = new Uint8Array(4096);
|
|
82
|
+
server[1 + 256] = 3;
|
|
83
|
+
const { store, client } = storeWith(() => Promise.reject(forbidden()), server);
|
|
84
|
+
store.seed(COORD, server.slice(), { writeBack: false });
|
|
85
|
+
guardWriteBacks(store, {
|
|
86
|
+
onServer: async () => (await client.chunks.get()) != null,
|
|
87
|
+
notify: () => undefined,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await paint(store, 7);
|
|
91
|
+
await store.flush();
|
|
92
|
+
|
|
93
|
+
await vi.waitFor(() => expect(store.voxelTypeAt(COORD, 1, 0, 1)).toBe(3));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('says it once for a stroke that drops several chunks', async () => {
|
|
97
|
+
const { store } = storeWith(() => Promise.reject(forbidden()), null);
|
|
98
|
+
let now = 1_000;
|
|
99
|
+
const notify = vi.fn();
|
|
100
|
+
guardWriteBacks(store, { onServer: async () => false, notify, now: () => now });
|
|
101
|
+
|
|
102
|
+
for (const x of [0, 1, 2]) {
|
|
103
|
+
const coord = { x, y: 0, z: 0 };
|
|
104
|
+
store.seed(coord, new Uint8Array(4096), { writeBack: false });
|
|
105
|
+
store.markDirty(coord);
|
|
106
|
+
}
|
|
107
|
+
await store.flush();
|
|
108
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
109
|
+
|
|
110
|
+
now += 5_000;
|
|
111
|
+
store.markDirty({ x: 0, y: 0, z: 0 });
|
|
112
|
+
await store.flush();
|
|
113
|
+
expect(notify).toHaveBeenCalledTimes(2);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('logs the refusal and survives a failed reload', async () => {
|
|
117
|
+
const { store } = storeWith(() => Promise.reject(forbidden()), null);
|
|
118
|
+
store.seed(COORD, new Uint8Array(4096), { writeBack: false });
|
|
119
|
+
const log = vi.fn();
|
|
120
|
+
guardWriteBacks(store, {
|
|
121
|
+
onServer: () => Promise.reject(new Error('offline')),
|
|
122
|
+
notify: () => undefined,
|
|
123
|
+
log,
|
|
124
|
+
});
|
|
125
|
+
await paint(store, 5);
|
|
126
|
+
await store.flush();
|
|
127
|
+
await vi.waitFor(() => expect(log).toHaveBeenCalledWith('chunk 2,0,3 reload failed: offline'));
|
|
128
|
+
expect(log).toHaveBeenCalledWith(
|
|
129
|
+
'chunk 2,0,3 write-back refused after 1 attempt(s): You cannot edit this chunk',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('describeWriteBackFailure', () => {
|
|
135
|
+
it('tells a refusal from a server that did not answer', () => {
|
|
136
|
+
expect(describeWriteBackFailure({ reason: 'refused' })).toEqual({
|
|
137
|
+
text: REFUSED_MESSAGE,
|
|
138
|
+
tone: 'warn',
|
|
139
|
+
});
|
|
140
|
+
expect(describeWriteBackFailure({ reason: 'exhausted' })).toEqual({
|
|
141
|
+
text: EXHAUSTED_MESSAGE,
|
|
142
|
+
tone: 'error',
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the game does when a chunk write-back is dropped.
|
|
3
|
+
*
|
|
4
|
+
* `ChunkStore` applies a voxel edit locally at once and saves the chunk later
|
|
5
|
+
* (`markDirty` -> `chunks.update`). The server refuses that save for a player
|
|
6
|
+
* without edit permission on the chunk (another player's claim, a safe zone, a
|
|
7
|
+
* closed wilderness); the store sends a refusal once, retries anything else a
|
|
8
|
+
* few times, then drops it and reports it through `onWriteBackFailed`. It does
|
|
9
|
+
* not undo the local edit, so without this the player keeps seeing blocks
|
|
10
|
+
* nobody else has and that are gone on the next load.
|
|
11
|
+
*
|
|
12
|
+
* The guard puts the chunk back to the server's copy and tells the player.
|
|
13
|
+
* Paint, the holodeck and CLIENT `voxel_set` all write through the one store,
|
|
14
|
+
* and `flush()` reports what it dropped through the same listener, so one guard
|
|
15
|
+
* per world session covers every path.
|
|
16
|
+
*/
|
|
17
|
+
import { CHUNK_VOLUME, type ChunkWriteBackFailure } from '@crowdedkingdoms/crowdyjs/stores';
|
|
18
|
+
|
|
19
|
+
import type { ChunkCoord } from './space';
|
|
20
|
+
|
|
21
|
+
/** One stroke across a refused plot drops several chunks; say it once. */
|
|
22
|
+
const NOTIFY_QUIET_MS = 4_000;
|
|
23
|
+
|
|
24
|
+
export type WriteBackTone = 'info' | 'warn' | 'error';
|
|
25
|
+
|
|
26
|
+
/** The slice of `ChunkStore` the guard uses. */
|
|
27
|
+
export interface GuardedChunkStore {
|
|
28
|
+
onWriteBackFailed(
|
|
29
|
+
listener: (failure: ChunkWriteBackFailure<unknown, unknown>) => void,
|
|
30
|
+
): () => void;
|
|
31
|
+
get(coord: ChunkCoord): { voxelStates: Map<number, unknown> } | undefined;
|
|
32
|
+
hydrate(coord: ChunkCoord): Promise<void>;
|
|
33
|
+
seed(coord: ChunkCoord, voxels: Uint8Array, options?: { writeBack?: boolean }): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WriteBackGuardOptions {
|
|
37
|
+
/** Whether the server stores the chunk (`client.chunks.get` is not null). */
|
|
38
|
+
onServer(coord: ChunkCoord): Promise<boolean>;
|
|
39
|
+
notify(text: string, tone: WriteBackTone): void;
|
|
40
|
+
log?(message: string): void;
|
|
41
|
+
now?(): number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const REFUSED_MESSAGE =
|
|
45
|
+
"You can't build here: this area is someone else's claim or a protected zone. Your change was undone.";
|
|
46
|
+
export const EXHAUSTED_MESSAGE =
|
|
47
|
+
"Your change couldn't be saved (the server didn't answer), so it was undone. Try again in a moment.";
|
|
48
|
+
|
|
49
|
+
/** The player-facing line for a dropped write-back. */
|
|
50
|
+
export function describeWriteBackFailure(failure: Pick<ChunkWriteBackFailure, 'reason'>): {
|
|
51
|
+
text: string;
|
|
52
|
+
tone: WriteBackTone;
|
|
53
|
+
} {
|
|
54
|
+
return failure.reason === 'refused'
|
|
55
|
+
? { text: REFUSED_MESSAGE, tone: 'warn' }
|
|
56
|
+
: { text: EXHAUSTED_MESSAGE, tone: 'error' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Put a chunk back to the server's copy: hydrate it when the server stores it,
|
|
61
|
+
* else back to the empty chunk The Construct seeds for a never-written one.
|
|
62
|
+
*/
|
|
63
|
+
export async function reloadChunk(
|
|
64
|
+
chunks: GuardedChunkStore,
|
|
65
|
+
coord: ChunkCoord,
|
|
66
|
+
onServer: (coord: ChunkCoord) => Promise<boolean>,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const stored = await onServer(coord);
|
|
69
|
+
chunks.get(coord)?.voxelStates.clear();
|
|
70
|
+
if (stored) await chunks.hydrate(coord);
|
|
71
|
+
else chunks.seed(coord, new Uint8Array(CHUNK_VOLUME), { writeBack: false });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Undo and report every write-back the store drops. @returns off. */
|
|
75
|
+
export function guardWriteBacks(
|
|
76
|
+
chunks: GuardedChunkStore,
|
|
77
|
+
options: WriteBackGuardOptions,
|
|
78
|
+
): () => void {
|
|
79
|
+
const now = options.now ?? (() => Date.now());
|
|
80
|
+
let lastText = '';
|
|
81
|
+
let lastAt = -Infinity;
|
|
82
|
+
return chunks.onWriteBackFailed((failure) => {
|
|
83
|
+
const { coord } = failure;
|
|
84
|
+
const where = `${coord.x},${coord.y},${coord.z}`;
|
|
85
|
+
options.log?.(
|
|
86
|
+
`chunk ${where} write-back ${failure.reason} after ${failure.attempts} attempt(s): ${errorText(failure.error)}`,
|
|
87
|
+
);
|
|
88
|
+
const { text, tone } = describeWriteBackFailure(failure);
|
|
89
|
+
const at = now();
|
|
90
|
+
if (text !== lastText || at - lastAt >= NOTIFY_QUIET_MS) options.notify(text, tone);
|
|
91
|
+
lastText = text;
|
|
92
|
+
lastAt = at;
|
|
93
|
+
void reloadChunk(chunks, coord, options.onServer).catch((error) =>
|
|
94
|
+
options.log?.(`chunk ${where} reload failed: ${errorText(error)}`),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function errorText(error: unknown): string {
|
|
100
|
+
return error instanceof Error ? error.message : String(error);
|
|
101
|
+
}
|
|
@@ -68,6 +68,8 @@ export interface StudioState {
|
|
|
68
68
|
clientModsAvailable: boolean;
|
|
69
69
|
/** The grid's CLIENT halves running for this player. */
|
|
70
70
|
clientModsRunning: number;
|
|
71
|
+
/** Those CLIENT halves, for the controls that take them back. */
|
|
72
|
+
clientHalves: readonly RunningClientHalf[];
|
|
71
73
|
/** Why CLIENT mods are unavailable, when they are. */
|
|
72
74
|
clientModsReason: string | null;
|
|
73
75
|
/** Agent dock mounted (policy + permission + host). */
|
|
@@ -76,6 +78,17 @@ export interface StudioState {
|
|
|
76
78
|
agentReason: string | null;
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/** A CLIENT half running for this player, and whose it is. */
|
|
82
|
+
export interface RunningClientHalf {
|
|
83
|
+
modId: string;
|
|
84
|
+
name: string;
|
|
85
|
+
authorId: string;
|
|
86
|
+
/** The grid owner's display name, when the game knows it. */
|
|
87
|
+
authorName: string | null;
|
|
88
|
+
/** Written by this player (their own grid, not installed from a listing). */
|
|
89
|
+
own: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
79
92
|
export interface StudioHooks {
|
|
80
93
|
suppressGameplayInput(): () => void;
|
|
81
94
|
onLayoutChange(rightInsetPx: number): void;
|
|
@@ -179,6 +192,7 @@ export class StudioService {
|
|
|
179
192
|
grid: null,
|
|
180
193
|
clientModsAvailable: CLIENT_MODS_ENABLED && isolated,
|
|
181
194
|
clientModsRunning: 0,
|
|
195
|
+
clientHalves: [],
|
|
182
196
|
clientModsReason: !CLIENT_MODS_ENABLED
|
|
183
197
|
? 'CLIENT mods are disabled in this build (VITE_CONSTRUCT_CLIENT_MODS=0).'
|
|
184
198
|
: isolated
|
|
@@ -326,6 +340,37 @@ export class StudioService {
|
|
|
326
340
|
this.openOn(grid);
|
|
327
341
|
}
|
|
328
342
|
|
|
343
|
+
/** "Stop running this": the player takes back their consent to one CLIENT half here. */
|
|
344
|
+
async stopClientHalf(modId: string): Promise<void> {
|
|
345
|
+
await this.takeBack(`stopped the CLIENT half of mod ${modId}`, (halves) =>
|
|
346
|
+
halves.revoke(modId),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** "Forget this author": the player takes back their trust in an author on this grid. */
|
|
351
|
+
async forgetClientHalfAuthor(authorId: string): Promise<void> {
|
|
352
|
+
await this.takeBack(`forgot author ${authorId} on this grid`, (halves) =>
|
|
353
|
+
halves.forgetAuthor(authorId),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async takeBack(
|
|
358
|
+
done: string,
|
|
359
|
+
work: (halves: GridClientHalves) => Promise<boolean>,
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
const halves = this.halves;
|
|
362
|
+
if (!halves) return;
|
|
363
|
+
try {
|
|
364
|
+
await work(halves);
|
|
365
|
+
this.session.network.log(`CLIENT halves: ${done}`);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
this.hooks?.notify(
|
|
368
|
+
`Stopped here, but the platform did not record it (${messageOf(error)}); it may run on your next visit.`,
|
|
369
|
+
'warn',
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
329
374
|
close(): void {
|
|
330
375
|
this.embed?.close();
|
|
331
376
|
if (this.currentGrid) {
|
|
@@ -461,7 +506,18 @@ export class StudioService {
|
|
|
461
506
|
}),
|
|
462
507
|
filter: (mod) => !this.previewedByStudio(mod),
|
|
463
508
|
onChange: (running) => {
|
|
464
|
-
|
|
509
|
+
const self = this.session.network.user?.userId ?? null;
|
|
510
|
+
const clientHalves = running.map((mod) => ({
|
|
511
|
+
modId: String(mod.modId),
|
|
512
|
+
name: mod.name,
|
|
513
|
+
authorId: String(mod.authorId),
|
|
514
|
+
authorName:
|
|
515
|
+
this.currentGrid?.gridId === String(mod.gridId)
|
|
516
|
+
? (this.currentGrid.ownerName ?? null)
|
|
517
|
+
: null,
|
|
518
|
+
own: self !== null && String(mod.authorId) === String(self) && mod.listingId == null,
|
|
519
|
+
}));
|
|
520
|
+
this.state = { ...this.state, clientModsRunning: running.length, clientHalves };
|
|
465
521
|
this.emit();
|
|
466
522
|
},
|
|
467
523
|
log: (line) => this.session.network.log(line),
|
|
@@ -16,6 +16,7 @@ vi.mock('@crowdedkingdoms/crowdyjs/crowdy-studio', async () => {
|
|
|
16
16
|
const {
|
|
17
17
|
GridClientHalves,
|
|
18
18
|
clientHalfSource,
|
|
19
|
+
describeClientCapabilities,
|
|
19
20
|
describeClientHalfPrompt,
|
|
20
21
|
isOwnClientHalfPrompt,
|
|
21
22
|
studioPreviewModName,
|
|
@@ -73,6 +74,8 @@ function harness(
|
|
|
73
74
|
gridClientMods: vi.fn(async () => listings[Math.min(listed++, listings.length - 1)]!),
|
|
74
75
|
consentClientMod: vi.fn(async () => true),
|
|
75
76
|
trustAuthor: vi.fn(async () => true),
|
|
77
|
+
revokeClientModConsent: vi.fn(async () => true),
|
|
78
|
+
revokeAuthorTrust: vi.fn(async () => true),
|
|
76
79
|
modClientArtifactBytes: vi.fn(async (_appId: string, modId: string) => {
|
|
77
80
|
const mod = listings.flat().find((m) => m.modId === modId)!;
|
|
78
81
|
return {
|
|
@@ -166,6 +169,31 @@ describe('GridClientHalves', () => {
|
|
|
166
169
|
expect(h.serverCalls).toHaveBeenCalledWith(GRID);
|
|
167
170
|
});
|
|
168
171
|
|
|
172
|
+
it('stops a CLIENT half the player takes back, and forgets its author, through the game client', async () => {
|
|
173
|
+
const h = harness({
|
|
174
|
+
listings: [
|
|
175
|
+
[
|
|
176
|
+
half({ callerConsented: true }),
|
|
177
|
+
half({ modId: 'm2', name: 'shop', digest: 'd2', callerConsented: true }),
|
|
178
|
+
],
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
h.halves.enterGrid(GRID);
|
|
182
|
+
await h.halves.refresh();
|
|
183
|
+
expect(h.halves.running.map((m) => m.modId)).toEqual(['m1', 'm2']);
|
|
184
|
+
|
|
185
|
+
await expect(h.halves.revoke('m1')).resolves.toBe(true);
|
|
186
|
+
expect(h.exec.revokeClientModConsent).toHaveBeenCalledWith('1', 'm1');
|
|
187
|
+
expect(h.halves.running.map((m) => m.modId)).toEqual(['m2']);
|
|
188
|
+
expect(h.hud.remove).toHaveBeenCalledWith('grid:m1');
|
|
189
|
+
|
|
190
|
+
await expect(h.halves.forgetAuthor('7')).resolves.toBe(true);
|
|
191
|
+
expect(h.exec.revokeAuthorTrust).toHaveBeenCalledWith('1', '42', '7');
|
|
192
|
+
expect(h.halves.running).toEqual([]);
|
|
193
|
+
await h.halves.refresh();
|
|
194
|
+
expect(h.halves.running).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
169
197
|
it("forwards a CLIENT half's log lines and invokes its handle_invoke", async () => {
|
|
170
198
|
const lines: string[] = [];
|
|
171
199
|
const h = harness({ log: (line) => lines.push(line) });
|
|
@@ -298,6 +326,20 @@ describe('client half prompts', () => {
|
|
|
298
326
|
expect(text).toContain('Installed from the marketplace: shop.');
|
|
299
327
|
expect(text).toContain('• host calls: hud_set, actors_list, user_state_get');
|
|
300
328
|
expect(text).toContain('• groups: present, world_read, state');
|
|
329
|
+
expect(text).toContain('• it can read your saved state in this game');
|
|
330
|
+
expect(text).not.toContain('replace your saved state');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('says in plain words what the calls that act as the player can do', () => {
|
|
334
|
+
const summary = {
|
|
335
|
+
...SUMMARY,
|
|
336
|
+
hostFunctions: ['user_state_set', 'voxel_set', 'emit_spatial', 'constructor'],
|
|
337
|
+
};
|
|
338
|
+
const text = describeClientCapabilities(summary as never, JSON.stringify(summary));
|
|
339
|
+
expect(text).toContain('• it can replace your saved state in this game');
|
|
340
|
+
expect(text).toContain('• it can change blocks on this grid as you');
|
|
341
|
+
expect(text).toContain('• it can send messages and effects to players near you');
|
|
342
|
+
expect(text.split('\n').filter((line) => line.startsWith('• it can'))).toHaveLength(3);
|
|
301
343
|
});
|
|
302
344
|
|
|
303
345
|
it('asks about one CLIENT half, and shows a summary that does not parse as it is', () => {
|
|
@@ -41,7 +41,12 @@ import type { ModOverlayStore } from './modOverlay';
|
|
|
41
41
|
|
|
42
42
|
export type ClientHalvesExec = Pick<
|
|
43
43
|
ExecAPI,
|
|
44
|
-
|
|
44
|
+
| 'gridClientMods'
|
|
45
|
+
| 'consentClientMod'
|
|
46
|
+
| 'trustAuthor'
|
|
47
|
+
| 'modClientArtifactBytes'
|
|
48
|
+
| 'revokeClientModConsent'
|
|
49
|
+
| 'revokeAuthorTrust'
|
|
45
50
|
>;
|
|
46
51
|
|
|
47
52
|
/** The grid the player stands in: its id and chunk box. */
|
|
@@ -105,6 +110,9 @@ export class GridClientHalves {
|
|
|
105
110
|
trustAuthor: (appId, gridId, authorId, hash) =>
|
|
106
111
|
exec().trustAuthor(appId, gridId, authorId, hash),
|
|
107
112
|
modClientArtifactBytes: (appId, modId) => exec().modClientArtifactBytes(appId, modId),
|
|
113
|
+
revokeClientModConsent: (appId, modId) => exec().revokeClientModConsent(appId, modId),
|
|
114
|
+
revokeAuthorTrust: (appId, gridId, authorId) =>
|
|
115
|
+
exec().revokeAuthorTrust(appId, gridId, authorId),
|
|
108
116
|
},
|
|
109
117
|
appId: options.appId,
|
|
110
118
|
workerUrl: options.workerUrl,
|
|
@@ -162,6 +170,24 @@ export class GridClientHalves {
|
|
|
162
170
|
return this.runner.invoke(modId, payload, options);
|
|
163
171
|
}
|
|
164
172
|
|
|
173
|
+
/**
|
|
174
|
+
* "Stop running this": stops the CLIENT half of mod `modId` and takes back the player's consent
|
|
175
|
+
* to it (and their trust in its author, when it ran through that trust, keeping the author's
|
|
176
|
+
* other halves). Not asked about again on this grid until it changes. Rejects when the platform
|
|
177
|
+
* refuses; the half stays stopped here.
|
|
178
|
+
*/
|
|
179
|
+
revoke(modId: string): Promise<boolean> {
|
|
180
|
+
return this.runner.revoke(modId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* "Forget this author": stops every CLIENT half of `authorId` on this grid and takes back the
|
|
185
|
+
* player's trust in them here, with every consent to their halves.
|
|
186
|
+
*/
|
|
187
|
+
forgetAuthor(authorId: string): Promise<boolean> {
|
|
188
|
+
return this.runner.forgetAuthor(authorId);
|
|
189
|
+
}
|
|
190
|
+
|
|
165
191
|
stop(): void {
|
|
166
192
|
this.asking?.abort();
|
|
167
193
|
this.asking = null;
|
|
@@ -273,6 +299,16 @@ export function describeClientHalfPrompt(
|
|
|
273
299
|
return lines.join('\n');
|
|
274
300
|
}
|
|
275
301
|
|
|
302
|
+
/** What the host calls that act as the player let a CLIENT half do, in the player's words. */
|
|
303
|
+
const ACTS_AS_YOU: Record<string, string> = {
|
|
304
|
+
user_state_get: 'read your saved state in this game',
|
|
305
|
+
user_state_set: 'replace your saved state in this game',
|
|
306
|
+
voxel_set: 'change blocks on this grid as you',
|
|
307
|
+
emit_spatial: 'send messages and effects to players near you, from your game',
|
|
308
|
+
emit_channel: "post in this grid's channels, from your game",
|
|
309
|
+
pointer_clicks: 'see your mouse clicks',
|
|
310
|
+
};
|
|
311
|
+
|
|
276
312
|
export function describeClientCapabilities(
|
|
277
313
|
summary: ExecClientCapabilitySummary | null,
|
|
278
314
|
json: string,
|
|
@@ -284,6 +320,9 @@ export function describeClientCapabilities(
|
|
|
284
320
|
if (summary.capabilityGroups.length > 0) {
|
|
285
321
|
rows.push(`• groups: ${summary.capabilityGroups.join(', ')}`);
|
|
286
322
|
}
|
|
323
|
+
for (const fn of summary.hostFunctions) {
|
|
324
|
+
if (Object.hasOwn(ACTS_AS_YOU, fn)) rows.push(`• it can ${ACTS_AS_YOU[fn]}`);
|
|
325
|
+
}
|
|
287
326
|
return rows.join('\n');
|
|
288
327
|
}
|
|
289
328
|
|
|
@@ -185,6 +185,54 @@ describe('routeClientHostCall', () => {
|
|
|
185
185
|
]);
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
it('writes no voxel outside the grid, whatever second chunk the call carries', async () => {
|
|
189
|
+
const r = reads();
|
|
190
|
+
const written: Array<{ chunk: { x: number; y: number; z: number } }> = [];
|
|
191
|
+
const writes = {
|
|
192
|
+
setVoxel: async (input: { chunk: { x: number; y: number; z: number } }) => {
|
|
193
|
+
written.push(input);
|
|
194
|
+
return true;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
const voxel = { voxelX: 1, voxelY: 0, voxelZ: 2, voxelType: 7 };
|
|
198
|
+
// The broker checks chunkX/Y/Z against the grid; a call must not steer the write elsewhere.
|
|
199
|
+
for (const args of [
|
|
200
|
+
{ chunkX: 0, chunkY: 0, chunkZ: 0, chunk: { x: 999, y: 0, z: 0 }, ...voxel },
|
|
201
|
+
{ chunkX: 0, chunkY: 0, chunkZ: 0, chunk: [999, 0, 0], ...voxel },
|
|
202
|
+
{ chunkX: 0, chunkY: 0, chunkZ: 0, chunk_x: 999, ...voxel },
|
|
203
|
+
]) {
|
|
204
|
+
await routeClientHostCall({ fn: 'voxel_set', args } as never, r.reads, grid, writes);
|
|
205
|
+
}
|
|
206
|
+
expect(written.map((w) => w.chunk)).toEqual([
|
|
207
|
+
{ x: 0, y: 0, z: 0 },
|
|
208
|
+
{ x: 0, y: 0, z: 0 },
|
|
209
|
+
{ x: 0, y: 0, z: 0 },
|
|
210
|
+
]);
|
|
211
|
+
// Named only the second way, the chunk is checked against the grid at the write.
|
|
212
|
+
for (const args of [
|
|
213
|
+
{ chunk: { x: 999, y: 0, z: 0 }, ...voxel },
|
|
214
|
+
{ chunk_x: 5, chunk_y: 0, chunk_z: 0, ...voxel },
|
|
215
|
+
{ chunkX: 2, chunkY: 0, chunkZ: 0, ...voxel },
|
|
216
|
+
]) {
|
|
217
|
+
expect(
|
|
218
|
+
await routeClientHostCall({ fn: 'voxel_set', args } as never, r.reads, grid, writes),
|
|
219
|
+
).toEqual({ ok: false, error: 'out_of_grid' });
|
|
220
|
+
}
|
|
221
|
+
expect(written.length).toBe(3);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('refuses voxel_set without the grid to confine it to', async () => {
|
|
225
|
+
const r = reads();
|
|
226
|
+
await expect(
|
|
227
|
+
routeClientHostCall(
|
|
228
|
+
{ fn: 'voxel_set', args: { chunkX: 0, chunkY: 0, chunkZ: 0, voxelType: 1 } } as never,
|
|
229
|
+
r.reads,
|
|
230
|
+
undefined,
|
|
231
|
+
{ setVoxel: async () => true },
|
|
232
|
+
),
|
|
233
|
+
).rejects.toBeInstanceOf(HostCallRefusedError);
|
|
234
|
+
});
|
|
235
|
+
|
|
188
236
|
it('parses flattened chunkX host-call args the broker clamps', () => {
|
|
189
237
|
expect(
|
|
190
238
|
parseVoxelSetArgs({
|
|
@@ -110,9 +110,10 @@ export async function routeClientHostCall(
|
|
|
110
110
|
: { voxels: voxelsFromBase64(chunk.voxelsBase64) };
|
|
111
111
|
}
|
|
112
112
|
case 'voxel_set': {
|
|
113
|
-
if (!writes) throw new HostCallRefusedError(fn);
|
|
113
|
+
if (!writes || !grid) throw new HostCallRefusedError(fn);
|
|
114
114
|
const parsed = parseVoxelSetArgs(args);
|
|
115
115
|
if (!parsed) return { ok: false, error: 'invalid voxel_set arguments' };
|
|
116
|
+
if (!chunkInGrid(parsed.chunk, grid)) return { ok: false, error: 'out_of_grid' };
|
|
116
117
|
const ok = await writes.setVoxel({
|
|
117
118
|
chunk: parsed.chunk,
|
|
118
119
|
x: parsed.x,
|
|
@@ -163,7 +164,11 @@ function asCoord3(value: unknown): { x: unknown; y: unknown; z: unknown } | null
|
|
|
163
164
|
return null;
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* Accept the SDKs' flattened host-call JSON and the tuple shape. The chunk is `chunkX/Y/Z`, the
|
|
169
|
+
* fields the broker checks against the grid, whenever any of them is present; `args.chunk` only
|
|
170
|
+
* when none is. The caller still checks the chunk against the grid.
|
|
171
|
+
*/
|
|
167
172
|
export function parseVoxelSetArgs(args: Record<string, unknown>): {
|
|
168
173
|
chunk: { x: number; y: number; z: number };
|
|
169
174
|
x: number;
|
|
@@ -172,13 +177,15 @@ export function parseVoxelSetArgs(args: Record<string, unknown>): {
|
|
|
172
177
|
voxelType: number;
|
|
173
178
|
state: string;
|
|
174
179
|
} | null {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
const flat = ['chunkX', 'chunkY', 'chunkZ'].some((key) => args[key] !== undefined);
|
|
181
|
+
const chunkRaw = flat
|
|
182
|
+
? { x: args.chunkX, y: args.chunkY, z: args.chunkZ }
|
|
183
|
+
: (asCoord3(args.chunk) ??
|
|
184
|
+
({
|
|
185
|
+
x: coordField(args, 'chunk_x'),
|
|
186
|
+
y: coordField(args, 'chunk_y'),
|
|
187
|
+
z: coordField(args, 'chunk_z'),
|
|
188
|
+
} as { x: unknown; y: unknown; z: unknown }));
|
|
182
189
|
const voxelRaw =
|
|
183
190
|
asCoord3(args.voxel) ??
|
|
184
191
|
({
|
|
@@ -248,6 +255,23 @@ export async function routeWithFallback(
|
|
|
248
255
|
}
|
|
249
256
|
}
|
|
250
257
|
|
|
258
|
+
function chunkInGrid(
|
|
259
|
+
chunk: { x: number; y: number; z: number },
|
|
260
|
+
grid: PlayerCodeGridBounds,
|
|
261
|
+
): boolean {
|
|
262
|
+
const x = BigInt(chunk.x);
|
|
263
|
+
const y = BigInt(chunk.y);
|
|
264
|
+
const z = BigInt(chunk.z);
|
|
265
|
+
return (
|
|
266
|
+
x >= grid.low.x &&
|
|
267
|
+
x <= grid.high.x &&
|
|
268
|
+
y >= grid.low.y &&
|
|
269
|
+
y <= grid.high.y &&
|
|
270
|
+
z >= grid.low.z &&
|
|
271
|
+
z <= grid.high.z
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
251
275
|
function minBig(a: bigint, b: bigint): bigint {
|
|
252
276
|
return a < b ? a : b;
|
|
253
277
|
}
|