# @qingzhenghust/game-sdk

Riffle 游戏客户端 SDK。0.3 起，多人的业务规则和状态由每个游戏自己的 `backend/` 实现；平台负责身份、HTTP/WS 路由、隔离的 SQL/Redis，以及语音视频的短期 TURN/SFU 授权。

## 初始化

```ts
import { createGameSdkFromHost } from '@qingzhenghust/game-sdk';

const sdk = createGameSdkFromHost({ app: riffleBridge.app });
await sdk.ready();
```

宿主注入 Runtime URL、Launch Token、平台用户和 game ID。SDK 自动刷新临近过期的 Launch Token，并为 HTTP 和 WebSocket 请求附加同一个可信身份。游戏不得自己保存平台 token、拼接 Runtime backend URL 或传入用户 ID 冒充其他玩家。

SDK 0.3 的默认入口只有这些能力：

- `sdk.backend()`：游戏 backend 的 HTTP/WS 客户端；
- `sdk.gameRooms()`：基于游戏 backend 约定的可选房间组件；
- `sdk.media()`：语音、视频、TURN/SFU 和网络探针；
- `sdk.llm()`、`sdk.uploads()` 和宿主 `sdk.app` 能力。

旧 Runtime 的 `room()`、`rooms()`、`matchmaking()`、`leaderboard()`、`db()` 和实时模拟 capability 不在 0.3 的公开 API 中。匹配、排行榜、状态存储和服务端模拟应在游戏 backend 中实现。

## HTTP 和 WebSocket

```ts
const backend = sdk.backend();

const snapshot = await backend.get('/matches/m1');
await backend.post('/matches/m1/input', {
  requestId: crypto.randomUUID(),
  sequence: 18,
  action: 'jump',
});

const socket = await backend.connect('/matches/m1/events');
const unsubscribe = socket.onMessage(event => applyServerEvent(event));
socket.send({ type: 'subscribe', protocol: 1, requestId: crypto.randomUUID() });
```

HTTP 适合命令、CRUD、权限检查和恢复快照。WebSocket 适合订阅、服务端推送和短小的实时输入。业务 ACK 必须说明请求是已接收、已持久化还是已应用；socket open 和 HTTP 200 本身不代表比赛动作已经执行。

游戏 backend 使用 `@qingzhenghust/game-sdk/server` 声明 HTTP/WS 入口：

```ts
import { defineGameBackend } from '@qingzhenghust/game-sdk/server';

export default defineGameBackend({
  async http(request, ctx) {
    // ctx.identity 是可信平台身份；ctx.sql/ctx.redis 已按部署隔离。
    return Response.json({ userId: ctx.identity.userId });
  },
  async websocket(socket, ctx) {
    socket.send({ type: 'ready', protocol: 1 });
  },
});
```

服务端入口放在 `backend/index.ts`，迁移放在 `backend/migrations/`。部署平台以稳定的 `backendDeploymentId` 更新该游戏唯一 Runner；发布新 artifact 不会多建一套长期 Runner。完整约定见 [Backend API](docs/backend-api.md) 和 [多人快速开始](docs/multiplayer-quickstart.md)。

## 房间组件

房间不是平台隐式状态。游戏需要房间时，由自己的 backend 使用 `@qingzhenghust/game-backend-kit` 定义规则，客户端再调用：

```ts
const rooms = sdk.gameRooms();
const room = await rooms.create({ maxPlayers: 4, metadata: { mode: 'casual' } });
await rooms.join(room.id, { team: 'blue' });
const current = await rooms.get(room.id);
await rooms.leave(room.id);
```

不需要房间的留言板、排行榜和异步协作可直接设计 HTTP/WS API。SDK 不会强迫这些玩法创建虚构房间。

## 语音和视频

先由游戏 backend 确认当前用户属于该房间。SDK 随后通过 backend 获取短期媒体授权，并管理采集、P2P/SFU、信令订阅、重连和资源释放。

```ts
const voice = await sdk.media().voice(room.id);
voice.onTrack((_peerId, stream) => attachAudio(stream));
await voice.join(); // 必须由用户操作触发权限请求
voice.setMuted(true);
await voice.leave();

const video = await sdk.media().video(room.id);
video.onLocalStream(stream => showPreview(stream));
video.onTrack((peerId, stream) => attachRemoteVideo(peerId, stream));
await video.join();
await video.setCameraEnabled(false);
await video.switchCamera();
await video.leave();
```

双人房默认可用 P2P + TURN，多人房使用 SFU。选择结果来自服务端授权，游戏不接触 TURN 密钥或 SFU 签名密钥。媒体帧不会进入 Runner、SQL 或 Redis；backend 只处理成员权限、房间业务和信令路由。

语音和视频都提供延迟与丢包探针：

```ts
const stop = video.observeNetwork(snapshot => {
  renderNetwork({
    rttMs: snapshot.summary.rttMs,
    loss: snapshot.summary.packetLossRate,
    quality: snapshot.summary.quality,
  });
}, { intervalMs: 1000 });

const diagnostic = await video.probeNetwork({ sampleWindowMs: 1000 });
stop();
```

探针读取浏览器 WebRTC 统计，不上传媒体内容、SDP、ICE 地址或凭据。

## 可靠性边界

- 客户端必须显式区分连接状态、业务状态、消息状态和状态版本。
- 写命令携带稳定 `requestId`，连续输入携带递增 `sequence`，持久状态使用版本或事务。
- 普通位置帧允许丢弃和覆盖，不能等待 ACK 后才继续模拟。
- 开局、计分和结算使用去重、有限重试和快照恢复。
- 断线恢复先读取服务端快照，再恢复订阅；旧连接回调不能覆盖新一代状态。
- SQL 保存持久事实；Redis 保存短期租约、限流和在线协调；高频帧不写入数据库。

更完整的状态机和测试要求见 [多人状态机](docs/multiplayer-state-machines.md) 与 [验证规范](docs/multiplayer-validation.md)。
