@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/LICENSE +21 -0
- package/dist/game-bundle.d.ts +73 -0
- package/dist/game-bundle.d.ts.map +1 -0
- package/dist/game-bundle.js +30 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/participantId.d.ts +159 -0
- package/dist/participantId.d.ts.map +1 -0
- package/dist/participantId.js +209 -0
- package/dist/resync.d.ts +117 -0
- package/dist/resync.d.ts.map +1 -0
- package/dist/resync.js +110 -0
- package/dist/seats.d.ts +269 -0
- package/dist/seats.d.ts.map +1 -0
- package/dist/seats.js +276 -0
- package/package.json +22 -4
- package/README.md +0 -3
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
|
+
}
|
package/dist/seats.d.ts
ADDED
|
@@ -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":"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"}
|
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
|
+
}
|