castle-web-cli 0.4.170 → 0.4.172

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.
Files changed (58) hide show
  1. package/dist/agent-prompts.d.ts +1 -0
  2. package/dist/agent-prompts.js +3 -0
  3. package/dist/agent.js +47 -11
  4. package/dist/castle-host/host.js +71 -0
  5. package/dist/devSessionServer.js +9 -5
  6. package/dist/ide.d.ts +10 -0
  7. package/dist/ide.js +5 -5
  8. package/dist/init.js +1 -1
  9. package/dist/shell/assets/index-6odVZQSZ.css +1 -0
  10. package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
  11. package/dist/shell/index.html +3 -3
  12. package/kits/base/castle.json +1 -1
  13. package/kits/base/sdk/README.md +17 -1
  14. package/kits/base/sdk/commands.d.ts +14 -0
  15. package/kits/base/sdk/user.d.ts +4 -0
  16. package/kits/base/sdk/user.js +36 -1
  17. package/kits/multiplayer-2d/CLAUDE.md +24 -9
  18. package/kits/multiplayer-2d/behaviors/Box.jsx +32 -9
  19. package/kits/multiplayer-2d/castle.json +3 -3
  20. package/kits/multiplayer-2d/code/client/avatars.js +6 -1
  21. package/kits/multiplayer-2d/code/server/players.js +27 -6
  22. package/kits/multiplayer-2d/code/server/world.js +24 -1
  23. package/kits/multiplayer-2d/code/systems/multiplayer.js +47 -4
  24. package/kits/multiplayer-2d/package-lock.json +26 -1
  25. package/kits/multiplayer-2d/package.json +2 -0
  26. package/kits/multiplayer-3d/CLAUDE.md +32 -9
  27. package/kits/multiplayer-3d/castle.json +4 -4
  28. package/kits/multiplayer-3d/code/client/avatars.js +15 -11
  29. package/kits/multiplayer-3d/code/client/nameTags.js +35 -16
  30. package/kits/multiplayer-3d/code/server/players.js +54 -5
  31. package/kits/multiplayer-3d/code/server/world.js +45 -1
  32. package/kits/multiplayer-3d/code/systems/multiplayer.js +112 -7
  33. package/kits/multiplayer-3d/package-lock.json +26 -1
  34. package/kits/multiplayer-3d/package.json +2 -0
  35. package/kits/physics-2d/castle.json +1 -1
  36. package/kits/physics-2d/editors/PxArtEditor.jsx +2 -2
  37. package/kits/physics-2d/engine/avatarArt.js +91 -0
  38. package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
  39. package/kits/physics-3d/castle.json +1 -1
  40. package/kits/real-time/CLAUDE.md +23 -4
  41. package/kits/real-time/castle.json +1 -1
  42. package/kits/real-time/code/client/connection.js +15 -4
  43. package/kits/real-time/code/client/joinOverlay.js +46 -0
  44. package/kits/real-time/code/client/messages.js +4 -0
  45. package/kits/real-time/code/server/gameHooks.js +4 -0
  46. package/kits/real-time/code/server/persist.js +105 -0
  47. package/kits/real-time/code/server/session.js +295 -19
  48. package/kits/real-time/package-lock.json +1139 -0
  49. package/kits/turn-based/CLAUDE.md +80 -20
  50. package/kits/turn-based/castle.json +2 -2
  51. package/kits/turn-based/code/behaviors/Board.jsx +15 -5
  52. package/kits/turn-based/code/server/index.js +17 -5
  53. package/kits/turn-based/package.json +2 -1
  54. package/kits/turn-based/room.js +164 -13
  55. package/kits/turn-based/testing.js +276 -92
  56. package/package.json +1 -2
  57. package/dist/shell/assets/index-BvQmVwlO.css +0 -1
  58. 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. Callbacks run synchronously and a returned promise is
53
- // not awaited; a two-client game over the real socket is the wider test.
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.170",
3
+ "version": "0.4.172",
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",