# 联机房间接入指南（WebStoreHouse 多人实时通道 + 官方 SDK mkroom-1.js）

托管在画廊里的任何项目都能让多个玩家（或多个浏览器/设备）进同一个房间实时互动：广播与点对点消息、
房间共享状态、房主机制、断线重连、WebRTC 信令。**登录可选**——未登录也能以匿名访客身份参与，
登录用户会带上账号名，房主可以要求「仅登录用户」。服务端只做通道，不解析消息、不做游戏规则。

真相源：`~/.claude/skills/publish-web/room-api.md`；公开副本 `/sdk/room-api.md`（改完必须 `cp` 过去）。
自测台：`/sdk/roomdemo.html`（开两个标签页即可验证全部功能）。类型：`/sdk/mkroom.d.ts`。

## 30 秒接入

```html
<script src="/sdk/mkroom-1.js?v=dev"></script>   <!-- ?v= 由 publish.py 发布时注入时间戳 -->
<script>
const room = MKRoom.create({ slug: 'mygame' });   // slug 与 mksync 一致：小写字母数字-

room.on('join',    p => addPlayer(p));            // 有人进来（p.id / p.name / p.user / p.online）
room.on('leave',   (p, reason) => removePlayer(p));
room.on('message', (data, from) => handle(data, from));   // 别人 send() 的内容
room.on('state',   (state, set, by) => render(state));    // 共享状态变化（含自己改的）
room.on('host',    (id, isMe) => { if (isMe) becomeAuthority(); });
room.on('closed',  reason => showLobby(reason));  // 整房关闭 / 被踢 / 断线太久席位丢失

// 建房：拿到 6 位房间码给朋友；或 room.join('AB3K9Q') 加入
const { code } = await room.create({ max: 4, state: { phase: 'lobby' } });

room.send({ t: 'move', x, y });                    // 广播给房内其他人
room.send({ t: 'input', key }, 'host');            // 只发给房主（自己是房主时本地回环）
room.send({ t: 'whisper' }, peerId);               // 点对点；也可传 id 数组
await room.setState({ phase: 'playing', turn: room.id });   // 共享状态：浅合并，全员按同一顺序收到
</script>
```

所有入参与事件都不依赖登录；要显示「登录后用账号名」入口，用 `location.href = room.loginUrl()`
（放在用户点击里，项目跑在 iframe 内，非手势跳转会被拦）。

## MKRoom.create(options) 速查

| 选项 | 默认 | 说明 |
|---|---|---|
| `slug` | 必填 | 小写字母数字-，≤23 位，与 mksync 同名即可 |
| `ns` | 自动 | 命名空间：线上从 `/p/<id>/` 推断，本地开发为 `<slug>-devlocal`。**不同项目的房间互不可见** |
| `name` | 账号名 / 玩家 | 昵称，房内自动去重（同名追加 (2)）；随时 `room.rename()` |
| `server` | 同源 | 指定房间服务地址。注意服务端校验 `Origin`，**跨源页面连不上**（见「本地开发」）；`file://` 无法连接。传了它就不走直连通道 |
| `reconnect` | `true` | 断线自动重连并恢复席位（见「断线与席位」） |
| `debug` | `false` | 把静默失败打到 `console.warn` |

## 实例 API

**进出**

| 方法 | 说明 |
|---|---|
| `create(opts?)` → `{code, room, self, host, peers, state}` | 建房并成为房主。`opts`：`max`(2..16，默认 8) `name` `code`(自定义房间码 2~24 位字母数字与 -) `authOnly` `public` `stateBy`('any'\|'host') `state`(初始共享状态) |
| `join(code, {create?})` → 同上 | 房间码不分大小写。`create: true` 或 `create: {…opts}`：房间不存在就以该码建一个——**固定大厅**写法：`room.join('lobby', { create: { public: true, max: 16 } })` |
| `list()` → `[{code,name,count,max,authOnly}]` | 本项目的公开房间（`public: true`），最多 50 条 |
| `leave()` | 离开当前房间，连接保留可再进 |
| `close()` | 房主：整房关闭，全员收到 `closed('closed')` |
| `disconnect()` | 断开连接、不重连（页面退出时不必调，浏览器会关） |

**通信**

| 方法 | 说明 |
|---|---|
| `send(data, to?)` → `boolean` | `data` 任意可 JSON 的值。`to` 缺省/`'*'` 广播给其他人；`'host'` 只发房主（自己是房主则本地回环，事件路径与非房主完全一致）；某个 `id` 或 `id[]` 点对点。**服务端不解析、不回执**；不在线的目标静默丢弃 |
| `setState(set)` → `Promise<v>` | 浅合并进房间共享状态，值为 `null` 删键；全员（含自己）收到同一顺序的 `state` 事件；`stateBy:'host'` 房间里非房主会得到 `forbidden` |
| `signal(to, data)` | WebRTC 信令透传（offer/answer/ice），`room.iceServers` 提供 STUN 列表；对方不在线触发 `error(peer_gone)` |
| `rename(name)` → `Promise<实际昵称>` | 改昵称，全员 `peer` 事件 |
| `kick(id)` | 房主踢人，对方收到 `closed('kicked')` |

**查询（同步属性）**

`status`（`idle | connecting | joined | reconnecting | closed`）· `code` · `room`（`{code,name,max,authOnly,public,stateBy,count}`）·
`self` · `id` · `host` · `isHost` · `peers`（不含自己）· `peer(id)`（含自己）· `state` · `stateV` ·
`latency`（往返 ms）· `serverNow()`（估算的服务器时间，做倒计时/同步起跑用）· `authed` · `user` · `iceServers` · `limits` · `ns`

**事件**（`room.on(ev, fn)` 返回解绑函数）

| 事件 | 参数 | 触发 |
|---|---|---|
| `join` / `leave` | `(peer)` / `(peer, reason)` | 成员进出；`reason`：`leave` 主动、`kicked`、`timeout` 断线宽限超时 |
| `peer` / `self` | `(peer)` | 对方 / 自己 上下线或改名 |
| `host` | `(hostId, isMe)` | 房主变更（房主离开自动移交给最早加入的在线成员） |
| `message` | `(data, from)` | 收到 `send()`；`from` 是 peer 对象（对方刚离开时只保证 `from.id`） |
| `state` | `(state, set, by)` | 共享状态变化；重连恢复后也会触发一次（`set` = 整份状态，`by` = null） |
| `signal` | `(data, from)` | 收到信令 |
| `status` | `(status)` | 连接状态变化 |
| `reconnected` | — | 断线后席位恢复成功 |
| `closed` | `(reason)` | 房间对你而言结束：`closed` 房主关闭、`kicked`、`expired` 房间存活超 24h、`lost` 断线太久席位已回收/服务器重启 |
| `latency` | `(ms)` | 每 25s 心跳一次 |
| `error` | `(err)` | 无请求上下文的服务端错误（`rate_limited` / `too_large` / `peer_gone` …） |

`create/join/setState/kick/close/rename/list` 失败时 reject 的 `err.code`：
`not_found` `full` `auth`（房间仅登录用户）`exists`（自定义码被占）`forbidden` `not_in_room` `too_large` `rate_limited` `busy`（房间数已满）`timeout` `network`。

## 语义与纪律

- **消息顺序**：同一发送者的消息按发送顺序到达；`state` 由服务端定序，所有人看到一致的版本号 `stateV`。
  不同发送者之间没有全局顺序保证——需要严格顺序的数据走 `setState` 或让房主定序。
- **权威在房主**：需要防作弊/裁决冲突时，所有人把输入 `send(…, 'host')`，房主计算后 `setState` 或广播结果。
  `room.isHost` 可能在游戏中途变成 true（原房主走了），监听 `host` 事件接管。
- **共享状态是小 JSON**（≤16KB）：放阶段、回合、比分、座位表这类"迟到者也要知道"的东西；每帧变化的位置走 `send`。
- **断线与席位**：连接断开后席位（含房主身份）保留 15 秒，期间其他人看到该成员 `online: false`，发给它的消息丢弃；
  SDK 自动重连（1s→10s 退避）并原样恢复 `id`/房主，触发 `reconnected` 与一次全量 `state`。超时则真正离开、房主移交。
  重连期间的消息不补发——游戏自己决定是重发还是从 `state` 追平。
- **服务器不持久化**：房间全在内存，服务重启即消失（客户端收到 `closed('lost')`）。持久数据走 mksync。
- **登录可选**：`peer.user` 有值即站内账号（服务端验证过的身份），`null` 是匿名访客；`authOnly` 房间拒绝匿名加入。
- **同源信任**：SDK 只能在画廊域下（或 `server` 指向它）使用；命名空间按项目隔离，但同一项目的所有房间对该项目的任何页面可见。
- **消息不落盘也不审计**：不要在消息里传敏感信息。
- **直连通道（自动，项目无需改动）**：线上 SDK 会先同源 `POST /api/room/ticket` 拿直连地址（`wss://rt.merrykinger.com`，
  不经 Cloudflare，大陆访客延迟从约 350ms 降到约 60ms）和一次性登录票据，再直连；4 秒连不上自动退回同源路径。
  登录身份、房间、命名空间与同源路径完全一致。本地开发的服务没配 `ROOM_WS_URL`，照旧走同源。

## 限额（`/api/site` 的 `room` 字段同步下发，前端 `room.limits` 可读）

| 项 | 值 |
|---|---|
| 单房人数 | 2..16（默认 8） |
| 单条消息 / 共享状态 | 16KB / 16KB |
| 每连接消息频率 | 30 条/秒（send + setState + signal 合计），超出触发 `error(rate_limited)`，消息丢弃 |
| 断线席位宽限 | 15s |
| 房间最长存活 | 24h（到期 `closed('expired')`） |
| 房间数 | 单项目 200 / 全站 2000；连接数全站 3000 |
| 建连 / 建房 | 单 IP 每分钟 60 / 30 |

高频同步（如 60fps 位置）请在客户端合并到 ≤20 条/秒，或用 `signal` 建 WebRTC DataChannel 直连后再走 P2P。

## 本地开发

- ⚠️ **跨源连不上**：服务端 WS 升级会校验 `Origin`（`lib/ws.js`），必须等于站点地址（`PUBLIC_URL`，未设时为 `协议://Host`）。
  所以从 `localhost:5173` 之类的开发服务器传 `server: 'https://web.merrykinger.com'` 会被 403，`create()` 报 `network`。
- **可行的本地联调**：起本机 WebStoreHouse，并让页面与它同源——
  `ln -s <项目构建目录> ~/projects/apple/WebStoreHouse/data/projects/<name>dev`，然后
  `env -u PUBLIC_URL npm start`（shell 里设了 `PUBLIC_URL` 时必须去掉，否则 Origin 期望值是公网域名），
  两个标签页打开 `http://localhost:4321/p/<name>dev/index.html`。命名空间就是 `<name>dev`，与线上隔离；测完删掉软链接。
- 后台标签页会被浏览器节流：两个标签页都不在前台时，消息处理会明显变慢，测到的延迟偏高，不代表真实网络。
- `file://` 直接打开无法建立 WebSocket（浏览器限制），`create()` 会抛错。
- 两个标签页就是两个玩家；`room.disconnect()` 后再 `join()` 可模拟断线重连（15s 内席位恢复）。

## 排错

| 现象 | 原因 / 做法 |
|---|---|
| `create()` 抛 `network` | 页面与房间服务不同源（Origin 校验 403，本地开发见上文）；或 nginx 没给 `/api/room/ws` 开 WS 反代 |
| `join` 得到 `not_found` | 房间码错、房主已关、或**对方项目不是同一个命名空间**（本地 devlocal 连不上线上房间，反之亦然） |
| 一直 `reconnecting` | 网络断了；SDK 会一直退避重试，15s 内恢复即无感，超时收到 `closed('lost')` |
| 收不到自己的 `message` | 设计如此：`send` 不回环（除 `to:'host'` 且自己是房主）；本地先行渲染再广播 |
| `error(rate_limited)` | 超过 30 条/秒，合并/节流 |
| `setState` reject `forbidden` | 房间 `stateBy: 'host'`，只有房主能改 |
| 延迟仍有 300ms+ | 看 DevTools Network 的 WS 地址：是 `wss://rt.…` 才是直连；是主域名说明直连在该网络下不通，已退回经 CF 的路径（`debug: true` 会打出原因） |

## 底层协议（不用 SDK 的项目 & 排错用）

`wss://<host>/api/room/ws?ns=<命名空间>&n=<昵称>`，JSON 文本帧，字段 `t` 为类型；带 `rid` 的请求会收到同 `rid` 的回复
（`joined` / `rooms` / `left` / `ok` / `error`）。带 `mk_token` cookie 即登录身份，否则匿名。
直连：同源 `POST /api/room/ticket` → `{ws, ticket}`（`ws` 为 null 表示站点没配直连；匿名时 `ticket` 为 null），
连 `<ws>?ns=…&n=…&tk=<ticket>`；票据一次性、60 秒有效，无效则握手 401。

| 客户端 → 服务端 | 服务端 → 客户端 |
|---|---|
| `create {rid, room:{max,name,code,authOnly,public,stateBy,state}, name}` | `joined {rid, room, sid, self, host, peers, state, stateV, iceServers, serverTime}` |
| `join {rid, code, sid?, create?, name?}` | 同上；`sid` 用于断线后恢复席位 |
| `leave {rid}` / `close {rid}` / `kick {rid, id}` / `rename {rid, name}` | `left {rid}` / `ok {rid}` |
| `list {rid}` | `rooms {rid, rooms:[…]}` |
| `send {data, to?}` | 目标收到 `msg {from, data}` |
| `state {rid?, set}` | 全员 `state {set, by, v}`，发起者另收 `ok {rid, v}` |
| `signal {to, data}` | 目标收到 `signal {from, data}` |
| `ping {ts}` | `pong {ts, serverTime}`（客户端须 <100s 一次，Cloudflare 空闲断连） |
| — | `welcome {user, ns, limits, serverTime}` · `peer-join {peer}` · `peer-leave {id, reason}` · `peer {peer}` · `host {id}` · `closed {reason}` · `bye`（服务器重启）· `error {code, msg, rid?}` |

管理员排错：`GET /api/room/status` → 各命名空间的房间与在线连接数。
