@yyyu-star/arcade-kit 0.0.0-stage → 0.10.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/LICENSE +21 -0
  3. package/README.md +440 -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 +89 -0
  53. package/dist/worker.d.ts.map +1 -0
  54. package/dist/worker.js +177 -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 +229 -0
package/README.md CHANGED
@@ -1,3 +1,441 @@
1
- # Temporary Holding Version
1
+ # arcade-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 小游戏共用的联机底座:Cloudflare Durable Object 房间、开房进房、浏览器连接、消息协议和每天的额度,各联机游戏(德州、掼蛋、麻将等)的好友房都用它。房间规范见仓库根 `docs/platform-backend.md` 第 4 节。
4
+
5
+ ## Install
6
+
7
+ Published on npm as **`@yyyu-star/arcade-kit`**. Install it under the alias `arcade-kit` so imports stay `arcade-kit/room`, `arcade-kit/worker`, …:
8
+
9
+ ```bash
10
+ npm i arcade-kit@npm:@yyyu-star/arcade-kit
11
+ ```
12
+
13
+ (`package.json` then reads `"arcade-kit": "npm:@yyyu-star/arcade-kit@^0.10.1"`.)
14
+
15
+ Multiplayer toolkit for small turn-based games on Cloudflare Workers: a Durable Object room base class (`arcade-kit/room`),
16
+ create/join entry points for your game Worker (`arcade-kit/worker`), a reconnecting browser client (`arcade-kit/client`),
17
+ a daily write-quota meter (`arcade-kit/meter`), a seeded ChaCha20 RNG (`arcade-kit/rng`) and the wire protocol (`arcade-kit/protocol`).
18
+
19
+ - **Two modes.** With a service binding `ARCADE` to the Arcade platform, players are identified through the platform's `/api/me` (login required).
20
+ Without it (and `GATE` not set to `"login"`), the game runs **standalone**: the Worker issues a long-lived guest cookie and anyone can play — no platform needed.
21
+ See "两种部署方式" below.
22
+ - **Package format.** ESM only. Each subpath ships compiled JavaScript plus `.d.ts` (`dist/`, with source maps back to `src/`).
23
+ Worker-side entries (`room`, `meter`, `testing`) import `cloudflare:workers` and need a Workers bundler (wrangler / esbuild,
24
+ `@cloudflare/vitest-plugin`); browser-safe entries (`client`, `protocol`, `rng`, `game`, `guest`, `fun`) also load in plain Node.
25
+ Types reference `@cloudflare/workers-types` (optional peer dependency). Node ≥ 22, TypeScript strict mode is fine.
26
+ - **Versioning.** Semver; before 1.0 a minor bump (0.x → 0.(x+1)) may change behaviour, see `CHANGELOG.md`.
27
+ The room wire protocol only adds fields; incompatible changes bump `PROTOCOL_VERSION` (old pages are asked to reload).
28
+ - **Source / issues:** <https://github.com/yyyu-star/arcade/tree/main/packages/arcade-kit>. License: MIT.
29
+
30
+ 下面的中文是细节。
31
+
32
+ ## 功能
33
+
34
+ | 入口 | 用在哪 | 做什么 |
35
+ |---|---|---|
36
+ | `arcade-kit/room` | 游戏 Worker(Durable Object) | 房间基类 `GameRoom`:入座离座(开打后座位绑人)、房主开局、轮流出手或一组人同时出手、按座位过滤下发、断线保留座位、超时代出、掉线托管和交还、电脑批量出手、空房和超时清理、硬限制 |
37
+ | `arcade-kit/worker` | 游戏 Worker | `acceptCreate`(开房)、`acceptJoin`(进房):查 Origin、认人、找房间打包在一起,游戏不用碰浏览器的原始请求;`loginGate`(0.8.0):首页的登录闸门(独立模式下放行并发游客 cookie);`standaloneRoot`、`isStandalone`(0.10.0);`json`、`readJson`、`withCookies`:游戏自己的接口用的小工具 |
38
+ | `arcade-kit/auth` | 游戏 Worker | `whoIs`(0.3.1):经平台 `/api/me` 认人,和开房进房同一个做法(只转发 Cookie 和 IP、3 秒超时、"没登录"和"平台不可用"分开、fail-closed),带回平台的 `Set-Cookie`;游戏自己的接口用它,不再各写一份(登录闸门直接用 `loginGate`);`identify`(0.10.0):按部署方式认人,有平台走 `whoIs`,独立模式认游客 |
39
+ | `arcade-kit/guest` | 游戏 Worker、浏览器 | 独立模式(0.10.0):`isStandalone`、游客 cookie(`mintGuestCookie`、`readGuestCookie`)、`guestIdentity`、昵称清洗 `cleanNickname` |
40
+ | `arcade-kit/rng` | 两边、规则核心 | ChaCha20(RFC 8439,0.2.0):256 位密钥按"用途 + 序号"分流、拒绝采样的均匀整数、Fisher–Yates 洗牌;纯函数 |
41
+ | `arcade-kit/meter` | 游戏 Worker(Durable Object) | 计量 `RoomMeter`(0.2.0):每天的写入额度、按人和全站的开房名额,游戏原样导出、绑成 `METER` 就行;`meterReport` / `meterLevel`(0.3.0)给房间以外的 DO 记写入、问门槛 |
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):独立部署时页面降级、带昵称 |
44
+ | `arcade-kit/testing` | 游戏的测试、本地开发 | 假平台钱包(0.8.0):`WalletService`、`FakeBank`、`walletHttp`,只复刻接口形状和关键规则;不进线上代码 |
45
+ | `arcade-kit/protocol` | 两边 | 消息类型、协议版本、关闭码、房间码格式 |
46
+
47
+ **发布和仓库内引用是两种形态**(0.10.0):
48
+ - npm 上的包是编译好的 `dist/`(JS + `.d.ts`),`exports` 每个入口给 `types` 和 `default`;
49
+ - 仓库里 `package.json` 的 `exports` 指向 `src/*.ts`:仓库内的游戏用 `file:` 引用时 npm 只建一个软链、不会替 kit 构建,
50
+ 指向源码才能 `npm ci` 后直接用(和 0.9 一样,游戏用 vite / wrangler / tsc 一起编译)。打包时 `prepack` 构建 dist 并把 `exports` 换成 dist 版,
51
+ `postpack` 换回来(`scripts/manifest.mjs`)。所以**不要手改 package.json 里的 exports 成 dist**,新加入口照 `"./x": "./src/x.ts"` 写;
52
+ - `src/` 里相互引用写 `.js` 扩展名(`./auth.js`):编出来的 JS 原样就能被 Node 原生 ESM 解析,vite、esbuild、tsc(bundler 解析)对源码会把它映射回 `.ts`。
53
+ 游戏只用上面这几个入口;内部地址和身份头(`src/internal.ts`)不在 `exports` 里,游戏引用不到,免得自己拼内部请求。
54
+
55
+ ## 游戏怎么接
56
+
57
+ 1. `package.json` 加依赖:仓库外 `npm i arcade-kit@npm:@yyyu-star/arcade-kit`(npm 上的包名是 `@yyyu-star/arcade-kit`,装成别名 `arcade-kit`,代码里照旧 `arcade-kit/room`);仓库内现在仍是 `"arcade-kit": "file:../../packages/arcade-kit"`(总仓库 AGENTS.md 第 1 节唯一允许的模块外引用),以后各游戏改用 npm 上的版本。
58
+ 2. 写规则:实现 `RoomGame<S, A, V>`(`begin`、`parse`、`apply`、`turn`、`view`、`ai`、`timeout`、`over`,可选 `actors`、`turnMs`、`breakPoint`),都是纯函数,浏览器和房间共用。
59
+ - **开局用 `begin(entropy, rulesVersion)`**(0.2.0):`entropy` 是房间现生成的 256 位熵(8 个 u32),当 `arcade-kit/rng` 的密钥发牌,
60
+ 后面每一副从同一把密钥分出的流里取:`shuffle(deck, stream(key, 'deal', 第几副))`。密钥放在自己的状态里(只在服务端),`view` 绝不能带上它。
61
+ 旧的 `start(seed, rulesVersion)` 只给 32 位种子,玩家拿自己的手牌逐个试种子几分钟就能反推出全桌的牌——0.2.0 里还留着只为老游戏升级时能开局(`RoomGame` 里暂时仍是必填,写了 `begin` 的游戏让它抛错即可),
62
+ 房间见到没有 `begin` 的游戏会记警告;下一版去掉。单机要"同种子同结果"的复现可以照旧用带种子的随机数,联机房间一律用 `begin`;
63
+ 换开局方式时升 `rulesVersion`,已经开打的旧房间按开局时记下的版本打完。
64
+ - `view(state, seat)` 只能放这个座位能看到的东西,房间只发它的结果;
65
+ - **`apply` 返回的 `{ error }` 会原样发给出手的人**,所以错误码只能是固定的短码(小写字母、数字、下划线,最长 32),不能带牌面之类的隐藏信息。
66
+ 不合这个格式的,房间一律换成 `illegal_move`;
67
+ - 不轮流的阶段(结算页大家点继续、倒计时后发下一副)用 `actors(state)` 返回这一刻能出手的全部座位、`turnMs(state)` 给这个阶段的时限:
68
+ 组里的电脑、托管马上代出,真人谁先发都收;时限从进入这组时起算、组里有人出手不重新计时,到点对还没出的真人逐个 `timeout`;
69
+ 同一组里别人先出了,后出的人不算 stale。**推荐返回 `{ seats, group }`**,`group` 是这一组的标识(比如 `settle-3`):出手前后一样、而且出手的人离开了名单、没有新座位加入,才是同一组,否则是新的一手(一组里每个人最多出一次)。
70
+ 只返回数组时房间靠"出手的人离开名单、其他人原样留着"推断,所以每个人出手后都要从名单里拿掉;
71
+ 这有歧义:`[2, 3]` 里 2 出完剩 `[3]`,和"新的一手只有 3 能出"分不清(会当成同一组),会出现这种情况的游戏一定要给 `group`。
72
+ 牌局没打完时名单不能是空的,空了房间记日志、按打完处理(游戏写了 `waiting` 并返回 true 的、在停一会儿(`pause`,0.5.0)的除外,0.3.2)。有人接管组里还没出的电脑座位、收回托管,整组的期限不变;
73
+ 页面看 `room.turn.seats`(还没出的座位)和 `room.turn.deadline`(整组一个截止时间)。两个都不写就是一次一个人、30 秒,和 0.1.0 一样;
74
+ - 在规则测试里挑几个最大的局面,断言 `snapshotBytes(rules, state)` 不超过自己定的上限(重连一律发全量快照,太大会拖慢手机重连)。
75
+ - 可选 `breakPoint(state)`(0.2.0):这一刻是不是"一段的结尾"(比如一副或一手打完、停在结算页)。当天写入到了 stopWrites,在打的房间走到这里就收场(见下面"每天的额度");
76
+ 不写的话房间一知道要收场就马上关。
77
+ - 0.3.0 的可选接口(开局选项、座位换人、对外的事、不轮流的操作)见下面"可选接口(0.3.0 起)",不写就和 0.2.0 一样。
78
+ - 停一会儿再接着来(0.5.0,`pause` / `resume`)见下面"可选接口"的最后一条。
79
+ 3. 房间类:`export class Room extends GameRoom<S, A, V, Env> { protected readonly game = rules; }`,需要时覆盖 `limits`(默认值是 4.5、4.6 那些数),
80
+ 写成 `{ ...DEFAULT_LIMITS, turnMs: 20_000 }` 这样只改要改的,以后加了新字段不用跟着改。
81
+ 4. 计量(0.2.0):Worker 的入口文件原样导出 `export { RoomMeter } from 'arcade-kit/meter';`,`wrangler.jsonc`:
82
+ ```jsonc
83
+ "durable_objects": { "bindings": [{ "name": "ROOM", "class_name": "Room" }, { "name": "METER", "class_name": "RoomMeter" }] },
84
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room", "RoomMeter"] }], // 免费版只能用 SQLite 后端
85
+ "services": [{ "binding": "ARCADE", "service": "arcade" }], // 登录闸门已经有了
86
+ "vars": { "KIT_QUOTA": { "dailyRooms": 300 } } // 可选:只写要改的门槛,见下面"每天的额度"
87
+ ```
88
+ 已经上线过 v1 的游戏不能改 v1,另加一条 `{ "tag": "v2", "new_sqlite_classes": ["RoomMeter"] }`。
89
+ **正式环境一定要绑 `METER`**:没绑时不限额、不计数,只是为了 0.1.x 的游戏升级 kit 时不至于一下子开不了房。
90
+ 5. 游戏 Worker 的路由(`test/worker.ts` 是完整例子),只接这两个,不要自己把浏览器的请求转给房间:
91
+ - 开房:`POST …/rooms` → `acceptCreate(request, env)`:成功 201 `{ code }`;失败回 JSON `{ error, code? }`(`protocol.ts` 的 `CreateError`,0.2.0):
92
+ | 状态 | error | 意思 | 页面可以说 |
93
+ |---|---|---|---|
94
+ | 401 | `login` | 没登录 | 先登录再来 |
95
+ | 403 | `forbidden` | 不是本站页面 | 从游戏大厅打开 |
96
+ | 409 | `has_room` | 这个人已经有一个开着的房间,`code` 是它的码 | 你已经有一个房间了,先回去(给"回到房间"按钮) |
97
+ | 429 | `daily_limit` | 这个人今天开房次数用完了 | 今天开房次数用完了,明天再来 |
98
+ | 503 | `busy` | 今天联机的人太多(写入到了 busyWrites,或全站开房数到了) | 今天联机的人太多了,明天再来,单机照常玩 |
99
+ | 503 | `unavailable` | 平台或计量暂时连不上、房间出错 | 暂时开不了房,稍后再试 |
100
+ | 400 | `bad_options` | 开房时带的选项不合格(0.4.0,见"可选接口"的开局选项) | 页面自己的选项写错了,一般不会出现 |
101
+
102
+ 房间走 RPC 建,身份不经过任何请求头;
103
+ - 进房:`GET …/rooms/<码>/ws`(WebSocket 升级)→ `acceptJoin(request, env, code)`。被拒时先接受升级再用关闭码关掉
104
+ (浏览器的 WebSocket 拿不到 HTTP 状态码):没登录 4401、别的网站 4403、码不对或没有这个房间 4004、页面太旧 4005、旁观的人满了 4003、
105
+ 这个人的重连次数用完 4029、这个人的入站额度用完 4030、今天联机的人太多不进新人 4503(0.2.0)——这些客户端都不再重连;平台暂时认不了人(503、超时)是 1013,客户端按退避重连,开房时则回 503。
106
+ 6. 浏览器:`new RoomClient({ url, clientVersion, onMessage, onStatus })`,`sit`、`start`(只有房主能开)、`act`、`leave`、`close`。
107
+ - 收到 `welcome`(进房、重连)整桌重画;收到 `state` 按 `frames` 依次播放(每帧带相对延迟,电脑连续出手时用);
108
+ - `act` 自动带上 `baseSeq`(看到的最新 seq);牌局在那之后变过的,房间回 `stale`,界面按最新状态让玩家重新出;
109
+ - 开打以后换座位有限制(见下面"座位绑人"):坐过的人回别的座位是 `not_your_seat`,接管别人坐过的电脑座位是 `seat_reserved`,按"座位被占"提示;
110
+ - 大厅和牌桌用这几个字段(0.1.1):
111
+ - `room.seats[i].reserved`:开打后这个座位绑了人(主人离座了也算)。`kind: 'ai'` 且 `reserved` 的座位别人接管不了,不要挂"接管"按钮;
112
+ - `room.seats[i].name`:绑了人的电脑座位是原主人的昵称,可以显示"电脑代 小王 打";没绑人的电脑座位是 null。判断是不是电脑看 `kind`,不看 `name`;
113
+ - `you.bound`:这个人绑定的座位(离座了也还是它;没开打、没坐过是 null)。`you.seat` 是 null、`you.bound` 不是 null 时,给他显示"回到我的座位"(发 `sit(bound)`);
114
+ - 只下发"绑没绑"和公开的昵称,不下发 userId;
115
+ - 开局选项(0.3.0):房主 `start(opts)`,`opts` 是任意 JSON,游戏的 `options` 校验;不合格回 `bad_options`,有人坐在这一局用不到的座位上回 `seat_closed`;
116
+ - `onStatus('reconnecting', 关闭码)`:正在重连,码是这次断开的原因(1006 网断了、1013 平台忙),可以据此换提示;
117
+ - `onStatus('closed', 关闭码, 原因)`:按码提示(4401 去登录、4005 刷新页面、4503 今天人太多……);4000 带原因 `CLOSE_REASON.quota`(0.2.0)是今天的额度满了、房间在这一副打完时收场,
118
+ 页面可以说"今天联机的人太多了,这一副打完先散了,单机照常玩";`onStatus('failed')`:连着 20 次没进得了房间,提示"连不上",用户点了调 `retry()`。
119
+ 退避和失败次数在收到 `welcome` 时才清零(被拒的连接也会先 open 再关)。
120
+
121
+ ## 登录闸门、开房、假钱包(0.8.0)
122
+
123
+ 几个游戏各抄一份的通用部分收进 kit。行为和游戏原来的写法一致,测试见 `test/gate.test.ts`、`test/create.test.ts`、`test/testing.test.ts`。
124
+
125
+ - **登录闸门** `loginGate(request, env, { downPage? })`(docs/platform-backend.md 5.8、15.8):游戏 Worker 把首页(`/games/<id>/`、`/games/<id>/index.html`)交给它。
126
+ `env` 要有 `ASSETS`、`ARCADE`,`GATE` 写 `"off"` 时不拦。处理分四种:
127
+ - 登录了:回页面,带上平台续期的 `Set-Cookie`,不缓存;
128
+ - 没登录:302 到 `/login?next=<原路径和查询参数>`(好友房链接的 `?room=` 原样带回),同样带 `Set-Cookie`;
129
+ - 平台不可用:503 整页,`Retry-After: 30`;
130
+ - 默认的 503 页不带游戏名,游戏可以传自己的 `downPage`(标题、配色)。
131
+
132
+ 游戏自己的接口(单机带入结回之类)用 `whoIs` 认人,回话用 `json(body, status, setCookie)`。认人之前读请求体用 `readJson(request, 最多几字节)`:
133
+ 不带 content-length 或超长的一律不读。
134
+ - **开房** `createRoom(url, opts)`:POST `{ opts }` 带 `X-Arcade: 1`,回 `{ code }` 或 `{ error, code? }`。401 当 `login`,回话不是 JSON 当 `unavailable`,网络断了是 `offline`。
135
+ 游戏 Worker 那边从请求体的 `opts` 取开局选项交给 `acceptCreate` 的 `options`。
136
+ - **默认文案** `createText(error, overrides?)`、`closeText(code, reason?, overrides?)`:开房失败、断开时给玩家看的话,只写各游戏通用的说法(表在 `ROOM_TEXT`)。
137
+ 游戏按 key 换掉个别措辞,比如 `{ roomClosed: '房间关了,桌上的筹码已经结回钱包', quota: '……这一手打完先散了……' }`。
138
+ key 有两类:开房错误码,以及 `CLOSE` 里的名字,外加 `quota`、`other`。房间拒了某个操作(`ErrorMsg`)的话跟游戏规则走,不在这里。
139
+ - **假平台钱包** `arcade-kit/testing`:只给游戏的 workerd 测试、冒烟和本地 `wrangler dev` 用,不进线上代码(它依赖 `cloudflare:workers`,也不能进浏览器包)。
140
+ - 复刻的范围:平台钱包的接口形状和关键规则,即带入、结回、放弃、只结一次、余额够这次才能带、操作 id 去重、按 `ref` 分份、到期退回、client 每天净赢上限、小结 `stats` 的上限、补给(5.9、5.10、15.9)。
141
+ 计量、熔断档、流水、战绩怎么存这些不复刻。
142
+ - 规则数取平台默认值(`NEW_BALANCE`、`RELIEF_TO`、`WIN_UNITS`、`TIERS`)。
143
+ - 测试辅助:`FakeBank` 的 `calls`、`writes` 数调了几次、写了几次;`stats` 记结回带的小结;`setBalance` 造余额。
144
+ - **平台改了钱包接口,平台 PR 的描述里写"影响 kit/testing",由维护者安排 kit 跟上**(一个 PR 只改一个模块)。
145
+ - 接法:测试 Worker 的主模块 `export { FakeBank, WalletService } from 'arcade-kit/testing'`,wrangler 配置加 DO 绑定 `FAKE_BANK`(`FakeBank`)。
146
+ `WALLET` 绑回这个测试 Worker 自己:
147
+ ```jsonc
148
+ "services": [{ "binding": "WALLET", "service": "<测试 Worker 名>", "entrypoint": "WalletService", "props": { "game": "<游戏 id>" } }]
149
+ ```
150
+ 本地的假平台(假的 `ARCADE`)里先 `const r = await walletHttp(request, env); if (r) return r;` 接住 `/api/wallet*`。
151
+
152
+ **游戏从自己那份迁过来**:每个游戏一个 PR,升到 kit 0.8.0(`npm install` 刷新 lockfile)。
153
+
154
+ 1. 假钱包:删掉自己的 `test/fake-wallet.ts`,用到的地方改成从 `arcade-kit/testing` 导入(类名、绑定名和原来一样)。和旧写法的差别:
155
+ - 不维护游戏名单,只核对 `props.game` 和参数一致;
156
+ - 同一个游戏按 `ref` 可以同时有好几份;
157
+ - client 每天净赢上限是 `WIN_UNITS`(10 份,平台 0.10.2),原来写死 3 份的测试数字要跟着改;
158
+ - 补给看余额加桌上的,桌上有筹码也能领;
159
+ - `FakeBank.stats` 每条多了 `game`,只记结回成功的;
160
+ - `walletHttp` 的回话多了 `winUnits`。
161
+ 2. 登录闸门:首页的 `home()` 换成 `loginGate(request, env, { downPage: 自己的 503 页 })`,删掉自己的 `reply` / `json` / `readJson`,改用 kit 的。
162
+ 单机带入结回的转发各游戏参数不同,不搬。
163
+ 3. 页面:开房换成 `createRoom('rooms', opts)`,开房失败和断开的话换成 `createText` / `closeText` 加自己的 `overrides`。
164
+ 原来说法和默认不同、又想保留的,写进 `overrides`。游戏专属的错误码仍在游戏自己的文案里。
165
+ 4. 跑模块的 `npm test`、构建和登录、开房相关的冒烟,行为不变。
166
+
167
+ ## 可选接口(0.3.0 起)
168
+
169
+ 都是 `RoomGame` 上的可选纯函数(加一个房间类上的 `perform`),不写就和 0.2.0 一样;kit 里不写死任何一个游戏。完整例子是 `test/ledger.ts`("记账桌")。
170
+ 类型上 `RoomGame<S, A, V, O, E>`、`GameRoom<S, A, V, Env, O, E>` 多了两个带默认值的参数:`O` 开局选项、`E` 对外的事(`extends RoomEffect`)。
171
+
172
+ - **开局选项** `options(raw)`、`seatCount(opts)`:房主 `start` 时带 `opts`,游戏校验(不合格返回 null,房间回 `bad_options`);`seatCount` 是这一局用几个座位,
173
+ 只给前面这些座位补电脑,后面的一直空着、开打后谁也坐不进去,有真人坐在后面时回 `seat_closed`。`begin(entropy, rv, setup)` 的 `setup` 带上 `opts` 和每个座位坐的是谁(`seats: SeatKind[]`),
174
+ 游戏自己放进状态。
175
+ - **开房就定选项**(0.4.0,推荐):游戏 Worker 从开房请求里取出房主选的选项,`acceptCreate(request, env, { ...gate, options })` 交给房间。
176
+ 房间开房时就用 `options` 校验(不合格回 400 `bad_options`,不占名额、不建房间),校验过的存进房间、随 `RoomInfo.opts` 发给每个进房的人,
177
+ 进房的人开打前就知道这一局怎么打(比如要从钱包带多少、几个人一桌)。规则:
178
+ - **定下以后不能改**:开打时以开房的为准,`start` 带的 `opts` 不看(合格的不换、不合格的也不拒)。要换就关掉重开;
179
+ - 等人时坐到 `seatCount` 以外的座位直接回 `seat_closed`(不用等房主开打时才被拒);
180
+ - 选项是房主选的、谁都能看,**不能放隐藏信息**;开房仍是那一步(建表 + 状态 + 闹钟),选项和状态在同一行里;
181
+ - 不带 `options`(或游戏没写 `options`)的房间和 0.3.x 完全一样:没有 `RoomInfo.opts`,开打时由 `start` 定。
182
+ - **座位换人** `seated(state, seat, 'human' | 'ai')`:开打后有人坐下(接管电脑、或者主人回到自己的座位)、离座交给电脑时调,和那一步一起写 1 行(不额外写)。
183
+ 托管不算换人、不调。房间不把 userId 交给规则,主人回来和新人接管都是 `human`,游戏按自己的状态分(`test/ledger.ts` 用"这个座位的筹码是不是真人买入的")。
184
+ - **对外的事** `effects(state)`、`settled(state, id, result)`、`closing(state)`,房间类实现 `perform(effect, userId)`:
185
+ - 游戏在状态里记着要去外面办的事(比如从玩家的钱包扣买入、把筹码结回去),每笔 `{ id, seat, ... }`,`id` 一局里唯一;
186
+ - 每一步之后房间把没在办的交给 `perform`(在 entry 之外 await,不打断"一步之内没有 await"),`userId` 是这个座位绑定的人;办完一步写回(`settled`,1 行,需要时再设闹钟);
187
+ - **同一笔可能办两次**(断线、被驱逐、写回前出错),办事的一方必须按 `id` 去重;
188
+ - 失败(`perform` 抛错)记下退避时间,用那唯一的闹钟重试:15 秒起、每次翻倍、最多 10 分钟;没到点不重试,不会每一步都去碰一次;
189
+ - 关房(寿命、没人、入站用完、额度满):先调 `closing` 让游戏记下收场要办的(比如把所有人桌上的筹码结回去)。还有没办完的,房间断开所有人(4000),
190
+ 留着存储把它们办完再删;正在关的房间进不来(4000)。最多等 `limits.closingMs`(默认 1 小时),到了还办不完就记日志照样删,办事的一方要有自己的兜底(比如到期退回);
191
+ - 写入:写回 1 行;失败记退避 ≤ 2 行;进入"正在关" 2 行(状态 + 闹钟);办完删掉 1 行。每步仍 ≤ 2 行。
192
+ - **不轮流的操作** `free(action)`:返回 true 的操作入座的人什么时候都能发,不看轮没轮到、不判 stale;该谁出手没变就不改这一手的期限和 `turnSeq`(轮到的人之前看到的 seq 照样有效)。
193
+ **别人看不出来的就不广播**(0.7.1):做完这一步、电脑也没接着出,比一比除出手人以外每个座位和没入座的人的视图、房间信息,都和做之前一样,
194
+ 就照常存(≤ 2 行)、只回给出手的连接(`StateMsg.quiet: true`,带 ack),**seq 不加**。不然哪怕广播一条内容一样的、或者 seq 跳一下,
195
+ 别人都知道"有人做了点什么"——比如麻将的声明窗口里,没选择的人不用答,有人答了就等于说他能碰、杠、胡。RoomClient 0.7.1 起认这种消息,
196
+ 旧的 RoomClient 当重复丢掉(只是出手人自己的画面等下一条消息才更新)。比较按每步重算视图,游戏的 view 要便宜(德州、掼蛋、麻将都是几十微秒)。
197
+ 照样过 `parse`、`apply`,照样占令牌桶和入站额度。适合离桌、暂离回来;只冒个气泡的用下面的快捷短语(不写存储)。
198
+ - **等外面的事** `waiting(state)`(0.3.2):牌局没打完、这一刻谁也不用出手(比如德州能打的人不够两个,等有人坐下、补码、回来,或者买入到账)时返回 true,
199
+ `actors` 可以是空的:房间不按出错处理,不计时、不驱动电脑、一行不写,牌局再变时重新看该谁出手。不写的话名单空了照旧记日志、按打完处理。
200
+ - **停一会儿再接着来** `pause(state)`、`resume(state)`(0.5.0):比如一手打完固定停几秒让大家看清结果,再自动发下一手。
201
+ 不能等所有人点(有人走开就卡住),也不能指给某个座位计时(电脑、托管的座位会被房间立刻代出,停不住),所以交给房间停:
202
+ - `pause` 返回要停的毫秒数(正的有限数),不在停就返回 null。**只在没人能出手(`actors` / `turn` 为空)时才问它,优先于 `waiting`**;
203
+ - **计时从进入停顿那一刻算**:电脑连着出完进来的,从客户端播放完(最后一手电脑的播放延迟之后)算;开局就是停顿的从开局算;
204
+ 从 `waiting` 变成停顿的从变的那一刻算。停顿期间不轮流的操作、座位换人、对外的事办完照常做,**不重新计时**;轮流的操作回 `not_your_turn`;
205
+ - 到点由房间用**唯一的那个闹钟**调 `resume` 做一步(比如发下一手),和这一步一起**最多写 2 行**,之后电脑照常接着出、帧照常广播;
206
+ - `resume` 返回的状态不能还在停(要连着停两段就在一次 `pause` 里给总时长)。它抛错或者返回的还在停:记日志、不用它的结果、过 60 秒再试,不会在当前时刻反复响;
207
+ - 页面看 **`room.pause.until`**(截止时间戳,毫秒)画倒计时,这时 `room.turn` 是 null;不在停时没有这个字段;
208
+ - 写了 `pause` 就必须写 `resume`:只写 `pause` 的当作没有停顿(记一次日志),和 0.4.0 一样。都不写的游戏行为完全不变。
209
+ - **快捷短语** `emotes`(0.6.0):游戏给一个正整数(短语表有几句),入座的人就能发 `0` 到 `emotes - 1` 的编号,房间转发给所有人。
210
+ **只认编号、不收自由文本**:短语的文字、图标由游戏自己在页面里定,房间不知道内容,不用审、也没人能发骂人的话。
211
+ - 浏览器:`client.emote(id)` 发;房间转发的 `{ t: 'emote', seat, id }`(`EmotedMsg`)交给 `RoomClientOptions.onEmote(seat, id)`,**不进 `onMessage`**
212
+ (不在 `DownMsg` 里,不用短语的游戏一行不用改)。`seat` 是房间的座位号,游戏按自己的视角换算;发的人自己也会收到,收到再冒气泡。没连上时发的直接丢,不存着重发;
213
+ - 房间:**0 行写入、不改 seq、不进状态**,断线时错过的不补发。入站照常计数(令牌桶、这个人的那一份、房间一生的 `maxInbound`),随下一次写库一起存;
214
+ - **限速用户感觉不到**(0.7.0):每个座位一个令牌桶,**每 `emoteMs`(默认 1000 毫秒)补 1 个、最多攒 `emoteBurst`(默认 3)个**,连扔三下没问题。
215
+ 两个值在游戏 `wrangler.jsonc` 的 `vars.KIT_QUOTA` 里改(`{ "emoteMs": 1000, "emoteBurst": 3 }`,`emoteMs: 0` 不限速),不用发版;没写就用 `RoomLimits` 里的默认。
216
+ 进房的快照(`welcome.emote`)带上这个节奏,`RoomClient.emote` 在本地按同样的节奏发:太快的排到下一个令牌,队列只留最后 2 个,多的丢掉,**界面上永远不提示"太快了"**;
217
+ 房间偶尔回的 `emote_too_fast`(刚重连、节奏刚调过)由 `RoomClient` 静默丢掉,不交给页面。
218
+ 账单:入站 WebSocket 消息按 20 条算 1 个请求、出站不计费,6 个人每秒一下连着扔一小时约 1,000 个请求;
219
+ - **指定对象**(0.7.0):`client.emote(id, to)`,`to` 是房间的座位号(扔道具、点名嘲讽、催促炸弹)。得是有人(真人或电脑)的别的座位,不然回 `bad_target`,不占令牌;
220
+ 转发时带上 `to`,`onEmote(seat, id, to)`。不带 `to` 和 0.6.0 完全一样;
221
+ - **编号空间**(0.7.0):短语、表情、道具共用 emote 的 id,不另开消息类型。每个游戏自己定编号表(比如 0–14 短语、15 起表情、再往后道具),
222
+ `RoomGame.emotes` 是总数;房间不区分种类,只校验 id 范围和 `to`。表情、道具按"游戏的起始编号 + 下标"映射到 `arcade-kit/fun` 的 `STICKERS`、`PROPS`(顺序固定、只往后加);
223
+ - 被拒回 `error`(不带 moveId,页面可以不提示):`no_emotes` 游戏没写 `emotes`;`bad_emote` 编号不是 0 到 `emotes - 1` 的整数;
224
+ `not_seated` 没入座(来看的人不能发,免得旁观的刷屏);`room_over` 房间打完了;`emote_too_fast` 这个座位没令牌了;`bad_target` 对象不对。
225
+ - **电脑也会互动**(0.10.0):房间子类覆盖 `protected onStep(state)`(每一步广播完调一次,同步、不能 await、不写存储,抛错只记日志),
226
+ 在里面看这一步的公开事件,需要时调 `protected botEmote(seat, id, to?): boolean` 让电脑座位发一句:
227
+ ```ts
228
+ class Room extends GameRoom<S, A, V, Env> {
229
+ protected readonly game = rules;
230
+ protected override onStep(state: S): void {
231
+ const e = lastPublicEvent(state); // 游戏自己的:谁出了什么、谁赢了一把(只取公开的)
232
+ if (e?.bigWin) this.botEmote(e.seat, CHEER, e.loser);
233
+ }
234
+ }
235
+ ```
236
+ 只有电脑在出手的座位能发(`kind` 是 `ai`,或真人掉线后电脑托管 `auto`);真人座位、空位、编号或 `to` 不对、房间打完了、这个座位没令牌了都回 `false`、不广播。
237
+ 广播的消息和真人的一样(`{ t: 'emote', seat, id, to? }`),多带 `bot: true`(`onEmote` 的第 4 个参数),页面一般不用区分。
238
+ 和真人共用同一个座位的令牌桶(`emoteMs` / `emoteBurst`),不写存储、不改 seq。电脑短语即时到达,不跟着 `frames` 的播放延迟走:想让它落在某一帧之后,游戏在页面上自己按帧排队。
239
+ **公平约束(游戏侧守,kit 只管广播)**:电脑发不发、发什么只看公开信息(出过的牌、结算结果),不读隐藏的牌,也不读任何真人的短语——
240
+ kit 本来就不把真人短语交给规则和 `onStep`,游戏也不要自己记下来喂给电脑。电脑出牌的决策(`ai`)照旧不受短语影响。
241
+ - **电脑回应真人的短语**(0.10.1):覆盖 `protected onEmote(from, id, to?)`,真人发的短语校验通过、广播完之后调一次,**只许在里面调 `botEmote`**:
242
+ ```ts
243
+ protected override onEmote(from: number, id: number, to?: number): void {
244
+ if (to !== undefined && id === TAUNT) this.botEmote(to, TALK_BACK, from); // 被点名的电脑回嘴(to 是真人座位时 botEmote 回 false,不发)
245
+ }
246
+ ```
247
+ 故意不给牌局状态;同步、不能 await、不能写存储,抛错只记日志。电脑的 `botEmote` 不会再触发它(免得电脑互相回个没完),
248
+ 回应照样占那个电脑座位的令牌桶。**不许把真人短语记进状态、字段或存储**,电脑的出牌决策绝不能读到它。
249
+ - **好玩素材** `arcade-kit/fun`(0.7.0,浏览器用,纯 DOM + SVG + Web Animations,不依赖渲染库):
250
+ - **图层**:`createFunLayer({ zIndex?, parent? })` 往 `document.body` 挂一个 `position: fixed; inset: 0; pointer-events: none` 的层(class `arcade-fun`,默认 z-index 50),
251
+ 点击全部穿透。要比牌桌高、比游戏的弹窗低:**游戏的弹窗、确认框的 z-index 要高于它**。界面在自己的层叠上下文里的(`position: fixed` 的容器、带 transform 缩放的舞台),
252
+ 光靠 z-index 夹不到中间,这时**打开弹窗前 `layer.hide()`、关掉后 `layer.show()`**(`visibility: hidden`,正在播的动画不中断)。离开牌桌 `layer.destroy()`;
253
+ - **坐标**:起点、终点都传**屏幕坐标的矩形**(`getBoundingClientRect()` 的 DOMRect,或任何有 `left/top/width/height` 的对象),kit 自己换算到图层里;
254
+ - **表情包** `showSticker(layer, rect, id)`:12 个(`STICKERS`:得意、狗头、大哭、流汗、震惊、坏笑、比心、生气、睡着、鼓掌、笑哭、墨镜),冒在头像右上方,
255
+ 弹出来做 1 秒内的小动作,共约 2 秒淡出;
256
+ - **互动道具** `throwAt(layer, from, to, prop, { sound?, onHit?, target? })`:8 种(`PROPS`:番茄、鸡蛋、臭鸡蛋、拖鞋、炸弹、玫瑰、点赞、干杯)。
257
+ 从 from 沿抛物线飞到 to,边飞边转,带两层残影,0.7 秒;命中:番茄爆开汁水四溅留果肉,鸡蛋蛋壳碎开、蛋清蛋黄糊上去往下流(臭鸡蛋再飘几缕绿色臭气),
258
+ 拖鞋拍扁弹开冒星星,炸弹引信闪三下再爆、冒烟、头像熏黑一下,玫瑰落下花瓣飘,点赞弹出带星星,干杯两杯碰一下冒泡沫。
259
+ 污渍在头像上留约 1.7 秒再淡出,同一个人(`target`,游戏传座位号最稳)最多叠 3 层;
260
+ - **头像的受击反应**由游戏做(头像在游戏的画布或 DOM 里):命中时 kit 调 `onHit(prop)`,游戏让头像抖一下、缩一下;
261
+ - **音效**:`throwAt` 在起飞、炸弹引信、命中时调 `sound(prop, phase)`,游戏判断静音后用 `playFunSound(自己的 AudioContext, 输出节点, prop, phase)` 放(WebAudio 现场合成);
262
+ - **性能**:只动 `transform`、`opacity`(合成层完成,不触发重排,不读布局),图层 `contain: strict`;同一个图层同时最多 `MAX_PLAYING`(4)个效果,多的直接丢掉(返回 false);
263
+ - **素材**:全部是 kit 自己手写的 SVG(`src/fun-art.ts`,viewBox 0 0 100 100),不用 emoji 字体(有的设备显示成方框)、不引外部图片和字体,许可同 kit 本身;
264
+ - **演示页**:`demo/index.html`,在 kit 目录里用任意能跑 TS 的开发服务器打开(如 `npx vite`,再访问 `/demo/`),每种道具、每个表情都有按钮。
265
+ - **房间以外的 DO 用计量**
266
+ - **房间以外的 DO 用计量** `meterReport(env, writes)`、`meterLevel(env)`(`arcade-kit/meter`):比如游戏自己的钱包 DO,每次写完报一次、写之前问一下门槛(`stop` 就别写了)。
267
+ 计量每收一次报数自己写 1 行,所以只适合写得少的地方;没绑 `METER` 时回 `ok`、什么都不做,连不上时抛错。
268
+
269
+ ## 房间怎么守规矩
270
+
271
+ - **只用 `ctx.acceptWebSocket`、一个闹钟**:所有到期时间(房间寿命 3 小时、没人 10 分钟、这一手 30 秒、掉线宽限 60 秒、电脑没算完、停够了(0.5.0))取最早的;
272
+ 只有比现在设的更早才 `setAlarm`(它也算一行写入),期限往后推时不动,到点醒来没到期再改。心跳 `ping`/`pong` 由运行时自动应答。
273
+ 闹钟时间以 `getAlarm()` 读到的为准;写完状态后广播出错,算好的闹钟照样设;闹钟里游戏代码出错,60 秒后再醒来重试。
274
+ - **每步最多写 2 行**:房间状态一行(到期时间、计数都在里面),外加需要时的 `setAlarm`。实测:
275
+ - 开房 4 行:建表 2 行(SQLite 记建表也算写入,一个房间只这一次)+ 状态 1 行 + 闹钟 1 行;
276
+ - 进房、出牌 1 行;到点醒来但还没到期只改闹钟 1 行;
277
+ - **状态没变不写**:没入座的人进出、在线时又从别的设备连进来,都是 0 行、不广播。只连着没入座的人也算"没人",他们不能让房间一直活着;
278
+ - 探一个不存在的房间码:0 行,不建表,存储里什么都不留(构造函数和读路径都不碰存储)。
279
+ 测试里每个用例都断言 ≤ 2(开房除外,如实断言 4)。
280
+ - **服务端说了算**:开局的 256 位熵用 `crypto.getRandomValues` 在房间里生成、只交给 `begin` 这一次、房间不存,规则在房间里算,下发前按座位过滤;userId、房主是谁都不下发。
281
+ 0.1.0 不记回放(没有地方读它,关房时也会删);以后做终局导出时再加,带体积上限。
282
+ - **续局**:接受连接、发快照、记座位在同一段没有 `await` 的代码里;只向收到过快照的连接广播;广播在设闹钟之前。
283
+ - 断线时座位留着;轮到他时 30 秒到了按 `timeout` 代出;掉线 60 秒后电脑托管;连回来交还。
284
+ - 重连**不重置**这一手的 30 秒(不然隔 29 秒重连一次就能一直拖着);只有托管被收回、又正好只轮到他一个人时才从头算(一组人时不改整组的期限)。
285
+ - 超时代出:先用游戏的 `timeout`,被规则拒了改用 `ai`;还不行就记日志、这一手的期限推后 60 秒(不然闹钟会在当前时刻反复响、每次写一行)。
286
+ - 同一个账号再连一次,旧连接 4001 让位。重连一律发全量快照,不补发漏掉的事件。
287
+ - 客户端重发没确认的操作带原来的 `baseSeq`:牌局变过就回 `stale`,旧操作不会被套到新的一手上。有人进出不算牌局变了。
288
+ - **硬限制**(4.6):
289
+ - 单条入站消息 4 KB(1009);每连接每秒 5 条、最多攒 10 条(1008);房间一生 3,000 条入站消息;寿命 3 小时;没人 10 分钟——到了就关房间:
290
+ 断开所有人(4000)、删闹钟、删存储;
291
+ - 没入座的连接最多 4 条(4003);入座的人不占这个数,旁观的再多也挡不住座位的主人回来;
292
+ - 每人一生最多 300 次"掉线后回来"(只数会写库的那种,4029)。按 userId 记,只管他自己,别人刷不光,离座再坐下也不清零;
293
+ 这些重连同时算进房间一生的 3,000 条入站额度,所以房间一生的写入总数有上限。没入座的人进出不写库,不受这个管;
294
+ - 一个人最多用掉房间入站额度的 `maxInboundShare`(默认四成,1,200 条):超了只断开他(4030),以后也进不来,房间和别人照常;
295
+ - 入站额度用完关房时,日志里记下发得最多的那个 userId 和条数(不记消息内容),方便事后查;
296
+ - 每个入口都包一层,游戏代码抛错只记日志;闹钟里不往外抛(会自动重试 6 次,每次都计费)。
297
+ - **座位绑人**(0.1.1):开局时记下每个座位坐的是谁。开打后坐过某个座位的人只能回这个座位;没坐过的人只能接管没绑人的电脑座位,接管后绑给他;
298
+ 绑给别人的座位(主人离座了也算)谁也接管不了,电脑一直替主人打,主人回来照样能坐。一个 userId 一局只对应一个座位,所以离座、换到对手座位看牌、再回来这条路走不通;
299
+ 也不能由同伙先占掉你的原座位、把你变成"没绑座位"的人。0.1.0 存下的已开打房间读出来时,电脑座位一律算已绑(旧格式看不出谁离过座)。
300
+ - **只有房主能开局**(9.1):房主是开房的人,别人发 `start` 回 `not_host`。
301
+ - **认人**:第一版不做 Ed25519 入场票。进房时游戏 Worker 经服务绑定问一次平台 `/api/me`(只转发 Cookie 和 cf-connecting-ip,3 秒超时),
302
+ 拿不到身份就拒绝。请求里根本没有平台的会话 cookie(`__Host-sid`,名字只在 `src/internal.ts` 定义一处)就直接 4401,不问平台。现在只有一个游戏、好友小范围开房,多问的这一次不贵,也不用管签名密钥;等开房要走平台登记和配额(5.3)再换入场票。
303
+ - "没登录"(200 且 user 为 null)和"平台暂时不可用"(其他状态码、不是 JSON、缺字段、超时、没有绑定)分开:两种都拒绝(fail-closed),
304
+ 前者 4401 不重连,后者 1013 让客户端稍后重连,平台抖一下不会把正在打牌的人踢走;
305
+ - 身份只经 `acceptJoin` 拼的内部请求(主机名 `KIT_HOST`)交给房间;房间不认别的地址,游戏把浏览器的原始请求转进来会被 400 拒掉,
306
+ 浏览器自己带的 `x-kit-user` 头不会被当真。开房走 RPC,身份是参数。
307
+ - 每次进房(含被房间拒掉的)都要问平台一次:进房额度和连接上限管住了写库和广播,但管不住这次 `/api/me`,要靠平台自己的限流;换入场票后这次调用就没了。
308
+
309
+ ## 每天的额度(0.2.0)
310
+
311
+ 免费版 Durable Object 每天整个账户 10 万行写入(docs/platform-backend.md 4.7),所有游戏共用,UTC 0 点重置。每个游戏 Worker 有一个计量 `RoomMeter`
312
+ (全局只有一个实例),房间自己数写了几行报给它,开房先向它占名额:
313
+
314
+ | 门槛(`KIT_QUOTA` 里的字段) | 默认 | 到了以后 |
315
+ |---|---|---|
316
+ | `roomsPerUser` | 1 | 这个人再开房回 409 `has_room`(带开着的那个房间的码) |
317
+ | `dailyRoomsPerUser` | 6 | 这个人今天再开房回 429 `daily_limit` |
318
+ | `dailyRooms` | 300 | 全站今天不再开房,503 `busy`(最后一道闸) |
319
+ | `busyWrites` | 60,000 | 不开新房(503 `busy`)、不进新人(4503);在打的照常打完,房主、坐过的人、已经连着的人照常进 |
320
+ | `stopWrites` | 85,000 | 在打的房间走到 `breakPoint`(这一副打完)时收场:把这一步发给大家,再以 4000 + 原因 `quota` 关房;等人的、已经打完的马上关 |
321
+ | `emoteMs`(0.7.0,房间读) | 1000 | 快捷短语每个座位多少毫秒补 1 个令牌,0 不限速 |
322
+ | `emoteBurst`(0.7.0,房间读) | 3 | 快捷短语每个座位最多攒几个令牌 |
323
+
324
+ - **改门槛**:在游戏的 `wrangler.jsonc` 里写 `"vars": { "KIT_QUOTA": { "busyWrites": 50000 } }`(对象或 JSON 字符串都行),只写要改的字段,部署后生效;
325
+ 不认识的字段、不是非负整数的值记日志后忽略,用默认值。门槛按每个游戏各算各的,有几个联机游戏就要分一分(见下面"多个游戏怎么分额度")。
326
+ - **名额什么时候还**:整场打完(`over`)、房间关掉(没人、到寿命、消息总数到了、收场)时房间去还。还不上(计量连不上、房间被驱逐时出错)也不会一直卡住:
327
+ 每个名额记着"开房时间 + 房间寿命 + 1 分钟",到点自动作废。房间打完了但还开着的时候还没还上的,下次有事件(重试间隔 60 秒)接着还。
328
+ - **怎么报写入**:房间每一步都数自己写了几行(`setAlarm`、关房也算),攒够 `RoomLimits.meterBatch`(默认 200)行报一次,打完、关房时把剩下的随还名额一起报。
329
+ 计量每报一次写 1 行,200 行一报摊下来多花 0.5%;开房(这个人一行、当天一行)、还名额(同样两行)另算,也都记进当天的数。
330
+ 没报的数随房间那一行一起存,被驱逐后接着数;被驱逐前刚报过、还没存下去的那一批会再报一次,偏保守。房间跨 UTC 0 点报上来的记在新的一天。
331
+ - **计量连不上**:不开新房(503 `unavailable`)、不进新人(1013,客户端过会儿自己重试);在打的照常,报不上的攒着,60 秒后再试。
332
+ - **房间怎么知道门槛**:每次报数、还名额时计量顺便回现在的门槛;有新人来进房时房间也会问(记 30 秒,只读不写)。所以 stop 以后在打的房间最晚再写一批(≤ 200 行)加上这一副剩下的才收场,
333
+ 按同时最多二三十个房间算,8.5 万离 10 万还有余量。
334
+ - 名额只按游戏算:同一个人在两个游戏里可以各开一个房间。
335
+
336
+ ### 多个游戏怎么分额度
337
+
338
+ 各游戏的额度在自己的 `wrangler.jsonc` 的 `KIT_QUOTA` 里,总账见仓库根 `docs/platform-backend.md` 4.7。
339
+
340
+ ## 两种部署方式:平台模式和独立模式(0.10.0)
341
+
342
+ 同一份游戏代码两种部署,**由 env 决定,不由代码决定**(`guest.ts` 的 `isStandalone(env)`):
343
+
344
+ | | 平台模式(本站正式环境) | 独立模式(任何人单独部署一个游戏) |
345
+ |---|---|---|
346
+ | 条件 | 有服务绑定 `ARCADE`;或者写了 `GATE: "login"` | 没有 `ARCADE`,`GATE` 也不是 `"login"` |
347
+ | 认人 | 经 `ARCADE` 问平台 `/api/me`(`whoIs`),和 0.9 完全一样 | 游客 cookie:游戏 Worker 自己发,`userId` 是 `g:` 加 cookie 值的 SHA-256 前 128 位 |
348
+ | 首页 `loginGate` | 没登录 302 到 `/login`,平台不可用 503 | 放行,没有游客 cookie 就补发一个 |
349
+ | 开房 `acceptCreate` | 没登录 401 | 没有游客 cookie 就现发(回话带 Set-Cookie) |
350
+ | 进房 `acceptJoin` | 没登录 4401 | 没有游客 cookie 4401(进房不发 cookie,页面刷新首页即可) |
351
+ | 昵称 | 平台的 displayName | 地址上的 `?name=`,`cleanNickname` 清洗;不合格或没带是"游客"加 4 位数字 |
352
+ | 计量 `METER`、房间、协议 | 照常 | 照常(`KIT_QUOTA` 一样管额度) |
353
+
354
+ - **安全**:有 `ARCADE` 绑定时游客 cookie 一律不看(测试覆盖:本站带游客 cookie 开房 401、进房 4401、首页 302)。
355
+ `GATE: "login"` 却没绑 `ARCADE`(部署工具漏了绑定)时**不退成游客模式**,按平台不可用拒绝(fail-closed)。同源检查两种模式都照旧。
356
+ - **游客 cookie**:https 下名字是 `__Host-kit-guest`(`HttpOnly; Secure; SameSite=Lax; Path=/`,约 400 天),值是 32 字节 `crypto.getRandomValues`。
357
+ 同一个站点下的几个游戏默认共用一个游客身份(和平台的一个账号一样),想分开就传 `guest: { cookie: '<游戏 id>-guest' }`,
358
+ `loginGate`、`acceptCreate`、`acceptJoin` 三处要传同一个。http(`wrangler dev` 用局域网 IP 打开)下浏览器不收 Secure cookie,名字不带 `__Host-`、不带 Secure。
359
+ - **昵称**:NFKC 归一后只许字母(含汉字)、数字和空格 `_ - . ·`,1 到 12 个字;不能含保留词:kit 自带"电脑、机器人、游客、AI、bot……"(`RESERVED_NICKNAMES`),
360
+ 游戏把自己电脑人物的名字传进 `guest: { reserved: [...] }`。浏览器用 `withNickname(url, name)` 给开房、进房地址带上。
361
+ - **站点根路径**:独立部署到 `workers.dev` 时游戏仍挂在 `/games/<id>/`。Worker 入口最前面加
362
+ `const root = standaloneRoot(request, env, '<id>'); if (root) return root;`,独立模式下 `/` 302 到 `/games/<id>/`(平台模式回 null,不管)。
363
+ `/` 要先到 Worker:静态资源根上不能有 `index.html`,或者把 `"/"` 加进 `assets.run_worker_first`。
364
+
365
+ **独立配置的 wrangler.jsonc**(游戏仓库里放这一份;本站正式部署时由维护者的部署工具补上 `ARCADE`、`WALLET` 和 `GATE: "login"`、路由):
366
+
367
+ ```jsonc
368
+ {
369
+ "name": "<游戏 id>",
370
+ "main": "worker/main.ts",
371
+ "compatibility_date": "2026-10-01",
372
+ "workers_dev": true, // 独立部署用 <name>.<子域>.workers.dev
373
+ "assets": { "directory": "./dist-cf", "binding": "ASSETS", "run_worker_first": ["/", "/games/<id>/", "/games/<id>/rooms", "/games/<id>/rooms/*"] },
374
+ "durable_objects": { "bindings": [{ "name": "ROOM", "class_name": "Room" }, { "name": "METER", "class_name": "RoomMeter" }] },
375
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room", "RoomMeter"] }]
376
+ // 不写 services(没有 ARCADE、WALLET),不写 GATE
377
+ }
378
+ ```
379
+
380
+ **页面怎么降级**:开局时 `const p = await detectPlatform()`(问同域 `/api/me`,默认 2.5 秒超时):
381
+ - `{ platform: true, user }`:有平台,照常(`user` 为 null 是没登录);
382
+ - `{ platform: false }`:404 或不是平台的 JSON,独立部署。藏起登录、云存档、排行榜、钱包(实盘),"回大厅"改成回游戏首页(`./`);
383
+ - `{ platform: 'unknown' }`:5xx、429、断网、超时。平台可能只是抖了一下,按游戏自己的取舍(比如先按有平台、稍后再问一次)。
384
+
385
+ ## 还没做的(以后按需加)
386
+
387
+ - 人机验证;跨游戏的统一房间登记(5.3)。开房名额现在由每个游戏的计量各管各的(上面"每天的额度"),开放注册前看一眼门槛够不够紧;
388
+ - Ed25519 入场票;观战(没入座的人现在能连、只看公开信息,最多 4 条连接);
389
+ - 回放记录、终局导出;
390
+ - 用 `ctx.abort()` 强制正在打的房间换新代码;`cpu_ms` 要按各游戏的规则在房间里实测再定(4.4);
391
+
392
+ ## 本地开发
393
+
394
+ ```bash
395
+ cd packages/arcade-kit
396
+ npm ci
397
+ npm test # 类型检查 + vitest(本地 workerd 里跑真的 Durable Object、WebSocket 休眠接口和闹钟)+ 构建 dist 并检查每个入口都在、浏览器入口 Node 能直接 import
398
+ npm run build # 只构建 dist/(tsconfig.build.json)
399
+ npm pack # prepack 构建并把 exports 换成 dist 版,打出 arcade-kit-<版本>.tgz,postpack 换回来
400
+ ```
401
+
402
+ 测试用的游戏 Worker 在 `test/worker.ts`(一个最小的"比大小"规则;0.3.0 的可选接口用 `test/ledger.ts` 的"记账桌"和假银行测),平台的 `/api/me` 用假的服务绑定代替(`vitest.config.ts`),假钱包 `WALLET` 绑回测试 Worker 自己导出的 `WalletService`(`test/wrangler.jsonc`)。
403
+ 测试里的站点一律是保留的示例域名 `game.example.com`。`demo/` 是一个能在浏览器里点的小例子。
404
+
405
+ ## 部署
406
+
407
+ kit 自己不部署,也没有 Worker:发到 npm(`npm publish`,由维护者做;`publishConfig.access` 是 public),仓库内的游戏现在仍经 `"arcade-kit": "file:../../packages/arcade-kit"` 依赖源码,打包进游戏自己的 Worker 和页面。
408
+ CI 的 `pkg-arcade-kit.yml` 只跑测试;kit 改动时依赖它的游戏的流程也会跑一遍测试。用 kit 的游戏要准备这些(写在游戏自己的 `wrangler.jsonc` 里,整体部署见仓库根 README 的部署一节):
409
+
410
+ - 绑定:`ROOM`(游戏的房间 DO)、`METER`(`RoomMeter`)、`ARCADE`(到平台 Worker 的服务绑定,`/api/me` 认人走它;独立部署不绑,见上面"两种部署方式");
411
+ - 变量:`GATE`(0.10.0 起有三种意思):`"login"` 必须接平台(没绑 `ARCADE` 时 fail-closed);`"off"` 本地开发不拦首页;不写时看有没有 `ARCADE`;
412
+ - 变量:`KIT_QUOTA`(可选,改每天的额度门槛,见上面"每天的额度");`SITE_ORIGIN`(可选,0.9.0):Origin 只认这个站点。
413
+ 不设就认请求自己的 origin(游戏和平台挂在同一个站点下,浏览器从哪个站点打开、连的就是哪个),想更严的在部署时注入,再由游戏传给 `acceptCreate` / `acceptJoin` 的 `siteOrigin`;
414
+ - kit 里不写死站点地址:同站校验默认用请求的 origin,`/api/me` 按请求的 origin 拼地址、经 `ARCADE` 绑定去问。
415
+
416
+ ## 目录结构
417
+
418
+ ```
419
+ packages/arcade-kit/
420
+ ├─ src/
421
+ │ ├─ room.ts 房间基类 GameRoom(Durable Object)
422
+ │ ├─ worker.ts 开房 acceptCreate、进房 acceptJoin、登录闸门 loginGate、json / readJson
423
+ │ ├─ auth.ts 经平台 /api/me 认人 whoIs、按部署方式认人 identify、同站校验 sameOrigin
424
+ │ ├─ guest.ts 独立模式:游客 cookie、游客身份、昵称清洗
425
+ │ ├─ meter.ts 每天的额度计量 RoomMeter
426
+ │ ├─ game.ts 规则接口 RoomGame、snapshotBytes
427
+ │ ├─ client.ts 浏览器连接器 RoomClient、createRoom、默认文案
428
+ │ ├─ protocol.ts 消息类型、关闭码、协议版本
429
+ │ ├─ rng.ts ChaCha20 随机数
430
+ │ ├─ fun.ts、fun-art.ts 快捷短语、表情、道具的画面层
431
+ │ ├─ testing.ts 假平台钱包(只给测试和本地开发)
432
+ │ └─ internal.ts 内部地址、身份头(不在 exports 里)
433
+ ├─ scripts/manifest.mjs 打包时把 exports 换成 dist 版、换回来、检查 dist
434
+ ├─ tsconfig.build.json 构建 dist 用
435
+ ├─ test/ vitest(workerd),测试用的游戏 Worker 和 wrangler 配置
436
+ └─ demo/ 浏览器里的小例子
437
+ ```
438
+
439
+ ## 许可证
440
+
441
+ MIT,见 `LICENSE`(和仓库根的同一份)。
package/dist/auth.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 进房认人(游戏 Worker 里用):WebSocket 升级请求带着平台的登录 cookie,游戏 Worker 经服务绑定 ARCADE 问一次平台 GET /api/me,
3
+ * 和游戏登录闸门一个做法(docs/platform-backend.md 15.8):
4
+ * - 请求里没有平台的会话 cookie(SESSION_COOKIE)就直接算"没登录",不问平台:没登录的脚本刷进房地址,不占平台的计量(0.1.1);
5
+ * - 只转发 Cookie 和 cf-connecting-ip 两个头,3 秒超时;
6
+ * - 200 且 user 不为 null 才认,拿 user.id 和昵称交给房间;200 且 user 为 null 是"没登录";
7
+ * - 其他状态码、不是 JSON、缺字段、超时、绑定不存在都是"平台暂时不可用":同样拒绝(fail-closed),但和"没登录"分开,
8
+ * 进房时回 1013(稍后再试,客户端会重连),不回 4401(不重连),平台抖一下不至于把正在打牌的人踢走;
9
+ * - 地址按请求自己的 origin 拼(`<请求的 origin>/api/me`,0.9.0):游戏和平台挂在同一个站点下,平台按主机名认是不是本站;
10
+ * kit 里不写死站点地址,测试或特殊部署可以用 meUrl 覆盖;
11
+ * - 平台续期或清掉失效会话时回的 Set-Cookie 原样带回来(setCookie),游戏的登录闸门、自己的接口在放行和"没登录"时转给浏览器,
12
+ * 只玩游戏、不回平台页的人 cookie 也能续上(15.8)。进房用不上,不转。
13
+ *
14
+ * 0.10.0 起有独立模式(guest.ts):游戏不带平台单独部署时认游客,按部署方式选哪种认法的是 identify;whoIs 本身不变,只问平台。
15
+ *
16
+ * 0.3.1 起作为 `arcade-kit/auth` 导出:游戏自己的接口用同一份,不再各抄一份;登录闸门 0.8.0 起直接用 `arcade-kit/worker` 的 `loginGate`。
17
+ *
18
+ * 第一版不做 Ed25519 入场票(5.3):好友房都是熟人小范围开房,进房时多问一次 /api/me(算平台 1 次调用,带 cookie 时读 D1 一两行)
19
+ * 换来不用管签名密钥、不用改平台;等开房要走平台登记和配额、或者进房量大到这一次调用显得贵了,再换成入场票,房间这边只是换一种认人的方式。
20
+ */
21
+ import { type GuestOptions, type ModeEnv } from './guest.js';
22
+ import { type Identity } from './internal.js';
23
+ /** 平台"我是谁"接口的路径;完整地址按请求的 origin 拼(meUrlOf) */
24
+ export declare const ME_PATH = "/api/me";
25
+ /** 这个请求所在站点的 /api/me 地址 */
26
+ export declare function meUrlOf(request: Request): string;
27
+ export interface WhoIsOptions {
28
+ /** 默认是请求所在站点的 /api/me(meUrlOf) */
29
+ meUrl?: string;
30
+ timeoutMs?: number;
31
+ }
32
+ export type WhoIs = {
33
+ user: Identity;
34
+ setCookie: string[];
35
+ } | {
36
+ error: 'anonymous';
37
+ setCookie: string[];
38
+ } | {
39
+ error: 'unavailable';
40
+ };
41
+ export type { Identity };
42
+ /** 认人。认不出来时分"没登录"(anonymous)和"平台暂时不可用"(unavailable),两种调用方都要拒绝。 */
43
+ export declare function whoIs(request: Request, arcade: Fetcher | undefined, opts?: WhoIsOptions): Promise<WhoIs>;
44
+ export interface IdentifyOptions extends WhoIsOptions {
45
+ /** 独立模式的游客设置(cookie 名、游戏的保留昵称) */
46
+ guest?: GuestOptions;
47
+ /** 独立模式下没有游客 cookie 时现发一个(放进 setCookie,调用方要转给浏览器);不给就算"没登录" */
48
+ mint?: boolean;
49
+ /** 独立模式下浏览器带来的昵称(没清洗过的原样),不合格就用默认的"游客1234";平台模式不看 */
50
+ nickname?: string | null;
51
+ }
52
+ /**
53
+ * 按部署方式认人(0.10.0):
54
+ * - 平台模式(有 ARCADE 绑定,或者 GATE=login):就是 whoIs,游客 cookie 一律不看;GATE=login 却没绑 ARCADE 时 whoIs 回 unavailable(fail-closed);
55
+ * - 独立模式(guest.ts 的 isStandalone):认游客 cookie;没有时 mint 为 true 就现发一个,否则算 anonymous。
56
+ */
57
+ export declare function identify(request: Request, env: ModeEnv & {
58
+ ARCADE?: Fetcher;
59
+ }, opts?: IdentifyOptions): Promise<WhoIs>;
60
+ /**
61
+ * WebSocket 不受同源策略管,别的网站的页面也能往这里连(浏览器会带上能带的 cookie):升级请求的 Origin 必须是本站。
62
+ * 没带 Origin 的(不是浏览器)也拒绝。
63
+ * 本站默认就是请求自己的 origin(0.9.0:kit 不写死站点地址);部署时想更严,可以传 siteOrigin(比如由 SITE_ORIGIN 变量注入)
64
+ */
65
+ export declare function sameOrigin(request: Request, siteOrigin?: string): boolean;
66
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAgC,KAAK,YAAY,EAAiC,KAAK,OAAO,EAAmB,MAAM,YAAY,CAAC;AAC3I,OAAO,EAAoB,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAEhE,8CAA8C;AAC9C,eAAO,MAAM,OAAO,YAAY,CAAC;AAEjC,2BAA2B;AAC3B,wBAAgB,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAEhD;AAED,MAAM,WAAW,YAAY;IAC3B,kCAAkC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,KAAK,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,aAAa,CAAA;CAAE,CAAC;AAIrI,YAAY,EAAE,QAAQ,EAAE,CAAC;AAEzB,kEAAkE;AAClE,wBAAsB,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,SAAS,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,KAAK,CAAC,CAsBlH;AAED,MAAM,WAAW,eAAgB,SAAQ,YAAY;IACnD,kCAAkC;IAClC,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,+DAA+D;IAC/D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,GAAG;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAAE,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,KAAK,CAAC,CAQhI;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAEzE"}