@yyyu-star/arcade-kit 0.19.1 → 0.20.1

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 (57) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +48 -2
  3. package/dist/client.d.ts +44 -1
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +103 -0
  6. package/dist/client.js.map +1 -1
  7. package/dist/fun-logic.d.ts +8 -0
  8. package/dist/fun-logic.d.ts.map +1 -1
  9. package/dist/fun-logic.js +12 -1
  10. package/dist/fun-logic.js.map +1 -1
  11. package/dist/game.d.ts +7 -0
  12. package/dist/game.d.ts.map +1 -1
  13. package/dist/game.js +38 -0
  14. package/dist/game.js.map +1 -1
  15. package/dist/internal.d.ts +4 -0
  16. package/dist/internal.d.ts.map +1 -1
  17. package/dist/internal.js +6 -0
  18. package/dist/internal.js.map +1 -1
  19. package/dist/match.d.ts +85 -0
  20. package/dist/match.d.ts.map +1 -0
  21. package/dist/match.js +361 -0
  22. package/dist/match.js.map +1 -0
  23. package/dist/meter.d.ts +38 -0
  24. package/dist/meter.d.ts.map +1 -1
  25. package/dist/meter.js +56 -14
  26. package/dist/meter.js.map +1 -1
  27. package/dist/protocol.d.ts +35 -0
  28. package/dist/protocol.d.ts.map +1 -1
  29. package/dist/protocol.js.map +1 -1
  30. package/dist/room.d.ts +48 -1
  31. package/dist/room.d.ts.map +1 -1
  32. package/dist/room.js +157 -17
  33. package/dist/room.js.map +1 -1
  34. package/dist/table-chat.d.ts +5 -1
  35. package/dist/table-chat.d.ts.map +1 -1
  36. package/dist/table-chat.js +25 -2
  37. package/dist/table-chat.js.map +1 -1
  38. package/dist/table-fun.d.ts +31 -7
  39. package/dist/table-fun.d.ts.map +1 -1
  40. package/dist/table-fun.js +31 -14
  41. package/dist/table-fun.js.map +1 -1
  42. package/dist/worker.d.ts +12 -0
  43. package/dist/worker.d.ts.map +1 -1
  44. package/dist/worker.js +44 -1
  45. package/dist/worker.js.map +1 -1
  46. package/package.json +5 -1
  47. package/src/client.ts +118 -1
  48. package/src/fun-logic.ts +14 -1
  49. package/src/game.ts +40 -0
  50. package/src/internal.ts +8 -0
  51. package/src/match.ts +371 -0
  52. package/src/meter.ts +68 -12
  53. package/src/protocol.ts +41 -0
  54. package/src/room.ts +171 -18
  55. package/src/table-chat.ts +26 -3
  56. package/src/table-fun.ts +53 -14
  57. package/src/worker.ts +44 -2
package/src/client.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * 浏览器里的计时器(重连、心跳)可以用 setTimeout,"只用一个闹钟"是房间(Durable Object)那边的规矩。
18
18
  */
19
19
  import { type ChatSendResult, checkChat } from './chat.js';
20
- import { type ChattedMsg, CLOSE, CLOSE_REASON, type CreateError, type DownMsg, type EmotedMsg, isFinalClose, PING, PONG, PROTOCOL_VERSION, type RoomInfo, type UpMsg } from './protocol.js';
20
+ import { type ChattedMsg, CLOSE, CLOSE_REASON, type CreateError, type DownMsg, type EmotedMsg, isFinalClose, type MatchDownMsg, type MatchSoloWhy, PING, PONG, PROTOCOL_VERSION, type RoomInfo, type UpMsg } from './protocol.js';
21
21
 
22
22
  export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'failed';
23
23
 
@@ -547,6 +547,123 @@ export function closeText(code: number, reason?: string, overrides: RoomTexts =
547
547
  return overrides[key] ?? ROOM_TEXT[key];
548
548
  }
549
549
 
550
+ // ---------- 快速匹配(0.20.0,docs/match.md) ----------
551
+
552
+ export type { MatchSoloWhy };
553
+
554
+ export interface MatchClientOptions {
555
+ /** 排队地址(游戏 Worker 接 acceptMatch 的那条路由,ws:// 或 wss://,带上档位) */
556
+ url: string;
557
+ /** 人数变了:n 是排着几个人(含自己),seats 一桌最多几个真人,leftMs 离"不等了"还剩多久(页面自己倒计时) */
558
+ onWaiting?(n: number, seats: number, leftMs: number): void;
559
+ /** 凑成一桌了:用 code 照常进房(RoomClient),这条排队连接已经关了 */
560
+ onMatched(code: string): void;
561
+ /** 不排了,转本机单机(同档位);why 给页面挑一句话(MATCH_TEXT) */
562
+ onSolo(why: MatchSoloWhy): void;
563
+ /** 已经有一个开着的房间:带他回去(不给就当 onMatched) */
564
+ onHasRoom?(code: string): void;
565
+ /** 排队最多等多久(毫秒,和排队那边的 waitMs 一样,默认 15 秒):没收到人数推送时,兜底计时按它算 */
566
+ waitMs?: number;
567
+ WebSocketImpl?: { new (url: string): WebSocketLike };
568
+ }
569
+
570
+ /**
571
+ * 兜底(安全审阅 0.20.0):排队那边到点了还没给结果(平台、房间卡住,或者连接静默死掉),再多等这么久就自己转单机(unavailable),
572
+ * 不让页面一直停在"等待中"
573
+ */
574
+ export const MATCH_GRACE_MS = 10_000;
575
+
576
+ /** 转单机时给玩家看的话(游戏可以换掉个别措辞) */
577
+ export const MATCH_TEXT: Record<MatchSoloWhy, string> = {
578
+ alone: '现在没人在排,先和电脑打',
579
+ daily_limit: '今天联机局数用完了,先和电脑打',
580
+ busy: '联机人多,先和电脑打',
581
+ unavailable: '暂时排不了,先和电脑打',
582
+ too_often: '排得太频繁了,先和电脑打一局',
583
+ replaced: '你在别的页面排上了,这里不排了',
584
+ };
585
+
586
+ /**
587
+ * 快速匹配的连接器:start() 连上就是在排,cancel() 关掉就是不排了。断线不重连:排队不值得重连,直接 onSolo('unavailable')。
588
+ * 结果只回一次(matched、solo、has_room 三选一),之后什么都不回调
589
+ */
590
+ export class MatchClient {
591
+ private ws: WebSocketLike | null = null;
592
+ private done = false;
593
+ private timer: ReturnType<typeof setTimeout> | null = null;
594
+
595
+ constructor(private readonly opts: MatchClientOptions) {}
596
+
597
+ start(): void {
598
+ if (this.ws || this.done) return;
599
+ this.arm((this.opts.waitMs ?? 15_000) + MATCH_GRACE_MS);
600
+ const Impl = this.opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as { new (url: string): WebSocketLike });
601
+ const ws = new Impl(this.opts.url);
602
+ this.ws = ws;
603
+ ws.onmessage = (ev) => {
604
+ if (typeof ev.data !== 'string' || ev.data === PONG) return;
605
+ let m: MatchDownMsg;
606
+ try {
607
+ m = JSON.parse(ev.data) as MatchDownMsg;
608
+ } catch {
609
+ return;
610
+ }
611
+ if (m.t === 'waiting') {
612
+ if (this.done) return;
613
+ this.arm(m.left + MATCH_GRACE_MS);
614
+ this.opts.onWaiting?.(m.n, m.seats, m.left);
615
+ } else if (m.t === 'matched') this.end(() => this.opts.onMatched(m.code));
616
+ else if (m.t === 'has_room') this.end(() => (this.opts.onHasRoom ?? this.opts.onMatched)(m.code));
617
+ else if (m.t === 'solo') this.end(() => this.opts.onSolo(m.why));
618
+ };
619
+ ws.onclose = (ev) => {
620
+ const why: MatchSoloWhy = ev.code === CLOSE.replaced ? 'replaced' : ev.code === CLOSE.tooManyJoins ? 'too_often' : ev.code === CLOSE.tryLater ? 'busy' : 'unavailable';
621
+ this.end(() => this.opts.onSolo(why));
622
+ };
623
+ ws.onerror = () => {};
624
+ }
625
+
626
+ /** 不排了("取消"或"不等了,和电脑打"):关掉连接,之后不再回调 */
627
+ cancel(): void {
628
+ this.done = true;
629
+ this.disarm();
630
+ const ws = this.ws;
631
+ this.ws = null;
632
+ try {
633
+ ws?.close(1000, 'cancel');
634
+ } catch {
635
+ // 已经关了
636
+ }
637
+ }
638
+
639
+ private end(fn: () => void): void {
640
+ if (this.done) return;
641
+ this.done = true;
642
+ this.disarm();
643
+ this.ws = null;
644
+ fn();
645
+ }
646
+
647
+ /** 到点还没有结果:关掉连接,自己转单机 */
648
+ private arm(ms: number): void {
649
+ this.disarm();
650
+ this.timer = setTimeout(() => {
651
+ const ws = this.ws;
652
+ this.end(() => this.opts.onSolo('unavailable'));
653
+ try {
654
+ ws?.close(1000, 'no answer');
655
+ } catch {
656
+ // 已经关了
657
+ }
658
+ }, Math.max(0, ms));
659
+ }
660
+
661
+ private disarm(): void {
662
+ if (this.timer !== null) clearTimeout(this.timer);
663
+ this.timer = null;
664
+ }
665
+ }
666
+
550
667
  // ---------- 有没有平台(0.10.0,独立部署时页面降级用) ----------
551
668
 
552
669
  /**
package/src/fun-logic.ts CHANGED
@@ -443,6 +443,19 @@ export function pickLane(freeAt: readonly number[], now: number): number {
443
443
  * 上面两个角从屏幕高度的 9% 往下:几个游戏的顶栏(回大厅、设置)都在那一条里,kit 不知道它们的位置
444
444
  */
445
445
  export function placeDock(view: { width: number; height: number }, w: number, h: number, avoid: readonly Box[] = [], margin = 12): { left: number; top: number } {
446
+ const { left, top } = dockSpot(view, w, h, avoid, margin);
447
+ return { left, top };
448
+ }
449
+
450
+ /**
451
+ * 四个角里有没有一个一点都不压禁区(0.20.0):没有的话,玩家又没自己展开过,电脑版聊天框默认收成"聊天"小按钮。
452
+ * 掼蛋 1280×720 上四个角都放不下,压得最少的左上角正好盖住左家的头像,点不到他、也看不到剩几张
453
+ */
454
+ export function dockFits(view: { width: number; height: number }, w: number, h: number, avoid: readonly Box[] = [], margin = 12): boolean {
455
+ return dockSpot(view, w, h, avoid, margin).cost === 0;
456
+ }
457
+
458
+ function dockSpot(view: { width: number; height: number }, w: number, h: number, avoid: readonly Box[], margin: number): { left: number; top: number; cost: number } {
446
459
  const l = margin;
447
460
  const r = Math.max(margin, view.width - margin - w);
448
461
  const t = Math.max(margin, Math.round(view.height * 0.09));
@@ -457,7 +470,7 @@ export function placeDock(view: { width: number; height: number }, w: number, h:
457
470
  if (cost === 0) break;
458
471
  }
459
472
  }
460
- return best;
473
+ return { ...best, cost: bestCost };
461
474
  }
462
475
 
463
476
  /**
package/src/game.ts CHANGED
@@ -209,3 +209,43 @@ export function snapshotBytes<S, A, V, O, E extends RoomEffect>(game: RoomGame<S
209
209
  }
210
210
  return max;
211
211
  }
212
+
213
+ /**
214
+ * 快速匹配的配置对不对得上房间(0.20.0,安全审阅):每个开放的档位,排队那边的 tableSize(一桌几个真人)和 roomOptions(开桌带的选项)
215
+ * 要过得了这张桌的 RoomGame。对不上的话房间每桌都回 bad / bad_options、排到的人都转单机,线上只看得到日志。
216
+ * 游戏在自己的测试里调:`expect(matchConfigErrors(game, tiers, (t) => queue.tableSize(t), (t) => queue.roomOptions(t))).toEqual([])`。
217
+ * 返回每个问题一句话,没问题是空数组。纯函数,不碰运行时(普通 Node 测试能用)
218
+ */
219
+ export function matchConfigErrors<S, A, V, O, E extends RoomEffect>(
220
+ game: RoomGame<S, A, V, O, E>,
221
+ tiers: readonly string[],
222
+ tableSize: (tier: string) => number,
223
+ roomOptions: (tier: string) => unknown = () => undefined,
224
+ ): string[] {
225
+ const out: string[] = [];
226
+ for (const tier of tiers) {
227
+ const raw = roomOptions(tier);
228
+ let opts: O | undefined;
229
+ if (raw !== undefined) {
230
+ if (!game.options) {
231
+ out.push(`${tier}: roomOptions given but the game has no options()`);
232
+ continue;
233
+ }
234
+ const o = game.options(raw);
235
+ if (o === null) {
236
+ out.push(`${tier}: roomOptions rejected by game.options()`);
237
+ continue;
238
+ }
239
+ opts = o;
240
+ } else if (game.options && game.options(undefined) === null) {
241
+ out.push(`${tier}: game.options() needs roomOptions for this tier`);
242
+ continue;
243
+ }
244
+ const count = game.seatCount ? game.seatCount(opts) : game.seats;
245
+ const size = tableSize(tier);
246
+ if (!Number.isInteger(size) || size < 2) out.push(`${tier}: tableSize ${size} must be an integer ≥ 2`);
247
+ else if (size > count) out.push(`${tier}: tableSize ${size} is more than the ${count} seats this table plays`);
248
+ else if (size > 9) out.push(`${tier}: tableSize ${size} is more than 9 (blockedAmong limit)`);
249
+ }
250
+ return out;
251
+ }
package/src/internal.ts CHANGED
@@ -73,3 +73,11 @@ export const LIMITED_HEADER = 'x-kit-limited';
73
73
  export function meterStub(ns: DurableObjectNamespace): DurableObjectStub {
74
74
  return ns.get(ns.idFromName(METER_NAME), { locationHint: 'apac' });
75
75
  }
76
+
77
+ /** 快速匹配的排队(0.20.0):每个档位一个实例,和房间一样放在亚太 */
78
+ export function matchStub(ns: DurableObjectNamespace, tier: string): DurableObjectStub {
79
+ return ns.get(ns.idFromName(`kit-match:${tier}`), { locationHint: 'apac' });
80
+ }
81
+
82
+ /** 档位 key 的格式:游戏给的短字符串(如 real-2、fun-normal) */
83
+ export const TIER_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
package/src/match.ts ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * 快速匹配的排队(0.20.0,设计 docs/match.md):游戏 Worker 里的 Durable Object 继承它,填上一桌几个真人,就能按档位排队凑桌。
3
+ * 每个档位一个实例(matchStub 按档位取),浏览器经 worker.ts 的 acceptMatch 连进来,连接本身就是"排着"。
4
+ *
5
+ * 守的规矩(和房间同一套,docs/platform-backend.md 第 4 节):
6
+ * - 0 行存储:排着的人就是 ctx.getWebSockets(),附件里只放 userId、昵称、开始排的时间;被驱逐后名单还在;
7
+ * - 只用一个闹钟:最早那个人等满 waitMs 的时刻,只往早了调(setAlarm 也算一行写入);
8
+ * - 只用 ctx.acceptWebSocket,不用 setTimeout / setInterval;排队连接只收不发,浏览器发什么都 1008 断开;
9
+ * - 不碰牌局:不 import 规则、不读房间状态。电脑补位、公平底线全在房间里,和好友房一样;
10
+ * - 名额由房间开桌时向计量一次占齐(kitCreateMatch),排队本身不占。
11
+ */
12
+ import { DurableObject } from 'cloudflare:workers';
13
+ import { type Identity, KIT_HOST, newRoomCode, parseIdentity, roomStub } from './internal.js';
14
+ import { CLOSE, type MatchDownMsg, type MatchSoloWhy } from './protocol.js';
15
+ import type { MatchOutcome } from './room.js';
16
+
17
+ export type { MatchDownMsg, MatchSoloWhy } from './protocol.js';
18
+
19
+ /** 排队对象要的绑定:房间 ROOM(开桌),平台 ARCADE(可选,屏蔽名单;单独部署没有) */
20
+ export interface MatchEnv {
21
+ ROOM: DurableObjectNamespace;
22
+ ARCADE?: Fetcher;
23
+ }
24
+
25
+ interface Waiter {
26
+ userId: string;
27
+ name: string;
28
+ /** 开始排的时间(毫秒) */
29
+ since: number;
30
+ /** 档位(一个实例只有一个档位;放在附件里,被驱逐后闹钟醒来还知道) */
31
+ tier: string;
32
+ }
33
+
34
+ /** 开桌时房间码撞了最多再换几次 */
35
+ const CODE_TRIES = 5;
36
+ /** 一次凑桌最多重来几轮(有人被计量挡掉、请出去以后接着凑) */
37
+ const FORM_ROUNDS = 8;
38
+ /** 平台屏蔽名单一次最多问几个人(平台 blockedAmong 的上限,德州 9 人桌) */
39
+ const BLOCK_MAX = 9;
40
+ /**
41
+ * 凑桌时找平台、找房间最多等多久(毫秒,安全审阅 0.20.0):凑桌持着"正在凑"的锁,平台或房间卡住时整个档位都会一直"等待中"。
42
+ * 超时的屏蔽名单按空的算,建房按 unavailable(这一桌的人转单机)
43
+ */
44
+ export const MATCH_CALL_MS = 3_000;
45
+
46
+ export abstract class MatchQueue<Env extends MatchEnv = MatchEnv> extends DurableObject<Env> {
47
+ /** 一桌最多几个真人(凑满就开)。按档位不同的游戏覆盖 tableSize */
48
+ protected abstract readonly seats: number;
49
+ /** 最多等多久(毫秒):到点 ≥ 2 人就开,只剩自己就转单机 */
50
+ protected readonly waitMs: number = 15_000;
51
+ /** 一个档位最多排几个人,满了 1013(挡刷连接) */
52
+ protected readonly maxQueue: number = 200;
53
+ /** 每人每小时最多排几次,超了 4029(内存里记,被驱逐就清零,接受) */
54
+ protected readonly perHour: number = 30;
55
+ /** 凑桌时找平台、找房间最多等多久(毫秒,见 MATCH_CALL_MS) */
56
+ protected readonly callMs: number = MATCH_CALL_MS;
57
+
58
+ /** 这个档位一桌最多几个真人;默认 seats */
59
+ protected tableSize(_tier: string): number {
60
+ return this.seats;
61
+ }
62
+
63
+ /** 这个档位开桌带的开局选项(交给房间的 RoomGame.options 校验);默认不带 */
64
+ protected roomOptions(_tier: string): unknown {
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * 这几个人之间谁屏蔽了谁(平台 0.25.0 的 ARCADE.blockedAmong,最多 9 人,只读)。没有平台(单独部署)、出错都当没有,回 []。
70
+ * 测试覆盖它换成假名单
71
+ */
72
+ protected async blockedAmong(userIds: string[]): Promise<[string, string][]> {
73
+ const arcade = this.env.ARCADE as unknown as { blockedAmong?: (ids: unknown) => Promise<unknown> } | undefined;
74
+ if (!arcade || userIds.length < 2) return [];
75
+ try {
76
+ const out = await arcade.blockedAmong!(userIds.slice(0, BLOCK_MAX));
77
+ return Array.isArray(out) ? out.filter((p): p is [string, string] => Array.isArray(p) && typeof p[0] === 'string' && typeof p[1] === 'string') : [];
78
+ } catch (e) {
79
+ console.error('blockedAmong failed, matching without it', e instanceof Error ? e.message : e);
80
+ return [];
81
+ }
82
+ }
83
+
84
+ /** 开一张匹配桌(房间的 kitCreateMatch)。游戏不用覆盖;kit 的测试用它模拟房间卡住 */
85
+ protected createMatchRoom(code: string, players: Identity[], opts: unknown): Promise<MatchOutcome> {
86
+ const stub = roomStub(this.env.ROOM, code) as unknown as { kitCreateMatch(code: string, players: Identity[], opts?: unknown): Promise<MatchOutcome> };
87
+ return stub.kitCreateMatch(code, players, opts);
88
+ }
89
+
90
+ /** 现在设着的闹钟;undefined 是还不知道,用前 getAlarm() 读 */
91
+ private alarmAt: number | null | undefined;
92
+ /** 正在凑桌(中间 await 了平台和房间):这时进来的人、到点的闹钟只记一笔,凑完再来一轮 */
93
+ private forming = false;
94
+ private again = false;
95
+ /** 凑桌途中已经选中的人:别的一轮不再选他们 */
96
+ private readonly taken = new Set<string>();
97
+ /** 每人这一小时排了几次 */
98
+ private readonly hourly = new Map<string, number[]>();
99
+
100
+ constructor(ctx: DurableObjectState, env: Env) {
101
+ super(ctx, env);
102
+ ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
103
+ }
104
+
105
+ /** 入队:只认 kit 自己拼的内部地址(worker.ts 的 acceptMatch),身份在 x-kit-user 头里。 */
106
+ override async fetch(request: Request): Promise<Response> {
107
+ const url = new URL(request.url);
108
+ if (url.hostname !== KIT_HOST || url.pathname !== '/match' || request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
109
+ return new Response('not a kit request', { status: 400 });
110
+ }
111
+ const me = parseIdentity(request.headers.get('x-kit-user'));
112
+ const tier = url.searchParams.get('tier');
113
+ if (!me || !tier) return new Response('no identity', { status: 401 });
114
+ const pair = new WebSocketPair();
115
+ const [client, server] = [pair[0], pair[1]];
116
+ this.ctx.acceptWebSocket(server);
117
+ const done = new Response(null, { status: 101, webSocket: client });
118
+ const now = Date.now();
119
+ // 只数还排着的(附件还在):已经出队、正在关的连接不占队长
120
+ const others = this.waiting().filter((o) => o !== server);
121
+ const mine = others.filter((o) => waiterOf(o)?.userId === me.userId);
122
+ if (others.length - mine.length >= this.maxQueue) {
123
+ server.close(CLOSE.tryLater, 'queue full');
124
+ return done;
125
+ }
126
+ // 一小时前的旧条目顺手清掉(排过一次就走的人不能让这张表一直涨)
127
+ for (const [u, ts] of this.hourly) if (now - ts[ts.length - 1]! >= 3_600_000) this.hourly.delete(u);
128
+ const times = (this.hourly.get(me.userId) ?? []).filter((t) => now - t < 3_600_000);
129
+ if (times.length >= this.perHour) {
130
+ this.hourly.set(me.userId, times);
131
+ server.close(CLOSE.tooManyJoins, 'too often');
132
+ return done;
133
+ }
134
+ times.push(now);
135
+ this.hourly.set(me.userId, times);
136
+ // 同一个人只排一次:旧的那条让位
137
+ for (const o of mine) this.drop(o, CLOSE.replaced, 'replaced');
138
+ server.serializeAttachment({ userId: me.userId, name: me.name, since: now, tier } satisfies Waiter);
139
+ // 先回 101 再凑桌:凑桌要找平台、房间,不能让新来的人连连接都拿不到
140
+ this.ctx.waitUntil(this.form());
141
+ return done;
142
+ }
143
+
144
+ /** 排队连接只收不发:发什么都断开(不让它变成能刷的入口) */
145
+ override async webSocketMessage(ws: WebSocket): Promise<void> {
146
+ this.drop(ws, CLOSE.policy, 'queue is receive-only');
147
+ this.pushWaiting();
148
+ }
149
+
150
+ override async webSocketClose(ws: WebSocket, code: number): Promise<void> {
151
+ try {
152
+ ws.close(code === 1005 || code === 1006 ? 1000 : code, 'bye');
153
+ } catch {
154
+ // 已经关了
155
+ }
156
+ ws.serializeAttachment(null);
157
+ this.pushWaiting();
158
+ }
159
+
160
+ override async webSocketError(ws: WebSocket): Promise<void> {
161
+ ws.serializeAttachment(null);
162
+ this.pushWaiting();
163
+ }
164
+
165
+ override async alarm(): Promise<void> {
166
+ this.alarmAt = null;
167
+ try {
168
+ await this.form();
169
+ } catch (e) {
170
+ // 闹钟里不往外抛(会自动重试、每次计费)
171
+ console.error('match alarm failed', e instanceof Error ? e.message : e);
172
+ }
173
+ }
174
+
175
+ // ---------- 凑桌 ----------
176
+
177
+ /**
178
+ * 凑一轮:凑满一桌立刻开;最早的人等够了:≥ 2 人就开,只剩自己转单机。凑完还有人就把闹钟对到下一个人。
179
+ * 凑桌中间要 await(平台、房间),这时别的入口进来只记 again,凑完再来一轮
180
+ */
181
+ private async form(): Promise<void> {
182
+ if (this.forming) {
183
+ this.again = true;
184
+ return;
185
+ }
186
+ this.forming = true;
187
+ try {
188
+ let rounds = 0;
189
+ do {
190
+ this.again = false;
191
+ while (rounds++ < FORM_ROUNDS && (await this.formOnce()));
192
+ } while (this.again && rounds < FORM_ROUNDS);
193
+ } finally {
194
+ this.forming = false;
195
+ this.taken.clear();
196
+ }
197
+ this.pushWaiting();
198
+ await this.planAlarm();
199
+ }
200
+
201
+ /** 凑一桌;凑出了(或者有人被请出去了)回 true,接着凑下一桌 */
202
+ private async formOnce(): Promise<boolean> {
203
+ const list = this.queue().filter((w) => !this.taken.has(w.me.userId));
204
+ if (!list.length) return false;
205
+ const tier = list[0]!.me.tier;
206
+ const size = Math.max(1, Math.min(this.tableSize(tier), BLOCK_MAX));
207
+ const due = Date.now() >= list[0]!.me.since + this.waitMs;
208
+ if (list.length < size && !due) return false;
209
+ const blocks = await withTimeout(this.blockedAmong(list.slice(0, BLOCK_MAX).map((w) => w.me.userId)), this.callMs).catch((e: unknown) => {
210
+ console.error('blockedAmong timed out, matching without it', e instanceof Error ? e.message : e);
211
+ return [] as [string, string][];
212
+ });
213
+ const chosen: typeof list = [];
214
+ for (const w of list.slice(0, BLOCK_MAX)) {
215
+ if (chosen.length >= size) break;
216
+ if (chosen.some((c) => blocks.some(([a, b]) => (a === c.me.userId && b === w.me.userId) || (a === w.me.userId && b === c.me.userId)))) continue;
217
+ chosen.push(w);
218
+ }
219
+ if (chosen.length < size && !due) return false;
220
+ if (chosen.length < 2) {
221
+ // 等够了却只有自己(或者在的人都和他互相屏蔽):转单机
222
+ this.solo(chosen[0]!.ws, 'alone');
223
+ return true;
224
+ }
225
+ for (const c of chosen) this.taken.add(c.me.userId);
226
+ const players: Identity[] = chosen.map((c) => ({ userId: c.me.userId, name: c.me.name }));
227
+ let out: MatchOutcome = { r: 'unavailable' };
228
+ let code = '';
229
+ for (let i = 0; i < CODE_TRIES; i++) {
230
+ code = newRoomCode();
231
+ try {
232
+ out = await withTimeout(this.createMatchRoom(code, players, this.roomOptions(tier)), this.callMs);
233
+ } catch (e) {
234
+ console.error('kitCreateMatch failed', e instanceof Error ? e.message : e);
235
+ out = { r: 'unavailable' };
236
+ }
237
+ if (out.r !== 'exists') break;
238
+ }
239
+ for (const c of chosen) this.taken.delete(c.me.userId);
240
+ // 等的这一会儿可能有人走了、换了页面:按 userId 找他现在的连接
241
+ const sockOf = (userId: string) => this.waiting().find((ws) => waiterOf(ws)?.userId === userId);
242
+ if (out.r === 'created') {
243
+ for (const c of chosen) {
244
+ const ws = sockOf(c.me.userId);
245
+ if (ws) this.finish(ws, { t: 'matched', code });
246
+ }
247
+ return true;
248
+ }
249
+ if (out.r === 'refused') {
250
+ if (out.userId === null) {
251
+ // 全站忙:这一轮的人都转单机,其他人留着(到点还会被同样挡掉)
252
+ for (const c of chosen) {
253
+ const ws = sockOf(c.me.userId);
254
+ if (ws) this.solo(ws, 'busy');
255
+ }
256
+ return true;
257
+ }
258
+ const ws = sockOf(out.userId);
259
+ if (ws) {
260
+ if (out.why === 'has_room') this.finish(ws, { t: 'has_room', code: out.code });
261
+ else this.solo(ws, 'daily_limit');
262
+ return true;
263
+ }
264
+ return true;
265
+ }
266
+ // 房间那边出错、超时、选项不对:这一轮的人转单机,不让他们一直等。
267
+ // bad / bad_options 是游戏配错了(tableSize 比这一局的座位多、roomOptions 过不了 RoomGame.options),每桌都会这样:
268
+ // 大声记日志;游戏在自己的测试里用 game.ts 的 matchConfigErrors 先查
269
+ if (out.r === 'bad' || out.r === 'bad_options') console.error(`MatchQueue misconfigured for tier ${tier}: room said ${out.r} (check tableSize / roomOptions with matchConfigErrors)`);
270
+ else console.error('match room not created', out.r);
271
+ for (const c of chosen) {
272
+ const ws = sockOf(c.me.userId);
273
+ if (ws) this.solo(ws, 'unavailable');
274
+ }
275
+ // 房间那边不好(超时、出错):这一次不接着凑,剩下的人等下一次闹钟,不让锁一直被连着的超时占着
276
+ this.again = false;
277
+ return false;
278
+ }
279
+
280
+ /** 只往早了调闹钟:最早那个人等够的时刻 */
281
+ private async planAlarm(): Promise<void> {
282
+ const list = this.queue();
283
+ if (!list.length) return;
284
+ const next = list[0]!.me.since + this.waitMs;
285
+ if (this.alarmAt === undefined) this.alarmAt = await this.ctx.storage.getAlarm();
286
+ if (this.alarmAt !== null && this.alarmAt <= next && this.alarmAt > Date.now() - 1_000) return;
287
+ this.alarmAt = next;
288
+ try {
289
+ await this.ctx.storage.setAlarm(next);
290
+ } catch (e) {
291
+ console.error('match setAlarm failed', e instanceof Error ? e.message : e);
292
+ this.alarmAt = undefined;
293
+ }
294
+ }
295
+
296
+ // ---------- 连接 ----------
297
+
298
+ private sockets(): WebSocket[] {
299
+ return this.ctx.getWebSockets();
300
+ }
301
+
302
+ private waiting(): WebSocket[] {
303
+ return this.sockets().filter((ws) => waiterOf(ws) !== null);
304
+ }
305
+
306
+ /** 排着的人,按开始排的先后;同一个人只算最新的那条 */
307
+ private queue(): { ws: WebSocket; me: Waiter }[] {
308
+ const by = new Map<string, { ws: WebSocket; me: Waiter }>();
309
+ for (const ws of this.waiting()) {
310
+ const me = waiterOf(ws)!;
311
+ const had = by.get(me.userId);
312
+ if (!had || had.me.since <= me.since) by.set(me.userId, { ws, me });
313
+ }
314
+ return [...by.values()].sort((a, b) => a.me.since - b.me.since);
315
+ }
316
+
317
+ /** 人数变了就推一次(不每秒推,页面自己倒计时) */
318
+ private pushWaiting(): void {
319
+ const list = this.queue();
320
+ if (!list.length) return;
321
+ // 倒计时按最早的那个人算:他到点时大家一起凑,后来的人等不满 waitMs
322
+ const left = Math.max(0, list[0]!.me.since + this.waitMs - Date.now());
323
+ const msg: MatchDownMsg = { t: 'waiting', n: list.length, seats: this.tableSize(list[0]!.me.tier), left };
324
+ for (const { ws } of list) send(ws, msg);
325
+ }
326
+
327
+ private solo(ws: WebSocket, why: MatchSoloWhy): void {
328
+ this.finish(ws, { t: 'solo', why });
329
+ }
330
+
331
+ /** 发最后一条,出队、关掉 */
332
+ private finish(ws: WebSocket, msg: MatchDownMsg): void {
333
+ send(ws, msg);
334
+ this.drop(ws, 1000, msg.t);
335
+ }
336
+
337
+ private drop(ws: WebSocket, code: number, reason: string): void {
338
+ ws.serializeAttachment(null);
339
+ try {
340
+ ws.close(code, reason);
341
+ } catch {
342
+ // 已经关了
343
+ }
344
+ }
345
+ }
346
+
347
+ function waiterOf(ws: WebSocket): Waiter | null {
348
+ const a = ws.deserializeAttachment() as Waiter | null | undefined;
349
+ return a && typeof a.userId === 'string' ? a : null;
350
+ }
351
+
352
+ function send(ws: WebSocket, msg: MatchDownMsg): void {
353
+ try {
354
+ ws.send(JSON.stringify(msg));
355
+ } catch {
356
+ // 连接正在关
357
+ }
358
+ }
359
+
360
+ /**
361
+ * 等一个外部调用最多 ms 毫秒,超时抛错(调用方按失败处理)。只在凑桌这一次请求里等,不是房间的状态计时,
362
+ * 所以用运行时的 scheduler.wait(不是 setTimeout、也不占那一个闹钟)
363
+ */
364
+ async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
365
+ // 超时以后原来的调用照样可能失败:先接住它的错,免得变成没人处理的 rejection
366
+ const settled = p.then((v) => ({ ok: true as const, v }), (e: unknown) => ({ ok: false as const, e }));
367
+ const out = await Promise.race([settled, scheduler.wait(ms).then(() => null)]);
368
+ if (out === null) throw new Error(`timed out after ${ms} ms`);
369
+ if (!out.ok) throw out.e;
370
+ return out.v;
371
+ }