@crowdedkingdoms/construct 0.3.3-dev.1 → 0.3.5-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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/construct",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5-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 || >=0.4.3-dev.0 <1"
|
|
48
|
+
"@crowdedkingdoms/crowdyjs": ">=18.0.4-dev.0 <19 || >=18.1.0-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 || >=0.4.4-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
|
+
}
|