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.
Files changed (51) 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/ide.d.ts +10 -0
  6. package/dist/ide.js +5 -5
  7. package/dist/init.js +1 -1
  8. package/dist/shell/assets/index-6odVZQSZ.css +1 -0
  9. package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
  10. package/dist/shell/index.html +3 -3
  11. package/kits/base/castle.json +1 -1
  12. package/kits/base/sdk/README.md +17 -1
  13. package/kits/base/sdk/commands.d.ts +14 -0
  14. package/kits/base/sdk/user.d.ts +4 -0
  15. package/kits/base/sdk/user.js +36 -1
  16. package/kits/multiplayer-2d/CLAUDE.md +20 -9
  17. package/kits/multiplayer-2d/castle.json +2 -2
  18. package/kits/multiplayer-2d/code/server/players.js +27 -6
  19. package/kits/multiplayer-2d/code/server/world.js +24 -1
  20. package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
  21. package/kits/multiplayer-2d/package-lock.json +26 -1
  22. package/kits/multiplayer-2d/package.json +2 -0
  23. package/kits/multiplayer-3d/CLAUDE.md +28 -9
  24. package/kits/multiplayer-3d/castle.json +3 -3
  25. package/kits/multiplayer-3d/code/server/players.js +54 -5
  26. package/kits/multiplayer-3d/code/server/world.js +45 -1
  27. package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
  28. package/kits/multiplayer-3d/package-lock.json +26 -1
  29. package/kits/multiplayer-3d/package.json +2 -0
  30. package/kits/physics-2d/castle.json +1 -1
  31. package/kits/physics-2d/editors/PxArtEditor.jsx +2 -2
  32. package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
  33. package/kits/physics-3d/castle.json +1 -1
  34. package/kits/real-time/CLAUDE.md +23 -4
  35. package/kits/real-time/castle.json +1 -1
  36. package/kits/real-time/code/client/connection.js +9 -4
  37. package/kits/real-time/code/client/joinOverlay.js +46 -0
  38. package/kits/real-time/code/client/messages.js +4 -0
  39. package/kits/real-time/code/server/gameHooks.js +4 -0
  40. package/kits/real-time/code/server/persist.js +105 -0
  41. package/kits/real-time/code/server/session.js +289 -19
  42. package/kits/real-time/package-lock.json +1139 -0
  43. package/kits/turn-based/CLAUDE.md +76 -20
  44. package/kits/turn-based/castle.json +1 -1
  45. package/kits/turn-based/code/server/index.js +16 -5
  46. package/kits/turn-based/package.json +2 -1
  47. package/kits/turn-based/room.js +162 -13
  48. package/kits/turn-based/testing.js +276 -92
  49. package/package.json +1 -2
  50. package/dist/shell/assets/index-BvQmVwlO.css +0 -1
  51. package/dist/shell/assets/index-CV5sBby1.js +0 -445
@@ -37,8 +37,20 @@ 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 room comes back.** `table.game`, the seat each account held, and the time
45
+ of the last accepted move are saved to deck storage on every accepted move, on
46
+ every leave, and from `room.save(session)` in `onShutdown`. Storage belongs to
47
+ a published deck; a serve brokers it as the deck's creator, so a kit served as
48
+ its own deck has none.
49
+ - One row per room -- `table:<sessionId>`, or `table:public` shared by every
50
+ public shard. `named` and `party` always resume; a public room resumes only
51
+ while its last move is inside `idleMs`. A seat goes back to the `userId` that
52
+ held it, on whatever connection; a second connection from an account that is
53
+ still seated is an arrival, and watches or takes a free seat.
42
54
  - **The deck owns its game.** `table.game` starts null and a clear puts it back
43
55
  to null, so the deck's join handler is `if (!table.game) { table.game = ... }`.
44
56
  The kit never builds or reads it.
@@ -96,18 +108,26 @@ function freshGame() {
96
108
  }
97
109
 
98
110
  export default {
99
- onPlayerJoin(session, player) {
100
- const { table } = room.join(session, player);
111
+ async onPlayerJoin(session, player) {
112
+ const { table } = await room.joinAsync(session, player);
101
113
  if (!table.game) {
102
114
  table.game = freshGame();
103
115
  }
104
116
  push(session, table);
105
117
  },
106
- onPlayerLeave(session, player) {
107
- push(session, room.leave(session, player).table);
118
+ async onPlayerLeave(session, player) {
119
+ const { table, saved } = room.leave(session, player);
120
+ push(session, table);
121
+ await saved;
122
+ },
123
+ async onShutdown(session) {
124
+ await room.save(session);
108
125
  },
109
126
  onMessage(session, player, data) {
110
127
  const table = room.table(session);
128
+ if (!table.game) {
129
+ return; // still reading the saved table
130
+ }
111
131
  // ... validate against table.seatOf(player.playerId) and table.game,
112
132
  // then table.keepAlive() only once the move is accepted.
113
133
  },
@@ -131,9 +151,15 @@ export default {
131
151
  ### `room`
132
152
 
133
153
  - `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 }`.
154
+ - `room.joinAsync(session, player)` -> `Promise<{ table }>`. Reads the saved
155
+ table on the first join into this process, then seats the player. Everyone
156
+ past the last seat watches.
157
+ - `room.join(session, player)` -> `{ table }`. The same join without the read,
158
+ for a deck that does not want persistence.
159
+ - `room.leave(session, player)` -> `{ table, saved }`. `saved` is the write this
160
+ leave started; it never rejects.
161
+ - `room.save(session)` -> a promise that never rejects. What `onShutdown` calls.
162
+ - `room.restore(session)` -> the read `joinAsync` does, on its own.
137
163
 
138
164
  ### `table`
139
165
 
@@ -148,19 +174,30 @@ export default {
148
174
  copies the name into its own state when it deals.
149
175
  - `table.seatNames()` -> `{ [seat]: name | null }`, ready to send.
150
176
  - `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.
177
+ - `table.keepAlive()` -- stamp the idle clock and save the table. **Past every
178
+ rejection, never before one.** A rematch is the deck's own
179
+ `table.game = fresh()` plus this. It returns the save, which a caller that can
180
+ await one does; a failed write is logged and never thrown into the game loop.
181
+ - `table.seatsByUserId` -- the seat each account last held. Saved with the game.
153
182
  - `table.lastActivityAt`, `table.sessionId`, `table.seats`, `table.departed` are
154
183
  readable for a status line or a test.
155
184
 
156
185
  ## The rules, stated
157
186
 
187
+ On the **first join** into a process that has no table for this session,
188
+ `joinAsync` reads the saved room first. A `named` room and a party always
189
+ resume. A public room resumes only while its saved last move is inside `idleMs`;
190
+ past that it starts clean. The join rules below then run over whatever came back,
191
+ which is why a stranger walking alone into a resumed room still clears it.
192
+
158
193
  On **join**, in this order:
159
194
 
160
195
  1. Expired departure records are pruned.
161
196
  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**.
197
+ `graceMs` whose seat is still free, or last held a free seat under the same
198
+ `userId`, they get it back and this join is a **return**. The account lookup
199
+ is what a page reload and a restarted server both come back through; in a
200
+ public room it is bounded by `idleMs` too.
164
201
  3. The table is cleared iff: the mode is not `party`, AND this is not a return,
165
202
  AND either (a) they arrived alone, or (b) fewer than `minPlayers` seats are
166
203
  held by present players and nothing has been accepted for `idleMs`. A clear
@@ -169,8 +206,12 @@ On **join**, in this order:
169
206
  4. They take the first free seat in declared order, or watch.
170
207
 
171
208
  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.
209
+ other present player without a seat is moved into one, in roster order; the table
210
+ is saved; and nothing else -- `game` is not touched.
211
+
212
+ A second connection from an account that is still seated finds its own seat
213
+ taken, so it arrives as a stranger and watches or takes a free one. It never
214
+ takes the seat over.
174
215
 
175
216
  Promotion is why a present person outranks a seat held for a connection that may
176
217
  never come back, and step 2 of join gives way once the seat is taken. It is also
@@ -230,9 +271,10 @@ import { checkRoom, printRoomChecks } from '@imports/castle.turn-based/testing.j
230
271
 
231
272
  process.exit(
232
273
  printRoomChecks(
233
- checkRoom({
274
+ await checkRoom({
234
275
  server,
235
276
  seats: 2,
277
+ reload: () => import(`../server/index.js?fresh=${Date.now()}`).then((m) => m.default),
236
278
  idleMs: 3 * 60 * 1000,
237
279
  graceMs: 45 * 1000,
238
280
  play: (h, seated) => {
@@ -252,6 +294,13 @@ process.exit(
252
294
  );
253
295
  ```
254
296
 
297
+ `checkRoom` returns a promise, and `harness.join` / `harness.leave` do too: a
298
+ join reads storage. Await them.
299
+
300
+ `reload` returns a fresh import of the deck's server, which is a restarted
301
+ process -- new tables over the same storage. The three resume checks need it and
302
+ are skipped without it.
303
+
255
304
  Two hooks are worth reading twice:
256
305
 
257
306
  - `snapshot` is the **public projection of the game**, not the player's own view
@@ -267,23 +316,30 @@ Two hooks are worth reading twice:
267
316
  is handed to refreshes the idle clock and fails the check that says refused
268
317
  messages do not.
269
318
 
270
- Twenty-three checks, covering seating and spectators, promotion, the grace
319
+ Thirty-three checks, covering seating and spectators, promotion, the grace
271
320
  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.
321
+ `party` / `named` / `public` split, that two sessions do not share a table, and
322
+ the resume rules -- a move is saved, a seat comes back to its account, a second
323
+ connection does not take it, and a named or recent public room comes back on a
324
+ fresh process.
273
325
  Run against the tic-tac-toe deck as it shipped BEFORE this kit existed, it
274
326
  reports six failures, all from one line that cleared the board when a seated
275
327
  player dropped.
276
328
 
277
329
  The rest of `testing.js` stands on its own:
278
330
 
279
- - `createSessionHarness(server, { mode, sessionId, deckId })` ->
331
+ - `createSessionHarness(server, { mode, sessionId, deckId, storage })` ->
280
332
  `{ session, start(), join(playerId, identity?), leave(player),
281
- message(player, data), sent(playerId), last(playerId, match?), outbox }`. It
333
+ message(player, data), shutdown(), sent(playerId), last(playerId, match?),
334
+ outbox, storage }`. `join` and `leave` are async. It
282
335
  mirrors the real wrapper where it counts: roster timing around the callbacks,
283
336
  keying by playerId, and JSON round-tripping every message so a payload holding
284
337
  a live reference to server state arrives as a copy.
285
338
  - `createTestClock(startAt?)` -> `{ now, advance(ms), set(at), install(),
286
339
  uninstall() }`. `install()` points the kit's clock at it.
340
+ - `createTestStorage()` -> deck storage in memory, with its rows readable at
341
+ `.rows`. Each harness makes its own; two harnesses handed the same one are two
342
+ processes of one deck. `settleStorage()` waits for the saves nobody awaited.
287
343
 
288
344
  It is not a transport. A two-client game over a real `castle-web serve` is still
289
345
  the thing that proves a deck -- `tic-tac-toe/tools/live.mjs` is a
@@ -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:10:44.202Z",
32
32
  "server": {
33
33
  "main": "code/server/index.js",
34
34
  "maxPlayers": 8,
@@ -110,9 +110,10 @@ export default {
110
110
  room.table(session);
111
111
  },
112
112
 
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);
113
+ async onPlayerJoin(session, player) {
114
+ // Room.joinAsync reads the saved table on the first join, then restores or
115
+ // assigns a seat and may clear an idle table.
116
+ const { table } = await room.joinAsync(session, player);
116
117
 
117
118
  // New and cleared tables have null game state for the deck to initialize.
118
119
  if (!table.game) {
@@ -121,16 +122,26 @@ export default {
121
122
  pushState(session, table);
122
123
  },
123
124
 
124
- onPlayerLeave(session, player) {
125
+ async onPlayerLeave(session, player) {
125
126
  // Room.leave releases the seat and may promote a spectator. It preserves
126
127
  // `table.game`, so remaining players continue the same game.
127
- const { table } = room.leave(session, player);
128
+ const { table, saved } = room.leave(session, player);
128
129
  pushState(session, table);
130
+ await saved;
131
+ },
132
+
133
+ async onShutdown(session) {
134
+ await room.save(session);
129
135
  },
130
136
 
131
137
  onMessage(session, player, data) {
132
138
  // Room.table makes the table on demand, so no null check is needed here.
133
139
  const table = room.table(session);
140
+
141
+ // A message can arrive while the first join is still reading the saved table.
142
+ if (!table.game) {
143
+ return;
144
+ }
134
145
  // A sync request returns only to its sender and does not update activity.
135
146
  if (data?.type === 'sync') {
136
147
  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,6 +165,13 @@ 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),
@@ -86,8 +183,10 @@ function createTable(settings, sessionId) {
86
183
  // Call this past every rejection, never before one. A refused message is not
87
184
  // a sign of life, and a client able to refresh the clock with junk could keep
88
185
  // a dead table alive indefinitely.
186
+ // Returns the save it starts, so a caller that can await one does.
89
187
  keepAlive: () => {
90
188
  table.lastActivityAt = readClock();
189
+ return saveTable(session, table);
91
190
  },
92
191
  });
93
192
  }
@@ -119,6 +218,14 @@ function seatEntry(settings, player) {
119
218
  };
120
219
  }
121
220
 
221
+ // A `playerId` is a connection and does not survive a page load; the account
222
+ // is what a returning player is found by.
223
+ function rememberSeat(table, player, seat) {
224
+ if (typeof player.userId === 'string' && player.userId) {
225
+ table.seatsByUserId.set(player.userId, seat);
226
+ }
227
+ }
228
+
122
229
  // A seat is held by a connection, not an account. One account joining twice
123
230
  // from two tabs is two players at the table.
124
231
  function takeSeat(settings, table, player) {
@@ -130,6 +237,7 @@ function takeSeat(settings, table, player) {
130
237
  continue;
131
238
  }
132
239
  table.seats[seat] = seatEntry(settings, player);
240
+ rememberSeat(table, player, seat);
133
241
  return seat;
134
242
  }
135
243
  return null;
@@ -161,31 +269,59 @@ function othersIn(session, player) {
161
269
  //
162
270
  // `playerId` survives a transport reconnect (see CLAUDE.md, "What a reconnect
163
271
  // is"), so a blipped connection comes back as the same player.
164
- function reclaimSeat(settings, table, player) {
272
+ function reclaimSeat(settings, table, session, player) {
165
273
  // Still seated, so nothing was lost.
166
274
  if (seatOf(table, player.playerId)) {
167
275
  return true;
168
276
  }
277
+ const seat =
278
+ reclaimByConnection(settings, table, player) ??
279
+ reclaimByAccount(settings, table, session, player);
280
+ if (!seat) {
281
+ return false;
282
+ }
283
+ rememberSeat(table, player, seat);
284
+ return true;
285
+ }
169
286
 
287
+ // The seat this connection left, or null.
288
+ function reclaimByConnection(settings, table, player) {
170
289
  const record = table.departed.get(player.playerId);
171
290
  if (!record) {
172
- return false;
291
+ return null;
173
292
  }
174
293
 
175
294
  // The record is spent either way. A player who missed the window is an
176
295
  // arrival, and arriving twice should not consume a second grace period.
177
296
  table.departed.delete(player.playerId);
178
297
  if (readClock() - record.at > settings.graceMs) {
179
- return false;
298
+ return null;
180
299
  }
181
300
 
182
301
  // A watcher was promoted into the seat while this player was away.
183
302
  if (table.seats[record.seat]) {
184
- return false;
303
+ return null;
185
304
  }
186
305
 
187
306
  table.seats[record.seat] = record.holder;
188
- return true;
307
+ return record.seat;
308
+ }
309
+
310
+ // The seat goes back to the account that held it, on whatever connection it
311
+ // arrives on. A second connection from someone still seated finds the seat
312
+ // taken and arrives as a stranger.
313
+ function reclaimByAccount(settings, table, session, player) {
314
+ const seat = table.seatsByUserId.get(player.userId);
315
+ if (!seat || table.seats[seat]) {
316
+ return null;
317
+ }
318
+
319
+ // A public room hands a seat back only inside the idle window.
320
+ if (session.mode === 'public' && readClock() - table.lastActivityAt > settings.idleMs) {
321
+ return null;
322
+ }
323
+ table.seats[seat] = seatEntry(settings, player);
324
+ return seat;
189
325
  }
190
326
 
191
327
  // Called on both join and leave, so the map cannot grow across a long session.
@@ -244,6 +380,18 @@ function clearTable(settings, table, present) {
244
380
  table.seats[seat] = null;
245
381
  }
246
382
  }
383
+ rememberHeldSeats(settings, table);
384
+ }
385
+
386
+ // After a clear the account map names only the seats that survived it.
387
+ function rememberHeldSeats(settings, table) {
388
+ table.seatsByUserId = new Map();
389
+ for (const seat of settings.seats) {
390
+ const holder = table.seats[seat];
391
+ if (holder) {
392
+ rememberSeat(table, holder, seat);
393
+ }
394
+ }
247
395
  }
248
396
 
249
397
  // Returns `{ table }`. A deck knows to build a fresh game from `table.game`
@@ -256,7 +404,7 @@ function joinTable(table, settings, session, player) {
256
404
  const others = othersIn(session, player);
257
405
 
258
406
  // Before the clear decision, which asks whether this is a return.
259
- const returning = reclaimSeat(settings, table, player);
407
+ const returning = reclaimSeat(settings, table, session, player);
260
408
 
261
409
  if (shouldClear(settings, table, others, returning, session.mode)) {
262
410
  clearTable(settings, table, others);
@@ -268,7 +416,8 @@ function joinTable(table, settings, session, player) {
268
416
  return { table };
269
417
  }
270
418
 
271
- // Returns `{ table }`. It does not touch `game`; see the file header.
419
+ // Returns `{ table, saved }`, where `saved` is the write this leave started. It
420
+ // does not touch `game`; see the file header.
272
421
  function leaveTable(table, settings, session, player) {
273
422
  const seat = seatOf(table, player.playerId);
274
423
  if (seat) {
@@ -285,5 +434,5 @@ function leaveTable(table, settings, session, player) {
285
434
  takeSeat(settings, table, other);
286
435
  }
287
436
  pruneDeparted(settings, table);
288
- return { table };
437
+ return { table, saved: saveTable(session, table) };
289
438
  }