@yyyu-star/arcade-kit 0.0.0-stage → 0.10.2

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 (69) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/LICENSE +21 -0
  3. package/README.md +441 -2
  4. package/dist/auth.d.ts +66 -0
  5. package/dist/auth.d.ts.map +1 -0
  6. package/dist/auth.js +86 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/client.d.ts +194 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +379 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/fun-art.d.ts +41 -0
  13. package/dist/fun-art.d.ts.map +1 -0
  14. package/dist/fun-art.js +141 -0
  15. package/dist/fun-art.js.map +1 -0
  16. package/dist/fun.d.ts +87 -0
  17. package/dist/fun.d.ts.map +1 -0
  18. package/dist/fun.js +512 -0
  19. package/dist/fun.js.map +1 -0
  20. package/dist/game.d.ts +160 -0
  21. package/dist/game.d.ts.map +1 -0
  22. package/dist/game.js +41 -0
  23. package/dist/game.js.map +1 -0
  24. package/dist/guest.d.ts +60 -0
  25. package/dist/guest.d.ts.map +1 -0
  26. package/dist/guest.js +87 -0
  27. package/dist/guest.js.map +1 -0
  28. package/dist/internal.d.ts +33 -0
  29. package/dist/internal.d.ts.map +1 -0
  30. package/dist/internal.js +53 -0
  31. package/dist/internal.js.map +1 -0
  32. package/dist/meter.d.ts +102 -0
  33. package/dist/meter.d.ts.map +1 -0
  34. package/dist/meter.js +207 -0
  35. package/dist/meter.js.map +1 -0
  36. package/dist/protocol.d.ts +227 -0
  37. package/dist/protocol.d.ts.map +1 -0
  38. package/dist/protocol.js +65 -0
  39. package/dist/protocol.js.map +1 -0
  40. package/dist/rng.d.ts +40 -0
  41. package/dist/rng.d.ts.map +1 -0
  42. package/dist/rng.js +119 -0
  43. package/dist/rng.js.map +1 -0
  44. package/dist/room.d.ts +352 -0
  45. package/dist/room.d.ts.map +1 -0
  46. package/dist/room.js +1563 -0
  47. package/dist/room.js.map +1 -0
  48. package/dist/testing.d.ts +125 -0
  49. package/dist/testing.d.ts.map +1 -0
  50. package/dist/testing.js +272 -0
  51. package/dist/testing.js.map +1 -0
  52. package/dist/worker.d.ts +92 -0
  53. package/dist/worker.d.ts.map +1 -0
  54. package/dist/worker.js +184 -0
  55. package/dist/worker.js.map +1 -0
  56. package/package.json +107 -4
  57. package/src/auth.ts +100 -0
  58. package/src/client.ts +438 -0
  59. package/src/fun-art.ts +184 -0
  60. package/src/fun.ts +626 -0
  61. package/src/game.ts +200 -0
  62. package/src/guest.ts +122 -0
  63. package/src/internal.ts +64 -0
  64. package/src/meter.ts +255 -0
  65. package/src/protocol.ts +243 -0
  66. package/src/rng.ts +131 -0
  67. package/src/room.ts +1677 -0
  68. package/src/testing.ts +300 -0
  69. package/src/worker.ts +234 -0
package/src/game.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * 游戏要提供的规则接口,和给游戏自己测"快照多大"的小工具。
3
+ * 这个文件不依赖 Workers 运行时,游戏的规则测试(普通 Node 里的 vitest)也能直接引用。
4
+ */
5
+ import { PROTOCOL_VERSION, type RoomInfo, type SeatKind, type WelcomeMsg } from './protocol.js';
6
+ import type { Entropy } from './rng.js';
7
+
8
+ export type { Entropy } from './rng.js';
9
+
10
+ /** 开局时房间告诉游戏的(0.3.0):房主选的选项、每个座位坐的是谁(电脑补满以后;seatCount 以外的座位是 empty)。 */
11
+ export interface RoomSetup<O> {
12
+ /** options() 校验过的选项;游戏没写 options 时是 undefined */
13
+ opts: O | undefined;
14
+ seats: SeatKind[];
15
+ }
16
+
17
+ /**
18
+ * 一笔要房间去外面办的事(0.3.0,比如从玩家的钱包扣买入、把筹码结回去),游戏在状态里记着,办完前一直在 effects() 里。
19
+ * id 在这一局里唯一、不重用:房间可能因为断线、被驱逐把同一笔办两次,办事的一方要按 id 去重(幂等)。
20
+ * seat 是为谁办:房间把这个座位绑定的 userId 交给 perform(没绑人的电脑座位是 null)。
21
+ */
22
+ export interface RoomEffect {
23
+ id: string;
24
+ seat: number;
25
+ }
26
+
27
+ /**
28
+ * 一个游戏的规则,纯函数(浏览器和房间共用,4.3)。S 状态、A 操作、V 某个座位看到的视图。
29
+ * 房间只通过这些函数碰游戏状态,自己不懂规则。
30
+ */
31
+ export interface RoomGame<S, A, V, O = unknown, E extends RoomEffect = RoomEffect> {
32
+ /** 座位数(掼蛋 4) */
33
+ seats: number;
34
+ /** 规则版本:开局时记进状态,进行中的牌局按开局时的版本打完(4.3) */
35
+ rulesVersion: number;
36
+ /**
37
+ * 开局(0.2.0 起推荐):entropy 是房间用 crypto.getRandomValues 现生成的 256 位熵(8 个 u32),只给这一次、房间不存。
38
+ * 用它当 arcade-kit/rng 的 ChaCha20 密钥发牌;后面每一副也从这把密钥分出的流里取(stream(key, 'deal', 第几副)),
39
+ * 所以密钥要放进自己的状态——状态只在服务端,view 绝不能带上它。谁拿到密钥谁就知道全桌的牌(9.1)。
40
+ * 写了 begin,房间就不再调 start
41
+ */
42
+ begin?(entropy: Entropy, rulesVersion: number, setup?: RoomSetup<O>): S;
43
+ /**
44
+ * @deprecated 0.2.0:32 位种子只有约 43 亿种,玩家拿自己的手牌逐个试,几分钟就能反推出全桌的牌。新游戏写 begin;
45
+ * 这里只为 0.1.x 的游戏升级 kit 时照常能开局(房间会记一条警告日志),游戏都换成 begin 以后去掉。
46
+ * 已经开打的房间不会再调开局,按开局时记下的规则版本打完
47
+ */
48
+ start(seed: number, rulesVersion: number): S;
49
+ /** 校验客户端发来的操作的形状,不合格返回 null(不进 apply) */
50
+ parse(raw: unknown): A | null;
51
+ /**
52
+ * 做一步;不合法返回 { error },状态不变。
53
+ * error 会原样发给出手的人,所以只能是固定的错误码(小写字母、数字、下划线,最长 32),不能带牌面之类的隐藏信息;
54
+ * 不合这个格式的一律换成 illegal_move 再发。
55
+ */
56
+ apply(state: S, seat: number, action: A): S | { error: string };
57
+ /** 现在该谁出手;null 表示不在等任何人(比如一局打完)。写了 actors 时房间只看 actors,不再用它 */
58
+ turn(state: S): number | null;
59
+ /**
60
+ * 这一刻可以出手的座位(0.1.1;不写就是 [turn(state)],和 0.1.0 一样)。多于一个时是"一组",比如结算页大家点继续:
61
+ * - 电脑、托管座位由房间立刻用 ai() 代出;真人谁先发都收,不用排队;
62
+ * - 组里有人出手不重新计时:时限从进入这组时起算,到点对还没出的真人逐个用 timeout() 代出;
63
+ * - 组里先出手的人改了局面,后出的人照样能出(不判 stale),只有在这组开始之前做的决定才是 stale。
64
+ * 怎么算"还是同一组":
65
+ * - 推荐返回 { seats, group },group 是这一组的标识(比如 'settle-3',第几副的结算):出手前后 group 一样、而且出手的人离开了名单、
66
+ * 没有新座位加入,才是同一组,否则是新的一手(所以一个人一手的阶段给同一个 group 也没关系,每一手照样重新计时);
67
+ * - 只返回座位数组时由房间推断:出手的那个座位离开名单、其他座位原样留着(不多不少)才算同一组。所以组里每个人出手后都要从名单里拿掉。
68
+ * 这有歧义:[2, 3] 里 2 出完剩 [3],和"新的一手只有 3 能出"分不清(会被当成同一组),有这种情况的游戏要给 group。
69
+ * 名单里不合法的座位号、重复的会被忽略。牌局没打完(over 为 false)时名单不能是空的,空了房间按游戏出错处理:记日志、当作打完。
70
+ */
71
+ actors?(state: S): number[] | { seats: number[]; group: string };
72
+ /**
73
+ * 这一手(或这一组)的时限毫秒(0.1.1;不写、或给的不是正数,就用 RoomLimits.turnMs)。
74
+ * 按当前局面现算,同一组里要一直给同一个数
75
+ */
76
+ turnMs?(state: S): number;
77
+ /** 这个座位能看到的东西。seat 为 null 是没入座的人,只能给公开信息 */
78
+ view(state: S, seat: number | null): V;
79
+ /** 电脑出手(电脑座位、掉线托管),必须合法 */
80
+ ai(state: S, seat: number): A;
81
+ /** 超时代出(真人 30 秒没出、掉线时轮到他),只看该座位能看到的;不给就用 ai。被 apply 拒了房间会再用 ai 试一次 */
82
+ timeout?(state: S, seat: number): A;
83
+ /** 整场打完了 */
84
+ over(state: S): boolean;
85
+ /**
86
+ * 这一刻是不是一段的结尾,适合收场(0.2.0,可选):比如掼蛋一副打完、停在结算页。
87
+ * 当天全站写入到了 stopWrites(meter.ts)时,在打的房间走到这里就停下电脑、把这一步发给大家,然后以 4000 加原因 quota 关房。
88
+ * 不写的游戏没有"这一段"可等,房间知道要收场时马上关
89
+ */
90
+ breakPoint?(state: S): boolean;
91
+
92
+ // ---------- 0.3.0:全部可选,不写就和 0.2.0 一样 ----------
93
+
94
+ /**
95
+ * 开局选项:房主发 start 时带的 opts(任意 JSON),在这里校验形状,不合格返回 null(房间回 bad_options、不开局)。
96
+ * 0.4.0 起也可以开房时就定(acceptCreate 的 options):开房时校验、存进房间、经 RoomInfo.opts 发给所有人,之后 start 带的不看。
97
+ * 校验过的选项经 begin 的 setup.opts 交给游戏;游戏要用就放进自己的状态
98
+ */
99
+ options?(raw: unknown): O | null;
100
+ /**
101
+ * 这一局用几个座位(1 到 seats):比如德州 2–6 人一桌、座位数按选项定。只用前面这么多个座位,
102
+ * 开局时只给它们补电脑,后面的座位一直空着、谁也坐不进去;有真人坐在后面的座位上时房间回 seat_closed、不开局。不写就是 seats
103
+ */
104
+ seatCount?(opts: O | undefined): number;
105
+ /**
106
+ * 开打以后座位换了人(纯函数):有人坐下或接管电脑的座位(human)、离座交给电脑(ai)。
107
+ * 和那一步(入座、离座)一起写 1 行。游戏可以借它记"这个座位现在是真人买入的"之类;不需要就不写。
108
+ * 托管(掉线超过宽限由电脑代打)不算换人,不调它:座位还是那个人的。
109
+ * 开打后座位绑人,离座的主人回到自己的座位也会以 human 调一次:房间不把 userId 交给规则,游戏按自己的状态判断
110
+ * (比如这个座位原来就是真人的,就什么都不做)
111
+ */
112
+ seated?(state: S, seat: number, kind: 'human' | 'ai'): S;
113
+ /**
114
+ * 现在还没办完的对外的事(见 RoomEffect)。房间每一步之后看一眼,把没在办的交给房间子类的 perform 去办,
115
+ * 办完用 settled 把结果写回状态(1 行);办失败了按退避重试(用那唯一的闹钟)。结果写回以后这笔就不该再出现在这里
116
+ */
117
+ effects?(state: S): E[];
118
+ /** 一笔对外的事办完了(纯函数):result 是 perform 的返回值,游戏自己校验。id 已经不在 effects() 里的,房间不会调 */
119
+ settled?(state: S, id: string, result: unknown): S;
120
+ /**
121
+ * 房间要关了(寿命到、没人、额度满、入站用完):给游戏最后一次机会把要办的事记下来(比如把所有人桌上的筹码结回钱包)。
122
+ * 写了 effects 才会调。返回的状态里还有 effects 的话,房间先断开所有人、留着存储把它们办完再删;办不完(一直失败)最多等 closingMs
123
+ */
124
+ closing?(state: S): S;
125
+ /**
126
+ * 不轮流的操作(比如离桌、暂离回来;只是冒个气泡的用 0.6.0 的 emotes,不写存储):入座的人什么时候都能发,不看轮没轮到他、不判 stale,
127
+ * 不改这一手的期限和 turnSeq(除非它让该谁出手变了)。照样过 parse、apply,照样占令牌桶和入站额度
128
+ */
129
+ free?(action: A): boolean;
130
+
131
+ // ---------- 0.3.2 ----------
132
+
133
+ /**
134
+ * 牌局没打完、这一刻谁也不用出手,在等外面的事(0.3.2,可选):比如德州的牌桌上能打的人不够两个,等有人坐下、补码、从暂离回来,
135
+ * 或者买入到账。返回 true 时 actors(turn)可以是空的:房间不当作出错,不计时、不驱动电脑、不设这一手的闹钟,一行也不写;
136
+ * 等到有人发不轮流的操作、座位换人、对外的事办完,牌局变了再看该谁出手。只在没人能出手时才问它;不写就和以前一样,空了按出错处理
137
+ */
138
+ waiting?(state: S): boolean;
139
+
140
+ // ---------- 0.5.0 ----------
141
+
142
+ /**
143
+ * 这一刻是不是"停一会儿再接着来"(比如一手打完停几秒让大家看清结果,再自动发下一手):返回要停的毫秒数(正的有限数),不是就返回 null。
144
+ * 不能等所有人点(有人走开就卡住),也不能指给某个座位计时(电脑、托管的座位会被房间立刻代出,停不住),所以由房间来停。
145
+ * 只在没人能出手(actors / turn 为空)时才问它,优先于 waiting。停顿从进入这个状态那一刻算(电脑连着出完进来的,从播放完算),
146
+ * 停顿期间不轮流的操作、座位换人、对外的事办完都不重新计时;按当前局面现算,同一个停顿里要一直给同一个数。
147
+ * 页面看 RoomInfo.pause.until 画倒计时(这时 RoomInfo.turn 是 null)
148
+ */
149
+ pause?(state: S): number | null;
150
+ /**
151
+ * 停够了(纯函数):房间到点用那唯一的闹钟调它做一步(和这一步一起最多写 2 行),比如发下一手;之后电脑照常接着出。
152
+ * 返回的状态不能还在停(要连着停两段,就在一次 pause 里给总时长):还在停、或者抛错,房间记日志、不用它的结果,过 60 秒再试。
153
+ * 写了 pause 就必须写 resume,只写 pause 的当作没有停顿(记一次日志)
154
+ */
155
+ resume?(state: S): S;
156
+
157
+ // ---------- 0.6.0 ----------
158
+
159
+ /**
160
+ * 快捷短语表有几句(0.6.0,可选):入座的人可以发 0 到 emotes - 1 的编号,房间只转发给所有人(EmotedMsg),不进状态、不写存储。
161
+ * 短语的文字、图标由游戏自己在页面里定,房间不知道内容。不写(或不是正整数)就是没有短语,发了回 no_emotes
162
+ */
163
+ emotes?: number;
164
+ }
165
+
166
+ /**
167
+ * 进房、重连时发的全量快照有多大(字节,取所有座位和"没入座"里最大的那个)。
168
+ * 重连一律发全量快照(4.5),快照太大会拖慢手机重连、多占出站流量;游戏在自己的测试里挑几个最大的局面断言一个上限。
169
+ * 座位信息按最坏情况算:全是真人、昵称都是 16 个汉字。
170
+ */
171
+ export function snapshotBytes<S, A, V, O, E extends RoomEffect>(game: RoomGame<S, A, V, O, E>, state: S): number {
172
+ const room: RoomInfo = {
173
+ code: '000000',
174
+ status: 'playing',
175
+ seats: Array.from({ length: game.seats }, () => ({ kind: 'human', name: '牌'.repeat(16), away: true, auto: true, reserved: true })),
176
+ turn: {
177
+ seat: 0,
178
+ deadline: 9_999_999_999_999,
179
+ ...(game.actors ? { seats: Array.from({ length: game.seats }, (_, i) => i) } : {}),
180
+ },
181
+ // 停顿和 turn 不会同时有,这里两样都算上,偏大一点(0.5.0)
182
+ ...(game.pause ? { pause: { until: 9_999_999_999_999 } } : {}),
183
+ };
184
+ let max = 0;
185
+ for (const seat of [null, ...Array.from({ length: game.seats }, (_, i) => i)]) {
186
+ const msg: WelcomeMsg<V> = {
187
+ t: 'welcome',
188
+ v: PROTOCOL_VERSION,
189
+ minClientVersion: 0,
190
+ seq: 999_999,
191
+ you: { seat, bound: seat ?? 0 },
192
+ room,
193
+ view: game.view(state, seat),
194
+ // 有短语的游戏多带一个节奏(0.7.0)
195
+ ...(game.emotes ? { emote: { ms: 999_999, burst: 99 } } : {}),
196
+ };
197
+ max = Math.max(max, new TextEncoder().encode(JSON.stringify(msg)).byteLength);
198
+ }
199
+ return max;
200
+ }
package/src/guest.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * 独立模式(0.10.0):游戏不带平台单独部署(Deploy to Cloudflare、fork)时,没有平台的 /api/me 可问,改认"游客"。
3
+ *
4
+ * 什么时候算独立模式(isStandalone):env 里**没有** ARCADE 绑定,而且 GATE 不是 "login"。
5
+ * - 有 ARCADE 绑定 = 本站正式环境,行为和 0.9 完全一样,游客 cookie 一律不认(防止有人带一个游客 cookie 绕过登录);
6
+ * - 没有 ARCADE 却写了 GATE=login:说明这次部署本该接平台、只是绑定丢了(部署工具出错),按"平台不可用"拒绝(fail-closed),
7
+ * 不悄悄退成人人可进的游客模式。
8
+ *
9
+ * 游客身份:游戏 Worker 自己发一个长期 cookie(HttpOnly、SameSite=Lax、Path=/;https 下是 `__Host-` 前缀加 Secure),
10
+ * 值是 32 字节 crypto 随机数(base64url)。userId 是值的 SHA-256 取前 128 位(`g:` 开头),不用原值:
11
+ * userId 会进房间存储、日志,泄露了也拿不到 cookie 本身。昵称默认"游客"加 4 位数字,浏览器可以带一个昵称(cleanNickname 严格清洗)。
12
+ *
13
+ * 这个文件不依赖 cloudflare:workers,浏览器也能用(页面可以先用 cleanNickname 检查一遍再提交)。
14
+ */
15
+ import type { Identity } from './internal.js';
16
+
17
+ /** 游客 cookie 名字的默认主体;https 下实际名字是 `__Host-kit-guest`。同域的几个游戏共用一个游客身份,想分开就各传 cookie */
18
+ export const GUEST_COOKIE = 'kit-guest';
19
+
20
+ /** 一年多一点(和平台会话一样长),过期前每次进首页都会被认出来,不续期也够用 */
21
+ const GUEST_MAX_AGE = 34_560_000;
22
+
23
+ /** 昵称最长几个字(按码点算) */
24
+ export const NICKNAME_MAX = 12;
25
+
26
+ /**
27
+ * 默认不许用的昵称词:冒充电脑、系统、管理员,以及"游客"本身(免得冒充别人的默认名"游客1234")。
28
+ * 游戏另传自己电脑人物的名字(GuestOptions.reserved),两份合起来查
29
+ */
30
+ export const RESERVED_NICKNAMES: readonly string[] = ['电脑', '机器人', '人机', '托管', '系统', '管理员', '官方', '客服', '游客', 'ai', 'bot', 'robot', 'cpu', 'admin', 'system'];
31
+
32
+ export interface GuestOptions {
33
+ /** cookie 名字的主体(字母、数字、`_`、`-`,最长 32),默认 GUEST_COOKIE;https 下会加 `__Host-` 前缀 */
34
+ cookie?: string;
35
+ /** 游戏自己不许用的昵称(电脑人物的名字等),和 RESERVED_NICKNAMES 合起来查 */
36
+ reserved?: readonly string[];
37
+ }
38
+
39
+ /** 判断用的 env 字段:ARCADE 绑定在不在、GATE 写的是什么 */
40
+ export interface ModeEnv {
41
+ ARCADE?: unknown;
42
+ GATE?: string;
43
+ }
44
+
45
+ /** 独立模式:没有 ARCADE 绑定、GATE 也不是 "login" */
46
+ export function isStandalone(env: ModeEnv): boolean {
47
+ return !env.ARCADE && env.GATE !== 'login';
48
+ }
49
+
50
+ const COOKIE_BASE_RE = /^[A-Za-z0-9_-]{1,32}$/;
51
+ /** 值:我们发的是 32 字节 base64url(43 位);读的时候只认 22–64 位 base64url,别的一律当没有 */
52
+ const COOKIE_VALUE_RE = /^[A-Za-z0-9_-]{22,64}$/;
53
+
54
+ /**
55
+ * 这个请求用的游客 cookie 名字。https 用 `__Host-` 前缀(浏览器保证只能由本站、Path=/、Secure 设置,子域设不进来);
56
+ * http 只会是本地开发(wrangler dev 经局域网 IP 打开时浏览器不收 Secure cookie),用不带前缀的名字,两边互不相认
57
+ */
58
+ export function guestCookieName(request: Request, opts: GuestOptions = {}): string {
59
+ const base = opts.cookie ?? GUEST_COOKIE;
60
+ if (!COOKIE_BASE_RE.test(base)) throw new Error(`arcade-kit: bad guest cookie name ${JSON.stringify(base)}`);
61
+ return isHttps(request) ? `__Host-${base}` : base;
62
+ }
63
+
64
+ function isHttps(request: Request): boolean {
65
+ return new URL(request.url).protocol === 'https:';
66
+ }
67
+
68
+ /** 请求里游客 cookie 的值;没有、格式不对都是 null */
69
+ export function readGuestCookie(request: Request, opts: GuestOptions = {}): string | null {
70
+ const name = guestCookieName(request, opts);
71
+ for (const part of (request.headers.get('cookie') ?? '').split(';')) {
72
+ const i = part.indexOf('=');
73
+ if (i > 0 && part.slice(0, i).trim() === name) {
74
+ const v = part.slice(i + 1).trim();
75
+ return COOKIE_VALUE_RE.test(v) ? v : null;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** 新发一个游客 cookie:返回值和整条 Set-Cookie */
82
+ export function mintGuestCookie(request: Request, opts: GuestOptions = {}): { value: string; setCookie: string } {
83
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
84
+ const value = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
85
+ const secure = isHttps(request) ? '; Secure' : '';
86
+ return { value, setCookie: `${guestCookieName(request, opts)}=${value}; Max-Age=${GUEST_MAX_AGE}; Path=/; HttpOnly; SameSite=Lax${secure}` };
87
+ }
88
+
89
+ /** cookie 值 → 游客身份:userId = 'g:' + SHA-256 前 128 位(hex),默认昵称"游客"+ 4 位数字;nickname 是清洗过的昵称 */
90
+ export async function guestIdentity(value: string, nickname?: string | null): Promise<Identity> {
91
+ const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
92
+ const hex = Array.from(hash.subarray(0, 16), (b) => b.toString(16).padStart(2, '0')).join('');
93
+ const short = String(((hash[16]! << 8) | hash[17]!) % 10_000).padStart(4, '0');
94
+ return { userId: `g:${hex}`, name: nickname || `游客${short}` };
95
+ }
96
+
97
+ /** 允许的字:字母(含汉字)、数字,和空格、`_`、`-`、`.`、`·` */
98
+ const NICKNAME_RE = /^[\p{L}\p{N} _\-.·]+$/u;
99
+
100
+ /**
101
+ * 清洗浏览器带来的昵称:不合格回 null(调用方用默认的"游客1234"),不报错。
102
+ * - NFKC 归一(全角、兼容字先折成普通字,免得用全角字母绕过保留词),连续空白并成一个、去掉首尾空白;
103
+ * - 只许字母(含汉字)、数字和少数几个符号,控制符、零宽字、方向控制符、emoji 一律不行;1 到 NICKNAME_MAX 个字;
104
+ * - 不能含保留词(RESERVED_NICKNAMES 加游戏传的):中文等非 ASCII 的词按"包含"查;ASCII 的词按"整个词"查(去掉数字后),
105
+ * 免得 "Kai" 因为含 "ai" 被拒,但 "AI"、"bot 007" 都会被拒。
106
+ */
107
+ export function cleanNickname(raw: unknown, reserved: readonly string[] = []): string | null {
108
+ if (typeof raw !== 'string' || raw.length > 64) return null;
109
+ const s = raw.normalize('NFKC').replace(/\s+/gu, ' ').trim();
110
+ const n = [...s].length;
111
+ if (n < 1 || n > NICKNAME_MAX || !NICKNAME_RE.test(s)) return null;
112
+ const lower = s.toLowerCase();
113
+ const compact = lower.replace(/ /g, '');
114
+ const tokens = lower.split(/[ _\-.·]+/).map((t) => t.replace(/\d+/g, ''));
115
+ for (const w0 of [...RESERVED_NICKNAMES, ...reserved]) {
116
+ const w = w0.normalize('NFKC').toLowerCase().replace(/\s+/g, '');
117
+ if (!w) continue;
118
+ const ascii = /^[\x00-\x7f]+$/.test(w);
119
+ if (ascii ? tokens.includes(w) || compact.replace(/\d+/g, '') === w : compact.includes(w)) return null;
120
+ }
121
+ return s;
122
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * kit 内部用的东西:房间的内部地址、身份头、按房间码取 stub。不在 package.json 的 exports 里,游戏引用不到,
3
+ * 只能经 worker.ts 的 acceptCreate / acceptJoin 开房进房,没法自己拼内部请求。
4
+ */
5
+
6
+ /**
7
+ * 平台会话 cookie 的名字(docs/platform-backend.md 15.8:"平台只读 __Host-sid")。kit 里只在这里写一次:
8
+ * 认人时请求里没有它就直接算没登录,不去问平台。
9
+ */
10
+ export const SESSION_COOKIE = '__Host-sid';
11
+
12
+ /** 请求的 Cookie 头里有没有非空的平台会话 cookie。 */
13
+ export function hasSessionCookie(cookie: string | null): boolean {
14
+ if (!cookie) return false;
15
+ return cookie.split(';').some((part) => {
16
+ const i = part.indexOf('=');
17
+ return i > 0 && part.slice(0, i).trim() === SESSION_COOKIE && part.slice(i + 1).trim() !== '';
18
+ });
19
+ }
20
+
21
+ /** 游戏 Worker 认出来的人(auth.ts 经平台 /api/me 拿到)。 */
22
+ export interface Identity {
23
+ userId: string;
24
+ name: string;
25
+ }
26
+
27
+ /**
28
+ * 房间 fetch 只认这个主机名:kit 自己拼的内部地址(acceptJoin)。游戏 Worker 要是把浏览器的原始请求直接转进来,
29
+ * 地址是站点自己的域名,一律拒绝,浏览器自己带的 x-kit-user 头就不可能被当成身份。
30
+ */
31
+ export const KIT_HOST = 'arcade-kit.internal';
32
+
33
+ /** 身份头:JSON 再 URL 编码(昵称是中文,请求头里只放 ASCII)。只有 acceptJoin 拼的内部请求会带它。 */
34
+ export function identityHeader(me: Identity): string {
35
+ return encodeURIComponent(JSON.stringify({ userId: me.userId, name: me.name }));
36
+ }
37
+
38
+ export function parseIdentity(raw: string | null): Identity | null {
39
+ if (!raw) return null;
40
+ try {
41
+ const v = JSON.parse(decodeURIComponent(raw)) as Partial<Identity>;
42
+ return typeof v.userId === 'string' && v.userId && typeof v.name === 'string' ? { userId: v.userId, name: v.name } : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** 6 位房间码(crypto 随机)。 */
49
+ export function newRoomCode(): string {
50
+ return String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1_000_000).padStart(6, '0');
51
+ }
52
+
53
+ /** 房间的 stub:按房间码取,尽量放在亚太(4.1)。 */
54
+ export function roomStub(ns: DurableObjectNamespace, code: string): DurableObjectStub {
55
+ return ns.get(ns.idFromName(code), { locationHint: 'apac' });
56
+ }
57
+
58
+ /** 计量的名字:每个游戏 Worker 只有这一个实例(meter.ts)。 */
59
+ export const METER_NAME = 'kit-meter';
60
+
61
+ /** 计量的 stub:和房间一样放在亚太,开房、报数少绕路。 */
62
+ export function meterStub(ns: DurableObjectNamespace): DurableObjectStub {
63
+ return ns.get(ns.idFromName(METER_NAME), { locationHint: 'apac' });
64
+ }
package/src/meter.ts ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * 每个游戏 Worker 一个的"计量"(Durable Object,全局只有一个实例,名字固定):管住免费版每天 10 万行写入(docs/platform-backend.md 4.7)。
3
+ * 同一个对象里放三样东西,一次 RPC 办完,比"每人一个小 DO + 一个计量"少一次请求、少写建表那几行:
4
+ * - 当天(UTC)全站的写入行数:房间自己数写了几行,攒够一批(RoomLimits.meterBatch,默认 200)或者结束时报一次;
5
+ * - 当天全站开了几个房间(最后一道闸);
6
+ * - 每个人现在开着的房间、当天开了几个(按人的开房额度)。
7
+ *
8
+ * 门槛(QuotaLimits,游戏在 wrangler.jsonc 的 vars.KIT_QUOTA 里改,不用改代码):
9
+ * - 写入到 busyWrites(默认 6 万):不开新房、不进新人,在打的照常;
10
+ * - 写入到 stopWrites(默认 8.5 万):在打的房间在这一段结束(RoomGame.breakPoint)时体面收场;
11
+ * - 开房先过按人额度,再过全站额度。
12
+ *
13
+ * 写入:开房(占额度)写 2 行(这个人那一行、当天那一行),报一次写 1 行,结束(还额度加最后一报)写 2 行;计量自己写的也记进当天的数。
14
+ * 自愈:每个开着的房间记一个"最晚到什么时候"(开房时间 + 房间寿命 + 1 分钟)。房间关的时候来还额度,
15
+ * 还不上(计量连不上、房间被驱逐时出错)就等到点自动作废,不会把人永远卡在"你已经有一个房间了"。
16
+ * 当天的数在 UTC 0 点后的第一次调用时清零(和免费版额度重置的时间一样);房间跨天报上来的写入记在新的一天,偏保守。
17
+ *
18
+ * 这里的方法只给 kit 自己调(房间经 RPC),游戏不直接用;kitUsage 给运维和测试看。
19
+ */
20
+ import { DurableObject } from 'cloudflare:workers';
21
+ import { meterStub } from './internal.js';
22
+
23
+ export interface QuotaLimits {
24
+ /** 每人同时开着的房间数(自己开的,打完、关掉、到寿命就还) */
25
+ roomsPerUser: number;
26
+ /** 每人每天(UTC)最多开几个房间 */
27
+ dailyRoomsPerUser: number;
28
+ /** 全站每天(UTC)最多开几个房间 */
29
+ dailyRooms: number;
30
+ /** 当天写入到这么多行:不开新房、不进新人 */
31
+ busyWrites: number;
32
+ /** 当天写入到这么多行:在打的房间这一段打完收场 */
33
+ stopWrites: number;
34
+ }
35
+
36
+ export const DEFAULT_QUOTA: QuotaLimits = {
37
+ roomsPerUser: 1,
38
+ dailyRoomsPerUser: 6,
39
+ dailyRooms: 300,
40
+ busyWrites: 60_000,
41
+ stopWrites: 85_000,
42
+ };
43
+
44
+ /** KIT_QUOTA 里由房间读的字段(0.7.0):emoteMs 每多少毫秒补一个短语令牌(0 不限速),emoteBurst 最多攒几个 */
45
+ export const ROOM_QUOTA_FIELDS: readonly string[] = ['emoteMs', 'emoteBurst'];
46
+
47
+ /** 计量的绑定名和环境变量名固定(和 ROOM、ARCADE 一样)。 */
48
+ export interface MeterEnv {
49
+ METER?: DurableObjectNamespace;
50
+ /** 部分覆盖 DEFAULT_QUOTA,比如 { "dailyRooms": 500 };wrangler.jsonc 的 vars 里写对象,或者写成 JSON 字符串 */
51
+ KIT_QUOTA?: (Partial<QuotaLimits> & { emoteMs?: number; emoteBurst?: number }) | string;
52
+ }
53
+
54
+ /** ok 照常;busy 不开新房、不进新人;stop 在打的这一段打完收场。 */
55
+ export type MeterLevel = 'ok' | 'busy' | 'stop';
56
+
57
+ export type Reserve = { ok: true } | { ok: false; error: 'has_room'; code: string } | { ok: false; error: 'daily_limit' | 'busy' };
58
+
59
+ export interface Usage {
60
+ day: string;
61
+ writes: number;
62
+ rooms: number;
63
+ level: MeterLevel;
64
+ limits: QuotaLimits;
65
+ }
66
+
67
+ interface UserRow {
68
+ day: string;
69
+ /** 当天开了几个 */
70
+ count: number;
71
+ /** 开着的房间,until 之后自动作废 */
72
+ live: { code: string; until: number }[];
73
+ }
74
+
75
+ /** 房间寿命之外多留一分钟:房间到寿命那一刻的闹钟、关房、还额度都在这之前做完。 */
76
+ const EXPIRY_SLACK_MS = 60_000;
77
+
78
+ export class RoomMeter extends DurableObject<MeterEnv> {
79
+ private ready = false;
80
+ private limitsCache: QuotaLimits | null = null;
81
+
82
+ /** 开房前占一个名额(房间调)。房间没建成要调 kitRelease(…, cancel) 退回。 */
83
+ async kitReserve(userId: string, code: string, until: number): Promise<Reserve> {
84
+ const now = Date.now();
85
+ const lim = this.limits();
86
+ const day = this.day(now);
87
+ const user = this.user(userId, day.day, now);
88
+ // 先说"回你的房间":在打的房间不受全站门槛影响,回去照样能玩
89
+ if (user.live.length >= lim.roomsPerUser) return { ok: false, error: 'has_room', code: user.live[user.live.length - 1]!.code };
90
+ if (user.count >= lim.dailyRoomsPerUser) return { ok: false, error: 'daily_limit' };
91
+ if (levelOf(day.writes, lim) !== 'ok' || day.rooms >= lim.dailyRooms) return { ok: false, error: 'busy' };
92
+ user.count++;
93
+ user.live.push({ code, until: until + EXPIRY_SLACK_MS });
94
+ const w = this.saveUser(userId, user);
95
+ this.saveDay(day.day, day.writes + w + 1, day.rooms + 1);
96
+ return { ok: true };
97
+ }
98
+
99
+ /**
100
+ * 房间结束(打完、关掉)时还名额,顺便报最后一批写入(writes 可以是 0)。cancel:房间没建成,开房次数也退回。
101
+ * 名额已经不在(还过一次、或者已经到点作废)就只记写入,可以重复调。
102
+ */
103
+ async kitRelease(userId: string, code: string, writes: number, cancel = false): Promise<MeterLevel> {
104
+ const now = Date.now();
105
+ const day = this.day(now);
106
+ const user = this.user(userId, day.day, now);
107
+ const i = user.live.findIndex((r) => r.code === code);
108
+ let w = 0;
109
+ if (i >= 0) {
110
+ user.live.splice(i, 1);
111
+ if (cancel && user.count > 0) user.count--;
112
+ w = this.saveUser(userId, user);
113
+ }
114
+ const rooms = cancel && i >= 0 ? Math.max(0, day.rooms - 1) : day.rooms;
115
+ const total = day.writes + clean(writes) + w + 1;
116
+ this.saveDay(day.day, total, rooms);
117
+ return levelOf(total, this.limits());
118
+ }
119
+
120
+ /** 报一批写入(房间攒够一批时调),回现在的门槛。 */
121
+ async kitReport(writes: number): Promise<MeterLevel> {
122
+ const day = this.day(Date.now());
123
+ const total = day.writes + clean(writes) + 1;
124
+ this.saveDay(day.day, total, day.rooms);
125
+ return levelOf(total, this.limits());
126
+ }
127
+
128
+ /** 只读:现在的门槛(房间放新人进来之前问,不写库)。 */
129
+ async kitLevel(): Promise<MeterLevel> {
130
+ return levelOf(this.day(Date.now()).writes, this.limits());
131
+ }
132
+
133
+ /** 只读:当天的数,给运维和测试看。 */
134
+ async kitUsage(): Promise<Usage> {
135
+ const day = this.day(Date.now());
136
+ const limits = this.limits();
137
+ return { day: day.day, writes: day.writes, rooms: day.rooms, level: levelOf(day.writes, limits), limits };
138
+ }
139
+
140
+ // ---------- 存储 ----------
141
+
142
+ private ensure(): void {
143
+ if (this.ready) return;
144
+ const sql = this.ctx.storage.sql;
145
+ // 建表只有第一次真的写(之后 IF NOT EXISTS 什么都不做);建表那几行记不进当天的数,一个游戏一生只有一次
146
+ sql.exec('CREATE TABLE IF NOT EXISTS kit_day (k INTEGER PRIMARY KEY, day TEXT NOT NULL, writes INTEGER NOT NULL, rooms INTEGER NOT NULL)');
147
+ // WITHOUT ROWID:按 userId 存一棵树,改一个人只写 1 行(普通表还要多写一份索引)
148
+ sql.exec('CREATE TABLE IF NOT EXISTS kit_user (id TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID');
149
+ this.ready = true;
150
+ }
151
+
152
+ /** 当天那一行;过了 UTC 0 点就是新的一天,从 0 算(不先写,下次保存时一起写)。 */
153
+ private day(now: number): { day: string; writes: number; rooms: number } {
154
+ this.ensure();
155
+ const today = utcDay(now);
156
+ const row = this.ctx.storage.sql.exec<{ day: string; writes: number; rooms: number }>('SELECT day, writes, rooms FROM kit_day WHERE k = 1').toArray()[0];
157
+ return row && row.day === today ? row : { day: today, writes: 0, rooms: 0 };
158
+ }
159
+
160
+ private saveDay(day: string, writes: number, rooms: number): void {
161
+ this.ctx.storage.sql.exec(
162
+ 'INSERT INTO kit_day (k, day, writes, rooms) VALUES (1, ?, ?, ?) ON CONFLICT (k) DO UPDATE SET day = excluded.day, writes = excluded.writes, rooms = excluded.rooms',
163
+ day,
164
+ writes,
165
+ rooms,
166
+ );
167
+ }
168
+
169
+ /** 这个人那一行:换了一天开房次数清零,到点的房间作废(只在内存里,下次保存时才写)。 */
170
+ private user(userId: string, today: string, now: number): UserRow {
171
+ const raw = this.ctx.storage.sql.exec<{ v: string }>('SELECT v FROM kit_user WHERE id = ?', userId).toArray()[0];
172
+ let row: UserRow = { day: today, count: 0, live: [] };
173
+ if (raw) {
174
+ try {
175
+ row = JSON.parse(raw.v) as UserRow;
176
+ } catch {
177
+ console.error('meter user row unreadable', userId);
178
+ }
179
+ }
180
+ return { day: today, count: row.day === today ? row.count : 0, live: row.live.filter((r) => r.until > now) };
181
+ }
182
+
183
+ /** 存这个人那一行,返回写了几行。没有开着的房间、当天也没开过的,删掉这一行,不留着占地方。 */
184
+ private saveUser(userId: string, row: UserRow): number {
185
+ const sql = this.ctx.storage.sql;
186
+ if (row.live.length === 0 && row.count === 0) return sql.exec('DELETE FROM kit_user WHERE id = ?', userId).rowsWritten;
187
+ return sql.exec('INSERT INTO kit_user (id, v) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET v = excluded.v', userId, JSON.stringify(row)).rowsWritten;
188
+ }
189
+
190
+ /** 门槛按调用时的环境变量算;配错的字段记日志、用默认值(不让一个手误把额度放开或关死)。 */
191
+ private limits(): QuotaLimits {
192
+ if (this.limitsCache) return this.limitsCache;
193
+ this.limitsCache = parseQuota(this.env.KIT_QUOTA);
194
+ return this.limitsCache;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 房间以外的 DO(比如游戏自己的钱包)把写了几行记进当天的数,回现在的门槛(0.3.0)。
200
+ * 计量每收一次报数自己写 1 行,所以只适合写得少的地方(钱包一次操作报一次,就是每次多 1 行);房间自己会攒一批再报,不用它。
201
+ * 没绑 METER 时什么都不做、回 ok(和房间一样,只为平滑升级)。连不上时抛错,调用方决定怎么办
202
+ */
203
+ export async function meterReport(env: MeterEnv, writes: number): Promise<MeterLevel> {
204
+ if (!env.METER) return 'ok';
205
+ return (meterStub(env.METER) as DurableObjectStub<RoomMeter>).kitReport(writes);
206
+ }
207
+
208
+ /** 只读:现在的门槛(0.3.0;写之前问一下,stop 时就别写了)。没绑 METER 回 ok,连不上抛错。 */
209
+ export async function meterLevel(env: MeterEnv): Promise<MeterLevel> {
210
+ if (!env.METER) return 'ok';
211
+ return (meterStub(env.METER) as DurableObjectStub<RoomMeter>).kitLevel();
212
+ }
213
+
214
+ /** 解析 KIT_QUOTA:对象或 JSON 字符串,只认 DEFAULT_QUOTA 里有的字段、非负整数;别的记日志后忽略。 */
215
+ export function parseQuota(raw: unknown): QuotaLimits {
216
+ let v: unknown = raw;
217
+ if (typeof raw === 'string') {
218
+ try {
219
+ v = JSON.parse(raw);
220
+ } catch {
221
+ console.error('KIT_QUOTA is not JSON, using defaults');
222
+ v = null;
223
+ }
224
+ }
225
+ const out: QuotaLimits = { ...DEFAULT_QUOTA };
226
+ if (v === undefined || v === null) return out;
227
+ if (typeof v !== 'object' || Array.isArray(v)) {
228
+ console.error('KIT_QUOTA is not an object, using defaults');
229
+ return out;
230
+ }
231
+ for (const [k, n] of Object.entries(v)) {
232
+ // 房间自己读的字段(0.7.0:快捷短语的节奏),计量不管
233
+ if (ROOM_QUOTA_FIELDS.includes(k)) continue;
234
+ if (!(k in DEFAULT_QUOTA)) console.error('KIT_QUOTA unknown field', k);
235
+ else if (typeof n !== 'number' || !Number.isInteger(n) || n < 0) console.error('KIT_QUOTA bad value for', k);
236
+ else out[k as keyof QuotaLimits] = n;
237
+ }
238
+ if (out.stopWrites < out.busyWrites) console.error('KIT_QUOTA stopWrites is below busyWrites');
239
+ return out;
240
+ }
241
+
242
+ export function levelOf(writes: number, lim: QuotaLimits): MeterLevel {
243
+ if (writes >= lim.stopWrites) return 'stop';
244
+ if (writes >= lim.busyWrites) return 'busy';
245
+ return 'ok';
246
+ }
247
+
248
+ function utcDay(now: number): string {
249
+ return new Date(now).toISOString().slice(0, 10);
250
+ }
251
+
252
+ /** 房间报上来的行数:只认非负整数(RPC 那头是自己人,这里防的是自己算错) */
253
+ function clean(n: number): number {
254
+ return Number.isInteger(n) && n > 0 ? n : 0;
255
+ }