@yyyu-star/arcade-kit 0.10.2 → 0.11.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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # 更新日志
2
2
 
3
+ ## 0.11.1
4
+
5
+ 联机稳定性(平台代理只读审阅 kit 0.10.2 后列的清单,主线 2026-10-16 派)。协议不变:没有新消息、新字段,旧页面和新房间、新页面和旧房间都照常。
6
+ - **静默死掉的连接**(`client.ts`):心跳和切回前台时发 `ping`,`PONG_WAIT_MS`(8 秒)内什么都没收到就判死、不等 close 直接重连;建连卡在 CONNECTING 超过 `CONNECT_MS`(10 秒)也重连。
7
+ iOS 锁屏、切 App 以后 socket 常常还是 OPEN、不触发 close,以前会一直停在"已连接"、画面冻住。只看"这段时间收到过没有",不比时钟,后台被节流的计时器晚跑不会误判。
8
+ - **切回前台、网络恢复**:`pageshow`、`online` 改听 `window`(以前 `pageshow` 挂在 `document` 上,永远收不到);`online` 时等着重连的马上连;
9
+ `pageshow` 带 `persisted`(从往返缓存回来)旧连接一律作废重连;切回前台时旧连接已经 CLOSING/CLOSED 却没等到 close 的,也扔掉马上连。新选项 `windowEvents`(测试注入)。
10
+ - **闹钟不丢**(`room.ts`):`setAlarm` 出错不再往外抛;内存里的闹钟时间改回"不知道",下一个入口按 `getAlarm()` 重新读、照常重排。以前内存里记着新时间,之后 `planAlarm` 一直以为"已经设了更早的",房间的超时、托管、关房都不触发。
11
+ 闹钟里那一次出错的,按存储里实际的闹钟判断要不要 60 秒后再醒。
12
+ - **moveId 去重窗口**:每个座位记最近 8 个做成的 moveId(`recentMoves`,旧房间的状态里没有就当空的),不再只认最后一个:A 的 ack 丢了、之后 B 也做成了,补发 A 仍然只回 ack。
13
+ - 测试:客户端 8 条(判死和不误判、建连超时、online、pageshow persisted、CLOSED 没等到 close、摘掉事件),房间 3 条(补发不是最后一个的 moveId、setAlarm 出错后重排、闹钟里重设失败);每条都做了反证(把对应的那行改坏,测试失败)。
14
+
15
+ ## 0.11.0
16
+
17
+ - **牌桌互动组件** `arcade-kit/table-fun`(仓库根 `docs/table-fun.md` 第 0 节"互动第二版"):几个牌桌游戏共用一套,游戏只给头像的屏幕矩形、禁区、发送通道、名场面映射和短语表。
18
+ - `TableFun`(DOM):头像上的透明热区(点别人出菜单、点自己出表情菜单;长按 0.4 秒连扔上次的道具 / 发上次的表情)、"互动"按钮和面板(短语 / 表情 / 道具、选对象、全部屏蔽)、
19
+ 菜单第一格是上次扔的道具、"屏蔽他"(sessionStorage `<id>.fun.blocked`,按 `scope` 分房间)、首次提示"点头像扔道具,长按连扔"(localStorage `<id>.hint.fun`,只一次)、
20
+ 冷却转圈、护盾、连击 ×N、回敬、名场面横幅 `banner`;菜单避开游戏给的禁区矩形,点按区 ≥ 44px。
21
+ - 单机(`localBots`)电脑由组件按 `botReact` 反应:一定是表情加一句话的气泡,回扔时道具飞回去;好友房电脑发来的东西晚 0.9 秒播、表情自动配一句话。
22
+ - `controls()` 给冒烟每个能点的东西的中心和大小,`log` 给一行行流水;演示页 `demo/table-fun.html`。
23
+ - **`arcade-kit/fun-logic`**(纯函数,房间、测试、页面都能用):`FUN_TIMING`(冷却 1.5 / 1 秒、护盾 10 秒 6 次、连击 3 秒、长按 0.4 秒、污渍 3 秒)、`FunCooldown`、`FunTally`、
24
+ `botReact(事件, 电脑, 状态, 现在, 随机数, 参数?)`(`BOT_REACT`:被扔 60% 回应、其中四分之一回扔、20 秒最多回扔 2 次,名场面输 40% / 赢 30%,每手 2 次,两次至少隔 1 秒;性格 ×1.5 / ×1 / ×0.3)、
25
+ `placeMenu`、编号换算 `funIdCount`/`funItem`/`stickerId`/`propId`,房间用的 `hitEvent`、`botEmoteIds`、`stickerMood`。
26
+ - `fun.ts`:污渍从约 1.7 秒改成 3 秒(`FUN_TIMING.stainMs`)。
27
+ - 测试 `test/fun-logic.test.ts`:botReact 的概率区间(按性格)、冷却、护盾、连击(换人断开)、回敬、回扔上限和窗口、每手上限、最小间隔、菜单避开禁区、编号和房间换算。
28
+
3
29
  ## 0.10.2
4
30
 
5
31
  - `standaloneRoot` 独立模式下也接 `/api/me`:回 200 `{ standalone: true }`(没有 `user`,页面的 `detectPlatform` 认作没有平台)。
package/README.md CHANGED
@@ -40,7 +40,7 @@ a daily write-quota meter (`arcade-kit/meter`), a seeded ChaCha20 RNG (`arcade-k
40
40
  | `arcade-kit/rng` | 两边、规则核心 | ChaCha20(RFC 8439,0.2.0):256 位密钥按"用途 + 序号"分流、拒绝采样的均匀整数、Fisher–Yates 洗牌;纯函数 |
41
41
  | `arcade-kit/meter` | 游戏 Worker(Durable Object) | 计量 `RoomMeter`(0.2.0):每天的写入额度、按人和全站的开房名额,游戏原样导出、绑成 `METER` 就行;`meterReport` / `meterLevel`(0.3.0)给房间以外的 DO 记写入、问门槛 |
42
42
  | `arcade-kit/game` | 两边、规则测试 | 规则接口 `RoomGame`;`snapshotBytes`:算进房快照最大有多大,游戏在自己的测试里断言上限 |
43
- | `arcade-kit/client` | 浏览器 | `RoomClient`:退避加抖动重连、切回前台马上连、按 seq 去重、没确认的操作按原 moveId 和 baseSeq 重发、一直连不上就停下等用户点重试;`createRoom`、`createText`、`closeText`(0.8.0):开房和给玩家看的默认文案;`detectPlatform`、`withNickname`(0.10.0):独立部署时页面降级、带昵称 |
43
+ | `arcade-kit/client` | 浏览器 | `RoomClient`:退避加抖动重连、切回前台和网络恢复时马上连、认得出静默死掉的连接(0.11.1)、按 seq 去重、没确认的操作按原 moveId 和 baseSeq 重发、一直连不上就停下等用户点重试;`createRoom`、`createText`、`closeText`(0.8.0):开房和给玩家看的默认文案;`detectPlatform`、`withNickname`(0.10.0):独立部署时页面降级、带昵称 |
44
44
  | `arcade-kit/testing` | 游戏的测试、本地开发 | 假平台钱包(0.8.0):`WalletService`、`FakeBank`、`walletHttp`,只复刻接口形状和关键规则;不进线上代码 |
45
45
  | `arcade-kit/protocol` | 两边 | 消息类型、协议版本、关闭码、房间码格式 |
46
46
 
@@ -117,6 +117,10 @@ a daily write-quota meter (`arcade-kit/meter`), a seeded ChaCha20 RNG (`arcade-k
117
117
  - `onStatus('closed', 关闭码, 原因)`:按码提示(4401 去登录、4005 刷新页面、4503 今天人太多……);4000 带原因 `CLOSE_REASON.quota`(0.2.0)是今天的额度满了、房间在这一副打完时收场,
118
118
  页面可以说"今天联机的人太多了,这一副打完先散了,单机照常玩";`onStatus('failed')`:连着 20 次没进得了房间,提示"连不上",用户点了调 `retry()`。
119
119
  退避和失败次数在收到 `welcome` 时才清零(被拒的连接也会先 open 再关)。
120
+ - 断线怎么认(0.11.1):每 25 秒的心跳和每次切回前台都发一个 `ping`,`PONG_WAIT_MS`(8 秒)内什么都没收到(房间自动回的 `pong` 也算)就判这条连接死了,
121
+ 不等 `close`、直接按 1006 重连——iOS 锁屏、切 App 后 socket 常常还显示 OPEN、也不触发 close。建连卡在 CONNECTING 超过 `CONNECT_MS`(10 秒)同样重连。
122
+ 监听 `document` 的 `visibilitychange`、`window` 的 `pageshow` 和 `online`:网络恢复、切回前台时别等退避;从往返缓存(bfcache,`pageshow` 带 `persisted`)回来的页面旧连接一律作废。
123
+ 测试和非浏览器环境可以传 `events`、`windowEvents` 注入事件源。
120
124
 
121
125
  ## 登录闸门、开房、假钱包(0.8.0)
122
126
 
@@ -256,21 +260,95 @@ a daily write-quota meter (`arcade-kit/meter`), a seeded ChaCha20 RNG (`arcade-k
256
260
  - **互动道具** `throwAt(layer, from, to, prop, { sound?, onHit?, target? })`:8 种(`PROPS`:番茄、鸡蛋、臭鸡蛋、拖鞋、炸弹、玫瑰、点赞、干杯)。
257
261
  从 from 沿抛物线飞到 to,边飞边转,带两层残影,0.7 秒;命中:番茄爆开汁水四溅留果肉,鸡蛋蛋壳碎开、蛋清蛋黄糊上去往下流(臭鸡蛋再飘几缕绿色臭气),
258
262
  拖鞋拍扁弹开冒星星,炸弹引信闪三下再爆、冒烟、头像熏黑一下,玫瑰落下花瓣飘,点赞弹出带星星,干杯两杯碰一下冒泡沫。
259
- 污渍在头像上留约 1.7 秒再淡出,同一个人(`target`,游戏传座位号最稳)最多叠 3 层;
263
+ 污渍在头像上留 3 秒再淡出(0.11.0 起,原来约 1.7 秒),同一个人(`target`,游戏传座位号最稳)最多叠 3 层;
260
264
  - **头像的受击反应**由游戏做(头像在游戏的画布或 DOM 里):命中时 kit 调 `onHit(prop)`,游戏让头像抖一下、缩一下;
261
265
  - **音效**:`throwAt` 在起飞、炸弹引信、命中时调 `sound(prop, phase)`,游戏判断静音后用 `playFunSound(自己的 AudioContext, 输出节点, prop, phase)` 放(WebAudio 现场合成);
262
266
  - **性能**:只动 `transform`、`opacity`(合成层完成,不触发重排,不读布局),图层 `contain: strict`;同一个图层同时最多 `MAX_PLAYING`(4)个效果,多的直接丢掉(返回 false);
263
267
  - **素材**:全部是 kit 自己手写的 SVG(`src/fun-art.ts`,viewBox 0 0 100 100),不用 emoji 字体(有的设备显示成方框)、不引外部图片和字体,许可同 kit 本身;
264
268
  - **演示页**:`demo/index.html`,在 kit 目录里用任意能跑 TS 的开发服务器打开(如 `npx vite`,再访问 `/demo/`),每种道具、每个表情都有按钮。
265
- - **房间以外的 DO 用计量**
269
+ - **牌桌互动组件** `arcade-kit/table-fun`(0.11.0):头像菜单、互动面板、冷却护盾连击回敬、电脑反应整套,见下一节。
266
270
  - **房间以外的 DO 用计量** `meterReport(env, writes)`、`meterLevel(env)`(`arcade-kit/meter`):比如游戏自己的钱包 DO,每次写完报一次、写之前问一下门槛(`stop` 就别写了)。
267
271
  计量每收一次报数自己写 1 行,所以只适合写得少的地方;没绑 `METER` 时回 `ok`、什么都不做,连不上时抛错。
268
272
 
273
+ ## 牌桌互动(table-fun,0.11.0)
274
+
275
+ 几个牌桌游戏共用的一整套互动:外观、位置规则、节奏、电脑怎么反应都在 kit 里,游戏只做接线。规范正本是仓库根的 `docs/table-fun.md`(第 0 节分工,第 2–4 节的数字)。
276
+
277
+ | kit 管 | 游戏管 |
278
+ |---|---|
279
+ | 点头像菜单、互动面板(短语 / 表情 / 道具)、自己的表情菜单、首次提示 | 每个座位头像的**屏幕矩形**、名字、是不是自己、是不是电脑、互动性格 |
280
+ | 冷却(道具 1.5 秒、表情和短语 1 秒)、护盾(同一人 10 秒最多被扔 6 次)、连击 ×N、回敬 | 菜单不能盖的地方(手牌、操作按钮)的屏幕矩形 |
281
+ | 长按 0.4 秒连扔、菜单第一格是上次扔的道具、污渍 3 秒淡出 | 发送通道(好友房走 kit 的 emote,单机直接回给组件) |
282
+ | 电脑的反应 `botReact`(一定是表情 + 一句话,回扔时再带道具)、名场面横幅 | 把自己的公开事件映射成名场面(`bomb`、`first-out`、`big-win`、`kong`、`self-draw`……)、短语表 |
283
+
284
+ ### 页面里接上
285
+
286
+ ```ts
287
+ import { TableFun, funIdCount, type FunSeat } from 'arcade-kit/table-fun';
288
+
289
+ const fun = new TableFun({
290
+ storageKey: 'mygame', // 存储前缀:mygame.hint.fun(localStorage)、mygame.fun.blocked(sessionStorage)
291
+ phrases: PHRASES, // 本游戏的短语表;编号 0..P-1 是短语,P..P+11 表情,P+12..P+19 道具
292
+ named: [3, 4, 2], // 头像菜单里的 3 句点名短语(短语表下标)
293
+ seats: (): (FunSeat | null)[] => view.seats.map((s, i) => s && ({
294
+ rect: avatarRect(i), // 屏幕矩形(CSS 像素);DOM 头像直接 getBoundingClientRect()
295
+ name: s.name, me: i === 0, bot: s.bot, temper: s.temper, // temper:'rowdy' | 'normal' | 'cool'
296
+ })),
297
+ avoid: () => [handRect(), actionBarRect()], // 菜单、面板尽量不盖;放不下时选盖得最少的位置
298
+ button: () => funButtonRect(), // "互动"按钮放哪;null 不画(没坐下)
299
+ send: (id, to) => (online ? client.emote(id, to) : fun.receive(0, id, to)),
300
+ canSend: () => seated,
301
+ enabled: () => settings.fun, // 设置里关掉:别人的一律不播,单机电脑也不反应
302
+ scope: () => roomCode ?? 'solo', // "屏蔽他""全部屏蔽"按房间分开
303
+ localBots: !online, // 单机:电脑的反应由组件算、在本地播
304
+ sound: (prop, phase) => muted || playFunSound(ac, sfxBus, prop, phase),
305
+ onHit: (seat) => shakeAvatar(seat),
306
+ });
307
+ client.onEmote = (m) => fun.receive(m.from, m.id, m.to); // 好友房收到的(自己发的也是从这里回来)
308
+ ```
309
+
310
+ - **画布游戏**(Phaser 等):`rect` 传头像换算到屏幕后的矩形,算法是 `canvas.getBoundingClientRect()` 的左上角加上"世界坐标 × 画布在屏幕上的缩放"。
311
+ 组件每次用到时现问,座位挪了、窗口变了都跟得上;窗口尺寸变了它自己 `refresh()`,游戏重排头像后也调一次。
312
+ - **房间的编号数**:`RoomGame.emotes` 填 `funIdCount(PHRASES.length)`(短语 + 12 表情 + 8 道具)。
313
+ - **名场面**:游戏从**公开事件**里认出来,调 `fun.moment({ kind: 'moment', moment: 'bomb', winners: [0], losers: [1, 3], hand: 第几手 })`,
314
+ 单机时电脑按性格冲赢家撇嘴、或得意一下;要横幅就 `fun.banner('炸弹!', '小王 炸了 老李')`。
315
+ - **游戏自己的弹窗**:打开前 `fun.hide()`,关掉后 `fun.show()`(正在播的不中断);离开牌桌 `fun.destroy()`。
316
+ - **冒烟**:`fun.controls()` 给每个能点的东西的屏幕中心和大小(`fun:button`、`fun:seat<i>`、菜单里的 `fun:prop<k>`、`fun:named<k>`、`fun:block`、
317
+ 表情菜单 `fun:sticker<k>`、面板 `fun:tab:phrase|sticker|prop`、`fun:phrase<i>`、`fun:psticker<k>`、`fun:pprop<k>`、`fun:target<i>`、`fun:blockAll`);
318
+ `log` 选项给一行行流水(`道具 0>1:tomato ×2 回敬`、`电脑 1:angry`、`护盾 1`、`提示 头像`),断言用它。
319
+ - 演示页 `demo/table-fun.html`(`npx vite` 后打开 `/demo/table-fun.html`):我加三个不同性格的电脑,单机接法。
320
+
321
+ ### 房间里的电脑(好友房)
322
+
323
+ 纯逻辑在 `arcade-kit/fun-logic`(不碰 DOM,房间、测试都能 import;`table-fun` 也把它全部转出):
324
+
325
+ ```ts
326
+ import { botReact, botEmoteIds, hitEvent, newBotState, type BotState } from 'arcade-kit/fun-logic';
327
+
328
+ private bots = newBotState(); // 只在内存里,不写存储;房间重启丢了没关系
329
+ protected override onEmote(from: number, id: number, to?: number): void {
330
+ const ev = hitEvent(id, PHRASES.length, from, to); // 冲人来的道具、点名短语才算
331
+ if (!ev || !this.isBot(ev.to)) return;
332
+ const a = botReact(ev, { seat: ev.to, temper: temperOf(ev.to) }, this.bots, Date.now(), Math.random);
333
+ if (a) for (const e of botEmoteIds(a, PHRASES.length)) this.botEmote(a.from, e.id, e.to);
334
+ }
335
+ ```
336
+
337
+ - 房间只有一个闹钟,`a.delay` 不用等,立刻发;页面上的 `TableFun` 收到电脑座位(`bot: true`)发来的东西会晚 0.9 秒再播,
338
+ 表情自动配一句话(`moodLines` 或 kit 默认),看起来和单机的电脑一样。名场面同理:在 `onStep` 里从公开事件认出来,每个电脑座位问一次 `botReact`。
339
+
340
+ - `botReact(事件, 电脑, 状态, 现在, 随机数, 参数?)` 返回 `null`(不理)或 `{ from, to?, delay, sticker, mood, prop? }`:被扔后 60% 回应(乘性格:爱闹 ×1.5、普通 ×1、高冷 ×0.3),
341
+ 其中四分之一同类回扔(鸡蛋回拖鞋;同一个电脑 20 秒最多回扔 2 次);名场面输的一方 40% 冲赢家、赢的一方 30% 得意;每手最多主动 2 次;两次反应至少隔 1 秒。
342
+ 数字都在 `BOT_REACT`,第六个参数能整个换掉。
343
+ - 电脑的反应只是画面,**出牌决策绝不读它**;真人的短语、道具不记进牌局状态(见上面 `onEmote` 的规矩)。
344
+ - 其他纯函数:`stickerMood`(表情对应的情绪)、`FunCooldown`(冷却)、`FunTally`(护盾、连击、回敬)、`placeMenu`(菜单放哪,避开禁区)、`funItem`/`stickerId`/`propId`(编号换算),测试在 `test/fun-logic.test.ts`。
345
+
269
346
  ## 房间怎么守规矩
270
347
 
271
348
  - **只用 `ctx.acceptWebSocket`、一个闹钟**:所有到期时间(房间寿命 3 小时、没人 10 分钟、这一手 30 秒、掉线宽限 60 秒、电脑没算完、停够了(0.5.0))取最早的;
272
349
  只有比现在设的更早才 `setAlarm`(它也算一行写入),期限往后推时不动,到点醒来没到期再改。心跳 `ping`/`pong` 由运行时自动应答。
273
350
  闹钟时间以 `getAlarm()` 读到的为准;写完状态后广播出错,算好的闹钟照样设;闹钟里游戏代码出错,60 秒后再醒来重试。
351
+ `setAlarm` 自己出错(0.11.1)不往外抛:内存里记的闹钟改回"不知道",下一个入口重新 `getAlarm()`、照常重排;出错的是闹钟里那一次,同样 60 秒后再醒来。
274
352
  - **每步最多写 2 行**:房间状态一行(到期时间、计数都在里面),外加需要时的 `setAlarm`。实测:
275
353
  - 开房 4 行:建表 2 行(SQLite 记建表也算写入,一个房间只这一次)+ 状态 1 行 + 闹钟 1 行;
276
354
  - 进房、出牌 1 行;到点醒来但还没到期只改闹钟 1 行;
@@ -285,6 +363,7 @@ a daily write-quota meter (`arcade-kit/meter`), a seeded ChaCha20 RNG (`arcade-k
285
363
  - 超时代出:先用游戏的 `timeout`,被规则拒了改用 `ai`;还不行就记日志、这一手的期限推后 60 秒(不然闹钟会在当前时刻反复响、每次写一行)。
286
364
  - 同一个账号再连一次,旧连接 4001 让位。重连一律发全量快照,不补发漏掉的事件。
287
365
  - 客户端重发没确认的操作带原来的 `baseSeq`:牌局变过就回 `stale`,旧操作不会被套到新的一手上。有人进出不算牌局变了。
366
+ - 重复的 moveId 每个座位记最近 8 个(0.11.1,和座位一起存在状态那一行里,不多写):连发 A、B 时 A 的 ack 丢了,补发 A 仍然只回 ack,不轮流的操作不会做第二遍。
288
367
  - **硬限制**(4.6):
289
368
  - 单条入站消息 4 KB(1009);每连接每秒 5 条、最多攒 10 条(1008);房间一生 3,000 条入站消息;寿命 3 小时;没人 10 分钟——到了就关房间:
290
369
  断开所有人(4000)、删闹钟、删存储;
@@ -429,6 +508,8 @@ packages/arcade-kit/
429
508
  │ ├─ protocol.ts 消息类型、关闭码、协议版本
430
509
  │ ├─ rng.ts ChaCha20 随机数
431
510
  │ ├─ fun.ts、fun-art.ts 快捷短语、表情、道具的画面层
511
+ │ ├─ fun-logic.ts 牌桌互动的规则:冷却、护盾、连击、回敬、电脑反应、菜单位置(纯函数)
512
+ │ ├─ table-fun.ts 牌桌互动组件 TableFun(DOM:头像菜单、面板、提示)
432
513
  │ ├─ testing.ts 假平台钱包(只给测试和本地开发)
433
514
  │ └─ internal.ts 内部地址、身份头(不在 exports 里)
434
515
  ├─ scripts/manifest.mjs 打包时把 exports 换成 dist 版、换回来、检查 dist
package/dist/client.d.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * 浏览器端连接器(docs/platform-backend.md 4.5):
3
3
  * - 断线后按指数退避加随机抖动重连;房间每次都回全量快照(welcome),客户端整桌重画,不做增量修补;
4
- * - 页面从后台切回来(visibilitychange、pageshow)时立刻检查连接:iOS Safari、微信切后台会挂起连接;
4
+ * - 页面从后台切回来(document 的 visibilitychange、window 的 pageshow)、网络恢复(window 的 online)时立刻检查连接:iOS Safari、微信切后台会挂起连接;
5
+ * 从往返缓存(bfcache,pageshow 带 persisted)回来的页面,旧连接一律作废、马上重连(0.11.1);
6
+ * - 静默死掉的连接(0.11.1):iOS 锁屏、切 App 以后 socket 常常还是 OPEN、也不触发 close,发出去的石沉大海。
7
+ * 每次心跳(和切回前台)发 "ping" 以后等 PONG_WAIT_MS,这期间什么都没收到(连房间自动回的 "pong" 都没有)就判它死了:
8
+ * 不等 close,直接扔掉、按断线重连。建连卡在 CONNECTING 超过 CONNECT_MS 也一样。只看"收到过没有",不比时钟,后台节流的计时器晚跑也不会误判;
5
9
  * - 收到 1008、1009 或 4000 段关闭码时不重连(违规、房间关了、被别处顶掉、版本太旧、没登录 4401、别的网站 4403);
6
10
  * - 平台暂时不可用(1013)照常按退避重连;
7
11
  * - 连着 maxFailures 次(默认 20 次,约 4 分钟)都没进得了房间(没收到 welcome)就停下,状态变成 failed,由界面提示"连不上"、让用户点 retry()。
8
12
  * 退避和失败次数都在收到 welcome 时才清零,不在 onopen:被拒的连接也会先 open 再关,按 onopen 清零就会一直快速重连;
9
13
  * - 下行消息按 seq 去重,旧的丢掉;上行操作带 moveId 和 baseSeq(做决定时看到的 seq),没收到 ack 的在重连后原样重发:
10
14
  * 房间认得出重复的 moveId;牌局在那之后变过的,房间回 stale,不会把旧操作套到新的一手上;
11
- * - 心跳发纯文本 "ping",房间由运行时自动回 "pong",不唤醒房间。
15
+ * - 心跳发纯文本 "ping",房间由运行时自动回 "pong"(setWebSocketAutoResponse),不唤醒房间。
12
16
  *
13
17
  * 浏览器里的计时器(重连、心跳)可以用 setTimeout,"只用一个闹钟"是房间(Durable Object)那边的规矩。
14
18
  */
@@ -38,15 +42,17 @@ export interface RoomClientOptions<V> {
38
42
  random?: () => number;
39
43
  setTimer?: (fn: () => void, ms: number) => unknown;
40
44
  clearTimer?: (t: unknown) => void;
41
- /** 页面事件来源(document / window),没有就不监听 */
42
- events?: {
43
- addEventListener(type: string, fn: () => void): void;
44
- removeEventListener(type: string, fn: () => void): void;
45
- };
45
+ /** 页面事件来源:events 收 visibilitychange(默认 document),windowEvents 收 pageshow、online(默认 window,没给时用 events);没有就不监听 */
46
+ events?: EventSourceLike;
47
+ windowEvents?: EventSourceLike;
46
48
  isHidden?: () => boolean;
47
49
  /** 连着几次没进得了房间(没收到 welcome;被拒的连接也会先 open 再关)就停下,默认 20 */
48
50
  maxFailures?: number;
49
51
  }
52
+ export interface EventSourceLike {
53
+ addEventListener(type: string, fn: (ev?: unknown) => void): void;
54
+ removeEventListener(type: string, fn: (ev?: unknown) => void): void;
55
+ }
50
56
  export interface WebSocketLike {
51
57
  readyState: number;
52
58
  onopen: ((ev: unknown) => void) | null;
@@ -61,6 +67,10 @@ export interface WebSocketLike {
61
67
  send(data: string): void;
62
68
  close(code?: number, reason?: string): void;
63
69
  }
70
+ /** 发了 ping 以后等多久没收到任何东西就判连接死了(0.11.1)。房间的自动回 pong 不经过游戏代码,正常几十毫秒就到 */
71
+ export declare const PONG_WAIT_MS = 8000;
72
+ /** 建连最多等多久(0.11.1):卡在 CONNECTING 的按一次失败算,重连 */
73
+ export declare const CONNECT_MS = 10000;
64
74
  /** 第 n 次重连(从 0 起)的等待:指数退避,上限 15 秒,乘一个 [0.5, 1) 的随机系数(抖动,免得一桌人同时重连)。 */
65
75
  export declare function backoffMs(attempt: number, random?: () => number): number;
66
76
  export declare class RoomClient<A = unknown, V = unknown> {
@@ -74,6 +84,10 @@ export declare class RoomClient<A = unknown, V = unknown> {
74
84
  private lastSeq;
75
85
  private retryTimer;
76
86
  private beatTimer;
87
+ /** 探活(0.11.1):发了 ping 还没收到任何东西;到点还是 true 就判死 */
88
+ private probing;
89
+ private probeTimer;
90
+ private connectTimer;
77
91
  /** 没收到 ack 的操作:重连后按原 moveId 重发 */
78
92
  private pending;
79
93
  private moveCounter;
@@ -86,6 +100,11 @@ export declare class RoomClient<A = unknown, V = unknown> {
86
100
  private emoteTimer;
87
101
  private readonly opts;
88
102
  private readonly onWake;
103
+ /** pageshow 带 persisted:页面是从往返缓存里拿回来的,旧连接不能信 */
104
+ private readonly onPageShow;
105
+ private readonly onOnline;
106
+ private readonly doc;
107
+ private readonly win;
89
108
  constructor(opts: RoomClientOptions<V>);
90
109
  /** 发一个操作意图;返回 moveId。没连上时先存着,连上后发。 */
91
110
  act(action: A): string;
@@ -107,10 +126,24 @@ export declare class RoomClient<A = unknown, V = unknown> {
107
126
  retry(): void;
108
127
  get state(): ClientStatus;
109
128
  private connect;
129
+ /** 这条连接断了(或者被判死了):最终关闭码停下,没进过房间的累计失败次数,其余按退避重连 */
130
+ private lost;
131
+ /** 不等 close 了:摘掉回调、关掉,按 1006(网络断了)走重连。死连接的 close 可能几分钟后才来,也可能不来 */
132
+ private abandon;
133
+ /** 发一个 ping,PONG_WAIT_MS 内什么都没收到就判死(已经在探就不重复发) */
134
+ private probe;
110
135
  /** 带上这次的关闭码:游戏据此区分"网络断了"(1006)和"平台有点忙"(1013)。 */
111
136
  private scheduleRetry;
112
- /** 切回前台:没连着就别等退避,马上连;连着就发个心跳探一下(挂起的连接会因此报错断开,再走重连)。 */
137
+ /**
138
+ * 切回前台、网络恢复:
139
+ * - 连着:探一下活(静默死掉的会在 PONG_WAIT_MS 后判死、重连);
140
+ * - 在等退避:别等了,马上连;
141
+ * - 有连接却不是 OPEN、也不在建连(CLOSING、CLOSED 却一直没等到 close,从往返缓存回来常见):扔掉,马上连;
142
+ * - fresh(pageshow 带 persisted):不管什么状态,旧连接一律扔掉重连。
143
+ */
113
144
  private wake;
145
+ /** 正在等退避的话,取消等待、马上连 */
146
+ private reconnectNow;
114
147
  private heartbeat;
115
148
  private clearTimers;
116
149
  private trySend;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * 浏览器端连接器(docs/platform-backend.md 4.5):
3
3
  * - 断线后按指数退避加随机抖动重连;房间每次都回全量快照(welcome),客户端整桌重画,不做增量修补;
4
- * - 页面从后台切回来(visibilitychange、pageshow)时立刻检查连接:iOS Safari、微信切后台会挂起连接;
4
+ * - 页面从后台切回来(document 的 visibilitychange、window 的 pageshow)、网络恢复(window 的 online)时立刻检查连接:iOS Safari、微信切后台会挂起连接;
5
+ * 从往返缓存(bfcache,pageshow 带 persisted)回来的页面,旧连接一律作废、马上重连(0.11.1);
6
+ * - 静默死掉的连接(0.11.1):iOS 锁屏、切 App 以后 socket 常常还是 OPEN、也不触发 close,发出去的石沉大海。
7
+ * 每次心跳(和切回前台)发 "ping" 以后等 PONG_WAIT_MS,这期间什么都没收到(连房间自动回的 "pong" 都没有)就判它死了:
8
+ * 不等 close,直接扔掉、按断线重连。建连卡在 CONNECTING 超过 CONNECT_MS 也一样。只看"收到过没有",不比时钟,后台节流的计时器晚跑也不会误判;
5
9
  * - 收到 1008、1009 或 4000 段关闭码时不重连(违规、房间关了、被别处顶掉、版本太旧、没登录 4401、别的网站 4403);
6
10
  * - 平台暂时不可用(1013)照常按退避重连;
7
11
  * - 连着 maxFailures 次(默认 20 次,约 4 分钟)都没进得了房间(没收到 welcome)就停下,状态变成 failed,由界面提示"连不上"、让用户点 retry()。
8
12
  * 退避和失败次数都在收到 welcome 时才清零,不在 onopen:被拒的连接也会先 open 再关,按 onopen 清零就会一直快速重连;
9
13
  * - 下行消息按 seq 去重,旧的丢掉;上行操作带 moveId 和 baseSeq(做决定时看到的 seq),没收到 ack 的在重连后原样重发:
10
14
  * 房间认得出重复的 moveId;牌局在那之后变过的,房间回 stale,不会把旧操作套到新的一手上;
11
- * - 心跳发纯文本 "ping",房间由运行时自动回 "pong",不唤醒房间。
15
+ * - 心跳发纯文本 "ping",房间由运行时自动回 "pong"(setWebSocketAutoResponse),不唤醒房间。
12
16
  *
13
17
  * 浏览器里的计时器(重连、心跳)可以用 setTimeout,"只用一个闹钟"是房间(Durable Object)那边的规矩。
14
18
  */
@@ -17,6 +21,10 @@ const OPEN = 1;
17
21
  const BASE_MS = 500;
18
22
  const MAX_MS = 15_000;
19
23
  const HEARTBEAT_MS = 25_000;
24
+ /** 发了 ping 以后等多久没收到任何东西就判连接死了(0.11.1)。房间的自动回 pong 不经过游戏代码,正常几十毫秒就到 */
25
+ export const PONG_WAIT_MS = 8_000;
26
+ /** 建连最多等多久(0.11.1):卡在 CONNECTING 的按一次失败算,重连 */
27
+ export const CONNECT_MS = 10_000;
20
28
  /** 快捷短语太快时最多排几个(多的丢最早的) */
21
29
  const EMOTE_QUEUE = 2;
22
30
  /** 第 n 次重连(从 0 起)的等待:指数退避,上限 15 秒,乘一个 [0.5, 1) 的随机系数(抖动,免得一桌人同时重连)。 */
@@ -35,6 +43,10 @@ export class RoomClient {
35
43
  lastSeq = -1;
36
44
  retryTimer = null;
37
45
  beatTimer = null;
46
+ /** 探活(0.11.1):发了 ping 还没收到任何东西;到点还是 true 就判死 */
47
+ probing = false;
48
+ probeTimer = null;
49
+ connectTimer = null;
38
50
  /** 没收到 ack 的操作:重连后按原 moveId 重发 */
39
51
  pending = new Map();
40
52
  moveCounter = 0;
@@ -46,7 +58,12 @@ export class RoomClient {
46
58
  emoteQueue = [];
47
59
  emoteTimer = null;
48
60
  opts;
49
- onWake = () => this.wake();
61
+ onWake = () => this.wake(false);
62
+ /** pageshow 带 persisted:页面是从往返缓存里拿回来的,旧连接不能信 */
63
+ onPageShow = (ev) => this.wake(ev?.persisted === true);
64
+ onOnline = () => this.wake(false);
65
+ doc;
66
+ win;
50
67
  constructor(opts) {
51
68
  this.opts = {
52
69
  random: Math.random,
@@ -54,9 +71,12 @@ export class RoomClient {
54
71
  clearTimer: (t) => clearTimeout(t),
55
72
  ...opts,
56
73
  };
57
- const ev = opts.events ?? (typeof document !== 'undefined' ? document : undefined);
58
- ev?.addEventListener('visibilitychange', this.onWake);
59
- ev?.addEventListener('pageshow', this.onWake);
74
+ this.doc = opts.events ?? (typeof document !== 'undefined' ? document : undefined);
75
+ // pageshow、online 只在 window 上派发,挂在 document 上永远收不到(0.11.0 以前就是这样)
76
+ this.win = opts.windowEvents ?? opts.events ?? (typeof window !== 'undefined' ? window : undefined);
77
+ this.doc?.addEventListener('visibilitychange', this.onWake);
78
+ this.win?.addEventListener('pageshow', this.onPageShow);
79
+ this.win?.addEventListener('online', this.onOnline);
60
80
  this.connect();
61
81
  }
62
82
  /** 发一个操作意图;返回 moveId。没连上时先存着,连上后发。 */
@@ -113,9 +133,9 @@ export class RoomClient {
113
133
  close() {
114
134
  this.setStatus('closed');
115
135
  this.clearTimers();
116
- const ev = this.opts.events ?? (typeof document !== 'undefined' ? document : undefined);
117
- ev?.removeEventListener('visibilitychange', this.onWake);
118
- ev?.removeEventListener('pageshow', this.onWake);
136
+ this.doc?.removeEventListener('visibilitychange', this.onWake);
137
+ this.win?.removeEventListener('pageshow', this.onPageShow);
138
+ this.win?.removeEventListener('online', this.onOnline);
119
139
  this.ws?.close(1000, 'bye');
120
140
  this.ws = null;
121
141
  }
@@ -139,9 +159,17 @@ export class RoomClient {
139
159
  url.searchParams.set('cv', String(this.opts.clientVersion ?? 0));
140
160
  const ws = new Impl(url.toString());
141
161
  this.ws = ws;
162
+ this.connectTimer = this.opts.setTimer(() => {
163
+ this.connectTimer = null;
164
+ if (this.ws === ws && ws.readyState !== OPEN)
165
+ this.abandon(ws);
166
+ }, CONNECT_MS);
142
167
  ws.onopen = () => {
143
168
  if (this.ws !== ws)
144
169
  return;
170
+ if (this.connectTimer !== null)
171
+ this.opts.clearTimer(this.connectTimer);
172
+ this.connectTimer = null;
145
173
  this.welcomed = false;
146
174
  this.setStatus('open');
147
175
  this.heartbeat();
@@ -150,7 +178,11 @@ export class RoomClient {
150
178
  this.trySend(msg);
151
179
  };
152
180
  ws.onmessage = (ev) => {
153
- if (this.ws !== ws || typeof ev.data !== 'string' || ev.data === PONG)
181
+ if (this.ws !== ws)
182
+ return;
183
+ // 收到任何东西都说明连接活着
184
+ this.probing = false;
185
+ if (typeof ev.data !== 'string' || ev.data === PONG)
154
186
  return;
155
187
  let msg;
156
188
  try {
@@ -192,26 +224,56 @@ export class RoomClient {
192
224
  ws.onclose = (ev) => {
193
225
  if (this.ws !== ws)
194
226
  return;
195
- const entered = this.status === 'open' && this.welcomed;
196
- this.ws = null;
197
- this.clearTimers();
198
- if (this.status === 'closed')
199
- return;
200
- if (isFinalClose(ev.code)) {
201
- this.setStatus('closed', ev.code, ev.reason);
202
- return;
203
- }
204
- // 升级被拒(4xx/5xx)时浏览器只给 1006,分不出原因;1013 是平台暂时不可用:一直进不了房间就停下,别无限重连
205
- if (!entered && ++this.failures >= (this.opts.maxFailures ?? 20)) {
206
- this.setStatus('failed', ev.code);
207
- return;
208
- }
209
- this.scheduleRetry(ev.code);
227
+ this.lost(ev.code, ev.reason);
210
228
  };
211
229
  ws.onerror = () => {
212
230
  // 之后一定会有 onclose,在那里处理
213
231
  };
214
232
  }
233
+ /** 这条连接断了(或者被判死了):最终关闭码停下,没进过房间的累计失败次数,其余按退避重连 */
234
+ lost(code, reason) {
235
+ const entered = this.status === 'open' && this.welcomed;
236
+ this.ws = null;
237
+ this.clearTimers();
238
+ if (this.status === 'closed')
239
+ return;
240
+ if (isFinalClose(code)) {
241
+ this.setStatus('closed', code, reason);
242
+ return;
243
+ }
244
+ // 升级被拒(4xx/5xx)时浏览器只给 1006,分不出原因;1013 是平台暂时不可用:一直进不了房间就停下,别无限重连
245
+ if (!entered && ++this.failures >= (this.opts.maxFailures ?? 20)) {
246
+ this.setStatus('failed', code);
247
+ return;
248
+ }
249
+ this.scheduleRetry(code);
250
+ }
251
+ /** 不等 close 了:摘掉回调、关掉,按 1006(网络断了)走重连。死连接的 close 可能几分钟后才来,也可能不来 */
252
+ abandon(ws) {
253
+ if (this.ws !== ws)
254
+ return;
255
+ ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
256
+ try {
257
+ ws.close();
258
+ }
259
+ catch {
260
+ // 已经坏了
261
+ }
262
+ this.lost(1006);
263
+ }
264
+ /** 发一个 ping,PONG_WAIT_MS 内什么都没收到就判死(已经在探就不重复发) */
265
+ probe() {
266
+ const ws = this.ws;
267
+ if (!ws || ws.readyState !== OPEN || this.probeTimer !== null)
268
+ return;
269
+ this.probing = true;
270
+ this.trySend(PING);
271
+ this.probeTimer = this.opts.setTimer(() => {
272
+ this.probeTimer = null;
273
+ if (this.ws === ws && this.probing)
274
+ this.abandon(ws);
275
+ }, PONG_WAIT_MS);
276
+ }
215
277
  /** 带上这次的关闭码:游戏据此区分"网络断了"(1006)和"平台有点忙"(1013)。 */
216
278
  scheduleRetry(code) {
217
279
  this.setStatus('reconnecting', code);
@@ -221,36 +283,50 @@ export class RoomClient {
221
283
  this.connect();
222
284
  }, wait);
223
285
  }
224
- /** 切回前台:没连着就别等退避,马上连;连着就发个心跳探一下(挂起的连接会因此报错断开,再走重连)。 */
225
- wake() {
286
+ /**
287
+ * 切回前台、网络恢复:
288
+ * - 连着:探一下活(静默死掉的会在 PONG_WAIT_MS 后判死、重连);
289
+ * - 在等退避:别等了,马上连;
290
+ * - 有连接却不是 OPEN、也不在建连(CLOSING、CLOSED 却一直没等到 close,从往返缓存回来常见):扔掉,马上连;
291
+ * - fresh(pageshow 带 persisted):不管什么状态,旧连接一律扔掉重连。
292
+ */
293
+ wake(fresh) {
226
294
  if (this.status === 'closed' || this.status === 'failed' || this.opts.isHidden?.() || (typeof document !== 'undefined' && document.hidden))
227
295
  return;
228
- if (this.ws && this.ws.readyState === OPEN) {
229
- this.trySend(PING);
296
+ const ws = this.ws;
297
+ if (ws && (fresh || (ws.readyState !== OPEN && ws.readyState !== 0))) {
298
+ // 扔掉以后按断线走一遍(状态、失败次数照常算),再立刻连,不等退避
299
+ this.abandon(ws);
300
+ this.reconnectNow();
230
301
  return;
231
302
  }
232
- if (this.retryTimer !== null) {
233
- this.opts.clearTimer(this.retryTimer);
234
- this.retryTimer = null;
235
- this.connect();
303
+ if (ws && ws.readyState === OPEN) {
304
+ this.probe();
305
+ return;
236
306
  }
307
+ if (!ws)
308
+ this.reconnectNow();
309
+ }
310
+ /** 正在等退避的话,取消等待、马上连 */
311
+ reconnectNow() {
312
+ if (this.retryTimer === null)
313
+ return;
314
+ this.opts.clearTimer(this.retryTimer);
315
+ this.retryTimer = null;
316
+ this.connect();
237
317
  }
238
318
  heartbeat() {
239
319
  this.beatTimer = this.opts.setTimer(() => {
240
- this.trySend(PING);
320
+ this.probe();
241
321
  this.heartbeat();
242
322
  }, HEARTBEAT_MS);
243
323
  }
244
324
  clearTimers() {
245
- if (this.retryTimer !== null)
246
- this.opts.clearTimer(this.retryTimer);
247
- if (this.emoteTimer !== null)
248
- this.opts.clearTimer(this.emoteTimer);
249
- this.emoteTimer = null;
250
- if (this.beatTimer !== null)
251
- this.opts.clearTimer(this.beatTimer);
252
- this.retryTimer = null;
253
- this.beatTimer = null;
325
+ for (const t of [this.retryTimer, this.emoteTimer, this.beatTimer, this.probeTimer, this.connectTimer])
326
+ if (t !== null)
327
+ this.opts.clearTimer(t);
328
+ this.retryTimer = this.emoteTimer = this.beatTimer = this.probeTimer = this.connectTimer = null;
329
+ this.probing = false;
254
330
  }
255
331
  trySend(msg) {
256
332
  if (!this.ws || this.ws.readyState !== OPEN)