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
@@ -37,8 +37,24 @@ This kit adds no engine and no editors. What it holds:
37
37
  `createRoom({ seats, idleMs, graceMs })` from
38
38
  `@imports/castle.turn-based/room.js`. Copy the kit's server as the starting
39
39
  point -- it is the shape a game of this kind has.
40
- - `room.join(session, player)` and `room.leave(session, player)` both return
41
- `{ table }`. One table per `session.sessionId`, made on demand.
40
+ - `room.joinAsync(session, player)` and `room.leave(session, player)` return
41
+ `{ table }`. One table per `session.sessionId`, made on demand. `joinAsync` is
42
+ the join to use: it reads the saved table first, which the synchronous
43
+ `room.join` cannot.
44
+ - **A claimed square shows its holder's avatar.** The state carries `users`,
45
+ seat -> userId, and `Board.jsx` swaps the square's `Sprite.file` for the
46
+ avatar from `castle.physics-2d`'s `engine/avatarArt.js`; a holder with no
47
+ photo keeps the seat tint.
48
+ - **A room comes back.** `table.game`, the seat each account held, and the time
49
+ of the last accepted move are saved to deck storage on every accepted move, on
50
+ every leave, and from `room.save(session)` in `onShutdown`. Storage belongs to
51
+ a published deck; a serve brokers it as the deck's creator, so a kit served as
52
+ its own deck has none.
53
+ - One row per room -- `table:<sessionId>`, or `table:public` shared by every
54
+ public shard. `named` and `party` always resume; a public room resumes only
55
+ while its last move is inside `idleMs`. A seat goes back to the `userId` that
56
+ held it, on whatever connection; a second connection from an account that is
57
+ still seated is an arrival, and watches or takes a free seat.
42
58
  - **The deck owns its game.** `table.game` starts null and a clear puts it back
43
59
  to null, so the deck's join handler is `if (!table.game) { table.game = ... }`.
44
60
  The kit never builds or reads it.
@@ -96,18 +112,26 @@ function freshGame() {
96
112
  }
97
113
 
98
114
  export default {
99
- onPlayerJoin(session, player) {
100
- const { table } = room.join(session, player);
115
+ async onPlayerJoin(session, player) {
116
+ const { table } = await room.joinAsync(session, player);
101
117
  if (!table.game) {
102
118
  table.game = freshGame();
103
119
  }
104
120
  push(session, table);
105
121
  },
106
- onPlayerLeave(session, player) {
107
- push(session, room.leave(session, player).table);
122
+ async onPlayerLeave(session, player) {
123
+ const { table, saved } = room.leave(session, player);
124
+ push(session, table);
125
+ await saved;
126
+ },
127
+ async onShutdown(session) {
128
+ await room.save(session);
108
129
  },
109
130
  onMessage(session, player, data) {
110
131
  const table = room.table(session);
132
+ if (!table.game) {
133
+ return; // still reading the saved table
134
+ }
111
135
  // ... validate against table.seatOf(player.playerId) and table.game,
112
136
  // then table.keepAlive() only once the move is accepted.
113
137
  },
@@ -131,9 +155,15 @@ export default {
131
155
  ### `room`
132
156
 
133
157
  - `room.table(session)` -- the table for this session, made if new.
134
- - `room.join(session, player)` -> `{ table }`. Everyone past the last seat
135
- watches.
136
- - `room.leave(session, player)` -> `{ table }`.
158
+ - `room.joinAsync(session, player)` -> `Promise<{ table }>`. Reads the saved
159
+ table on the first join into this process, then seats the player. Everyone
160
+ past the last seat watches.
161
+ - `room.join(session, player)` -> `{ table }`. The same join without the read,
162
+ for a deck that does not want persistence.
163
+ - `room.leave(session, player)` -> `{ table, saved }`. `saved` is the write this
164
+ leave started; it never rejects.
165
+ - `room.save(session)` -> a promise that never rejects. What `onShutdown` calls.
166
+ - `room.restore(session)` -> the read `joinAsync` does, on its own.
137
167
 
138
168
  ### `table`
139
169
 
@@ -148,19 +178,30 @@ export default {
148
178
  copies the name into its own state when it deals.
149
179
  - `table.seatNames()` -> `{ [seat]: name | null }`, ready to send.
150
180
  - `table.spectatorCount(session)` -- people in the session holding no seat.
151
- - `table.keepAlive()` -- stamp the idle clock. **Past every rejection, never
152
- before one.** A rematch is the deck's own `table.game = fresh()` plus this.
181
+ - `table.keepAlive()` -- stamp the idle clock and save the table. **Past every
182
+ rejection, never before one.** A rematch is the deck's own
183
+ `table.game = fresh()` plus this. It returns the save, which a caller that can
184
+ await one does; a failed write is logged and never thrown into the game loop.
185
+ - `table.seatsByUserId` -- the seat each account last held. Saved with the game.
153
186
  - `table.lastActivityAt`, `table.sessionId`, `table.seats`, `table.departed` are
154
187
  readable for a status line or a test.
155
188
 
156
189
  ## The rules, stated
157
190
 
191
+ On the **first join** into a process that has no table for this session,
192
+ `joinAsync` reads the saved room first. A `named` room and a party always
193
+ resume. A public room resumes only while its saved last move is inside `idleMs`;
194
+ past that it starts clean. The join rules below then run over whatever came back,
195
+ which is why a stranger walking alone into a resumed room still clears it.
196
+
158
197
  On **join**, in this order:
159
198
 
160
199
  1. Expired departure records are pruned.
161
200
  2. If this player already holds a seat, or has a departure record inside
162
- `graceMs` whose seat is still free, they get it back and this join is a
163
- **return**.
201
+ `graceMs` whose seat is still free, or last held a free seat under the same
202
+ `userId`, they get it back and this join is a **return**. The account lookup
203
+ is what a page reload and a restarted server both come back through; in a
204
+ public room it is bounded by `idleMs` too.
164
205
  3. The table is cleared iff: the mode is not `party`, AND this is not a return,
165
206
  AND either (a) they arrived alone, or (b) fewer than `minPlayers` seats are
166
207
  held by present players and nothing has been accepted for `idleMs`. A clear
@@ -169,8 +210,12 @@ On **join**, in this order:
169
210
  4. They take the first free seat in declared order, or watch.
170
211
 
171
212
  On **leave**: their seat, if any, is recorded in `departed` and freed; every
172
- other present player without a seat is moved into one, in roster order; and
173
- nothing else -- `game` is not touched.
213
+ other present player without a seat is moved into one, in roster order; the table
214
+ is saved; and nothing else -- `game` is not touched.
215
+
216
+ A second connection from an account that is still seated finds its own seat
217
+ taken, so it arrives as a stranger and watches or takes a free one. It never
218
+ takes the seat over.
174
219
 
175
220
  Promotion is why a present person outranks a seat held for a connection that may
176
221
  never come back, and step 2 of join gives way once the seat is taken. It is also
@@ -230,9 +275,10 @@ import { checkRoom, printRoomChecks } from '@imports/castle.turn-based/testing.j
230
275
 
231
276
  process.exit(
232
277
  printRoomChecks(
233
- checkRoom({
278
+ await checkRoom({
234
279
  server,
235
280
  seats: 2,
281
+ reload: () => import(`../server/index.js?fresh=${Date.now()}`).then((m) => m.default),
236
282
  idleMs: 3 * 60 * 1000,
237
283
  graceMs: 45 * 1000,
238
284
  play: (h, seated) => {
@@ -252,6 +298,13 @@ process.exit(
252
298
  );
253
299
  ```
254
300
 
301
+ `checkRoom` returns a promise, and `harness.join` / `harness.leave` do too: a
302
+ join reads storage. Await them.
303
+
304
+ `reload` returns a fresh import of the deck's server, which is a restarted
305
+ process -- new tables over the same storage. The three resume checks need it and
306
+ are skipped without it.
307
+
255
308
  Two hooks are worth reading twice:
256
309
 
257
310
  - `snapshot` is the **public projection of the game**, not the player's own view
@@ -267,23 +320,30 @@ Two hooks are worth reading twice:
267
320
  is handed to refreshes the idle clock and fails the check that says refused
268
321
  messages do not.
269
322
 
270
- Twenty-three checks, covering seating and spectators, promotion, the grace
323
+ Thirty-three checks, covering seating and spectators, promotion, the grace
271
324
  window, present-beats-ghost, lone arrivals, the idle window, the long think, the
272
- `party` / `named` / `public` split, and that two sessions do not share a table.
325
+ `party` / `named` / `public` split, that two sessions do not share a table, and
326
+ the resume rules -- a move is saved, a seat comes back to its account, a second
327
+ connection does not take it, and a named or recent public room comes back on a
328
+ fresh process.
273
329
  Run against the tic-tac-toe deck as it shipped BEFORE this kit existed, it
274
330
  reports six failures, all from one line that cleared the board when a seated
275
331
  player dropped.
276
332
 
277
333
  The rest of `testing.js` stands on its own:
278
334
 
279
- - `createSessionHarness(server, { mode, sessionId, deckId })` ->
335
+ - `createSessionHarness(server, { mode, sessionId, deckId, storage })` ->
280
336
  `{ session, start(), join(playerId, identity?), leave(player),
281
- message(player, data), sent(playerId), last(playerId, match?), outbox }`. It
337
+ message(player, data), shutdown(), sent(playerId), last(playerId, match?),
338
+ outbox, storage }`. `join` and `leave` are async. It
282
339
  mirrors the real wrapper where it counts: roster timing around the callbacks,
283
340
  keying by playerId, and JSON round-tripping every message so a payload holding
284
341
  a live reference to server state arrives as a copy.
285
342
  - `createTestClock(startAt?)` -> `{ now, advance(ms), set(at), install(),
286
343
  uninstall() }`. `install()` points the kit's clock at it.
344
+ - `createTestStorage()` -> deck storage in memory, with its rows readable at
345
+ `.rows`. Each harness makes its own; two harnesses handed the same one are two
346
+ processes of one deck. `settleStorage()` waits for the saves nobody awaited.
287
347
 
288
348
  It is not a transport. A two-client game over a real `castle-web serve` is still
289
349
  the thing that proves a deck -- `tic-tac-toe/tools/live.mjs` is a
@@ -17,7 +17,7 @@
17
17
  "imports": {
18
18
  "castle.physics-2d": {
19
19
  "deckId": "ckRZGFW4iPrx",
20
- "version": "2026-09-10T00:57:31.408Z"
20
+ "version": "2026-09-10T20:21:12.665Z"
21
21
  },
22
22
  "castle.base": {
23
23
  "deckId": "yRcmH4_aYllE",
@@ -28,7 +28,7 @@
28
28
  "autoUpdateWhenImported": true,
29
29
  "deckId": "K6-Xs7dCoMVi",
30
30
  "cardId": "Kh_08IYATpC3",
31
- "publishedVersion": "2026-09-10T00:58:22.840Z",
31
+ "publishedVersion": "2026-09-10T20:28:55.394Z",
32
32
  "server": {
33
33
  "main": "code/server/index.js",
34
34
  "maxPlayers": 8,
@@ -3,8 +3,10 @@
3
3
  // multiplayer session, send square claims, tint actors and format status text.
4
4
 
5
5
  import { Multiplayer } from 'castle-web-sdk';
6
+ import { avatarSpritePath } from '@imports/castle.physics-2d/engine/avatarArt';
6
7
 
7
- // Sprite tint multiplies these seat colors over each square's own artwork.
8
+ // A claimed square shows its holder's avatar. Until that has loaded, or when the
9
+ // holder has none, the seat's tint multiplies the square's own artwork instead.
8
10
  const SEAT_TINT = { A: '#ea323cff', B: '#0cf1ffff' };
9
11
  const UNCLAIMED_TINT = '#ffffffff';
10
12
 
@@ -130,10 +132,18 @@ function paintSquares(scene, state) {
130
132
  for (const squareActor of scene.actorsWith('Square')) {
131
133
  // Square.index selects the same slot in the server's sixteen-element array.
132
134
  const seat = state.squares[squareActor.components.Square.index];
133
- // Sprite tint multiplies the editable `drawings/square.sprite` pixels.
134
- // White preserves their colors; a seat tint displays the square's owner.
135
- // A game with separate pieces can spawn piece actors over these square actors.
136
- squareActor.components.Sprite.tint = seat ? SEAT_TINT[seat] : UNCLAIMED_TINT;
135
+ const sprite = squareActor.components.Sprite;
136
+
137
+ // The square's own art is remembered the first time, since `file` is
138
+ // swapped for an avatar below and back again when the square is cleared.
139
+ squareActor.runtime.squareFile ??= sprite.file;
140
+ const avatar = seat ? avatarSpritePath(scene, state.users?.[seat]) : null;
141
+ sprite.file = avatar ?? squareActor.runtime.squareFile;
142
+
143
+ // Sprite tint multiplies the pixels. White preserves their colors; a seat
144
+ // tint displays the owner of a square without an avatar. A game with
145
+ // separate pieces can spawn piece actors over these square actors.
146
+ sprite.tint = seat && !avatar ? SEAT_TINT[seat] : UNCLAIMED_TINT;
137
147
  }
138
148
  }
139
149
 
@@ -91,6 +91,7 @@ function stateFor(session, table, playerId) {
91
91
  turn: table.game.turn,
92
92
  winner: table.game.winner,
93
93
  names: table.seatNames(),
94
+ users: table.seatUsers(),
94
95
  watching: table.spectatorCount(session),
95
96
  you: table.seatOf(playerId),
96
97
  };
@@ -110,9 +111,10 @@ export default {
110
111
  room.table(session);
111
112
  },
112
113
 
113
- onPlayerJoin(session, player) {
114
- // Room.join restores or assigns a seat and may clear an idle table.
115
- const { table } = room.join(session, player);
114
+ async onPlayerJoin(session, player) {
115
+ // Room.joinAsync reads the saved table on the first join, then restores or
116
+ // assigns a seat and may clear an idle table.
117
+ const { table } = await room.joinAsync(session, player);
116
118
 
117
119
  // New and cleared tables have null game state for the deck to initialize.
118
120
  if (!table.game) {
@@ -121,16 +123,26 @@ export default {
121
123
  pushState(session, table);
122
124
  },
123
125
 
124
- onPlayerLeave(session, player) {
126
+ async onPlayerLeave(session, player) {
125
127
  // Room.leave releases the seat and may promote a spectator. It preserves
126
128
  // `table.game`, so remaining players continue the same game.
127
- const { table } = room.leave(session, player);
129
+ const { table, saved } = room.leave(session, player);
128
130
  pushState(session, table);
131
+ await saved;
132
+ },
133
+
134
+ async onShutdown(session) {
135
+ await room.save(session);
129
136
  },
130
137
 
131
138
  onMessage(session, player, data) {
132
139
  // Room.table makes the table on demand, so no null check is needed here.
133
140
  const table = room.table(session);
141
+
142
+ // A message can arrive while the first join is still reading the saved table.
143
+ if (!table.game) {
144
+ return;
145
+ }
134
146
  // A sync request returns only to its sender and does not update activity.
135
147
  if (data?.type === 'sync') {
136
148
  session.send(player.playerId, stateFor(session, table, player.playerId));
@@ -6,7 +6,8 @@
6
6
  "restart": "node /Users/nikki/Development/castle-xyz/castle-experimental-web/cli/dist/index.js restart .",
7
7
  "screenshot": "node /Users/nikki/Development/castle-xyz/castle-experimental-web/cli/dist/index.js screenshot .",
8
8
  "save-deck": "node /Users/nikki/Development/castle-xyz/castle-experimental-web/cli/dist/index.js save-deck .",
9
- "draw": "node imports/castle.physics-2d/scripts/draw.mjs"
9
+ "draw": "node imports/castle.physics-2d/scripts/draw.mjs",
10
+ "check": "eslint . && node --input-type=module -e \"const { bundleProject } = await import('../../cli/dist/bundle.js'); await bundleProject('.');\""
10
11
  },
11
12
  "dependencies": {
12
13
  "@codemirror/commands": "^6.10.3",
@@ -9,8 +9,12 @@
9
9
  // departure and promotes a watcher. Clearing happens on join, where there is
10
10
  // someone to hand a clean table to.
11
11
  //
12
- // Laid out as: the clock, then the room and its tables, then seats, then the
13
- // join and leave rules.
12
+ // A table is also saved to deck storage, so a room comes back after the process
13
+ // holding it ends. The kit stores `table.game` opaquely and still never reads
14
+ // inside it.
15
+ //
16
+ // Laid out as: the clock, then storage, then the room and its tables, then
17
+ // seats, then the join and leave rules.
14
18
 
15
19
  // --- the clock ------------------------------------------------------------
16
20
  //
@@ -23,6 +27,84 @@ export function setClock(fn) {
23
27
  readClock = fn ?? (() => Date.now());
24
28
  }
25
29
 
30
+ // --- storage --------------------------------------------------------------
31
+ //
32
+ // One row per room in the server-owned deck scope: the deck's game, the seat
33
+ // each account last held, and when the table was last played. Every public
34
+ // shard shares one row; a named room and a party each have their own.
35
+ //
36
+ // A deck has storage only once it is published, and a serve brokers it as the
37
+ // deck's creator. A kit served as its own deck has none; writes fail and are
38
+ // logged.
39
+
40
+ const STORAGE_PREFIX = 'table:';
41
+
42
+ function storageKey(session) {
43
+ return STORAGE_PREFIX + (session.mode === 'public' ? 'public' : session.sessionId);
44
+ }
45
+
46
+ function deckStorage(session) {
47
+ return session.storage?.deck ?? null;
48
+ }
49
+
50
+ function logStorageFailure(what, error) {
51
+ console.error(`turn-based: table ${what} failed: ${error?.message ?? error}`);
52
+ }
53
+
54
+ // Saves run one after another, so a slow write cannot land on a newer one. The
55
+ // returned promise never rejects.
56
+ function saveTable(session, table) {
57
+ const storage = deckStorage(session);
58
+ if (!storage) {
59
+ return Promise.resolve();
60
+ }
61
+ const row = {
62
+ game: table.game ?? null,
63
+ seatsByUserId: Object.fromEntries(table.seatsByUserId),
64
+ lastActivityAt: table.lastActivityAt,
65
+ };
66
+ table.saving = table.saving
67
+ .then(() => storage.set({ [storageKey(session)]: row }))
68
+ .catch((error) => logStorageFailure('save', error));
69
+ return table.saving;
70
+ }
71
+
72
+ // Read once per table: the first join into a process pays for it.
73
+ function restoreTable(settings, session, table) {
74
+ if (!table.restoring) {
75
+ table.restoring = readTable(settings, session, table);
76
+ }
77
+ return table.restoring;
78
+ }
79
+
80
+ // `named` and `party` always resume. A public room resumes only while its last
81
+ // accepted move is inside `idleMs`.
82
+ async function readTable(settings, session, table) {
83
+ const storage = deckStorage(session);
84
+ if (!storage) {
85
+ return;
86
+ }
87
+ const key = storageKey(session);
88
+ let row = null;
89
+ try {
90
+ row = (await storage.get([key]))?.[key] ?? null;
91
+ } catch (error) {
92
+ logStorageFailure('restore', error);
93
+ return;
94
+ }
95
+
96
+ // A game built while the read was in flight is the newer one.
97
+ if (!row || table.game) {
98
+ return;
99
+ }
100
+ if (session.mode === 'public' && readClock() - (row.lastActivityAt ?? 0) > settings.idleMs) {
101
+ return;
102
+ }
103
+ table.game = row.game ?? null;
104
+ table.seatsByUserId = new Map(Object.entries(row.seatsByUserId ?? {}));
105
+ table.lastActivityAt = row.lastActivityAt ?? table.lastActivityAt;
106
+ }
107
+
26
108
  // --- the room -------------------------------------------------------------
27
109
 
28
110
  // `config`:
@@ -44,7 +126,15 @@ export function createRoom(config) {
44
126
  return {
45
127
  table,
46
128
  join: (session, player) => joinTable(table(session), settings, session, player),
129
+
130
+ // Reads the saved table first. `join` is the same without the read.
131
+ joinAsync: async (session, player) => {
132
+ await restoreTable(settings, session, table(session));
133
+ return joinTable(table(session), settings, session, player);
134
+ },
47
135
  leave: (session, player) => leaveTable(table(session), settings, session, player),
136
+ restore: (session) => restoreTable(settings, session, table(session)),
137
+ save: (session) => saveTable(session, table(session)),
48
138
  };
49
139
  }
50
140
 
@@ -55,14 +145,14 @@ function tableFor(tables, settings, session) {
55
145
  if (existing) {
56
146
  return existing;
57
147
  }
58
- const table = createTable(settings, session.sessionId);
148
+ const table = createTable(settings, session);
59
149
  tables.set(session.sessionId, table);
60
150
  return table;
61
151
  }
62
152
 
63
- function createTable(settings, sessionId) {
153
+ function createTable(settings, session) {
64
154
  const table = {
65
- sessionId,
155
+ sessionId: session.sessionId,
66
156
 
67
157
  // The deck's own game, null until the deck makes one and again after a
68
158
  // clear. The kit never reads it.
@@ -75,19 +165,30 @@ function createTable(settings, sessionId) {
75
165
 
76
166
  // playerId -> { seat, holder, at }, for the grace window.
77
167
  departed: new Map(),
168
+
169
+ // userId -> seat, so a player coming back on a new connection finds it.
170
+ seatsByUserId: new Map(),
171
+
172
+ // The tail of the save chain, and the one read of the saved table.
173
+ saving: Promise.resolve(),
174
+ restoring: null,
78
175
  };
79
176
  return Object.assign(table, {
80
177
  seatOf: (playerId) => seatOf(table, playerId),
81
178
  playerAt: (seat) => table.seats[seat] ?? null,
82
179
  seatNames: () =>
83
180
  Object.fromEntries(settings.seats.map((seat) => [seat, table.seats[seat]?.name ?? null])),
181
+ seatUsers: () =>
182
+ Object.fromEntries(settings.seats.map((seat) => [seat, table.seats[seat]?.userId ?? null])),
84
183
  spectatorCount: (session) => spectatorCount(table, session),
85
184
 
86
185
  // Call this past every rejection, never before one. A refused message is not
87
186
  // a sign of life, and a client able to refresh the clock with junk could keep
88
187
  // a dead table alive indefinitely.
188
+ // Returns the save it starts, so a caller that can await one does.
89
189
  keepAlive: () => {
90
190
  table.lastActivityAt = readClock();
191
+ return saveTable(session, table);
91
192
  },
92
193
  });
93
194
  }
@@ -119,6 +220,14 @@ function seatEntry(settings, player) {
119
220
  };
120
221
  }
121
222
 
223
+ // A `playerId` is a connection and does not survive a page load; the account
224
+ // is what a returning player is found by.
225
+ function rememberSeat(table, player, seat) {
226
+ if (typeof player.userId === 'string' && player.userId) {
227
+ table.seatsByUserId.set(player.userId, seat);
228
+ }
229
+ }
230
+
122
231
  // A seat is held by a connection, not an account. One account joining twice
123
232
  // from two tabs is two players at the table.
124
233
  function takeSeat(settings, table, player) {
@@ -130,6 +239,7 @@ function takeSeat(settings, table, player) {
130
239
  continue;
131
240
  }
132
241
  table.seats[seat] = seatEntry(settings, player);
242
+ rememberSeat(table, player, seat);
133
243
  return seat;
134
244
  }
135
245
  return null;
@@ -161,31 +271,59 @@ function othersIn(session, player) {
161
271
  //
162
272
  // `playerId` survives a transport reconnect (see CLAUDE.md, "What a reconnect
163
273
  // is"), so a blipped connection comes back as the same player.
164
- function reclaimSeat(settings, table, player) {
274
+ function reclaimSeat(settings, table, session, player) {
165
275
  // Still seated, so nothing was lost.
166
276
  if (seatOf(table, player.playerId)) {
167
277
  return true;
168
278
  }
279
+ const seat =
280
+ reclaimByConnection(settings, table, player) ??
281
+ reclaimByAccount(settings, table, session, player);
282
+ if (!seat) {
283
+ return false;
284
+ }
285
+ rememberSeat(table, player, seat);
286
+ return true;
287
+ }
169
288
 
289
+ // The seat this connection left, or null.
290
+ function reclaimByConnection(settings, table, player) {
170
291
  const record = table.departed.get(player.playerId);
171
292
  if (!record) {
172
- return false;
293
+ return null;
173
294
  }
174
295
 
175
296
  // The record is spent either way. A player who missed the window is an
176
297
  // arrival, and arriving twice should not consume a second grace period.
177
298
  table.departed.delete(player.playerId);
178
299
  if (readClock() - record.at > settings.graceMs) {
179
- return false;
300
+ return null;
180
301
  }
181
302
 
182
303
  // A watcher was promoted into the seat while this player was away.
183
304
  if (table.seats[record.seat]) {
184
- return false;
305
+ return null;
185
306
  }
186
307
 
187
308
  table.seats[record.seat] = record.holder;
188
- return true;
309
+ return record.seat;
310
+ }
311
+
312
+ // The seat goes back to the account that held it, on whatever connection it
313
+ // arrives on. A second connection from someone still seated finds the seat
314
+ // taken and arrives as a stranger.
315
+ function reclaimByAccount(settings, table, session, player) {
316
+ const seat = table.seatsByUserId.get(player.userId);
317
+ if (!seat || table.seats[seat]) {
318
+ return null;
319
+ }
320
+
321
+ // A public room hands a seat back only inside the idle window.
322
+ if (session.mode === 'public' && readClock() - table.lastActivityAt > settings.idleMs) {
323
+ return null;
324
+ }
325
+ table.seats[seat] = seatEntry(settings, player);
326
+ return seat;
189
327
  }
190
328
 
191
329
  // Called on both join and leave, so the map cannot grow across a long session.
@@ -244,6 +382,18 @@ function clearTable(settings, table, present) {
244
382
  table.seats[seat] = null;
245
383
  }
246
384
  }
385
+ rememberHeldSeats(settings, table);
386
+ }
387
+
388
+ // After a clear the account map names only the seats that survived it.
389
+ function rememberHeldSeats(settings, table) {
390
+ table.seatsByUserId = new Map();
391
+ for (const seat of settings.seats) {
392
+ const holder = table.seats[seat];
393
+ if (holder) {
394
+ rememberSeat(table, holder, seat);
395
+ }
396
+ }
247
397
  }
248
398
 
249
399
  // Returns `{ table }`. A deck knows to build a fresh game from `table.game`
@@ -256,7 +406,7 @@ function joinTable(table, settings, session, player) {
256
406
  const others = othersIn(session, player);
257
407
 
258
408
  // Before the clear decision, which asks whether this is a return.
259
- const returning = reclaimSeat(settings, table, player);
409
+ const returning = reclaimSeat(settings, table, session, player);
260
410
 
261
411
  if (shouldClear(settings, table, others, returning, session.mode)) {
262
412
  clearTable(settings, table, others);
@@ -268,7 +418,8 @@ function joinTable(table, settings, session, player) {
268
418
  return { table };
269
419
  }
270
420
 
271
- // Returns `{ table }`. It does not touch `game`; see the file header.
421
+ // Returns `{ table, saved }`, where `saved` is the write this leave started. It
422
+ // does not touch `game`; see the file header.
272
423
  function leaveTable(table, settings, session, player) {
273
424
  const seat = seatOf(table, player.playerId);
274
425
  if (seat) {
@@ -285,5 +436,5 @@ function leaveTable(table, settings, session, player) {
285
436
  takeSeat(settings, table, other);
286
437
  }
287
438
  pruneDeparted(settings, table);
288
- return { table };
439
+ return { table, saved: saveTable(session, table) };
289
440
  }