@hadoku/rooms-protocol 0.0.0-stage → 0.43.16

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/resync.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Reconnect and resync — the CONTRACT, not the transport.
3
+ *
4
+ * A client that drops and returns has three problems, and they are separable:
5
+ * did it miss anything (sequence), is it even talking to the same room
6
+ * incarnation (epoch), and does the server still agree about who it is
7
+ * (identity). The rules for all three live here, pure and dependency-free, so
8
+ * both ends agree on them and either end can test them without a socket.
9
+ *
10
+ * **The implementations deliberately stay where they are.** Watchparty's socket
11
+ * lifecycle is in `apps/ui/src/lib/socket.ts` and is not extracted; a second
12
+ * consumer whose client is a cross-origin iframe relaying over `postMessage`
13
+ * has a different transport entirely. What both need to agree on is the RULES,
14
+ * which is this file.
15
+ *
16
+ * ## What watchparty actually stamps today — read this before binding
17
+ *
18
+ * `CoreRoomState.lastEventSeq` is incremented **only for playback control
19
+ * events** (`ws/control.ts`), and it reaches clients in the `snapshot` and
20
+ * `control_ack` payloads. Chat, presence, health and game envelopes carry no
21
+ * sequence at all. So `classifySeq` is a rule this package *guarantees*, not a
22
+ * behaviour watchparty currently exercises end to end — an app that wants gap
23
+ * detection stamps `SeqCursor` on its own envelopes and runs the classifier.
24
+ *
25
+ * That distinction was stated wrongly once (an earlier version of the
26
+ * extraction reply claimed "every envelope carries seq"), so it is spelled out
27
+ * here rather than left to be inferred.
28
+ */
29
+ /**
30
+ * Classify an incoming event against the last one applied.
31
+ *
32
+ * `last === null` means nothing has been applied yet, which is 'ok' rather than
33
+ * a gap: a client joining mid-stream has missed everything by definition, and
34
+ * its snapshot — not a resync request — is what establishes the baseline.
35
+ *
36
+ * **Sequence numbers are NEVER compared across epochs.** A restarted server
37
+ * counting from 1 while the client holds 500 would otherwise read as a flood
38
+ * of duplicates and the client would sit there discarding live events forever.
39
+ * The epoch check therefore comes first, before any arithmetic.
40
+ */
41
+ export function classifySeq(last, incoming) {
42
+ if (last === null)
43
+ return 'ok';
44
+ if (incoming.epoch !== last.epoch)
45
+ return 'epoch-change';
46
+ if (incoming.seq === last.seq + 1)
47
+ return 'ok';
48
+ if (incoming.seq <= last.seq)
49
+ return 'duplicate';
50
+ return 'gap';
51
+ }
52
+ /** How many events a gap skipped. 0 when the verdict is not 'gap'. */
53
+ export function missedCount(last, incoming) {
54
+ if (last === null || incoming.epoch !== last.epoch)
55
+ return 0;
56
+ return Math.max(0, incoming.seq - last.seq - 1);
57
+ }
58
+ /**
59
+ * Does this verdict require the client to ask the server for state?
60
+ *
61
+ * A helper rather than a comparison at each call site, because the answer is
62
+ * NOT "anything that isn't ok": a duplicate must not trigger a resync. Under
63
+ * any retransmit, duplicates arrive in bursts, and resyncing on each one turns
64
+ * a harmless replay into a request storm against the server that is already
65
+ * struggling enough to be retransmitting.
66
+ */
67
+ export function needsResync(verdict) {
68
+ return verdict === 'gap' || verdict === 'epoch-change';
69
+ }
70
+ /**
71
+ * Should the client re-assert its local identity to the server?
72
+ *
73
+ * On each snapshot a client compares its own name/avatar against the server's
74
+ * roster entry FOR ITSELF and emits an identity update only for the fields that
75
+ * actually disagree, so later snapshots are no-ops.
76
+ *
77
+ * **Read `serverEntry` from the ROSTER, not from the snapshot.** Watchparty's
78
+ * snapshot carries seven fields and has never included a roster; a caller that
79
+ * looked itself up there got `undefined` permanently, both guards collapsed to
80
+ * local state alone, and every client with a saved identity re-emitted on
81
+ * every snapshot — including the room-wide ones. That bug survived a long time
82
+ * because broken and correct agree on the FIRST snapshot after join and
83
+ * diverge only afterwards.
84
+ *
85
+ * `serverEntry === undefined` still means "assert": that is the genuine
86
+ * first-snapshot case, where the client cannot know the server's view.
87
+ *
88
+ * Known quirk, preserved deliberately: an empty server name does not trigger a
89
+ * name assert, because an empty string is not a default name under any sane
90
+ * `isDefaultName`. Widening that is a separate decision with a different blast
91
+ * radius.
92
+ *
93
+ * @param isDefaultName recognises an auto-assigned name; a local name that is
94
+ * still a default is not worth asserting.
95
+ * @returns the fields to emit, or null when nothing differs.
96
+ */
97
+ export function resolveIdentityReassert(local, serverEntry, isDefaultName) {
98
+ const wantsName = !!local.name &&
99
+ !isDefaultName(local.name) &&
100
+ (!serverEntry || isDefaultName(serverEntry.name || ''));
101
+ const wantsAvatar = !!local.avatarId && (!serverEntry || serverEntry.avatarId !== local.avatarId);
102
+ if (!wantsName && !wantsAvatar)
103
+ return null;
104
+ const out = {};
105
+ if (wantsName)
106
+ out.name = local.name;
107
+ if (wantsAvatar)
108
+ out.avatarId = local.avatarId;
109
+ return out;
110
+ }
@@ -0,0 +1,269 @@
1
+ import type { ParticipantId } from './participantId.js';
2
+ /**
3
+ * Seating — who is in the game, in what ORDER, and who is merely watching.
4
+ *
5
+ * ## SeatId IS the ordering
6
+ *
7
+ * A `SeatId` is an index into `seats`, not a sort key. Seat order is
8
+ * load-bearing, not cosmetic: in bomberman it selects a spawn point, and in a
9
+ * draft the pack passes left, then right, then left again. So the table stores
10
+ * position directly rather than deriving it from anything that can be
11
+ * reordered.
12
+ *
13
+ * What it replaces in watchparty: `buildPlayerRoster` walked `room.clients`
14
+ * and handed out `playerId` in **Map insertion order of socket ids**. That map
15
+ * is keyed by socket id, so a player who reconnected was deleted and re-inserted
16
+ * at the END — silently moving them to the last slot and shifting everyone
17
+ * after them. Mid-session, one person's refresh reshuffled the table's spawn
18
+ * points.
19
+ *
20
+ * ## Sticky by default, RESERVED when the app asks
21
+ *
22
+ * Two different needs pull in opposite directions, and there are two mechanisms
23
+ * because one is not enough:
24
+ *
25
+ * - **Sticky** (always on). On leave the seat is released but the
26
+ * participant -> seat assignment is REMEMBERED, so a returning participant
27
+ * reclaims their old place when it is still free instead of being appended.
28
+ * A departed viewer never holds a slot others are waiting for.
29
+ * - **Reserved** (opt-in, `reservationTtlMs`). The seat is additionally held
30
+ * AGAINST OTHER PEOPLE for a bounded window, so a reconnect cannot lose it
31
+ * to whoever happened to be waiting.
32
+ *
33
+ * Sticky alone is right for a watch party, where an empty slot should go to
34
+ * whoever wants it. It is not enough for a long game: on a bounded table a
35
+ * network blip, a suspended laptop or a backgrounded tab frees the seat with no
36
+ * overlap at all, and that is exactly when it gets taken.
37
+ *
38
+ * A reservation also survives the case nothing else can. When a server
39
+ * restarts, every socket drops at once — so any scheme based on "is a transport
40
+ * still attached" is empty for every seat simultaneously and cannot help. A
41
+ * reservation is data, so it can be persisted and restored with the table.
42
+ *
43
+ * Both need an identity that outlives the transport, which is why
44
+ * `ParticipantId` had to land first — with socket ids there is nothing to
45
+ * remember or reserve. See `participantId.ts`.
46
+ *
47
+ * ## A reservation blocks other HUMANS, not bots
48
+ *
49
+ * The seat of an absent player can be filled by a bot and reclaimed on their
50
+ * return — which is what a pod of eight wants when one person's train goes into
51
+ * a tunnel. `padWithBots` therefore ignores reservations and `seatParticipant`
52
+ * honours them, and a returning human evicts the bot sitting in their seat.
53
+ *
54
+ * ## Roles are DERIVED, never stored
55
+ *
56
+ * `roleOf` reads the table. A stored role could disagree with the seating —
57
+ * a "player" with no seat, or a seated "spectator" — and every consumer would
58
+ * then have to guess which half to trust. There is one source of truth.
59
+ *
60
+ * ## Plain arrays and records, deliberately — NOT Map/Set
61
+ *
62
+ * Every other collection on `RoomState` is a `Map` or a `Set`, which is the
63
+ * reason room state cannot be snapshotted or restored today: `JSON.stringify`
64
+ * renders them as `{}`. This table is plain JSON-safe data so that a pluggable
65
+ * snapshot/restore is a matter of writing it out, not of teaching a serializer
66
+ * about five container types. Keep it that way.
67
+ */
68
+ /** Index into `SeatTable.seats`. Stable across a reconnect. */
69
+ export type SeatId = number;
70
+ /** Derived from seating — see this module's header. */
71
+ export type SeatRole = 'player' | 'spectator';
72
+ export type SeatOccupant = {
73
+ kind: 'human';
74
+ participantId: ParticipantId;
75
+ } | {
76
+ kind: 'bot';
77
+ botId: string;
78
+ };
79
+ export interface Seat {
80
+ id: SeatId;
81
+ /** null = empty and claimable. */
82
+ occupant: SeatOccupant | null;
83
+ }
84
+ /** A seat held for one participant until `expiresAt` (epoch ms). */
85
+ export interface SeatReservation {
86
+ participantId: ParticipantId;
87
+ expiresAt: number;
88
+ }
89
+ export interface SeatTable {
90
+ /**
91
+ * Maximum seats, or `null` for unbounded.
92
+ *
93
+ * `null` is watchparty's normal state: a room watching video has no slot
94
+ * limit and everyone present is a participant, so every joiner is seated in
95
+ * arrival order and there are no spectators. Bomberman sets a real capacity
96
+ * when a game is selected; a draft pod sets one at creation.
97
+ */
98
+ capacity: number | null;
99
+ /** Index === SeatId. Length grows to capacity (or with arrivals when unbounded). */
100
+ seats: Seat[];
101
+ /**
102
+ * participantId -> the seat they last held. Outlives their departure so a
103
+ * reconnect reclaims the same place. Never consulted when that seat is
104
+ * occupied by someone else.
105
+ */
106
+ remembered: Record<ParticipantId, SeatId>;
107
+ /**
108
+ * seatId -> who it is being held for, and until when. Keyed by seat because
109
+ * a seat has at most one reservation while a participant could otherwise
110
+ * appear to hold several.
111
+ *
112
+ * Expiry is LAZY — checked when the seat is considered, never swept by a
113
+ * timer. Timers do not compose (they stack into cascading delays) and they
114
+ * mask state bugs by silently fixing them on the next tick.
115
+ */
116
+ reservations: Record<SeatId, SeatReservation>;
117
+ /**
118
+ * How long `unseatParticipant(..., { hold: true })` holds a seat, in ms.
119
+ *
120
+ * **0 disables reservations entirely**, which is the default and is exactly
121
+ * the pre-reservation behaviour — so a host that wants sticky-only gets it by
122
+ * doing nothing. Per TABLE rather than a module constant because the right
123
+ * value is a product decision that genuinely differs: a watch party wants a
124
+ * departed viewer's slot back promptly, a 45-minute draft would rather hold a
125
+ * seat for minutes than break the pod for everyone else.
126
+ */
127
+ reservationTtlMs: number;
128
+ }
129
+ export interface SeatTableOptions {
130
+ /** See `SeatTable.reservationTtlMs`. 0 (the default) disables reservations. */
131
+ reservationTtlMs?: number;
132
+ }
133
+ export declare function createSeatTable(capacity?: number | null, opts?: SeatTableOptions): SeatTable;
134
+ /** The seat this participant currently occupies, or null. */
135
+ export declare function seatOf(table: SeatTable, participantId: ParticipantId): SeatId | null;
136
+ /** Occupant of a seat, or null when empty or out of range. */
137
+ export declare function occupantOf(table: SeatTable, id: SeatId): SeatOccupant | null;
138
+ /**
139
+ * The live reservation on a seat, or null when there is none or it has expired.
140
+ *
141
+ * Expired entries are deleted as they are found — lazy expiry, so the table
142
+ * self-cleans through normal use without a timer.
143
+ */
144
+ export declare function reservationFor(table: SeatTable, id: SeatId, now?: number): SeatReservation | null;
145
+ /** The seat currently held for this participant, if any. */
146
+ export declare function reservedSeatOf(table: SeatTable, participantId: ParticipantId, now?: number): SeatId | null;
147
+ /** Drop a seat's reservation, whoever it is for. */
148
+ export declare function clearReservation(table: SeatTable, id: SeatId): void;
149
+ /**
150
+ * May this participant take this seat?
151
+ *
152
+ * False only when someone ELSE holds a live reservation on it. A participant is
153
+ * never blocked by their own reservation — that is the point of one.
154
+ */
155
+ export declare function seatClaimableBy(table: SeatTable, id: SeatId, participantId: ParticipantId, now?: number): boolean;
156
+ /**
157
+ * Seat a participant, preferring the seat they last held.
158
+ *
159
+ * Returns their SeatId, or null when the table is full of PEOPLE — in which
160
+ * case the caller treats them as a spectator. Idempotent: already-seated
161
+ * returns the seat they are in, so a duplicate join cannot take two.
162
+ *
163
+ * ## A human outranks a bot
164
+ *
165
+ * A bot holding a seat never turns a real player away — it is evicted and the
166
+ * human takes the seat. Without that, `padWithBots` filling the table locks
167
+ * every later arrival out permanently: padding clears and re-fills the same
168
+ * lowest free seat, so the seat a human needs is re-taken the instant it frees.
169
+ * Callers CAN avoid it by clamping the bot count to capacity-minus-humans (what
170
+ * `handleGameSetBots` does), but a table that depends on every caller getting
171
+ * that right will eventually meet one that doesn't.
172
+ *
173
+ * Eviction is a last resort, after a genuinely free seat and after growing the
174
+ * table — bots only move when there is no other way to seat a person. The one
175
+ * exception is the remembered seat: seat stability is the whole point, so
176
+ * reclaiming it displaces a bot rather than sending its owner elsewhere.
177
+ */
178
+ export declare function seatParticipant(table: SeatTable, participantId: ParticipantId, now?: number): SeatId | null;
179
+ export interface ReleaseOptions {
180
+ /**
181
+ * Also HOLD the seat against other people for `ttlMs`.
182
+ *
183
+ * A per-call flag rather than table policy because the same table wants both
184
+ * answers: a viewer saying "I'm leaving" should free the slot at once, while
185
+ * a player dropping off a pod should not cost the other seven their game.
186
+ * The caller knows which of those just happened; the table cannot.
187
+ *
188
+ * Defaults to true, which is a no-op unless the table has a
189
+ * `reservationTtlMs` — so a host that never opted in sees no change.
190
+ */
191
+ hold?: boolean;
192
+ /** Override the table's `reservationTtlMs` for this release. */
193
+ ttlMs?: number;
194
+ now?: number;
195
+ }
196
+ /**
197
+ * Release a participant's seat, remembering it for their return.
198
+ *
199
+ * Returns the vacated SeatId, or null if they held none.
200
+ */
201
+ export declare function unseatParticipant(table: SeatTable, participantId: ParticipantId, opts?: ReleaseOptions): SeatId | null;
202
+ /** Forget a participant entirely, so they get a fresh seat next time. */
203
+ export declare function forgetParticipant(table: SeatTable, participantId: ParticipantId): void;
204
+ /**
205
+ * Role of a participant. `'player'` iff they hold a seat.
206
+ *
207
+ * With an unbounded table every participant is a player, which is watchparty's
208
+ * behaviour today — it has no spectator concept, and inventing one here with
209
+ * nothing to render it would be a subsystem with no consumer.
210
+ */
211
+ export declare function roleOf(table: SeatTable, participantId: ParticipantId): SeatRole;
212
+ /** Seats holding a human, in seat order. */
213
+ export declare function humanSeats(table: SeatTable): Array<{
214
+ id: SeatId;
215
+ participantId: ParticipantId;
216
+ }>;
217
+ /** Every occupied seat, humans and bots, in seat order. */
218
+ export declare function occupiedSeats(table: SeatTable): Array<{
219
+ id: SeatId;
220
+ occupant: SeatOccupant;
221
+ }>;
222
+ /**
223
+ * Change capacity, releasing any human seated beyond the new limit.
224
+ *
225
+ * Returns the participants displaced — the caller decides what that means
226
+ * (they become spectators; a game in progress may prefer not to shrink at all).
227
+ * Their seat stays remembered, so growing the table again restores them.
228
+ */
229
+ export declare function setSeatCapacity(table: SeatTable, capacity: number | null): ParticipantId[];
230
+ /**
231
+ * Fill up to `count` free seats with bots, after the humans.
232
+ *
233
+ * Existing bots are cleared first so the count is a target rather than an
234
+ * increment — the leader's control sets "how many bots", not "add this many".
235
+ * Bot ids are positional (`bot:<n>` by seat) so a re-pad is deterministic.
236
+ */
237
+ export declare function padWithBots(table: SeatTable, count: number): SeatId[];
238
+ /** Free seats available to a new human right now. */
239
+ export declare function freeSeatCount(table: SeatTable): number;
240
+ /**
241
+ * The table as a RENDERER should see it: one row per seat up to capacity,
242
+ * empty ones included.
243
+ *
244
+ * `SeatTable.seats` grows with arrivals rather than being pre-allocated, which
245
+ * is right for the table — `seatParticipant` creates the next seat on demand
246
+ * and `freeSeatCount` compensates with `capacity - seats.length`. It is the
247
+ * wrong shape for a UI: rendering `table.seats` verbatim draws a four-player
248
+ * room with two players in it as a TWO-seat table, which reads as "full" or
249
+ * as broken. (Found by hadoku_site's `templates/game` from a screenshot, not
250
+ * from a test — nothing about the data is inconsistent, so nothing failed.)
251
+ *
252
+ * Here rather than in each consumer because every bounded-table UI needs the
253
+ * same loop, and a hand-rolled `Array.from({length: capacity})` is one
254
+ * `seats.length > capacity` away from silently DROPPING an occupied seat from
255
+ * the view — a player who vanishes from the table while still seated and still
256
+ * playing. `setSeatCapacity` truncates, so that state is unreachable today;
257
+ * this keeps it unreachable from the render side too, rather than relying on
258
+ * every consumer having read that function.
259
+ *
260
+ * Unbounded (`capacity === null`) is a pass-through: there is no number to pad
261
+ * to, and inventing one would draw empty seats nobody may sit in. Rows are
262
+ * fresh objects, like every other derived view here, so a renderer cannot
263
+ * mutate the table through one.
264
+ */
265
+ export declare function seatRows(table: SeatTable): Array<{
266
+ id: SeatId;
267
+ occupant: SeatOccupant | null;
268
+ }>;
269
+ //# sourceMappingURL=seats.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seats.d.ts","sourceRoot":"","sources":["../src/seats.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AAEH,+DAA+D;AAC/D,MAAM,MAAM,MAAM,GAAG,MAAM,CAAC;AAE5B,uDAAuD;AACvD,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,WAAW,CAAC;AAE9C,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,aAAa,EAAE,aAAa,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnC,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,kCAAkC;IAClC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;CAC/B;AAED,oEAAoE;AACpE,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,aAAa,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB;;;;;;;OAOG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,oFAAoF;IACpF,KAAK,EAAE,IAAI,EAAE,CAAC;IACd;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;IAC1C;;;;;;;;OAQG;IACH,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAC9C;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,+EAA+E;IAC/E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,wBAAgB,eAAe,CAC7B,QAAQ,GAAE,MAAM,GAAG,IAAW,EAC9B,IAAI,GAAE,gBAAqB,GAC1B,SAAS,CAQX;AAMD,6DAA6D;AAC7D,wBAAgB,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,aAAa,GAAG,MAAM,GAAG,IAAI,CAGpF;AAED,8DAA8D;AAC9D,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAE5E;AAkBD;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,SAAS,EAChB,EAAE,EAAE,MAAM,EACV,GAAG,GAAE,MAAmB,GACvB,eAAe,GAAG,IAAI,CAQxB;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,CAC5B,KAAK,EAAE,SAAS,EAChB,aAAa,EAAE,aAAa,EAC5B,GAAG,GAAE,MAAmB,GACvB,MAAM,GAAG,IAAI,CAMf;AAED,oDAAoD;AACpD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAEnE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,SAAS,EAChB,EAAE,EAAE,MAAM,EACV,aAAa,EAAE,aAAa,EAC5B,GAAG,GAAE,MAAmB,GACvB,OAAO,CAGT;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,SAAS,EAChB,aAAa,EAAE,aAAa,EAC5B,GAAG,GAAE,MAAmB,GACvB,MAAM,GAAG,IAAI,CAsCf;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;;;;;;OAUG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,SAAS,EAChB,aAAa,EAAE,aAAa,EAC5B,IAAI,GAAE,cAAmB,GACxB,MAAM,GAAG,IAAI,CAiBf;AAED,yEAAyE;AACzE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,aAAa,GAAG,IAAI,CAKtF;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,aAAa,GAAG,QAAQ,CAE/E;AAED,4CAA4C;AAC5C,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,aAAa,CAAA;CAAE,CAAC,CAMhG;AAED,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,YAAY,CAAA;CAAE,CAAC,CAM7F;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,aAAa,EAAE,CAa1F;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAcrE;AAED,qDAAqD;AACrD,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAItD;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;CAAE,CAAC,CAO/F"}
package/dist/seats.js ADDED
@@ -0,0 +1,276 @@
1
+ export function createSeatTable(capacity = null, opts = {}) {
2
+ return {
3
+ capacity,
4
+ seats: [],
5
+ remembered: {},
6
+ reservations: {},
7
+ reservationTtlMs: opts.reservationTtlMs ?? 0,
8
+ };
9
+ }
10
+ function isHuman(o, participantId) {
11
+ return o?.kind === 'human' && o.participantId === participantId;
12
+ }
13
+ /** The seat this participant currently occupies, or null. */
14
+ export function seatOf(table, participantId) {
15
+ const seat = table.seats.find((s) => isHuman(s.occupant, participantId));
16
+ return seat ? seat.id : null;
17
+ }
18
+ /** Occupant of a seat, or null when empty or out of range. */
19
+ export function occupantOf(table, id) {
20
+ return table.seats[id]?.occupant ?? null;
21
+ }
22
+ /** Whether another seat could still be created or claimed. */
23
+ function hasRoom(table) {
24
+ return table.capacity === null || table.seats.length < table.capacity
25
+ || table.seats.some((s) => s.occupant === null);
26
+ }
27
+ /** Grow the array to cover `id`, filling any hole with empty seats. */
28
+ function ensureSeat(table, id) {
29
+ while (table.seats.length <= id) {
30
+ table.seats.push({ id: table.seats.length, occupant: null });
31
+ }
32
+ return table.seats[id];
33
+ }
34
+ // ── Reservations ────────────────────────────────────────────────────────────
35
+ /**
36
+ * The live reservation on a seat, or null when there is none or it has expired.
37
+ *
38
+ * Expired entries are deleted as they are found — lazy expiry, so the table
39
+ * self-cleans through normal use without a timer.
40
+ */
41
+ export function reservationFor(table, id, now = Date.now()) {
42
+ const held = table.reservations[id];
43
+ if (!held)
44
+ return null;
45
+ if (held.expiresAt <= now) {
46
+ delete table.reservations[id];
47
+ return null;
48
+ }
49
+ return held;
50
+ }
51
+ /** The seat currently held for this participant, if any. */
52
+ export function reservedSeatOf(table, participantId, now = Date.now()) {
53
+ for (const seat of table.seats) {
54
+ const held = reservationFor(table, seat.id, now);
55
+ if (held?.participantId === participantId)
56
+ return seat.id;
57
+ }
58
+ return null;
59
+ }
60
+ /** Drop a seat's reservation, whoever it is for. */
61
+ export function clearReservation(table, id) {
62
+ delete table.reservations[id];
63
+ }
64
+ /**
65
+ * May this participant take this seat?
66
+ *
67
+ * False only when someone ELSE holds a live reservation on it. A participant is
68
+ * never blocked by their own reservation — that is the point of one.
69
+ */
70
+ export function seatClaimableBy(table, id, participantId, now = Date.now()) {
71
+ const held = reservationFor(table, id, now);
72
+ return held === null || held.participantId === participantId;
73
+ }
74
+ /**
75
+ * Seat a participant, preferring the seat they last held.
76
+ *
77
+ * Returns their SeatId, or null when the table is full of PEOPLE — in which
78
+ * case the caller treats them as a spectator. Idempotent: already-seated
79
+ * returns the seat they are in, so a duplicate join cannot take two.
80
+ *
81
+ * ## A human outranks a bot
82
+ *
83
+ * A bot holding a seat never turns a real player away — it is evicted and the
84
+ * human takes the seat. Without that, `padWithBots` filling the table locks
85
+ * every later arrival out permanently: padding clears and re-fills the same
86
+ * lowest free seat, so the seat a human needs is re-taken the instant it frees.
87
+ * Callers CAN avoid it by clamping the bot count to capacity-minus-humans (what
88
+ * `handleGameSetBots` does), but a table that depends on every caller getting
89
+ * that right will eventually meet one that doesn't.
90
+ *
91
+ * Eviction is a last resort, after a genuinely free seat and after growing the
92
+ * table — bots only move when there is no other way to seat a person. The one
93
+ * exception is the remembered seat: seat stability is the whole point, so
94
+ * reclaiming it displaces a bot rather than sending its owner elsewhere.
95
+ */
96
+ export function seatParticipant(table, participantId, now = Date.now()) {
97
+ const existing = seatOf(table, participantId);
98
+ if (existing !== null)
99
+ return existing;
100
+ const claim = (seat) => {
101
+ seat.occupant = { kind: 'human', participantId };
102
+ table.remembered[participantId] = seat.id;
103
+ // Taking the seat consumes any hold on it — theirs, by the rule above.
104
+ clearReservation(table, seat.id);
105
+ return seat.id;
106
+ };
107
+ const open = (seat) => (seat.occupant === null || seat.occupant.kind === 'bot')
108
+ && seatClaimableBy(table, seat.id, participantId, now);
109
+ // 1. The remembered seat, if it is within capacity and not held by a person.
110
+ const remembered = table.remembered[participantId];
111
+ if (remembered !== undefined && (table.capacity === null || remembered < table.capacity)) {
112
+ const seat = ensureSeat(table, remembered);
113
+ if (open(seat))
114
+ return claim(seat);
115
+ }
116
+ // 2. The lowest free seat — deterministic, so two servers replaying the same
117
+ // join order land on the same table. Skips seats held for someone else.
118
+ const free = table.seats.find((s) => s.occupant === null && seatClaimableBy(table, s.id, participantId, now));
119
+ if (free)
120
+ return claim(free);
121
+ // 3. A new seat, while capacity allows one.
122
+ if (table.capacity === null || table.seats.length < table.capacity) {
123
+ return claim(ensureSeat(table, table.seats.length));
124
+ }
125
+ // 4. Last resort: take the lowest bot's seat — again, not one being held for
126
+ // somebody else, or a returning player would find a stranger in it.
127
+ const bot = table.seats.find((s) => s.occupant?.kind === 'bot' && seatClaimableBy(table, s.id, participantId, now));
128
+ if (bot)
129
+ return claim(bot);
130
+ return null;
131
+ }
132
+ /**
133
+ * Release a participant's seat, remembering it for their return.
134
+ *
135
+ * Returns the vacated SeatId, or null if they held none.
136
+ */
137
+ export function unseatParticipant(table, participantId, opts = {}) {
138
+ const id = seatOf(table, participantId);
139
+ if (id === null)
140
+ return null;
141
+ const now = opts.now ?? Date.now();
142
+ table.seats[id].occupant = null;
143
+ table.remembered[participantId] = id; // sticky — see header
144
+ const ttl = opts.ttlMs ?? table.reservationTtlMs;
145
+ if ((opts.hold ?? true) && ttl > 0) {
146
+ table.reservations[id] = { participantId, expiresAt: now + ttl };
147
+ }
148
+ // No `else clearReservation(id)`. It looks prudent and is unreachable: this
149
+ // function is the ONLY writer of a reservation and it empties the seat in the
150
+ // same breath, so a held seat is always vacant — and a vacant seat is not one
151
+ // anybody is being unseated from. `claim()` consumes the hold on the way back
152
+ // in. A mutation test proved the branch dead (removing it failed nothing).
153
+ return id;
154
+ }
155
+ /** Forget a participant entirely, so they get a fresh seat next time. */
156
+ export function forgetParticipant(table, participantId) {
157
+ unseatParticipant(table, participantId, { hold: false });
158
+ delete table.remembered[participantId];
159
+ const held = reservedSeatOf(table, participantId);
160
+ if (held !== null)
161
+ clearReservation(table, held);
162
+ }
163
+ /**
164
+ * Role of a participant. `'player'` iff they hold a seat.
165
+ *
166
+ * With an unbounded table every participant is a player, which is watchparty's
167
+ * behaviour today — it has no spectator concept, and inventing one here with
168
+ * nothing to render it would be a subsystem with no consumer.
169
+ */
170
+ export function roleOf(table, participantId) {
171
+ return seatOf(table, participantId) === null ? 'spectator' : 'player';
172
+ }
173
+ /** Seats holding a human, in seat order. */
174
+ export function humanSeats(table) {
175
+ const out = [];
176
+ for (const s of table.seats) {
177
+ if (s.occupant?.kind === 'human')
178
+ out.push({ id: s.id, participantId: s.occupant.participantId });
179
+ }
180
+ return out;
181
+ }
182
+ /** Every occupied seat, humans and bots, in seat order. */
183
+ export function occupiedSeats(table) {
184
+ const out = [];
185
+ for (const s of table.seats) {
186
+ if (s.occupant !== null)
187
+ out.push({ id: s.id, occupant: s.occupant });
188
+ }
189
+ return out;
190
+ }
191
+ /**
192
+ * Change capacity, releasing any human seated beyond the new limit.
193
+ *
194
+ * Returns the participants displaced — the caller decides what that means
195
+ * (they become spectators; a game in progress may prefer not to shrink at all).
196
+ * Their seat stays remembered, so growing the table again restores them.
197
+ */
198
+ export function setSeatCapacity(table, capacity) {
199
+ table.capacity = capacity;
200
+ if (capacity === null)
201
+ return [];
202
+ const displaced = [];
203
+ for (const seat of table.seats.slice(capacity)) {
204
+ if (seat.occupant?.kind === 'human')
205
+ displaced.push(seat.occupant.participantId);
206
+ seat.occupant = null;
207
+ // A reservation on a seat that no longer exists can never be honoured and
208
+ // would keep `reservedSeatOf` pointing off the end of the table.
209
+ clearReservation(table, seat.id);
210
+ }
211
+ table.seats.length = Math.min(table.seats.length, capacity);
212
+ return displaced;
213
+ }
214
+ /**
215
+ * Fill up to `count` free seats with bots, after the humans.
216
+ *
217
+ * Existing bots are cleared first so the count is a target rather than an
218
+ * increment — the leader's control sets "how many bots", not "add this many".
219
+ * Bot ids are positional (`bot:<n>` by seat) so a re-pad is deterministic.
220
+ */
221
+ export function padWithBots(table, count) {
222
+ for (const seat of table.seats) {
223
+ if (seat.occupant?.kind === 'bot')
224
+ seat.occupant = null;
225
+ }
226
+ const filled = [];
227
+ let remaining = Math.max(0, count);
228
+ while (remaining > 0 && hasRoom(table)) {
229
+ const free = table.seats.find((s) => s.occupant === null);
230
+ const seat = free ?? ensureSeat(table, table.seats.length);
231
+ seat.occupant = { kind: 'bot', botId: `bot:${seat.id}` };
232
+ filled.push(seat.id);
233
+ remaining -= 1;
234
+ }
235
+ return filled;
236
+ }
237
+ /** Free seats available to a new human right now. */
238
+ export function freeSeatCount(table) {
239
+ const empty = table.seats.filter((s) => s.occupant === null).length;
240
+ if (table.capacity === null)
241
+ return empty + 1; // one can always be created
242
+ return empty + Math.max(0, table.capacity - table.seats.length);
243
+ }
244
+ /**
245
+ * The table as a RENDERER should see it: one row per seat up to capacity,
246
+ * empty ones included.
247
+ *
248
+ * `SeatTable.seats` grows with arrivals rather than being pre-allocated, which
249
+ * is right for the table — `seatParticipant` creates the next seat on demand
250
+ * and `freeSeatCount` compensates with `capacity - seats.length`. It is the
251
+ * wrong shape for a UI: rendering `table.seats` verbatim draws a four-player
252
+ * room with two players in it as a TWO-seat table, which reads as "full" or
253
+ * as broken. (Found by hadoku_site's `templates/game` from a screenshot, not
254
+ * from a test — nothing about the data is inconsistent, so nothing failed.)
255
+ *
256
+ * Here rather than in each consumer because every bounded-table UI needs the
257
+ * same loop, and a hand-rolled `Array.from({length: capacity})` is one
258
+ * `seats.length > capacity` away from silently DROPPING an occupied seat from
259
+ * the view — a player who vanishes from the table while still seated and still
260
+ * playing. `setSeatCapacity` truncates, so that state is unreachable today;
261
+ * this keeps it unreachable from the render side too, rather than relying on
262
+ * every consumer having read that function.
263
+ *
264
+ * Unbounded (`capacity === null`) is a pass-through: there is no number to pad
265
+ * to, and inventing one would draw empty seats nobody may sit in. Rows are
266
+ * fresh objects, like every other derived view here, so a renderer cannot
267
+ * mutate the table through one.
268
+ */
269
+ export function seatRows(table) {
270
+ const rows = table.seats.map((s) => ({ id: s.id, occupant: s.occupant }));
271
+ if (table.capacity === null)
272
+ return rows;
273
+ for (let id = rows.length; id < table.capacity; id++)
274
+ rows.push({ id, occupant: null });
275
+ return rows;
276
+ }