castle-web-cli 0.4.170 → 0.4.171
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/dist/agent-prompts.d.ts +1 -0
- package/dist/agent-prompts.js +3 -0
- package/dist/agent.js +47 -11
- package/dist/castle-host/host.js +71 -0
- package/dist/ide.d.ts +10 -0
- package/dist/ide.js +5 -5
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-6odVZQSZ.css +1 -0
- package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
- package/dist/shell/index.html +3 -3
- package/kits/base/castle.json +1 -1
- package/kits/base/sdk/README.md +17 -1
- package/kits/base/sdk/commands.d.ts +14 -0
- package/kits/base/sdk/user.d.ts +4 -0
- package/kits/base/sdk/user.js +36 -1
- package/kits/multiplayer-2d/CLAUDE.md +20 -9
- package/kits/multiplayer-2d/castle.json +2 -2
- package/kits/multiplayer-2d/code/server/players.js +27 -6
- package/kits/multiplayer-2d/code/server/world.js +24 -1
- package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
- package/kits/multiplayer-2d/package-lock.json +26 -1
- package/kits/multiplayer-2d/package.json +2 -0
- package/kits/multiplayer-3d/CLAUDE.md +28 -9
- package/kits/multiplayer-3d/castle.json +3 -3
- package/kits/multiplayer-3d/code/server/players.js +54 -5
- package/kits/multiplayer-3d/code/server/world.js +45 -1
- package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
- package/kits/multiplayer-3d/package-lock.json +26 -1
- package/kits/multiplayer-3d/package.json +2 -0
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PxArtEditor.jsx +2 -2
- package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
- package/kits/physics-3d/castle.json +1 -1
- package/kits/real-time/CLAUDE.md +23 -4
- package/kits/real-time/castle.json +1 -1
- package/kits/real-time/code/client/connection.js +9 -4
- package/kits/real-time/code/client/joinOverlay.js +46 -0
- package/kits/real-time/code/client/messages.js +4 -0
- package/kits/real-time/code/server/gameHooks.js +4 -0
- package/kits/real-time/code/server/persist.js +105 -0
- package/kits/real-time/code/server/session.js +289 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +76 -20
- package/kits/turn-based/castle.json +1 -1
- package/kits/turn-based/code/server/index.js +16 -5
- package/kits/turn-based/package.json +2 -1
- package/kits/turn-based/room.js +162 -13
- package/kits/turn-based/testing.js +276 -92
- package/package.json +1 -2
- package/dist/shell/assets/index-BvQmVwlO.css +0 -1
- package/dist/shell/assets/index-CV5sBby1.js +0 -445
|
@@ -49,13 +49,49 @@ register(import.meta.url);
|
|
|
49
49
|
// - messages cross as JSON, so a payload holding a live reference to server
|
|
50
50
|
// state arrives as a copy, and an unstringifiable one throws here.
|
|
51
51
|
//
|
|
52
|
-
// It is not a transport.
|
|
53
|
-
//
|
|
52
|
+
// It is not a transport. `join` and `leave` await the deck's callback, because
|
|
53
|
+
// a join reads storage; a two-client game over the real socket is the wider test.
|
|
54
54
|
|
|
55
55
|
let sessionCounter = 0;
|
|
56
56
|
|
|
57
|
+
// Deck storage, in memory. Rows are JSON, as they are in the real store, so a
|
|
58
|
+
// server that saves a live reference to its own state reads back a copy.
|
|
59
|
+
// Two harnesses sharing one of these are two processes sharing one deck.
|
|
60
|
+
export function createTestStorage() {
|
|
61
|
+
const rows = new Map();
|
|
62
|
+
const scope = {
|
|
63
|
+
get: async (keys) => {
|
|
64
|
+
const found = {};
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
if (rows.has(key)) {
|
|
67
|
+
found[key] = encode(rows.get(key));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
},
|
|
72
|
+
set: async (values) => {
|
|
73
|
+
for (const [key, value] of Object.entries(values)) {
|
|
74
|
+
rows.set(key, encode(value));
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
remove: async (keys) => {
|
|
78
|
+
for (const key of keys) {
|
|
79
|
+
rows.delete(key);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return { rows, deck: scope, user: () => scope };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Everything a pending storage write is waiting on. `keepAlive` starts a save
|
|
87
|
+
// nobody awaits, so a check that reads the store waits for this first.
|
|
88
|
+
export function settleStorage() {
|
|
89
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
// `server` is the deck's default export from `server/index.js`.
|
|
58
|
-
// Options: `mode` ('public' | 'named' | 'party'), `sessionId`, `deckId
|
|
93
|
+
// Options: `mode` ('public' | 'named' | 'party'), `sessionId`, `deckId`,
|
|
94
|
+
// `storage` (share one to make two harnesses two processes of one deck).
|
|
59
95
|
export function createSessionHarness(server, options = {}) {
|
|
60
96
|
const sessionId = options.sessionId ?? `test-session-${++sessionCounter}`;
|
|
61
97
|
const players = new Map();
|
|
@@ -65,10 +101,12 @@ export function createSessionHarness(server, options = {}) {
|
|
|
65
101
|
session,
|
|
66
102
|
outbox,
|
|
67
103
|
sessionId,
|
|
104
|
+
storage: session.storage,
|
|
68
105
|
start: () => server.onStart?.(session),
|
|
69
106
|
join: (playerId, identity) => joinPlayer(server, session, players, playerId, identity),
|
|
70
107
|
leave: (player) => leavePlayer(server, session, players, player),
|
|
71
108
|
message: (player, data) => messageFrom(server, session, players, player, data),
|
|
109
|
+
shutdown: () => server.onShutdown?.(session),
|
|
72
110
|
|
|
73
111
|
// Every message this player has been sent, oldest first, already decoded.
|
|
74
112
|
sent: (playerId) =>
|
|
@@ -90,6 +128,7 @@ function createSession(sessionId, options, players, outbox) {
|
|
|
90
128
|
sessionId,
|
|
91
129
|
deckId: options.deckId ?? 'test-deck',
|
|
92
130
|
mode: options.mode ?? 'public',
|
|
131
|
+
storage: options.storage ?? createTestStorage(),
|
|
93
132
|
send(playerId, data) {
|
|
94
133
|
outbox.push({ playerId, data: encode(data) });
|
|
95
134
|
},
|
|
@@ -125,7 +164,7 @@ function encode(data) {
|
|
|
125
164
|
return JSON.parse(text);
|
|
126
165
|
}
|
|
127
166
|
|
|
128
|
-
function joinPlayer(server, session, players, playerId, identity = {}) {
|
|
167
|
+
async function joinPlayer(server, session, players, playerId, identity = {}) {
|
|
129
168
|
const player = {
|
|
130
169
|
playerId,
|
|
131
170
|
userId: identity.userId ?? playerId,
|
|
@@ -133,17 +172,17 @@ function joinPlayer(server, session, players, playerId, identity = {}) {
|
|
|
133
172
|
isAnonymous: identity.isAnonymous ?? false,
|
|
134
173
|
};
|
|
135
174
|
players.set(playerId, player); // in the roster before the callback, as upstream
|
|
136
|
-
server.onPlayerJoin?.(session, player);
|
|
175
|
+
await server.onPlayerJoin?.(session, player);
|
|
137
176
|
return player;
|
|
138
177
|
}
|
|
139
178
|
|
|
140
|
-
function leavePlayer(server, session, players, player) {
|
|
179
|
+
async function leavePlayer(server, session, players, player) {
|
|
141
180
|
const known = players.get(player.playerId);
|
|
142
181
|
if (!known) {
|
|
143
182
|
return null;
|
|
144
183
|
}
|
|
145
184
|
players.delete(player.playerId); // out of the roster before the callback
|
|
146
|
-
server.onPlayerLeave?.(session, known);
|
|
185
|
+
await server.onPlayerLeave?.(session, known);
|
|
147
186
|
return known;
|
|
148
187
|
}
|
|
149
188
|
|
|
@@ -208,6 +247,8 @@ export function createTestClock(startAt = Date.now()) {
|
|
|
208
247
|
// the bug this kit was extracted to prevent. A shipped deck cleared the board
|
|
209
248
|
// when a seated player's connection dropped.
|
|
210
249
|
//
|
|
250
|
+
// `checkRoom` returns a promise: a join reads storage, so the scenarios await.
|
|
251
|
+
//
|
|
211
252
|
// The deck supplies four hooks, all small:
|
|
212
253
|
//
|
|
213
254
|
// play(harness, seated) make one move the server will accept, from
|
|
@@ -228,8 +269,16 @@ export function createTestClock(startAt = Date.now()) {
|
|
|
228
269
|
//
|
|
229
270
|
// plus `server` (the deck's default export), `seats` (how many), `idleMs`,
|
|
230
271
|
// `graceMs`, and `minPlayers` if it is not every seat.
|
|
272
|
+
//
|
|
273
|
+
// `reload` is optional: an async hook returning a fresh import of the deck's
|
|
274
|
+
// server, which is a restarted process for the three checks that need one.
|
|
275
|
+
//
|
|
276
|
+
// reload: () => import(`../code/server/index.js?fresh=${Date.now()}`)
|
|
277
|
+
// .then((module) => module.default)
|
|
278
|
+
//
|
|
279
|
+
// Without it those checks are skipped.
|
|
231
280
|
|
|
232
|
-
export function checkRoom(options) {
|
|
281
|
+
export async function checkRoom(options) {
|
|
233
282
|
const ctx = { ...options, minPlayers: options.minPlayers ?? options.seats, results: [] };
|
|
234
283
|
|
|
235
284
|
// The whole run happens on the movable clock, so the idle and grace windows
|
|
@@ -238,7 +287,7 @@ export function checkRoom(options) {
|
|
|
238
287
|
clock.install();
|
|
239
288
|
try {
|
|
240
289
|
for (const scenario of SCENARIOS) {
|
|
241
|
-
scenario(ctx, clock);
|
|
290
|
+
await scenario(ctx, clock);
|
|
242
291
|
}
|
|
243
292
|
} finally {
|
|
244
293
|
clock.uninstall();
|
|
@@ -266,12 +315,22 @@ const SCENARIOS = [
|
|
|
266
315
|
partyTableIsNeverCleared,
|
|
267
316
|
namedBehavesLikePublic,
|
|
268
317
|
sessionsDoNotLeak,
|
|
318
|
+
movesAreSaved,
|
|
319
|
+
seatComesBackToTheAccount,
|
|
320
|
+
aSecondConnectionDoesNotTakeTheSeat,
|
|
321
|
+
namedRoomComesBack,
|
|
322
|
+
publicRoomComesBackInsideTheWindow,
|
|
323
|
+
publicRoomStartsCleanPastTheWindow,
|
|
269
324
|
];
|
|
270
325
|
|
|
271
326
|
// Prints one line per check and returns the failure count, so a deck's test
|
|
272
|
-
// script is `process.exit(printRoomChecks(checkRoom({...})))`.
|
|
327
|
+
// script is `process.exit(printRoomChecks(await checkRoom({...})))`.
|
|
273
328
|
export function printRoomChecks(outcome) {
|
|
274
329
|
for (const result of outcome.results) {
|
|
330
|
+
if (result.skipped) {
|
|
331
|
+
console.log(`skip ${result.name} -- ${result.label}`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
275
334
|
const detail = result.ok
|
|
276
335
|
? ''
|
|
277
336
|
: `\n got ${json(result.actual)}\n want ${result.label}`;
|
|
@@ -295,31 +354,48 @@ function checkNot(ctx, name, actual, unexpected) {
|
|
|
295
354
|
ctx.results.push({ name, ok, actual, label: `anything but ${json(unexpected)}` });
|
|
296
355
|
}
|
|
297
356
|
|
|
357
|
+
function skip(ctx, name, why) {
|
|
358
|
+
ctx.results.push({ name, ok: true, skipped: true, label: why });
|
|
359
|
+
}
|
|
360
|
+
|
|
298
361
|
// --- scenario scaffolding -------------------------------------------------
|
|
299
362
|
|
|
300
363
|
let counter = 0;
|
|
301
364
|
|
|
302
365
|
// A session with every seat taken, and nothing played yet.
|
|
303
|
-
function seatedTable(ctx, mode = 'public') {
|
|
366
|
+
async function seatedTable(ctx, mode = 'public') {
|
|
304
367
|
const harness = createSessionHarness(ctx.server, { mode, sessionId: `check-${++counter}` });
|
|
305
368
|
const seated = [];
|
|
306
369
|
for (let i = 0; i < ctx.seats; i++) {
|
|
307
|
-
seated.push(harness.join(`${harness.sessionId}-p${i}`));
|
|
370
|
+
seated.push(await harness.join(`${harness.sessionId}-p${i}`));
|
|
308
371
|
}
|
|
309
372
|
return { harness, seated };
|
|
310
373
|
}
|
|
311
374
|
|
|
312
375
|
// The same, with one accepted move on it.
|
|
313
|
-
function playedTable(ctx, mode = 'public') {
|
|
314
|
-
const table = seatedTable(ctx, mode);
|
|
315
|
-
ctx.play(table.harness, table.seated);
|
|
376
|
+
async function playedTable(ctx, mode = 'public') {
|
|
377
|
+
const table = await seatedTable(ctx, mode);
|
|
378
|
+
await ctx.play(table.harness, table.seated);
|
|
316
379
|
return table;
|
|
317
380
|
}
|
|
318
381
|
|
|
382
|
+
// A played table whose players have accounts, so another process can recognize
|
|
383
|
+
// them. `options` is the harness's, and a second harness given the same ones is
|
|
384
|
+
// the same room seen by a restarted server.
|
|
385
|
+
async function accountTable(ctx, options) {
|
|
386
|
+
const harness = createSessionHarness(ctx.server, options);
|
|
387
|
+
const seated = [];
|
|
388
|
+
for (let i = 0; i < ctx.seats; i++) {
|
|
389
|
+
seated.push(await harness.join(`${options.sessionId}-p${i}`, { userId: `u${i}` }));
|
|
390
|
+
}
|
|
391
|
+
await ctx.play(harness, seated);
|
|
392
|
+
return { harness, seated };
|
|
393
|
+
}
|
|
394
|
+
|
|
319
395
|
// The game as a brand-new table looks, for comparing a cleared table against.
|
|
320
|
-
function freshGame(ctx) {
|
|
396
|
+
async function freshGame(ctx) {
|
|
321
397
|
if (ctx.freshCache === undefined) {
|
|
322
|
-
const { harness, seated } = seatedTable(ctx);
|
|
398
|
+
const { harness, seated } = await seatedTable(ctx);
|
|
323
399
|
ctx.freshCache = ctx.snapshot(harness, seated[0].playerId);
|
|
324
400
|
}
|
|
325
401
|
return ctx.freshCache;
|
|
@@ -330,17 +406,17 @@ function dropsToUncover(ctx) {
|
|
|
330
406
|
return ctx.seats - ctx.minPlayers + 1;
|
|
331
407
|
}
|
|
332
408
|
|
|
333
|
-
function leaveSome(table, count) {
|
|
409
|
+
async function leaveSome(table, count) {
|
|
334
410
|
const gone = table.seated.slice(-count);
|
|
335
411
|
for (const player of gone) {
|
|
336
|
-
table.harness.leave(player);
|
|
412
|
+
await table.harness.leave(player);
|
|
337
413
|
}
|
|
338
414
|
return gone;
|
|
339
415
|
}
|
|
340
416
|
|
|
341
|
-
function leaveAll(table) {
|
|
417
|
+
async function leaveAll(table) {
|
|
342
418
|
for (const player of table.seated) {
|
|
343
|
-
table.harness.leave(player);
|
|
419
|
+
await table.harness.leave(player);
|
|
344
420
|
}
|
|
345
421
|
}
|
|
346
422
|
|
|
@@ -348,42 +424,42 @@ function leaveAll(table) {
|
|
|
348
424
|
|
|
349
425
|
// If this one fails, nothing below means anything: the hooks are wrong, not the
|
|
350
426
|
// deck.
|
|
351
|
-
function hooksWork(ctx) {
|
|
352
|
-
const { harness, seated } = playedTable(ctx);
|
|
427
|
+
async function hooksWork(ctx) {
|
|
428
|
+
const { harness, seated } = await playedTable(ctx);
|
|
353
429
|
checkNot(
|
|
354
430
|
ctx,
|
|
355
431
|
'the play hook changes the game',
|
|
356
432
|
ctx.snapshot(harness, seated[0].playerId),
|
|
357
|
-
freshGame(ctx)
|
|
433
|
+
await freshGame(ctx)
|
|
358
434
|
);
|
|
359
435
|
}
|
|
360
436
|
|
|
361
|
-
function seating(ctx) {
|
|
362
|
-
const { harness, seated } = seatedTable(ctx);
|
|
437
|
+
async function seating(ctx) {
|
|
438
|
+
const { harness, seated } = await seatedTable(ctx);
|
|
363
439
|
const held = seated.map((p) => ctx.seatOf(harness, p.playerId));
|
|
364
440
|
check(ctx, 'every seat is taken, each by a different player', new Set(held).size, ctx.seats);
|
|
365
441
|
check(ctx, 'no arrival is left seatless while a seat is free', held.includes(null), false);
|
|
366
|
-
const watcher = harness.join(`${harness.sessionId}-extra`);
|
|
442
|
+
const watcher = await harness.join(`${harness.sessionId}-extra`);
|
|
367
443
|
check(ctx, 'past the last seat, an arrival watches', ctx.seatOf(harness, watcher.playerId), null);
|
|
368
444
|
}
|
|
369
445
|
|
|
370
446
|
// Seats belong to connections, so two tabs of one account can play each other
|
|
371
447
|
// locally.
|
|
372
|
-
function twoTabsOfOneAccount(ctx) {
|
|
448
|
+
async function twoTabsOfOneAccount(ctx) {
|
|
373
449
|
const harness = createSessionHarness(ctx.server, { sessionId: `check-${++counter}` });
|
|
374
|
-
const a = harness.join('tab-a', { userId: 'one-account', username: 'nikki' });
|
|
375
|
-
const b = harness.join('tab-b', { userId: 'one-account', username: 'nikki' });
|
|
450
|
+
const a = await harness.join('tab-a', { userId: 'one-account', username: 'nikki' });
|
|
451
|
+
const b = await harness.join('tab-b', { userId: 'one-account', username: 'nikki' });
|
|
376
452
|
const held = [ctx.seatOf(harness, a.playerId), ctx.seatOf(harness, b.playerId)];
|
|
377
453
|
check(ctx, 'two tabs of one account take two seats', new Set(held).size, 2);
|
|
378
454
|
}
|
|
379
455
|
|
|
380
456
|
// The bug this kit exists for: one player's connection blips and the other
|
|
381
457
|
// player's game disappears from under them.
|
|
382
|
-
function dropDoesNotDestroy(ctx) {
|
|
383
|
-
const table = playedTable(ctx);
|
|
458
|
+
async function dropDoesNotDestroy(ctx) {
|
|
459
|
+
const table = await playedTable(ctx);
|
|
384
460
|
const stays = table.seated[0].playerId;
|
|
385
461
|
const before = ctx.snapshot(table.harness, stays);
|
|
386
|
-
leaveSome(table, dropsToUncover(ctx));
|
|
462
|
+
await leaveSome(table, dropsToUncover(ctx));
|
|
387
463
|
check(
|
|
388
464
|
ctx,
|
|
389
465
|
'a seated player dropping leaves the game standing',
|
|
@@ -392,12 +468,12 @@ function dropDoesNotDestroy(ctx) {
|
|
|
392
468
|
);
|
|
393
469
|
}
|
|
394
470
|
|
|
395
|
-
function watcherIsPromoted(ctx) {
|
|
396
|
-
const table = playedTable(ctx);
|
|
397
|
-
const watcher = table.harness.join(`${table.harness.sessionId}-w`);
|
|
471
|
+
async function watcherIsPromoted(ctx) {
|
|
472
|
+
const table = await playedTable(ctx);
|
|
473
|
+
const watcher = await table.harness.join(`${table.harness.sessionId}-w`);
|
|
398
474
|
const leaver = table.seated[table.seated.length - 1];
|
|
399
475
|
const freed = ctx.seatOf(table.harness, leaver.playerId);
|
|
400
|
-
table.harness.leave(leaver);
|
|
476
|
+
await table.harness.leave(leaver);
|
|
401
477
|
check(
|
|
402
478
|
ctx,
|
|
403
479
|
'a watcher is promoted into the vacated seat',
|
|
@@ -406,15 +482,15 @@ function watcherIsPromoted(ctx) {
|
|
|
406
482
|
);
|
|
407
483
|
}
|
|
408
484
|
|
|
409
|
-
function reconnectKeepsItsSeat(ctx, clock) {
|
|
410
|
-
const table = playedTable(ctx);
|
|
485
|
+
async function reconnectKeepsItsSeat(ctx, clock) {
|
|
486
|
+
const table = await playedTable(ctx);
|
|
411
487
|
const stays = table.seated[0].playerId;
|
|
412
488
|
const before = ctx.snapshot(table.harness, stays);
|
|
413
489
|
const leaver = table.seated[table.seated.length - 1];
|
|
414
490
|
const seat = ctx.seatOf(table.harness, leaver.playerId);
|
|
415
|
-
table.harness.leave(leaver);
|
|
491
|
+
await table.harness.leave(leaver);
|
|
416
492
|
clock.advance(Math.max(1, Math.floor(ctx.graceMs / 2)));
|
|
417
|
-
table.harness.join(leaver.playerId);
|
|
493
|
+
await table.harness.join(leaver.playerId);
|
|
418
494
|
check(
|
|
419
495
|
ctx,
|
|
420
496
|
'a reconnect inside the grace window gets its own seat back',
|
|
@@ -424,13 +500,13 @@ function reconnectKeepsItsSeat(ctx, clock) {
|
|
|
424
500
|
check(ctx, '...and the game with it', ctx.snapshot(table.harness, stays), before);
|
|
425
501
|
}
|
|
426
502
|
|
|
427
|
-
function presentOutranksGhost(ctx) {
|
|
428
|
-
const table = playedTable(ctx);
|
|
429
|
-
const watcher = table.harness.join(`${table.harness.sessionId}-w`);
|
|
503
|
+
async function presentOutranksGhost(ctx) {
|
|
504
|
+
const table = await playedTable(ctx);
|
|
505
|
+
const watcher = await table.harness.join(`${table.harness.sessionId}-w`);
|
|
430
506
|
const leaver = table.seated[table.seated.length - 1];
|
|
431
507
|
const seat = ctx.seatOf(table.harness, leaver.playerId);
|
|
432
|
-
table.harness.leave(leaver);
|
|
433
|
-
table.harness.join(leaver.playerId);
|
|
508
|
+
await table.harness.leave(leaver);
|
|
509
|
+
await table.harness.join(leaver.playerId);
|
|
434
510
|
check(
|
|
435
511
|
ctx,
|
|
436
512
|
'a promoted watcher outranks a reconnecting ghost',
|
|
@@ -447,12 +523,12 @@ function presentOutranksGhost(ctx) {
|
|
|
447
523
|
|
|
448
524
|
// Everyone dropped and one of them came back inside the window: still their own
|
|
449
525
|
// game, however alone they are.
|
|
450
|
-
function reconnectAloneKeepsTheGame(ctx, clock) {
|
|
451
|
-
const table = playedTable(ctx);
|
|
526
|
+
async function reconnectAloneKeepsTheGame(ctx, clock) {
|
|
527
|
+
const table = await playedTable(ctx);
|
|
452
528
|
const before = ctx.snapshot(table.harness, table.seated[0].playerId);
|
|
453
|
-
leaveAll(table);
|
|
529
|
+
await leaveAll(table);
|
|
454
530
|
clock.advance(Math.max(1, Math.floor(ctx.graceMs / 2)));
|
|
455
|
-
const back = table.harness.join(table.seated[0].playerId);
|
|
531
|
+
const back = await table.harness.join(table.seated[0].playerId);
|
|
456
532
|
check(
|
|
457
533
|
ctx,
|
|
458
534
|
'a lone reconnect inside the grace window keeps its own game',
|
|
@@ -461,38 +537,38 @@ function reconnectAloneKeepsTheGame(ctx, clock) {
|
|
|
461
537
|
);
|
|
462
538
|
}
|
|
463
539
|
|
|
464
|
-
function loneArrivalGetsACleanTable(ctx, clock) {
|
|
465
|
-
const table = playedTable(ctx);
|
|
466
|
-
leaveAll(table);
|
|
540
|
+
async function loneArrivalGetsACleanTable(ctx, clock) {
|
|
541
|
+
const table = await playedTable(ctx);
|
|
542
|
+
await leaveAll(table);
|
|
467
543
|
clock.advance(ctx.graceMs + 1);
|
|
468
|
-
const arrival = table.harness.join(`${table.harness.sessionId}-new`);
|
|
544
|
+
const arrival = await table.harness.join(`${table.harness.sessionId}-new`);
|
|
469
545
|
check(
|
|
470
546
|
ctx,
|
|
471
547
|
'a lone arrival gets a clean table',
|
|
472
548
|
ctx.snapshot(table.harness, arrival.playerId),
|
|
473
|
-
freshGame(ctx)
|
|
549
|
+
await freshGame(ctx)
|
|
474
550
|
);
|
|
475
551
|
}
|
|
476
552
|
|
|
477
|
-
function staleTableIsCleared(ctx, clock) {
|
|
478
|
-
const table = playedTable(ctx);
|
|
479
|
-
leaveSome(table, dropsToUncover(ctx));
|
|
553
|
+
async function staleTableIsCleared(ctx, clock) {
|
|
554
|
+
const table = await playedTable(ctx);
|
|
555
|
+
await leaveSome(table, dropsToUncover(ctx));
|
|
480
556
|
clock.advance(ctx.idleMs + 1);
|
|
481
|
-
const arrival = table.harness.join(`${table.harness.sessionId}-new`);
|
|
557
|
+
const arrival = await table.harness.join(`${table.harness.sessionId}-new`);
|
|
482
558
|
check(
|
|
483
559
|
ctx,
|
|
484
560
|
'a table untouched for the idle window is cleared for an arrival',
|
|
485
561
|
ctx.snapshot(table.harness, arrival.playerId),
|
|
486
|
-
freshGame(ctx)
|
|
562
|
+
await freshGame(ctx)
|
|
487
563
|
);
|
|
488
564
|
}
|
|
489
565
|
|
|
490
|
-
function recentTableIsKept(ctx, clock) {
|
|
491
|
-
const table = playedTable(ctx);
|
|
566
|
+
async function recentTableIsKept(ctx, clock) {
|
|
567
|
+
const table = await playedTable(ctx);
|
|
492
568
|
const before = ctx.snapshot(table.harness, table.seated[0].playerId);
|
|
493
|
-
leaveSome(table, dropsToUncover(ctx));
|
|
569
|
+
await leaveSome(table, dropsToUncover(ctx));
|
|
494
570
|
clock.advance(Math.max(1, Math.floor(ctx.idleMs / 2)));
|
|
495
|
-
const arrival = table.harness.join(`${table.harness.sessionId}-new`);
|
|
571
|
+
const arrival = await table.harness.join(`${table.harness.sessionId}-new`);
|
|
496
572
|
check(
|
|
497
573
|
ctx,
|
|
498
574
|
'a recently played table with a free seat is kept',
|
|
@@ -503,11 +579,11 @@ function recentTableIsKept(ctx, clock) {
|
|
|
503
579
|
|
|
504
580
|
// The idle window is only ever consulted when the table is short of players, so
|
|
505
581
|
// however long the people at it stare at the board, nothing takes it away.
|
|
506
|
-
function coveredTableSurvivesALongThink(ctx, clock) {
|
|
507
|
-
const table = playedTable(ctx);
|
|
582
|
+
async function coveredTableSurvivesALongThink(ctx, clock) {
|
|
583
|
+
const table = await playedTable(ctx);
|
|
508
584
|
const before = ctx.snapshot(table.harness, table.seated[0].playerId);
|
|
509
585
|
clock.advance(ctx.idleMs * 10);
|
|
510
|
-
const arrival = table.harness.join(`${table.harness.sessionId}-w`);
|
|
586
|
+
const arrival = await table.harness.join(`${table.harness.sessionId}-w`);
|
|
511
587
|
check(
|
|
512
588
|
ctx,
|
|
513
589
|
'a long think with the seats covered is never cleared',
|
|
@@ -517,31 +593,31 @@ function coveredTableSurvivesALongThink(ctx, clock) {
|
|
|
517
593
|
check(ctx, '...and the arrival watches', ctx.seatOf(table.harness, arrival.playerId), null);
|
|
518
594
|
}
|
|
519
595
|
|
|
520
|
-
function refusedMessagesDoNotRefreshTheClock(ctx, clock) {
|
|
521
|
-
const table = playedTable(ctx);
|
|
596
|
+
async function refusedMessagesDoNotRefreshTheClock(ctx, clock) {
|
|
597
|
+
const table = await playedTable(ctx);
|
|
522
598
|
const half = Math.max(1, Math.floor(ctx.idleMs * 0.6));
|
|
523
599
|
clock.advance(half);
|
|
524
|
-
ctx.junk(table.harness, table.seated[0]);
|
|
600
|
+
await ctx.junk(table.harness, table.seated[0]);
|
|
525
601
|
clock.advance(half);
|
|
526
|
-
leaveSome(table, dropsToUncover(ctx));
|
|
527
|
-
const arrival = table.harness.join(`${table.harness.sessionId}-new`);
|
|
602
|
+
await leaveSome(table, dropsToUncover(ctx));
|
|
603
|
+
const arrival = await table.harness.join(`${table.harness.sessionId}-new`);
|
|
528
604
|
check(
|
|
529
605
|
ctx,
|
|
530
606
|
'refused messages do not refresh the idle clock',
|
|
531
607
|
ctx.snapshot(table.harness, arrival.playerId),
|
|
532
|
-
freshGame(ctx)
|
|
608
|
+
await freshGame(ctx)
|
|
533
609
|
);
|
|
534
610
|
}
|
|
535
611
|
|
|
536
|
-
function acceptedMovesDoRefreshTheClock(ctx, clock) {
|
|
537
|
-
const table = playedTable(ctx);
|
|
612
|
+
async function acceptedMovesDoRefreshTheClock(ctx, clock) {
|
|
613
|
+
const table = await playedTable(ctx);
|
|
538
614
|
const half = Math.max(1, Math.floor(ctx.idleMs * 0.6));
|
|
539
615
|
clock.advance(half);
|
|
540
|
-
ctx.play(table.harness, table.seated);
|
|
616
|
+
await ctx.play(table.harness, table.seated);
|
|
541
617
|
const before = ctx.snapshot(table.harness, table.seated[0].playerId);
|
|
542
618
|
clock.advance(half);
|
|
543
|
-
leaveSome(table, dropsToUncover(ctx));
|
|
544
|
-
const arrival = table.harness.join(`${table.harness.sessionId}-new`);
|
|
619
|
+
await leaveSome(table, dropsToUncover(ctx));
|
|
620
|
+
const arrival = await table.harness.join(`${table.harness.sessionId}-new`);
|
|
545
621
|
check(
|
|
546
622
|
ctx,
|
|
547
623
|
'an accepted move does refresh it',
|
|
@@ -552,12 +628,12 @@ function acceptedMovesDoRefreshTheClock(ctx, clock) {
|
|
|
552
628
|
|
|
553
629
|
// Every party gets its own session, so whatever a party session holds belongs
|
|
554
630
|
// to that party. Nothing in it is ever cleared.
|
|
555
|
-
function partyTableIsNeverCleared(ctx, clock) {
|
|
556
|
-
const table = playedTable(ctx, 'party');
|
|
631
|
+
async function partyTableIsNeverCleared(ctx, clock) {
|
|
632
|
+
const table = await playedTable(ctx, 'party');
|
|
557
633
|
const before = ctx.snapshot(table.harness, table.seated[0].playerId);
|
|
558
|
-
leaveAll(table);
|
|
634
|
+
await leaveAll(table);
|
|
559
635
|
clock.advance(ctx.graceMs + ctx.idleMs + 1);
|
|
560
|
-
const arrival = table.harness.join(`${table.harness.sessionId}-new`);
|
|
636
|
+
const arrival = await table.harness.join(`${table.harness.sessionId}-new`);
|
|
561
637
|
check(
|
|
562
638
|
ctx,
|
|
563
639
|
'a party table survives a lone arrival and the idle window',
|
|
@@ -568,34 +644,142 @@ function partyTableIsNeverCleared(ctx, clock) {
|
|
|
568
644
|
|
|
569
645
|
// A local serve reports `named` for every session, so that is the branch a serve
|
|
570
646
|
// exercises. It must behave like public, not like party.
|
|
571
|
-
function namedBehavesLikePublic(ctx, clock) {
|
|
572
|
-
const table = playedTable(ctx, 'named');
|
|
573
|
-
leaveAll(table);
|
|
647
|
+
async function namedBehavesLikePublic(ctx, clock) {
|
|
648
|
+
const table = await playedTable(ctx, 'named');
|
|
649
|
+
await leaveAll(table);
|
|
574
650
|
clock.advance(ctx.graceMs + 1);
|
|
575
|
-
const arrival = table.harness.join(`${table.harness.sessionId}-new`);
|
|
651
|
+
const arrival = await table.harness.join(`${table.harness.sessionId}-new`);
|
|
576
652
|
check(
|
|
577
653
|
ctx,
|
|
578
654
|
'a named session still clears for a lone arrival',
|
|
579
655
|
ctx.snapshot(table.harness, arrival.playerId),
|
|
580
|
-
freshGame(ctx)
|
|
656
|
+
await freshGame(ctx)
|
|
581
657
|
);
|
|
582
658
|
}
|
|
583
659
|
|
|
584
660
|
// Two sessions must not share a table. A module-level game object is the usual
|
|
585
661
|
// way this goes wrong.
|
|
586
|
-
function sessionsDoNotLeak(ctx) {
|
|
587
|
-
const a = playedTable(ctx);
|
|
588
|
-
const b = seatedTable(ctx);
|
|
662
|
+
async function sessionsDoNotLeak(ctx) {
|
|
663
|
+
const a = await playedTable(ctx);
|
|
664
|
+
const b = await seatedTable(ctx);
|
|
589
665
|
check(
|
|
590
666
|
ctx,
|
|
591
667
|
'a second session has its own table',
|
|
592
668
|
ctx.snapshot(b.harness, b.seated[0].playerId),
|
|
593
|
-
freshGame(ctx)
|
|
669
|
+
await freshGame(ctx)
|
|
594
670
|
);
|
|
595
671
|
checkNot(
|
|
596
672
|
ctx,
|
|
597
673
|
'...and the first one still has its game',
|
|
598
674
|
ctx.snapshot(a.harness, a.seated[0].playerId),
|
|
599
|
-
freshGame(ctx)
|
|
675
|
+
await freshGame(ctx)
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// --- persistence ----------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
async function movesAreSaved(ctx) {
|
|
682
|
+
const sessionId = `check-${++counter}`;
|
|
683
|
+
const { harness } = await accountTable(ctx, { mode: 'named', sessionId });
|
|
684
|
+
await settleStorage();
|
|
685
|
+
const rows = [...harness.storage.rows.values()];
|
|
686
|
+
check(ctx, 'an accepted move is saved, under one key', rows.length, 1);
|
|
687
|
+
checkNot(ctx, '...with the game in it', rows[0]?.game ?? null, null);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// The grace window is over and the connection is a different one, so only the
|
|
691
|
+
// account can put this player back in their seat.
|
|
692
|
+
async function seatComesBackToTheAccount(ctx, clock) {
|
|
693
|
+
const sessionId = `check-${++counter}`;
|
|
694
|
+
const { harness, seated } = await accountTable(ctx, { mode: 'named', sessionId });
|
|
695
|
+
const before = ctx.snapshot(harness, seated[0].playerId);
|
|
696
|
+
const seat = ctx.seatOf(harness, seated[0].playerId);
|
|
697
|
+
await harness.leave(seated[0]);
|
|
698
|
+
clock.advance(ctx.graceMs + 1);
|
|
699
|
+
const back = await harness.join(`${sessionId}-again`, { userId: 'u0' });
|
|
700
|
+
check(
|
|
701
|
+
ctx,
|
|
702
|
+
'a new connection from the same account takes that seat back',
|
|
703
|
+
ctx.seatOf(harness, back.playerId),
|
|
704
|
+
seat
|
|
600
705
|
);
|
|
706
|
+
check(ctx, '...and the game with it', ctx.snapshot(harness, back.playerId), before);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// The user's rule: joining again while your own play is running is an arrival.
|
|
710
|
+
async function aSecondConnectionDoesNotTakeTheSeat(ctx) {
|
|
711
|
+
const sessionId = `check-${++counter}`;
|
|
712
|
+
const { harness, seated } = await accountTable(ctx, { mode: 'named', sessionId });
|
|
713
|
+
const held = ctx.seatOf(harness, seated[0].playerId);
|
|
714
|
+
const second = await harness.join(`${sessionId}-tab2`, { userId: 'u0' });
|
|
715
|
+
check(
|
|
716
|
+
ctx,
|
|
717
|
+
'a second connection leaves the seat with the connection holding it',
|
|
718
|
+
ctx.seatOf(harness, seated[0].playerId),
|
|
719
|
+
held
|
|
720
|
+
);
|
|
721
|
+
checkNot(ctx, '...and arrives as someone else', ctx.seatOf(harness, second.playerId), held);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// A fresh import of the deck's server is a restarted process: new tables, same
|
|
725
|
+
// storage.
|
|
726
|
+
async function restartedInto(ctx, options) {
|
|
727
|
+
const server = await ctx.reload();
|
|
728
|
+
return createSessionHarness(server, options);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const NO_RELOAD = 'no `reload` hook';
|
|
732
|
+
|
|
733
|
+
async function namedRoomComesBack(ctx) {
|
|
734
|
+
const name = 'a named room comes back on a fresh process';
|
|
735
|
+
if (!ctx.reload) {
|
|
736
|
+
skip(ctx, name, NO_RELOAD);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const options = { mode: 'named', sessionId: `check-${++counter}`, storage: createTestStorage() };
|
|
740
|
+
const { harness, seated } = await accountTable(ctx, options);
|
|
741
|
+
const before = ctx.snapshot(harness, seated[0].playerId);
|
|
742
|
+
const seat = ctx.seatOf(harness, seated[0].playerId);
|
|
743
|
+
await leaveAll({ harness, seated });
|
|
744
|
+
await settleStorage();
|
|
745
|
+
|
|
746
|
+
const restarted = await restartedInto(ctx, options);
|
|
747
|
+
const back = await restarted.join('later-connection', { userId: 'u0' });
|
|
748
|
+
check(ctx, name, ctx.snapshot(restarted, back.playerId), before);
|
|
749
|
+
check(ctx, '...seat and all', ctx.seatOf(restarted, back.playerId), seat);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function publicRoomComesBackInsideTheWindow(ctx, clock) {
|
|
753
|
+
const name = 'a public room inside the idle window comes back';
|
|
754
|
+
if (!ctx.reload) {
|
|
755
|
+
skip(ctx, name, NO_RELOAD);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const options = { mode: 'public', sessionId: `check-${++counter}`, storage: createTestStorage() };
|
|
759
|
+
const { harness, seated } = await accountTable(ctx, options);
|
|
760
|
+
const before = ctx.snapshot(harness, seated[0].playerId);
|
|
761
|
+
await leaveAll({ harness, seated });
|
|
762
|
+
await settleStorage();
|
|
763
|
+
clock.advance(Math.max(1, Math.floor(ctx.idleMs / 2)));
|
|
764
|
+
|
|
765
|
+
const restarted = await restartedInto(ctx, options);
|
|
766
|
+
const back = await restarted.join('later-connection', { userId: 'u0' });
|
|
767
|
+
check(ctx, name, ctx.snapshot(restarted, back.playerId), before);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function publicRoomStartsCleanPastTheWindow(ctx, clock) {
|
|
771
|
+
const name = 'a public room past the idle window starts clean';
|
|
772
|
+
if (!ctx.reload) {
|
|
773
|
+
skip(ctx, name, NO_RELOAD);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const options = { mode: 'public', sessionId: `check-${++counter}`, storage: createTestStorage() };
|
|
777
|
+
const { harness, seated } = await accountTable(ctx, options);
|
|
778
|
+
await leaveAll({ harness, seated });
|
|
779
|
+
await settleStorage();
|
|
780
|
+
clock.advance(ctx.idleMs + 1);
|
|
781
|
+
|
|
782
|
+
const restarted = await restartedInto(ctx, options);
|
|
783
|
+
const back = await restarted.join('later-connection', { userId: 'u0' });
|
|
784
|
+
check(ctx, name, ctx.snapshot(restarted, back.playerId), await freshGame(ctx));
|
|
601
785
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castle-web-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.171",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"castle-web": "./dist/index.js"
|
|
@@ -41,7 +41,6 @@
|
|
|
41
41
|
"@xterm/headless": "^6.0.0",
|
|
42
42
|
"@xterm/xterm": "^6.0.0",
|
|
43
43
|
"codemirror": "^6.0.2",
|
|
44
|
-
"dockview": "^4.13.1",
|
|
45
44
|
"html2canvas": "^1.4.1",
|
|
46
45
|
"marked": "^18.0.5",
|
|
47
46
|
"nanoid": "^5.1.7",
|