@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/meter.ts CHANGED
@@ -65,6 +65,20 @@ export type MeterLevel = 'ok' | 'busy' | 'stop';
65
65
 
66
66
  export type Reserve = { ok: true } | { ok: false; error: 'has_room'; code: string } | { ok: false; error: 'daily_limit' | 'busy' };
67
67
 
68
+ /** 几个人一起占(0.20.0):被挡时 userId 是第一个被挡的人;全站忙(busy)不是哪一个人的事,userId 为 null */
69
+ export type ReserveMany =
70
+ | { ok: true }
71
+ | { ok: false; userId: string; error: 'has_room'; code: string }
72
+ | { ok: false; userId: string; error: 'daily_limit' }
73
+ | { ok: false; userId: null; error: 'busy' };
74
+
75
+ /** kitPeek 的结果(0.20.0):room 是这个人开着的房间码,daily 是今天的开房次数用完了 */
76
+ export interface Peek {
77
+ room: string | null;
78
+ daily: boolean;
79
+ level: MeterLevel;
80
+ }
81
+
68
82
  export interface Usage {
69
83
  day: string;
70
84
  writes: number;
@@ -90,17 +104,29 @@ export class RoomMeter extends DurableObject<MeterEnv> {
90
104
 
91
105
  /** 开房前占一个名额(房间调)。房间没建成要调 kitRelease(…, cancel) 退回。 */
92
106
  async kitReserve(userId: string, code: string, until: number): Promise<Reserve> {
107
+ const r = await this.kitReserveMany([userId], code, until);
108
+ return r.ok ? r : r.error === 'has_room' ? { ok: false, error: 'has_room', code: r.code } : { ok: false, error: r.error };
109
+ }
110
+
111
+ /**
112
+ * 一张桌给几个人一起占名额(0.20.0,快速匹配):要么都占上,要么一个都不占,占不上的回第一个被挡的人。
113
+ * 全站开房数只记 1(一张桌一个房间)。一次写 1 + 人数 行(每人那一行加当天那一行),写在计量里、记进当天的数,不算房间的"每步 ≤ 2 行"
114
+ */
115
+ async kitReserveMany(userIds: readonly string[], code: string, until: number): Promise<ReserveMany> {
93
116
  const now = Date.now();
94
117
  const lim = this.limits();
95
118
  const day = this.day(now);
96
- const user = this.user(userId, day.day, now);
119
+ const users = userIds.map((id) => ({ id, row: this.user(id, day.day, now) }));
97
120
  // 先说"回你的房间":在打的房间不受全站门槛影响,回去照样能玩
98
- if (user.live.length >= lim.roomsPerUser) return { ok: false, error: 'has_room', code: user.live[user.live.length - 1]!.code };
99
- if (user.count >= lim.dailyRoomsPerUser) return { ok: false, error: 'daily_limit' };
100
- if (levelOf(day.writes, lim) !== 'ok' || day.rooms >= lim.dailyRooms) return { ok: false, error: 'busy' };
101
- user.count++;
102
- user.live.push({ code, until: until + EXPIRY_SLACK_MS });
103
- const w = this.saveUser(userId, user);
121
+ for (const { id, row } of users) if (row.live.length >= lim.roomsPerUser) return { ok: false, userId: id, error: 'has_room', code: row.live[row.live.length - 1]!.code };
122
+ for (const { id, row } of users) if (row.count >= lim.dailyRoomsPerUser) return { ok: false, userId: id, error: 'daily_limit' };
123
+ if (levelOf(day.writes, lim) !== 'ok' || day.rooms >= lim.dailyRooms) return { ok: false, userId: null, error: 'busy' };
124
+ let w = 0;
125
+ for (const { id, row } of users) {
126
+ row.count++;
127
+ row.live.push({ code, until: until + EXPIRY_SLACK_MS });
128
+ w += this.saveUser(id, row);
129
+ }
104
130
  this.saveDay(day.day, day.writes + w + 1, day.rooms + 1);
105
131
  return { ok: true };
106
132
  }
@@ -110,22 +136,52 @@ export class RoomMeter extends DurableObject<MeterEnv> {
110
136
  * 名额已经不在(还过一次、或者已经到点作废)就只记写入,可以重复调。
111
137
  */
112
138
  async kitRelease(userId: string, code: string, writes: number, cancel = false): Promise<MeterLevel> {
139
+ return this.kitReleaseMany([userId], code, writes, cancel);
140
+ }
141
+
142
+ /**
143
+ * 一张桌的几个人一起还(0.20.0)。cancel:开房次数也退回;room:全站开房数也退 1(默认跟着 cancel:整桌没建成)。
144
+ * 匹配桌开打时没来的人(0.20.0)是 cancel 但不退全站数:桌子开成了,只是他的那一份退给他
145
+ */
146
+ async kitReleaseMany(userIds: readonly string[], code: string, writes: number, cancel = false, room = cancel): Promise<MeterLevel> {
113
147
  const now = Date.now();
114
148
  const day = this.day(now);
115
- const user = this.user(userId, day.day, now);
116
- const i = user.live.findIndex((r) => r.code === code);
117
149
  let w = 0;
118
- if (i >= 0) {
150
+ let found = false;
151
+ for (const id of userIds) {
152
+ const user = this.user(id, day.day, now);
153
+ const i = user.live.findIndex((r) => r.code === code);
154
+ if (i < 0) continue;
155
+ found = true;
119
156
  user.live.splice(i, 1);
120
157
  if (cancel && user.count > 0) user.count--;
121
- w = this.saveUser(userId, user);
158
+ w += this.saveUser(id, user);
122
159
  }
123
- const rooms = cancel && i >= 0 ? Math.max(0, day.rooms - 1) : day.rooms;
160
+ const rooms = room && found ? Math.max(0, day.rooms - 1) : day.rooms;
124
161
  const total = day.writes + clean(writes) + w + 1;
125
162
  this.saveDay(day.day, total, rooms);
126
163
  return levelOf(total, this.limits());
127
164
  }
128
165
 
166
+ /**
167
+ * 只读(0.20.0,快速匹配入队前问):这个人现在开着的房间(没有为 null)、今天的开房次数用完没有、全站的门槛。
168
+ * 入队前就挡掉"有房间""今天用完了""联机忙"的人,不让他们白等 15 秒
169
+ */
170
+ async kitPeek(userId: string): Promise<Peek> {
171
+ const now = Date.now();
172
+ const lim = this.limits();
173
+ const day = this.day(now);
174
+ const user = this.user(userId, day.day, now);
175
+ const lv = levelOf(day.writes, lim);
176
+ // 全站开房数到了也开不了新桌,对排队的人和"联机忙"一样
177
+ const level = lv === 'ok' && day.rooms >= lim.dailyRooms ? 'busy' : lv;
178
+ return {
179
+ room: user.live.length >= lim.roomsPerUser ? user.live[user.live.length - 1]!.code : null,
180
+ daily: user.count >= lim.dailyRoomsPerUser,
181
+ level,
182
+ };
183
+ }
184
+
129
185
  /** 报一批写入(房间攒够一批时调),回现在的门槛。 */
130
186
  async kitReport(writes: number): Promise<MeterLevel> {
131
187
  const day = this.day(Date.now());
package/src/protocol.ts CHANGED
@@ -69,6 +69,42 @@ export const CLOSE_REASON = {
69
69
  */
70
70
  export type CreateError = 'login' | 'forbidden' | 'has_room' | 'daily_limit' | 'busy' | 'unavailable' | 'bad_options';
71
71
 
72
+ /**
73
+ * 快速匹配(0.20.0,docs/match.md)转单机的原因:
74
+ * - alone 等到时间只有自己在排;daily_limit 这个人今天的联机局数用完了;busy 今天联机的人太多(全站门槛);
75
+ * - unavailable 计量或房间暂时连不上;too_often 这一小时排得太多了;replaced 同一个人在别的页面又排了一次(这一页不用管了)。
76
+ */
77
+ export type MatchSoloWhy = 'alone' | 'daily_limit' | 'busy' | 'unavailable' | 'too_often' | 'replaced';
78
+
79
+ /** 排队时的人数(0.20.0):n 是现在排着几个人(含自己),seats 是一桌最多几个真人,left 是离"不等了"还剩几毫秒(按房间的钟算好的差值) */
80
+ export interface MatchWaitingMsg {
81
+ t: 'waiting';
82
+ n: number;
83
+ seats: number;
84
+ left: number;
85
+ }
86
+
87
+ /** 凑成一桌了(0.20.0):用 code 照常进房(RoomClient),房间只认排进来的这几个人 */
88
+ export interface MatchedMsg {
89
+ t: 'matched';
90
+ code: string;
91
+ }
92
+
93
+ /** 不排了,转本机单机(0.20.0) */
94
+ export interface MatchSoloMsg {
95
+ t: 'solo';
96
+ why: MatchSoloWhy;
97
+ }
98
+
99
+ /** 这个人已经有一个开着的房间(0.20.0):不排队,页面带他回那个房间 */
100
+ export interface MatchHasRoomMsg {
101
+ t: 'has_room';
102
+ code: string;
103
+ }
104
+
105
+ /** 排队连接上的下行消息(0.20.0)。排队连接只收不发,浏览器发什么都会被 1008 断开 */
106
+ export type MatchDownMsg = MatchWaitingMsg | MatchedMsg | MatchSoloMsg | MatchHasRoomMsg;
107
+
72
108
  /** 不重连的关闭码。 */
73
109
  export function isFinalClose(code: number): boolean {
74
110
  return code === CLOSE.policy || code === CLOSE.tooBig || (code >= 4000 && code < 5000);
@@ -176,6 +212,11 @@ export interface RoomInfo {
176
212
  * 只有在停时才有,这时 turn 是 null;游戏的 resume 出错时房间会把它往后推
177
213
  */
178
214
  pause?: { until: number };
215
+ /**
216
+ * 快速匹配凑出来的桌(0.20.0):只有排进来的人能进、没有房主、人到齐(或等满 RoomLimits.matchJoinMs)自动开打。
217
+ * 页面据此藏起房间码、邀请、叫人和"开打"按钮
218
+ */
219
+ match?: true;
179
220
  }
180
221
 
181
222
  /** 一帧画面:按座位过滤过的游戏视图,delay 是相对上一帧的播放延迟(电脑连续出牌时用,4.4)。 */
package/src/room.ts CHANGED
@@ -103,6 +103,11 @@ export interface RoomLimits {
103
103
  * 到了还办不完就记日志照样删(办事的一方要有自己的兜底,比如钱包到期退回买入)
104
104
  */
105
105
  closingMs: number;
106
+ /**
107
+ * 快速匹配的桌(0.20.0):建好以后最多等排进来的人这么久,到了还没连上的座位由电脑代打、照样开打(人后来到了交还给他)。
108
+ * 都连上了就不等,马上开
109
+ */
110
+ matchJoinMs: number;
106
111
  }
107
112
 
108
113
  /** 4.5、4.6 的默认值。 */
@@ -127,6 +132,7 @@ export const DEFAULT_LIMITS: RoomLimits = {
127
132
  chatPerMin: 10,
128
133
  meterBatch: 200,
129
134
  closingMs: 60 * 60_000,
135
+ matchJoinMs: 20_000,
130
136
  };
131
137
 
132
138
  /** 闹钟里出错时,过这么久再醒来重试(不用"马上",免得每次都出错时连着计费)。 */
@@ -225,6 +231,11 @@ interface Persist<S> {
225
231
  ending?: { reason: string; at: number } | null;
226
232
  /** 开房时定下的开局选项(0.4.0,RoomGame.options 校验过的);没有就是开打时由 start 定 */
227
233
  opts?: unknown;
234
+ /**
235
+ * 快速匹配的桌(0.20.0):排进来的人(建房时已经坐在各自的座位上)和最晚什么时候开打。
236
+ * 只有这几个人能进;没有房主(host 只是第一个人,用来还名额以外不起作用),start 一律 not_host
237
+ */
238
+ match?: { users: string[]; joinBy: number };
228
239
  }
229
240
 
230
241
  interface Attachment {
@@ -241,6 +252,16 @@ export type CreateOutcome =
241
252
  | { r: 'created' | 'exists' | 'bad' | 'bad_options' | 'error' | 'unavailable' | 'busy' | 'daily_limit' }
242
253
  | { r: 'has_room'; code: string };
243
254
 
255
+ /**
256
+ * 匹配开桌的结果(kitCreateMatch → match.ts 的 MatchQueue,0.20.0)。refused:计量挡了某个人(has_room 带他那个房间的码),
257
+ * userId 为 null 是全站忙(busy);这一桌一个名额都没占,排队那边把这个人请出去再凑
258
+ */
259
+ export type MatchOutcome =
260
+ | { r: 'created' | 'exists' | 'bad' | 'bad_options' | 'error' | 'unavailable' }
261
+ | { r: 'refused'; userId: string; why: 'has_room'; code: string }
262
+ | { r: 'refused'; userId: string; why: 'daily_limit' }
263
+ | { r: 'refused'; userId: null; why: 'busy' };
264
+
244
265
  /** 房间里一步的结果:要广播的帧、给做这步的连接回 ack。 */
245
266
  interface StepOut<S> {
246
267
  /** 广播的帧:每个元素是一个状态,广播时按座位换成视图 */
@@ -343,8 +364,11 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
343
364
  lastStepFailed = false;
344
365
  /** 写了还没报给计量的行数 */
345
366
  private unmetered = 0;
346
- /** 这一步做完以后要还房主的名额(打完、关房),entry 最后去找计量 */
347
- private release: { host: string; code: string } | null = null;
367
+ /**
368
+ * 这一步做完以后要还的名额(打完、关房;房主一个人,匹配桌是排进来的每个人),entry 最后去找计量。
369
+ * refund:匹配桌开打时没来的人(0.20.0),连当天的开房次数一起退给他(全站开房数不退,桌子开成了)
370
+ */
371
+ private release: { users: string[]; code: string; refund?: true } | null = null;
348
372
  /** 计量最近一次说的门槛(内存;被驱逐就重新问) */
349
373
  private level: { value: MeterLevel; at: number } | null = null;
350
374
  /** 计量连不上时,什么时候再试着报数 */
@@ -399,6 +423,45 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
399
423
  return { r: out instanceof Response ? 'error' : out };
400
424
  }
401
425
 
426
+ /**
427
+ * 快速匹配开桌(0.20.0,只有 match.ts 的排队对象经 RPC 调):players 按排队先后,每人占一个开房名额(计量一次占齐,占不齐一个都不占),
428
+ * 建房时把他们按 matchSeats 坐好。和 kitCreate 一样:码撞了回 exists、什么都不写;占到了却没建成就退回去。
429
+ */
430
+ async kitCreateMatch(code: string, players: Identity[], rawOpts?: unknown): Promise<MatchOutcome> {
431
+ const fixed = this.checkOpts(rawOpts);
432
+ if (fixed === undefined || (rawOpts !== undefined && !this.game.options)) return { r: 'bad_options' };
433
+ if (!validPlayers(code, players, fixed.count)) return { r: 'bad' };
434
+ const meter = this.meter();
435
+ const users = players.map((p) => p.userId);
436
+ let reserved = false;
437
+ if (meter && !this.load()) {
438
+ try {
439
+ const r = await meter.kitReserveMany(users, code, Date.now() + this.limits.maxLifeMs);
440
+ if (!r.ok) {
441
+ if (r.error === 'has_room') return { r: 'refused', userId: r.userId, why: 'has_room', code: r.code };
442
+ return r.error === 'daily_limit' ? { r: 'refused', userId: r.userId, why: 'daily_limit' } : { r: 'refused', userId: null, why: 'busy' };
443
+ }
444
+ reserved = true;
445
+ } catch (e) {
446
+ console.error('meter unreachable, refusing match room', e instanceof Error ? e.message : e);
447
+ return { r: 'unavailable' };
448
+ }
449
+ }
450
+ const before = this.maxStepWrites;
451
+ const out = await this.entry(() => this.createMatch(code, players, fixed));
452
+ if (out === 'created') this.maxStepWrites = before;
453
+ else if (reserved && meter) await meter.kitReleaseMany(users, code, 0, true).catch(() => console.error('meter release failed', code));
454
+ return { r: out instanceof Response ? 'error' : out };
455
+ }
456
+
457
+ /**
458
+ * 快速匹配时 n 个人坐哪几个座位(0.20.0,可选覆盖):count 是这一局用几个座位,返回 n 个不重复的座位号(排队先后的顺序)。
459
+ * 默认随机(crypto,不在规则核心里);比如两个真人要坐对家,游戏在这里给。给的不合格(重复、越界、个数不对)就按默认的随机
460
+ */
461
+ protected matchSeats(n: number, count: number): number[] {
462
+ return randomSeats(n, count);
463
+ }
464
+
402
465
  /** 进房:只认 kit 自己拼的内部地址(worker.ts 的 acceptJoin),身份在 x-kit-user 头里。 */
403
466
  override async fetch(request: Request): Promise<Response> {
404
467
  const url = new URL(request.url);
@@ -569,7 +632,9 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
569
632
  this.release = null;
570
633
  this.unmetered = 0;
571
634
  try {
572
- const level = release ? await meter.kitRelease(release.host, release.code, n) : await meter.kitReport(n);
635
+ const level = release
636
+ ? await meter.kitReleaseMany(release.users, release.code, n, release.refund === true, false)
637
+ : await meter.kitReport(n);
573
638
  this.level = { value: level, at: Date.now() };
574
639
  this.meterRetryAt = 0;
575
640
  } catch (e) {
@@ -606,7 +671,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
606
671
  private async newcomerGate(userId: string): Promise<number | null> {
607
672
  const meter = this.meter();
608
673
  const st = this.load();
609
- if (!meter || !st || st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null) return null;
674
+ if (!meter || !st || st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null || st.match?.users.includes(userId)) return null;
610
675
  if (this.sockets().some((o) => attachmentOf(o)?.userId === userId)) return null;
611
676
  const now = Date.now();
612
677
  if (!this.level || now - this.level.at >= LEVEL_TTL_MS) {
@@ -650,6 +715,32 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
650
715
  return 'created';
651
716
  }
652
717
 
718
+ /** 匹配桌(0.20.0):和 create 一样建表、存一行、设闹钟,只是排进来的人都先坐好(算掉线,连上就"回来"),记下最晚开打的时间。 */
719
+ private createMatch(code: string, players: Identity[], fixed: { opts: O | undefined; count: number }): 'created' | 'exists' | 'bad' {
720
+ if (this.load()) return 'exists';
721
+ this.writes += this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS ${TABLE} (k INTEGER PRIMARY KEY, v TEXT NOT NULL)`).rowsWritten;
722
+ const now = Date.now();
723
+ const seats: SeatState[] = Array.from({ length: this.game.seats }, () => emptySeat());
724
+ let order: number[];
725
+ try {
726
+ order = this.matchSeats(players.length, fixed.count);
727
+ } catch (e) {
728
+ console.error('matchSeats threw', e instanceof Error ? e.message : e);
729
+ order = [];
730
+ }
731
+ if (!validSeats(order, players.length, fixed.count)) order = randomSeats(players.length, fixed.count);
732
+ players.forEach((p, i) => (seats[order[i]!] = humanSeat(p.userId, p.name, now)));
733
+ this.st = {
734
+ sv: 1, code, host: players[0]!.userId, createdAt: now, status: 'waiting', seq: 0, gameSeq: 0, turnSeq: 0, seats, game: null, rulesVersion: null,
735
+ turnSince: null, aiAt: null, noHumanSince: now, inbound: 0, inboundBy: {}, joins: {}, owners: seats.map(() => null),
736
+ ...(fixed.opts !== undefined ? { opts: fixed.opts } : {}),
737
+ match: { users: players.map((p) => p.userId), joinBy: now + this.limits.matchJoinMs },
738
+ };
739
+ this.planAlarm();
740
+ this.save();
741
+ return 'created';
742
+ }
743
+
653
744
  /** 进房(含重连):接受、记座位、发快照,中间没有 await(4.5 续局的写法)。 */
654
745
  private join(me: Identity, rawCv: string | null, chat = false): Response {
655
746
  const pair = new WebSocketPair();
@@ -671,6 +762,11 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
671
762
  server.close(CLOSE.tooOld, 'client too old');
672
763
  return done();
673
764
  }
765
+ // 匹配桌只认排进来的人(0.20.0):房间码不给页面看,别人拿到码也进不来、看不了,回的和"没有这个房间"一样
766
+ if (st.match && !st.match.users.includes(me.userId)) {
767
+ server.close(CLOSE.noRoom, 'no room');
768
+ return done();
769
+ }
674
770
  const seat = this.seatOf(me.userId);
675
771
  const others = this.sockets().filter((o) => o !== server && attachmentOf(o));
676
772
  const mine = others.filter((o) => attachmentOf(o)!.userId === me.userId);
@@ -727,8 +823,10 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
727
823
  st.joins[me.userId] = used + 1;
728
824
  st.noHumanSince = null;
729
825
  }
826
+ // 匹配桌的人到齐了:不等,和这次进房同一步开打(还是只写这一行和闹钟)
827
+ const frames = changed && this.matchReady(st) ? this.beginPlay(st, Date.now()) : [{ state: st.game, delay: 0 }];
730
828
  // 状态没变(没入座的人进来、在线时又从别的设备连一次)不写库、不广播
731
- if (changed) this.commit({ frames: [{ state: st.game, delay: 0 }] }, server);
829
+ if (changed) this.commit({ frames }, server);
732
830
  this.send(server, this.welcome(seat, this.boundOf(me.userId)));
733
831
  return done();
734
832
  }
@@ -856,6 +954,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
856
954
  if (current === raw) return this.reject(ws, 'already_seated');
857
955
  const target = st.seats[raw]!;
858
956
  if (st.status === 'over') return this.reject(ws, 'room_over');
957
+ // 匹配桌等人时座位是排好的,不换(开打以后离座、回座照常)
958
+ if (st.match && st.status === 'waiting') return this.reject(ws, 'seat_taken');
859
959
  if (st.status === 'waiting') {
860
960
  // 开房时定了选项的(0.4.0):这一局用不到的座位现在就不让坐,免得房主开打时才被 seat_closed 拒
861
961
  if (st.opts !== undefined && raw >= this.countOf(st.opts as O)) return this.reject(ws, 'seat_closed');
@@ -890,7 +990,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
890
990
 
891
991
  private leave(seat: number | null): void {
892
992
  const st = this.st!;
893
- if (seat === null) return;
993
+ if (seat === null || (st.match && st.status === 'waiting')) return;
894
994
  // 等人时空出来;开打后由电脑接手(不判负),轮到它就接着出。座位还绑着他,留下昵称给牌桌显示"电脑代某某打"
895
995
  st.seats[seat] = st.status === 'playing' ? { ...aiSeat(), name: st.seats[seat]!.name } : emptySeat();
896
996
  if (!this.seatedOnline()) st.noHumanSince ??= Date.now();
@@ -902,8 +1002,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
902
1002
 
903
1003
  private start(ws: WebSocket, me: Attachment, seat: number | null, rawOpts: unknown): void {
904
1004
  const st = this.st!;
905
- // 房主选择开打(9.1)
906
- if (me.userId !== st.host) return this.reject(ws, 'not_host');
1005
+ // 房主选择开打(9.1);匹配桌没有房主,到齐或到点自动开(0.20.0)
1006
+ if (me.userId !== st.host || st.match) return this.reject(ws, 'not_host');
907
1007
  if (st.status !== 'waiting') return this.reject(ws, 'already_started');
908
1008
  if (seat === null) return this.reject(ws, 'not_seated');
909
1009
  // 开局选项(0.3.0):游戏校验;这一局用几个座位,后面的座位一直空着。
@@ -911,20 +1011,42 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
911
1011
  const fixed = st.opts !== undefined ? { opts: st.opts as O, count: this.countOf(st.opts as O) } : null;
912
1012
  const checked = fixed ?? this.checkOpts(rawOpts);
913
1013
  if (!checked) return this.reject(ws, 'bad_options');
914
- const { opts, count } = checked;
915
- if (st.seats.some((s, i) => i >= count && s.kind === 'human')) return this.reject(ws, 'seat_closed');
916
- // 空座位由电脑补满;入座的人从这一刻起绑在自己的座位上
1014
+ if (st.seats.some((s, i) => i >= checked.count && s.kind === 'human')) return this.reject(ws, 'seat_closed');
1015
+ this.commit({ frames: this.beginPlay(st, Date.now(), checked) });
1016
+ }
1017
+
1018
+ /**
1019
+ * 开打:空座位由电脑补满,入座的人从这一刻起绑在自己的座位上,返回要广播的帧。
1020
+ * 匹配桌(0.20.0)到点还没连上的人:座位照样绑给他,电脑先代打(auto),他连上就交还
1021
+ */
1022
+ private beginPlay(st: Persist<S>, now: number, checked?: { opts: O | undefined; count: number }): { state: S | null; delay: number }[] {
1023
+ const { opts, count } = checked ?? { opts: st.opts as O | undefined, count: this.countOf(st.opts as O | undefined) };
917
1024
  st.seats = st.seats.map((s, i) => (s.kind === 'empty' && i < count ? aiSeat() : s));
1025
+ if (st.match) {
1026
+ const absent: string[] = [];
1027
+ for (const s of st.seats) {
1028
+ if (s.kind !== 'human' || s.awaySince === null) continue;
1029
+ s.auto = true;
1030
+ absent.push(s.userId!);
1031
+ }
1032
+ // 没来的人(关了页面、网断了):他的开房名额和当天次数退给他,不然白扣一个、还被 has_room 卡到这桌打完。
1033
+ // 他后来回来照样能接着打这个座位(座位还绑着他)
1034
+ if (absent.length) this.release = { users: absent, code: st.code, refund: true };
1035
+ }
918
1036
  st.owners = st.seats.map((s) => (s.kind === 'human' ? s.userId : null));
919
1037
  st.rulesVersion = this.game.rulesVersion;
920
1038
  st.game = this.begin(st.code, { opts, seats: st.seats.map((s) => s.kind) });
921
1039
  st.gameSeq = st.seq + 1;
922
1040
  st.turnSeq = st.gameSeq;
923
1041
  st.status = 'playing';
924
- const now = Date.now();
925
1042
  st.turnSince = now;
926
1043
  if (!this.game.over(st.game)) this.stalled(this.actorsOf(st.game));
927
- this.commit({ frames: [{ state: st.game, delay: 0 }, ...this.runAuto(now)] });
1044
+ return [{ state: st.game, delay: 0 }, ...this.runAuto(now)];
1045
+ }
1046
+
1047
+ /** 匹配桌在等人,排进来的人都连上了 */
1048
+ private matchReady(st: Persist<S>): boolean {
1049
+ return !!st.match && st.status === 'waiting' && st.seats.every((s) => s.kind !== 'human' || s.awaySince === null);
928
1050
  }
929
1051
 
930
1052
  /**
@@ -1033,6 +1155,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1033
1155
 
1034
1156
  const frames: { state: S | null; delay: number }[] = [];
1035
1157
  let changed = false;
1158
+ // 匹配桌等够了(0.20.0):没来的由电脑代打,照样开
1159
+ if (st.match && st.status === 'waiting' && now >= st.match.joinBy) return this.commit({ frames: this.beginPlay(st, now) });
1036
1160
  // 掉线超过宽限:电脑托管(只在开打以后有意义,等人时不管)
1037
1161
  for (const s of st.status === 'playing' ? st.seats : []) {
1038
1162
  if (s.kind === 'human' && s.awaySince !== null && !s.auto && now >= s.awaySince + this.limits.graceMs) {
@@ -1087,6 +1211,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1087
1211
  const ds = [st.createdAt + this.limits.maxLifeMs];
1088
1212
  if (st.effectsAt != null) ds.push(st.effectsAt);
1089
1213
  if (st.noHumanSince !== null) ds.push(st.noHumanSince + this.limits.noHumanMs);
1214
+ if (st.match && st.status === 'waiting') ds.push(st.match.joinBy);
1090
1215
  if (st.status === 'playing') {
1091
1216
  for (const s of st.seats) if (s.kind === 'human' && s.awaySince !== null && !s.auto) ds.push(s.awaySince + this.limits.graceMs);
1092
1217
  const due = this.deadline();
@@ -1143,7 +1268,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1143
1268
  if (over) {
1144
1269
  st.status = 'over';
1145
1270
  st.turnSince = null;
1146
- this.release = { host: st.host, code: st.code };
1271
+ this.release = releaseOf(st);
1147
1272
  return;
1148
1273
  }
1149
1274
  const after = this.turnOf(next);
@@ -1190,7 +1315,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1190
1315
  console.error('game has nobody to act but is not over', st.code);
1191
1316
  st.status = 'over';
1192
1317
  st.turnSince = null;
1193
- this.release = { host: st.host, code: st.code };
1318
+ this.release = releaseOf(st);
1194
1319
  return true;
1195
1320
  }
1196
1321
 
@@ -1571,7 +1696,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1571
1696
  st.ending = { reason, at: Date.now() };
1572
1697
  st.effectsAt = null;
1573
1698
  st.effectTries = 0;
1574
- this.release = { host: st.host, code: st.code };
1699
+ this.release = releaseOf(st);
1575
1700
  this.closing = false;
1576
1701
  this.planAlarm();
1577
1702
  this.save();
@@ -1587,7 +1712,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1587
1712
  ws.serializeAttachment(null);
1588
1713
  ws.close(CLOSE.roomClosed, reason);
1589
1714
  }
1590
- if (this.st) this.release = { host: this.st.host, code: this.st.code };
1715
+ if (this.st) this.release = releaseOf(this.st);
1591
1716
  this.st = null;
1592
1717
  this.alarmToSet = null;
1593
1718
  this.alarmAt = null;
@@ -1655,13 +1780,14 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1655
1780
  turn,
1656
1781
  ...(st.opts !== undefined ? { opts: st.opts } : {}),
1657
1782
  ...(until !== null ? { pause: { until } } : {}),
1783
+ ...(st.match ? { match: true as const } : {}),
1658
1784
  };
1659
1785
  }
1660
1786
 
1661
1787
  /** 这个房间的自己人:房主、坐着的、开打后绑过座位的。猜码超限时只放他们进(0.14.0) */
1662
1788
  private insider(userId: string): boolean {
1663
1789
  const st = this.load();
1664
- return !!st && (st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null);
1790
+ return !!st && (st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null || !!st.match?.users.includes(userId));
1665
1791
  }
1666
1792
 
1667
1793
  /** 房主,或者开打后绑过座位的人:旁观名额不算他们(0.14.0) */
@@ -1778,6 +1904,33 @@ function validHost(code: string, host: Identity): boolean {
1778
1904
  return ROOM_CODE_RE.test(code) && !!host && typeof host.userId === 'string' && !!host.userId && typeof host.name === 'string';
1779
1905
  }
1780
1906
 
1907
+ /** 打完、关房时要还谁的名额:匹配桌是排进来的每个人,好友房是房主 */
1908
+ function releaseOf(st: { host: string; code: string; match?: { users: string[] } }): { users: string[]; code: string } {
1909
+ return { users: st.match ? [...st.match.users] : [st.host], code: st.code };
1910
+ }
1911
+
1912
+ /** 匹配桌的人:1 到 count 个、userId 不重复、各自像个身份 */
1913
+ function validPlayers(code: string, players: unknown, count: number): players is Identity[] {
1914
+ if (!Array.isArray(players) || players.length < 1 || players.length > count) return false;
1915
+ if (!players.every((p: Identity) => validHost(code, p))) return false;
1916
+ return new Set(players.map((p: Identity) => p.userId)).size === players.length;
1917
+ }
1918
+
1919
+ function validSeats(order: unknown, n: number, count: number): order is number[] {
1920
+ return Array.isArray(order) && order.length === n && new Set(order).size === n && order.every((x) => Number.isInteger(x) && x >= 0 && x < count);
1921
+ }
1922
+
1923
+ /** 从 0..count-1 里随机挑 n 个座位(crypto;房间里的随机,不是规则核心) */
1924
+ function randomSeats(n: number, count: number): number[] {
1925
+ const all = Array.from({ length: count }, (_, i) => i);
1926
+ const r = crypto.getRandomValues(new Uint32Array(count));
1927
+ for (let i = count - 1; i > 0; i--) {
1928
+ const j = r[i]! % (i + 1);
1929
+ [all[i], all[j]] = [all[j]!, all[i]!];
1930
+ }
1931
+ return all.slice(0, n);
1932
+ }
1933
+
1781
1934
  function emptySeat(): SeatState {
1782
1935
  return { kind: 'empty', userId: null, name: null, awaySince: null, auto: false, lastMoveId: null };
1783
1936
  }
package/src/table-chat.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * 和 table-fun.ts 同样的规矩:import 时不碰 DOM;动画只动 transform、opacity;量位置只在摆放(refresh)和出一条弹幕时。
5
5
  */
6
6
  import { CHAT_MAX, chatRejectText, type ChatBlockWhy } from './chat.js';
7
- import { DANMAKU, barBottom, chatFont, chatMode, danmakuLanes, danmakuSpeed, pickLane, placeBeside, placeDock, seatColor, type Box, type ChatMode } from './fun-logic.js';
7
+ import { DANMAKU, barBottom, chatFont, chatMode, danmakuLanes, danmakuSpeed, dockFits, pickLane, placeBeside, placeDock, seatColor, type Box, type ChatMode } from './fun-logic.js';
8
8
 
9
9
  export type ChatResultLike = { ok: true } | { ok: false; code: string; why?: ChatBlockWhy; waitMs?: number };
10
10
 
@@ -20,7 +20,7 @@ export interface ChatLine {
20
20
  interface ChatPrefs {
21
21
  /** 弹幕开关;没设过:系统要求减少动态效果、或省内存模式时默认关,其余默认开 */
22
22
  danmaku?: boolean;
23
- /** 电脑版聊天框收起来了 */
23
+ /** 电脑版聊天框收起来了;没设过:四个角都会压到东西(dockFits)时默认收着(0.20.0),其余默认展开 */
24
24
  folded?: boolean;
25
25
  /** 电脑版聊天框的记录展开成长条 */
26
26
  long?: boolean;
@@ -51,6 +51,8 @@ export interface ChatHost {
51
51
  styleButton(b: HTMLElement): void;
52
52
  /** 游戏指定电脑版聊天框的位置(只用 left、top、width),不给就自己挑角 */
53
53
  dock?(): Box | null;
54
+ /** 游戏指定没点过时默认收不收(0.20.1) */
55
+ folded?(): boolean;
54
56
  /** 游戏指定手机版"聊天"按钮的位置,不给就挨着"互动"按钮 */
55
57
  chatButton?(): Box | null;
56
58
  /** 游戏指定弹幕区,不给就是牌桌上半部分 */
@@ -75,6 +77,7 @@ export const CHAT_CSS = `
75
77
  .atc-quick{display:flex;gap:6px;overflow-x:auto;overscroll-behavior:contain;scrollbar-width:none;flex-shrink:0;padding-right:28px;-webkit-mask-image:linear-gradient(to right,#000 calc(100% - 36px),transparent);mask-image:linear-gradient(to right,#000 calc(100% - 36px),transparent)}
76
78
  .atc-quick::-webkit-scrollbar{display:none}
77
79
  .atc-quick .atf-chip{flex:none;white-space:nowrap;font-size:.8em}
80
+ .atc-dock .atc-quick{flex-wrap:wrap;overflow:visible;padding-right:0;-webkit-mask-image:none;mask-image:none}
78
81
  .atc-row{display:flex;gap:6px;align-items:center}
79
82
  .atc-row .atf-input{font-size:max(16px,.85em)}
80
83
  .atc-row .atf-chip{flex:none}
@@ -230,7 +233,17 @@ export class ChatUi {
230
233
  private drawDesk(view: { width: number; height: number }): void {
231
234
  const w = Math.round(this.font * 17);
232
235
  const fixed = this.h.dock?.() ?? null;
233
- if (this.prefs.folded) {
236
+ // 没自己收起、展开过:游戏说了默认收不收就听它的(0.20.1);没说、也没指定位置时,四个角都会压到东西(dockFits,0.20.0)就默认收着。先排出来量真实高度再判断,不靠估
237
+ let folded = this.prefs.folded === true;
238
+ const want = this.prefs.folded === undefined ? this.h.folded?.() : undefined;
239
+ if (want !== undefined) folded = want;
240
+ else if (this.prefs.folded === undefined && !fixed) {
241
+ const d = this.layoutDock(view, w, null);
242
+ folded = !dockFits(view, w, d.offsetHeight || 260, this.h.blocked());
243
+ if (folded) this.clearDesk();
244
+ else return;
245
+ }
246
+ if (folded) {
234
247
  if (this.dockEl) this.clearDesk();
235
248
  if (!this.pill) {
236
249
  const p = this.el('button', 'atc-pill');
@@ -251,6 +264,15 @@ export class ChatUi {
251
264
  this.pill.style.transform = `translate(${at.left}px,${at.top}px)`;
252
265
  return;
253
266
  }
267
+ this.layoutDock(view, w, fixed);
268
+ }
269
+
270
+ /** 排出电脑版聊天框(大小、记录高度),放到 fixed 或 placeDock 挑的角上,返回它 */
271
+ private layoutDock(view: { width: number; height: number }, w: number, fixed: Box | null): HTMLDivElement {
272
+ if (this.pill) {
273
+ this.pill.remove();
274
+ this.pill = null;
275
+ }
254
276
  if (!this.dockEl) this.dockEl = this.buildDock();
255
277
  const d = this.dockEl;
256
278
  d.style.fontSize = `${this.font - 2}px`;
@@ -265,6 +287,7 @@ export class ChatUi {
265
287
  const at = fixed ? { left: fixed.left, top: fixed.top } : placeDock(view, w, d.offsetHeight || 260, this.h.blocked());
266
288
  d.style.transform = `translate(${at.left}px,${at.top}px)`;
267
289
  log.scrollTop = log.scrollHeight;
290
+ return d;
268
291
  }
269
292
 
270
293
  private buildDock(): HTMLDivElement {