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.
- 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/devSessionServer.js +9 -5
- 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 +24 -9
- package/kits/multiplayer-2d/behaviors/Box.jsx +32 -9
- package/kits/multiplayer-2d/castle.json +3 -3
- package/kits/multiplayer-2d/code/client/avatars.js +6 -1
- 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 +47 -4
- package/kits/multiplayer-2d/package-lock.json +26 -1
- package/kits/multiplayer-2d/package.json +2 -0
- package/kits/multiplayer-3d/CLAUDE.md +32 -9
- package/kits/multiplayer-3d/castle.json +4 -4
- package/kits/multiplayer-3d/code/client/avatars.js +15 -11
- package/kits/multiplayer-3d/code/client/nameTags.js +35 -16
- 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 +112 -7
- 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-2d/engine/avatarArt.js +91 -0
- 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 +15 -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 +295 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +80 -20
- package/kits/turn-based/castle.json +2 -2
- package/kits/turn-based/code/behaviors/Board.jsx +15 -5
- package/kits/turn-based/code/server/index.js +17 -5
- package/kits/turn-based/package.json +2 -1
- package/kits/turn-based/room.js +164 -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,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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
152
|
-
before one.** A rematch is the deck's own
|
|
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,
|
|
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;
|
|
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
|
-
|
|
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,
|
|
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?),
|
|
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-
|
|
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-
|
|
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
|
-
//
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
|
|
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.
|
|
115
|
-
|
|
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",
|
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,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
|
|
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
|
|
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
|
|
305
|
+
return null;
|
|
185
306
|
}
|
|
186
307
|
|
|
187
308
|
table.seats[record.seat] = record.holder;
|
|
188
|
-
return
|
|
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 }
|
|
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
|
}
|