@yyyu-star/arcade-kit 0.17.0 → 0.18.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.
- package/CHANGELOG.md +20 -0
- package/README.md +21 -1
- package/dist/chat-words.d.ts +7 -0
- package/dist/chat-words.d.ts.map +1 -0
- package/dist/chat-words.js +15 -0
- package/dist/chat-words.js.map +1 -0
- package/dist/chat.d.ts +64 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +151 -0
- package/dist/chat.js.map +1 -0
- package/dist/client.d.ts +19 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +49 -0
- package/dist/client.js.map +1 -1
- package/dist/game.d.ts +7 -0
- package/dist/game.d.ts.map +1 -1
- package/dist/game.js +1 -0
- package/dist/game.js.map +1 -1
- package/dist/protocol.d.ts +35 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +6 -0
- package/dist/protocol.js.map +1 -1
- package/dist/room.d.ts +23 -0
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +66 -3
- package/dist/room.js.map +1 -1
- package/dist/table-fun.d.ts +53 -3
- package/dist/table-fun.d.ts.map +1 -1
- package/dist/table-fun.js +194 -7
- package/dist/table-fun.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +5 -2
- package/dist/worker.js.map +1 -1
- package/package.json +5 -1
- package/src/chat-words.ts +14 -0
- package/src/chat.ts +177 -0
- package/src/client.ts +57 -3
- package/src/game.ts +11 -0
- package/src/protocol.ts +33 -1
- package/src/room.ts +76 -4
- package/src/table-fun.ts +199 -8
- package/src/worker.ts +5 -2
package/src/chat.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 好友房聊天的过滤(0.18.0,设计见 docs/chat.md 第 2 节):纯函数,房间和页面都 import,页面发之前跑一遍给即时提示,房间收到再跑一遍(以房间为准)。
|
|
3
|
+
* 顺序:规整 → 判长度 → 判导流(整条拒发)→ 脏话打码(照发)。不碰 DOM、时间和随机数(只有末尾单机用的 localChat 默认读当前时间来限速)。
|
|
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
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 发一句的结果(0.18.0 起 RoomClient.chat、0.18.1 起 localChat 共用):被挡下的 code 和房间回的一样(CHAT_ERRORS),
|
|
135
|
+
* 多一个 chat_offline(没连上);chat_blocked 带 why(链接还是号码,挑说法用),chat_too_fast 带 waitMs
|
|
136
|
+
*/
|
|
137
|
+
export type ChatSendResult =
|
|
138
|
+
| { ok: true }
|
|
139
|
+
| { ok: false; code: 'chat_off' | 'chat_empty' | 'chat_too_long' | 'chat_offline' }
|
|
140
|
+
| { ok: false; code: 'chat_blocked'; why: ChatBlockWhy }
|
|
141
|
+
| { ok: false; code: 'chat_too_fast'; waitMs: number };
|
|
142
|
+
|
|
143
|
+
export interface LocalChatOptions {
|
|
144
|
+
/** 游戏加的打码词(和房间的 RoomGame.chatWords 一样,只能加) */
|
|
145
|
+
words?: readonly string[];
|
|
146
|
+
/** 两条至少隔多久(毫秒),默认和房间一样 3000;0 不限 */
|
|
147
|
+
ms?: number;
|
|
148
|
+
/** 滚动一分钟最多几条,默认 10;0 不限 */
|
|
149
|
+
perMin?: number;
|
|
150
|
+
/** 现在几点(毫秒),测试注入;默认 Date.now */
|
|
151
|
+
now?(): number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 单机聊天(0.18.1,所有者要求单机也能聊):返回一个和 RoomClient.chat 一样用法的发送函数,过滤、限速和房间同一套,
|
|
156
|
+
* 行就调 say(打码后的字, 有没有打码) 让页面在自己头像旁显示。只在本机显示自己说的,不发到任何地方、不存;
|
|
157
|
+
* 电脑不读、不回(公平:电脑的决策和反应都不看真人打的字)。不碰 DOM;时间只在这里用(上面的过滤仍然是纯函数)
|
|
158
|
+
*/
|
|
159
|
+
export function localChat(say: (text: string, masked: boolean) => void, opts: LocalChatOptions = {}): (text: string) => ChatSendResult {
|
|
160
|
+
const ms = opts.ms ?? 3000;
|
|
161
|
+
const perMin = opts.perMin ?? 10;
|
|
162
|
+
const now = opts.now ?? ((): number => Date.now());
|
|
163
|
+
let times: number[] = [];
|
|
164
|
+
return (text) => {
|
|
165
|
+
const c = checkChat(text, opts.words ?? []);
|
|
166
|
+
if (!c.ok) return c.code === 'chat_blocked' ? { ok: false, code: c.code, why: c.why } : { ok: false, code: c.code };
|
|
167
|
+
const t = now();
|
|
168
|
+
times = times.filter((x) => t - x < 60_000);
|
|
169
|
+
const last = times[times.length - 1];
|
|
170
|
+
let wait = ms > 0 && last !== undefined ? last + ms - t : 0;
|
|
171
|
+
if (perMin > 0 && times.length >= perMin) wait = Math.max(wait, times[times.length - perMin]! + 60_000 - t);
|
|
172
|
+
if (wait > 0) return { ok: false, code: 'chat_too_fast', waitMs: wait };
|
|
173
|
+
times.push(t);
|
|
174
|
+
say(c.text, c.masked);
|
|
175
|
+
return { ok: true };
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
*
|
|
17
17
|
* 浏览器里的计时器(重连、心跳)可以用 setTimeout,"只用一个闹钟"是房间(Durable Object)那边的规矩。
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
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
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,12 @@ export function backoffMs(attempt: number, random: () => number = Math.random):
|
|
|
89
97
|
return Math.round(cap * (0.5 + random() / 2));
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
// chat() 的结果类型 0.18.1 起定义在 chat.ts(单机的 localChat 也用),这里照旧导出,旧代码不用改
|
|
101
|
+
export type { ChatSendResult };
|
|
102
|
+
|
|
103
|
+
/** 聊天专有的拒绝码:不交给 onMessage(not_seated、room_over 和短语共用,照旧走 onMessage) */
|
|
104
|
+
const CHAT_ONLY_ERRORS = new Set(['chat_off', 'chat_empty', 'chat_too_long', 'chat_too_fast', 'chat_blocked']);
|
|
105
|
+
|
|
92
106
|
export class RoomClient<A = unknown, V = unknown> {
|
|
93
107
|
private ws: WebSocketLike | null = null;
|
|
94
108
|
private status: ClientStatus = 'connecting';
|
|
@@ -117,6 +131,9 @@ export class RoomClient<A = unknown, V = unknown> {
|
|
|
117
131
|
/** 太快时排着的(只留最后 EMOTE_QUEUE 个),到下一个令牌时发 */
|
|
118
132
|
private emoteQueue: { id: number; to?: number }[] = [];
|
|
119
133
|
private emoteTimer: unknown = null;
|
|
134
|
+
/** 聊天的节奏(0.18.0,welcome.chat)和本地记的最近一分钟发出去的时间;没收到 welcome 前按 kit 的默认 */
|
|
135
|
+
private chatRate = { ms: 3000, perMin: 10 };
|
|
136
|
+
private chatTimes: number[] = [];
|
|
120
137
|
private readonly opts: Required<Pick<RoomClientOptions<V>, 'random' | 'setTimer' | 'clearTimer'>> & RoomClientOptions<V>;
|
|
121
138
|
private readonly onWake = () => this.wake(false);
|
|
122
139
|
/** pageshow 带 persisted:页面是从往返缓存里拿回来的,旧连接不能信 */
|
|
@@ -163,6 +180,33 @@ export class RoomClient<A = unknown, V = unknown> {
|
|
|
163
180
|
this.trySend(opts === undefined ? { t: 'start' } : { t: 'start', opts });
|
|
164
181
|
}
|
|
165
182
|
|
|
183
|
+
/**
|
|
184
|
+
* 说一句(0.18.0):先在本地过一遍房间同样的检查(字数、导流、节奏),不行就不发、直接告诉页面为什么(chatRejectText 给说法);
|
|
185
|
+
* 行就发出去,房间转发回来的经 onChat 交给页面(发的人自己也收到)。没连上不存着重发。waitMs 是太快时还要等多久
|
|
186
|
+
*/
|
|
187
|
+
chat(text: string, now = Date.now()): ChatSendResult {
|
|
188
|
+
if (!this.opts.onChat) return { ok: false, code: 'chat_off' };
|
|
189
|
+
const c = checkChat(text);
|
|
190
|
+
if (!c.ok) return c.code === 'chat_blocked' ? { ok: false, code: c.code, why: c.why } : { ok: false, code: c.code };
|
|
191
|
+
const wait = this.chatWait(now);
|
|
192
|
+
if (wait > 0) return { ok: false, code: 'chat_too_fast', waitMs: wait };
|
|
193
|
+
if (!this.ws || this.ws.readyState !== OPEN || !this.welcomed) return { ok: false, code: 'chat_offline' };
|
|
194
|
+
this.chatTimes.push(now);
|
|
195
|
+
// 发原文:本地打过码的再交给房间就认不出脏话了,masked 会丢(房间以自己的过滤为准)
|
|
196
|
+
this.trySend({ t: 'chat', text });
|
|
197
|
+
return { ok: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 还要等多久才能说下一句(毫秒,0 = 现在就能):按房间给的节奏,界面拿来给"发送"按钮倒计时 */
|
|
201
|
+
chatWait(now = Date.now()): number {
|
|
202
|
+
const { ms, perMin } = this.chatRate;
|
|
203
|
+
this.chatTimes = this.chatTimes.filter((t) => now - t < 60_000);
|
|
204
|
+
const last = this.chatTimes[this.chatTimes.length - 1];
|
|
205
|
+
let wait = ms > 0 && last !== undefined ? last + ms - now : 0;
|
|
206
|
+
if (perMin > 0 && this.chatTimes.length >= perMin) wait = Math.max(wait, this.chatTimes[this.chatTimes.length - perMin]! + 60_000 - now);
|
|
207
|
+
return Math.max(0, wait);
|
|
208
|
+
}
|
|
209
|
+
|
|
166
210
|
/**
|
|
167
211
|
* 快捷短语(0.6.0):发游戏短语表里的编号;to(0.7.0)是冲着哪个座位(扔道具、点名嘲讽),不带就是对全桌。
|
|
168
212
|
* 0.7.0 起本地按房间的节奏(welcome.emote)排队:太快的排到下一个令牌,只留最后 2 个,不提示用户。没连上就丢掉,不存着重发(过时的短语没意义);
|
|
@@ -224,6 +268,7 @@ export class RoomClient<A = unknown, V = unknown> {
|
|
|
224
268
|
const Impl = this.opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as { new (url: string): WebSocketLike });
|
|
225
269
|
const url = new URL(this.opts.url);
|
|
226
270
|
url.searchParams.set('cv', String(this.opts.clientVersion ?? 0));
|
|
271
|
+
if (this.opts.onChat) url.searchParams.set('chat', '1');
|
|
227
272
|
const ws = new Impl(url.toString());
|
|
228
273
|
this.ws = ws;
|
|
229
274
|
this.connectTimer = this.opts.setTimer(() => {
|
|
@@ -245,9 +290,9 @@ export class RoomClient<A = unknown, V = unknown> {
|
|
|
245
290
|
// 收到任何东西都说明连接活着
|
|
246
291
|
this.probing = false;
|
|
247
292
|
if (typeof ev.data !== 'string' || ev.data === PONG) return;
|
|
248
|
-
let msg: DownMsg<V> | EmotedMsg;
|
|
293
|
+
let msg: DownMsg<V> | EmotedMsg | ChattedMsg;
|
|
249
294
|
try {
|
|
250
|
-
msg = JSON.parse(ev.data) as DownMsg<V> | EmotedMsg;
|
|
295
|
+
msg = JSON.parse(ev.data) as DownMsg<V> | EmotedMsg | ChattedMsg;
|
|
251
296
|
} catch {
|
|
252
297
|
return;
|
|
253
298
|
}
|
|
@@ -255,10 +300,19 @@ export class RoomClient<A = unknown, V = unknown> {
|
|
|
255
300
|
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
301
|
return;
|
|
257
302
|
}
|
|
303
|
+
if (msg.t === 'chat') {
|
|
304
|
+
if (Number.isInteger(msg.seat) && typeof msg.text === 'string') this.opts.onChat?.(msg.seat, msg.text, msg.masked === true);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (msg.t === 'error' && !msg.moveId && CHAT_ONLY_ERRORS.has(msg.code)) {
|
|
308
|
+
this.opts.onChatError?.(msg.code);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
258
311
|
// 本地已经按节奏排队了,房间偶尔还嫌快(比如刚重连、节奏刚调过):静默丢掉,不打扰页面
|
|
259
312
|
if (msg.t === 'error' && msg.code === 'emote_too_fast' && !msg.moveId) return;
|
|
260
313
|
if (msg.t === 'welcome') {
|
|
261
314
|
if (msg.emote && msg.emote.ms >= 0 && msg.emote.burst >= 1) this.emoteRate = { ms: msg.emote.ms, burst: msg.emote.burst };
|
|
315
|
+
if (msg.chat && msg.chat.ms >= 0 && msg.chat.perMin >= 0) this.chatRate = { ms: msg.chat.ms, perMin: msg.chat.perMin };
|
|
262
316
|
// 快照:整桌重画,从它的 seq 往后接;真的进了房间,退避和失败次数清零
|
|
263
317
|
this.lastSeq = msg.seq;
|
|
264
318
|
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
|
-
|
|
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
|
+
}
|