# AwakePlay 游戏 SDK 2.2.0

新游戏使用 SDK 2，提供玩家公开身份、最高分提交、排行榜读取和云存档。2.2.0 增加可选的开局、结算分析，需要平台单独启用。平台负责数据服务，游戏自行设计榜单和存档选择界面。普通静态游戏无需接入 SDK 也能发布。

线上可下载版本以 `/sdk-version.json` 为准。云存档需要 SDK 2.1.0 及平台云存档开关；未启用时接口返回 `unavailable`，不能把读取失败当作空档。SDK 2.0.0 / 2.1.0 固定文件保留原字节，旧游戏需要作者主动升级接入。SDK 2.2.0 的游玩分析由平台独立开关控制；读取反馈的 `gameplay.status` 确认当前是否启用，不仅凭下载版本判断。

## 获取 SDK：不需要先创建或发布作品

- 接入指南：[https://awakeplay.com/sdk](https://awakeplay.com/sdk)
- 版本与 SHA-256 清单：[https://awakeplay.com/sdk-version.json](https://awakeplay.com/sdk-version.json)
- 当前文件：[awakeplay-sdk-2.2.0.js](https://awakeplay.com/downloads/awakeplay-sdk-2.2.0.js)

以上入口无需登录、Cookie、Agent Token 或 Cloudflare 凭据；没有官方 npm 包。先读取清单，下载其同源 `url`，核对 SHA-256 等于 `sha256`，再保存到游戏目录。清单 `version` 是浏览器 SDK 的版本，与 CLI/Skill 版本独立。下载不等于运行；不要把文件作为 Node.js 程序执行。

例如用 `curl` 下载（Windows PowerShell 使用 `curl.exe`）：

```sh
curl --fail --silent --show-error https://awakeplay.com/sdk-version.json
curl --fail --silent --show-error https://awakeplay.com/downloads/awakeplay-sdk-2.2.0.js --output awakeplay-sdk-2.2.0.js
```

用 PowerShell `Get-FileHash -Algorithm SHA256` 或系统 SHA-256 工具校验下载文件。不要跟随其他来源的重定向；哈希不一致时重新读取清单并重试，仍不一致则停止。固定版本的 SDK 字节不会原地修改。

已有作品的隔离来源也提供 `/_platform/sdk/2.2.0.js`，但无需猜作品子域名。SDK 1.0.0 只保留兼容旧作品，不用于新接入。升级旧游戏需要修改游戏并重新发布；平台更新不会替换作者上传的内置 SDK。

## 放进游戏构建产物

将下载文件随游戏一起打包，使用相对路径，且先加载 SDK、再加载调用它的游戏代码：

```html
<script src="./awakeplay-sdk-2.2.0.js" defer></script>
<script src="./game.js" defer></script>
```

Vite 等工程可将文件放在 `public/`，确认构建后仍在输出目录并正确引用。不要在已发布游戏内用 `https://awakeplay.com/downloads/...` 作为远程 script，不要用 `/_platform/sdk/...` 代替随包文件：平台发布检查要求作品资源使用相对路径，游戏 CSP 也不允许远程依赖。下载用官网地址，运行用作品自己的文件。

## 最小接入

SDK 加载后提供 `window.AwakePlay`，身份、成绩与存档方法返回 Promise；下文 `gameplay.start/end` 是同步、尽力入队的统计方法。下面是 `game.js` 中可按游戏逻辑调用的函数；完成一局时传入真实分数，打开游戏自制榜单时读取排行榜。不要在每帧或每次页面加载时自动提交测试分数。

```js
async function getPlayer() {
  return await window.AwakePlay.ready();
}

async function finishRound(finalScore, requestId = crypto.randomUUID()) {
  // 同一局网络失败后的重试沿用同一个 requestId 和 finalScore。
  return await window.AwakePlay.submitScore(finalScore, { requestId });
}

async function getLeaderboard() {
  return await window.AwakePlay.leaderboard();
}
```

在游戏事件处理器中 `try/catch` 这些调用，失败时保留游戏状态并给玩家重试入口；不把网络失败显示成保存成功。SDK 会为一次网络错误自动重试报分；再次失败时错误对象的 `requestId` 可用于沿用同一局请求。相同编号不能改成另一分数或用于另一局。

| 方法 | 返回数据与规则 |
| --- | --- |
| `ready()` | `{ id, name, avatar_id?, avatar_url? }`。`id` 仅在当前作品内识别玩家；正式入口通常带公开头像，预览可能只有 id/name，头像须允许缺省。不会返回邮箱、全局账号 ID、Cookie 或 Token。 |
| `submitScore(score, { requestId? })` | `{ request_id, score, best_score, player }`。分数须是 0–1,000,000,000 的整数；保留个人最高分。requestId 使用 `crypto.randomUUID()`。较低分不会降低最高分。 |
| `leaderboard()` | `{ entries, me, total, mode?, trusted?, preview? }`。正式榜最多返回前 50 名；每项含 `{ player, score, rank, is_me }`；`me` 是本人记录或 null，可能在前 50 名之外。同分同名次，后续名次跳号。 |

用文本节点或 `textContent` 绘制昵称；不要将昵称拼进 HTML。公开头像是可选项，不能依赖它决定玩家身份。当前 `avatar_url` 指向控制域，作品 CSP 不允许跨源图片，游戏内不要直接热链它；可按 `avatar_id` 映射游戏随包图片，或显示昵称/占位图。成绩由客户端提交，不能视为经过服务端反作弊验证的数据；SDK 不提供自定义用户认证或排行榜 UI。

## 云存档（2.1.0 起）

每个作品、每个玩家一份 JSON 对象，最大 64 KiB（UTF-8），深度不超过 24、值节点不超过 10,000。游戏管理数据格式和恢复逻辑，平台不识别关卡、道具或本机 localStorage，也不自动合并两份游戏进度。不能用存档作为可信资产或服务端反作弊依据。

| 方法 | 返回与要求 |
| --- | --- |
| `loadSave()` | `{ revision, save, conflicts }`；无存档为 `{ revision: 0, save: null, conflicts: [] }`。`save` 是 `{ schemaVersion, data, updatedAt }`，时间为 Unix 秒。 |
| `save(data, { schemaVersion, expectedRevision, requestId? })` | `{ requestId, revision, updatedAt }`。`schemaVersion` 是游戏自己的 1–2,147,483,647 整数格式版本，独立于发布版本；`expectedRevision` 必须来自最近读取/成功保存的结果，首次为 0。 |
| `resolveSave(conflictId, 'current' 或 'incoming', { expectedRevision, requestId? })` | 同保存回执。玩家明确选择保留账号当前进度或这一份游客进度；仅移除所选冲突项，其他冲突仍需处理。 |

`conflicts` 每项为 `{ id, schemaVersion, data, updatedAt, importedAt }`，最多 5 项。有冲突时普通 `save()` 返回 `save_conflict`，不能跳过选择直接覆盖。向玩家展示双方进度及更新时间，明确确认后再调用 `resolveSave`；没有通用的“更高关卡一定更好”规则。不认识的 `schemaVersion` 应保留并提示更新，不能清空重存。

同一游戏所有正式发布版本共用一份进度；发布或回退不会自动复制、回退存档，也不提供每个版本的历史快照。存档格式升级由游戏识别 `schemaVersion` 后迁移，再用读取时的 revision 保存。旧页面在作品更新后不能写入；回退后的旧游戏若不认识新格式，应提示更新并保留原档。

```js
// 游戏开始前读取一次。不要每帧保存；在检查点/关卡结束时保存。
let snapshot = await AwakePlay.loadSave();
if (snapshot.conflicts.length) {
  // 展示双方数据，由玩家选择后调用 resolveSave，再 loadSave。
  showSaveChoices(snapshot);
} else if (snapshot.save && snapshot.save.schemaVersion !== 1) {
  showUpdateRequired(); // 不覆盖不认识的格式
} else {
  restoreProgress(snapshot.save?.data ?? { level: 1 });
}

async function saveCheckpoint(progress) {
  const requestId = crypto.randomUUID();
  const data = JSON.parse(JSON.stringify(progress));
  const options = { schemaVersion: 1, expectedRevision: snapshot.revision, requestId };
  // 保存 data/options 以备断网重试，不能只复用编号却换掉内容。
  const receipt = await AwakePlay.save(data, options);
  snapshot = { revision: receipt.revision, save: { schemaVersion: 1, data, updatedAt: receipt.updatedAt }, conflicts: [] };
  return receipt;
}
```

以上 `showSaveChoices`、`showUpdateRequired`、`restoreProgress` 是游戏自行实现的界面/恢复函数。完整可运行示例在仓库 `examples/cloud-save/`；开发者可运行 `npm run dev:play` 打开终端输出的 Cloud Save 链接（临时本地数据库）。

调用者需捕获失败、保留尚未保存的状态，收到成功回执才显示“已保存”。SDK 对网络失败自动重试一次，使用同一个编号、修订号和数据快照；再次失败的 `error.requestId` 可用于原操作重试。服务端回执保留 7 天，过期后不承诺原请求成功回放，必须重新读取。`save_conflict` 时停止写入并读取，不能自动拿最新 revision 重写旧内容。重新读取替换本机未保存进度前应让玩家确认。`idempotency_conflict` 表示错误地复用了编号，不能改编号后盲目覆盖。

写入限流为每个作品/玩家每分钟 10 次、每天 500 次（含有效重试及冲突尝试），另有共享入口限制；遵循 `retryAfter`。`invalid_save` 检查 JSON、格式版本、修订号和编号；`save_too_large` 检查 UTF-8 大小/深度/节点数；`unavailable` 表示服务未启用或暂不可用，不能伪装成首次无存档。

游客可在同一浏览器恢复；登录后通过有效游客 Cookie 认领所有作品的存档。账号没有存档时接收游客存档；两边不同则保留为待选项，同格式同字节不重复保留。达到冲突上限时整笔认领暂停，本机游客记录保留，入口允许先进入账号处理冲突，之后重新打开再认领。账号换设备登录可以读取账号存档，清 Cookie 前未认领的游客进度不能保证找回。身份切换返回 `identity_changed`，重新打开后再读档。

游戏发布/回退不清空存档；旧标签页不能向新的线上版本写入。新版本需自行迁移已知旧格式并使用读取的 revision 保存；回退后的旧游戏若不识别新格式，应禁止覆盖。预览的读、写、冲突选择均返回 `preview_read_only`，不会接触正式进度。

## 游玩分析（2.2.0 起，平台单独启用）

游戏只标记真正的开局与结算。SDK 加载在可见文档中会连接分析，但不会自动创建玩家、登录、报分或保存进度；`ready()` 和 `submitScore()` 也不会替代开局或结算。

```js
function beginRound() {
  // 先执行游戏自己的开局逻辑，再记录；本地开发/未启用时可返回 null。
  return window.AwakePlay?.gameplay?.start() ?? null;
}
function finishRound(runId, won) {
  // 同步且不抛统计异常；true 只表示本地入队，不是服务器收到的凭证。
  window.AwakePlay?.gameplay?.end(runId, won ? 'success' : 'failure');
}
```

每局开始时保存 `beginRound()` 返回的编号，结算传回这一局的编号。异步回调应捕获当局编号，不要在晚到回调中读取一个已被下一局覆盖的全局变量。

| 方法 | 契约 |
| --- | --- |
| `gameplay.start()` | 同步返回本局 UUID 或 `null`。每次有效调用都是一次新开局，只在游戏真的开始/重开一局时调用；不要在每帧、加载、读档、登录、倒计时 tick 或报分重试中调用。新开局不替上一局制造结算。 |
| `gameplay.end(runId, outcome)` | `outcome` 只接受 `success` / `failure`，含义由游戏规则定义。同步返回是否本地入队；只结束当前对应局一次，旧异步回调、重复结束、非法值返回 `false`。忽略返回值也不影响游戏。 |

同页至少两次开局用来估算重玩，无需第三个接口。回到菜单、关页、刷新、断网均不应伪造 `failure`；下一局使用新的 run ID，刷新也不会恢复旧局。没有输赢的作品可把正常完成一局定义为 `success`，先向作者说明含义，不能擅自把低分当失败。

接入前先约定“一局”是整次挑战还是一个关卡，并保持一致；SDK 同时只跟踪一个当前局，不支持嵌套或并行局。`successes` 是按这个约定统计的成功/正常完成数，不是所有作品通用的“通关次数”。新版改了关卡目标或结算规则，比较版本数据时需要说明口径变化。

本版不提供 `track()`、关卡/道具属性或点击坐标统计；不要向接口附加 `level`、`score`、`properties` 等自定义字段，也不要自行 POST 绕过 SDK。采集端会将包含未知字段或未知事件类型的整批请求拒绝。SDK 自动合并 start/end 和连接包，网络请求次数不等于局数；同一局的结束补全已有记录，重试不会增加新局。

已结算局记录 SDK 累计的前台时长；隐藏页面与 BFCache 挂起不累计，游戏内部暂停但页面仍可见会累计。事件时间以服务端签发时间加采集器启动后的单调计时估算，不受设备墙钟调整影响；HTML 传输延迟会让时刻偏早，午夜附近可能影响日期归属。服务器限定在签发后 24 小时内、不能晚于接收时间 5 秒。平均时长只含有效结算局，最多接受 6 小时前台时长；不承诺录屏、精确操作时长或跨设备追踪。

分析沿用 DNT/GPC、关闭匿名统计、机器人过滤和项目内匿名标识。请求发送到作品内容域，`credentials: omit`，不含昵称、邮箱、分数、存档或任意自定义属性。每批最多 20 事件、8 KiB；SDK 每文档最多 100 局、200 条待发事件和 120 次发送（含重试），每批网络/服务端失败最多重试一次。限流、隐私关闭、票据过期和故障只影响数据完整性；不要给玩家弹统计错误，也不要添加无限离线补传。

作者数据页和 `feedback` 的 `gameplay` 字段共用版本、日期、正式/测试筛选：

- `status: disabled`：平台尚未开启或已关闭，`summary` 为 `null`；此时不要把空数据当接入失败。
- `status: no_samples`：本范围没有收到连接/游玩样本，可能未接入、没有访问或上报被拦截，不能认定 0% 的玩家愿意玩。
- `status: collecting`：可用 `summary`、`versions` 与 `definitions` 一起解读。`starts`、`successes`、`failures`、`missing_ends` 为开局日期批次，结算归原开局日期与版本；`average_duration_ms` 为已结算局前台均值。
- `connected_documents` 是本日期范围新连接的游戏文档数，`started_documents` / `replayed_documents` 统计这些文档在票据有效期内至少开局一次/两次的数量。`start_rate` = 开玩文档 / 连接文档；`replay_rate` = 重玩文档 / 开玩文档。文档跨日时，连接批次与开局批次可能不同。分母为零返回 `null`，不能展示为 0%。

未收到结算不等于失败或流失；同页重玩不等于次日留存。已打开的旧版本继续归旧 Deployment；版本回退也不改归属。固定预览的行为数据只进 `traffic=test`，不写正式榜单或存档。分析明细保留约 30 天，永久删除作品会清理；作者/获授 `analytics:read` 的 Agent 只能取得汇总，不能读取逐玩家事件。

### 给接入 Agent 的验证流程

1. 读取新鲜 `/skill-version.json`、`/skill` 和 `/sdk-version.json`，校验哈希；平台 SDK 需至少 2.2.0。用已有作品的 `feedback` 确认 `gameplay.status` 不是 `disabled`；缺字段或关闭时，报告“平台待升级/启用”，可继续本地接入，但公网统计保持待验收。需要读权限时走作者授权，不能把凭据放入游戏。
2. 在游戏源码定位实际开局、正常完成、明确失败和重开路径，说明本游戏 success/failure 的定义。只接入两个钩子，保留本地运行和现有登录/排行榜/存档行为。重复定时器或报分重试不能重复开始/结算。若只是计时挑战，正常到时究竟成功还是失败必须按玩法定义。
3. 构建并创建固定版本预览，记录 CLI 回执里的 `deployment.version`、`deployment.id`、`preview.url` 和 `release_revision`；先用 `feedback --game-version <候选版本号> --traffic test` 记一份基线。不要用 `current` 查询未上线候选；正式确认时将核对过的 `release_revision` 作为 `--expected-revision` 传入。
4. 在同一个真实浏览器文档依次：先只打开；开始并完成一局；再玩一局并失败；第三局开始后回菜单而不结算。每步等至少数秒，再读相同筛选的反馈比对增量。没有其他测试访问的理想结果是连接文档 +1、开局 +3、成功 +1、失败 +1、未结算 +1、开玩文档 +1、重玩文档 +1；报告实际结果和后台切换前台时长，不捏造预期数字。成功/失败的测试应符合本游戏规则。
5. 刷新应增加一个文档，不续旧局；重复结算与报分重试不多算；切后台后回到同局继续，平均前台时长不含隐藏时间。检查正式统计没有因预览测试增长，旧版本不被污染。隐私关闭/离线时游戏可玩、不弹统计错误。`navigator.webdriver=true` 的自动化浏览器会被过滤；不要改 SDK 绕过过滤来冒充真实浏览器验收。
6. 回报源码挂钩位置、SDK/Skill 版本和哈希、预览版本/链接、统计基线与增量、实际设备/网络、未通过与未验收项。取得当前任务的正式发布授权后，才按既有发布流程上线同一个候选；上报权限不等于发布授权。

## 在哪里运行和验收

SDK 2 的身份、排行榜和存档方法必须在平台正式游玩页或平台提供的预览页内运行，经受限 iframe 消息桥连接玩家服务。游玩分析独立走内容域采集，不要求调用 `ready()`；普通本地 HTML 没有采集上下文时静默返回 `null` / `false`：

- 正式分享使用 CLI 回执的 `share_url`（`https://awakeplay.com/g/<share_slug>`）。平台入口处理游客默认资料/自选头像昵称及登录；游戏无需另造登录页。用户从主页登录后重新进入游戏时，平台处理有效游客成绩认领。
- 试玩使用 CLI 回执的 `preview.url`。可验证界面与身份调用；预览身份临时，排行榜为 `{ entries: [], total: 0, me: null, preview: true }`，`submitScore` 及云存档操作拒绝并返回 `preview_read_only`。正式存档联调用独立验收作品，不把预览接到正式数据。
- 直接打开本地 HTML、普通 localhost 或 `runtime_url` 不具备平台父页面上下文，会得到 `play_page_required`。不要伪造运行配置、签名参数、Cookie 或直接调用玩家服务接口。开发时保留普通本地游玩，平台集成用预览页验证。
- 发布作品后，用正式分享链接验证真实的一局报分与榜单，再刷新确认身份和最高分；按需要使用独立验收作品。不要把 HTTP 200、预览空榜或本地模拟当作正式报分成功。

常见错误：`play_page_required` 请改用平台分享/预览入口；`preview_read_only` 请在正式链接验证报分；`storage_unavailable` 请按入口提示允许保存身份；`network_error` 检查网络后重试；`rate_limited` 按错误中的 `retryAfter` 等待；`invalid_score` 检查整数范围及请求编号。处理其他错误时显示可理解的失败提示，保留重试与重新打开入口的能力。

发布和更新仍使用 [AwakePlay 发布 Skill](https://awakeplay.com/skill)。SDK 下载不授予作者权限，也不需要为玩家分发 Agent 凭据。
