# 双人付费开局（可选）

免费联机不需要本能力。网页游戏通过可信宿主的 `payments.admission.prepare/ready/get/cancel` 完成双人一次性核销；联机 SDK 只传消息，不代替付款，也不自动收费。当前 Godot 绑定和旧宿主不保证支持本能力。Flutter App 的 `native-v1` 入口当前未提供 admission；网页宿主已支持不代表 App 同时支持。发布前分别检查目标入口的 hello 能力；缺少能力时应在购买、准备或开局前停止该收费联机入口，不能先收费后报不支持。

## 平台交接与能力检查

先确定商品、每局消耗数量、支持的模式与广告替代，再交平台配置。这里只支持每位玩家每局消耗 1 次消耗品；商品价格、购买确认、库存和广告资格由平台决定。示例商品名不是现成可用商品。

平台配置示意：

```json
{"realtime":{"modes":["random","invite"],"maxPlayers":2,"build":"my-game-admission-v1","disconnect":"pause_then_exit","networkMode":"P2P_2","centralRelayFallbackEnabled":false,"admission":{"version":1,"rules":[{"mode":"random","productKey":"duel_ticket","quantity":1,"funding":["inventory"]},{"mode":"invite","productKey":"duel_ticket","quantity":1,"funding":["inventory"],"metadata":{"gameMode":"duel"}}]}}}
```

邀请的 metadata 规则由中央房间资料严格匹配；不能用客户端 context 选择更低价格。一个房间必须唯一命中规则。引入本开局协议使用新 build，避免新旧客户端被匹配到一起。仅平台配置不会替游戏补写资源加载、开局或恢复逻辑。

先调用 `INeedHost.request('hello',{protocols:[1]})`，检查返回 value.capabilities 含全部四个方法。缺少任意方法就明确提示当前入口不支持该收费联机模式，不退化为两个独立 consume 或免费放行。其他已支持的单机/免费入口可以保留。

库存与购买沿[支付技能](https://ineeds.club/creator-skills/payments.html)：`payments.products` / `payments.inventory` 读取平台商品与现有库存，不在游戏中推算价格或伪造库存。恢复日志沿[存档技能](https://ineeds.club/creator-skills/storage.html)及[接口手册](https://ineeds.club/creator-skills/protocol.html)：使用游戏已有持久层，或将日志合并进账号存档，以 `storage.load` 的 version 作为 `storage.save` 的 baseVersion。保存未知结果先回读，冲突不能直接覆盖；不要为了保存联机记录抹掉原游戏进度。此能力要求在 ready 前确认日志已持久化，内存变量本身不满足刷新恢复。

## 时序与消费边界

1. 先登录并获得每局权益：读取库存，必要时经平台确认购买。购买只是增加库存。不要先调用普通 consume/buy_and_consume，随后又调用 admission，这会产生两次不同消费。
2. 进入同一匹配或中央邀请房间。邀请双方选人、换图、准备后，房主 startGame；等待大厅本身不建立 RTC。
3. 连接 RTC，安装游戏消息接收器，预载本局资源，交换并核对规则、角色、地图、随机种子和本局编号。SDK onReady 只等待本地初始化，不能在其中等对端消息；参照 [双方准备示例](../examples/ready.mjs)。
4. 双方以相同 context、previousRoundId 各自 prepare；每人使用自己固定的 requestId。PREPARED 不扣库存，有效期 120 秒。
5. 双方资源、协议和传输均可用后，各自用自己的 receiptId、共同 roundId/bindingHash 调 ready。两人都 ready 才由服务端在同一事务中核销双方。
6. 本端读到 COMMITTED、resumable:true 且 start 与当前 roundId/bindingHash 一致后，再参加游戏自己的双端开战确认。对手的 P2P 消息不能代替本人可信回执。
7. 取消或建连失败时，若已 prepare 并持有本人的 roundId/receiptId，再调用 admission.cancel 并核实结果；尚未 prepare 就没有可取消的金融轮，不能伪造 ID。中央邀请房还须按[邀请示例](../examples/invite.mjs)捕获同一房间、代际和 epoch，调用 room.cancelStart 并核实已回到 waiting。admission.cancel 不会替你取消房间的 starting 状态；旧回调不能取消新一局。已提交消费不会因任一种取消/断线变成退款。未知结果保留记录并查询，不能显示“未扣费”。

已提交后双方同时进入玩法仍是游戏自己的协议。浏览器崩溃、客户端永久离线等情况不能仅靠核销接口保证玩家已完整享受一局；需要明确恢复/客服策略，不承诺自动退款。

## 方法参数

调用形式为 `INeedHost.request('payments.admission.'+action, params)`，返回 `{ok:true,value}` 或 `{ok:false,error:{code,message}}`。params 不带 action、账号、作品 ID、价格或广告 token；可信宿主补充可信身份。

每个方法都含 `v:1`。当前房间参数为 `room:{code,clientId}`，取当前 transport/ServerRoom 的真实身份。code 为 12 位大写十六进制，clientId/requestId 为 16–80 位字母、数字、下划线或连字符。请求 ID 在同一操作重试时保持不变；可用 SDK 的安全 ID 实现，不依赖旧 iOS 缺失的 crypto.randomUUID。

| 方法 | 其他参数 | 作用 |
| --- | --- | --- |
| prepare | room、requestId、previousRoundId、context、funding、productKey（inventory 可省略，rewarded 经可信宿主时必填） | 建立本人同意，尚不消费。第一轮 previousRoundId:null；新一轮使用已结束上一轮的 roundId。funding 为 inventory 或已配置的 rewarded。 |
| ready | room、requestId、roundId、receiptId、bindingHash | 标记本人可开战；两人都满足条件才原子核销。 |
| get 当前轮 | room | 查询当前轮。不能同时带 roundId/receiptId。 |
| get 历史回执 | roundId、receiptId | 不带 room；只能查本人回执，可用于未知提交结果核对。 |
| cancel | room、requestId、roundId、receiptId | 取消尚未提交的一轮；已提交则返回既有事实。 |

roundId/receiptId 是服务端返回的 48 位小写十六进制 ID，bindingHash 是 64 位小写十六进制摘要，不要自己生成。context 为最多 16 项、JSON 最多 2048 字节的普通对象；键为 1–64 位字母数字/下划线/连字符，值为不超过 128 字符的字符串、有限数值或布尔值。两端内容必须一致，不能放各自不同的昵称、角色排序或本地时间戳。

下面只展示方法形状；persistIntent、loadResources、双方协议和 UI 属于游戏。必须先保存固定请求，再发送，不能在重试时重新生成 ID：

```js
const params = {
  v: 1, room: {code: transport.code, clientId: transport.roomClientId},
  requestId: savedPrepareRequestId,
  previousRoundId: previousRoundId,
  context: agreedContext, funding: 'inventory', productKey: 'duel_ticket',
};
const response = await INeedHost.request('payments.admission.prepare', params);
if (!response.ok) handleAdmissionError(response.error);
// 只有 ok 且 value 为当前轮 PREPARED 时，保存本人 receiptId / roundId /
// bindingHash。完成游戏准备后再用固定的 ready requestId 调 ready。
```

`get({v:1,room})` 返回 EMPTY 时才是尚无轮；若为 PREPARED，使用其 previousRoundId 加入当前轮，不把它的 roundId 当下一轮 previousRoundId。若为 COMMITTED/CANCELLED，只有玩家明确开始新一局且上一局已收口，才将该轮 roundId 用于新 prepare。迟到回复不能改变新一局。

## 状态与恢复

| state | 含义与处理 |
| --- | --- |
| EMPTY | 尚无当前轮；有 previousRoundId:null、sequence:0。 |
| PREPARED | 未消费；包含 roundId、bindingHash、context、productKey、expiresAt 等。本人已 prepare 时有 receiptId/funding、ready；peerReady 表示对方准备。viewerNotPrepared:true 时不能直接 ready。 |
| COMMITTED | 本人消费已提交，含 receipt:{id,productKey,quantity,funding,committedAt}。只有 resumable:true 且 start 匹配当前会话才能继续本局；历史回执查询始终 resumable:false，不能用旧收据开启新局。 |
| CANCELLED | 本轮未提交，charged:false，附 reason。重新开局需要明确新意图。 |
| NOT_FOUND | 本人历史消费回执未找到，不是“免费开战”凭证；结合固定请求和当前房间核对。 |

按账号、作品和局保存恢复记录：room/clientId、prepare/ready/cancel requestId、previousRoundId、context、roundId、receiptId、bindingHash、资金来源和最后可信状态。先保存再发 ready；持久化失败时停止进入提交阶段。只存恢复线索，不存账号令牌、广告凭证或把本地记录作为权益证明。账号切换后不将 A 的结果应用到 B，原记录仍归 A。

ready 超时/断流后先用相同身份 get 当前轮；房间已不可访问但有 receiptId 时查询本人历史回执。保留未知操作，有限重试、退避并向用户显示核实中；不能换 ID 再消费、偷偷开始下一局，或清掉未确认记录。COMMITTED 表示消费事实，不能回滚成未扣费。已提交但不可恢复时显示事实并按作品的恢复策略处理。

prepare/cancel 重试必须保留原参数和请求 ID。BINDING_CHANGED、STALE_ROUND、SESSION_CHANGED 等错误应重新核对房间/账号/局；不要自行更换身份“试到成功”。INSUFFICIENT_INVENTORY 仅代表明确余额拒绝，不能把网络超时当余额不足再买一次。UNSUPPORTED 停止该模式；BUSY、STORAGE_UNAVAILABLE、ACCOUNT_CHANGED 不能忽略。

## 广告和预览

rewarded 仅用于平台已允许的规则，并依赖可信宿主核验真实广告完成。prepare 必须显式传该广告对应的 productKey，供可信宿主定位授权；服务器仍会核对中央规则，不能以此改变价格或商品。普通展示广告 shown/closed、游戏计时器和 P2P 回执都不能授予资格；游戏不提交或保存 rewardedAccessToken。真实供应商完成、账号/商品绑定和重复使用拒绝需要单独验收。

预览只模拟 inventory，返回 preview:true/noCharge:true；rewarded 为 UNSUPPORTED。预览回执有容量和生命周期限制，不能证明真实钱包扣费、广告发奖或永久恢复。

## 验收最小集合

双端正常开局各消费一次；重复 ready 不多扣；一方建连/加载失败两侧均零消费；取消与第二人 ready 并发结果以可信回执为准；提交成功但回复丢失查回同一收据；当前/历史回执不能授予新局；换图/换人/退出/接管使旧准备失效；库存不足或广告拒绝不出现单侧新成功消费；同账号多页面、刷新与持久化失败正确收口。预览、真实数据库和正式广告/支付分别报告。
