@yyyu-star/arcade-kit 0.0.0-stage → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/LICENSE +21 -0
  3. package/README.md +441 -2
  4. package/dist/auth.d.ts +66 -0
  5. package/dist/auth.d.ts.map +1 -0
  6. package/dist/auth.js +86 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/client.d.ts +194 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +379 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/fun-art.d.ts +41 -0
  13. package/dist/fun-art.d.ts.map +1 -0
  14. package/dist/fun-art.js +141 -0
  15. package/dist/fun-art.js.map +1 -0
  16. package/dist/fun.d.ts +87 -0
  17. package/dist/fun.d.ts.map +1 -0
  18. package/dist/fun.js +512 -0
  19. package/dist/fun.js.map +1 -0
  20. package/dist/game.d.ts +160 -0
  21. package/dist/game.d.ts.map +1 -0
  22. package/dist/game.js +41 -0
  23. package/dist/game.js.map +1 -0
  24. package/dist/guest.d.ts +60 -0
  25. package/dist/guest.d.ts.map +1 -0
  26. package/dist/guest.js +87 -0
  27. package/dist/guest.js.map +1 -0
  28. package/dist/internal.d.ts +33 -0
  29. package/dist/internal.d.ts.map +1 -0
  30. package/dist/internal.js +53 -0
  31. package/dist/internal.js.map +1 -0
  32. package/dist/meter.d.ts +102 -0
  33. package/dist/meter.d.ts.map +1 -0
  34. package/dist/meter.js +207 -0
  35. package/dist/meter.js.map +1 -0
  36. package/dist/protocol.d.ts +227 -0
  37. package/dist/protocol.d.ts.map +1 -0
  38. package/dist/protocol.js +65 -0
  39. package/dist/protocol.js.map +1 -0
  40. package/dist/rng.d.ts +40 -0
  41. package/dist/rng.d.ts.map +1 -0
  42. package/dist/rng.js +119 -0
  43. package/dist/rng.js.map +1 -0
  44. package/dist/room.d.ts +352 -0
  45. package/dist/room.d.ts.map +1 -0
  46. package/dist/room.js +1563 -0
  47. package/dist/room.js.map +1 -0
  48. package/dist/testing.d.ts +125 -0
  49. package/dist/testing.d.ts.map +1 -0
  50. package/dist/testing.js +272 -0
  51. package/dist/testing.js.map +1 -0
  52. package/dist/worker.d.ts +92 -0
  53. package/dist/worker.d.ts.map +1 -0
  54. package/dist/worker.js +184 -0
  55. package/dist/worker.js.map +1 -0
  56. package/package.json +107 -4
  57. package/src/auth.ts +100 -0
  58. package/src/client.ts +438 -0
  59. package/src/fun-art.ts +184 -0
  60. package/src/fun.ts +626 -0
  61. package/src/game.ts +200 -0
  62. package/src/guest.ts +122 -0
  63. package/src/internal.ts +64 -0
  64. package/src/meter.ts +255 -0
  65. package/src/protocol.ts +243 -0
  66. package/src/rng.ts +131 -0
  67. package/src/room.ts +1677 -0
  68. package/src/testing.ts +300 -0
  69. package/src/worker.ts +234 -0
package/src/room.ts ADDED
@@ -0,0 +1,1677 @@
1
+ /**
2
+ * 房间基类:游戏 Worker 里的 Durable Object 继承它,填上规则(RoomGame)就是一张联机桌(docs/platform-backend.md 第 4 节)。
3
+ *
4
+ * 守的规矩(4.1–4.6),每条都有测试:
5
+ * - 只用 ctx.acceptWebSocket(休眠),只用一个闹钟,不用 setTimeout / setInterval;心跳 ping/pong 由运行时自动应答;
6
+ * - 每一步最多写 2 行:房间状态一行(到期时间、计数都合在这一行里),外加"最早到期时间变早了"才重设闹钟(setAlarm 也算一行)。
7
+ * 闹钟只往早了调:期限往后推(比如出完牌下一手的 30 秒)时不动它,到点醒来发现没到期再改,那一步只写这两行。
8
+ * 例外只有开房:第一次建表另算 2 行(SQLite 的建表记录),一个房间只有这一次;
9
+ * - 状态没变不写:没入座的人进出、本来就有人在线时又连进来一个,都不写库、不广播;
10
+ * - 服务端说了算,下发前按座位过滤:每条连接只收到 game.view(state, 它的座位) 的结果,绝不发别人的隐藏信息;
11
+ * - 续局:接受连接、发快照、记下座位在同一段没有 await 的代码里做完;只向已经收到快照的连接广播;广播在 await(设闹钟)之前;
12
+ * - 每个入口(fetch、RPC、webSocketMessage、webSocketClose、alarm)都包一层:先计数、再查硬限制,最后才进游戏代码;游戏代码抛错只记日志,
13
+ * 闹钟里不往外抛(闹钟失败会自动重试最多 6 次,每次都计费,4.4)。
14
+ * - 内存里的东西都是缓存:被驱逐后从存储重建(attachment 里只放 userId、昵称);闹钟时间以 getAlarm() 读到的为准。
15
+ * - 额度(0.2.0,绑了 METER 才有,见 meter.ts):开房先向计量占名额;自己数写了几行,攒够 meterBatch 行或房间结束时报给计量;
16
+ * 打完、关房时还房主的名额。找计量都在 entry 之外(同步的那一步做完、广播完以后)await,不影响"一步之内没有 await"。
17
+ * 计量说今天快满了(busy)就不进新人;说满了(stop)就在这一段打完(RoomGame.breakPoint)时收场。
18
+ */
19
+ import { DurableObject } from 'cloudflare:workers';
20
+ import type { RoomEffect, RoomGame, RoomSetup } from './game.js';
21
+ import { type Identity, KIT_HOST, meterStub, parseIdentity } from './internal.js';
22
+ import type { MeterEnv, MeterLevel, RoomMeter } from './meter.js';
23
+ import { ENTROPY_WORDS } from './rng.js';
24
+ import {
25
+ CLOSE,
26
+ CLOSE_REASON,
27
+ type DownMsg,
28
+ type EmotedMsg,
29
+ type Frame,
30
+ PING,
31
+ PONG,
32
+ PROTOCOL_VERSION,
33
+ ROOM_CODE_RE,
34
+ type RoomInfo,
35
+ type RoomStatus,
36
+ type SeatInfo,
37
+ } from './protocol.js';
38
+
39
+ export type { RoomEffect, RoomGame, RoomSetup } from './game.js';
40
+ export type { Identity } from './internal.js';
41
+
42
+ export interface RoomLimits {
43
+ /** 单条入站消息字节数,超了 1009 断开 */
44
+ maxMessageBytes: number;
45
+ /** 每个连接的令牌桶:每秒补几个、最多攒几个,超了 1008 断开 */
46
+ ratePerSec: number;
47
+ rateBurst: number;
48
+ /** 房间一生的入站消息数,超了房间结束 */
49
+ maxInbound: number;
50
+ /**
51
+ * 一个人最多用掉 maxInbound 的几成(0–1)。超了只断开他本人(4030)、以后也进不来,房间照常:
52
+ * 一个人刷不完整个房间的额度。默认 0.4:正常一局一个人用不到一成(掼蛋 4 真人桌一局全桌约 1000 条)
53
+ */
54
+ maxInboundShare: number;
55
+ /**
56
+ * 每个人一生的"掉线后回来"次数(只数会写库的那种),用完以后他再连是 4029。断开总是跟在一次进房后面,
57
+ * 所以也管住了断开的写入。按 userId 分开算:别人刷不光你的额度,离座再坐下也不清零;没入座的人进出不写库,不受这个管。
58
+ * 这些重连同时算进 maxInbound,房间一生的写入总数有上限
59
+ */
60
+ maxJoins: number;
61
+ /** 没入座的连接最多几条(来看的、还没选座位的),超了 4003。入座的人不占这个数,掉线回来总能进 */
62
+ extraConnections: number;
63
+ /** 房间最长寿命 */
64
+ maxLifeMs: number;
65
+ /** 没有真人连着多久关房间 */
66
+ noHumanMs: number;
67
+ /** 每一手的时间,超时代出 */
68
+ turnMs: number;
69
+ /** 掉线宽限:超过就由电脑托管,回来交还 */
70
+ graceMs: number;
71
+ /** 电脑连续出手时每一步的播放间隔 */
72
+ aiDelayMs: number;
73
+ /** 一次最多连算几步电脑,剩下的按闹钟接着算(防死循环) */
74
+ maxAiBatch: number;
75
+ /** 客户端版本低于这个就 4005 让它刷新 */
76
+ minClientVersion: number;
77
+ /**
78
+ * 快捷短语的令牌桶(0.7.0 起):每个座位每 emoteMs 补 1 个、最多攒 emoteBurst 个,没令牌回 emote_too_fast、不转发。
79
+ * 这两个是代码里的默认值;游戏 wrangler.jsonc 的 vars.KIT_QUOTA 里写了 emoteMs / emoteBurst 就以那里为准(不用发版就能调)。
80
+ * emoteMs 为 0 表示不限速
81
+ */
82
+ emoteMs: number;
83
+ emoteBurst: number;
84
+ /**
85
+ * 写了多少行报一次计量(0.2.0)。计量每报一次自己写 1 行,200 就是多花 0.5%;
86
+ * 太大的话计量知道得晚,满了以后房间还会多写最多这么多行才收场
87
+ */
88
+ meterBatch: number;
89
+ /**
90
+ * 关房时还有对外的事没办完(RoomGame.effects,0.3.0):断开所有人以后留着存储接着办,最多等这么久,
91
+ * 到了还办不完就记日志照样删(办事的一方要有自己的兜底,比如钱包到期退回买入)
92
+ */
93
+ closingMs: number;
94
+ }
95
+
96
+ /** 4.5、4.6 的默认值。 */
97
+ export const DEFAULT_LIMITS: RoomLimits = {
98
+ maxMessageBytes: 4096,
99
+ ratePerSec: 5,
100
+ rateBurst: 10,
101
+ maxInbound: 3000,
102
+ maxInboundShare: 0.4,
103
+ maxJoins: 300,
104
+ extraConnections: 4,
105
+ maxLifeMs: 3 * 3_600_000,
106
+ noHumanMs: 10 * 60_000,
107
+ turnMs: 30_000,
108
+ graceMs: 60_000,
109
+ aiDelayMs: 900,
110
+ maxAiBatch: 64,
111
+ minClientVersion: 0,
112
+ emoteMs: 1000,
113
+ emoteBurst: 3,
114
+ meterBatch: 200,
115
+ closingMs: 60 * 60_000,
116
+ };
117
+
118
+ /** 闹钟里出错时,过这么久再醒来重试(不用"马上",免得每次都出错时连着计费)。 */
119
+ const ALARM_RETRY_MS = 60_000;
120
+
121
+ /** 计量说的门槛在内存里记多久(不进新人时用;报数、还名额时顺便更新)。 */
122
+ const LEVEL_TTL_MS = 30_000;
123
+
124
+ /** 计量连不上以后,过多久再试着报数(不每一步都去碰一次) */
125
+ const METER_RETRY_MS = 60_000;
126
+
127
+ /** 对外的事办失败了,第一次过这么久重试,之后每次翻倍,最多 EFFECT_RETRY_MAX_MS。 */
128
+ const EFFECT_RETRY_MS = 15_000;
129
+ const EFFECT_RETRY_MAX_MS = 10 * 60_000;
130
+ /** 一步之后最多连着办几轮对外的事(办完一笔可能引出下一笔,比如买入到账后开下一手)。 */
131
+ const EFFECT_ROUNDS = 4;
132
+
133
+ /** owners 里"绑了、但不是任何人":0.1.0 存下的已开打房间里的电脑座位(谁都不能接管)。userId 不会是空串 */
134
+ const NOBODY = '';
135
+
136
+ /** 游戏错误码的格式:固定的短码,不能夹带牌面之类的信息。 */
137
+ const ERROR_CODE_RE = /^[a-z][a-z0-9_]{0,31}$/;
138
+
139
+ // ---------- 存储里的那一行 ----------
140
+
141
+ interface SeatState {
142
+ kind: 'empty' | 'human' | 'ai';
143
+ userId: string | null;
144
+ name: string | null;
145
+ /** 掉线时刻;在线为 null */
146
+ awaySince: number | null;
147
+ /** 掉线超过宽限,电脑在代打 */
148
+ auto: boolean;
149
+ /** 这个座位最后一次做成的操作的 moveId(重发去重) */
150
+ lastMoveId: string | null;
151
+ }
152
+
153
+ interface Persist<S> {
154
+ /** 结构版本:只加字段,不改含义 */
155
+ sv: 1;
156
+ code: string;
157
+ /** 房主的 userId:只有房主能开局(9.1) */
158
+ host: string;
159
+ createdAt: number;
160
+ status: RoomStatus;
161
+ seq: number;
162
+ /** 牌局最后一次变化(开局、出了一步)时的 seq */
163
+ gameSeq: number;
164
+ /**
165
+ * 这一手(或这一组,RoomGame.actors)开始时的 gameSeq;客户端的 baseSeq 比它小就是 stale。
166
+ * 一手一个人时每出一步都是新的一手,它和 gameSeq 一样(就是 0.1.0 的判法);一组人时组里有人出手不变,后出的人不会因此被判 stale。
167
+ * 0.1.1 加的字段,读 0.1.0 存下的房间时补成 gameSeq
168
+ */
169
+ turnSeq: number;
170
+ seats: SeatState[];
171
+ game: S | null;
172
+ rulesVersion: number | null;
173
+ /** 当前这一手(或这一组)从什么时候开始算 */
174
+ turnSince: number | null;
175
+ /** 电脑一批没算完,什么时候接着算 */
176
+ aiAt: number | null;
177
+ /** 从什么时候起没有入座的真人连着(只连着没入座的人也算"没人",他们不能让房间一直活着) */
178
+ noHumanSince: number | null;
179
+ /** 一生收到的入站消息数,含会写库的重连(没写库的那些在内存里攒着,下次写库时一起记) */
180
+ inbound: number;
181
+ /** 上面这个数按 userId 分开记,只用来在关房日志里指出是谁发得最多 */
182
+ inboundBy: Record<string, number>;
183
+ /**
184
+ * 开打以后每个座位绑定的 userId(null 是还没有真人坐过的电脑座位)。开局时记下入座的人;
185
+ * 开打后坐过某个座位的人只能回这个座位,绑给别人的座位谁也接管不了(电脑一直替那个人打);
186
+ * 没坐过的人只能接管没绑人的电脑座位,接管后绑给他。这样一个人一局里只能看到一个座位的牌。
187
+ * 0.1.1 加的字段;0.1.0 存下的房间没有它,读出来时按当时的座位补上(loadPersist)
188
+ */
189
+ owners: (string | null)[];
190
+ /**
191
+ * 每个人"掉线后回来"(会写库的那种进房)的次数,见 RoomLimits.maxJoins。按 userId 记、不挂在座位对象上:
192
+ * 离座再坐下会换一个新座位,挂在座位上就会清零
193
+ */
194
+ joins: Record<string, number>;
195
+ /** 写了还没报给计量的行数(0.2.0;内存里的为准,随这一行一起存,被驱逐后接着数) */
196
+ unmetered?: number;
197
+ /** 对外的事办失败了,什么时候再试(0.3.0;null 是不用等) */
198
+ effectsAt?: number | null;
199
+ /** 连着失败了几次(退避用) */
200
+ effectTries?: number;
201
+ /** 正在关:人都断开了,留着存储把对外的事办完再删(0.3.0) */
202
+ ending?: { reason: string; at: number } | null;
203
+ /** 开房时定下的开局选项(0.4.0,RoomGame.options 校验过的);没有就是开打时由 start 定 */
204
+ opts?: unknown;
205
+ }
206
+
207
+ interface Attachment {
208
+ userId: string;
209
+ name: string;
210
+ }
211
+
212
+ const TABLE = 'kit_room';
213
+
214
+ /** 开房的结果(kitCreate → worker.ts 的 acceptCreate)。 */
215
+ export type CreateOutcome =
216
+ | { r: 'created' | 'exists' | 'bad' | 'bad_options' | 'error' | 'unavailable' | 'busy' | 'daily_limit' }
217
+ | { r: 'has_room'; code: string };
218
+
219
+ /** 房间里一步的结果:要广播的帧、给做这步的连接回 ack。 */
220
+ interface StepOut<S> {
221
+ /** 广播的帧:每个元素是一个状态,广播时按座位换成视图 */
222
+ frames: { state: S | null; delay: number }[];
223
+ ack?: { ws: WebSocket; moveId: string };
224
+ }
225
+
226
+ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends RoomEffect = RoomEffect> extends DurableObject<Env> {
227
+ protected abstract readonly game: RoomGame<S, A, V, O, E>;
228
+ protected readonly limits: RoomLimits = DEFAULT_LIMITS;
229
+
230
+ /**
231
+ * 去外面办一笔事(0.3.0,游戏写了 effects 时房间子类实现):比如经 DO 调用从 userId 的钱包扣买入。
232
+ * userId 是 effect.seat 绑定的人(没绑人的电脑座位是 null)。返回值原样交给 RoomGame.settled;抛错算失败,按退避重试。
233
+ * 同一笔可能被办两次(断线、被驱逐),办事的一方按 effect.id 去重。在 entry 之外 await,不打断"一步之内没有 await"
234
+ */
235
+ protected perform(_effect: E, _userId: string | null): Promise<unknown> {
236
+ return Promise.reject(new Error('GameRoom.perform is not implemented'));
237
+ }
238
+
239
+ /**
240
+ * 每一步广播完之后调一次(0.10.0,可选覆盖):state 是这一步结束时的牌局状态(服务端全量,含隐藏信息)。
241
+ * 给"电脑也会互动"用:房间子类看这一步的**公开**事件(谁出了什么、谁赢了一把),需要时调 botEmote 让电脑座位发一句短语。
242
+ * 同步执行、不能 await、不能写存储;抛错只记日志,不影响这一步。公平约束(游戏侧守):电脑发不发、发什么只看公开信息,
243
+ * 不读任何真人的短语、不读隐藏的牌。房间要收场(closing)时不调
244
+ */
245
+ protected onStep(_state: S): void {}
246
+
247
+ /**
248
+ * 真人发的短语校验通过、广播完之后调一次(0.10.1,可选覆盖):from 是发的座位,id 是编号,to 是对象座位(没指定是 undefined)。
249
+ * **只许用来调 botEmote 让电脑回一句**(被点名的电脑回嘴、全桌起哄),别的什么都不做:
250
+ * - 故意不给牌局状态:电脑的出牌决策(RoomGame.ai)绝不能读到真人的短语,游戏也不许把短语记进状态、字段或存储,留着以后喂给电脑;
251
+ * - 同步执行、不能 await、不能写存储;抛错只记日志,不影响已经广播的那一句;
252
+ * - 电脑自己的 botEmote 不会再触发它(不然几个电脑互相回个没完);回应同样走那个电脑座位的令牌桶,刷不出限速。
253
+ */
254
+ protected onEmote(_from: number, _id: number, _to?: number): void {}
255
+
256
+ /**
257
+ * 让电脑座位发一句快捷短语(0.10.0):广播的消息和真人发的一样({ t: 'emote', seat, id, to? }),多带 bot: true,客户端可以不管它。
258
+ * 只有电脑在出手的座位能发:kind 是 ai,或者真人掉线后由电脑托管(auto)。id、to 的校验和真人一样,走同一个座位的令牌桶
259
+ * (emoteMs / emoteBurst),不写存储、不改 seq。发出去了回 true;座位是真人在打、空位、编号不对、目标不对、房间打完了、太快了,回 false 不广播
260
+ */
261
+ protected botEmote(seat: number, id: number, to?: number): boolean {
262
+ const st = this.st;
263
+ const n = this.game.emotes;
264
+ if (!st || st.status === 'over' || !hasEmotes(n)) return false;
265
+ if (!Number.isInteger(seat) || seat < 0 || seat >= this.game.seats || !this.isAuto(seat)) return false;
266
+ if (!Number.isInteger(id) || id < 0 || id >= n) return false;
267
+ if (to !== undefined && (!Number.isInteger(to) || to < 0 || to >= this.game.seats || to === seat || st.seats[to]!.kind === 'empty')) return false;
268
+ if (!this.takeEmote(seat)) return false;
269
+ const msg: EmotedMsg = { t: 'emote', seat, id, ...(to !== undefined ? { to } : {}), bot: true };
270
+ for (const o of this.sockets()) if (attachmentOf(o)) this.send(o, msg);
271
+ return true;
272
+ }
273
+
274
+ /** 内存缓存,被驱逐后从存储重建 */
275
+ private st: Persist<S> | null | undefined;
276
+ /** 现在设着的闹钟;undefined 表示还不知道(刚建出来),进第一个入口前用 getAlarm() 读 */
277
+ private alarmAt: number | null | undefined;
278
+ /** 这一步要设的闹钟,entry 最后(广播之后)才真正 setAlarm */
279
+ private alarmToSet: number | null = null;
280
+ private pendingInbound = 0;
281
+ private readonly pendingBy = new Map<string, number>();
282
+ private readonly buckets = new WeakMap<WebSocket, { tokens: number; at: number }>();
283
+ /** 每个座位快捷短语的令牌桶(内存里;被驱逐就重置成满的,驱逐前至少空闲了一阵,本来也该满了) */
284
+ private readonly emoteBuckets = new Map<number, { tokens: number; at: number }>();
285
+ private emoteRateCache: EmoteRate | null = null;
286
+ /** 这一步写了几行(sql 的 rowsWritten + setAlarm、关房各 1);测试断言 ≤ 2 */
287
+ private writes = 0;
288
+ /** 每个入口结束时记下来,给测试看 */
289
+ lastStepWrites = 0;
290
+ maxStepWrites = 0;
291
+ /** 这个实例一共写了几行(测试断言"整轮"的写入) */
292
+ totalWrites = 0;
293
+ lastStepFailed = false;
294
+ /** 写了还没报给计量的行数 */
295
+ private unmetered = 0;
296
+ /** 这一步做完以后要还房主的名额(打完、关房),entry 最后去找计量 */
297
+ private release: { host: string; code: string } | null = null;
298
+ /** 计量最近一次说的门槛(内存;被驱逐就重新问) */
299
+ private level: { value: MeterLevel; at: number } | null = null;
300
+ /** 计量连不上时,什么时候再试着报数 */
301
+ private meterRetryAt = 0;
302
+ /** 计量说满了:在打的房间走到 breakPoint 就收场 */
303
+ private winding = false;
304
+ /** 这一步走到了收场点:commit 广播完就关房 */
305
+ private closing = false;
306
+ /** 正在办的对外的事(内存;被驱逐就重新办,办事的一方按 id 去重) */
307
+ private readonly inflight = new Set<string>();
308
+ private effectsBusy = false;
309
+ /** 游戏写了 pause 没写 resume,已经记过日志(0.5.0;每个实例只记一次) */
310
+ private warnedNoResume = false;
311
+ /** 每个实例不一样(测试用来确认真的被驱逐、构造函数重跑过) */
312
+ readonly instanceId = crypto.randomUUID();
313
+
314
+ constructor(ctx: DurableObjectState, env: Env) {
315
+ super(ctx, env);
316
+ // 构造函数里不碰存储:探一个不存在的房间码不能留下任何东西
317
+ // 心跳不唤醒房间、不计费(4.1)
318
+ ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(PING, PONG));
319
+ }
320
+
321
+ // ---------- 入口 ----------
322
+
323
+ /**
324
+ * 开房(游戏 Worker 经 RPC 调,见 worker.ts 的 acceptCreate)。绑了计量时先占名额(按人、全站、当天写入),
325
+ * 占不到就不建房间、这个房间对象什么都不写;占到了却没建成,退回去。
326
+ */
327
+ async kitCreate(code: string, host: Identity, rawOpts?: unknown): Promise<CreateOutcome> {
328
+ // 开房就定选项(0.4.0):不合格的不占名额、不建房间
329
+ const fixed = rawOpts !== undefined && this.game.options ? this.checkOpts(rawOpts) : null;
330
+ if (fixed === undefined) return { r: 'bad_options' };
331
+ const meter = this.meter();
332
+ let reserved = false;
333
+ // 码不对、房间已经在(码撞了)的先挡掉,不去占名额;这两样 create 里还会再查一遍
334
+ if (meter && validHost(code, host) && !this.load()) {
335
+ try {
336
+ const r = await meter.kitReserve(host.userId, code, Date.now() + this.limits.maxLifeMs);
337
+ if (!r.ok) return r.error === 'has_room' ? { r: 'has_room', code: r.code } : { r: r.error };
338
+ reserved = true;
339
+ } catch (e) {
340
+ console.error('meter unreachable, refusing new room', e instanceof Error ? e.message : e);
341
+ return { r: 'unavailable' };
342
+ }
343
+ }
344
+ const before = this.maxStepWrites;
345
+ const out = await this.entry(() => this.create(code, host, fixed?.opts));
346
+ // 开房那一步另算(含建表),不计入"每步最多 2 行"的统计;房间已经在的(exists)没写东西,照常计
347
+ if (out === 'created') this.maxStepWrites = before;
348
+ else if (reserved && meter) await meter.kitRelease(host.userId, code, 0, true).catch(() => console.error('meter release failed', code));
349
+ return { r: out instanceof Response ? 'error' : out };
350
+ }
351
+
352
+ /** 进房:只认 kit 自己拼的内部地址(worker.ts 的 acceptJoin),身份在 x-kit-user 头里。 */
353
+ override async fetch(request: Request): Promise<Response> {
354
+ const url = new URL(request.url);
355
+ if (url.hostname !== KIT_HOST || url.pathname !== '/ws' || request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
356
+ return new Response('not a kit request', { status: 400 });
357
+ }
358
+ const me = parseIdentity(request.headers.get('x-kit-user'));
359
+ if (!me) return new Response('no identity', { status: 401 });
360
+ const gate = await this.newcomerGate(me.userId);
361
+ if (gate !== null) return this.refuse(gate);
362
+ return this.entry(() => this.join(me, url.searchParams.get('cv')));
363
+ }
364
+
365
+ override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
366
+ await this.entry(() => this.onMessage(ws, message));
367
+ }
368
+
369
+ override async webSocketClose(ws: WebSocket, code: number): Promise<void> {
370
+ await this.entry(() => {
371
+ // 对方先关的:回一个关闭帧把握手做完,不然对方一直等
372
+ try {
373
+ ws.close(code === 1005 || code === 1006 ? 1000 : code, 'bye');
374
+ } catch {
375
+ // 已经关了
376
+ }
377
+ this.onGone(ws);
378
+ });
379
+ }
380
+
381
+ override async webSocketError(ws: WebSocket): Promise<void> {
382
+ await this.entry(() => this.onGone(ws));
383
+ }
384
+
385
+ override async alarm(): Promise<void> {
386
+ // 闹钟里出错不往外抛:抛出去会自动重试最多 6 次,每次都计费(4.4)
387
+ try {
388
+ // 运行时叫醒我们时这个闹钟已经用掉了
389
+ this.alarmAt = null;
390
+ await this.entry(() => this.onAlarm());
391
+ // 出错时状态可能没走到"重设闹钟"那一步:房间还在、又没有闹钟,就过一会儿再醒来,不然房间永远没人关
392
+ if (this.lastStepFailed && this.alarmAt === null && this.load()) {
393
+ this.alarmAt = Date.now() + ALARM_RETRY_MS;
394
+ this.lastStepWrites++;
395
+ this.totalWrites++;
396
+ this.unmetered++;
397
+ this.maxStepWrites = Math.max(this.maxStepWrites, this.lastStepWrites);
398
+ await this.ctx.storage.setAlarm(this.alarmAt);
399
+ }
400
+ } catch (e) {
401
+ console.error('room alarm failed', e instanceof Error ? e.message : e);
402
+ }
403
+ }
404
+
405
+ /**
406
+ * 包装层:先清这一步的写入计数,跑游戏代码(同步,中间没有 await),记下写了几行,最后才设闹钟(广播之后,4.5)。
407
+ * 游戏代码抛错只记日志:fetch 回 500,消息和关闭事件吞掉;内存缓存作废(下次从存储读),已经算好的闹钟照样设
408
+ * (状态可能已经写进去了;闹钟设早了没关系,醒来会重新算)。
409
+ */
410
+ private async entry<T>(run: () => T): Promise<T | Response> {
411
+ if (this.alarmAt === undefined) this.alarmAt = await this.ctx.storage.getAlarm();
412
+ this.writes = 0;
413
+ this.closing = false;
414
+ let out: T | Response;
415
+ this.lastStepFailed = false;
416
+ try {
417
+ out = run();
418
+ } catch (e) {
419
+ console.error('room handler failed', e instanceof Error ? e.message : e);
420
+ this.lastStepFailed = true;
421
+ this.st = undefined;
422
+ this.pendingInbound = 0;
423
+ this.pendingBy.clear();
424
+ out = new Response('room error', { status: 500 });
425
+ }
426
+ const at = this.alarmToSet;
427
+ this.alarmToSet = null;
428
+ if (at !== null) this.writes++;
429
+ this.lastStepWrites = this.writes;
430
+ this.totalWrites += this.writes;
431
+ this.unmetered += this.writes;
432
+ this.maxStepWrites = Math.max(this.maxStepWrites, this.writes);
433
+ if (at !== null) await this.ctx.storage.setAlarm(at);
434
+ await this.afterStep();
435
+ return out;
436
+ }
437
+
438
+ // ---------- 计量(0.2.0) ----------
439
+
440
+ /**
441
+ * 计量的绑定:默认是 env.METER。没绑(0.1.x 的游戏还没加绑定)就不限额、不计数——这只为平滑升级,正式环境一定要绑。
442
+ * 游戏不用覆盖它;kit 的测试靠它换一个能"断线"的计量
443
+ */
444
+ protected meterNamespace(): DurableObjectNamespace | undefined {
445
+ return (this.env as unknown as MeterEnv | undefined)?.METER;
446
+ }
447
+
448
+ private meter(): DurableObjectStub<RoomMeter> | null {
449
+ const ns = this.meterNamespace();
450
+ return ns ? (meterStub(ns) as DurableObjectStub<RoomMeter>) : null;
451
+ }
452
+
453
+ /**
454
+ * 一步做完、广播完以后找计量:要还名额的还(带上最后一批写入),没有就看写入攒够一批没有,够了报一次。
455
+ * 先从 unmetered 里扣掉再 await,报失败了加回去:中间别的事件进来不会把同一批报两次。
456
+ * 被驱逐时存下的 unmetered 可能还没扣掉刚报过的那批,会再报一次,偏保守。
457
+ */
458
+ private async afterStep(): Promise<void> {
459
+ const meter = this.meter();
460
+ if (!meter) {
461
+ this.release = null;
462
+ this.unmetered = 0;
463
+ return;
464
+ }
465
+ const now = Date.now();
466
+ const release = this.release;
467
+ // 计量刚连不上过:房间还在的等一会儿再试;房间已经关了的只有这一次机会
468
+ const due = release !== null || this.unmetered >= this.limits.meterBatch;
469
+ if (due && (now >= this.meterRetryAt || !this.st)) {
470
+ const n = this.unmetered;
471
+ this.release = null;
472
+ this.unmetered = 0;
473
+ try {
474
+ const level = release ? await meter.kitRelease(release.host, release.code, n) : await meter.kitReport(n);
475
+ this.level = { value: level, at: Date.now() };
476
+ this.meterRetryAt = 0;
477
+ } catch (e) {
478
+ console.error('meter report failed', e instanceof Error ? e.message : e);
479
+ // 房间还在的(打完了、还在给大家看结果),下次接着报、接着还;关掉了的就丢了,名额到点自动作废
480
+ if (this.st) {
481
+ this.unmetered += n;
482
+ this.release ??= release;
483
+ }
484
+ this.meterRetryAt = now + METER_RETRY_MS;
485
+ }
486
+ }
487
+ if (this.level?.value === 'stop' && !this.winding) {
488
+ this.winding = true;
489
+ await this.entry(() => this.windDown());
490
+ }
491
+ await this.runEffects();
492
+ }
493
+
494
+ /** 计量说满了:没在打(等人、打完了)的、正停在收场点的、游戏没有收场点的,马上关;在打的等 step 走到收场点。 */
495
+ private windDown(): void {
496
+ const st = this.load();
497
+ if (!st || st.ending) return;
498
+ if (st.status !== 'playing' || !this.game.breakPoint || this.game.breakPoint(st.game as S)) {
499
+ console.error('daily writes reached stop, closing room', st.code);
500
+ this.closeRoom(CLOSE_REASON.quota);
501
+ }
502
+ }
503
+
504
+ /**
505
+ * 今天额度快用完时不进新人(计量说 busy、stop):房主、坐着的、开打后绑了座位的、已经连着的照常进。
506
+ * 计量连不上时也不进新人,但用 1013 让客户端过一会儿重试。只读,不写库。返回关闭码,放行为 null
507
+ */
508
+ private async newcomerGate(userId: string): Promise<number | null> {
509
+ const meter = this.meter();
510
+ const st = this.load();
511
+ if (!meter || !st || st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null) return null;
512
+ if (this.sockets().some((o) => attachmentOf(o)?.userId === userId)) return null;
513
+ const now = Date.now();
514
+ if (!this.level || now - this.level.at >= LEVEL_TTL_MS) {
515
+ try {
516
+ this.level = { value: await meter.kitLevel(), at: now };
517
+ } catch (e) {
518
+ console.error('meter unreachable, refusing newcomer', e instanceof Error ? e.message : e);
519
+ return CLOSE.tryLater;
520
+ }
521
+ }
522
+ return this.level.value === 'ok' ? null : CLOSE.busy;
523
+ }
524
+
525
+ /** 进房被拒:接受升级后马上用关闭码关掉(浏览器拿不到 HTTP 状态码)。 */
526
+ private refuse(code: number): Response {
527
+ const pair = new WebSocketPair();
528
+ this.ctx.acceptWebSocket(pair[1]);
529
+ pair[1].close(code, code === CLOSE.busy ? 'busy' : 'try later');
530
+ return new Response(null, { status: 101, webSocket: pair[0] });
531
+ }
532
+
533
+ // ---------- 开房、进房 ----------
534
+
535
+ private create(code: string, host: Identity, opts: O | undefined): 'created' | 'exists' | 'bad' {
536
+ if (!validHost(code, host)) return 'bad';
537
+ if (this.load()) return 'exists';
538
+ // 只在开房时建表(一个房间只这一次,SQLite 记建表也算写入,如实计数)
539
+ this.writes += this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS ${TABLE} (k INTEGER PRIMARY KEY, v TEXT NOT NULL)`).rowsWritten;
540
+ const now = Date.now();
541
+ const seats: SeatState[] = Array.from({ length: this.game.seats }, () => emptySeat());
542
+ // 房主先坐 0 号位,但还没连上:算掉线,连上就"回来"
543
+ seats[0] = humanSeat(host.userId, host.name, now);
544
+ this.st = {
545
+ sv: 1, code, host: host.userId, createdAt: now, status: 'waiting', seq: 0, gameSeq: 0, turnSeq: 0, seats, game: null, rulesVersion: null,
546
+ turnSince: null, aiAt: null, noHumanSince: now, inbound: 0, inboundBy: {}, joins: {}, owners: seats.map(() => null),
547
+ ...(opts !== undefined ? { opts } : {}),
548
+ };
549
+ // 没人连进来也要能关掉:设闹钟(10 分钟没真人就关)
550
+ this.planAlarm();
551
+ this.save();
552
+ return 'created';
553
+ }
554
+
555
+ /** 进房(含重连):接受、记座位、发快照,中间没有 await(4.5 续局的写法)。 */
556
+ private join(me: Identity, rawCv: string | null): Response {
557
+ const pair = new WebSocketPair();
558
+ const [client, server] = [pair[0], pair[1]];
559
+ this.ctx.acceptWebSocket(server);
560
+ const done = () => new Response(null, { status: 101, webSocket: client });
561
+ const st = this.load();
562
+ if (!st) {
563
+ server.close(CLOSE.noRoom, 'no room');
564
+ return done();
565
+ }
566
+ if (st.ending) {
567
+ server.close(CLOSE.roomClosed, st.ending.reason);
568
+ return done();
569
+ }
570
+ const cv = Number(rawCv ?? '0');
571
+ if (!Number.isFinite(cv) || cv < this.limits.minClientVersion) {
572
+ server.close(CLOSE.tooOld, 'client too old');
573
+ return done();
574
+ }
575
+ const seat = this.seatOf(me.userId);
576
+ const others = this.sockets().filter((o) => o !== server && attachmentOf(o));
577
+ const mine = others.filter((o) => attachmentOf(o)!.userId === me.userId);
578
+ if (seat === null) {
579
+ // 没入座的连接单独封顶;入座的人不占这个数,旁观的再多也挡不住座位的主人回来
580
+ const watching = others.filter((o) => o !== server && this.seatOf(attachmentOf(o)!.userId) === null && !mine.includes(o));
581
+ if (watching.length >= this.limits.extraConnections) {
582
+ server.close(CLOSE.full, 'room full');
583
+ return done();
584
+ }
585
+ }
586
+ // 会写库的进房:入座的人掉线后回来,或者房间里原本没有入座的人连着
587
+ const s = seat === null ? null : st.seats[seat]!;
588
+ const changed = s !== null && (s.awaySince !== null || s.auto || s.name !== me.name || st.noHumanSince !== null);
589
+ // 消息额度用完的人进不来(不然断开以后重连还能接着发)
590
+ if (this.inboundOf(st, me.userId) >= this.userInboundCap()) {
591
+ server.close(CLOSE.userQuota, 'too many messages');
592
+ return done();
593
+ }
594
+ const used = st.joins[me.userId] ?? 0;
595
+ if (s && changed && used >= this.limits.maxJoins) {
596
+ server.close(CLOSE.tooManyJoins, 'too many joins');
597
+ return done();
598
+ }
599
+ // 会写库的进房也算一条入站消息:房间一生的写入总数因此被 maxInbound 封住
600
+ if (s && changed) {
601
+ const r = this.countInbound(st, me.userId);
602
+ if (r === 'user') {
603
+ server.close(CLOSE.userQuota, 'too many messages');
604
+ return done();
605
+ }
606
+ if (r === 'room') {
607
+ this.closeRoom();
608
+ return done();
609
+ }
610
+ }
611
+ // 同一个账号只留一条连接:旧的让位
612
+ for (const other of mine) {
613
+ other.serializeAttachment(null);
614
+ other.close(CLOSE.replaced, 'replaced');
615
+ }
616
+ server.serializeAttachment({ userId: me.userId, name: me.name } satisfies Attachment);
617
+
618
+ if (s && changed) {
619
+ // 托管被收回、而且正轮到他:这一手从现在重新算 30 秒。只是掉线没到托管就回来的,不重置(不然隔 29 秒重连一次就能一直拖着)
620
+ // 一组人时不重置:组的期限中途不改,和"组里有人出手不重新计时"一致
621
+ if (s.auto && st.status === 'playing' && st.turnSince !== null && seat !== null && this.onlyActor(seat)) st.turnSince = Date.now();
622
+ // 回来了:交还座位(电脑托管也收回)
623
+ s.awaySince = null;
624
+ s.auto = false;
625
+ s.name = me.name;
626
+ st.joins[me.userId] = used + 1;
627
+ st.noHumanSince = null;
628
+ }
629
+ // 状态没变(没入座的人进来、在线时又从别的设备连一次)不写库、不广播
630
+ if (changed) this.commit({ frames: [{ state: st.game, delay: 0 }] }, server);
631
+ this.send(server, this.welcome(seat, this.boundOf(me.userId)));
632
+ return done();
633
+ }
634
+
635
+ // ---------- 消息 ----------
636
+
637
+ private onMessage(ws: WebSocket, message: string | ArrayBuffer): void {
638
+ const st = this.load();
639
+ const me = attachmentOf(ws);
640
+ if (!st || !me || st.ending) return void ws.close(CLOSE.roomClosed, 'room closed');
641
+ const bytes = typeof message === 'string' ? new TextEncoder().encode(message).byteLength : message.byteLength;
642
+ if (bytes > this.limits.maxMessageBytes) return void ws.close(CLOSE.tooBig, 'message too big');
643
+ if (!this.takeToken(ws)) return void ws.close(CLOSE.policy, 'too fast');
644
+ const counted = this.countInbound(st, me.userId);
645
+ if (counted === 'user') return void ws.close(CLOSE.userQuota, 'too many messages');
646
+ if (counted === 'room') return this.closeRoom();
647
+
648
+ let msg: Record<string, unknown>;
649
+ try {
650
+ const parsed: unknown = typeof message === 'string' ? JSON.parse(message) : null;
651
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
652
+ msg = parsed as Record<string, unknown>;
653
+ } catch {
654
+ return this.reject(ws, 'bad_message');
655
+ }
656
+ const seat = this.seatOf(me.userId);
657
+ switch (msg.t) {
658
+ case 'sit':
659
+ return this.sit(ws, me, seat, msg.seat);
660
+ case 'leave':
661
+ return this.leave(seat);
662
+ case 'start':
663
+ return this.start(ws, me, seat, msg.opts);
664
+ case 'act':
665
+ return this.act(ws, seat, msg.moveId, msg.baseSeq, msg.action);
666
+ case 'emote':
667
+ return this.emote(ws, seat, msg.id, msg.to);
668
+ default:
669
+ return this.reject(ws, 'bad_message');
670
+ }
671
+ }
672
+
673
+ /**
674
+ * 快捷短语(0.6.0):校验编号、按座位限频,然后只广播,不写存储、不改 seq。
675
+ * 入站已经在 onMessage 里记过(占令牌桶和这个人的那一份额度),刷不出房间一生的上限以外。
676
+ * to(0.7.0,可选):冲着哪个座位发(扔道具、点名嘲讽)。得是有人(真人或电脑)的别的座位,不然回 bad_target;被拒的不算这个座位的一句
677
+ */
678
+ private emote(ws: WebSocket, seat: number | null, raw: unknown, to: unknown): void {
679
+ const st = this.st!;
680
+ const n = this.game.emotes;
681
+ if (!hasEmotes(n)) return this.reject(ws, 'no_emotes');
682
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0 || raw >= n) return this.reject(ws, 'bad_emote');
683
+ if (seat === null) return this.reject(ws, 'not_seated');
684
+ if (st.status === 'over') return this.reject(ws, 'room_over');
685
+ if (to !== undefined && (typeof to !== 'number' || !Number.isInteger(to) || to < 0 || to >= this.game.seats || to === seat || st.seats[to]!.kind === 'empty')) {
686
+ return this.reject(ws, 'bad_target');
687
+ }
688
+ if (!this.takeEmote(seat)) return this.reject(ws, 'emote_too_fast');
689
+ const msg: EmotedMsg = { t: 'emote', seat, id: raw, ...(to !== undefined ? { to } : {}) };
690
+ for (const o of this.sockets()) if (attachmentOf(o)) this.send(o, msg);
691
+ try {
692
+ this.onEmote(seat, raw, to as number | undefined);
693
+ } catch (e) {
694
+ console.error('onEmote threw', st.code, e instanceof Error ? e.message : e);
695
+ }
696
+ }
697
+
698
+ /** 快捷短语的节奏:KIT_QUOTA 里写了就用那里的,没写用 RoomLimits(只算一次) */
699
+ private emoteRate(): EmoteRate {
700
+ this.emoteRateCache ??= emoteRateOf((this.env as unknown as MeterEnv | undefined)?.KIT_QUOTA, { ms: this.limits.emoteMs, burst: this.limits.emoteBurst });
701
+ return this.emoteRateCache;
702
+ }
703
+
704
+ private takeEmote(seat: number): boolean {
705
+ const { ms, burst } = this.emoteRate();
706
+ if (ms <= 0) return true;
707
+ const now = Date.now();
708
+ const b = this.emoteBuckets.get(seat) ?? { tokens: burst, at: now };
709
+ b.tokens = Math.min(burst, b.tokens + (now - b.at) / ms);
710
+ b.at = now;
711
+ this.emoteBuckets.set(seat, b);
712
+ if (b.tokens < 1) return false;
713
+ b.tokens -= 1;
714
+ return true;
715
+ }
716
+
717
+ private sit(ws: WebSocket, me: Attachment, current: number | null, raw: unknown): void {
718
+ const st = this.st!;
719
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0 || raw >= this.game.seats) return this.reject(ws, 'bad_seat');
720
+ if (current === raw) return this.reject(ws, 'already_seated');
721
+ const target = st.seats[raw]!;
722
+ if (st.status === 'over') return this.reject(ws, 'room_over');
723
+ if (st.status === 'waiting') {
724
+ // 开房时定了选项的(0.4.0):这一局用不到的座位现在就不让坐,免得房主开打时才被 seat_closed 拒
725
+ if (st.opts !== undefined && raw >= this.countOf(st.opts as O)) return this.reject(ws, 'seat_closed');
726
+ if (target.kind !== 'empty') return this.reject(ws, 'seat_taken');
727
+ if (current !== null) st.seats[current] = emptySeat();
728
+ } else {
729
+ // 开打以后只能坐电脑在打的座位,而且得是还没坐下的人
730
+ if (target.kind !== 'ai') return this.reject(ws, 'seat_taken');
731
+ if (current !== null) return this.reject(ws, 'already_seated');
732
+ // 座位绑人:坐过的只能回自己那个座位,别人的座位(主人离座了也算)谁也接管不了。
733
+ // 不然 A 可以离座、接管对手的电脑座位看牌、再离座回到自己的座位
734
+ const mine = st.owners.indexOf(me.userId);
735
+ if (mine !== -1 && mine !== raw) return this.reject(ws, 'not_your_seat');
736
+ if (mine === -1 && st.owners[raw] !== null) return this.reject(ws, 'seat_reserved');
737
+ st.owners[raw] = me.userId;
738
+ }
739
+ st.seats[raw] = humanSeat(me.userId, me.name, null);
740
+ // 入座的人连着:房间不再算"没人"
741
+ st.noHumanSince = null;
742
+ const frames = [{ state: st.game, delay: 0 }];
743
+ if (st.status === 'playing') {
744
+ const now = Date.now();
745
+ // 接管的正好是这一手唯一要出的人:从现在算。一组人时不重置整组的期限
746
+ if (this.onlyActor(raw)) st.turnSince = now;
747
+ this.seatChanged(raw, 'human', now);
748
+ frames[0] = { state: st.game, delay: 0 };
749
+ // 桌上没真人时电脑停着(runAuto);有人接管了别的座位,轮到的电脑要接着出,不然没人能让牌局往下走
750
+ frames.push(...this.runAuto(now));
751
+ }
752
+ this.commit({ frames });
753
+ }
754
+
755
+ private leave(seat: number | null): void {
756
+ const st = this.st!;
757
+ if (seat === null) return;
758
+ // 等人时空出来;开打后由电脑接手(不判负),轮到它就接着出。座位还绑着他,留下昵称给牌桌显示"电脑代某某打"
759
+ st.seats[seat] = st.status === 'playing' ? { ...aiSeat(), name: st.seats[seat]!.name } : emptySeat();
760
+ if (!this.seatedOnline()) st.noHumanSince ??= Date.now();
761
+ if (st.status === 'playing') this.seatChanged(seat, 'ai', Date.now());
762
+ const frames = [{ state: st.game, delay: 0 }];
763
+ if (st.status === 'playing') frames.push(...this.runAuto(Date.now()));
764
+ this.commit({ frames });
765
+ }
766
+
767
+ private start(ws: WebSocket, me: Attachment, seat: number | null, rawOpts: unknown): void {
768
+ const st = this.st!;
769
+ // 房主选择开打(9.1)
770
+ if (me.userId !== st.host) return this.reject(ws, 'not_host');
771
+ if (st.status !== 'waiting') return this.reject(ws, 'already_started');
772
+ if (seat === null) return this.reject(ws, 'not_seated');
773
+ // 开局选项(0.3.0):游戏校验;这一局用几个座位,后面的座位一直空着。
774
+ // 开房时定了的(0.4.0)以开房的为准,start 带的 opts 不看(大家进房时看到的就是开打时的)
775
+ const fixed = st.opts !== undefined ? { opts: st.opts as O, count: this.countOf(st.opts as O) } : null;
776
+ const checked = fixed ?? this.checkOpts(rawOpts);
777
+ if (!checked) return this.reject(ws, 'bad_options');
778
+ const { opts, count } = checked;
779
+ if (st.seats.some((s, i) => i >= count && s.kind === 'human')) return this.reject(ws, 'seat_closed');
780
+ // 空座位由电脑补满;入座的人从这一刻起绑在自己的座位上
781
+ st.seats = st.seats.map((s, i) => (s.kind === 'empty' && i < count ? aiSeat() : s));
782
+ st.owners = st.seats.map((s) => (s.kind === 'human' ? s.userId : null));
783
+ st.rulesVersion = this.game.rulesVersion;
784
+ st.game = this.begin(st.code, { opts, seats: st.seats.map((s) => s.kind) });
785
+ st.gameSeq = st.seq + 1;
786
+ st.turnSeq = st.gameSeq;
787
+ st.status = 'playing';
788
+ const now = Date.now();
789
+ st.turnSince = now;
790
+ if (!this.game.over(st.game)) this.stalled(this.actorsOf(st.game));
791
+ this.commit({ frames: [{ state: st.game, delay: 0 }, ...this.runAuto(now)] });
792
+ }
793
+
794
+ /**
795
+ * 开局拿随机:写了 begin 的给 256 位熵(只在这里用一次,房间不存;游戏要接着发牌就把它放进自己的状态,那只在服务端)。
796
+ * 只有 start 的旧游戏照旧给 32 位种子,记一条警告:这种种子能被玩家用手牌反推出来。
797
+ */
798
+ private begin(code: string, setup: RoomSetup<O>): S {
799
+ const rv = this.game.rulesVersion;
800
+ if (this.game.begin) return this.game.begin(Array.from(crypto.getRandomValues(new Uint32Array(ENTROPY_WORDS))), rv, setup);
801
+ console.warn('game has no begin(); 32-bit seed can be brute-forced from a hand', code);
802
+ return this.game.start(crypto.getRandomValues(new Uint32Array(1))[0]!, rv);
803
+ }
804
+
805
+ private act(ws: WebSocket, seat: number | null, moveId: unknown, baseSeq: unknown, raw: unknown): void {
806
+ const st = this.st!;
807
+ if (typeof moveId !== 'string' || moveId.length < 1 || moveId.length > 64) return this.reject(ws, 'bad_message');
808
+ if (typeof baseSeq !== 'number' || !Number.isInteger(baseSeq)) return this.reject(ws, 'bad_message', moveId);
809
+ if (seat === null) return this.reject(ws, 'not_seated', moveId);
810
+ const s = st.seats[seat]!;
811
+ // 重发的同一个操作:不再做一遍,只回 ack 和当前状态
812
+ if (s.lastMoveId === moveId) {
813
+ const bound = this.boundOf(attachmentOf(ws)!.userId);
814
+ return this.send(ws, this.stateMsg(seat, bound, [{ state: st.game, delay: 0 }], moveId));
815
+ }
816
+ if (st.status !== 'playing') return this.reject(ws, 'not_playing', moveId);
817
+ const action = this.game.parse(raw);
818
+ if (action === null) return this.reject(ws, 'bad_action', moveId);
819
+ // 不轮流的操作(0.3.0):不看轮没轮到、不判 stale
820
+ const free = this.game.free?.(action) === true;
821
+ // 做决定时看到的牌局已经过时(断线前点的、在别人出牌之后才送到的):不做,客户端看最新状态再出。
822
+ // 按这一手(组)开始的时候算:同一组里别人先点了,后点的人照样算数
823
+ if (!free && baseSeq < st.turnSeq) return this.reject(ws, 'stale', moveId);
824
+ if (!free && !this.actorsOf(st.game as S).includes(seat)) return this.reject(ws, 'not_your_turn', moveId);
825
+ const now = Date.now();
826
+ // 不轮流的操作有可能别人什么都看不出来(比如声明窗口里真人答"碰",0.7.1):先记下别人眼里的样子,做完比一比
827
+ const seen = free ? this.othersSee(seat) : null;
828
+ const out = this.step(seat, action, now, free);
829
+ if (out) return this.reject(ws, out, moveId);
830
+ s.lastMoveId = moveId;
831
+ const auto = this.runAuto(now);
832
+ if (seen !== null && auto.length === 0 && this.othersSee(seat) === seen) return this.commitQuiet(ws, seat, moveId);
833
+ this.commit({ frames: [{ state: st.game, delay: 0 }, ...auto], ack: { ws, moveId } });
834
+ }
835
+
836
+ /**
837
+ * 除了 seat 以外的人看到的一切(其他座位和没入座的人的视图、房间信息),拼成一个串用来比较(0.7.1)。
838
+ * 不只比在线的连接:掉线的人回来拿到的快照也不能和没这一步时不一样
839
+ */
840
+ private othersSee(seat: number): string {
841
+ const st = this.st!;
842
+ const game = st.game as S;
843
+ const views: unknown[] = [this.game.view(game, null)];
844
+ for (let i = 0; i < st.seats.length; i++) if (i !== seat) views.push(this.game.view(game, i));
845
+ return JSON.stringify([this.roomInfo(), views]);
846
+ }
847
+
848
+ /**
849
+ * 别人看不出来的一步(0.7.1):照常存(和闹钟,最多 2 行),只回给出手的连接,seq 不加。
850
+ * 广播出去(哪怕内容一样)或者 seq 跳一下,别人就知道有人做了点什么——比如声明窗口里有真人能碰、杠、胡。
851
+ * 回给他的消息 seq 和上一条一样、带着他的 ack 和 quiet:RoomClient 0.7.1 起认这种消息,旧页面会丢掉(只是自己的画面慢一步更新)
852
+ */
853
+ private commitQuiet(ws: WebSocket, seat: number, moveId: string): void {
854
+ if (!this.closing) {
855
+ this.planAlarm();
856
+ this.save();
857
+ }
858
+ this.send(ws, this.stateMsg(seat, this.boundOf(attachmentOf(ws)!.userId), [{ state: this.st!.game, delay: 0 }], moveId, true));
859
+ if (this.closing) {
860
+ console.error('daily writes reached stop, room closed at a break point', this.st!.code);
861
+ this.closeRoom(CLOSE_REASON.quota);
862
+ }
863
+ }
864
+
865
+ /** 连接断了:留着座位,记下掉线时间;没有入座的人连着就开始算"没人"的时间。没入座的人走了不写。 */
866
+ private onGone(ws: WebSocket): void {
867
+ const st = this.load();
868
+ const me = attachmentOf(ws);
869
+ ws.serializeAttachment(null);
870
+ if (!st || !me) return;
871
+ const stillHere = this.sockets().some((o) => o !== ws && attachmentOf(o)?.userId === me.userId);
872
+ if (stillHere) return;
873
+ const now = Date.now();
874
+ let changed = false;
875
+ const seat = this.seatOf(me.userId);
876
+ if (seat !== null && st.seats[seat]!.awaySince === null) {
877
+ st.seats[seat]!.awaySince = now;
878
+ changed = true;
879
+ }
880
+ if (seat !== null && st.noHumanSince === null && !this.seatedOnline(ws)) {
881
+ st.noHumanSince = now;
882
+ changed = true;
883
+ }
884
+ if (changed) this.commit({ frames: [{ state: st.game, delay: 0 }] }, ws);
885
+ }
886
+
887
+ // ---------- 闹钟 ----------
888
+
889
+ private onAlarm(): void {
890
+ const st = this.load();
891
+ if (!st) return;
892
+ const now = Date.now();
893
+ if (st.ending) return this.endingAlarm(st, now);
894
+ if (now >= st.createdAt + this.limits.maxLifeMs) return this.closeRoom();
895
+ if (st.noHumanSince !== null && now >= st.noHumanSince + this.limits.noHumanMs) return this.closeRoom();
896
+
897
+ const frames: { state: S | null; delay: number }[] = [];
898
+ let changed = false;
899
+ // 掉线超过宽限:电脑托管(只在开打以后有意义,等人时不管)
900
+ for (const s of st.status === 'playing' ? st.seats : []) {
901
+ if (s.kind === 'human' && s.awaySince !== null && !s.auto && now >= s.awaySince + this.limits.graceMs) {
902
+ s.auto = true;
903
+ changed = true;
904
+ }
905
+ }
906
+ if (st.status === 'playing') {
907
+ const due = this.deadline();
908
+ // 停够了(0.5.0):让游戏接着来一步,电脑在下面照常接着出
909
+ if (due !== null && now >= due && this.pauseOf(st.game as S) !== null) {
910
+ if (this.resumeStep(now)) frames.push({ state: st.game, delay: 0 });
911
+ changed = true;
912
+ }
913
+ // 真人这一手超时:代出(掉线还没到宽限的也是这样)。一组人时对组里还没出的真人逐个代出,
914
+ // 换成新的一手就停(新的一手从现在重新计时)
915
+ else if (due !== null && now >= due) {
916
+ const turnSeq = st.turnSeq;
917
+ let stuck = false;
918
+ for (const seat of this.actorsOf(st.game as S).filter((x) => !this.isAuto(x))) {
919
+ if (this.closing || st.status !== 'playing' || st.turnSeq !== turnSeq || !this.actorsOf(st.game as S).includes(seat)) break;
920
+ if (this.timeoutStep(seat, now)) frames.push({ state: st.game, delay: 0 });
921
+ else stuck = true;
922
+ }
923
+ // 有人代出不了(游戏的 timeout 和 ai 都给了不合法的操作):这一组的期限推到 ALARM_RETRY_MS 以后再试。
924
+ // 不推的话期限一直是过去的时间,闹钟会在当前时刻反复响、每次写一行
925
+ if (stuck && st.status === 'playing' && st.turnSeq === turnSeq) {
926
+ st.turnSince = now + ALARM_RETRY_MS - this.turnMsOf(st.game as S);
927
+ changed = true;
928
+ }
929
+ }
930
+ if (st.aiAt !== null && now >= st.aiAt) st.aiAt = null;
931
+ frames.push(...this.runAuto(now));
932
+ }
933
+ // 对外的事到了重试的时间:清掉等待,这一步之后(afterStep)接着办
934
+ if (st.effectsAt != null && now >= st.effectsAt) {
935
+ st.effectsAt = null;
936
+ changed = true;
937
+ }
938
+ changed ||= frames.length > 0;
939
+ if (changed) return this.commit({ frames: frames.length ? frames : [{ state: st.game, delay: 0 }] });
940
+ // 什么都没到期:只把闹钟改到新的最早时间,不写状态、不广播
941
+ this.planAlarm();
942
+ }
943
+
944
+ /** 只用一个闹钟:所有到期时间取最早的。 */
945
+ private nextDeadline(): number | null {
946
+ const st = this.st;
947
+ if (!st) return null;
948
+ // 正在关的房间只等两件事:重试对外的事、到点放弃
949
+ if (st.ending) return Math.min(st.ending.at + this.limits.closingMs, st.effectsAt ?? Infinity);
950
+ const ds = [st.createdAt + this.limits.maxLifeMs];
951
+ if (st.effectsAt != null) ds.push(st.effectsAt);
952
+ if (st.noHumanSince !== null) ds.push(st.noHumanSince + this.limits.noHumanMs);
953
+ if (st.status === 'playing') {
954
+ for (const s of st.seats) if (s.kind === 'human' && s.awaySince !== null && !s.auto) ds.push(s.awaySince + this.limits.graceMs);
955
+ const due = this.deadline();
956
+ if (due !== null) ds.push(due);
957
+ if (st.aiAt !== null) ds.push(st.aiAt);
958
+ }
959
+ return Math.min(...ds);
960
+ }
961
+
962
+ /**
963
+ * 只在比现在设的更早(或没设)时重设,因为每次 setAlarm 也算一行写入。
964
+ * alarmAt 马上改(同步),真正 setAlarm 留到 entry 最后:这中间别的事件看到的已经是新时间,不会把它往后改。
965
+ */
966
+ private planAlarm(): void {
967
+ const next = this.nextDeadline();
968
+ if (next === null || (this.alarmAt != null && this.alarmAt <= next)) return;
969
+ this.alarmAt = next;
970
+ this.alarmToSet = next;
971
+ }
972
+
973
+ // ---------- 规则和电脑 ----------
974
+
975
+ /**
976
+ * 做一步;不合法返回错误码。换成了新的一手(组)时,从 at 起计时。
977
+ * free(不轮流的操作):该谁出手没变就不算新的一手,不改期限和 turnSeq。
978
+ */
979
+ private step(seat: number, action: A, at: number, free = false): string | null {
980
+ const st = this.st!;
981
+ const before = this.turnOf(st.game as S);
982
+ let next: S | { error: string };
983
+ try {
984
+ next = this.game.apply(st.game as S, seat, action);
985
+ } catch (e) {
986
+ console.error('game.apply threw', e instanceof Error ? e.message : e);
987
+ return 'rule_error';
988
+ }
989
+ if (isError(next)) return ERROR_CODE_RE.test(next.error) ? next.error : 'illegal_move';
990
+ this.land(next, before, free ? null : seat, at);
991
+ return null;
992
+ }
993
+
994
+ /**
995
+ * 牌局换成 next 以后的收尾:打完了、卡住了、换了新的一手(组)。mover 是出手的座位;
996
+ * null 是不轮流的变化(free 操作、座位换人、对外的事办完):该谁出手原样没变就还是同一手。
997
+ */
998
+ private land(next: S, before: { seats: number[]; group: string | null }, mover: number | null, at: number): void {
999
+ const st = this.st!;
1000
+ const wasPaused = st.game !== null && this.pauseOf(st.game) !== null;
1001
+ st.game = next;
1002
+ st.gameSeq = st.seq + 1;
1003
+ const over = this.game.over(next);
1004
+ // 计量说满了:走到这一段的结尾(或者整场打完)就收场,电脑不再接着出
1005
+ if (this.winding && (over || this.game.breakPoint?.(next))) this.closing = true;
1006
+ if (over) {
1007
+ st.status = 'over';
1008
+ st.turnSince = null;
1009
+ this.release = { host: st.host, code: st.code };
1010
+ return;
1011
+ }
1012
+ const after = this.turnOf(next);
1013
+ if (this.stalled(after.seats)) return;
1014
+ const same = mover === null ? sameTurn(before, after) : sameGroup(before, mover, after);
1015
+ // 刚进入停顿(0.5.0)一定从现在算:从等外面的事变过来(前后都没人出手)、或者游戏给了同一个组标识的,上面会当成同一手
1016
+ const enteredPause = !wasPaused && after.seats.length === 0 && this.pauseOf(next) !== null;
1017
+ if (!same || enteredPause) {
1018
+ st.turnSeq = st.gameSeq;
1019
+ st.turnSince = at;
1020
+ }
1021
+ }
1022
+
1023
+ /** 开打以后座位换了人(0.3.0):告诉游戏(RoomGame.seated),和这一步一起写。游戏出错只记日志、牌局不动。 */
1024
+ private seatChanged(seat: number, kind: 'human' | 'ai', at: number): void {
1025
+ const st = this.st!;
1026
+ if (!this.game.seated || st.game === null) return;
1027
+ const before = this.turnOf(st.game);
1028
+ let next: S;
1029
+ try {
1030
+ next = this.game.seated(st.game, seat, kind);
1031
+ } catch (e) {
1032
+ console.error('game.seated threw', e instanceof Error ? e.message : e);
1033
+ return;
1034
+ }
1035
+ if (next !== st.game) this.land(next, before, null, at);
1036
+ }
1037
+
1038
+ /**
1039
+ * 牌局没打完、却没有任何座位能出手:游戏出错了,房间不然会一直卡着(没人能出、也不会超时)。
1040
+ * 记日志,按打完处理(不再计时、不再驱动电脑),房间之后照常按没人、寿命到期关掉。
1041
+ * 游戏说在等外面的事(RoomGame.waiting,0.3.2)的不算:停着等,牌局再变时重新看。
1042
+ */
1043
+ private stalled(seats: number[]): boolean {
1044
+ const st = this.st!;
1045
+ if (seats.length) return false;
1046
+ // 在停一会儿(0.5.0):到点由闹钟接着来,不算卡住
1047
+ if (this.pauseOf(st.game as S) !== null) return false;
1048
+ try {
1049
+ if (this.game.waiting?.(st.game as S) === true) return false;
1050
+ } catch (e) {
1051
+ console.error('game.waiting threw', e instanceof Error ? e.message : e);
1052
+ }
1053
+ console.error('game has nobody to act but is not over', st.code);
1054
+ st.status = 'over';
1055
+ st.turnSince = null;
1056
+ this.release = { host: st.host, code: st.code };
1057
+ return true;
1058
+ }
1059
+
1060
+ /** 超时代出:先用 timeout,被规则拒了再用 ai 试一次。都不行返回 false(记日志)。 */
1061
+ private timeoutStep(seat: number, now: number): boolean {
1062
+ const st = this.st!;
1063
+ let err = this.step(seat, (this.game.timeout ?? this.game.ai)(st.game as S, seat), now);
1064
+ if (err && this.game.timeout) err = this.step(seat, this.game.ai(st.game as S, seat), now);
1065
+ if (err) console.error('timeout and ai moves both rejected', st.code, seat, err);
1066
+ return !err;
1067
+ }
1068
+
1069
+ /**
1070
+ * 这一刻能出手的座位:游戏写了 actors 用它(去掉不合法、重复的),没写就是 [turn](和 0.1.0 一样)。
1071
+ * group 是游戏给的组标识(没给为 null),用来判断出完一步还是不是同一组。
1072
+ */
1073
+ private turnOf(state: S): { seats: number[]; group: string | null } {
1074
+ if (!this.game.actors) {
1075
+ const seat = this.game.turn(state);
1076
+ return { seats: seat === null ? [] : [seat], group: null };
1077
+ }
1078
+ const raw = this.game.actors(state);
1079
+ const list = Array.isArray(raw) ? raw : raw.seats;
1080
+ const group = !Array.isArray(raw) && typeof raw.group === 'string' ? raw.group : null;
1081
+ const seats: number[] = [];
1082
+ for (const s of list) if (Number.isInteger(s) && s >= 0 && s < this.game.seats && !seats.includes(s)) seats.push(s);
1083
+ return { seats, group };
1084
+ }
1085
+
1086
+ private actorsOf(state: S): number[] {
1087
+ return this.turnOf(state).seats;
1088
+ }
1089
+
1090
+ /** 这一手只有这一个座位要出(一个人的一手,不是一组)。 */
1091
+ private onlyActor(seat: number): boolean {
1092
+ const seats = this.actorsOf(this.st!.game as S);
1093
+ return seats.length === 1 && seats[0] === seat;
1094
+ }
1095
+
1096
+ private turnMsOf(state: S): number {
1097
+ const ms = this.game.turnMs?.(state);
1098
+ return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : this.limits.turnMs;
1099
+ }
1100
+
1101
+ /**
1102
+ * 这一手(组)的截止时间;没有在等真人(都是电脑、托管,或者没人要出)时为 null。
1103
+ * 在停一会儿时(0.5.0)是停够的时间:nextDeadline 把它算进唯一的闹钟。
1104
+ */
1105
+ private deadline(): number | null {
1106
+ const st = this.st!;
1107
+ if (st.status !== 'playing' || st.turnSince === null) return null;
1108
+ const state = st.game as S;
1109
+ const actors = this.actorsOf(state);
1110
+ if (!actors.length) {
1111
+ const ms = this.pauseOf(state);
1112
+ return ms === null ? null : st.turnSince + ms;
1113
+ }
1114
+ if (!actors.some((x) => !this.isAuto(x))) return null;
1115
+ return st.turnSince + this.turnMsOf(state);
1116
+ }
1117
+
1118
+ /** 停顿的截止时间(0.5.0):在打、在停时才有,给 RoomInfo.pause */
1119
+ private pausedUntil(): number | null {
1120
+ const st = this.st!;
1121
+ if (st.status !== 'playing' || st.game === null || this.pauseOf(st.game) === null) return null;
1122
+ return this.deadline();
1123
+ }
1124
+
1125
+ /**
1126
+ * 这一刻要停多久(0.5.0,RoomGame.pause):没人能出手、没打完、游戏给了正的有限数才算,否则 null。
1127
+ * 只写了 pause 没写 resume 的,停了也没法接着来,当作没有停顿(行为和 0.4.0 一样),只记一次日志免得每步刷屏。
1128
+ */
1129
+ private pauseOf(state: S): number | null {
1130
+ if (!this.game.pause) return null;
1131
+ if (!this.game.resume) {
1132
+ if (!this.warnedNoResume) console.error('game has pause() but no resume(); pause ignored');
1133
+ this.warnedNoResume = true;
1134
+ return null;
1135
+ }
1136
+ if (this.actorsOf(state).length || this.game.over(state)) return null;
1137
+ let ms: number | null;
1138
+ try {
1139
+ ms = this.game.pause(state);
1140
+ } catch (e) {
1141
+ console.error('game.pause threw', e instanceof Error ? e.message : e);
1142
+ return null;
1143
+ }
1144
+ return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : null;
1145
+ }
1146
+
1147
+ /**
1148
+ * 停够了:调游戏的 resume 做一步(和闹钟这一步一起写)。resume 抛错、或者返回的还在停,就不用它的结果,
1149
+ * 把截止时间推到 ALARM_RETRY_MS 以后再试——不推的话截止时间一直是过去的,闹钟会在当前时刻反复响、每次写一行。
1150
+ * 返回 true 是牌局往前走了。
1151
+ */
1152
+ private resumeStep(now: number): boolean {
1153
+ const st = this.st!;
1154
+ const state = st.game as S;
1155
+ const before = this.turnOf(state);
1156
+ let next: S | undefined;
1157
+ try {
1158
+ next = this.game.resume!(state);
1159
+ } catch (e) {
1160
+ console.error('game.resume threw', st.code, e instanceof Error ? e.message : e);
1161
+ }
1162
+ if (next !== undefined && this.pauseOf(next) === null) {
1163
+ this.land(next, before, null, now);
1164
+ return true;
1165
+ }
1166
+ if (next !== undefined) console.error('game.resume left the room paused', st.code);
1167
+ st.turnSince = now + ALARM_RETRY_MS - this.pauseOf(state)!;
1168
+ return false;
1169
+ }
1170
+
1171
+ private isAuto(seat: number): boolean {
1172
+ const s = this.st!.seats[seat]!;
1173
+ return s.kind === 'ai' || (s.kind === 'human' && s.auto);
1174
+ }
1175
+
1176
+ /** 轮到电脑(或托管)时一次算到下一个真人回合,每步带播放延迟(4.4);算不完的按闹钟接着算。 */
1177
+ private runAuto(now: number): { state: S | null; delay: number }[] {
1178
+ const st = this.st!;
1179
+ const frames: { state: S | null; delay: number }[] = [];
1180
+ // 桌上一个真人都没有(都离座了)就停着,不让电脑在空房间里自己打完(等有人接管或房间超时关掉)
1181
+ if (!st.seats.some((s) => s.kind === 'human')) return frames;
1182
+ while (st.status === 'playing' && !this.closing) {
1183
+ // 一组人时组里的电脑、托管一个个代出,剩下的真人各自点
1184
+ const seat = this.actorsOf(st.game as S).find((x) => this.isAuto(x));
1185
+ if (seat === undefined) break;
1186
+ if (frames.length >= this.limits.maxAiBatch) {
1187
+ st.aiAt = now + frames.length * this.limits.aiDelayMs;
1188
+ break;
1189
+ }
1190
+ let action: A;
1191
+ try {
1192
+ action = this.game.ai(st.game as S, seat);
1193
+ } catch (e) {
1194
+ console.error('game.ai threw', e instanceof Error ? e.message : e);
1195
+ break;
1196
+ }
1197
+ // 下一手的计时从电脑出完开始算(加上客户端播放的时间)
1198
+ const err = this.step(seat, action, now + (frames.length + 1) * this.limits.aiDelayMs);
1199
+ if (err) {
1200
+ console.error('game.ai made an illegal move', err);
1201
+ break;
1202
+ }
1203
+ frames.push({ state: st.game, delay: this.limits.aiDelayMs });
1204
+ }
1205
+ return frames;
1206
+ }
1207
+
1208
+ // ---------- 对外的事(0.3.0,RoomGame.effects) ----------
1209
+
1210
+ /** 游戏说还没办完的事;格式不对的丢掉、游戏出错当没有(都记日志)。 */
1211
+ private effectsOf(state: S): E[] {
1212
+ if (!this.game.effects || !this.game.settled) return [];
1213
+ try {
1214
+ return this.game.effects(state).filter((e) => {
1215
+ const ok = !!e && typeof e.id === 'string' && e.id.length > 0 && Number.isInteger(e.seat) && e.seat >= 0 && e.seat < this.game.seats;
1216
+ if (!ok) console.error('game.effects returned a bad effect');
1217
+ return ok;
1218
+ });
1219
+ } catch (e) {
1220
+ console.error('game.effects threw', e instanceof Error ? e.message : e);
1221
+ return [];
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * 一步做完以后(entry 之外)把没在办的事交给 perform,办完再用一步(entry)把结果写回状态。
1227
+ * 办完一笔可能引出下一笔,最多连着 EFFECT_ROUNDS 轮;有失败的就记下退避时间、交给闹钟。
1228
+ * 正在关的房间办完了就删掉。同一时间只有一处在跑(写回那一步的 afterStep 不会再进来)。
1229
+ */
1230
+ private async runEffects(): Promise<void> {
1231
+ if (this.effectsBusy || !this.game.effects || !this.game.settled) return;
1232
+ this.effectsBusy = true;
1233
+ try {
1234
+ for (let round = 0; round < EFFECT_ROUNDS; round++) {
1235
+ const st = this.load();
1236
+ if (!st || st.game === null || (st.effectsAt != null && st.effectsAt > Date.now())) return;
1237
+ const todo = this.effectsOf(st.game).filter((e) => !this.inflight.has(e.id));
1238
+ if (!todo.length) {
1239
+ if (st.ending) await this.entry(() => this.wipe(st.ending!.reason));
1240
+ return;
1241
+ }
1242
+ for (const e of todo) this.inflight.add(e.id);
1243
+ const owners = st.owners;
1244
+ const results = await Promise.allSettled(todo.map((e) => this.perform(e, owners[e.seat] || null)));
1245
+ for (const e of todo) this.inflight.delete(e.id);
1246
+ const done: { id: string; value: unknown }[] = [];
1247
+ let failed = false;
1248
+ results.forEach((r, i) => {
1249
+ if (r.status === 'fulfilled') done.push({ id: todo[i]!.id, value: r.value });
1250
+ else {
1251
+ failed = true;
1252
+ console.error('room effect failed', st.code, todo[i]!.id, r.reason instanceof Error ? r.reason.message : r.reason);
1253
+ }
1254
+ });
1255
+ const stuck = await this.entry(() => this.settle(done, failed));
1256
+ if (failed || stuck === true) return;
1257
+ }
1258
+ } finally {
1259
+ this.effectsBusy = false;
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * 把办完的事写回状态(1 行,需要时再设闹钟);有失败的记下退避时间。正在关的房间全办完了就删掉。
1265
+ * 返回 true:游戏的 settled 写错了,办完的事还留在 effects() 里。记日志、当失败交给退避,不在这一轮里接着重办(否则每步之后同一笔要办 EFFECT_ROUNDS 次)。
1266
+ */
1267
+ private settle(done: { id: string; value: unknown }[], failed: boolean): boolean {
1268
+ const st = this.load();
1269
+ if (!st || st.game === null) return false;
1270
+ const now = Date.now();
1271
+ const before = this.turnOf(st.game);
1272
+ const pending = new Set(this.effectsOf(st.game).map((e) => e.id));
1273
+ let next: S = st.game;
1274
+ for (const d of done) {
1275
+ if (!pending.has(d.id)) continue;
1276
+ try {
1277
+ next = this.game.settled!(next, d.id, d.value);
1278
+ } catch (e) {
1279
+ console.error('game.settled threw', e instanceof Error ? e.message : e);
1280
+ }
1281
+ }
1282
+ const left = new Set(this.effectsOf(next).map((e) => e.id));
1283
+ const stuck = done.filter((d) => left.has(d.id)).map((d) => d.id);
1284
+ if (stuck.length) {
1285
+ console.error('game.settled left effects pending', st.code, stuck.join(','));
1286
+ failed = true;
1287
+ }
1288
+ if (failed) {
1289
+ st.effectTries = (st.effectTries ?? 0) + 1;
1290
+ st.effectsAt = now + Math.min(EFFECT_RETRY_MAX_MS, EFFECT_RETRY_MS * 2 ** (st.effectTries - 1));
1291
+ } else {
1292
+ st.effectTries = 0;
1293
+ st.effectsAt = null;
1294
+ }
1295
+ if (st.ending) {
1296
+ st.game = next;
1297
+ if (!failed && left.size === 0) this.wipe(st.ending.reason);
1298
+ else {
1299
+ this.planAlarm();
1300
+ this.save();
1301
+ }
1302
+ return stuck.length > 0;
1303
+ }
1304
+ const frames: { state: S | null; delay: number }[] = [];
1305
+ if (next !== st.game) {
1306
+ this.land(next, before, null, now);
1307
+ frames.push({ state: st.game, delay: 0 }, ...(st.status === 'playing' ? this.runAuto(now) : []));
1308
+ }
1309
+ this.commit({ frames: frames.length ? frames : [{ state: st.game, delay: 0 }] });
1310
+ return stuck.length > 0;
1311
+ }
1312
+
1313
+ /** 正在关的房间醒来:到了 closingMs 还没办完就放弃(记日志删掉);到了重试时间就清掉等待,afterStep 接着办。 */
1314
+ private endingAlarm(st: Persist<S>, now: number): void {
1315
+ const ending = st.ending!;
1316
+ if (now >= ending.at + this.limits.closingMs) {
1317
+ console.error('room closing: effects still pending, giving up', st.code, this.effectsOf(st.game as S).map((e) => e.id).join(','));
1318
+ return this.wipe(ending.reason);
1319
+ }
1320
+ if (st.effectsAt != null && now >= st.effectsAt) {
1321
+ st.effectsAt = null;
1322
+ this.planAlarm();
1323
+ return this.save();
1324
+ }
1325
+ this.planAlarm();
1326
+ }
1327
+
1328
+ // ---------- 写库、广播 ----------
1329
+
1330
+ /**
1331
+ * 一步的收尾:seq 加一、算闹钟、写那一行,然后广播(都在同步代码里),闹钟留到 entry 最后设。
1332
+ * 这一步走到了收场点(closing):不写了,广播完直接关房,大家先看到这一副的结果再收到 4000 和原因。
1333
+ */
1334
+ private commit(out: StepOut<S>, exclude?: WebSocket): void {
1335
+ const st = this.st!;
1336
+ st.seq++;
1337
+ if (!this.closing) {
1338
+ this.planAlarm();
1339
+ this.save();
1340
+ }
1341
+ this.broadcast(out, exclude);
1342
+ if (this.closing) {
1343
+ console.error('daily writes reached stop, room closed at a break point', st.code);
1344
+ this.closeRoom(CLOSE_REASON.quota);
1345
+ return;
1346
+ }
1347
+ if (st.game != null) {
1348
+ try {
1349
+ this.onStep(st.game);
1350
+ } catch (e) {
1351
+ console.error('onStep threw', st.code, e instanceof Error ? e.message : e);
1352
+ }
1353
+ }
1354
+ }
1355
+
1356
+ /** 一个人在这个房间最多能用多少条入站额度。 */
1357
+ private userInboundCap(): number {
1358
+ return Math.max(1, Math.floor(this.limits.maxInbound * this.limits.maxInboundShare));
1359
+ }
1360
+
1361
+ private inboundOf(st: Persist<S>, userId: string): number {
1362
+ return (st.inboundBy[userId] ?? 0) + (this.pendingBy.get(userId) ?? 0);
1363
+ }
1364
+
1365
+ /**
1366
+ * 记一条入站(消息或会写库的重连)。'user':这个人的那一份用完了,调用方只断开他(这条不记,不写库);
1367
+ * 'room':房间一生的上限到了,调用方关房;'ok':照常。
1368
+ */
1369
+ private countInbound(st: Persist<S>, userId: string): 'ok' | 'user' | 'room' {
1370
+ if (this.inboundOf(st, userId) >= this.userInboundCap()) {
1371
+ console.error('room user inbound share reached', st.code, userId);
1372
+ return 'user';
1373
+ }
1374
+ this.pendingInbound++;
1375
+ this.pendingBy.set(userId, (this.pendingBy.get(userId) ?? 0) + 1);
1376
+ if (st.inbound + this.pendingInbound <= this.limits.maxInbound) return 'ok';
1377
+ // 只记 userId 和条数,不记消息内容
1378
+ let top = '';
1379
+ let most = 0;
1380
+ for (const id of new Set([...Object.keys(st.inboundBy), ...this.pendingBy.keys()])) {
1381
+ const n = (st.inboundBy[id] ?? 0) + (this.pendingBy.get(id) ?? 0);
1382
+ if (n > most) [top, most] = [id, n];
1383
+ }
1384
+ console.error('room inbound limit reached', st.code, 'top sender', top, most);
1385
+ return 'room';
1386
+ }
1387
+
1388
+ private save(): void {
1389
+ const st = this.st!;
1390
+ st.inbound += this.pendingInbound;
1391
+ this.pendingInbound = 0;
1392
+ for (const [id, n] of this.pendingBy) st.inboundBy[id] = (st.inboundBy[id] ?? 0) + n;
1393
+ this.pendingBy.clear();
1394
+ // 连同这一行、这一步要设的闹钟一起算上(entry 最后才加进内存里的 unmetered)
1395
+ st.unmetered = this.unmetered + this.writes + 1 + (this.alarmToSet !== null ? 1 : 0);
1396
+ const cur = this.ctx.storage.sql.exec(
1397
+ `INSERT INTO ${TABLE} (k, v) VALUES (1, ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v`,
1398
+ JSON.stringify(st),
1399
+ );
1400
+ this.writes += cur.rowsWritten;
1401
+ }
1402
+
1403
+ /** 只读:表不在(从没开过房、或者关房时连表一起删了)就是没有这个房间,不建表、不写任何东西。 */
1404
+ private load(): Persist<S> | null {
1405
+ if (this.st !== undefined) return this.st;
1406
+ const sql = this.ctx.storage.sql;
1407
+ const hasTable = sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, TABLE).toArray().length > 0;
1408
+ const row = hasTable ? sql.exec<{ v: string }>(`SELECT v FROM ${TABLE} WHERE k = 1`).toArray()[0] : undefined;
1409
+ this.st = row ? loadPersist<S>(row.v) : null;
1410
+ if (this.st) this.unmetered = this.st.unmetered ?? 0;
1411
+ return this.st;
1412
+ }
1413
+
1414
+ /**
1415
+ * 关房间:断开所有人、删闹钟、删存储(4.1),不留任何东西;房主的名额在 entry 最后还(afterStep)。
1416
+ * 游戏有对外的事(0.3.0):先让游戏记下收场要办的(RoomGame.closing),还有没办完的就断开所有人、留着存储接着办(ending),
1417
+ * 办完或者到了 closingMs 再删。
1418
+ */
1419
+ private closeRoom(reason = 'room closed'): void {
1420
+ const st = this.st;
1421
+ if (st && !st.ending && st.game !== null && this.game.effects && this.game.settled) {
1422
+ let final: S = st.game;
1423
+ try {
1424
+ if (this.game.closing) final = this.game.closing(st.game);
1425
+ } catch (e) {
1426
+ console.error('game.closing threw', e instanceof Error ? e.message : e);
1427
+ }
1428
+ if (this.effectsOf(final).length) {
1429
+ for (const ws of this.sockets()) {
1430
+ ws.serializeAttachment(null);
1431
+ ws.close(CLOSE.roomClosed, reason);
1432
+ }
1433
+ st.game = final;
1434
+ st.ending = { reason, at: Date.now() };
1435
+ st.effectsAt = null;
1436
+ st.effectTries = 0;
1437
+ this.release = { host: st.host, code: st.code };
1438
+ this.closing = false;
1439
+ this.planAlarm();
1440
+ this.save();
1441
+ return;
1442
+ }
1443
+ }
1444
+ this.wipe(reason);
1445
+ }
1446
+
1447
+ /** 真的删掉:断开所有人、删闹钟、删存储。 */
1448
+ private wipe(reason: string): void {
1449
+ for (const ws of this.sockets()) {
1450
+ ws.serializeAttachment(null);
1451
+ ws.close(CLOSE.roomClosed, reason);
1452
+ }
1453
+ if (this.st) this.release = { host: this.st.host, code: this.st.code };
1454
+ this.st = null;
1455
+ this.alarmToSet = null;
1456
+ this.alarmAt = null;
1457
+ this.writes++;
1458
+ void this.ctx.storage.deleteAlarm();
1459
+ void this.ctx.storage.deleteAll();
1460
+ }
1461
+
1462
+ private sockets(): WebSocket[] {
1463
+ return this.ctx.getWebSockets();
1464
+ }
1465
+
1466
+ /** 还有没有入座的真人连着(不算 except 这条) */
1467
+ private seatedOnline(except?: WebSocket): boolean {
1468
+ return this.sockets().some((o) => {
1469
+ const a = o === except ? null : attachmentOf(o);
1470
+ return a !== null && this.seatOf(a.userId) !== null;
1471
+ });
1472
+ }
1473
+
1474
+ /** 校验开局选项(游戏写了 options 时)和它定的座位数;不合格返回 undefined */
1475
+ private checkOpts(raw: unknown): { opts: O | undefined; count: number } | undefined {
1476
+ let opts: O | undefined;
1477
+ if (this.game.options) {
1478
+ const o = this.game.options(raw);
1479
+ if (o === null) return undefined;
1480
+ opts = o;
1481
+ }
1482
+ const count = this.countOf(opts);
1483
+ return Number.isInteger(count) && count >= 1 && count <= this.game.seats ? { opts, count } : undefined;
1484
+ }
1485
+
1486
+ private countOf(opts: O | undefined): number {
1487
+ return this.game.seatCount ? this.game.seatCount(opts) : this.game.seats;
1488
+ }
1489
+
1490
+ private seatOf(userId: string): number | null {
1491
+ const i = this.st?.seats.findIndex((s) => s.kind === 'human' && s.userId === userId) ?? -1;
1492
+ return i >= 0 ? i : null;
1493
+ }
1494
+
1495
+ private roomInfo(): RoomInfo {
1496
+ const st = this.st!;
1497
+ // owners 里是 userId,只下发"绑没绑",不下发是谁
1498
+ const seats: SeatInfo[] = st.seats.map((s, i) => {
1499
+ const reserved = st.owners[i] != null;
1500
+ return {
1501
+ kind: s.kind,
1502
+ name: s.kind === 'human' || (s.kind === 'ai' && reserved) ? s.name : null,
1503
+ away: s.kind === 'human' && s.awaySince !== null,
1504
+ auto: s.kind === 'human' && s.auto,
1505
+ reserved,
1506
+ };
1507
+ });
1508
+ let turn: RoomInfo['turn'] = null;
1509
+ if (st.status === 'playing') {
1510
+ const seats = this.actorsOf(st.game as S);
1511
+ if (seats.length) turn = { seat: seats[0]!, deadline: this.deadline(), ...(this.game.actors ? { seats } : {}) };
1512
+ }
1513
+ const until = this.pausedUntil();
1514
+ return {
1515
+ code: st.code,
1516
+ status: st.status,
1517
+ seats,
1518
+ turn,
1519
+ ...(st.opts !== undefined ? { opts: st.opts } : {}),
1520
+ ...(until !== null ? { pause: { until } } : {}),
1521
+ };
1522
+ }
1523
+
1524
+ /** 这个人开打后绑定的座位(离座了也还是它);还没开打、没坐过为 null。 */
1525
+ private boundOf(userId: string): number | null {
1526
+ const i = this.st!.owners.indexOf(userId);
1527
+ return i >= 0 ? i : null;
1528
+ }
1529
+
1530
+ private welcome(seat: number | null, bound: number | null): DownMsg<V> {
1531
+ const st = this.st!;
1532
+ return {
1533
+ t: 'welcome',
1534
+ v: PROTOCOL_VERSION,
1535
+ minClientVersion: this.limits.minClientVersion,
1536
+ seq: st.seq,
1537
+ you: { seat, bound },
1538
+ room: this.roomInfo(),
1539
+ view: st.game === null ? null : this.game.view(st.game, seat),
1540
+ // 有短语的游戏告诉页面限速的节奏(0.7.0),RoomClient 按它排队,用户感觉不到限速
1541
+ ...(hasEmotes(this.game.emotes) ? { emote: this.emoteRate() } : {}),
1542
+ };
1543
+ }
1544
+
1545
+ private stateMsg(seat: number | null, bound: number | null, frames: { state: S | null; delay: number }[], ack?: string, quiet = false): DownMsg<V> {
1546
+ const st = this.st!;
1547
+ const out: Frame<V>[] = frames.map((f) => ({ view: (f.state === null ? null : this.game.view(f.state, seat)) as V, delay: f.delay }));
1548
+ return { t: 'state', seq: st.seq, you: { seat, bound }, room: this.roomInfo(), frames: out, ...(ack ? { ack } : {}), ...(quiet ? { quiet: true as const } : {}) };
1549
+ }
1550
+
1551
+ /** 按座位过滤后广播:同一个座位(加上绑定的座位,没入座的人各自不同)的消息只算一次。只发给已经收到快照(有 attachment)的连接。 */
1552
+ private broadcast(out: StepOut<S>, exclude?: WebSocket): void {
1553
+ const bySeat = new Map<string, DownMsg<V>>();
1554
+ for (const ws of this.sockets()) {
1555
+ if (ws === exclude) continue;
1556
+ const me = attachmentOf(ws);
1557
+ if (!me) continue;
1558
+ const seat = this.seatOf(me.userId);
1559
+ const bound = this.boundOf(me.userId);
1560
+ const key = `${seat}|${bound}`;
1561
+ const isAcked = out.ack?.ws === ws;
1562
+ let msg = isAcked ? undefined : bySeat.get(key);
1563
+ if (!msg) {
1564
+ msg = this.stateMsg(seat, bound, out.frames, isAcked ? out.ack!.moveId : undefined);
1565
+ if (!isAcked) bySeat.set(key, msg);
1566
+ }
1567
+ this.send(ws, msg);
1568
+ }
1569
+ }
1570
+
1571
+ private send(ws: WebSocket, msg: DownMsg<V> | EmotedMsg): void {
1572
+ try {
1573
+ ws.send(JSON.stringify(msg));
1574
+ } catch {
1575
+ // 连接正在关,忽略:webSocketClose 会处理座位
1576
+ }
1577
+ }
1578
+
1579
+ private reject(ws: WebSocket, code: string, moveId?: string): void {
1580
+ this.send(ws, { t: 'error', code, ...(moveId ? { moveId } : {}) });
1581
+ }
1582
+
1583
+ /** 每个连接一个令牌桶(内存里;被驱逐后重置成满的,驱逐前至少空闲了一阵,本来也该满了)。 */
1584
+ private takeToken(ws: WebSocket): boolean {
1585
+ const now = Date.now();
1586
+ const b = this.buckets.get(ws) ?? { tokens: this.limits.rateBurst, at: now };
1587
+ b.tokens = Math.min(this.limits.rateBurst, b.tokens + ((now - b.at) / 1000) * this.limits.ratePerSec);
1588
+ b.at = now;
1589
+ if (b.tokens < 1) return false;
1590
+ b.tokens -= 1;
1591
+ this.buckets.set(ws, b);
1592
+ return true;
1593
+ }
1594
+ }
1595
+
1596
+ /** 读存储里的那一行;0.1.0 存下的房间没有后来加的字段,按当时的状态补上(结构只加字段,不改含义)。 */
1597
+ function loadPersist<S>(raw: string): Persist<S> {
1598
+ const st = JSON.parse(raw) as Persist<S>;
1599
+ // 已经开打的:坐着的真人绑他自己;电脑座位一律当作已绑(NOBODY),谁都不能接管——
1600
+ // 0.1.0 没记谁离过座,放开的话离过座的人读出来就成了"没坐过",又能去换座位看牌。等人阶段开局时才绑
1601
+ st.owners ??= st.seats.map((s) => (st.status === 'waiting' ? null : s.kind === 'human' ? s.userId : NOBODY));
1602
+ st.turnSeq ??= st.gameSeq;
1603
+ return st;
1604
+ }
1605
+
1606
+ /**
1607
+ * 出完这一步还是不是同一组。不管给没给组标识,都要"出手的人离开名单、没有新座位加入":
1608
+ * 这样一组里每个人最多出一次(或被代出一次),期限不会停在过去(游戏每次都给同一个 group 时,每一手照样是新的一手)。
1609
+ * 给了组标识(任何一边):再要求两边标识一样(别的人跟着离开也行)。
1610
+ * 没给就推断:组里不止一个人,出手的那个离开、其他人原样留着(不多不少);一手一个人时永远不是(和 0.1.0 一样)。
1611
+ */
1612
+ function sameGroup(
1613
+ before: { seats: number[]; group: string | null },
1614
+ seat: number,
1615
+ after: { seats: number[]; group: string | null },
1616
+ ): boolean {
1617
+ const b = before.seats;
1618
+ const a = after.seats;
1619
+ const shrank = a.every((x) => x !== seat && b.includes(x));
1620
+ if (before.group !== null || after.group !== null) return shrank && before.group !== null && before.group === after.group;
1621
+ return shrank && b.length > 1 && a.length === b.length - 1;
1622
+ }
1623
+
1624
+ function validHost(code: string, host: Identity): boolean {
1625
+ return ROOM_CODE_RE.test(code) && !!host && typeof host.userId === 'string' && !!host.userId && typeof host.name === 'string';
1626
+ }
1627
+
1628
+ function emptySeat(): SeatState {
1629
+ return { kind: 'empty', userId: null, name: null, awaySince: null, auto: false, lastMoveId: null };
1630
+ }
1631
+
1632
+ function aiSeat(): SeatState {
1633
+ return { kind: 'ai', userId: null, name: null, awaySince: null, auto: false, lastMoveId: null };
1634
+ }
1635
+
1636
+ function humanSeat(userId: string, name: string, awaySince: number | null): SeatState {
1637
+ return { kind: 'human', userId, name, awaySince, auto: false, lastMoveId: null };
1638
+ }
1639
+
1640
+ function isError(x: unknown): x is { error: string } {
1641
+ return !!x && typeof x === 'object' && 'error' in x && typeof (x as { error: unknown }).error === 'string' && Object.keys(x).length === 1;
1642
+ }
1643
+
1644
+ function attachmentOf(ws: WebSocket): Attachment | null {
1645
+ const a = ws.deserializeAttachment() as Attachment | null | undefined;
1646
+ return a && typeof a.userId === 'string' ? a : null;
1647
+ }
1648
+
1649
+ /** 该谁出手原样没变(座位一样、组标识一样):不轮流的变化之后还是同一手。 */
1650
+ function sameTurn(before: { seats: number[]; group: string | null }, after: { seats: number[]; group: string | null }): boolean {
1651
+ return before.group === after.group && before.seats.length === after.seats.length && before.seats.every((x, i) => after.seats[i] === x);
1652
+ }
1653
+
1654
+ /** 快捷短语的节奏(0.7.0):每 ms 毫秒补 1 个令牌、最多攒 burst 个 */
1655
+ export interface EmoteRate {
1656
+ ms: number;
1657
+ burst: number;
1658
+ }
1659
+
1660
+ function hasEmotes(n: number | undefined): n is number {
1661
+ return typeof n === 'number' && Number.isInteger(n) && n > 0;
1662
+ }
1663
+
1664
+ /** KIT_QUOTA(对象或 JSON 字符串)里的 emoteMs / emoteBurst 覆盖默认值;不合格的忽略。burst 至少 1 */
1665
+ export function emoteRateOf(raw: unknown, base: EmoteRate): EmoteRate {
1666
+ let v: unknown = raw;
1667
+ if (typeof raw === 'string') {
1668
+ try {
1669
+ v = JSON.parse(raw);
1670
+ } catch {
1671
+ v = null;
1672
+ }
1673
+ }
1674
+ const q = v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
1675
+ const ok = (x: unknown): x is number => typeof x === 'number' && Number.isInteger(x) && x >= 0;
1676
+ return { ms: ok(q.emoteMs) ? q.emoteMs : base.ms, burst: Math.max(1, ok(q.emoteBurst) ? q.emoteBurst : base.burst) };
1677
+ }