@yyyu-star/arcade-kit 0.16.0 → 0.18.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/chat.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 好友房聊天的过滤(0.18.0,设计见 docs/chat.md 第 2 节):纯函数,房间和页面都 import,页面发之前跑一遍给即时提示,房间收到再跑一遍(以房间为准)。
3
+ * 顺序:规整 → 判长度 → 判导流(整条拒发)→ 脏话打码(照发)。不碰 DOM、时间和随机数。
4
+ * kit 里不出现任何游戏的名字和概念;词表不针对真实个人。
5
+ */
6
+ import { CHAT_WORDS } from './chat-words.js';
7
+
8
+ /** 一条最多几个字(按码点) */
9
+ export const CHAT_MAX = 30;
10
+
11
+ /** 被拒的原因(页面挑说法用;房间回的 code 都是 chat_blocked) */
12
+ export type ChatBlockWhy = 'link' | 'number' | 'contact';
13
+
14
+ export type ChatCheck =
15
+ | { ok: true; text: string; masked: boolean }
16
+ | { ok: false; code: 'chat_empty' | 'chat_too_long' }
17
+ | { ok: false; code: 'chat_blocked'; why: ChatBlockWhy };
18
+
19
+ /** 控制字符、零宽字符、方向控制符、变体选择符:拿来藏字、倒序显示、把号码拆开 */
20
+ const INVISIBLE = /[\u0000-\u001f\u007f-\u009f­͏؜ᅟᅠ឴឵᠋-᠏​-‏‪-‮⁠-ㅤ︀-️ᅠ]/gu;
21
+
22
+ /** 规整:NFKC(全角转半角、①转 1)、去掉看不见的字符、连续空白并成一个、去首尾空白 */
23
+ export function normalizeChat(raw: string): string {
24
+ // 换行、制表先当空格,不然会被当成控制字符删掉、把两边的字粘在一起
25
+ return raw.normalize('NFKC').replace(/[\t\n\v\f\r]/gu, ' ').replace(INVISIBLE, '').replace(/\s+/gu, ' ').trim();
26
+ }
27
+
28
+ /** 中文数字(含大写)转阿拉伯数字,只用来判号码,不改发出去的字 */
29
+ const CN_DIGIT: Record<string, string> = {
30
+ 〇: '0', 零: '0', 洞: '0', 一: '1', 壹: '1', 幺: '1', 二: '2', 贰: '2', 两: '2', 三: '3', 叁: '3', 四: '4', 肆: '4',
31
+ 五: '5', 伍: '5', 六: '6', 陆: '6', 七: '7', 柒: '7', 八: '8', 捌: '8', 九: '9', 玖: '9',
32
+ };
33
+ const CN_DIGITS = new RegExp(`[${Object.keys(CN_DIGIT).join('')}]`, 'gu');
34
+
35
+ /**
36
+ * 筹码金额放行(主线 2026-10-17 审):标准千分位写法(1,000,000)不算号码。前后不能再挨着数字或逗号,
37
+ * 不然 "138,0013,8000" 这种乱分组的号码能混过去;总位数封顶 10 位:11 位的手机号写成千分位(13,800,138,000)照样拦
38
+ */
39
+ // 不用后行断言(?<!):iOS 16.4 以前的 Safari 不认,整个模块会解析失败、连带 RoomClient 起不来;前面那个字符用捕获组带回去
40
+ const AMOUNT = /(^|[^\d,])(\d{1,3}(?:,\d{3})+)(?![\d,])/gu;
41
+ const AMOUNT_MAX_DIGITS = 10;
42
+
43
+ /** 夹在号码中间的分隔符:空格、点、横线、下划线、斜杠、逗号、顿号、句号、各种破折号 */
44
+ const SEPARATORS = /[\s.\-_/,、。·•~~—–]/gu;
45
+
46
+ const TLD = 'com|cn|net|org|top|xyz|cc|io|me|vip|info|biz|club|site|online|shop|app|link|fun|tv|co|ly|gg|ink|wang|xin|ren|asia|hk|tw|us|uk';
47
+ /** 网址:协议、www、"字母数字 + 点 + 常见顶级域"(。和"点"当成点)。只在小写、去掉空白的文本上判 */
48
+ const LINK = new RegExp(`https?|:\\/\\/|www[.。点]|[a-z0-9][a-z0-9-]*(?:\\.|。|点)(?:${TLD})(?![a-z])`, 'u');
49
+
50
+ /**
51
+ * 微信、QQ 一类的叫法,隔着最多 3 个字("号是:")跟着 ≥ 5 位字母数字就是在留联系方式。
52
+ * 电话、手机不在这里:号码本身 ≥ 7 位就拦了,放进来反倒误伤"手机快没电了 hello"
53
+ */
54
+ const CONTACT = /(?:微信|威信|薇信|徽信|vx|v信|wx|weixin|wechat|qq|扣扣|企鹅|加我|私聊|私信|加群|群号)[^a-z0-9]{0,3}[a-z0-9]{5,}/u;
55
+
56
+ /** 判导流:命中返回原因;先把常见的绕法还原(中文数字、分隔符、金额) */
57
+ export function blockReason(text: string): ChatBlockWhy | null {
58
+ const lower = text.toLowerCase();
59
+ if (LINK.test(lower.replace(/\s+/gu, ''))) return 'link';
60
+ const digits = lower.replace(CN_DIGITS, (c) => CN_DIGIT[c]!);
61
+ const compact = digits.replace(SEPARATORS, '');
62
+ if (CONTACT.test(compact)) return 'contact';
63
+ const noAmount = digits.replace(AMOUNT, (m, pre: string, num: string) => (num.replace(/,/gu, '').length <= AMOUNT_MAX_DIGITS ? `${pre} ` : m));
64
+ if (/\d{7,}/u.test(noAmount.replace(SEPARATORS, ''))) return 'number';
65
+ return null;
66
+ }
67
+
68
+ /** 能参与匹配的字(字母、数字、汉字等);夹在中间的空格、符号跳过("傻 逼"、"s.b" 也认) */
69
+ const WORD_CHAR = /[\p{L}\p{N}]/u;
70
+ const ASCII = /^[a-z0-9]+$/u;
71
+
72
+ /** 脏话打码:命中的每个字换成 *,返回打码后的文本和有没有打过码。words 是游戏加的词(不能删 kit 的) */
73
+ export function maskChat(text: string, words: readonly string[] = []): { text: string; masked: boolean } {
74
+ const chars = Array.from(text);
75
+ const at: number[] = [];
76
+ const sk: string[] = [];
77
+ chars.forEach((c, i) => {
78
+ if (!WORD_CHAR.test(c)) return;
79
+ at.push(i);
80
+ // 个别字母小写后变两个码点(İ),只取第一个,保证 sk 和 at 一一对应
81
+ sk.push(Array.from(c.toLowerCase())[0]!);
82
+ });
83
+ const hit = new Set<number>();
84
+ for (const raw of [...CHAT_WORDS, ...words]) {
85
+ const w = Array.from(normalizeChat(raw).toLowerCase()).filter((c) => WORD_CHAR.test(c));
86
+ if (w.length === 0) continue;
87
+ const ascii = ASCII.test(w.join(''));
88
+ for (let i = 0; i + w.length <= sk.length; i++) {
89
+ if (!w.every((c, k) => sk[i + k] === c)) continue;
90
+ // 英文缩写要整词:sb 不该打掉 "isbn" 里的两个字母
91
+ if (ascii && (/[a-z0-9]/u.test(sk[i - 1] ?? '') || /[a-z0-9]/u.test(sk[i + w.length] ?? ''))) continue;
92
+ for (let k = 0; k < w.length; k++) hit.add(at[i + k]!);
93
+ }
94
+ }
95
+ if (hit.size === 0) return { text, masked: false };
96
+ return { text: chars.map((c, i) => (hit.has(i) ? '*' : c)).join(''), masked: true };
97
+ }
98
+
99
+ /** 一条聊天从头到尾过一遍:房间和页面用同一个函数 */
100
+ export function checkChat(raw: unknown, words: readonly string[] = []): ChatCheck {
101
+ if (typeof raw !== 'string') return { ok: false, code: 'chat_empty' };
102
+ // 先截一刀再规整:几十 KB 的消息早在 maxMessageBytes 就挡了,这里只防规整本身吃太多
103
+ const text = normalizeChat(raw.slice(0, CHAT_MAX * 8));
104
+ const n = Array.from(text).length;
105
+ if (n === 0) return { ok: false, code: 'chat_empty' };
106
+ if (n > CHAT_MAX || raw.length > CHAT_MAX * 8) return { ok: false, code: 'chat_too_long' };
107
+ const why = blockReason(text);
108
+ if (why) return { ok: false, code: 'chat_blocked', why };
109
+ const m = maskChat(text, words);
110
+ return { ok: true, text: m.text, masked: m.masked };
111
+ }
112
+
113
+ /** 被拒时给发的人看的话(页面用;房间只回 code) */
114
+ export function chatRejectText(code: string, why?: ChatBlockWhy): string {
115
+ switch (code) {
116
+ case 'chat_blocked':
117
+ return why === 'link' ? '不能发链接' : '不能发号码,金额请写成 1,000,000';
118
+ case 'chat_too_long':
119
+ return `一条最多 ${CHAT_MAX} 个字`;
120
+ case 'chat_too_fast':
121
+ return '说慢一点';
122
+ case 'not_seated':
123
+ return '坐下才能说话';
124
+ case 'room_over':
125
+ return '这桌已经散了';
126
+ case 'chat_offline':
127
+ return '还没连上';
128
+ default:
129
+ return '没发出去';
130
+ }
131
+ }
package/src/client.ts CHANGED
@@ -16,7 +16,8 @@
16
16
  *
17
17
  * 浏览器里的计时器(重连、心跳)可以用 setTimeout,"只用一个闹钟"是房间(Durable Object)那边的规矩。
18
18
  */
19
- import { CLOSE, CLOSE_REASON, type CreateError, type DownMsg, type EmotedMsg, isFinalClose, PING, PONG, PROTOCOL_VERSION, type RoomInfo, type UpMsg } from './protocol.js';
19
+ import { type ChatBlockWhy, 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
21
 
21
22
  export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'failed';
22
23
 
@@ -37,6 +38,13 @@ export interface RoomClientOptions<V> {
37
38
  * to(0.7.0):冲着哪个座位发的,没指定时是 undefined;bot(0.10.0):电脑座位发的(GameRoom.botEmote)是 true,一般不用区分
38
39
  */
39
40
  onEmote?(seat: number, id: number, to?: number, bot?: boolean): void;
41
+ /**
42
+ * 有人说了一句(0.18.0,见 RoomGame.chat 和 docs/chat.md):seat 是房间的座位号,text 是房间过滤、打码后的字,masked 表示打过码。
43
+ * 传了它连接时才带 chat=1,房间才会给这个页面发聊天;不传就收不到、也别调 chat()
44
+ */
45
+ onChat?(seat: number, text: string, masked: boolean): void;
46
+ /** 房间拒了一条聊天(0.18.0,CHAT_ERRORS 里聊天专有的那几个):本地已经先挡过一遍,一般只在节奏刚调过、刚重连时出现 */
47
+ onChatError?(code: string): void;
40
48
  /** 下面这些是给测试注入的;浏览器里不用传 */
41
49
  WebSocketImpl?: { new (url: string): WebSocketLike };
42
50
  random?: () => number;
@@ -89,6 +97,19 @@ export function backoffMs(attempt: number, random: () => number = Math.random):
89
97
  return Math.round(cap * (0.5 + random() / 2));
90
98
  }
91
99
 
100
+ /**
101
+ * chat() 的结果(0.18.0):被本地挡下的 code 和房间回的一样(CHAT_ERRORS),多一个 chat_offline(没连上);
102
+ * chat_blocked 带 why(链接还是号码,挑说法用),chat_too_fast 带 waitMs
103
+ */
104
+ export type ChatSendResult =
105
+ | { ok: true }
106
+ | { ok: false; code: 'chat_off' | 'chat_empty' | 'chat_too_long' | 'chat_offline' }
107
+ | { ok: false; code: 'chat_blocked'; why: ChatBlockWhy }
108
+ | { ok: false; code: 'chat_too_fast'; waitMs: number };
109
+
110
+ /** 聊天专有的拒绝码:不交给 onMessage(not_seated、room_over 和短语共用,照旧走 onMessage) */
111
+ const CHAT_ONLY_ERRORS = new Set(['chat_off', 'chat_empty', 'chat_too_long', 'chat_too_fast', 'chat_blocked']);
112
+
92
113
  export class RoomClient<A = unknown, V = unknown> {
93
114
  private ws: WebSocketLike | null = null;
94
115
  private status: ClientStatus = 'connecting';
@@ -117,6 +138,9 @@ export class RoomClient<A = unknown, V = unknown> {
117
138
  /** 太快时排着的(只留最后 EMOTE_QUEUE 个),到下一个令牌时发 */
118
139
  private emoteQueue: { id: number; to?: number }[] = [];
119
140
  private emoteTimer: unknown = null;
141
+ /** 聊天的节奏(0.18.0,welcome.chat)和本地记的最近一分钟发出去的时间;没收到 welcome 前按 kit 的默认 */
142
+ private chatRate = { ms: 3000, perMin: 10 };
143
+ private chatTimes: number[] = [];
120
144
  private readonly opts: Required<Pick<RoomClientOptions<V>, 'random' | 'setTimer' | 'clearTimer'>> & RoomClientOptions<V>;
121
145
  private readonly onWake = () => this.wake(false);
122
146
  /** pageshow 带 persisted:页面是从往返缓存里拿回来的,旧连接不能信 */
@@ -163,6 +187,33 @@ export class RoomClient<A = unknown, V = unknown> {
163
187
  this.trySend(opts === undefined ? { t: 'start' } : { t: 'start', opts });
164
188
  }
165
189
 
190
+ /**
191
+ * 说一句(0.18.0):先在本地过一遍房间同样的检查(字数、导流、节奏),不行就不发、直接告诉页面为什么(chatRejectText 给说法);
192
+ * 行就发出去,房间转发回来的经 onChat 交给页面(发的人自己也收到)。没连上不存着重发。waitMs 是太快时还要等多久
193
+ */
194
+ chat(text: string, now = Date.now()): ChatSendResult {
195
+ if (!this.opts.onChat) return { ok: false, code: 'chat_off' };
196
+ const c = checkChat(text);
197
+ if (!c.ok) return c.code === 'chat_blocked' ? { ok: false, code: c.code, why: c.why } : { ok: false, code: c.code };
198
+ const wait = this.chatWait(now);
199
+ if (wait > 0) return { ok: false, code: 'chat_too_fast', waitMs: wait };
200
+ if (!this.ws || this.ws.readyState !== OPEN || !this.welcomed) return { ok: false, code: 'chat_offline' };
201
+ this.chatTimes.push(now);
202
+ // 发原文:本地打过码的再交给房间就认不出脏话了,masked 会丢(房间以自己的过滤为准)
203
+ this.trySend({ t: 'chat', text });
204
+ return { ok: true };
205
+ }
206
+
207
+ /** 还要等多久才能说下一句(毫秒,0 = 现在就能):按房间给的节奏,界面拿来给"发送"按钮倒计时 */
208
+ chatWait(now = Date.now()): number {
209
+ const { ms, perMin } = this.chatRate;
210
+ this.chatTimes = this.chatTimes.filter((t) => now - t < 60_000);
211
+ const last = this.chatTimes[this.chatTimes.length - 1];
212
+ let wait = ms > 0 && last !== undefined ? last + ms - now : 0;
213
+ if (perMin > 0 && this.chatTimes.length >= perMin) wait = Math.max(wait, this.chatTimes[this.chatTimes.length - perMin]! + 60_000 - now);
214
+ return Math.max(0, wait);
215
+ }
216
+
166
217
  /**
167
218
  * 快捷短语(0.6.0):发游戏短语表里的编号;to(0.7.0)是冲着哪个座位(扔道具、点名嘲讽),不带就是对全桌。
168
219
  * 0.7.0 起本地按房间的节奏(welcome.emote)排队:太快的排到下一个令牌,只留最后 2 个,不提示用户。没连上就丢掉,不存着重发(过时的短语没意义);
@@ -224,6 +275,7 @@ export class RoomClient<A = unknown, V = unknown> {
224
275
  const Impl = this.opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as { new (url: string): WebSocketLike });
225
276
  const url = new URL(this.opts.url);
226
277
  url.searchParams.set('cv', String(this.opts.clientVersion ?? 0));
278
+ if (this.opts.onChat) url.searchParams.set('chat', '1');
227
279
  const ws = new Impl(url.toString());
228
280
  this.ws = ws;
229
281
  this.connectTimer = this.opts.setTimer(() => {
@@ -245,9 +297,9 @@ export class RoomClient<A = unknown, V = unknown> {
245
297
  // 收到任何东西都说明连接活着
246
298
  this.probing = false;
247
299
  if (typeof ev.data !== 'string' || ev.data === PONG) return;
248
- let msg: DownMsg<V> | EmotedMsg;
300
+ let msg: DownMsg<V> | EmotedMsg | ChattedMsg;
249
301
  try {
250
- msg = JSON.parse(ev.data) as DownMsg<V> | EmotedMsg;
302
+ msg = JSON.parse(ev.data) as DownMsg<V> | EmotedMsg | ChattedMsg;
251
303
  } catch {
252
304
  return;
253
305
  }
@@ -255,10 +307,19 @@ export class RoomClient<A = unknown, V = unknown> {
255
307
  if (Number.isInteger(msg.seat) && Number.isInteger(msg.id)) this.opts.onEmote?.(msg.seat, msg.id, Number.isInteger(msg.to) ? msg.to : undefined, msg.bot === true);
256
308
  return;
257
309
  }
310
+ if (msg.t === 'chat') {
311
+ if (Number.isInteger(msg.seat) && typeof msg.text === 'string') this.opts.onChat?.(msg.seat, msg.text, msg.masked === true);
312
+ return;
313
+ }
314
+ if (msg.t === 'error' && !msg.moveId && CHAT_ONLY_ERRORS.has(msg.code)) {
315
+ this.opts.onChatError?.(msg.code);
316
+ return;
317
+ }
258
318
  // 本地已经按节奏排队了,房间偶尔还嫌快(比如刚重连、节奏刚调过):静默丢掉,不打扰页面
259
319
  if (msg.t === 'error' && msg.code === 'emote_too_fast' && !msg.moveId) return;
260
320
  if (msg.t === 'welcome') {
261
321
  if (msg.emote && msg.emote.ms >= 0 && msg.emote.burst >= 1) this.emoteRate = { ms: msg.emote.ms, burst: msg.emote.burst };
322
+ if (msg.chat && msg.chat.ms >= 0 && msg.chat.perMin >= 0) this.chatRate = { ms: msg.chat.ms, perMin: msg.chat.perMin };
262
323
  // 快照:整桌重画,从它的 seq 往后接;真的进了房间,退避和失败次数清零
263
324
  this.lastSeq = msg.seq;
264
325
  this.welcomed = true;
package/src/game.ts CHANGED
@@ -161,6 +161,16 @@ export interface RoomGame<S, A, V, O = unknown, E extends RoomEffect = RoomEffec
161
161
  * 短语的文字、图标由游戏自己在页面里定,房间不知道内容。不写(或不是正整数)就是没有短语,发了回 no_emotes
162
162
  */
163
163
  emotes?: number;
164
+
165
+ // ---------- 0.18.0 ----------
166
+
167
+ /**
168
+ * 好友房聊天(docs/chat.md):true 时入座的人能发自由文字(一条 ≤ 30 个字),房间过滤、限速后只转发,不进状态、不写存储。
169
+ * 不写就没有聊天,发了回 chat_off,welcome 里也不带节奏
170
+ */
171
+ chat?: boolean;
172
+ /** 聊天打码时在 kit 的词表(chat-words.ts)之外再加的词;只能加,删不了 kit 的 */
173
+ chatWords?: readonly string[];
164
174
  }
165
175
 
166
176
  /**
@@ -193,6 +203,7 @@ export function snapshotBytes<S, A, V, O, E extends RoomEffect>(game: RoomGame<S
193
203
  view: game.view(state, seat),
194
204
  // 有短语的游戏多带一个节奏(0.7.0)
195
205
  ...(game.emotes ? { emote: { ms: 999_999, burst: 99 } } : {}),
206
+ ...(game.chat ? { chat: { ms: 999_999, perMin: 99, max: 99 } } : {}),
196
207
  };
197
208
  max = Math.max(max, new TextEncoder().encode(JSON.stringify(msg)).byteLength);
198
209
  }
package/src/protocol.ts CHANGED
@@ -111,7 +111,15 @@ export interface EmoteMsg {
111
111
  /** 冲着哪个座位(0.7.0,可选):扔道具、点名嘲讽。得是有人的别的座位,不然回 bad_target;不带就和 0.6.0 一样 */
112
112
  to?: number;
113
113
  }
114
- export type UpMsg<A = unknown> = SitMsg | LeaveMsg | StartMsg | ActMsg<A> | EmoteMsg;
114
+ /**
115
+ * 好友房聊天(0.18.0,docs/chat.md):自由文字,一条 ≤ 30 个字(chat.ts 的 CHAT_MAX)。只有入座的人能发、游戏开了(RoomGame.chat)才收;
116
+ * 房间过一遍 checkChat(导流整条拒发、脏话打码)、按座位限速,然后只转发,不进状态、不写存储、不进快照。被拒回 error(见 CHAT_ERRORS)
117
+ */
118
+ export interface ChatMsg {
119
+ t: 'chat';
120
+ text: string;
121
+ }
122
+ export type UpMsg<A = unknown> = SitMsg | LeaveMsg | StartMsg | ActMsg<A> | EmoteMsg | ChatMsg;
115
123
 
116
124
  // ---------- 下行(房间 → 浏览器) ----------
117
125
 
@@ -195,6 +203,11 @@ export interface WelcomeMsg<V = unknown> {
195
203
  * 用户感觉不到限速;运维在 KIT_QUOTA 里改,不用发版
196
204
  */
197
205
  emote?: { ms: number; burst: number };
206
+ /**
207
+ * 聊天的节奏和字数(0.18.0,游戏开了聊天才带):同一个座位两条至少隔 ms 毫秒、一分钟最多 perMin 条,一条最多 max 个字。
208
+ * RoomClient 按它在本地先挡一遍(按钮置灰、倒计时),不靠被拒
209
+ */
210
+ chat?: { ms: number; perMin: number; max: number };
198
211
  }
199
212
 
200
213
  /** 每一步之后的状态。ack 只发给做这一步的那条连接:对应它的 moveId。 */
@@ -247,4 +260,23 @@ export interface EmotedMsg {
247
260
  */
248
261
  export const EMOTE_ERRORS = ['no_emotes', 'bad_emote', 'not_seated', 'room_over', 'emote_too_fast', 'bad_target'] as const;
249
262
 
263
+ /**
264
+ * 有人说了一句(0.18.0):seat 是房间的座位号,text 是过滤、打码以后的字,masked 表示打过码。
265
+ * 只发给连接时带了 chat=1 的连接(新版 RoomClient 传了 onChat 才带;旧页面不认识这条消息,不发给它),含发的人自己。
266
+ * 和短语一样不在 DownMsg 里,RoomClient 交给 onChat;不带 seq,断线时错过的不补发
267
+ */
268
+ export interface ChattedMsg {
269
+ t: 'chat';
270
+ seat: number;
271
+ text: string;
272
+ masked?: true;
273
+ }
274
+
275
+ /**
276
+ * 聊天被拒时 error 的 code(0.18.0,只回发的人):chat_off 这个游戏没开聊天;not_seated 没入座;room_over 房间打完了;
277
+ * chat_empty 去掉空白后是空的;chat_too_long 超过 30 个字;chat_too_fast 这个座位太快了(两条间隔、每分钟条数);
278
+ * chat_blocked 像链接、号码、微信 QQ 号(chat.ts 的 blockReason)
279
+ */
280
+ export const CHAT_ERRORS = ['chat_off', 'not_seated', 'room_over', 'chat_empty', 'chat_too_long', 'chat_too_fast', 'chat_blocked'] as const;
281
+
250
282
  export type DownMsg<V = unknown> = WelcomeMsg<V> | StateMsg<V> | ErrorMsg;
package/src/room.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * 计量说今天快满了(busy)就不进新人;说满了(stop)就在这一段打完(RoomGame.breakPoint)时收场。
18
18
  */
19
19
  import { DurableObject } from 'cloudflare:workers';
20
+ import { CHAT_MAX, checkChat } from './chat.js';
20
21
  import type { RoomEffect, RoomGame, RoomSetup } from './game.js';
21
22
  import { type Identity, KIT_HOST, LIMITED_HEADER, MISS_HEADER, meterStub, parseIdentity } from './internal.js';
22
23
  import type { MeterEnv, MeterLevel, RoomMeter } from './meter.js';
@@ -24,6 +25,7 @@ import { ENTROPY_WORDS } from './rng.js';
24
25
  import {
25
26
  CLOSE,
26
27
  CLOSE_REASON,
28
+ type ChattedMsg,
27
29
  type DownMsg,
28
30
  type EmotedMsg,
29
31
  type Frame,
@@ -85,6 +87,12 @@ export interface RoomLimits {
85
87
  */
86
88
  emoteMs: number;
87
89
  emoteBurst: number;
90
+ /**
91
+ * 聊天的节奏(0.18.0,RoomGame.chat):同一个座位两条至少隔 chatMs、滚动一分钟最多 chatPerMin 条,超了回 chat_too_fast、不转发、不占数。
92
+ * 和短语一样是代码里的默认值,KIT_QUOTA 里写了 chatMs / chatPerMin 就以那里为准。chatMs 为 0 不限间隔,chatPerMin 为 0 不限条数
93
+ */
94
+ chatMs: number;
95
+ chatPerMin: number;
88
96
  /**
89
97
  * 写了多少行报一次计量(0.2.0)。计量每报一次自己写 1 行,200 就是多花 0.5%;
90
98
  * 太大的话计量知道得晚,满了以后房间还会多写最多这么多行才收场
@@ -115,6 +123,8 @@ export const DEFAULT_LIMITS: RoomLimits = {
115
123
  minClientVersion: 0,
116
124
  emoteMs: 1000,
117
125
  emoteBurst: 3,
126
+ chatMs: 3000,
127
+ chatPerMin: 10,
118
128
  meterBatch: 200,
119
129
  closingMs: 60 * 60_000,
120
130
  };
@@ -220,6 +230,8 @@ interface Persist<S> {
220
230
  interface Attachment {
221
231
  userId: string;
222
232
  name: string;
233
+ /** 连接时带了 chat=1(0.18.0,新版 RoomClient 传了 onChat):只给这样的连接发聊天,旧页面不认识这条消息 */
234
+ chat?: true;
223
235
  }
224
236
 
225
237
  const TABLE = 'kit_room';
@@ -318,6 +330,9 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
318
330
  /** 每个座位快捷短语的令牌桶(内存里;被驱逐就重置成满的,驱逐前至少空闲了一阵,本来也该满了) */
319
331
  private readonly emoteBuckets = new Map<number, { tokens: number; at: number }>();
320
332
  private emoteRateCache: EmoteRate | null = null;
333
+ /** 每个座位最近一分钟里发出去的聊天的时间(内存里,被驱逐就清空,和短语的桶一样) */
334
+ private readonly chatTimes = new Map<number, number[]>();
335
+ private chatRateCache: ChatRate | null = null;
321
336
  /** 这一步写了几行(sql 的 rowsWritten + setAlarm、关房各 1);测试断言 ≤ 2 */
322
337
  private writes = 0;
323
338
  /** 每个入口结束时记下来,给测试看 */
@@ -396,7 +411,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
396
411
  if (request.headers.has(LIMITED_HEADER) && !this.insider(me.userId)) return this.refuse(CLOSE.tryLater);
397
412
  const gate = await this.newcomerGate(me.userId);
398
413
  if (gate !== null) return this.refuse(gate);
399
- return this.entry(() => this.join(me, url.searchParams.get('cv')));
414
+ return this.entry(() => this.join(me, url.searchParams.get('cv'), url.searchParams.get('chat') === '1'));
400
415
  }
401
416
 
402
417
  override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
@@ -636,7 +651,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
636
651
  }
637
652
 
638
653
  /** 进房(含重连):接受、记座位、发快照,中间没有 await(4.5 续局的写法)。 */
639
- private join(me: Identity, rawCv: string | null): Response {
654
+ private join(me: Identity, rawCv: string | null, chat = false): Response {
640
655
  const pair = new WebSocketPair();
641
656
  const [client, server] = [pair[0], pair[1]];
642
657
  this.ctx.acceptWebSocket(server);
@@ -699,7 +714,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
699
714
  other.serializeAttachment(null);
700
715
  other.close(CLOSE.replaced, 'replaced');
701
716
  }
702
- server.serializeAttachment({ userId: me.userId, name: me.name } satisfies Attachment);
717
+ server.serializeAttachment({ userId: me.userId, name: me.name, ...(chat ? { chat: true as const } : {}) } satisfies Attachment);
703
718
 
704
719
  if (s && changed) {
705
720
  // 托管被收回、而且正轮到他:这一手从现在重新算 30 秒。只是掉线没到托管就回来的,不重置(不然隔 29 秒重连一次就能一直拖着)
@@ -751,6 +766,8 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
751
766
  return this.act(ws, seat, msg.moveId, msg.baseSeq, msg.action);
752
767
  case 'emote':
753
768
  return this.emote(ws, seat, msg.id, msg.to);
769
+ case 'chat':
770
+ return this.chat(ws, seat, msg.text);
754
771
  default:
755
772
  return this.reject(ws, 'bad_message');
756
773
  }
@@ -781,6 +798,39 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
781
798
  }
782
799
  }
783
800
 
801
+ /**
802
+ * 聊天(0.18.0,docs/chat.md):校验、过滤、按座位限速,然后只转发给带了 chat=1 的连接;不写存储、不改 seq、不进快照,房间关了就没了。
803
+ * 入站已经在 onMessage 里记过。被拒的不占这个座位的条数。真人说的话不交给规则、onStep、onEmote(公平:电脑不读真人说的话)
804
+ */
805
+ private chat(ws: WebSocket, seat: number | null, raw: unknown): void {
806
+ const st = this.st!;
807
+ if (this.game.chat !== true) return this.reject(ws, 'chat_off');
808
+ if (seat === null) return this.reject(ws, 'not_seated');
809
+ if (st.status === 'over') return this.reject(ws, 'room_over');
810
+ const c = checkChat(raw, this.game.chatWords ?? []);
811
+ if (!c.ok) return this.reject(ws, c.code);
812
+ if (!this.takeChat(seat)) return this.reject(ws, 'chat_too_fast');
813
+ const msg: ChattedMsg = { t: 'chat', seat, text: c.text, ...(c.masked ? { masked: true as const } : {}) };
814
+ for (const o of this.sockets()) if (attachmentOf(o)?.chat) this.send(o, msg);
815
+ }
816
+
817
+ private chatRate(): ChatRate {
818
+ this.chatRateCache ??= chatRateOf((this.env as unknown as MeterEnv | undefined)?.KIT_QUOTA, { ms: this.limits.chatMs, perMin: this.limits.chatPerMin });
819
+ return this.chatRateCache;
820
+ }
821
+
822
+ private takeChat(seat: number): boolean {
823
+ const { ms, perMin } = this.chatRate();
824
+ const now = Date.now();
825
+ const times = (this.chatTimes.get(seat) ?? []).filter((t) => now - t < 60_000);
826
+ this.chatTimes.set(seat, times);
827
+ const last = times[times.length - 1];
828
+ if (ms > 0 && last !== undefined && now - last < ms) return false;
829
+ if (perMin > 0 && times.length >= perMin) return false;
830
+ times.push(now);
831
+ return true;
832
+ }
833
+
784
834
  /** 快捷短语的节奏:KIT_QUOTA 里写了就用那里的,没写用 RoomLimits(只算一次) */
785
835
  private emoteRate(): EmoteRate {
786
836
  this.emoteRateCache ??= emoteRateOf((this.env as unknown as MeterEnv | undefined)?.KIT_QUOTA, { ms: this.limits.emoteMs, burst: this.limits.emoteBurst });
@@ -1638,6 +1688,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1638
1688
  now: Date.now(),
1639
1689
  // 有短语的游戏告诉页面限速的节奏(0.7.0),RoomClient 按它排队,用户感觉不到限速
1640
1690
  ...(hasEmotes(this.game.emotes) ? { emote: this.emoteRate() } : {}),
1691
+ ...(this.game.chat === true ? { chat: { ...this.chatRate(), max: CHAT_MAX } } : {}),
1641
1692
  };
1642
1693
  }
1643
1694
 
@@ -1667,7 +1718,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
1667
1718
  }
1668
1719
  }
1669
1720
 
1670
- private send(ws: WebSocket, msg: DownMsg<V> | EmotedMsg): void {
1721
+ private send(ws: WebSocket, msg: DownMsg<V> | EmotedMsg | ChattedMsg): void {
1671
1722
  try {
1672
1723
  ws.send(JSON.stringify(msg));
1673
1724
  } catch {
@@ -1777,3 +1828,24 @@ export function emoteRateOf(raw: unknown, base: EmoteRate): EmoteRate {
1777
1828
  const ok = (x: unknown): x is number => typeof x === 'number' && Number.isInteger(x) && x >= 0;
1778
1829
  return { ms: ok(q.emoteMs) ? q.emoteMs : base.ms, burst: Math.max(1, ok(q.emoteBurst) ? q.emoteBurst : base.burst) };
1779
1830
  }
1831
+
1832
+ /** 聊天的节奏(0.18.0):两条至少隔 ms 毫秒,一分钟最多 perMin 条 */
1833
+ export interface ChatRate {
1834
+ ms: number;
1835
+ perMin: number;
1836
+ }
1837
+
1838
+ /** KIT_QUOTA(对象或 JSON 字符串)里的 chatMs / chatPerMin 覆盖默认值;不合格的忽略 */
1839
+ export function chatRateOf(raw: unknown, base: ChatRate): ChatRate {
1840
+ let v: unknown = raw;
1841
+ if (typeof raw === 'string') {
1842
+ try {
1843
+ v = JSON.parse(raw);
1844
+ } catch {
1845
+ v = null;
1846
+ }
1847
+ }
1848
+ const q = v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
1849
+ const ok = (x: unknown): x is number => typeof x === 'number' && Number.isInteger(x) && x >= 0;
1850
+ return { ms: ok(q.chatMs) ? q.chatMs : base.ms, perMin: ok(q.chatPerMin) ? q.chatPerMin : base.perMin };
1851
+ }