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
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
152
|
-
before one.** A rematch is the deck's own
|
|
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,
|
|
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;
|
|
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
|
-
|
|
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,
|
|
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?),
|
|
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-
|
|
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.
|
|
115
|
-
|
|
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",
|
package/kits/turn-based/room.js
CHANGED
|
@@ -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
|
-
//
|
|
13
|
-
//
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
303
|
+
return null;
|
|
185
304
|
}
|
|
186
305
|
|
|
187
306
|
table.seats[record.seat] = record.holder;
|
|
188
|
-
return
|
|
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 }
|
|
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
|
}
|