@yyyu-star/arcade-kit 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/room.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { DurableObject } from 'cloudflare:workers';
20
20
  import type { RoomEffect, RoomGame, RoomSetup } from './game.js';
21
- import { type Identity, KIT_HOST, meterStub, parseIdentity } from './internal.js';
21
+ import { type Identity, KIT_HOST, LIMITED_HEADER, MISS_HEADER, meterStub, parseIdentity } from './internal.js';
22
22
  import type { MeterEnv, MeterLevel, RoomMeter } from './meter.js';
23
23
  import { ENTROPY_WORDS } from './rng.js';
24
24
  import {
@@ -60,7 +60,9 @@ export interface RoomLimits {
60
60
  * 这些重连同时算进 maxInbound,房间一生的写入总数有上限
61
61
  */
62
62
  maxJoins: number;
63
- /** 没入座的连接最多几条(来看的、还没选座位的),超了 4003。入座的人不占这个数,掉线回来总能进 */
63
+ /**
64
+ * 没入座的人最多几个(来看的、还没选座位的),超了 4003。按账号算(0.14.0,以前按连接);入座的人、房主、开打后绑过座位的人不占这个数,掉线回来总能进
65
+ */
64
66
  extraConnections: number;
65
67
  /** 房间最长寿命 */
66
68
  maxLifeMs: number;
@@ -119,6 +121,8 @@ export const DEFAULT_LIMITS: RoomLimits = {
119
121
 
120
122
  /** 闹钟里出错时,过这么久再醒来重试(不用"马上",免得每次都出错时连着计费)。 */
121
123
  const ALARM_RETRY_MS = 60_000;
124
+ /** setAlarm 一次最多试几回(0.13.1):存储一时出错当场再试一次,不等下一个入口 */
125
+ const SET_ALARM_TRIES = 2;
122
126
  /** 每个座位记住最近几个做成的 moveId(0.11.1):客户端断线时攒着没确认的操作,重连后原样补发 */
123
127
  const RECENT_MOVES = 8;
124
128
 
@@ -309,7 +313,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
309
313
  private alarmToSet: number | null = null;
310
314
  private pendingInbound = 0;
311
315
  private readonly pendingBy = new Map<string, number>();
312
- private readonly buckets = new WeakMap<WebSocket, { tokens: number; at: number }>();
316
+ /** 入站消息的令牌桶,按 userId 记(0.14.0;以前按连接,断开重连就换一个满的桶,第三轮审计) */
317
+ private readonly buckets = new Map<string, { tokens: number; at: number }>();
313
318
  /** 每个座位快捷短语的令牌桶(内存里;被驱逐就重置成满的,驱逐前至少空闲了一阵,本来也该满了) */
314
319
  private readonly emoteBuckets = new Map<number, { tokens: number; at: number }>();
315
320
  private emoteRateCache: EmoteRate | null = null;
@@ -387,6 +392,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
387
392
  }
388
393
  const me = parseIdentity(request.headers.get('x-kit-user'));
389
394
  if (!me) return new Response('no identity', { status: 401 });
395
+ // 猜码超了门槛(0.14.0):自己人照常进(断线重连不受牵连),别人不管房间在不在都回 1013,说不出房间在不在
396
+ if (request.headers.has(LIMITED_HEADER) && !this.insider(me.userId)) return this.refuse(CLOSE.tryLater);
390
397
  const gate = await this.newcomerGate(me.userId);
391
398
  if (gate !== null) return this.refuse(gate);
392
399
  return this.entry(() => this.join(me, url.searchParams.get('cv')));
@@ -461,26 +468,56 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
461
468
  this.totalWrites += this.writes;
462
469
  this.unmetered += this.writes;
463
470
  this.maxStepWrites = Math.max(this.maxStepWrites, this.writes);
464
- if (at !== null && !(await this.setAlarmSafely(at))) this.lastStepFailed = true;
471
+ if (at !== null && !(await this.setAlarmSafely(at))) {
472
+ this.lastStepFailed = true;
473
+ await this.recoverAlarm();
474
+ }
465
475
  await this.afterStep();
466
476
  return out;
467
477
  }
468
478
 
469
479
  /**
470
- * 设闹钟;失败了不往外抛(0.11.1)。planAlarm 已经把 alarmAt 改成了新时间,失败时它就是错的:
471
- * 留着的话以后 planAlarm 看"已经设了更早的"一直不重设,房间就没人收场。改回"不知道",下一个入口按 getAlarm() 重新读、照常重排
480
+ * 这一步要设的闹钟两次都没设上(0.13.1,第三轮审计):按 getAlarm() 核对存储里还有没有闹钟(以前设的更早的那个可能还在);
481
+ * 有就以它为准,没有而房间还在,就补一个 ALARM_RETRY_MS 后的,醒来时 onAlarm 按状态重新算(到点的照常处理)。
482
+ * 以前只靠下一个入口重排:全是电脑、大家都在等时限的房间没人发消息,就一直停着,不关房、结回也不重试。
483
+ * 补设也失败(存储一直坏着)就只剩浏览器的看门狗(RoomClient)重连来叫醒。读不了存储时不抛(入口里不往外抛)
472
484
  */
473
- private async setAlarmSafely(at: number): Promise<boolean> {
485
+ private async recoverAlarm(): Promise<void> {
474
486
  try {
475
- await this.ctx.storage.setAlarm(at);
476
- return true;
487
+ if (!this.load()) return;
488
+ const cur = await this.ctx.storage.getAlarm();
489
+ if (cur !== null) {
490
+ this.alarmAt = cur;
491
+ return;
492
+ }
493
+ // 不另记一行:这一步的那次 setAlarm 已经记过了,它没写成,补设的这次顶替它
494
+ this.alarmAt = Date.now() + ALARM_RETRY_MS;
495
+ await this.setAlarmSafely(this.alarmAt);
477
496
  } catch (e) {
478
- console.error('room setAlarm failed', e instanceof Error ? e.message : e);
497
+ console.error('room alarm recovery failed', e instanceof Error ? e.message : e);
479
498
  this.alarmAt = undefined;
480
- return false;
481
499
  }
482
500
  }
483
501
 
502
+ /**
503
+ * 设闹钟;失败了不往外抛(0.11.1)。存储一时出错大多下一次就好,所以当场再试一次(0.13.1);还不行:
504
+ * planAlarm 已经把 alarmAt 改成了新时间,失败时它就是错的,留着的话以后 planAlarm 看"已经设了更早的"一直不重设,房间就没人收场。
505
+ * 改回"不知道",下一个入口(任何消息、进房、闹钟)按 getAlarm() 重新读、照常重排。
506
+ * 一直没人发消息(全是电脑在打、大家都在等时限)时,下一个入口由浏览器的看门狗给(RoomClient:时限过了很久还没有新状态就重连一次)
507
+ */
508
+ private async setAlarmSafely(at: number): Promise<boolean> {
509
+ for (let i = 0; i < SET_ALARM_TRIES; i++) {
510
+ try {
511
+ await this.ctx.storage.setAlarm(at);
512
+ return true;
513
+ } catch (e) {
514
+ console.error('room setAlarm failed', i + 1, e instanceof Error ? e.message : e);
515
+ }
516
+ }
517
+ this.alarmAt = undefined;
518
+ return false;
519
+ }
520
+
484
521
  // ---------- 计量(0.2.0) ----------
485
522
 
486
523
  /**
@@ -607,7 +644,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
607
644
  const st = this.load();
608
645
  if (!st) {
609
646
  server.close(CLOSE.noRoom, 'no room');
610
- return done();
647
+ // 告诉 acceptJoin 这是一次"码不存在"(0.14.0,进房猜码按人、按 IP 计数);它转给浏览器前去掉这个头
648
+ return new Response(null, { status: 101, webSocket: client, headers: { [MISS_HEADER]: '1' } });
611
649
  }
612
650
  if (st.ending) {
613
651
  server.close(CLOSE.roomClosed, st.ending.reason);
@@ -621,10 +659,12 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
621
659
  const seat = this.seatOf(me.userId);
622
660
  const others = this.sockets().filter((o) => o !== server && attachmentOf(o));
623
661
  const mine = others.filter((o) => attachmentOf(o)!.userId === me.userId);
624
- if (seat === null) {
625
- // 没入座的连接单独封顶;入座的人不占这个数,旁观的再多也挡不住座位的主人回来
626
- const watching = others.filter((o) => o !== server && this.seatOf(attachmentOf(o)!.userId) === null && !mine.includes(o));
627
- if (watching.length >= this.limits.extraConnections) {
662
+ if (seat === null && !this.ownsRoom(st, me.userId)) {
663
+ // 没入座的单独封顶;入座的人不占这个数,旁观的再多也挡不住座位的主人回来。
664
+ // 0.14.0(第三轮审计):按账号数不按连接数(一个人开几个标签页只占一个),房主和开打后绑过座位的人(离座去看的)不占也不受这个限,
665
+ // 不然几个陌生账号就能占满名额,让房主的朋友连进来选座都进不来
666
+ const watchers = new Set(others.map((o) => attachmentOf(o)!.userId).filter((u) => u !== me.userId && this.seatOf(u) === null && !this.ownsRoom(st, u)));
667
+ if (watchers.size >= this.limits.extraConnections) {
628
668
  server.close(CLOSE.full, 'room full');
629
669
  return done();
630
670
  }
@@ -686,7 +726,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
686
726
  if (!st || !me || st.ending) return void ws.close(CLOSE.roomClosed, 'room closed');
687
727
  const bytes = typeof message === 'string' ? new TextEncoder().encode(message).byteLength : message.byteLength;
688
728
  if (bytes > this.limits.maxMessageBytes) return void ws.close(CLOSE.tooBig, 'message too big');
689
- if (!this.takeToken(ws)) return void ws.close(CLOSE.policy, 'too fast');
729
+ if (!this.takeToken(me.userId)) return void ws.close(CLOSE.policy, 'too fast');
690
730
  const counted = this.countInbound(st, me.userId);
691
731
  if (counted === 'user') return void ws.close(CLOSE.userQuota, 'too many messages');
692
732
  if (counted === 'room') return this.closeRoom();
@@ -1568,6 +1608,17 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1568
1608
  };
1569
1609
  }
1570
1610
 
1611
+ /** 这个房间的自己人:房主、坐着的、开打后绑过座位的。猜码超限时只放他们进(0.14.0) */
1612
+ private insider(userId: string): boolean {
1613
+ const st = this.load();
1614
+ return !!st && (st.host === userId || this.seatOf(userId) !== null || this.boundOf(userId) !== null);
1615
+ }
1616
+
1617
+ /** 房主,或者开打后绑过座位的人:旁观名额不算他们(0.14.0) */
1618
+ private ownsRoom(st: Persist<S>, userId: string): boolean {
1619
+ return st.host === userId || st.owners.includes(userId);
1620
+ }
1621
+
1571
1622
  /** 这个人开打后绑定的座位(离座了也还是它);还没开打、没坐过为 null。 */
1572
1623
  private boundOf(userId: string): number | null {
1573
1624
  const i = this.st!.owners.indexOf(userId);
@@ -1584,6 +1635,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1584
1635
  you: { seat, bound },
1585
1636
  room: this.roomInfo(),
1586
1637
  view: st.game === null ? null : this.game.view(st.game, seat),
1638
+ now: Date.now(),
1587
1639
  // 有短语的游戏告诉页面限速的节奏(0.7.0),RoomClient 按它排队,用户感觉不到限速
1588
1640
  ...(hasEmotes(this.game.emotes) ? { emote: this.emoteRate() } : {}),
1589
1641
  };
@@ -1592,7 +1644,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1592
1644
  private stateMsg(seat: number | null, bound: number | null, frames: { state: S | null; delay: number }[], ack?: string, quiet = false): DownMsg<V> {
1593
1645
  const st = this.st!;
1594
1646
  const out: Frame<V>[] = frames.map((f) => ({ view: (f.state === null ? null : this.game.view(f.state, seat)) as V, delay: f.delay }));
1595
- return { t: 'state', seq: st.seq, you: { seat, bound }, room: this.roomInfo(), frames: out, ...(ack ? { ack } : {}), ...(quiet ? { quiet: true as const } : {}) };
1647
+ return { t: 'state', seq: st.seq, you: { seat, bound }, room: this.roomInfo(), frames: out, now: Date.now(), ...(ack ? { ack } : {}), ...(quiet ? { quiet: true as const } : {}) };
1596
1648
  }
1597
1649
 
1598
1650
  /** 按座位过滤后广播:同一个座位(加上绑定的座位,没入座的人各自不同)的消息只算一次。只发给已经收到快照(有 attachment)的连接。 */
@@ -1627,15 +1679,18 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1627
1679
  this.send(ws, { t: 'error', code, ...(moveId ? { moveId } : {}) });
1628
1680
  }
1629
1681
 
1630
- /** 每个连接一个令牌桶(内存里;被驱逐后重置成满的,驱逐前至少空闲了一阵,本来也该满了)。 */
1631
- private takeToken(ws: WebSocket): boolean {
1682
+ /**
1683
+ * 每个人一个令牌桶(内存里;被驱逐后重置成满的,驱逐前至少空闲了一阵,本来也该满了)。
1684
+ * 按人不按连接(0.14.0):断开重连、多开几个标签页都还是同一个桶,刷不出新的 burst
1685
+ */
1686
+ private takeToken(userId: string): boolean {
1632
1687
  const now = Date.now();
1633
- const b = this.buckets.get(ws) ?? { tokens: this.limits.rateBurst, at: now };
1688
+ const b = this.buckets.get(userId) ?? { tokens: this.limits.rateBurst, at: now };
1634
1689
  b.tokens = Math.min(this.limits.rateBurst, b.tokens + ((now - b.at) / 1000) * this.limits.ratePerSec);
1635
1690
  b.at = now;
1636
1691
  if (b.tokens < 1) return false;
1637
1692
  b.tokens -= 1;
1638
- this.buckets.set(ws, b);
1693
+ this.buckets.set(userId, b);
1639
1694
  return true;
1640
1695
  }
1641
1696
  }
package/src/table-fun.ts CHANGED
@@ -49,8 +49,20 @@ export interface FunSeat {
49
49
  temper?: FunTemper;
50
50
  }
51
51
 
52
+ /**
53
+ * storageKey 的检查(0.13.1):它就是游戏 id,kit 拿它拼 `<id>.hint.fun`、`<id>.fun.blocked`。同站的几个游戏和平台只靠 `<id>.` 前缀分开存储,
54
+ * 所以只认游戏 id 的写法(小写字母、数字、连字符,字母或数字开头,最多 32 位);带点("guandan." 会拼出 "guandan..fun")、大写、空串、
55
+ * 平台自己的前缀 arcade 都不行。不合格直接抛错:同一个字符串每次都一样,开发时第一次打开牌桌就看得到,不会带到线上
56
+ */
57
+ export function checkStorageKey(key: unknown): string {
58
+ if (typeof key !== 'string' || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(key) || key === 'arcade') {
59
+ throw new Error(`arcade-kit/table-fun: storageKey 要写游戏 id(比如 "mygame",存成 "mygame.hint.fun"),收到 ${JSON.stringify(key)}`);
60
+ }
61
+ return key;
62
+ }
63
+
52
64
  export interface TableFunOptions {
53
- /** 游戏 id:首次提示、屏蔽名单的存储 key 前缀(`<id>.hint.fun`、`<id>.fun.blocked`) */
65
+ /** 游戏 id:首次提示、屏蔽名单的存储 key 前缀(`<id>.hint.fun`、`<id>.fun.blocked`);不是游戏 id 的写法构造时就抛错(checkStorageKey) */
54
66
  storageKey: string;
55
67
  /** 本游戏的短语表(编号 0..P-1,见 fun-logic 的 funItem) */
56
68
  phrases: readonly string[];
@@ -165,6 +177,8 @@ export class TableFun {
165
177
  private readonly onResize = (): void => this.refresh();
166
178
 
167
179
  constructor(opts: TableFunOptions) {
180
+ // 先查 key 再碰 DOM:不合格的不留下半个界面
181
+ checkStorageKey(opts.storageKey);
168
182
  this.o = opts;
169
183
  this.P = opts.phrases.length;
170
184
  this.doc = document;
package/src/worker.ts CHANGED
@@ -13,8 +13,9 @@
13
13
  import { identify, sameOrigin } from './auth.js';
14
14
  import { type GuestOptions, isStandalone, type ModeEnv } from './guest.js';
15
15
  import { CLOSE, type CreateError, ROOM_CODE_RE } from './protocol.js';
16
- import { identityHeader, type Identity, KIT_HOST, newRoomCode, roomStub } from './internal.js';
17
- import type { MeterEnv } from './meter.js';
16
+ import { identityHeader, type Identity, KIT_HOST, LIMITED_HEADER, meterStub, MISS_HEADER, missStub, newRoomCode, roomStub } from './internal.js';
17
+ // 只引类型:meter.ts 依赖 cloudflare:workers,游戏会在普通 Node 的测试里加载本文件
18
+ import type { MeterEnv, RoomMeter } from './meter.js';
18
19
  import type { CreateOutcome } from './room.js';
19
20
 
20
21
  /**
@@ -119,11 +120,48 @@ export async function acceptJoin(request: Request, env: KitEnv, code: string, op
119
120
  const w = await who(request, env, opts, false);
120
121
  if ('error' in w) return w.error === 'anonymous' ? refuse(CLOSE.unauthorized, 'login first') : refuse(CLOSE.tryLater, 'try later');
121
122
  const me = w.user;
123
+ // 进房猜码(0.14.0):这一小时"码不存在"超了门槛的人(或 IP)带上 LIMITED_HEADER,房间只放自己人进,别人不管房间在不在都回 1013,
124
+ // 说不出房间在不在;在打的人照常重连。计量连不上时同样处理(和开房一样不放新人进来)
125
+ const guard = missGuard(env, me.userId, request.headers.get('cf-connecting-ip'));
126
+ let limited = false;
127
+ try {
128
+ limited = !!guard && !(await guard.ok());
129
+ } catch (e) {
130
+ console.error('join guard failed', e instanceof Error ? e.message : e);
131
+ limited = true;
132
+ }
122
133
  // 新建请求,只带认好的身份和客户端版本;浏览器带来的 x-kit-user 之类的头一律不转
123
134
  const cv = new URL(request.url).searchParams.get('cv') ?? '0';
124
- return roomStub(env.ROOM, code).fetch(`https://${KIT_HOST}/ws?cv=${encodeURIComponent(cv)}`, {
125
- headers: { upgrade: 'websocket', 'x-kit-user': identityHeader(me) },
135
+ const res = await roomStub(env.ROOM, code).fetch(`https://${KIT_HOST}/ws?cv=${encodeURIComponent(cv)}`, {
136
+ headers: { upgrade: 'websocket', 'x-kit-user': identityHeader(me), ...(limited ? { [LIMITED_HEADER]: '1' } : {}) },
126
137
  });
138
+ if (!res.headers.has(MISS_HEADER)) return res;
139
+ // 房间说"这个码没有房间":记一次(按人、按 IP 各 1 行,再报进当天的写入),转给浏览器的回话不带这个内部头
140
+ if (guard) await guard.miss().catch((e: unknown) => console.error('join miss record failed', e instanceof Error ? e.message : e));
141
+ return new Response(null, { status: 101, webSocket: res.webSocket });
142
+ }
143
+
144
+ /**
145
+ * 按人、按 IP 数"码不存在"(0.14.0,第三轮审计:6 位房间码能一个个试)。没绑 METER(单独部署、测试里没配)就不管,和开房额度一样。
146
+ * 没有 IP 头(本地开发)只按人算
147
+ */
148
+ function missGuard(env: KitEnv, userId: string, ip: string | null): { ok(): Promise<boolean>; miss(): Promise<void> } | null {
149
+ const ns = env.METER;
150
+ if (!ns) return null;
151
+ const keys: [string, 'user' | 'ip'][] = [[`u:${userId}`, 'user']];
152
+ if (ip) keys.push([`ip:${ip}`, 'ip']);
153
+ const stub = (k: string) => missStub(ns, k) as DurableObjectStub<RoomMeter>;
154
+ return {
155
+ async ok() {
156
+ const blocked = await Promise.all(keys.map(([k, kind]) => stub(k).kitMissBlocked(kind)));
157
+ return !blocked.includes(true);
158
+ },
159
+ async miss() {
160
+ await Promise.all(keys.map(([k]) => stub(k).kitMiss()));
161
+ // 这几行也记进当天的写入(计量自己的规矩),报一次计量再写 1 行
162
+ await (meterStub(ns) as DurableObjectStub<RoomMeter>).kitReport(keys.length);
163
+ },
164
+ };
127
165
  }
128
166
 
129
167
  function refuse(code: number, reason: string): Response {