# Lying Bottle Unity 开发者指南

这份文档面向 Unity 游戏开发者，说明如何在 Unity 工程中接入和调用 `IGP.UnitySDK.LyingBottle`。

本文包含：

- 安装和初始化。
- `IGPLyingBottle` 方法和业务动作的对应关系。
- POST body / GET query 字段。
- 成功响应字段和 C# DTO 定义方式。
- 错误码、错误响应和重试规则。

## 1. 接入前提

导入包：

```text
cn.indiegp.sdk.unity-<version>.unitypackage
cn.indiegp.sdk.unity.lying-bottle-<version>.unitypackage
```

场景配置：

1. 一个 `IGPRuntimeManager`。
2. `Assets/IGP/IGPConfig.asset` 已填写 `appId`。
3. 游戏启动流程里调用过 `await runtimeManager.InitializeAsync()`。
4. desktop 可用，并且用户已登录。

`IGPLyingBottle` 是静态 API，不作为 Unity 组件挂载到 GameObject。

调用链路：

```text
Unity 游戏
 -> IGPLyingBottle
 -> IGPRuntimeManager
 -> desktop session
 -> Lying Bottle API
```

Lying Bottle 调用通过 `IGPLyingBottle -> IGPRuntimeManager -> desktop session` 完成；认证、签名和 HTTP 转发由 desktop 处理。

## 2. 最小示例

第一次接入时，建议先用 `string` 作为响应类型，直接打印原始 JSON。

```csharp
using UnityEngine;
using IGP.UnitySDK;
using IGP.UnitySDK.LyingBottle;

public sealed class LyingBottleBootstrap : MonoBehaviour
{
    [SerializeField] private IGPRuntimeManager runtimeManager;

    private async void Start()
    {
        runtimeManager ??= GetComponent<IGPRuntimeManager>();

        var initialized = await runtimeManager.InitializeAsync();
        if (!initialized)
        {
            Debug.LogError("[LyingBottle] IGP SDK 初始化失败。");
            return;
        }

        try
        {
            var json = await IGPLyingBottle.GetBottlesAsync<string>(runtimeManager);
            Debug.Log($"[LyingBottle] bottles={json}");
        }
        catch (IGPLyingBottleException ex)
        {
            Debug.LogError($"[LyingBottle] code={ex.Code}, message={ex.Message}, upstream={ex.UpstreamJson}");
        }
    }
}
```

## 3. 泛型和 DTO

`IGPLyingBottle` 当前不导出具体的 C# 请求/响应 DTO 类。方法用泛型表达请求和响应类型：

- `TRequest`：POST 请求 body 类型。
- `TResponse`：成功响应 JSON 要反序列化成的类型。
- 使用 `string` 作为 `TResponse` 时，SDK 直接返回原始 JSON。
- 使用自定义 C# class 作为 `TResponse` 时，SDK 用 Newtonsoft.Json 反序列化 `contentJson`。

常见写法：

```csharp
// 不确定响应结构时，直接拿 raw JSON。
string json = await IGPLyingBottle.GetBottlesAsync<string>(runtimeManager);

// 用匿名对象构造 POST body，响应仍然拿 raw JSON。
string drawJson = await IGPLyingBottle.DrawGachaAsync<object, string>(
    runtimeManager,
    subBottleId,
    new
    {
        requestId = System.Guid.NewGuid().ToString(),
        poolKey = "default",
        drawCount = 1,
    });
```

如果你要强类型访问字段，就在游戏项目里按本文第 7 节定义自己的 C# DTO。

成功响应没有统一外层 envelope。`TResponse` 对应的是 API 直接返回的 JSON。例如 `GetBottlesAsync` 返回的是：

```json
{
  "bottles": [],
  "recommendedSubBottleId": "..."
}
```

不是：

```json
{
  "data": {
    "bottles": []
  }
}
```

部分业务响应本身可能有 `success`、`duplicated` 等字段；它们是该 route 的业务字段，不是统一外层 envelope。

失败时才会通过 `IGPLyingBottleException` 暴露错误，`ex.UpstreamJson` 里可能有 API 的错误 envelope。

## 4. SDK 如何构造请求

`IGPLyingBottle` 会把你的调用转成 Lying Bottle forward payload：

```json
{
  "method": "POST",
  "route": "bottles/:subBottleId/gacha/draw",
  "pathParams": {
    "subBottleId": "sub-1"
  },
  "body": {
    "requestId": "same-id-for-retry",
    "poolKey": "default",
    "drawCount": 1
  }
}
```

字段含义：

| 字段 | 说明 |
| --- | --- |
| `method` | `GET` 或 `POST` |
| `route` | 相对 `/lying-bottle/` 的 route 模板，例如 `bottles/:subBottleId/gacha/draw` |
| `pathParams` | 替换 route 里的 `:subBottleId` |
| `query` | GET 查询参数，key/value 都是 string |
| `body` | POST 请求体；GET 不发送 body |

SDK 会校验 `method`、`route`、`pathParams`、`query` 的基础形状。业务字段由 API 校验，例如 `requestId`、`sessionId`、`poolKey`、`drawCount`、`abilityKey`、`quantity`。

## 5. 常用调用

读取副瓶列表：

```csharp
string json = await IGPLyingBottle.GetBottlesAsync<string>(runtimeManager);
```

读取指定副瓶状态：

```csharp
string json = await IGPLyingBottle.GetBottleAsync<string>(runtimeManager, subBottleId);
```

开始副瓶运行会话：

```csharp
string json = await IGPLyingBottle.StartSessionAsync<object, string>(
    runtimeManager,
    subBottleId,
    new
    {
        clientOpenedAt = System.DateTimeOffset.UtcNow.ToString("O"),
    });
```

抽卡：

```csharp
var requestId = System.Guid.NewGuid().ToString();

string json = await IGPLyingBottle.DrawGachaAsync<object, string>(
    runtimeManager,
    subBottleId,
    new
    {
        requestId,
        poolKey = "default",
        drawCount = 1,
    });
```

带 query 的 GET 使用通用入口：

```csharp
var query = new System.Collections.Generic.Dictionary<string, string>
{
    ["includeState"] = "true",
    ["includeConfigSnapshot"] = "true",
};

string json = await IGPLyingBottle.CallAsync<string>(
    runtimeManager,
    IGPLyingBottleRoutes.BottlesSpec,
    query: query);
```

## 6. 13 个 player API

route 使用相对 `/lying-bottle/` 的路径模板，例如 `bottles/:subBottleId/gacha/draw`。当前支持 player route。

| SDK 方法 | Method | Route | 请求类型 | 成功响应类型 |
| --- | --- | --- | --- | --- |
| `StartupSyncAsync<TRequest,TResponse>` | POST | `startup-sync` | `SyncLyingBottlesRequest` | `SyncLyingBottlesResponse` |
| `GetBottlesAsync<TResponse>` | GET | `bottles` | `ListLyingBottlesQuery` | `ListLyingBottlesResponse` |
| `GetBottleAsync<TResponse>` | GET | `bottles/:subBottleId` | path: `subBottleId` | `GetBottleResponse` |
| `GetBottleConfigAsync<TResponse>` | GET | `bottles/:subBottleId/config` | `ListLyingBottleConfigQuery` | `GetConfigResponse` |
| `StartSessionAsync<TRequest,TResponse>` | POST | `bottles/:subBottleId/sessions/start` | `StartRunSessionRequest` | `StartRunSessionResponse` |
| `EndSessionAsync<TRequest,TResponse>` | POST | `bottles/:subBottleId/sessions/end` | `EndRunSessionRequest` | `EndRunSessionResponse` |
| `ReportCoinProgressAsync<TRequest,TResponse>` | POST | `bottles/:subBottleId/coin/progress` | `CoinSyncRequest` | `CoinSyncResponse` |
| `GetCoinInfoAsync<TRequest,TResponse>` | POST | `bottles/:subBottleId/coin/info` | `CoinSyncRequest` | `CoinSyncResponse` |
| `UpgradeAbilityAsync<TRequest,TResponse>` | POST | `bottles/:subBottleId/abilities/upgrade` | `UpgradeAbilityRequest` | `UpgradeAbilityResponse` |
| `PurchaseTicketsAsync<TRequest,TResponse>` | POST | `bottles/:subBottleId/tickets/purchase` | `BuyTicketsRequest` | `BuyTicketsResponse` |
| `DrawGachaAsync<TRequest,TResponse>` | POST | `bottles/:subBottleId/gacha/draw` | `DrawGachaRequest` | `DrawGachaResponse` |
| `GetInventoryAsync<TResponse>` | GET | `bottles/:subBottleId/inventory` | `ListInventoryQuery` | `ListInventoryResponse` |
| `GetRecordsAsync<TResponse>` | GET | `bottles/:subBottleId/records` | `QueryRecordsQuery` | `QueryRecordsResponse` |

注意：`GetBottlesAsync`、`GetBottleConfigAsync`、`GetInventoryAsync`、`GetRecordsAsync` 这些便捷方法默认不传 query。上表里的 query 字段通过第 5 节展示的 `CallAsync` 通用入口传入。

## 7. 请求和响应字段

下面用 TypeScript 风格描述 JSON 字段，方便看字段数量、类型和含义。Unity 里可以按实际需要写成 C# class。

约定：

- `UuidV4` 是 UUID v4 字符串，例如 `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`。
- `IsoDate` 是 ISO8601 时间字符串。
- `BigIntString` 是十进制整数字符串。金币、券等余额按 string 读取；如需计算，使用大整数方案。
- `Record<string, unknown>` 表示结构由配置或业务决定。
- 字段注释中的长度、范围和枚举约束来自最新 API guide。

### 7.1 通用类型

```ts
type UuidV4 = string;
type IsoDate = string;
type BigIntString = string;

type RequestMeta = {
  /** 客户端请求时间。 */
  requestTime?: IsoDate;
  /** 请求随机串，最长 128 字符。 */
  nonce?: string;
  /** 请求签名，最长 256 字符。 */
  signature?: string;
  /** 客户端版本，最长 64 字符。 */
  clientVersion?: string;
  /** 客户端平台。 */
  platform?: 'Windows' | 'Mac' | 'Linux' | 'Other';
  /** 设备标识，仅用于排查，最长 128 字符。 */
  deviceId?: string;
};

type MutationBase = {
  /** 通用请求元信息。普通 Unity 调用通常可以省略。 */
  meta?: RequestMeta;
  /** 客户端生成的全局唯一请求 ID；重试同一次业务操作时保持不变。 */
  requestId: UuidV4;
};

type LyingBottleSummary = {
  /** 副瓶记录 ID。 */
  id: UuidV4;
  /** 副瓶 ID；当前与 id 相同，保留给 SDK 侧明确语义。 */
  subBottleId: UuidV4;
  /** 副瓶定义 ID。 */
  bottleDefinitionId: number;
  /** 副瓶业务标识。 */
  bottleKey: string;
  /** 赛季编码。 */
  seasonCode: string;
  /** 发放批次标识。 */
  releaseBatchKey: string;
  /** 展示名称。 */
  displayName: string;
  /** 发放时间。 */
  grantedAt: IsoDate;
  /** 副瓶状态；状态尚未激活或未加载时为 null。 */
  state: LyingBottleState | null;
  /** 能力等级摘要。 */
  abilities: Array<{ abilityKey: string; level: number }>;
  /** 仓库物品；只有对应查询包含仓库时返回。 */
  inventory?: Array<{
    itemKey: string;
    quantity: BigIntString;
    firstAcquiredAt: IsoDate;
    updatedAt: IsoDate;
  }>;
};

type LyingBottleDetailed = LyingBottleSummary & {
  /** 当前金币余额。 */
  coinBalance: BigIntString;
  /** 当前金币变化序号。 */
  coinSequence: number;
  /** 下次上报应使用的金币变化序号。 */
  nextCoinSequence: number;
  /** 当前抽卡券余额。 */
  ticketBalance: BigIntString;
  /** 账号累计购买的抽卡券数量。 */
  ticketPurchasedTotal: BigIntString;
  /** 能力状态和升级成本。 */
  abilities: LyingBottleAbilityState[];
  /** 当前每秒挂机收益，字符串数字。 */
  idleRatePerSecond: string;
  /** 当前金币堆容量上限。 */
  coinPileCapacity: BigIntString;
  /** 客户端最近一次上报的金币余额。 */
  reportedCoinBalance: BigIntString;
  /** 最近一次上报进度时间。 */
  lastProgressReportedAt: IsoDate | null;
  /** 最近一次金币校准时间。 */
  lastCoinCalibratedAt: IsoDate | null;
  /** 当前活跃会话；没有活跃会话时缺省。 */
  activeSession?: LyingBottleRunSession;
  /** 进度上报策略。 */
  progressReportPolicy: LyingBottleProgressReportPolicy;
  /** 客户端下次应上报金币进度的最早时间。 */
  nextProgressReportAfter: IsoDate;
  /** 当前生效的客户端配置版本快照，按 configType 索引。 */
  configVersionSnapshot: Record<string, unknown>;
};

type LyingBottleState = {
  /** 当前金币余额。 */
  coinBalance: BigIntString;
  /** 当前金币变化序号。 */
  coinSequence: number;
  /** 客户端最近一次上报的金币余额。 */
  reportedCoinBalance: BigIntString | null;
  /** 当前抽卡券余额。 */
  ticketBalance: BigIntString;
  /** 账号累计购买的抽卡券数量。 */
  ticketPurchasedTotal: BigIntString;
  /** 最近一次上报进度时间。 */
  lastProgressReportedAt: IsoDate | null;
  /** 最近一次金币校准时间。 */
  lastCoinCalibratedAt: IsoDate | null;
  /** 其他状态字段由服务端状态模型决定。 */
  [key: string]: unknown;
};

type LyingBottleRunSession = {
  /** 运行会话 ID。 */
  sessionId: UuidV4;
  /** 副瓶 ID。 */
  subBottleId: UuidV4;
  /** 会话状态。 */
  status: 'ACTIVE' | 'ENDED';
  /** 会话开始时间。 */
  startedAt: IsoDate;
  /** 最近一次活跃时间。 */
  lastActiveAt: IsoDate;
  /** 会话结束时间；活跃中为 null。 */
  endedAt: IsoDate | null;
  /** 结束原因；活跃中为 null。 */
  endReason: string | null;
  /** 会话开始时的金币变化序号。 */
  startedCoinSequence: number;
  /** 服务端认为该会话仍然活跃的宽限秒数。 */
  activeGraceSeconds: number;
  /** 会话使用的配置版本快照。 */
  configVersionSnapshot?: Record<string, unknown>;
};

type LyingBottleAbilityState = {
  /** 能力标识。 */
  abilityKey: string;
  /** 当前等级。 */
  level: number;
  /** 下一级升级所需金币。 */
  upgradeCost?: BigIntString;
  /** 当前能力贡献的效果参数。 */
  effects?: Record<string, unknown>;
};

type LyingBottleCoinCalibration = {
  /** false 表示服务端调整了客户端上报的余额。 */
  accepted: boolean;
  /** true 表示本次下发了校准结果；false 表示回放已有结果。 */
  calibrationApplied: boolean;
  /** 当前生效的校准模式。 */
  calibrationMode: string;
  /** 触发校准的原因；accepted=true 时通常为空。 */
  riskReason?: string | null;
  /** 客户端提交的余额。 */
  submittedCoinBalance?: BigIntString;
  /** 服务端返回给客户端使用的余额。 */
  returnedCoinBalance: BigIntString;
  /** 服务端可接受的最大金币余额。 */
  maxAcceptedCoinBalance?: BigIntString;
  /** 当前金币变化序号。 */
  coinSequence: number;
  /** 下次上报应使用的金币变化序号。 */
  nextCoinSequence: number;
};

type LyingBottleProgressReportPolicy = {
  /** 客户端两次上报之间允许的最大间隔秒数。 */
  maxReportDelaySeconds: number;
  /** 上报时间抖动秒数，用于分散请求。 */
  reportJitterSeconds: number;
  /** 其他策略字段由服务端配置决定。 */
  [key: string]: unknown;
};

type LyingBottleClientConfig = {
  /** 副瓶业务标识。 */
  bottleKey: string;
  /** 配置类型。 */
  configType: string;
  /** 配置版本 ID。 */
  configVersionId: UuidV4;
  /** 配置版本号。 */
  version: number;
  /** 生效时间。 */
  effectiveAt: IsoDate;
  /** 配置内容；结构由 configType 决定。 */
  content: unknown;
};
```

### 7.2 `POST startup-sync`

请求：

```ts
type SyncLyingBottlesRequest = MutationBase & {
  /** 客户端版本，仅用于排查和兼容判断，最长 64 字符。 */
  clientVersion?: string;
  /** 客户端已知副瓶 ID 列表，仅用于排查。 */
  knownSubBottleIds?: UuidV4[];
};
```

成功响应：

```ts
type SyncLyingBottlesResponse = {
  /** 本次调用新发放给该账号的副瓶。 */
  grantedBottles: LyingBottleSummary[];
  /** 该账号当前持有的全部副瓶。 */
  bottles: LyingBottleSummary[];
};
```

### 7.3 `GET bottles`

query：

```ts
type ListLyingBottlesQuery = {
  /** 是否返回金币、券、能力和会话状态。 */
  includeState?: 'true' | 'false';
  /** 是否返回配置版本快照。 */
  includeConfigSnapshot?: 'true' | 'false';
};
```

成功响应：

```ts
type ListLyingBottlesResponse = {
  /** 当前账号持有的副瓶列表。 */
  bottles: LyingBottleSummary[];
  /** 推荐默认进入的副瓶 ID。 */
  recommendedSubBottleId?: UuidV4;
};
```

### 7.4 `GET bottles/:subBottleId`

成功响应：

```ts
type GetBottleResponse = LyingBottleDetailed;
```

### 7.5 `GET bottles/:subBottleId/config`

query：

```ts
type ListLyingBottleConfigQuery = {
  /** 配置类型；省略时返回全部当前生效配置类型。 */
  configType?: 'BOTTLE_CONFIG'
    | 'ABILITY_UPGRADE'
    | 'COIN_RATE_FORMULA_GRAPH'
    | 'COIN_CALIBRATION'
    | string;
  /** 指定要读取的副瓶配置版本 ID。 */
  configVersionId?: UuidV4;
};
```

成功响应可能是配置集合，也可能是单个 client config：

```ts
type GetConfigResponse =
  | {
      configs: Array<{
        /** 配置记录 ID。 */
        id: UuidV4;
        /** 配置类型。 */
        configType: string;
        /** 配置业务键。 */
        configKey: string;
        /** 配置版本号。 */
        version: number;
        /** 生效时间。 */
        effectiveAt: IsoDate;
        /** 配置内容；结构由 configType 决定。 */
        content: unknown;
      }>;
    }
  | LyingBottleClientConfig;
```

### 7.6 `POST bottles/:subBottleId/sessions/start`

请求：

```ts
type StartRunSessionRequest = {
  /** 通用请求元信息。 */
  meta?: RequestMeta;
  /** 客户端打开副瓶时间，仅用于排查。 */
  clientOpenedAt?: IsoDate;
};
```

成功响应：

```ts
type StartRunSessionResponse = {
  /** 本次创建的运行会话。 */
  session: LyingBottleRunSession;
  /** 当前副瓶完整状态，包含运行时计算字段。 */
  bottleState: LyingBottleDetailed;
};
```

### 7.7 `POST bottles/:subBottleId/sessions/end`

请求：

```ts
type EndRunSessionRequest = {
  /** 通用请求元信息。 */
  meta?: RequestMeta;
  /** 本次运行会话 ID。 */
  sessionId: UuidV4;
  /** 结束原因。 */
  endReason: 'CLIENT_CLOSE' | 'SWITCH_BOTTLE' | 'QUIT_GAME';
  /** 客户端关闭副瓶时间，仅用于排查。 */
  clientClosedAt?: IsoDate;
};
```

成功响应：

```ts
type EndRunSessionResponse = {
  /** 已结束的运行会话。 */
  session: LyingBottleRunSession;
  /** 结束时刻校准后的金币余额。 */
  coinBalance: BigIntString;
  /** 当前能力下的金币堆容量上限。 */
  coinPileCapacity: BigIntString;
  /** 服务端下发的最新金币变化序号。 */
  coinSequence: number;
  /** 客户端下次上报应使用的金币变化序号。 */
  nextCoinSequence: number;
};
```

### 7.8 `POST bottles/:subBottleId/coin/progress` 和 `POST bottles/:subBottleId/coin/info`

这两个接口使用相同的请求和响应。`coin/progress` 用于上报客户端当前金币进度；`coin/info` 用于读取校准后的金币信息。幂等键使用 `progressSubmission.requestId`。

请求：

```ts
type LyingBottleCoinProgress = {
  /** 服务端下发的本次金币变化序号，最小值 1。 */
  coinSequence: number;
  /** 客户端当前金币余额，十进制整数文本，最长 32 字符。 */
  reportedCoinBalance: BigIntString;
  /** 客户端当前使用的副瓶配置版本 ID。 */
  clientConfigVersionId: UuidV4;
  /** 客户端记录该进度的时间，仅用于排查。 */
  clientRecordedAt: IsoDate;
  /** 客户端本地累计挂机秒数，仅用于排查，整数且 >= 0。 */
  reportedIdleSeconds?: number;
};

type LyingBottleCoinProgressSubmission = {
  /** 本次金币同步请求 ID；重试同一次同步时保持不变。 */
  requestId: UuidV4;
  /** 客户端金币进度。 */
  progress: LyingBottleCoinProgress;
};

type CoinSyncRequest = {
  /** 通用请求元信息。 */
  meta?: RequestMeta;
  /** 本次副瓶运行会话 ID。 */
  sessionId: UuidV4;
  /** 金币同步请求内容。 */
  progressSubmission: LyingBottleCoinProgressSubmission;
};
```

成功响应：

```ts
type CoinSyncResponse = {
  /** 校准后的服务端权威金币余额。 */
  coinBalance: BigIntString;
  /** 当前金币变化序号。 */
  coinSequence: number;
  /** 下次上报应使用的金币变化序号。 */
  nextCoinSequence: number;
  /** 回显客户端本次上报的金币余额。 */
  reportedCoinBalance: string;
  /** 抽卡券余额。 */
  ticketBalance: BigIntString;
  /** 当前金币堆容量上限。 */
  coinPileCapacity: BigIntString;
  /** 当前每秒挂机收益，字符串数字。 */
  idleRatePerSecond: string;
  /** 校准结果。 */
  calibration: LyingBottleCoinCalibration;
};
```

### 7.9 `POST bottles/:subBottleId/abilities/upgrade`

请求：

```ts
type UpgradeAbilityRequest = MutationBase & {
  /** 本次副瓶运行会话 ID。 */
  sessionId: UuidV4;
  /** 能力标识，最长 64 字符。 */
  abilityKey: string;
};
```

成功响应：

```ts
type UpgradeAbilityResponse = {
  /** 服务端处理是否成功。 */
  success: true;
  /** 是否为重复请求；true 表示读取的是上一次同 requestId 的结果。 */
  duplicated: boolean;
  /** 当前金币余额。 */
  coinBalance: BigIntString;
  /** 当前金币变化序号。 */
  coinSequence: number;
  /** 下次上报应使用的金币变化序号。 */
  nextCoinSequence: number;
  /** 客户端最近一次上报的金币余额。 */
  reportedCoinBalance: BigIntString;
  /** 当前抽卡券余额。 */
  ticketBalance: BigIntString;
  /** 当前金币堆容量上限。 */
  coinPileCapacity: BigIntString;
  /** 当前能力状态列表。 */
  abilities: LyingBottleAbilityState[];
  /** 最近一次金币校准结果。 */
  lastCoinCalibration: LyingBottleCoinCalibration;
  /** 进度上报策略。 */
  progressReportPolicy: LyingBottleProgressReportPolicy;
  /** 客户端下次应上报金币进度的最早时间。 */
  nextProgressReportAfter: IsoDate;
  /** 当前生效配置版本快照。 */
  configVersionSnapshot: Record<string, unknown>;
  /** 升级后的能力状态。 */
  upgradedAbility?: LyingBottleAbilityState;
  /** 下一级升级所需金币。 */
  nextUpgradeCost?: BigIntString;
};
```

### 7.10 `POST bottles/:subBottleId/tickets/purchase`

请求：

```ts
type BuyTicketsRequest = MutationBase & {
  /** 本次副瓶运行会话 ID。 */
  sessionId: UuidV4;
  /** 本次兑换抽卡券数量，1 到 10。 */
  quantity: number; // 1..10
};
```

成功响应：

```ts
type BuyTicketsResponse = {
  /** 服务端处理是否成功。 */
  success: true;
  /** 是否为重复请求；true 表示读取的是上一次同 requestId 的结果。 */
  duplicated: boolean;
  /** 本次成功兑换的券数量。 */
  quantity: number;
  /** 本次消耗的金币总额。 */
  totalCost: BigIntString;
  /** 当前金币余额。 */
  coinBalance: BigIntString;
  /** 当前金币变化序号。 */
  coinSequence: number;
  /** 下次上报应使用的金币变化序号。 */
  nextCoinSequence: number;
  /** 客户端最近一次上报的金币余额。 */
  reportedCoinBalance: BigIntString;
  /** 当前抽卡券余额。 */
  ticketBalance: BigIntString;
  /** 当前金币堆容量上限。 */
  coinPileCapacity: BigIntString;
  /** 当前能力状态列表。 */
  abilities: LyingBottleAbilityState[];
  /** 最近一次金币校准结果。 */
  lastCoinCalibration: LyingBottleCoinCalibration;
  /** 进度上报策略。 */
  progressReportPolicy: LyingBottleProgressReportPolicy;
  /** 客户端下次应上报金币进度的最早时间。 */
  nextProgressReportAfter: IsoDate;
  /** 当前生效配置版本快照。 */
  configVersionSnapshot: Record<string, unknown>;
  /** 账号累计购买的抽卡券总数。 */
  ticketPurchasedTotal: BigIntString;
  /** 买券后继续购买后续 10 张抽卡券时每张券的价格。 */
  nextTicketPrices: BigIntString[];
};
```

### 7.11 `POST bottles/:subBottleId/gacha/draw`

请求：

```ts
type DrawGachaRequest = MutationBase & {
  /** 卡池标识，最长 64 字符。 */
  poolKey: string;
  /** 抽卡次数，支持 1 或 10。 */
  drawCount: 1 | 10;
};
```

成功响应：

```ts
type DrawGachaResponse = {
  /** 服务端处理是否成功。 */
  success: true;
  /** 是否为重复请求；true 表示读取的是上一次同 requestId 的结果。 */
  duplicated: boolean;
  /** 卡池标识。 */
  poolKey: string;
  /** 本次抽到的物品列表。 */
  draws: Array<{
    /** 物品业务标识。 */
    itemKey: string;
    /** 物品全局唯一标识。 */
    itemId: string;
    /** 稀有度。 */
    rarity: string;
    /** 获得数量。 */
    quantity: BigIntString;
    /** 是否首次获得。 */
    isNew: boolean;
  }>;
  /** 本次消耗的抽卡券数量。 */
  ticketsSpent: number;
  /** 抽卡后剩余的券余额。 */
  ticketBalance: BigIntString;
  /** 本次对仓库的增量。 */
  inventoryChanges?: Array<{
    /** 物品业务标识。 */
    itemKey: string;
    /** 数量变化。 */
    deltaQuantity: BigIntString;
  }>;
};
```

### 7.12 `GET bottles/:subBottleId/inventory`

query：

```ts
type ListInventoryQuery = {
  /** 按物品类型筛选，依赖物品配置，最长 64 字符。 */
  itemType?: string;
  /** 按主题筛选，依赖物品配置，最长 64 字符。 */
  theme?: string;
  /** 只返回可用于装扮的物品。 */
  onlyUsableForOutfit?: 'true' | 'false';
  /** 分页游标，使用上一页最后一条记录 ID，最长 128 字符。 */
  cursor?: string;
  /** 返回数量，1 到 100；作为 query string 传入。 */
  limit?: string; // 1..100
};
```

成功响应：

```ts
type ListInventoryResponse = {
  items: Array<{
    /** 物品全局唯一标识。 */
    itemId: string;
    /** 物品业务标识。 */
    itemKey: string;
    /** 持有数量。 */
    quantity: BigIntString;
    /** 物品配置快照，结构由物品类型决定。 */
    definition: Record<string, unknown>;
  }>;
  /** 下一页游标；缺省表示已是末页。 */
  nextCursor?: string;
};
```

### 7.13 `GET bottles/:subBottleId/records`

query：

```ts
type QueryRecordsQuery = {
  /** 记录类型，逗号分隔：COIN,TICKET,ABILITY,INVENTORY,GACHA。 */
  types?: string; // 逗号分隔：COIN,TICKET,ABILITY,INVENTORY,GACHA
  /** 开始时间。 */
  from?: IsoDate;
  /** 结束时间。 */
  to?: IsoDate;
  /** 分页游标，使用上一页最后一条记录 ID，最长 128 字符。 */
  cursor?: string;
  /** 返回数量，1 到 100；作为 query string 传入。 */
  limit?: string; // 1..100
};
```

成功响应：

```ts
type QueryRecordsResponse = {
  records: Array<{
    /** 记录 ID。 */
    recordId: string;
    /** 记录类型。 */
    recordType: 'COIN' | 'TICKET' | 'ABILITY' | 'INVENTORY' | 'GACHA';
    /** 创建时间。 */
    createdAt: IsoDate;
    /** 记录内容；结构随 recordType 变化。 */
    payload: Record<string, unknown>;
  }>;
  /** 下一页游标；缺省表示已是末页。 */
  nextCursor?: string;
};
```

## 8. C# DTO 示例

游戏项目可以只定义自己实际会用到的 DTO。字段名保持和 JSON 一致即可。

```csharp
[System.Serializable]
public sealed class DrawGachaResponse
{
    public bool success;
    public bool duplicated;
    public string poolKey;
    public DrawResult[] draws;
    public int ticketsSpent;
    public string ticketBalance;
    public InventoryChange[] inventoryChanges;
}

[System.Serializable]
public sealed class DrawResult
{
    public string itemKey;
    public string itemId;
    public string rarity;
    public string quantity;
    public bool isNew;
}

[System.Serializable]
public sealed class InventoryChange
{
    public string itemKey;
    public string deltaQuantity;
}

var body = new
{
    requestId = System.Guid.NewGuid().ToString(),
    poolKey = "default",
    drawCount = 1,
};

var response = await IGPLyingBottle.DrawGachaAsync<object, DrawGachaResponse>(
    runtimeManager,
    subBottleId,
    body);

Debug.Log(response.draws[0].itemKey);
```

另一个常见 DTO 是读取副瓶列表：

```csharp
[System.Serializable]
public sealed class ListLyingBottlesResponse
{
    public LyingBottleSummary[] bottles;
    public string recommendedSubBottleId;
}

[System.Serializable]
public sealed class LyingBottleSummary
{
    public string id;
    public string subBottleId;
    public string bottleKey;
    public int bottleDefinitionId;
    public string configVersionId;
    public string grantedAt;
}

var bottles = await IGPLyingBottle.GetBottlesAsync<ListLyingBottlesResponse>(runtimeManager);
Debug.Log(bottles.recommendedSubBottleId);
```

## 9. 错误处理

`IGPLyingBottleException` 会保留 desktop 或 API 返回的错误码、错误消息和上游 JSON。

```csharp
try
{
    var response = await IGPLyingBottle.DrawGachaAsync<object, DrawGachaResponse>(
        runtimeManager,
        subBottleId,
        requestBody);
}
catch (IGPLyingBottleException ex)
{
    var httpStatus = ex.HttpStatus();

    if (httpStatus.HasValue && httpStatus.Value >= 500)
    {
        // 可按业务策略重试。重试同一次业务操作时复用 requestBody.requestId。
    }
    else if (ex.Code.StartsWith("DESKTOP_"))
    {
        // desktop session 问题，通常需要用户登录后重新 attach。
    }
    else if (ex.Code.StartsWith("LYING_BOTTLE_"))
    {
        // 参数错误或 API 业务错误。非 5xx 通常按业务错误处理。
    }

    Debug.LogError($"code={ex.Code}, message={ex.Message}, upstream={ex.UpstreamJson}");
}
```

错误类型：

| 错误码 | 来源 | 处理建议 |
| --- | --- | --- |
| `DESKTOP_SESSION_CAPABILITY_MISSING` | desktop session | 用户可能未登录，登录后重新 attach |
| `DESKTOP_USER_CONTEXT_REQUIRED` | desktop session | 用户上下文不可用，等待或重新 attach |
| `LYING_BOTTLE_ROUTE_NOT_ALLOWED` | SDK / desktop | route / method 不在允许列表，修正调用 |
| `LYING_BOTTLE_MISSING_PATH_PARAM` | SDK / desktop | 缺少 `subBottleId` 等 path param |
| `LYING_BOTTLE_INVALID_QUERY` | SDK / desktop | query 参数不是 string |
| `LYING_BOTTLE_HTTP_<status>` | API | API 返回非 2xx，`UpstreamJson` 通常是 API error envelope |
| `LYING_BOTTLE_SESSION_INVALIDATED` | desktop session | 连接失效，重新 attach 后再试 |

`LYING_BOTTLE_HTTP_<status>` 的 `UpstreamJson` 通常长这样：

```json
{
  "success": false,
  "code": 10001,
  "message": "参数错误",
  "data": {},
  "timestamp": "2026-06-02T00:00:00.000Z",
  "path": "/lying-bottle/bottles/.../gacha/draw"
}
```

常见 HTTP / API 错误：

| HTTP | API code | 常见原因 |
| --- | --- | --- |
| 400 | `PARAM_ERROR` 10001 | body / query 不符合接口 DTO |
| 401 | `UNAUTHORIZED` 10002 | 用户登录状态失效 |
| 403 | `FORBIDDEN` 10003 | 权限或签名校验失败 |
| 404 | `NOT_FOUND` 10004 | `subBottleId` 不存在或不属于当前用户 |
| 409 | `CONFLICT` 10005 | requestId 被错误复用到不同业务操作 |
| 500 | `INTERNAL_ERROR` 10000 | 服务端错误，可按策略重试 |

## 10. requestId 和重试

业务 requestId：

- `MutationBase.requestId`：用于 `startup-sync`、升级能力、购券、抽卡。
- `progressSubmission.requestId`：用于 `coin/progress` 和 `coin/info`。
- `StartSession` / `EndSession` 当前没有 body-level `requestId`。

规则：

- 同一次业务操作失败后重试，要复用同一个业务 requestId。
- 新的一次业务操作要生成新的业务 requestId。
- requestId 代表一次业务操作，不绑定 Unity 帧或按钮点击次数。
- 同一个 requestId 只用于同一个业务操作，例如一次抽卡或一次购券。

示例：

```csharp
var requestId = System.Guid.NewGuid().ToString();
var body = new
{
    requestId,
    poolKey = "default",
    drawCount = 1,
};

try
{
    await IGPLyingBottle.DrawGachaAsync<object, string>(runtimeManager, subBottleId, body);
}
catch (IGPLyingBottleException ex)
{
    var status = ex.HttpStatus();
    if (status.HasValue && status.Value >= 500)
    {
        // 重试同一次抽卡，复用同一个 body 和 requestId。
        await IGPLyingBottle.DrawGachaAsync<object, string>(runtimeManager, subBottleId, body);
    }
    else
    {
        throw;
    }
}
```

## 11. 使用规则

HTTP 调用链路：

```text
Unity 游戏 -> IGPLyingBottle -> desktop session
```

route 写法：

```csharp
// 使用 SDK 内置 route 常量。
IGPLyingBottleRoutes.GachaDraw
```

Lying Bottle payload：

```csharp
// appId 配在 IGPConfig；业务 payload 只放接口字段。
new { requestId, poolKey = "default", drawCount = 1 }
```

成功响应：

```csharp
// 成功响应按 route 的响应类型接收；失败时通过 IGPLyingBottleException 处理。
var response = await IGPLyingBottle.GetBottlesAsync<ListLyingBottlesResponse>(runtimeManager);
```

初始化顺序：

```csharp
await runtimeManager.InitializeAsync();
await IGPLyingBottle.GetBottlesAsync<string>(runtimeManager);
```

大整数余额字段：

```csharp
string balance = response.ticketBalance;
// 或使用 System.Numerics.BigInteger 做计算。
```

## 12. 接入检查清单

- 已导入 `cn.indiegp.sdk.unity`。
- 已导入 `cn.indiegp.sdk.unity.lying-bottle`。
- 场景里有 `IGPRuntimeManager`。
- `IGPConfig.appId` 已填写。
- 游戏启动流程已调用 `InitializeAsync()`。
- desktop 可用且用户已登录。
- Lying Bottle 调用发生在初始化成功之后。
- POST 请求体字段与第 7 节一致。
- 带业务 requestId 的请求，重试时复用同一个 requestId。
- 成功响应按 route 的响应类型接收；失败响应才有 error envelope。
- 大整数余额字段按 string 处理。
- 错误日志保留 `IGPLyingBottleException.Code` 和 `UpstreamJson`。
