Commit a3cd11bdc85ec7078f80f89421dcff03173a00c2

Authored by yibo
0 parents

chore: 初始化 vokoworld 仓库骨架(初始化清单第 1–2 步)

- 从 shadiao-2d(stage@5bae6b7)搬入 19 个包与工程规则:ESLint 架构红线、Turbo、Vitest、MySQL 本地脚本
- 独立数据库 voko;迁移入口改为 apps/voko-server(待建)
- AGENTS.md 按新产品裁剪;docs/voko 记录文档索引与上游来源
- GitLab CI:install → lint → test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011X363N3XYVt41WUtnjtDZH
Showing 397 changed files with 47055 additions and 0 deletions

Too many changes.

To preserve performance only 100 of 397 files are displayed.

.gitignore 0 → 100644
  1 +++ a/.gitignore
  1 +node_modules/
  2 +.DS_Store
  3 +dist/
  4 +*.log
  5 +*.db
  6 +coverage/
  7 +tmp/
  8 +.out/
  9 +.eslintcache
  10 +.pnpm-store/
  11 +.turbo
  12 +*.timestamp-*.mjs
  13 +
  14 +# 真密钥只进本地 .env,永不入库
  15 +.env
  16 +.env.*
  17 +!.env.example
  18 +!.env.*.example
... ...
.gitlab-ci.yml 0 → 100644
  1 +++ a/.gitlab-ci.yml
  1 +image: node:24
  2 +
  3 +stages:
  4 + - check
  5 +
  6 +cache:
  7 + key:
  8 + files:
  9 + - pnpm-lock.yaml
  10 + paths:
  11 + - .pnpm-store/
  12 +
  13 +check:
  14 + stage: check
  15 + before_script:
  16 + - corepack enable
  17 + - pnpm config set store-dir .pnpm-store
  18 + - pnpm install --frozen-lockfile
  19 + script:
  20 + - pnpm lint
  21 + - pnpm test
... ...
.nvmrc 0 → 100644
  1 +++ a/.nvmrc
  1 +24
... ...
AGENTS.md 0 → 100644
  1 +++ a/AGENTS.md
  1 +# Repository Guidelines
  2 +
  3 +VOKO Agent 世界(voko-world)。pnpm 11 + Turbo monorepo,Node >=24,ESM JavaScript + JSDoc。应用在 `apps/*`,可复用包在 `packages/*`。
  4 +
  5 +本仓库于 2026-10-10 从 shadiao-2d 搬入 19 个包后独立演进;搬入来源与后续同步记录见 `docs/voko/upstream.md`。产品与设计文档见 `docs/voko/README.md`。
  6 +
  7 +## 应用与端口
  8 +
  9 +| app | 说明 | dev 端口 |
  10 +| --- | --- | --- |
  11 +| @voko/server | VOKO API(`/v1` 公开接口、`/api/voko` 网页与后台接口),连 `voko` 库 | 9201 |
  12 +| @voko/worker | 定时任务与 Webhook 投递(与 server 同目录,独立进程) | — |
  13 +| @voko/web | 员工端与 `/admin` 后台,`/api` → 9201 | 5201 |
  14 +
  15 +(以上 app 在 W1 初始化步骤 3、4 建立。)
  16 +
  17 +## 命令
  18 +
  19 +- `pnpm install`;`pnpm mysql:up` 启动本地 MySQL 并确保 `voko` 库存在(与 shadiao-2d 共用同一个本地 MySQL 服务也可以,库名不同)。
  20 +- `pnpm lint` / `pnpm test`:架构红线与全量测试。CI 即 install → lint → test(schema:check 在建立 voko-server 后加入)。
  21 +
  22 +## 文档入口
  23 +
  24 +- 产品、设计、计划:`docs/voko/README.md`。
  25 +- 新增或修改可执行流程时,必须新增或更新运行手册(`docs/runbooks/`)并登记到索引。只增加脚本或 `package.json` 命令不算完成。
  26 +
  27 +## 边界(ESLint 强制)
  28 +
  29 +- app 之间禁止源码级互相导入;跨应用只走 HTTP/API、contracts 或 packages 公开能力。
  30 +- 包一律从 `@drama/<name>` 公开入口导入(各包 API 从 `src/index.js` 汇出),禁止深挖别包 `src/*`。
  31 +- 数据库和外部服务连接只在 composition root 创建;包内禁止顶层创建连接。运行参数在实际使用点读取 `process.env`,禁止为转发配置增加通用配置对象和无业务逻辑的参数链路。
  32 +- 包职责:`contracts` 接口 schema 与快照;`auth`/`account`/`billing`/`payment` 业务域;`core`/`render-pixi`/`player-vue` 播放链路;`ui` 基础组件与主题 token;`config` 共享预设。
  33 +
  34 +## 数据库
  35 +
  36 +- 本产品使用独立库 `voko`,不连接 shadiao-2d 的 `idearun` / `idearun_visual_game`。
  37 +- 结构事实源:`packages/*/src/db/tables/*.cjs`(当前完整结构)+ `apps/voko-server/src/db/migrations/*.js`(版本间变更)。迁移历史表 `voko_schema_migrations`。服务启动不执行 DDL。
  38 +- 迁移只在合入主干后,才允许在 stage / 生产等共享库执行;开发只连本地库。
  39 +- 时间列一律 `DATETIME(3)`,值为 UTC;写入传 Date 对象,API 返回 ISO 8601 字符串。
  40 +- 全链路 snake_case:库列、函数参数、返回对象、contracts、请求/响应体、前端状态同名透传,不做任何大小写转换或多名兜底。
  41 +
  42 +## 环境变量
  43 +
  44 +- 连接与基础设施配置由各 app 的 `src/env.js` 统一解析;业务运行参数由所属模块直接读取。
  45 +- 真实密钥放各 app gitignored 的 `.env`,不提交;也不提交本地数据库文件和 `node_modules`。
  46 +
  47 +## 代码
  48 +
  49 +- 两空格缩进、单引号、无分号;类型说明用 JSDoc,不写 TypeScript 语法。
  50 +- 工艺规范唯一权威源 `~/.codex/code-standards.md`;写代码、改代码、写脚本、做评审前必须读取并遵守该文件。评审对照它逐条举反例,lint 干净不作质量证据。
  51 +- 服务方法把 `ctx` 作为显式第一参数一路传到 service/repo(`packages/observability/CONVENTION.md`),不用 AsyncLocalStorage 或全局单例。
  52 +
  53 +## 代码生成硬规则
  54 +
  55 +### 心智模型(最高优先级,先读这个)
  56 +
  57 +系统分为入口和内部。入口有且只有四类:
  58 +1. HTTP controller 的请求入参
  59 +2. 消息/任务消费的 payload
  60 +3. 第三方 API / 外部服务的响应
  61 +4. DB row 读取后转成业务数据的那一次转换
  62 +
  63 +数据在入口处被一次性校验并构造成正确形状,校验失败立即 throw。
  64 +过了入口的数据无条件信任:内部代码不判空、不兜底、不猜字段、不怀疑。
  65 +内部代码觉得数据可疑 = 入口漏了或构造函数没把不变量执行到位,
  66 +去修入口和构造函数,禁止在现场打补丁。
  67 +
  68 +### 事实源
  69 +
  70 +- 写任何代码前,先定位:数据生产者、schema/migration、现有调用方、相关测试。
  71 + 以生产者和 schema 为唯一事实源。
  72 +- 字段结构不确定时,读代码、读 migration、查真实数据。查完仍不确定就停下来问。
  73 +- 禁止一切猜测式读取:`a || b || c`、`x?.foo ?? x?.bar`、
  74 + `typeof x.foo === 'string'` 探测、`Array.isArray` 兜底分支,全部不许。
  75 +
  76 +### 数据形状
  77 +
  78 +- 每类核心业务数据(订单、会员段、积分流水等)有唯一构造点,
  79 + 普通函数即可(如 orderFromRow):非法输入当场 throw,
  80 + 带数据 id 和具体哪个字段的问题。
  81 +- 状态相关的必有字段由构造点按 status 保证(paid 必有 paidAt,缺失即 throw),
  82 + 下游读取零判空——内部代码出现判空,视为构造点没做到位,回去修构造点。
  83 +- 核心格式使用 JSDoc / d.ts 说明清楚。
  84 +
  85 +### 状态消费
  86 +
  87 +- 消费 status 的 switch 必须穷举所有已知状态,且必须写 default 分支,
  88 + default 只许 `throw new Error("Unhandled status: <值>, id: <数据id>")`。
  89 + 禁止 default 里给默认行为或静默跳过。
  90 +- 同一个 status 的 switch 散落超过两三处时,把行为收进该数据的变换函数
  91 + 或 reducer,不许继续复制分支。
  92 +
  93 +### 数据库约束
  94 +
  95 +- 唯一性、非空、状态与字段的组合合法性,一律下沉为 DB 约束:
  96 + NOT NULL / UNIQUE / CHECK(如 `CHECK (status != 'refunded' OR refunded_at IS NOT NULL)`)。
  97 +- 禁止用"先 SELECT 判断再 INSERT/UPDATE"在应用层模拟约束——有并发窗口,不可靠。
  98 +
  99 +### 错误处理
  100 +
  101 +- 区分两类失败:
  102 + - 预期失败(用户输错、余额不足、并发冲突):用显式返回值或明确的业务错误建模,
  103 + 调用方必须处理;
  104 + - 程序 bug(不变量被违反、不可能的状态):带完整上下文 throw,
  105 + 交给全局 error handler 记日志、报警、返回 500。
  106 +- 中间层禁止 try-catch。禁止 catch 后吞掉、catch 后返回默认值、
  107 + catch 后包一层泛化错误重抛。
  108 +- try-catch 只允许出现在三处:全局 error handler、入口校验处、
  109 + 对外部 IO(第三方 API、文件、网络)的最小包裹。
  110 +- 原则:宁可当场 500,不许静默写脏数据。涉及金额、会员权益的路径尤其如此。
  111 +
  112 +### 兼容与迁移
  113 +
  114 +- 新代码禁止同时支持新旧两套数据形状。禁止:多字段兜底、大小写兜底、
  115 + 旧字段别名、猜测式解析、默认值吞错、空数组/空对象兜底、遗留分支保留。
  116 +- 发现存量数据、线上接口、已发版客户端依赖旧形状时:停止,报告冲突,
  117 + 给出迁移方案(migration 脚本 + 切换步骤 + 回滚点),等确认后执行。
  118 + 禁止自行改线上 schema、删在用字段,也禁止用兼容层绕过。
  119 +
  120 +### 测试与诊断
  121 +
  122 +- 生产、开发、测试、诊断、验收代码一律禁止 fake / mock / stub / spy、内存替身、
  123 + 伪造第三方响应、伪造数据库结果、手工插入业务终态,也禁止 broken implementation
  124 + 或测试专用开关。外部系统只用真实服务或官方 sandbox/test mode;真实依赖不可用就明确
  125 + `BLOCKED` / 未验证,不准用替身补绿。任务触达范围内的既有替身必须删除并改走真实链路。
  126 +- 禁止无用纯单元测试。纯单元测试只有在直接调用现有生产纯算法、零替身、验证明确业务
  127 + 不变量/数学公式/完整状态全集,且同任务已有真实端到端链路时才允许;字段搬运、配置映射、
  128 + 默认值、getter/setter、调用次数、mock 交互和代码存在性测试一律禁止。
  129 +- 测试数据必须符合生产 schema 的真实形状,禁止 mock 出宽容的假结构
  130 + 为兼容代码制造存在理由。
  131 +- 涉及状态机的测试,必须覆盖全部已知 status 值——
  132 + 没有编译器做穷尽检查,测试就是穷尽检查。
  133 +- 诊断脚本必须验证具体问题,复用生产 schema 和生产函数;
  134 + 失败就直接失败,失败信息要能定位到数据,不写通用兼容层。负路径必须把真实非法输入
  135 + 或真实失败状态送入同一套生产入口,禁止另造错误实现证明脚本会红。
  136 +
  137 +## 前端
  138 +
  139 +- 文案走 `vue-i18n`,新增 key 补齐 `en/zh/ja/es/id/pt`。
  140 +- 颜色/间距/圆角/阴影用 `var(--drama-*)` token(`packages/ui/src/theme/`),不硬编码 hex/rgba。
  141 +- 优先复用 `@drama/ui` 的 Button/Card/Modal/ToastHost;页面只做装配,不写鉴权协议、SQL、厂商 SDK。
  142 +- 设计参考 `docs/design/IMPLEMENTATION-STANDARD.md`、`docs/design/screen-to-module-map.md`。
  143 +
  144 +## 测试
  145 +
  146 +- Vitest 是自动化测试套件的唯一 runner,用例放各 workspace 的 `test/*.test.js`;真实链路验收脚本放对应 app/package 的 `tools/scenarios/`,通过明确的 `node` 或 `pnpm` 命令运行,不伪装成纯单元测试。
  147 +- 自动化测试禁止 `CREATE DATABASE` / `DROP DATABASE`,也禁止随机生成数据库名。真实链路场景只连接本地 `voko` 库,通过真实业务入口写入生产形状数据并按场景精确清理。
  148 +- 改哪个包先 `pnpm --filter <workspace> test`;改共享契约、路由守卫、支付、鉴权、播放链路再跑全量 `pnpm test` + `pnpm lint`。这些命令只作回归门禁,不能替代真实链路验收。
  149 +
  150 +## 提交与 PR
  151 +
  152 +- 提交信息用短 scope 风格(`fix(server): ...`),写清变更对象和动作。
  153 +- PR 带变更摘要与验证命令结果;UI 改动附截图;contract/schema 改动说明迁移影响。
... ...
README.md 0 → 100644
  1 +++ a/README.md
  1 +# vokoworld
  2 +
  3 +VOKO Agent 世界:任务交换 + 积分清算与确认 + 游戏化可视化。
  4 +
  5 +## 本地启动
  6 +
  7 +```bash
  8 +nvm use # Node 24(见 .nvmrc)
  9 +corepack enable # 使用 package.json 指定的 pnpm 11.5.3
  10 +pnpm install
  11 +pnpm mysql:up # 本地 MySQL,确保 voko 库存在
  12 +pnpm lint && pnpm test
  13 +```
  14 +
  15 +开发规范见 `AGENTS.md`,产品与设计文档见 `docs/voko/README.md`。
... ...
docker-compose.yml 0 → 100644
  1 +++ a/docker-compose.yml
  1 +services:
  2 + mysql:
  3 + image: mysql:8.4
  4 + ports:
  5 + - "${MYSQL_PORT:-3306}:3306"
  6 + # 口令没有默认值:容器口令必须和代码里连库用的口令是同一个来源,否则起来的库谁也连不上。
  7 + # 唯一入口是 `pnpm mysql:up`(tools/scripts/mysql-dev.js),它从 @drama/db 的 devMysqlUrl()
  8 + # 解析出口令注进来。直接 `docker compose up mysql` 会明确报缺变量,而不是悄悄起一个错口令的库。
  9 + environment:
  10 + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?run `pnpm mysql:up` instead of `docker compose up mysql`}
  11 + MYSQL_DATABASE: ${MYSQL_DATABASE:?run `pnpm mysql:up` instead of `docker compose up mysql`}
  12 + command:
  13 + - --character-set-server=utf8mb4
  14 + - --collation-server=utf8mb4_unicode_ci
  15 + volumes:
  16 + - mysql-data:/var/lib/mysql
  17 + - ./docker/mysql/init:/docker-entrypoint-initdb.d:ro
  18 + healthcheck:
  19 + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
  20 + interval: 5s
  21 + timeout: 3s
  22 + retries: 30
  23 +
  24 +volumes:
  25 + mysql-data:
... ...
docker/mysql/init/01-databases.sql 0 → 100644
  1 +++ a/docker/mysql/init/01-databases.sql
  1 +CREATE DATABASE IF NOT EXISTS `voko`
  2 + CHARACTER SET utf8mb4
  3 + COLLATE utf8mb4_unicode_ci;
... ...
docs/voko/README.md 0 → 100644
  1 +++ a/docs/voko/README.md
  1 +# VOKO 文档索引
  2 +
  3 +| 文档 | 用途 | 链接 |
  4 +| --- | --- | --- |
  5 +| VOKO Alpha PRD | 用户故事与验收标准(F0–F4)、模块、页面 | https://claude.ai/code/artifact/247532d7-efb9-4d95-bf02-10f2c5fdc281 |
  6 +| VOKO Alpha SDD | 技术设计:数据模型、状态机、清算、接入层、worker | https://claude.ai/code/artifact/e3fa5c34-d943-404c-849f-9a2ad241491f |
  7 +| VOKO Alpha Planning | W1–W6 分周计划、54 个测试用例、验收脚本 | https://claude.ai/code/artifact/27b94ca4-ccfb-420b-9193-b81eaaca09cf |
  8 +| VOKO Agent 接入说明 | 公开 API、游戏规则参数、skill.md / heartbeat.md 草稿 | https://claude.ai/code/artifact/1c0e789b-7b54-4cd7-971c-31ec7db2bee4 |
  9 +| voko-world 仓库初始化清单 | 本仓库的搭建步骤与验收 | https://claude.ai/code/artifact/20a68ea3-4b05-49fd-9173-520b80733534 |
  10 +| VOKO Beta PRD | 公测阶段需求 | https://claude.ai/code/artifact/8260dfaa-ad11-4283-a651-c7db785a0c6b |
  11 +
  12 +以上为在线文档;进入开发后关键结论以本目录下的导出版为准(W1 内导出)。
... ...
docs/voko/upstream.md 0 → 100644
  1 +++ a/docs/voko/upstream.md
  1 +# 搬入来源与同步记录
  2 +
  3 +## 来源
  4 +
  5 +- 仓库:shadiao-2d
  6 +- 分支:stage
  7 +- 提交:5bae6b786ec2f7d961497690b004e463c07bd848(2026-09-09),搬入日 2026-10-10,被搬入文件在工作区无未提交改动
  8 +- 搬入的包(19 个):auth、billing、config、config-source、contracts、core、db、email、i18n、llm、llm-router、moderation、observability、ratelimit、render-pixi、rig、server-kit、service-call、ui
  9 +- 另搬入:`tools/eslint-rules/`、`eslint.config.js`、`turbo.json`、`vitest.workspace.js`、`docker-compose.yml`、`tools/scripts/mysql-dev.js`、`pnpm-lock.yaml`(安装时已按本仓库依赖裁剪)
  10 +
  11 +## 搬入时的改动
  12 +
  13 +| 文件 | 改动 |
  14 +| --- | --- |
  15 +| `eslint.config.js` | 删除 callchain 包的忽略与规则块 |
  16 +| `tools/eslint-rules/config.js` | COMPOSITION_ROOTS 改为 `apps/voko-server` 下的入口 |
  17 +| `tools/scripts/mysql-dev.js` | 默认库 `voko`,默认 env 文件 `apps/voko-server/.env` |
  18 +| `packages/llm-router/package.json` | 测试脚本加 `--passWithNoTests`(上游该包目前没有测试文件,`vitest run` 会以 1 退出) |
  19 +| `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` |
  20 +
  21 +## 待裁剪(原样搬入,后续处理)
  22 +
  23 +- `@drama/contracts`:仍含 drama / visual-game / waitlist 等 schema 与 parse 助手;VOKO 自己的 schema 放新包,待 voko-server 只依赖 platform 子路径后再裁剪。
  24 +- `@drama/auth` 的 `fake-repo.js`、`test/fakes.js` 与 `@drama/moderation` 的 `fake.js`:违反"禁止替身",需改写为真实 MySQL 测试后删除。
  25 +- `@drama/email` 模板:`video-ready`、`order-notice` 与本产品无关;`waitlist-invite` 可改作加入申请通过通知。
  26 +- `@drama/i18n`:保留全部语言(海外产品后续需要 ja / ko 等),删除 drama / visual-game 文案待前端成形后进行。
  27 +
  28 +## 同步纪律
  29 +
  30 +- 每两周对照一次 shadiao-2d 中 `billing`、`auth`、`db`、`email`、`server-kit` 的提交,修复类提交按需搬入,并在下方追加记录。
  31 +- 本仓库对这些包的修复如 shadiao-2d 也需要,向 shadiao-2d 提 MR。
  32 +
  33 +## 同步记录
  34 +
  35 +| 日期 | 包 | 上游提交 | 说明 |
  36 +| --- | --- | --- | --- |
... ...
eslint.config.js 0 → 100644
  1 +++ a/eslint.config.js
  1 +/**
  2 + * ESLint flat config(根)—— 装配本地架构红线 plugin + 基础 JS 规则。
  3 + *
  4 + * 声明式:判定逻辑全在 `tools/eslint-rules/*.js`,红线装配(白名单 / 规则集 / globals)
  5 + * 单源于 `tools/eslint-rules/config.js`,本文件与 `packages/config/eslint` 都从那里 import,
  6 + * 不各自再抄一份。根配置只 import 相对路径的 `tools/eslint-rules/*`,不 import 任何 workspace 包
  7 + * (避免把 config 的依赖拖进 lint 链)。
  8 + *
  9 + * composition-root / vite 配置白名单在单源 `config.js` 里维护,新增豁免点先改任务文档再改那里。
  10 + */
  11 +
  12 +import js from '@eslint/js'
  13 +import { dramaPlugin } from './tools/eslint-rules/index.js'
  14 +import {
  15 + buildArchRules,
  16 + BASE_GLOBALS,
  17 + BROWSER_GLOBALS,
  18 + COMMONJS_GLOBALS,
  19 +} from './tools/eslint-rules/config.js'
  20 +
  21 +const ARCH_RULES = buildArchRules()
  22 +
  23 +export default [
  24 + {
  25 + ignores: [
  26 + '**/node_modules/**',
  27 + '**/dist/**',
  28 + '**/coverage/**',
  29 + 'tmp/**',
  30 + ],
  31 + },
  32 + js.configs.recommended,
  33 + {
  34 + files: ['**/*.js', '**/*.mjs'],
  35 + languageOptions: {
  36 + ecmaVersion: 2023,
  37 + sourceType: 'module',
  38 + globals: {
  39 + ...BASE_GLOBALS,
  40 + ...BROWSER_GLOBALS,
  41 + },
  42 + },
  43 + plugins: { drama: dramaPlugin },
  44 + rules: { ...ARCH_RULES },
  45 + },
  46 + {
  47 + files: ['**/*.cjs'],
  48 + languageOptions: {
  49 + ecmaVersion: 2023,
  50 + sourceType: 'commonjs',
  51 + globals: { ...COMMONJS_GLOBALS },
  52 + },
  53 + plugins: { drama: dramaPlugin },
  54 + rules: { ...ARCH_RULES },
  55 + },
  56 + {
  57 + // 规则/预设单测 fixture 含故意违规代码与故意的 env 操作,不参与全仓 lint。
  58 + ignores: ['tools/eslint-rules/test/**', 'packages/config/test/**'],
  59 + },
  60 +]
... ...
package.json 0 → 100644
  1 +++ a/package.json
  1 +{
  2 + "name": "vokoworld",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "packageManager": "pnpm@11.5.3",
  7 + "engines": {
  8 + "node": ">=24"
  9 + },
  10 + "scripts": {
  11 + "dev": "turbo run dev",
  12 + "mysql:up": "node tools/scripts/mysql-dev.js up",
  13 + "mysql:ensure": "node tools/scripts/mysql-dev.js ensure",
  14 + "mysql:status": "node tools/scripts/mysql-dev.js status",
  15 + "mysql:stop": "node tools/scripts/mysql-dev.js stop",
  16 + "build": "turbo run build",
  17 + "test": "turbo run test",
  18 + "test:all": "vitest run",
  19 + "lint": "eslint ."
  20 + },
  21 + "devDependencies": {
  22 + "@drama/db": "workspace:*",
  23 + "@eslint/js": "^9.13.0",
  24 + "@types/estree": "^1.0.6",
  25 + "@types/node": "^24.0.0",
  26 + "eslint": "^9.13.0",
  27 + "ioredis": "^5.11.1",
  28 + "playwright": "^1.61.0",
  29 + "turbo": "^2.9.18",
  30 + "vitest": "^2.1.4"
  31 + },
  32 + "dependencies": {
  33 + "vue": "^3.5.38",
  34 + "vue-i18n": "^11.4.5"
  35 + }
  36 +}
... ...
packages/auth/package.json 0 → 100644
  1 +++ a/packages/auth/package.json
  1 +{
  2 + "name": "@drama/auth",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "exports": {
  7 + ".": "./src/index.js"
  8 + },
  9 + "scripts": {
  10 + "test": "vitest run"
  11 + },
  12 + "peerDependencies": {
  13 + "jose": "^5.9.0",
  14 + "argon2": "^0.41.0",
  15 + "google-auth-library": "^9.14.0"
  16 + },
  17 + "peerDependenciesMeta": {
  18 + "jose": {
  19 + "optional": true
  20 + },
  21 + "argon2": {
  22 + "optional": true
  23 + },
  24 + "google-auth-library": {
  25 + "optional": true
  26 + }
  27 + }
  28 +}
... ...
packages/auth/src/auth.js 0 → 100644
  1 +++ a/packages/auth/src/auth.js
  1 +/**
  2 + * createAuth —— 唯一入口工厂,只做依赖装配 + 各能力编排。
  3 + *
  4 + * 纪律:本文件不碰任何密码学(全在注入 Port);缺依赖在装配时即 assert 抛错
  5 + * (不拖到运行某行);每条业务流程在独立文件(register/login/oauth/refresh/code)。
  6 + * verifyAccessToken / logout / revokeAllSessions 是薄方法,直接落本文件。
  7 + */
  8 +
  9 +import { AuthError } from './errors.js'
  10 +import { makeRegister } from './register.js'
  11 +import { makeLogin } from './login.js'
  12 +import { makeOauthLogin } from './oauth.js'
  13 +import { makeRefresh } from './refresh.js'
  14 +import { makeSendCode, makeVerifyCode } from './code.js'
  15 +import { makeResetPassword } from './reset-password.js'
  16 +import { makeRequestPasswordReset } from './request-password-reset.js'
  17 +import { parseRefreshToken } from './session.js'
  18 +
  19 +/**
  20 + * @param {Object} [deps]
  21 + * @param {import('./index.js').UserRepo} [deps.userRepo]
  22 + * @param {import('./index.js').EmailPort} [deps.email]
  23 + * @param {import('./index.js').OauthPort} [deps.oauth]
  24 + * @param {import('./index.js').TokenSigner} [deps.tokenSigner]
  25 + * @param {import('./index.js').Hasher} [deps.hasher]
  26 + * @param {Object} [opts] 可选的业务规则覆写(TTL / 阈值 / 窗口)。
  27 + * @returns {import('./index.js').Auth}
  28 + */
  29 +export function createAuth({ userRepo, email, oauth, tokenSigner, hasher } = {}, opts = {}) {
  30 + assertDep('userRepo', userRepo, ['findByEmail', 'create', 'updatePasswordHash', 'saveSession', 'findSession'])
  31 + assertDep('email', email, ['sendCode'])
  32 + assertDep('oauth', oauth, ['exchange'])
  33 + assertDep('tokenSigner', tokenSigner, ['sign', 'verify'])
  34 + assertDep('hasher', hasher, ['hash', 'verify'])
  35 +
  36 + const deps = /** @type {{ userRepo: import('./index.js').UserRepo, email: import('./index.js').EmailPort, oauth: import('./index.js').OauthPort, tokenSigner: import('./index.js').TokenSigner, hasher: import('./index.js').Hasher, maxAttempts?: number, windowMs?: number, accessTtlMs?: number, refreshTtlMs?: number, ttlMs?: number, rand?: () => number }} */ (
  37 + { userRepo, email, oauth, tokenSigner, hasher, ...opts }
  38 + )
  39 +
  40 + const register = makeRegister(deps)
  41 + const login = makeLogin(deps)
  42 + const oauthLogin = makeOauthLogin(deps)
  43 + const refresh = makeRefresh(deps)
  44 + const sendCode = makeSendCode(deps)
  45 + const requestPasswordReset = makeRequestPasswordReset({ userRepo: deps.userRepo, sendCode })
  46 + const verifyCode = makeVerifyCode(deps)
  47 + const resetPassword = makeResetPassword(deps)
  48 +
  49 + /** @type {import('./index.js').Auth['verifyAccessToken']} */
  50 + async function verifyAccessToken(token) {
  51 + if (!token || typeof token !== 'string') {
  52 + throw new AuthError({ code: 'TOKEN_INVALID' })
  53 + }
  54 + try {
  55 + const claims = await deps.tokenSigner.verify(token)
  56 + await assertAccessSessionActive(claims)
  57 + return claims
  58 + } catch (/** @type {any} */ err) {
  59 + if (err instanceof AuthError) throw err
  60 + // jose 用 'ERR_JWT_EXPIRED'(code 或 name)区分过期与无效签名。
  61 + const tag = (err && (err.code || err.name)) || ''
  62 + const expired = /EXPIRED/i.test(String(tag))
  63 + throw new AuthError({ code: expired ? 'TOKEN_EXPIRED' : 'TOKEN_INVALID', cause: err })
  64 + }
  65 + }
  66 +
  67 + /**
  68 + * @param {Record<string, any>} claims
  69 + */
  70 + async function assertAccessSessionActive(claims) {
  71 + const sid = String(claims?.sid || '')
  72 + if (!sid) throw new AuthError({ code: 'TOKEN_INVALID' })
  73 + const session = await deps.userRepo.findSession({ sid })
  74 + if (!session || session.revoked) throw new AuthError({ code: 'TOKEN_INVALID' })
  75 + if (Date.now() > session.expires_at.getTime()) {
  76 + throw new AuthError({ code: 'TOKEN_EXPIRED' })
  77 + }
  78 + }
  79 +
  80 + /** @type {import('./index.js').Auth['logout']} */
  81 + async function logout({ refresh_token }) {
  82 + const parsed = parseRefreshToken(refresh_token)
  83 + if (parsed) {
  84 + const session = await deps.userRepo.findSession({ sid: parsed.sid })
  85 + if (session) await deps.userRepo.revokeFamily({ family: session.family })
  86 + }
  87 + return { ok: true }
  88 + }
  89 +
  90 + /** @type {import('./index.js').Auth['revokeAllSessions']} */
  91 + async function revokeAllSessions({ user_id }) {
  92 + if (!user_id) throw new AuthError({ code: 'USER_NOT_FOUND' })
  93 + await deps.userRepo.revokeUserSessions({ user_id })
  94 + return { ok: true }
  95 + }
  96 +
  97 + /**
  98 + * @param {{ user_id: string }} args
  99 + */
  100 + async function getUser({ user_id }) {
  101 + if (!user_id || typeof deps.userRepo.findById !== 'function') return null
  102 + return deps.userRepo.findById(user_id)
  103 + }
  104 +
  105 + return {
  106 + register,
  107 + login,
  108 + oauthLogin,
  109 + refresh,
  110 + verifyCode,
  111 + sendCode,
  112 + requestPasswordReset,
  113 + resetPassword,
  114 + getUser,
  115 + verifyAccessToken,
  116 + logout,
  117 + revokeAllSessions,
  118 + }
  119 +}
  120 +
  121 +/**
  122 + * 装配时依赖自检:缺整个依赖或缺关键方法都立刻抛 AuthError。
  123 + * @param {string} name
  124 + * @param {any} dep
  125 + * @param {string[]} methods
  126 + */
  127 +function assertDep(name, dep, methods) {
  128 + if (!dep || typeof dep !== 'object') {
  129 + throw new AuthError({ code: 'TOKEN_INVALID', message: `createAuth: missing dependency "${name}"` })
  130 + }
  131 + for (const m of methods) {
  132 + if (typeof dep[m] !== 'function') {
  133 + throw new AuthError({
  134 + code: 'TOKEN_INVALID',
  135 + message: `createAuth: dependency "${name}" missing method "${m}"`,
  136 + })
  137 + }
  138 + }
  139 +}
... ...
packages/auth/src/bruteforce.js 0 → 100644
  1 +++ a/packages/auth/src/bruteforce.js
  1 +/**
  2 + * 防爆破 —— 窗口计数 + 阈值判定的纯逻辑(计数存储走注入的 repo,不用进程内 Map)。
  3 + *
  4 + * 判定逻辑(`evaluateAttempts`)是纯函数:给定窗口内已记录的失败次数、阈值、窗口长度,
  5 + * 返回是否锁定与 `retryAfterMs`。落库/读库由 repo 完成,本文件只决策。
  6 + *
  7 + * 默认阈值/窗口可由调用方覆写(开闭原则:改规则只动这里的常量或注入 opts)。
  8 + */
  9 +
  10 +export const DEFAULT_MAX_ATTEMPTS = 5
  11 +export const DEFAULT_WINDOW_MS = 15 * 60 * 1000 // 15 分钟
  12 +
  13 +/**
  14 + * 把 email + ip 归一成防爆破计数 key(同标识合并计数)。
  15 + * @param {string} email
  16 + * @param {string} [ip]
  17 + * @returns {string}
  18 + */
  19 +export function attemptKey(email, ip) {
  20 + const e = String(email ?? '').trim().toLowerCase()
  21 + return ip ? `${e}|${ip}` : e
  22 +}
  23 +
  24 +/**
  25 + * 纯判定:当前窗口失败次数是否已达阈值;若是,给出还需等待多久。
  26 + *
  27 + * `retryAfterMs` = 窗口长度(保守估计:最早一条失败记录在窗口边界时需等待整个窗口;
  28 + * 若提供 `oldestAt` 与 `now` 则给出精确剩余时间)。
  29 + *
  30 + * @param {Object} args
  31 + * @param {number} args.recentCount 窗口内已记录的失败次数
  32 + * @param {number} [args.maxAttempts]
  33 + * @param {number} [args.windowMs]
  34 + * @param {number} [args.oldestAt] 窗口内最早一条失败记录的时间戳(epoch ms),可选精确化
  35 + * @param {number} [args.now]
  36 + * @returns {{ locked: boolean, retryAfterMs: number }}
  37 + */
  38 +export function evaluateAttempts({
  39 + recentCount,
  40 + maxAttempts = DEFAULT_MAX_ATTEMPTS,
  41 + windowMs = DEFAULT_WINDOW_MS,
  42 + oldestAt,
  43 + now = Date.now(),
  44 +}) {
  45 + const locked = recentCount >= maxAttempts
  46 + if (!locked) return { locked: false, retryAfterMs: 0 }
  47 + let retryAfterMs = windowMs
  48 + if (typeof oldestAt === 'number') {
  49 + // 最早一条失败记录滚出窗口后即可重试。
  50 + retryAfterMs = Math.max(0, oldestAt + windowMs - now)
  51 + }
  52 + return { locked: true, retryAfterMs }
  53 +}
... ...
packages/auth/src/code.js 0 → 100644
  1 +++ a/packages/auth/src/code.js
  1 +/**
  2 + * 验证码 —— sendCode / verifyCode 编排 + 验证码生命周期纯逻辑。
  3 + *
  4 + * 纯逻辑(`generateCode` / `evaluateCode`)可直接单测,无 IO;落库(saveCode/consumeCode)、
  5 + * 投递(email Port)、哈希(hasher)由编排函数注入。验证码本身经 hasher 哈希落库,
  6 + * 库内 grep 不到明文验证码持久化。
  7 + */
  8 +
  9 +import { AuthError } from './errors.js'
  10 +
  11 +export const DEFAULT_CODE_TTL_MS = 10 * 60 * 1000 // 10 分钟
  12 +export const DEFAULT_MAX_CODE_ATTEMPTS = 5
  13 +export const DEFAULT_CODE_SEND_WINDOW_MS = 60 * 1000
  14 +export const DEFAULT_MAX_CODE_SENDS = 3
  15 +export const VALID_PURPOSES = new Set(['register', 'login', 'reset'])
  16 +
  17 +/**
  18 + * 生成 6 位数字验证码(密码学随机)。投递走 email Port,哈希后落库。
  19 + * @param {() => number} [rand] 注入随机源(测试用,缺省 crypto 安全随机)。
  20 + * @returns {string}
  21 + */
  22 +export function generateCode(rand) {
  23 + if (typeof rand === 'function') {
  24 + const n = Math.floor(rand() * 1_000_000)
  25 + return String(((n % 1_000_000) + 1_000_000) % 1_000_000).padStart(6, '0')
  26 + }
  27 + // 无注入时用 Node 内置 CSPRNG(不自研随机)。
  28 + const buf = globalThis.crypto.getRandomValues(new Uint32Array(1))
  29 + return String(buf[0] % 1_000_000).padStart(6, '0')
  30 +}
  31 +
  32 +/**
  33 + * 纯判定:给定已落库的验证码记录与提交的明码校验结果,决定通过 / 抛哪种结构化错误。
  34 + *
  35 + * 分支(各对应一个错误码):
  36 + * - 记录不存在 / purpose 不匹配 → CODE_INVALID
  37 + * - 已过期 → CODE_EXPIRED
  38 + * - 尝试次数超限 → CODE_INVALID(视为失效,调用方应要求重发)
  39 + * - 哈希不匹配 → CODE_INVALID
  40 + *
  41 + * @param {Object} args
  42 + * @param {import('./index.js').CodeRecord|null} args.record
  43 + * @param {string} args.purpose
  44 + * @param {boolean} args.matches hasher.verify(submitted, record.code_hash) 的结果
  45 + * @param {number} [args.maxAttempts]
  46 + * @param {number} [args.now]
  47 + * @returns {{ ok: true }}
  48 + * @throws {AuthError}
  49 + */
  50 +export function evaluateCode({
  51 + record,
  52 + purpose,
  53 + matches,
  54 + maxAttempts = DEFAULT_MAX_CODE_ATTEMPTS,
  55 + now = Date.now(),
  56 +}) {
  57 + if (!record || record.purpose !== purpose) {
  58 + throw new AuthError({ code: 'CODE_INVALID' })
  59 + }
  60 + if (now > record.expires_at.getTime()) {
  61 + throw new AuthError({ code: 'CODE_EXPIRED' })
  62 + }
  63 + if (typeof record.attempts === 'number' && record.attempts >= maxAttempts) {
  64 + throw new AuthError({ code: 'CODE_INVALID' })
  65 + }
  66 + if (!matches) {
  67 + throw new AuthError({ code: 'CODE_INVALID' })
  68 + }
  69 + return { ok: true }
  70 +}
  71 +
  72 +/**
  73 + * sendCode 编排:生成码 → 哈希落库 → 后台经 email Port 投递。
  74 + * @param {Object} deps
  75 + * @param {import('./index.js').UserRepo} deps.userRepo
  76 + * @param {import('./index.js').EmailPort} deps.email
  77 + * @param {import('./index.js').Hasher} deps.hasher
  78 + * @param {number} [deps.ttlMs]
  79 + * @param {number} [deps.sendWindowMs]
  80 + * @param {number} [deps.maxSends]
  81 + * @param {() => number} [deps.rand]
  82 + * @returns {(args: { email: string, purpose: string, locale?: string, ip?: string }) => Promise<{ ok: true }>}
  83 + */
  84 +export function makeSendCode({
  85 + userRepo,
  86 + email,
  87 + hasher,
  88 + ttlMs = DEFAULT_CODE_TTL_MS,
  89 + sendWindowMs = DEFAULT_CODE_SEND_WINDOW_MS,
  90 + maxSends = DEFAULT_MAX_CODE_SENDS,
  91 + rand,
  92 +}) {
  93 + return async function sendCode({ email: to, purpose, locale, ip }) {
  94 + if (!VALID_PURPOSES.has(purpose)) {
  95 + throw new AuthError({ code: 'CODE_INVALID', message: `unsupported purpose: ${purpose}` })
  96 + }
  97 + const normalizedEmail = normEmail(to)
  98 + await guardCodeSend({ userRepo, email: normalizedEmail, purpose, ip, sendWindowMs, maxSends })
  99 + const code = generateCode(rand)
  100 + const code_hash = await hasher.hash(code)
  101 + const expires_at = new Date(Date.now() + ttlMs)
  102 + await userRepo.saveCode({ email: normalizedEmail, purpose, code_hash, expires_at })
  103 + email.sendCode({ to, code, purpose, locale })
  104 + return { ok: true }
  105 + }
  106 +}
  107 +
  108 +/**
  109 + * verifyCode 编排:取记录 → hasher 校验 → evaluateCode 判定。
  110 + * @param {Object} deps
  111 + * @param {import('./index.js').UserRepo} deps.userRepo
  112 + * @param {import('./index.js').Hasher} deps.hasher
  113 + * @param {number} [deps.maxAttempts]
  114 + * @returns {(args: { email: string, code: string, purpose: string }) => Promise<{ ok: true }>}
  115 + */
  116 +export function makeVerifyCode({ userRepo, hasher, maxAttempts = DEFAULT_MAX_CODE_ATTEMPTS }) {
  117 + return async function verifyCode({ email: to, code, purpose }) {
  118 + const record = await userRepo.consumeCode({ email: normEmail(to), purpose })
  119 + const matches = record ? await hasher.verify(code, record.code_hash) : false
  120 + return evaluateCode({ record, purpose, matches, maxAttempts })
  121 + }
  122 +}
  123 +
  124 +/** @param {string} e */
  125 +function normEmail(e) {
  126 + return String(e ?? '').trim().toLowerCase()
  127 +}
  128 +
  129 +async function guardCodeSend({ userRepo, email, purpose, ip, sendWindowMs, maxSends }) {
  130 + const key = codeSendAttemptKey(email, purpose, ip)
  131 + const count = await userRepo.countRecentAttempts({ key, windowMs: sendWindowMs })
  132 + if (count >= maxSends) {
  133 + throw new AuthError({ code: 'TOO_MANY_ATTEMPTS', retryAfterMs: sendWindowMs })
  134 + }
  135 + await userRepo.recordAttempt({ key, ip, at: new Date() })
  136 +}
  137 +
  138 +function codeSendAttemptKey(email, purpose, ip) {
  139 + return `code:${purpose}:${email}:${ip || '*'}`
  140 +}
... ...
packages/auth/src/db/README.md 0 → 100644
  1 +++ a/packages/auth/src/db/README.md
  1 +# @drama/auth Database Schema
  2 +
  3 +这里是 `@drama/auth` 当前表结构的唯一阅读入口。
  4 +
  5 +- `tables/`:一个表一个文件,文件名等于表名。
  6 +- `tables/index.cjs`:批量加载入口,只给显式初始化/测试/校验工具使用,不是人工阅读入口。
  7 +
  8 +## 表
  9 +
  10 +- `tables/users_auth_fields.cjs`:auth 对 `users` 根表追加的字段。
  11 +- `tables/user_oauth.cjs`
  12 +- `tables/auth_sessions.cjs`
  13 +- `tables/auth_attempts.cjs`
  14 +- `tables/auth_codes.cjs`
... ...
packages/auth/src/db/tables/auth_attempts.cjs 0 → 100644
  1 +++ a/packages/auth/src/db/tables/auth_attempts.cjs
  1 +const tableName = 'auth_attempts'
  2 +
  3 +function buildTable(t) {
  4 + t.increments('id').primary()
  5 + t.string('key').notNullable()
  6 + t.string('ip', 64).nullable()
  7 + t.datetime('at', { precision: 3 }).notNullable()
  8 + t.index(['key', 'at'])
  9 +}
  10 +
  11 +module.exports = { tableName, buildTable }
... ...
packages/auth/src/db/tables/auth_codes.cjs 0 → 100644
  1 +++ a/packages/auth/src/db/tables/auth_codes.cjs
  1 +const tableName = 'auth_codes'
  2 +
  3 +function buildTable(t) {
  4 + t.string('email', 320).notNullable()
  5 + t.string('purpose', 64).notNullable()
  6 + t.text('code_hash').notNullable()
  7 + t.datetime('expires_at', { precision: 3 }).notNullable()
  8 + t.integer('attempts').notNullable().defaultTo(0)
  9 + t.primary(['email', 'purpose'])
  10 +}
  11 +
  12 +module.exports = { tableName, buildTable }
... ...
packages/auth/src/db/tables/auth_sessions.cjs 0 → 100644
  1 +++ a/packages/auth/src/db/tables/auth_sessions.cjs
  1 +const tableName = 'auth_sessions'
  2 +
  3 +function buildTable(t) {
  4 + t.string('sid').primary()
  5 + t.string('user_id').notNullable()
  6 + t.text('refresh_hash').notNullable()
  7 + t.string('family').notNullable()
  8 + t.datetime('expires_at', { precision: 3 }).notNullable()
  9 + t.boolean('used').notNullable().defaultTo(false)
  10 + t.boolean('revoked').notNullable().defaultTo(false)
  11 + t.index('user_id')
  12 + t.index('family')
  13 +}
  14 +
  15 +module.exports = { tableName, buildTable }
... ...
packages/auth/src/db/tables/index.cjs 0 → 100644
  1 +++ a/packages/auth/src/db/tables/index.cjs
  1 +const tableModules = [
  2 + require('./user_oauth.cjs'),
  3 + require('./auth_sessions.cjs'),
  4 + require('./auth_attempts.cjs'),
  5 + require('./auth_codes.cjs'),
  6 +]
  7 +
  8 +const targetTables = tableModules.filter(({ buildTable }) => typeof buildTable === 'function').map(({ tableName, buildTable }) => [tableName, buildTable])
  9 +const authTables = Object.fromEntries(tableModules.map(({ tableName }) => [tableName.replace(/^(auth_|account_|billing_|payment_|email_)/, ''), tableName]))
  10 +
  11 +module.exports = {
  12 + targetTables,
  13 + authTables,
  14 +}
... ...
packages/auth/src/db/tables/user_oauth.cjs 0 → 100644
  1 +++ a/packages/auth/src/db/tables/user_oauth.cjs
  1 +const tableName = 'user_oauth'
  2 +
  3 +function buildTable(t) {
  4 + t.string('user_id').notNullable()
  5 + t.string('provider', 64).notNullable()
  6 + t.string('provider_user_id', 255).notNullable()
  7 + t.unique(['provider', 'provider_user_id'])
  8 + t.index('user_id')
  9 +}
  10 +
  11 +module.exports = { tableName, buildTable }
... ...
packages/auth/src/errors.js 0 → 100644
  1 +++ a/packages/auth/src/errors.js
  1 +/**
  2 + * 结构化认证错误 —— 本包唯一对外错误类型(§3)。
  3 + *
  4 + * 所有失败一律抛 AuthError,禁止裸 Error / 静默吞错。`retryable` 给调用方决定
  5 + * 是否提示重试;`retryAfterMs` 仅 TOO_MANY_ATTEMPTS 携带。
  6 + */
  7 +
  8 +/**
  9 + * @typedef {'EMAIL_TAKEN'|'INVALID_CREDENTIALS'|'TOO_MANY_ATTEMPTS'|
  10 + * 'OAUTH_VERIFY_FAILED'|'REFRESH_REUSED'|'CODE_EXPIRED'|'CODE_INVALID'|
  11 + * 'TOKEN_INVALID'|'TOKEN_EXPIRED'|'USER_NOT_FOUND'|'UNSUPPORTED_PROVIDER'} AuthCode
  12 + */
  13 +
  14 +/** 默认可重试的错误码集合(瞬时/限流类)。 */
  15 +const RETRYABLE_CODES = new Set(['TOO_MANY_ATTEMPTS'])
  16 +
  17 +export class AuthError extends Error {
  18 + /**
  19 + * @param {Object} info
  20 + * @param {AuthCode} info.code
  21 + * @param {boolean} [info.retryable]
  22 + * @param {number} [info.retryAfterMs]
  23 + * @param {unknown} [info.cause]
  24 + * @param {string} [info.message]
  25 + */
  26 + constructor({ code, retryable, retryAfterMs, cause, message }) {
  27 + super(message ?? `auth failed: ${code}`)
  28 + this.name = 'AuthError'
  29 + /** @type {AuthCode} */
  30 + this.code = code
  31 + this.retryable = retryable ?? RETRYABLE_CODES.has(code)
  32 + if (retryAfterMs != null) this.retryAfterMs = retryAfterMs
  33 + if (cause != null) this.cause = cause
  34 + }
  35 +}
  36 +
  37 +/**
  38 + * 把任意底层错误归一为 AuthError。已是 AuthError 的原样透传。
  39 + * @param {unknown} err
  40 + * @param {AuthCode} fallbackCode
  41 + * @returns {AuthError}
  42 + */
  43 +export function asAuthError(err, fallbackCode) {
  44 + if (err instanceof AuthError) return err
  45 + return new AuthError({ code: fallbackCode, cause: err })
  46 +}
... ...
packages/auth/src/fake-repo.js 0 → 100644
  1 +++ a/packages/auth/src/fake-repo.js
  1 +/**
  2 + * createFakeUserRepo —— 全内存 UserRepo 实现,测试用(证明所有接缝可替换)。
  3 + *
  4 + * 与真实 repo.js 行为对齐:会话族 / refresh hash / 防爆破计数 / 验证码 hash 全在内存 Map,
  5 + * 无网络无 DB 即可跑通 register / login / oauth / refresh / code 全流程。
  6 + */
  7 +
  8 +/**
  9 + * @param {Object} [seed]
  10 + * @param {Array<import('./index.js').User>} [seed.users]
  11 + * @returns {import('./index.js').UserRepo}
  12 + */
  13 +export function createFakeUserRepo(seed = {}) {
  14 + /** @type {Map<string, import('./index.js').User>} */
  15 + const usersById = new Map()
  16 + /** @type {Map<string, import('./index.js').Session>} */
  17 + const sessions = new Map()
  18 + /** @type {Array<{ key: string, ip?: string, at: Date }>} */
  19 + const attempts = []
  20 + /** @type {Map<string, import('./index.js').CodeRecord>} */
  21 + const codes = new Map()
  22 +
  23 + for (const u of seed.users ?? []) {
  24 + usersById.set(u.id, { ...u, oauth_providers: u.oauth_providers ?? [] })
  25 + }
  26 +
  27 + /** @param {string} e */
  28 + const norm = (e) => String(e ?? '').trim().toLowerCase()
  29 + /** @param {string} email @param {string} purpose */
  30 + const codeKey = (email, purpose) => `${norm(email)}|${purpose}`
  31 + let idSeq = usersById.size
  32 +
  33 + /** @param {string} email */
  34 + function findUserByEmail(email) {
  35 + const e = norm(email)
  36 + for (const u of usersById.values()) if (norm(u.email) === e) return u
  37 + return null
  38 + }
  39 +
  40 + return {
  41 + async findByEmail(email) {
  42 + const u = findUserByEmail(email)
  43 + return u ? clone(u) : null
  44 + },
  45 +
  46 + async findById(id) {
  47 + const u = usersById.get(id)
  48 + return u ? clone(u) : null
  49 + },
  50 +
  51 + async create({ email, password_hash, oauth_providers, locale }) {
  52 + idSeq += 1
  53 + const id = `u${idSeq}`
  54 + const user = {
  55 + id,
  56 + email: norm(email),
  57 + password_hash,
  58 + locale,
  59 + oauth_providers: oauth_providers ?? [],
  60 + }
  61 + usersById.set(id, user)
  62 + return clone(user)
  63 + },
  64 +
  65 + async linkOauth({ user_id, provider, provider_user_id }) {
  66 + const u = usersById.get(user_id)
  67 + if (!u) throw new Error('user not found')
  68 + u.oauth_providers = u.oauth_providers ?? []
  69 + const dup = u.oauth_providers.some(
  70 + (p) => p.provider === provider && p.provider_user_id === provider_user_id,
  71 + )
  72 + if (!dup) u.oauth_providers.push({ provider, provider_user_id })
  73 + return clone(u)
  74 + },
  75 +
  76 + async updatePasswordHash({ user_id, password_hash }) {
  77 + const u = usersById.get(user_id)
  78 + if (!u) throw new Error('user not found')
  79 + u.password_hash = password_hash
  80 + },
  81 +
  82 + async saveSession({ user_id, sid, refresh_hash, family, expires_at }) {
  83 + sessions.set(sid, { sid, user_id, refresh_hash, family, expires_at, used: false, revoked: false })
  84 + },
  85 +
  86 + async findSession({ sid }) {
  87 + const s = sessions.get(sid)
  88 + return s ? { ...s } : null
  89 + },
  90 +
  91 + async markRefreshUsed({ sid }) {
  92 + const s = sessions.get(sid)
  93 + if (s) s.used = true
  94 + },
  95 +
  96 + async revokeFamily({ family }) {
  97 + for (const s of sessions.values()) if (s.family === family) s.revoked = true
  98 + },
  99 +
  100 + async revokeUserSessions({ user_id }) {
  101 + for (const s of sessions.values()) if (s.user_id === user_id) s.revoked = true
  102 + },
  103 +
  104 + async recordAttempt({ key, ip, at }) {
  105 + attempts.push({ key, ip, at })
  106 + },
  107 +
  108 + async countRecentAttempts({ key, windowMs }) {
  109 + const since = Date.now() - windowMs
  110 + return attempts.filter((a) => a.key === key && a.at.getTime() >= since).length
  111 + },
  112 +
  113 + async saveCode({ email, purpose, code_hash, expires_at }) {
  114 + codes.set(codeKey(email, purpose), {
  115 + email: norm(email),
  116 + purpose,
  117 + code_hash,
  118 + expires_at,
  119 + attempts: 0,
  120 + })
  121 + },
  122 +
  123 + async consumeCode({ email, purpose }) {
  124 + const rec = codes.get(codeKey(email, purpose))
  125 + if (!rec) return null
  126 + const snapshot = { ...rec }
  127 + rec.attempts += 1
  128 + return snapshot
  129 + },
  130 + }
  131 +}
  132 +
  133 +/** @param {any} o */
  134 +function clone(o) {
  135 + // structuredClone 保留 Date(时间字段与真实 repo 一致返回 Date),JSON round-trip 会把它变字符串。
  136 + return structuredClone(o)
  137 +}
... ...
packages/auth/src/index.js 0 → 100644
  1 +++ a/packages/auth/src/index.js
  1 +/**
  2 + * `@drama/auth` 公共面 —— 身份认证域唯一对外导出口(任务文档 §3 契约)。
  3 + *
  4 + * 设计纪律(架构 §9 红线 + §1 边界):
  5 + * - 包内不读 `process.env`、不在模块顶层建任何连接(db 由 `createUserRepo(db)` 注入,
  6 + * 密钥/JWKS/OAuth client 全由注入的 Port 在 composition root 持有)。
  7 + * - 绝不自研密码学:JWT 签发/验证走 `tokenSigner`,OAuth 验签走 `oauth`,
  8 + * 密码哈希走 `hasher`。本包只编排这些 Port + 把会话/凭据/验证码状态落库。
  9 + * - 唯一对外错误类型是 `AuthError`(结构化 `code`/`retryable`)。
  10 + *
  11 + * ----- 注入式 Port 协议(实现在 composition root,本包只消费契约)-----
  12 + *
  13 + * @typedef {Object} TokenSigner 薄封装 jose。
  14 + * @property {(claims: object, opts: { ttlMs: number }) => string|Promise<string>} sign
  15 + * @property {(token: string) => object|Promise<object>} verify 验签 + 过期校验,失败抛错。
  16 + *
  17 + * @typedef {Object} Hasher 薄封装 argon2 / bcrypt。
  18 + * @property {(plain: string) => string|Promise<string>} hash
  19 + * @property {(plain: string, hash: string) => boolean|Promise<boolean>} verify
  20 + *
  21 + * @typedef {Object} OauthExchangeResult
  22 + * @property {string} provider_user_id
  23 + * @property {string} email
  24 + * @property {boolean} email_verified
  25 + *
  26 + * @typedef {Object} OauthPort 薄封装各家 SDK(含验签)。
  27 + * @property {(args: { provider: 'google'|'apple', code: string, oauth_flow: 'access_token'|'authorization_code', redirect_uri?: string }) => OauthExchangeResult|Promise<OauthExchangeResult>} exchange
  28 + *
  29 + * 来自 @drama/email 的投递端口(验证码投递)。
  30 + * @typedef {Object} EmailPort
  31 + * @property {(args: { to: string, code: string, purpose: string, locale?: string }) => void|Promise<void>} sendCode
  32 + *
  33 + * ----- 领域 DTO(与 @drama/contracts 的 User/Session 对齐)-----
  34 + *
  35 + * @typedef {Object} User
  36 + * @property {string} id
  37 + * @property {string} email
  38 + * @property {string} [password_hash]
  39 + * @property {Array<{ provider: string, provider_user_id: string }>} [oauth_providers]
  40 + * @property {string} [locale]
  41 + * @property {Date} [email_verified_at]
  42 + *
  43 + * @typedef {Object} Session
  44 + * @property {string} sid
  45 + * @property {string} user_id
  46 + * @property {string} refresh_hash
  47 + * @property {string} family
  48 + * @property {Date} expires_at UTC
  49 + * @property {boolean} [used]
  50 + * @property {boolean} [revoked]
  51 + *
  52 + * @typedef {Object} CodeRecord
  53 + * @property {string} email
  54 + * @property {string} purpose
  55 + * @property {string} code_hash
  56 + * @property {Date} expires_at UTC
  57 + * @property {number} attempts
  58 + *
  59 + * ----- UserRepo 形态(createUserRepo(db) 返回,本包内消费)-----
  60 + *
  61 + * @typedef {Object} UserRepo
  62 + * @property {(email: string) => Promise<User|null>} findByEmail
  63 + * @property {(id: string) => Promise<User|null>} findById
  64 + * @property {(args: { email: string, password_hash?: string, oauth_providers?: Array<{provider:string,provider_user_id:string}>, locale?: string, email_verified_at?: Date }) => Promise<User>} create
  65 + * @property {(args: { user_id: string, provider: string, provider_user_id: string }) => Promise<User>} linkOauth
  66 + * @property {(args: { user_id: string, password_hash: string }) => Promise<void>} updatePasswordHash
  67 + * @property {(args: { user_id: string, sid: string, refresh_hash: string, family: string, expires_at: Date }) => Promise<void>} saveSession
  68 + * @property {(args: { sid: string }) => Promise<Session|null>} findSession
  69 + * @property {(args: { sid: string }) => Promise<void>} markRefreshUsed
  70 + * @property {(args: { family: string }) => Promise<void>} revokeFamily
  71 + * @property {(args: { user_id: string }) => Promise<void>} revokeUserSessions
  72 + * @property {(args: { key: string, ip?: string, at: Date }) => Promise<void>} recordAttempt
  73 + * @property {(args: { key: string, ip?: string, windowMs: number }) => Promise<number>} countRecentAttempts
  74 + * @property {(args: { email: string, purpose: string, code_hash: string, expires_at: Date }) => Promise<void>} saveCode
  75 + * @property {(args: { email: string, purpose: string }) => Promise<CodeRecord|null>} consumeCode
  76 + * @property {(args: { email: string, purpose: string, attempts: number }) => Promise<void>} [bumpCodeAttempts]
  77 + *
  78 + * ----- Auth 形态(createAuth(...) 返回,全部返回 Promise)-----
  79 + *
  80 + * @typedef {Object} AuthTokens
  81 + * @property {User} user
  82 + * @property {string} access_token
  83 + * @property {string} refresh_token
  84 + *
  85 + * @typedef {Object} Auth
  86 + * @property {(args: { email: string, password: string, locale?: string, email_verified_at?: Date }) => Promise<AuthTokens>} register
  87 + * @property {(args: { email: string, password: string, ip?: string, device_id?: string }) => Promise<AuthTokens>} login
  88 + * @property {(args: { provider: 'google'|'apple', code: string, redirect_uri?: string, device_id?: string }) => Promise<AuthTokens & { is_new_user: boolean }>} oauthLogin
  89 + * @property {(args: { refresh_token: string }) => Promise<{ user: User|{id:string}, access_token: string, refresh_token: string }>} refresh
  90 + * @property {(args: { email: string, code: string, purpose: 'register'|'login'|'reset' }) => Promise<{ ok: true }>} verifyCode
  91 + * @property {(args: { email: string, purpose: 'register'|'login'|'reset', locale?: string, ip?: string }) => Promise<{ ok: true }>} sendCode
  92 + * @property {(args: { email: string, locale?: string, ip?: string }) => Promise<{ ok: true }>} requestPasswordReset
  93 + * @property {(args: { email: string, password: string }) => Promise<{ ok: true }>} resetPassword
  94 + * @property {(args: { user_id: string }) => Promise<User|null>} getUser
  95 + * @property {(token: string) => Promise<object>} verifyAccessToken
  96 + * @property {(args: { refresh_token: string }) => Promise<{ ok: true }>} logout
  97 + * @property {(args: { user_id: string }) => Promise<{ ok: true }>} revokeAllSessions
  98 + */
  99 +
  100 +export { createAuth } from './auth.js'
  101 +export { createUserRepo } from './repo.js'
  102 +export { AuthError } from './errors.js'
  103 +export { createFakeUserRepo } from './fake-repo.js'
  104 +export { default as authDbSchema } from './db/tables/index.cjs'
... ...
packages/auth/src/login.js 0 → 100644
  1 +++ a/packages/auth/src/login.js
  1 +/**
  2 + * 登录流程:防爆破前置检查 → hasher 验密 → 失败计数落库 / 成功签发会话。
  3 + *
  4 + * 防爆破计数走注入 repo(不靠进程内 Map,多实例下仍生效);判定纯逻辑在 bruteforce.js。
  5 + * 失败一律 INVALID_CREDENTIALS(不区分「用户不存在」与「密码错」以免账号枚举),
  6 + * 超阈值返 TOO_MANY_ATTEMPTS + retryAfterMs。
  7 + */
  8 +
  9 +import { AuthError } from './errors.js'
  10 +import { issueSession } from './session.js'
  11 +import { attemptKey, evaluateAttempts, DEFAULT_MAX_ATTEMPTS, DEFAULT_WINDOW_MS } from './bruteforce.js'
  12 +
  13 +/**
  14 + * @param {Object} deps
  15 + * @param {import('./index.js').UserRepo} deps.userRepo
  16 + * @param {import('./index.js').TokenSigner} deps.tokenSigner
  17 + * @param {import('./index.js').Hasher} deps.hasher
  18 + * @param {number} [deps.maxAttempts]
  19 + * @param {number} [deps.windowMs]
  20 + * @param {number} [deps.accessTtlMs]
  21 + * @param {number} [deps.refreshTtlMs]
  22 + * @returns {(args: { email: string, password: string, ip?: string, device_id?: string }) => Promise<import('./index.js').AuthTokens>}
  23 + */
  24 +export function makeLogin(deps) {
  25 + const {
  26 + userRepo,
  27 + hasher,
  28 + maxAttempts = DEFAULT_MAX_ATTEMPTS,
  29 + windowMs = DEFAULT_WINDOW_MS,
  30 + } = deps
  31 +
  32 + return async function login({ email, password, ip }) {
  33 + const normalized = String(email ?? '').trim().toLowerCase()
  34 + const key = attemptKey(normalized, ip)
  35 +
  36 + const recentCount = await userRepo.countRecentAttempts({ key, ip, windowMs })
  37 + const gate = evaluateAttempts({ recentCount, maxAttempts, windowMs })
  38 + if (gate.locked) {
  39 + throw new AuthError({ code: 'TOO_MANY_ATTEMPTS', retryAfterMs: gate.retryAfterMs })
  40 + }
  41 +
  42 + const user = await userRepo.findByEmail(normalized)
  43 + const ok = user && user.password_hash
  44 + ? await hasher.verify(password, user.password_hash)
  45 + : false
  46 +
  47 + if (!ok || !user) {
  48 + await userRepo.recordAttempt({ key, ip, at: new Date() })
  49 + throw new AuthError({ code: 'INVALID_CREDENTIALS' })
  50 + }
  51 +
  52 + const issued = await issueSession(deps, { user_id: user.id })
  53 + return { user, access_token: issued.access_token, refresh_token: issued.refresh_token }
  54 + }
  55 +}
... ...
packages/auth/src/oauth.js 0 → 100644
  1 +++ a/packages/auth/src/oauth.js
  1 +/**
  2 + * OAuth 登录:经注入 oauth Port 换码 + 验签取邮箱 → 首登建号 / 已存在合并 → 签发会话。
  3 + *
  4 + * 红线:包内不自写 JWKS 拉取 / RS256 验签 / id_token 解码——全在 oauth.exchange 内完成
  5 + * (Google 走 google-auth-library 校验 id_token、Apple 走其 JWKS)。验签失败统一
  6 + * OAUTH_VERIFY_FAILED。本文件只做 provider 校验 + 建号/合并的账户状态机。
  7 + */
  8 +
  9 +import { AuthError } from './errors.js'
  10 +import { issueSession } from './session.js'
  11 +
  12 +const SUPPORTED_PROVIDERS = new Set(['google', 'apple'])
  13 +
  14 +/**
  15 + * @param {Object} deps
  16 + * @param {import('./index.js').UserRepo} deps.userRepo
  17 + * @param {import('./index.js').OauthPort} deps.oauth
  18 + * @param {import('./index.js').TokenSigner} deps.tokenSigner
  19 + * @param {import('./index.js').Hasher} deps.hasher
  20 + * @param {number} [deps.accessTtlMs]
  21 + * @param {number} [deps.refreshTtlMs]
  22 + * @returns {(args: { provider: string, code: string, oauth_flow?: 'access_token'|'authorization_code', redirect_uri?: string, device_id?: string }) => Promise<import('./index.js').AuthTokens & { is_new_user: boolean }>}
  23 + */
  24 +export function makeOauthLogin(deps) {
  25 + const { userRepo, oauth } = deps
  26 +
  27 + return async function oauthLogin({ provider, code, oauth_flow = 'access_token', redirect_uri }) {
  28 + if (!SUPPORTED_PROVIDERS.has(provider)) {
  29 + throw new AuthError({ code: 'UNSUPPORTED_PROVIDER', message: `provider: ${provider}` })
  30 + }
  31 +
  32 + let identity
  33 + try {
  34 + identity = await oauth.exchange({
  35 + provider: /** @type {'google'|'apple'} */ (provider),
  36 + code,
  37 + oauth_flow,
  38 + redirect_uri,
  39 + })
  40 + } catch (err) {
  41 + throw new AuthError({ code: 'OAUTH_VERIFY_FAILED', cause: err })
  42 + }
  43 + if (!identity || !identity.email || !identity.provider_user_id) {
  44 + throw new AuthError({ code: 'OAUTH_VERIFY_FAILED' })
  45 + }
  46 +
  47 + const email = String(identity.email).trim().toLowerCase()
  48 +
  49 + let user = await userRepo.findByEmail(email)
  50 + let is_new_user = false
  51 + if (!user) {
  52 + // 首登:自动建号并绑定 provider 身份。
  53 + user = await userRepo.create({
  54 + email,
  55 + oauth_providers: [{ provider, provider_user_id: identity.provider_user_id }],
  56 + ...(identity.email_verified ? { email_verified_at: new Date() } : {}),
  57 + })
  58 + is_new_user = true
  59 + } else {
  60 + // 已存在:合并到同一 user(绑定该 provider,若未绑)。
  61 + const linked = (user.oauth_providers ?? []).some(
  62 + (p) => p.provider === provider && p.provider_user_id === identity.provider_user_id,
  63 + )
  64 + if (!linked) {
  65 + user = await userRepo.linkOauth({
  66 + user_id: user.id,
  67 + provider,
  68 + provider_user_id: identity.provider_user_id,
  69 + })
  70 + }
  71 + }
  72 +
  73 + const issued = await issueSession(deps, { user_id: user.id })
  74 + return {
  75 + user,
  76 + access_token: issued.access_token,
  77 + refresh_token: issued.refresh_token,
  78 + is_new_user,
  79 + }
  80 + }
  81 +}
... ...
packages/auth/src/refresh.js 0 → 100644
  1 +++ a/packages/auth/src/refresh.js
  1 +/**
  2 + * refresh 轮转 + token-reuse 检测(本包最关键的安全路径)。
  3 + *
  4 + * 正常轮转:旧 refresh 校验通过 → 立即标记 used → 沿用同一 family 签发新 access+refresh。
  5 + * Token-reuse 检测:若提交的 refresh 对应会话已被标记 used(重放),视为凭据泄露,
  6 + * 抛 REFRESH_REUSED 并撤销整个会话族(family)全部 token——后续该族任何 refresh 全失效。
  7 + *
  8 + * 密码学(refresh 比对)走注入 hasher,签发走 issueSession,本文件只做状态机决策。
  9 + */
  10 +
  11 +import { AuthError } from './errors.js'
  12 +import { issueSession, parseRefreshToken } from './session.js'
  13 +
  14 +/**
  15 + * @param {Object} deps
  16 + * @param {import('./index.js').UserRepo} deps.userRepo
  17 + * @param {import('./index.js').TokenSigner} deps.tokenSigner
  18 + * @param {import('./index.js').Hasher} deps.hasher
  19 + * @param {number} [deps.accessTtlMs]
  20 + * @param {number} [deps.refreshTtlMs]
  21 + * @returns {(args: { refresh_token: string }) => Promise<{ user: import('./index.js').User|{id:string}, access_token: string, refresh_token: string }>}
  22 + */
  23 +export function makeRefresh(deps) {
  24 + const { userRepo, hasher } = deps
  25 + return async function refresh({ refresh_token }) {
  26 + const parsed = parseRefreshToken(refresh_token)
  27 + if (!parsed) throw new AuthError({ code: 'TOKEN_INVALID' })
  28 +
  29 + const session = await userRepo.findSession({ sid: parsed.sid })
  30 + if (!session || session.revoked) {
  31 + throw new AuthError({ code: 'TOKEN_INVALID' })
  32 + }
  33 +
  34 + // 校验提交的 refresh 与落库 hash 匹配(防伪造 sid 重放)。
  35 + const matches = await hasher.verify(parsed.secret, session.refresh_hash)
  36 + if (!matches) {
  37 + throw new AuthError({ code: 'TOKEN_INVALID' })
  38 + }
  39 +
  40 + // Token-reuse:该会话的 refresh 已被用过却又被提交 → 凭据泄露,撤销整个会话族。
  41 + if (session.used) {
  42 + await userRepo.revokeFamily({ family: session.family })
  43 + throw new AuthError({ code: 'REFRESH_REUSED' })
  44 + }
  45 +
  46 + if (Date.now() > session.expires_at.getTime()) {
  47 + throw new AuthError({ code: 'TOKEN_EXPIRED' })
  48 + }
  49 +
  50 + // 正常轮转:旧 refresh 立即失效,沿用 family 签发新对。
  51 + await userRepo.markRefreshUsed({ sid: session.sid })
  52 + const issued = await issueSession(deps, { user_id: session.user_id, family: session.family })
  53 + // 带回 user(前端 SK 门面映射 account 需要);findById 不可用时优雅降级为 { id }。
  54 + let user = { id: session.user_id }
  55 + if (typeof userRepo.findById === 'function') {
  56 + const found = await userRepo.findById(session.user_id)
  57 + if (found) user = found
  58 + }
  59 + return { user, access_token: issued.access_token, refresh_token: issued.refresh_token }
  60 + }
  61 +}
... ...
packages/auth/src/register.js 0 → 100644
  1 +++ a/packages/auth/src/register.js
  1 +/**
  2 + * 注册流程:密码经 hasher 哈希落库 + 首次签发会话。
  3 + *
  4 + * 产品决策(§3 留口):本实现采用「注册即激活并签发会话」路径(欧美刷剧 MVP 追求转化、
  5 + * 降低试看→注册摩擦)。验证码确认走独立 sendCode/verifyCode(用于敏感操作 / reset),
  6 + * 不阻塞注册主路径。库内 grep 不到明文密码持久化、不到自写哈希循环(全走 hasher)。
  7 + */
  8 +
  9 +import { AuthError } from './errors.js'
  10 +import { issueSession } from './session.js'
  11 +
  12 +/**
  13 + * @param {Object} deps
  14 + * @param {import('./index.js').UserRepo} deps.userRepo
  15 + * @param {import('./index.js').TokenSigner} deps.tokenSigner
  16 + * @param {import('./index.js').Hasher} deps.hasher
  17 + * @param {number} [deps.accessTtlMs]
  18 + * @param {number} [deps.refreshTtlMs]
  19 + * @returns {(args: { email: string, password: string, locale?: string, email_verified_at?: Date }) => Promise<import('./index.js').AuthTokens>}
  20 + */
  21 +export function makeRegister(deps) {
  22 + const { userRepo, hasher } = deps
  23 + return async function register({ email, password, locale, email_verified_at }) {
  24 + const normalized = String(email ?? '').trim().toLowerCase()
  25 + if (!normalized || !password) {
  26 + throw new AuthError({ code: 'INVALID_CREDENTIALS', message: 'email and password required' })
  27 + }
  28 +
  29 + const existing = await userRepo.findByEmail(normalized)
  30 + if (existing) {
  31 + throw new AuthError({ code: 'EMAIL_TAKEN' })
  32 + }
  33 +
  34 + const password_hash = await hasher.hash(password)
  35 + const user = await userRepo.create({ email: normalized, password_hash, locale, email_verified_at })
  36 +
  37 + const issued = await issueSession(deps, { user_id: user.id })
  38 + return { user, access_token: issued.access_token, refresh_token: issued.refresh_token }
  39 + }
  40 +}
... ...
packages/auth/src/repo.js 0 → 100644
  1 +++ a/packages/auth/src/repo.js
  1 +/**
  2 + * createUserRepo(db) —— 本包唯一写 SQL 的地方,db(Knex 实例)由 composition root 注入。
  3 + *
  4 + * 表结构定义在 `src/db/tables/`(users 归 `@drama/db`),本包不建连接:
  5 + * users(id, email, password_hash, locale, email_verified_at, created_at)
  6 + * user_oauth(user_id, provider, provider_user_id)
  7 + * auth_sessions(sid, user_id, refresh_hash, family, expires_at, used, revoked)
  8 + * auth_attempts(key, ip, at)
  9 + * auth_codes(email, purpose, code_hash, expires_at, attempts)
  10 + *
  11 + * 字段纪律(plan/db-root-solution.md 决定 2/3):出入参、返回对象与列名同名透传(snake_case);
  12 + * 时间列是 DATETIME(3) UTC,进出都是 JS Date。
  13 + *
  14 + * 仅落库状态:会话族、refresh hash、防爆破计数、验证码 hash。密码学不在此处。
  15 + */
  16 +
  17 +import { AuthError } from './errors.js'
  18 +import authAttemptsTable from './db/tables/auth_attempts.cjs'
  19 +import authCodesTable from './db/tables/auth_codes.cjs'
  20 +import authSessionsTable from './db/tables/auth_sessions.cjs'
  21 +import userOauthTable from './db/tables/user_oauth.cjs'
  22 +
  23 +const USERS = 'users'
  24 +const USER_OAUTH = userOauthTable.tableName
  25 +const AUTH_SESSIONS = authSessionsTable.tableName
  26 +const AUTH_ATTEMPTS = authAttemptsTable.tableName
  27 +const AUTH_CODES = authCodesTable.tableName
  28 +
  29 +/**
  30 + * @param {import('knex').Knex} db
  31 + * @returns {import('./index.js').UserRepo}
  32 + */
  33 +export function createUserRepo(db) {
  34 + if (!db || typeof db !== 'function') {
  35 + throw new AuthError({ code: 'USER_NOT_FOUND', message: 'createUserRepo: db (Knex) required' })
  36 + }
  37 +
  38 + /** @param {any} row @param {any[]} [oauthRows] @returns {import('./index.js').User} */
  39 + function rowToUser(row, oauthRows = []) {
  40 + return {
  41 + id: String(row.id),
  42 + email: row.email,
  43 + password_hash: row.password_hash ?? undefined,
  44 + locale: row.locale ?? undefined,
  45 + role: row.role ?? undefined,
  46 + email_verified_at: row.email_verified_at ?? undefined,
  47 + oauth_providers: oauthRows.map((o) => ({
  48 + provider: o.provider,
  49 + provider_user_id: o.provider_user_id,
  50 + })),
  51 + }
  52 + }
  53 +
  54 + /** @param {string} user_id */
  55 + async function loadOauth(user_id) {
  56 + return db(USER_OAUTH).where({ user_id }).select('provider', 'provider_user_id')
  57 + }
  58 +
  59 + return {
  60 + async findByEmail(email) {
  61 + const row = await db(USERS).where({ email: norm(email) }).first()
  62 + if (!row) return null
  63 + return rowToUser(row, await loadOauth(row.id))
  64 + },
  65 +
  66 + async findById(id) {
  67 + const row = await db(USERS).where({ id }).first()
  68 + if (!row) return null
  69 + return rowToUser(row, await loadOauth(row.id))
  70 + },
  71 +
  72 + async create({ email, password_hash, oauth_providers, locale, email_verified_at }) {
  73 + const id = newId()
  74 + try {
  75 + await db(USERS).insert({
  76 + id,
  77 + email: norm(email),
  78 + password_hash: password_hash ?? null,
  79 + locale: locale ?? null,
  80 + email_verified_at: email_verified_at ?? null,
  81 + created_at: new Date(),
  82 + })
  83 + } catch (error) {
  84 + if (error.code === 'ER_DUP_ENTRY') throw new AuthError({ code: 'EMAIL_TAKEN' })
  85 + throw error
  86 + }
  87 + if (oauth_providers && oauth_providers.length) {
  88 + await db(USER_OAUTH).insert(
  89 + oauth_providers.map((p) => ({
  90 + user_id: id,
  91 + provider: p.provider,
  92 + provider_user_id: p.provider_user_id,
  93 + })),
  94 + )
  95 + }
  96 + const row = await db(USERS).where({ id }).first()
  97 + return rowToUser(row, await loadOauth(id))
  98 + },
  99 +
  100 + async linkOauth({ user_id, provider, provider_user_id }) {
  101 + const exists = await db(USER_OAUTH)
  102 + .where({ user_id, provider, provider_user_id })
  103 + .first()
  104 + if (!exists) {
  105 + await db(USER_OAUTH).insert({ user_id, provider, provider_user_id })
  106 + }
  107 + const row = await db(USERS).where({ id: user_id }).first()
  108 + if (!row) throw new AuthError({ code: 'USER_NOT_FOUND' })
  109 + return rowToUser(row, await loadOauth(user_id))
  110 + },
  111 +
  112 + async updatePasswordHash({ user_id, password_hash }) {
  113 + const count = await db(USERS)
  114 + .where({ id: user_id })
  115 + .update({ password_hash, updated_at: new Date() })
  116 + if (!count) throw new AuthError({ code: 'USER_NOT_FOUND' })
  117 + },
  118 +
  119 + async saveSession({ user_id, sid, refresh_hash, family, expires_at }) {
  120 + await db(AUTH_SESSIONS).insert({
  121 + sid,
  122 + user_id,
  123 + refresh_hash,
  124 + family,
  125 + expires_at,
  126 + used: false,
  127 + revoked: false,
  128 + })
  129 + },
  130 +
  131 + async findSession({ sid }) {
  132 + const row = await db(AUTH_SESSIONS).where({ sid }).first()
  133 + if (!row) return null
  134 + return {
  135 + sid: row.sid,
  136 + user_id: String(row.user_id),
  137 + refresh_hash: row.refresh_hash,
  138 + family: row.family,
  139 + expires_at: row.expires_at,
  140 + used: Boolean(row.used),
  141 + revoked: Boolean(row.revoked),
  142 + }
  143 + },
  144 +
  145 + async markRefreshUsed({ sid }) {
  146 + await db(AUTH_SESSIONS).where({ sid }).update({ used: true })
  147 + },
  148 +
  149 + async revokeFamily({ family }) {
  150 + await db(AUTH_SESSIONS).where({ family }).update({ revoked: true })
  151 + },
  152 +
  153 + async revokeUserSessions({ user_id }) {
  154 + await db(AUTH_SESSIONS).where({ user_id }).update({ revoked: true })
  155 + },
  156 +
  157 + async recordAttempt({ key, ip, at }) {
  158 + await db(AUTH_ATTEMPTS).insert({ key, ip: ip ?? null, at })
  159 + },
  160 +
  161 + async countRecentAttempts({ key, windowMs }) {
  162 + const since = new Date(Date.now() - windowMs)
  163 + const res = await db(AUTH_ATTEMPTS)
  164 + .where({ key })
  165 + .andWhere('at', '>=', since)
  166 + .count({ c: '*' })
  167 + .first()
  168 + return Number(res?.c ?? 0)
  169 + },
  170 +
  171 + async saveCode({ email, purpose, code_hash, expires_at }) {
  172 + // 同 email+purpose 只保留最新一条:先删旧。
  173 + await db(AUTH_CODES).where({ email: norm(email), purpose }).del()
  174 + await db(AUTH_CODES).insert({
  175 + email: norm(email),
  176 + purpose,
  177 + code_hash,
  178 + expires_at,
  179 + attempts: 0,
  180 + })
  181 + },
  182 +
  183 + async consumeCode({ email, purpose }) {
  184 + const row = await db(AUTH_CODES).where({ email: norm(email), purpose }).first()
  185 + if (!row) return null
  186 + // 读取即递增尝试次数(消费语义),并把超额删除留给下次 saveCode。
  187 + await db(AUTH_CODES)
  188 + .where({ email: norm(email), purpose })
  189 + .update({ attempts: Number(row.attempts) + 1 })
  190 + return {
  191 + email: row.email,
  192 + purpose: row.purpose,
  193 + code_hash: row.code_hash,
  194 + expires_at: row.expires_at,
  195 + attempts: Number(row.attempts),
  196 + }
  197 + },
  198 + }
  199 +}
  200 +
  201 +/** @param {string} e */
  202 +function norm(e) {
  203 + return String(e ?? '').trim().toLowerCase()
  204 +}
  205 +
  206 +function newId() {
  207 + return globalThis.crypto.randomUUID()
  208 +}
... ...
packages/auth/src/request-password-reset.js 0 → 100644
  1 +++ a/packages/auth/src/request-password-reset.js
  1 +/**
  2 + * 请求密码重置:只有已注册邮箱可以接收重置验证码。
  3 + */
  4 +
  5 +import { AuthError } from './errors.js'
  6 +
  7 +/**
  8 + * @param {Object} deps
  9 + * @param {import('./index.js').UserRepo} deps.userRepo
  10 + * @param {(args: { email: string, purpose: 'reset', locale?: string, ip?: string }) => Promise<{ ok: true }>} deps.sendCode
  11 + * @returns {(args: { email: string, locale?: string, ip?: string }) => Promise<{ ok: true }>}
  12 + */
  13 +export function makeRequestPasswordReset({ userRepo, sendCode }) {
  14 + return async function requestPasswordReset({ email, locale, ip }) {
  15 + const normalized = String(email).trim().toLowerCase()
  16 + const user = await userRepo.findByEmail(normalized)
  17 + if (!user) throw new AuthError({ code: 'USER_NOT_FOUND' })
  18 +
  19 + return sendCode({
  20 + email: normalized,
  21 + purpose: 'reset',
  22 + locale,
  23 + ip,
  24 + })
  25 + }
  26 +}
... ...
packages/auth/src/reset-password.js 0 → 100644
  1 +++ a/packages/auth/src/reset-password.js
  1 +/**
  2 + * 密码重置:验证码校验由调用方先走 verifyCode,本流程只负责更新密码并撤销旧会话。
  3 + */
  4 +
  5 +import { AuthError } from './errors.js'
  6 +
  7 +/**
  8 + * @param {Object} deps
  9 + * @param {import('./index.js').UserRepo} deps.userRepo
  10 + * @param {import('./index.js').Hasher} deps.hasher
  11 + * @returns {(args: { email: string, password: string }) => Promise<{ ok: true }>}
  12 + */
  13 +export function makeResetPassword({ userRepo, hasher }) {
  14 + return async function resetPassword({ email, password }) {
  15 + const normalized = String(email ?? '').trim().toLowerCase()
  16 + if (!normalized || !password) {
  17 + throw new AuthError({ code: 'INVALID_CREDENTIALS', message: 'email and password required' })
  18 + }
  19 +
  20 + const user = await userRepo.findByEmail(normalized)
  21 + if (!user) throw new AuthError({ code: 'USER_NOT_FOUND' })
  22 +
  23 + const password_hash = await hasher.hash(password)
  24 + await userRepo.updatePasswordHash({ user_id: user.id, password_hash })
  25 + await userRepo.revokeUserSessions({ user_id: user.id })
  26 + return { ok: true }
  27 + }
  28 +}
... ...
packages/auth/src/session.js 0 → 100644
  1 +++ a/packages/auth/src/session.js
  1 +/**
  2 + * 会话与 token 签发的共享编排 —— register / login / oauth / refresh 共用。
  3 + *
  4 + * 一次签发产出一对 access + refresh:access 是无状态 JWT(claims 含 sub/sid),
  5 + * refresh 是不透明随机串,其哈希连同会话族(family)落库以支持轮转与 token-reuse 检测。
  6 + * 签名/哈希全走注入 Port,本文件不碰密码学,只生成随机标识与组织 claims。
  7 + */
  8 +
  9 +const DEFAULT_ACCESS_TTL_MS = 15 * 60 * 1000 // 15 分钟
  10 +const DEFAULT_REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
  11 +
  12 +/** 生成不透明随机标识(CSPRNG,非自研)。 @returns {string} */
  13 +export function randomId() {
  14 + return globalThis.crypto.randomUUID().replace(/-/g, '')
  15 +}
  16 +
  17 +/**
  18 + * 为某 user + 会话族签发新 access+refresh,并把会话落库。
  19 + * 用于首次签发(family 新建)与轮转(沿用旧 family)。
  20 + *
  21 + * @param {Object} deps
  22 + * @param {import('./index.js').UserRepo} deps.userRepo
  23 + * @param {import('./index.js').TokenSigner} deps.tokenSigner
  24 + * @param {import('./index.js').Hasher} deps.hasher
  25 + * @param {number} [deps.accessTtlMs]
  26 + * @param {number} [deps.refreshTtlMs]
  27 + * @param {Object} args
  28 + * @param {string} args.user_id
  29 + * @param {string} [args.family] 轮转时沿用旧族;缺省新建。
  30 + * @returns {Promise<{ access_token: string, refresh_token: string, sid: string, family: string }>}
  31 + */
  32 +export async function issueSession(
  33 + { userRepo, tokenSigner, hasher, accessTtlMs = DEFAULT_ACCESS_TTL_MS, refreshTtlMs = DEFAULT_REFRESH_TTL_MS },
  34 + { user_id, family },
  35 +) {
  36 + const sid = randomId()
  37 + const fam = family ?? randomId()
  38 + const refreshSecret = randomId() + randomId()
  39 + const refresh_hash = await hasher.hash(refreshSecret)
  40 + const expires_at = new Date(Date.now() + refreshTtlMs)
  41 +
  42 + await userRepo.saveSession({ user_id, sid, refresh_hash, family: fam, expires_at })
  43 +
  44 + const access_token = await tokenSigner.sign(
  45 + { sub: user_id, sid, fam },
  46 + { ttlMs: accessTtlMs },
  47 + )
  48 + // refresh 客户端态需携带 sid 以便 refresh 时定位会话(不暴露 hash)。
  49 + return { access_token, refresh_token: `${sid}.${refreshSecret}`, sid, family: fam }
  50 +}
  51 +
  52 +/**
  53 + * 拆包客户端 refresh token → { sid, secret }。格式非法返 null。
  54 + * @param {string} token
  55 + * @returns {{ sid: string, secret: string }|null}
  56 + */
  57 +export function parseRefreshToken(token) {
  58 + if (typeof token !== 'string') return null
  59 + const idx = token.indexOf('.')
  60 + if (idx <= 0 || idx === token.length - 1) return null
  61 + return { sid: token.slice(0, idx), secret: token.slice(idx + 1) }
  62 +}
  63 +
  64 +export { DEFAULT_ACCESS_TTL_MS, DEFAULT_REFRESH_TTL_MS }
... ...
packages/auth/test/bruteforce.test.js 0 → 100644
  1 +++ a/packages/auth/test/bruteforce.test.js
  1 +import { describe, it, expect } from 'vitest'
  2 +import {
  3 + attemptKey,
  4 + evaluateAttempts,
  5 + DEFAULT_MAX_ATTEMPTS,
  6 + DEFAULT_WINDOW_MS,
  7 +} from '../src/bruteforce.js'
  8 +
  9 +describe('attemptKey', () => {
  10 + it('normalizes email and joins ip', () => {
  11 + expect(attemptKey(' A@B.com ', '1.2.3.4')).toBe('a@b.com|1.2.3.4')
  12 + })
  13 + it('omits ip when absent', () => {
  14 + expect(attemptKey('a@b.com')).toBe('a@b.com')
  15 + })
  16 +})
  17 +
  18 +describe('evaluateAttempts', () => {
  19 + it('not locked below threshold', () => {
  20 + const r = evaluateAttempts({ recentCount: DEFAULT_MAX_ATTEMPTS - 1 })
  21 + expect(r.locked).toBe(false)
  22 + expect(r.retryAfterMs).toBe(0)
  23 + })
  24 +
  25 + it('locked at threshold, retryAfter = full window without oldestAt', () => {
  26 + const r = evaluateAttempts({ recentCount: DEFAULT_MAX_ATTEMPTS })
  27 + expect(r.locked).toBe(true)
  28 + expect(r.retryAfterMs).toBe(DEFAULT_WINDOW_MS)
  29 + })
  30 +
  31 + it('precise retryAfter when oldestAt given', () => {
  32 + const now = 1_000_000
  33 + const oldestAt = now - 60_000
  34 + const windowMs = 100_000
  35 + const r = evaluateAttempts({ recentCount: 5, maxAttempts: 5, windowMs, oldestAt, now })
  36 + expect(r.locked).toBe(true)
  37 + expect(r.retryAfterMs).toBe(40_000)
  38 + })
  39 +
  40 + it('clamps retryAfter to 0 when oldest already aged out', () => {
  41 + const now = 1_000_000
  42 + const r = evaluateAttempts({
  43 + recentCount: 5,
  44 + maxAttempts: 5,
  45 + windowMs: 10_000,
  46 + oldestAt: now - 50_000,
  47 + now,
  48 + })
  49 + expect(r.retryAfterMs).toBe(0)
  50 + })
  51 +
  52 + it('respects custom maxAttempts', () => {
  53 + expect(evaluateAttempts({ recentCount: 2, maxAttempts: 3 }).locked).toBe(false)
  54 + expect(evaluateAttempts({ recentCount: 3, maxAttempts: 3 }).locked).toBe(true)
  55 + })
  56 +})
... ...
packages/auth/test/fakes.js 0 → 100644
  1 +++ a/packages/auth/test/fakes.js
  1 +/**
  2 + * 测试用 fake Port —— 无密码学副作用的薄替身(断言「编排走 Port」而非自研)。
  3 + * 仅测试目录引用,不进公共面。
  4 + */
  5 +
  6 +/** fake hasher:哈希=可逆前缀(仅测试,证明编排调用了 hash/verify)。 */
  7 +export function createFakeHasher() {
  8 + const calls = { hash: 0, verify: 0 }
  9 + return {
  10 + calls,
  11 + async hash(plain) {
  12 + calls.hash += 1
  13 + return `h$${plain}`
  14 + },
  15 + async verify(plain, hash) {
  16 + calls.verify += 1
  17 + return hash === `h$${plain}`
  18 + },
  19 + }
  20 +}
  21 +
  22 +/** fake tokenSigner:把 claims JSON 编码为 token(含 ttl 派生 exp)。 */
  23 +export function createFakeSigner() {
  24 + return {
  25 + async sign(claims, { ttlMs }) {
  26 + const exp = Date.now() + ttlMs
  27 + return `tok.${Buffer.from(JSON.stringify({ ...claims, exp })).toString('base64url')}`
  28 + },
  29 + async verify(token) {
  30 + if (typeof token !== 'string' || !token.startsWith('tok.')) {
  31 + const e = new Error('bad token')
  32 + e.code = 'ERR_JWT_INVALID'
  33 + throw e
  34 + }
  35 + const claims = JSON.parse(Buffer.from(token.slice(4), 'base64url').toString('utf8'))
  36 + if (claims.exp && Date.now() > claims.exp) {
  37 + const e = new Error('expired')
  38 + e.code = 'ERR_JWT_EXPIRED'
  39 + throw e
  40 + }
  41 + return claims
  42 + },
  43 + }
  44 +}
  45 +
  46 +/** fake oauth:按注入表返回身份,或抛错模拟验签失败。 */
  47 +export function createFakeOauth(table = {}, { throwOn } = {}) {
  48 + return {
  49 + async exchange({ provider, code }) {
  50 + if (throwOn && throwOn === code) throw new Error('verify failed')
  51 + const entry = table[`${provider}:${code}`]
  52 + if (!entry) throw new Error('unknown code')
  53 + return entry
  54 + },
  55 + }
  56 +}
  57 +
  58 +/** fake email:记录投递的验证码,供断言。 */
  59 +export function createFakeEmail(sink = []) {
  60 + return {
  61 + sent: sink,
  62 + async sendCode(args) {
  63 + sink.push(args)
  64 + },
  65 + }
  66 +}
... ...
packages/auth/test/oauth.test.js 0 → 100644
  1 +++ a/packages/auth/test/oauth.test.js
  1 +import { describe, it, expect } from 'vitest'
  2 +import { createAuth, createFakeUserRepo } from '../src/index.js'
  3 +import { createFakeHasher, createFakeSigner, createFakeOauth, createFakeEmail } from './fakes.js'
  4 +
  5 +function build(oauth) {
  6 + const userRepo = createFakeUserRepo()
  7 + const auth = createAuth({
  8 + userRepo,
  9 + hasher: createFakeHasher(),
  10 + tokenSigner: createFakeSigner(),
  11 + oauth,
  12 + email: createFakeEmail(),
  13 + })
  14 + return { userRepo, auth }
  15 +}
  16 +
  17 +describe('oauthLogin', () => {
  18 + it('first login auto-creates user (is_new_user true)', async () => {
  19 + const oauth = createFakeOauth({
  20 + 'google:CODE1': { provider_user_id: 'g-1', email: 'g@x.com', email_verified: true },
  21 + })
  22 + const { auth, userRepo } = build(oauth)
  23 + const res = await auth.oauthLogin({ provider: 'google', code: 'CODE1', redirect_uri: 'https://app/cb' })
  24 + expect(res.is_new_user).toBe(true)
  25 + expect(res.user.email).toBe('g@x.com')
  26 + expect(res.access_token).toBeTruthy()
  27 + const stored = await userRepo.findByEmail('g@x.com')
  28 + expect(stored.oauth_providers).toContainEqual({ provider: 'google', provider_user_id: 'g-1' })
  29 + })
  30 +
  31 + it('existing email merges into same user (is_new_user false)', async () => {
  32 + const oauth = createFakeOauth({
  33 + 'google:CODE1': { provider_user_id: 'g-1', email: 'merge@x.com', email_verified: true },
  34 + })
  35 + const { auth, userRepo } = build(oauth)
  36 + // pre-existing password user with same email
  37 + await userRepo.create({ email: 'merge@x.com', password_hash: 'h$pw' })
  38 + const res = await auth.oauthLogin({ provider: 'google', code: 'CODE1' })
  39 + expect(res.is_new_user).toBe(false)
  40 + const stored = await userRepo.findByEmail('merge@x.com')
  41 + expect(stored.oauth_providers).toContainEqual({ provider: 'google', provider_user_id: 'g-1' })
  42 + })
  43 +
  44 + it('verify failure -> OAUTH_VERIFY_FAILED', async () => {
  45 + const oauth = createFakeOauth({}, { throwOn: 'BAD' })
  46 + const { auth } = build(oauth)
  47 + await expect(auth.oauthLogin({ provider: 'apple', code: 'BAD' }))
  48 + .rejects.toMatchObject({ code: 'OAUTH_VERIFY_FAILED' })
  49 + })
  50 +
  51 + it('unsupported provider rejected', async () => {
  52 + const { auth } = build(createFakeOauth())
  53 + await expect(auth.oauthLogin({ provider: 'wechat', code: 'x' }))
  54 + .rejects.toMatchObject({ code: 'UNSUPPORTED_PROVIDER' })
  55 + })
  56 +})
... ...
packages/billing/package.json 0 → 100644
  1 +++ a/packages/billing/package.json
  1 +{
  2 + "name": "@drama/billing",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "exports": {
  7 + ".": "./src/index.js"
  8 + },
  9 + "scripts": {
  10 + "test": "vitest run"
  11 + },
  12 + "devDependencies": {
  13 + "knex": "^3.1.0"
  14 + }
  15 +}
... ...
packages/billing/src/action-policies.js 0 → 100644
  1 +++ a/packages/billing/src/action-policies.js
  1 +export const BillingAction = Object.freeze({
  2 + DAILY_CLAIM: 'daily_claim',
  3 + TURN_PLAY: 'turn_play',
  4 + FULL_RUN_VIDEO: 'full_run_video',
  5 + SIGNUP_REWARD: 'signup_reward',
  6 +})
  7 +
  8 +export function createActionPolicies(repo) {
  9 + async function find(action) {
  10 + assertActionName(action)
  11 + return repo.findActionPolicy(action)
  12 + }
  13 +
  14 + async function get(action) {
  15 + const policy = await find(action)
  16 + if (policy === null) throw new Error(`Missing billing_action_policy, action=${action}`)
  17 + return policy
  18 + }
  19 +
  20 + async function list() {
  21 + return repo.listActionPolicies()
  22 + }
  23 +
  24 + async function update(policy) {
  25 + assertActionName(policy.action)
  26 + return repo.updateActionPolicy(policy)
  27 + }
  28 +
  29 + return { find, get, list, update }
  30 +}
  31 +
  32 +function assertActionName(action) {
  33 + if (typeof action !== 'string' || action === '') throw new Error(`Invalid billing action: ${String(action)}`)
  34 +}
... ...
packages/billing/src/billing.js 0 → 100644
  1 +++ a/packages/billing/src/billing.js
  1 +/**
  2 + * `billing.js` —— `createBilling`:装配各能力 + 暴露 `asBillingPort`(§3 唯一编排入口)。
  3 + *
  4 + * 只做装配:注入 `billingRepo` → 组出 ledger(deduct/grant)、paywall、products、port。
  5 + * 装配时即自检(少注入 billingRepo 当场 assert,不拖到运行某行——架构 §10.3)。
  6 + * 不读 env、不建连接。
  7 + *
  8 + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo
  9 + * @typedef {import('./port.js').BillingPort} BillingPort
  10 + */
  11 +
  12 +import { createLedger } from './ledger.js'
  13 +import { createActionPolicies } from './action-policies.js'
  14 +import { createPaywall } from './paywall.js'
  15 +import { createProducts } from './products.js'
  16 +import { asBillingPort } from './port.js'
  17 +import { createModelBilling } from './model-billing.js'
  18 +import { createDailyClaim } from './daily-claim.js'
  19 +import { createCommerceOperations } from './commerce-operations.js'
  20 +import { createSignupReward } from './signup-reward.js'
  21 +
  22 +/**
  23 + * 工厂:唯一入口(§3)。
  24 + * @param {object} [deps]
  25 + * @param {BillingRepo} [deps.billingRepo] 注入真实数据库仓库(createBillingRepo)
  26 + * @returns {{
  27 + * deduct:Function, grant:Function, reversePayment:Function, balance:Function, ledger:Function, findLedgerByIdempotencyKey:Function,
  28 + * settleModelBilling:Function,
  29 + * paywall:{ check:Function, unlock:Function, listUnlocked:Function },
  30 + * products:{ list:Function, get:Function, save:Function },
  31 + * withTransaction?:Function,
  32 + * asBillingPort:() => BillingPort
  33 + * }}
  34 + */
  35 +export function createBilling({ billingRepo, onDeductCommitted = null } = {}) {
  36 + if (!billingRepo || typeof billingRepo.applyDelta !== 'function') {
  37 + throw new Error('[billing] createBilling 需要注入 billingRepo')
  38 + }
  39 + /** @type {BillingRepo} */
  40 + const repo = billingRepo
  41 +
  42 + const ledgerOperations = createLedger(repo)
  43 + async function deduct(input) {
  44 + const result = await ledgerOperations.deduct(input)
  45 + if (!result.replayed && onDeductCommitted !== null) {
  46 + repo.afterCommit(() => onDeductCommitted({
  47 + user_id: input.user_id,
  48 + balance_after: result.balance_after,
  49 + ledger_id: result.ledger_id,
  50 + }))
  51 + }
  52 + return result
  53 + }
  54 + const { grant, reversePayment } = ledgerOperations
  55 + const actionPolicies = createActionPolicies(repo)
  56 + const settleModelBilling = createModelBilling({ deduct })
  57 + const paywall = createPaywall({ billingRepo: repo, deduct })
  58 + const products = createProducts(repo)
  59 +
  60 + /**
  61 + * 当前余额。
  62 + * @param {{ user_id:string }} p
  63 + * @returns {Promise<{ points:number }>}
  64 + */
  65 + async function balance({ user_id }) {
  66 + const points = await repo.getBalance(user_id)
  67 + return { points: Number(points) }
  68 + }
  69 +
  70 + /**
  71 + * 可审计流水(按时间倒序,支持类型/时间过滤与分页)。
  72 + * @param {object} p
  73 + * @param {string} p.user_id
  74 + * @param {{ type?:string, since?:string, until?:string, limit?:number, cursor?:string }} [p.filter]
  75 + * @returns {Promise<{ items:any[], next_cursor:string|null }>}
  76 + */
  77 + async function ledger({ user_id, filter }) {
  78 + return repo.listLedger(user_id, filter ?? {})
  79 + }
  80 +
  81 + async function findLedgerByIdempotencyKey(key) {
  82 + return repo.findLedgerByIdempotencyKey(key)
  83 + }
  84 +
  85 + function withTransaction(trx) {
  86 + if (typeof repo.forTransaction !== 'function') {
  87 + throw new Error('[billing] 当前 billingRepo 不支持事务绑定')
  88 + }
  89 + return createBilling({ billingRepo: repo.forTransaction(trx), onDeductCommitted })
  90 + }
  91 +
  92 + const billing = {
  93 + deduct,
  94 + grant,
  95 + reversePayment,
  96 + balance,
  97 + ledger,
  98 + findLedgerByIdempotencyKey,
  99 + settleModelBilling,
  100 + paywall,
  101 + products,
  102 + actionPolicies,
  103 + withTransaction,
  104 + asBillingPort: () => asBillingPort({ deduct }),
  105 + }
  106 + billing.dailyClaim = createDailyClaim(billing)
  107 + billing.signupReward = createSignupReward(billing)
  108 + billing.commerce = createCommerceOperations(billing)
  109 + return billing
  110 +}
... ...
packages/billing/src/calendar-day.js 0 → 100644
  1 +++ a/packages/billing/src/calendar-day.js
  1 +/**
  2 + * 「某时区的自然日」三件套。每日领取按 America/Los_Angeles 自然日结算,商业化看板按同一口径分桶,
  3 + * 两边必须用同一份换算,否则领取日和报表日会差一天。
  4 + */
  5 +
  6 +const DAY_FORMATTERS = new Map()
  7 +
  8 +function dayFormatter(timezone) {
  9 + let formatter = DAY_FORMATTERS.get(timezone)
  10 + if (formatter === undefined) {
  11 + formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' })
  12 + DAY_FORMATTERS.set(timezone, formatter)
  13 + }
  14 + return formatter
  15 +}
  16 +
  17 +/**
  18 + * 一个时刻落在 `timezone` 的哪个自然日。
  19 + * @param {Date} instant
  20 + * @param {string} timezone IANA 时区名
  21 + * @returns {string} `YYYY-MM-DD`
  22 + */
  23 +export function calendarDayOf(instant, timezone) {
  24 + const value = Object.fromEntries(dayFormatter(timezone).formatToParts(instant).map(part => [part.type, part.value]))
  25 + return `${value.year}-${value.month}-${value.day}`
  26 +}
  27 +
  28 +/**
  29 + * `timezone` 里某个自然日的零点对应的 UTC 时刻。
  30 + * 先按 UTC 零点猜一个时刻,读出该时刻在目标时区的偏移再减回去;夏令时切换日的偏移以那一刻为准。
  31 + * @param {string} date `YYYY-MM-DD`
  32 + * @param {string} timezone IANA 时区名
  33 + * @returns {Date}
  34 + */
  35 +export function midnightInTimezone(date, timezone) {
  36 + const guess = new Date(`${date}T00:00:00.000Z`)
  37 + const offset = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'longOffset' })
  38 + .formatToParts(guess)
  39 + .find(part => part.type === 'timeZoneName')
  40 + .value
  41 + const match = /^GMT(?:([+-])(\d\d):(\d\d))?$/.exec(offset)
  42 + if (match === null) throw new Error(`Cannot resolve timezone offset: ${timezone}`)
  43 + if (match[1] === undefined) return guess
  44 + const minutes = Number(match[2]) * 60 + Number(match[3])
  45 + const signedMinutes = match[1] === '+' ? minutes : -minutes
  46 + return new Date(guess.getTime() - signedMinutes * 60 * 1000)
  47 +}
  48 +
  49 +/**
  50 + * 自然日加减天数,纯日历运算,不涉及时区。
  51 + * @param {string} date `YYYY-MM-DD`
  52 + * @param {number} days
  53 + * @returns {string}
  54 + */
  55 +export function shiftCalendarDay(date, days) {
  56 + const at = new Date(`${date}T00:00:00.000Z`)
  57 + at.setUTCDate(at.getUTCDate() + days)
  58 + return at.toISOString().slice(0, 10)
  59 +}
... ...
packages/billing/src/commerce-operations.js 0 → 100644
  1 +++ a/packages/billing/src/commerce-operations.js
  1 +import { BillingAction } from './action-policies.js'
  2 +
  3 +/** @param {{ balance:Function, actionPolicies:{ get:Function }, dailyClaim:{ status:Function } }} billing */
  4 +export function createCommerceOperations(billing) {
  5 + async function summary({ user_id, now = new Date() }) {
  6 + const [balance, turn, daily_claim] = await Promise.all([
  7 + billing.balance({ user_id }),
  8 + billing.actionPolicies.get(BillingAction.TURN_PLAY),
  9 + billing.dailyClaim.status({ user_id, now }),
  10 + ])
  11 + if (!turn.enabled) throw new Error('turn_play is disabled')
  12 + return {
  13 + points: balance.points,
  14 + playable_turns: Math.floor(balance.points / turn.points),
  15 + action_prices: { turn_play: turn.points },
  16 + daily_claim,
  17 + }
  18 + }
  19 + return { summary }
  20 +}
... ...
packages/billing/src/daily-claim.js 0 → 100644
  1 +++ a/packages/billing/src/daily-claim.js
  1 +import { BillingAction } from './action-policies.js'
  2 +import { calendarDayOf, midnightInTimezone, shiftCalendarDay } from './calendar-day.js'
  3 +
  4 +/** @param {{ actionPolicies:{ get:Function }, grant:Function, balance:Function }} billing */
  5 +export function createDailyClaim(billing) {
  6 + async function claim({ user_id, now = new Date() }) {
  7 + const policy = await billing.actionPolicies.get(BillingAction.DAILY_CLAIM)
  8 + if (!policy.enabled) throw new Error('daily_claim is disabled')
  9 + const window = losAngelesDay(now, policy.timezone)
  10 + const idempotency_key = `daily_claim:${user_id}:${window.date}`
  11 + const granted = await billing.grant({
  12 + user_id,
  13 + amount: policy.points,
  14 + source: 'daily_reward',
  15 + credit_kind: 'daily',
  16 + reason: 'daily_claim',
  17 + ref_type: 'daily_claim',
  18 + ref_id: window.date,
  19 + idempotency_key,
  20 + })
  21 + return {
  22 + ...(await billing.balance({ user_id })),
  23 + claimed: !granted.replayed,
  24 + claimed_today: true,
  25 + next_available_at: window.next_available_at,
  26 + }
  27 + }
  28 +
  29 + async function status({ user_id, now = new Date() }) {
  30 + const policy = await billing.actionPolicies.get(BillingAction.DAILY_CLAIM)
  31 + const window = losAngelesDay(now, policy.timezone)
  32 + const entry = await billing.findLedgerByIdempotencyKey(`daily_claim:${user_id}:${window.date}`)
  33 + return {
  34 + claimed_today: entry !== null,
  35 + next_available_at: window.next_available_at,
  36 + points: policy.points,
  37 + }
  38 + }
  39 +
  40 + return { claim, status }
  41 +}
  42 +
  43 +function losAngelesDay(now, timezone) {
  44 + if (timezone !== 'America/Los_Angeles') throw new Error(`Unsupported daily claim timezone: ${timezone}`)
  45 + const date = calendarDayOf(now, timezone)
  46 + return {
  47 + date,
  48 + next_available_at: midnightInTimezone(shiftCalendarDay(date, 1), timezone).toISOString(),
  49 + }
  50 +}
... ...
packages/billing/src/db/README.md 0 → 100644
  1 +++ a/packages/billing/src/db/README.md
  1 +# @drama/billing Database Schema
  2 +
  3 +这里是 `@drama/billing` 当前表结构的唯一阅读入口。
  4 +
  5 +- `tables/`:一个表一个文件,文件名等于表名。
  6 +- `tables/index.cjs`:批量加载入口,只给显式初始化/测试/校验工具使用,不是人工阅读入口。
  7 +
  8 +## 表
  9 +
  10 +- `tables/billing_product.cjs`
  11 +- `tables/billing_ledger.cjs`
  12 +- `tables/billing_unlock.cjs`
  13 +- `tables/billing_free_quota.cjs`
  14 +- `tables/billing_paywall_rule.cjs`
  15 +- `tables/points_balance_view.cjs`
  16 +
  17 +## Stripe Price ID 约定
  18 +
  19 +`billing_product.stripe_price_id` 是商品目录到 Stripe 的唯一绑定字段。它必须保存 Stripe 测试/生产环境真实存在的 `price_...`,不能保存本地占位值并期待 `@drama/payment` 自动转换。
  20 +
  21 +`@drama/payment` 创建 Checkout Session 时只按 `productId` 读取 `billing.products.get(productId)`,然后把 `stripe_price_id` 作为 Stripe `line_items[].price` 传给官方 SDK。
  22 +
  23 +本地 Stripe 测试流程见 `packages/payment/docs/stripe/README.md`。
... ...
packages/billing/src/db/index.js 0 → 100644
  1 +++ a/packages/billing/src/db/index.js
  1 +export { default as billingActionPolicyTable } from './tables/billing_action_policy.cjs'
  2 +export { default as billingFreeQuotaTable } from './tables/billing_free_quota.cjs'
  3 +export { default as billingPaywallRuleTable } from './tables/billing_paywall_rule.cjs'
  4 +export { default as billingProductTable } from './tables/billing_product.cjs'
  5 +export { default as billingUnlockTable } from './tables/billing_unlock.cjs'
... ...
packages/billing/src/db/tables/billing_action_policy.cjs 0 → 100644
  1 +++ a/packages/billing/src/db/tables/billing_action_policy.cjs
  1 +const tableName = 'billing_action_policy'
  2 +
  3 +function buildTable(t) {
  4 + t.string('action', 48).primary()
  5 + t.bigInteger('points').unsigned().notNullable()
  6 + t.string('period_kind', 16).notNullable()
  7 + t.string('timezone', 64).nullable()
  8 + t.boolean('enabled').notNullable()
  9 + t.string('updated_by', 64).nullable()
  10 + t.datetime('created_at', { precision: 3 }).notNullable()
  11 + t.datetime('updated_at', { precision: 3 }).notNullable()
  12 + t.check("action <> ''", [], 'ck_billing_action_policy_action')
  13 + t.check('points > 0', [], 'ck_billing_action_policy_points')
  14 + t.check("period_kind in ('daily','none')", [], 'ck_billing_action_policy_period_kind')
  15 + t.check("(period_kind = 'daily' and timezone is not null) or (period_kind = 'none' and timezone is null)", [], 'ck_billing_action_policy_shape')
  16 +}
  17 +
  18 +module.exports = { tableName, buildTable }
... ...
packages/billing/src/db/tables/billing_free_quota.cjs 0 → 100644
  1 +++ a/packages/billing/src/db/tables/billing_free_quota.cjs
  1 +const tableName = 'billing_free_quota'
  2 +
  3 +function buildTable(t) {
  4 + t.string('user_id', 64).notNullable()
  5 + t.string('content_id', 96).notNullable()
  6 + t.string('kind', 32).notNullable()
  7 + t.datetime('window_start', { precision: 3 }).notNullable()
  8 + t.integer('used').notNullable().defaultTo(0)
  9 + t.primary(['user_id', 'content_id', 'kind', 'window_start'])
  10 +}
  11 +
  12 +module.exports = { tableName, buildTable }
... ...
packages/billing/src/db/tables/billing_ledger.cjs 0 → 100644
  1 +++ a/packages/billing/src/db/tables/billing_ledger.cjs
  1 +const tableName = 'billing_ledger'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('user_id', 64).notNullable()
  6 + t.string('type', 24).notNullable()
  7 + t.bigInteger('amount').notNullable()
  8 + t.string('reason', 96).nullable()
  9 + t.string('source', 32).notNullable()
  10 + t.string('credit_kind', 24).nullable()
  11 + t.string('ref_type', 64).nullable()
  12 + t.string('ref_id', 191).nullable()
  13 + t.text('note').nullable()
  14 + t.string('failure_code', 64).nullable()
  15 + t.bigInteger('balance_after').notNullable().defaultTo(0)
  16 + t.string('idempotency_key', 191).nullable()
  17 + t.datetime('created_at', { precision: 3 }).notNullable()
  18 + t.index(['user_id', 'created_at'], 'idx_billing_ledger_user_time')
  19 + t.index(['ref_type', 'ref_id'], 'idx_billing_ledger_ref')
  20 + t.index(['source', 'created_at', 'id'], 'idx_billing_ledger_source_time')
  21 + t.index(['reason', 'created_at', 'id'], 'idx_billing_ledger_reason_time')
  22 + t.unique(['idempotency_key'], { indexName: 'uq_billing_ledger_idem' })
  23 + t.check("type in ('grant','deduct')", [], 'ck_billing_ledger_type')
  24 + t.check("(type = 'grant' and amount > 0 and credit_kind is not null) or (type = 'deduct' and amount < 0 and credit_kind is null)", [], 'ck_billing_ledger_amount_credit_kind')
  25 + t.check('(ref_type is null and ref_id is null) or (ref_type is not null and ref_id is not null)', [], 'ck_billing_ledger_reference_pair')
  26 + t.check("reason <> 'admin_adjustment' or note is not null", [], 'ck_billing_ledger_admin_note')
  27 + t.check("reason not in ('turn_play_refund','full_run_video_refund') or failure_code is not null", [], 'ck_billing_ledger_refund_failure_code')
  28 + t.check("reason in ('admin_adjustment','turn_play_refund','full_run_video_refund') or (note is null and failure_code is null)", [], 'ck_billing_ledger_phase_one_note_failure')
  29 +}
  30 +
  31 +module.exports = { tableName, buildTable }
... ...
packages/billing/src/db/tables/billing_paywall_rule.cjs 0 → 100644
  1 +++ a/packages/billing/src/db/tables/billing_paywall_rule.cjs
  1 +const tableName = 'billing_paywall_rule'
  2 +
  3 +function buildTable(t) {
  4 + t.string('content_id', 96).primary()
  5 + t.integer('free_episodes').notNullable().defaultTo(0)
  6 + t.integer('free_continue_per_day').notNullable().defaultTo(0)
  7 + t.text('prices_json', 'longtext').notNullable()
  8 + t.boolean('active').notNullable().defaultTo(true)
  9 + t.datetime('updated_at', { precision: 3 }).nullable()
  10 +}
  11 +
  12 +module.exports = { tableName, buildTable }
... ...
packages/billing/src/db/tables/billing_product.cjs 0 → 100644
  1 +++ a/packages/billing/src/db/tables/billing_product.cjs
  1 +const tableName = 'billing_product'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 96).primary()
  5 + t.string('kind', 32).notNullable()
  6 + t.string('title', 160).nullable()
  7 + t.bigInteger('points').nullable()
  8 + t.bigInteger('paid_points').unsigned().nullable()
  9 + t.bigInteger('bonus_points').unsigned().nullable()
  10 + t.decimal('price_usd', 10, 2).nullable()
  11 + t.bigInteger('price_cents').nullable()
  12 + t.string('currency', 8).nullable()
  13 + t.string('stripe_price_id', 191).nullable()
  14 + t.text('metadata_json', 'longtext').nullable()
  15 + t.boolean('active').notNullable().defaultTo(true)
  16 + t.datetime('created_at', { precision: 3 }).nullable()
  17 + t.datetime('updated_at', { precision: 3 }).nullable()
  18 + t.index(['active', 'kind'], 'idx_billing_product_active_kind')
  19 + t.unique(['stripe_price_id'], { indexName: 'uq_billing_product_stripe_price' })
  20 + t.check("kind <> 'points_pack' or (points is not null and paid_points is not null and bonus_points is not null and points = paid_points + bonus_points and price_cents > 0 and currency = 'usd' and price_usd = price_cents / 100)", [], 'ck_billing_product_points_pack')
  21 +}
  22 +
  23 +module.exports = { tableName, buildTable }
... ...
packages/billing/src/db/tables/billing_unlock.cjs 0 → 100644
  1 +++ a/packages/billing/src/db/tables/billing_unlock.cjs
  1 +const tableName = 'billing_unlock'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('user_id', 64).notNullable()
  6 + t.string('content_id', 96).notNullable()
  7 + t.string('target_key', 191).notNullable()
  8 + t.string('ledger_id', 64).nullable()
  9 + t.string('target_kind', 32).nullable()
  10 + t.datetime('created_at', { precision: 3 }).notNullable()
  11 + t.index(['user_id', 'content_id'], 'idx_billing_unlock_user_content')
  12 + t.unique(['user_id', 'content_id', 'target_key'], { indexName: 'uq_billing_unlock_target' })
  13 +}
  14 +
  15 +module.exports = { tableName, buildTable }
... ...
packages/billing/src/db/tables/index.cjs 0 → 100644
  1 +++ a/packages/billing/src/db/tables/index.cjs
  1 +const tableModules = [
  2 + require('./billing_action_policy.cjs'),
  3 + require('./billing_product.cjs'),
  4 + require('./billing_ledger.cjs'),
  5 + require('./billing_unlock.cjs'),
  6 + require('./billing_free_quota.cjs'),
  7 + require('./billing_paywall_rule.cjs'),
  8 +]
  9 +const viewModules = [
  10 + require('./points_balance_view.cjs'),
  11 +]
  12 +
  13 +const targetTables = tableModules.filter(({ buildTable }) => typeof buildTable === 'function').map(({ tableName, buildTable }) => [tableName, buildTable])
  14 +const billingTables = Object.fromEntries(tableModules.map(({ tableName }) => [tableName.replace(/^(auth_|account_|billing_|payment_|email_)/, ''), tableName]))
  15 +const billingViews = Object.fromEntries(viewModules.map(({ viewName }) => [viewName, viewName]))
  16 +
  17 +module.exports = {
  18 + targetTables,
  19 + billingTables,
  20 + billingViews,
  21 +}
... ...
packages/billing/src/db/tables/points_balance_view.cjs 0 → 100644
  1 +++ a/packages/billing/src/db/tables/points_balance_view.cjs
  1 +const viewName = 'points_balance_view'
  2 +
  3 +function createViewSql() {
  4 + return 'CREATE VIEW points_balance_view AS SELECT user_id, COALESCE(SUM(amount), 0) AS balance FROM billing_ledger GROUP BY user_id'
  5 +}
  6 +
  7 +function createEmptyViewSql(dialect) {
  8 + if (dialect === 'mysql2') {
  9 + return 'CREATE OR REPLACE VIEW points_balance_view AS SELECT NULL AS user_id, 0 AS balance FROM DUAL WHERE 1=0'
  10 + }
  11 + return 'CREATE VIEW IF NOT EXISTS points_balance_view AS SELECT NULL AS user_id, 0 AS balance WHERE 0'
  12 +}
  13 +
  14 +module.exports = {
  15 + viewName,
  16 + createViewSql,
  17 + createEmptyViewSql,
  18 +}
... ...
packages/billing/src/errors.js 0 → 100644
  1 +++ a/packages/billing/src/errors.js
  1 +/**
  2 + * 结构化计费错误(§3 契约)。所有可预期的业务失败(余额不足 / 产品缺失 /
  3 + * 规则缺失 / 重复解锁)都以 `BillingError` 抛出,绝不返回魔法值或静默吞错。
  4 + *
  5 + * 字段语义:
  6 + * - `code`:稳定枚举(见 `BillingErrorCode`),调用方据此分流,不依赖 message 文案。
  7 + * - `retryable`:是否值得重试。业务约束类(余额不足 / 产品缺失 / 已解锁)= false;
  8 + * 预留给底层瞬时故障(DB 抖动)时由调用方包成 retryable=true。
  9 + * - `cause`:底层原始错误,保留链路。
  10 + */
  11 +
  12 +/**
  13 + * 失败原因枚举(稳定字符串)。
  14 + * @readonly
  15 + */
  16 +export const BillingErrorCode = Object.freeze({
  17 + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
  18 + PRODUCT_NOT_FOUND: 'PRODUCT_NOT_FOUND',
  19 + RULE_NOT_FOUND: 'RULE_NOT_FOUND',
  20 + ALREADY_UNLOCKED: 'ALREADY_UNLOCKED',
  21 +})
  22 +
  23 +export class BillingError extends Error {
  24 + /**
  25 + * @param {object} info
  26 + * @param {string} info.code 见 `BillingErrorCode`
  27 + * @param {boolean} [info.retryable=false]
  28 + * @param {unknown} [info.cause]
  29 + * @param {string} [info.message]
  30 + */
  31 + constructor({ code, retryable = false, cause, message }) {
  32 + super(message || `[billing] ${code}`)
  33 + this.name = 'BillingError'
  34 + this.code = code
  35 + this.retryable = Boolean(retryable)
  36 + if (cause !== undefined) this.cause = cause
  37 + }
  38 +}
... ...
packages/billing/src/index.js 0 → 100644
  1 +++ a/packages/billing/src/index.js
  1 +/**
  2 + * `@drama/billing` 公共面(唯一对外入口,§3 契约)。
  3 + *
  4 + * 把「积分账册 + 付费墙」封装成注入式 package:扣费/入账(原子+幂等)、消费日志、
  5 + * 产品目录、付费墙规则(数据驱动纯判定)、以及给 llm/tts 注入消费的 `BillingPort`。
  6 + *
  7 + * 对外只暴露 §3 契约声明的符号:`createBilling`(工厂)、`createBillingRepo`(仓库工厂,
  8 + * db 注入)、`BillingError`(结构化错误)。
  9 + *
  10 + * 边界纪律(架构红线):包内不读 `process.env`、不建任何顶层连接;`db` 只经
  11 + * `createBillingRepo(db)` 注入;不 import 任何其他领域包 / 能力包,与 llm/tts 协作
  12 + * 只经 `asBillingPort` 注入。
  13 + *
  14 + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo
  15 + * @typedef {import('./port.js').BillingPort} BillingPort
  16 + * @typedef {import('./quota.js').PaywallRules} PaywallRules
  17 + */
  18 +
  19 +export { createBilling } from './billing.js'
  20 +export { BillingAction, createActionPolicies } from './action-policies.js'
  21 +export { createLedger } from './ledger.js'
  22 +export { createProducts } from './products.js'
  23 +export { createDailyClaim } from './daily-claim.js'
  24 +export { calendarDayOf, midnightInTimezone, shiftCalendarDay } from './calendar-day.js'
  25 +export { createCommerceOperations } from './commerce-operations.js'
  26 +export { createBillingRepo } from './repo/index.js'
  27 +export { createModelSettlementPort } from './model-settlement-port.js'
  28 +export { BillingError } from './errors.js'
  29 +export { recreateBillingViews } from './views.js'
  30 +export { default as billingDbSchema } from './db/tables/index.cjs'
... ...
packages/billing/src/ledger.js 0 → 100644
  1 +++ a/packages/billing/src/ledger.js
  1 +/**
  2 + * `ledger.js` —— 钱的原子性命门:`deduct` / `grant`。
  3 + *
  4 + * 职责单一:把「余额检查 → 扣减/增加 → 写流水」编排成**同一事务**的原子操作,
  5 + * 并用 `idempotency_key` 兜重放幂等。真正的 SQL/事务在 `repo/ledger-repo.js`;本文件只编排,
  6 + * 不认识数据库具体形态(repo 注入)。
  7 + *
  8 + * 幂等模型:扣分必须带 `idempotency_key`,由 ledger 唯一约束裁决并发竞争;唯一键冲突后
  9 + * 回读首次结果并逐项核对用户、金额和引用,完全一致才作为重放返回。禁止 SELECT 后 INSERT。
  10 + *
  11 + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo
  12 + */
  13 +
  14 +import { BillingError, BillingErrorCode } from './errors.js'
  15 +
  16 +/** 流水类型枚举(写入 ledger.type,用于审计与过滤)。 */
  17 +export const LedgerType = Object.freeze({
  18 + DEDUCT: 'deduct',
  19 + GRANT: 'grant',
  20 +})
  21 +
  22 +/**
  23 + * 装配 deduct / grant,闭包持有注入的 repo。
  24 + * @param {BillingRepo} billingRepo
  25 + */
  26 +export function createLedger(billingRepo) {
  27 + /**
  28 + * 扣费:事务内余额检查 + 扣减 + 写 ledger。余额不足抛 INSUFFICIENT_BALANCE 不写账。
  29 + * 同 idempotency_key 重放返首次结果、不二次扣。
  30 + *
  31 + * @param {object} p
  32 + * @param {string} p.user_id
  33 + * @param {number} p.amount 正整数积分
  34 + * @param {string} p.reason 人类可读原因('continue' / 'unlock_branch' ...)
  35 + * @param {string} [p.ref_type] 关联对象类型('drama' / 'episode' / 'continue' ...)
  36 + * @param {string} [p.ref_id]
  37 + * @param {string} p.idempotency_key 扣费必须带 key(重放保护)
  38 + * @returns {Promise<{ balance_after:number, ledger_id:string }>}
  39 + */
  40 + async function deduct({ user_id, amount, source, reason, ref_type = null, ref_id = null, idempotency_key, note = null, failure_code = null }) {
  41 + assertAmount(amount)
  42 + assertIdempotencyKey(idempotency_key, 'deduct')
  43 +
  44 + const result = await billingRepo.applyDelta({
  45 + user_id,
  46 + delta: -Math.abs(amount),
  47 + require_sufficient: true,
  48 + ledger_row: {
  49 + type: LedgerType.DEDUCT,
  50 + source: source ?? sourceForLegacyDeduct(ref_type),
  51 + credit_kind: null,
  52 + reason,
  53 + ref_type,
  54 + ref_id,
  55 + note,
  56 + failure_code,
  57 + idempotency_key,
  58 + },
  59 + })
  60 + if (result.kind === 'insufficient_balance') {
  61 + throw new BillingError({
  62 + code: BillingErrorCode.INSUFFICIENT_BALANCE,
  63 + message: `余额不足:当前 ${result.balance_before},需扣 ${amount}`,
  64 + })
  65 + }
  66 + return { balance_after: result.balance_after, ledger_id: result.ledger_id, replayed: result.kind === 'replayed' }
  67 + }
  68 +
  69 + /**
  70 + * 入账:充值成功 / 新用户赠送 / 退款返还。写 ledger,余额增加。
  71 + * 给了 idempotency_key 时同样幂等。
  72 + *
  73 + * @param {object} p
  74 + * @param {string} p.user_id
  75 + * @param {number} p.amount
  76 + * @param {string} p.reason
  77 + * @param {string} [p.source] 来源('recharge' / 'signup_gift' / 'refund' ...)
  78 + * @param {string} [p.idempotency_key]
  79 + * @returns {Promise<{ balance_after:number, ledger_id:string }>}
  80 + */
  81 + async function grant({ user_id, amount, source, credit_kind, reason, ref_type = null, ref_id = null, idempotency_key, note = null, failure_code = null }) {
  82 + assertAmount(amount)
  83 + if (source === undefined || source === '') throw new Error('grant requires source')
  84 + assertIdempotencyKey(idempotency_key, 'grant')
  85 + const result = await billingRepo.applyDelta({
  86 + user_id,
  87 + delta: Math.abs(amount),
  88 + require_sufficient: false,
  89 + ledger_row: {
  90 + type: LedgerType.GRANT,
  91 + source,
  92 + credit_kind: credit_kind ?? creditKindForLegacyGrant(source),
  93 + reason,
  94 + ref_type,
  95 + ref_id,
  96 + note,
  97 + failure_code,
  98 + idempotency_key,
  99 + },
  100 + })
  101 + return { balance_after: result.balance_after, ledger_id: result.ledger_id, replayed: result.kind === 'replayed' }
  102 + }
  103 +
  104 + /**
  105 + * 支付退款或拒付冲正:只扣可用余额,绝不形成负余额。
  106 + * @param {{ user_id:string, total_points:number, reason:'payment_refund_reversal'|'payment_dispute_reversal', ref_id:string, idempotency_key:string }} input
  107 + * @returns {Promise<{ ledger_id:string|null, balance_after:number, points_reversed:number, reversal_shortfall_points:number, replayed:boolean }>}
  108 + */
  109 + async function reversePayment({ user_id, total_points, reason, ref_id, idempotency_key }) {
  110 + assertAmount(total_points)
  111 + assertIdempotencyKey(idempotency_key, 'reversePayment')
  112 + assertPaymentReversalReason(reason)
  113 + const result = await billingRepo.reverseDelta({
  114 + user_id,
  115 + total_points,
  116 + ledger_row: {
  117 + type: LedgerType.DEDUCT,
  118 + source: 'stripe',
  119 + credit_kind: null,
  120 + reason,
  121 + ref_type: 'order',
  122 + ref_id,
  123 + note: null,
  124 + failure_code: null,
  125 + idempotency_key,
  126 + },
  127 + })
  128 + return {
  129 + ledger_id: result.ledger_id,
  130 + balance_after: result.balance_after,
  131 + points_reversed: result.points_reversed,
  132 + reversal_shortfall_points: result.reversal_shortfall_points,
  133 + replayed: result.kind === 'replayed',
  134 + }
  135 + }
  136 +
  137 + return { deduct, grant, reversePayment }
  138 +}
  139 +
  140 +function assertPaymentReversalReason(reason) {
  141 + switch (reason) {
  142 + case 'payment_refund_reversal':
  143 + case 'payment_dispute_reversal':
  144 + return
  145 + default:
  146 + throw new Error(`Unhandled payment reversal reason: ${reason}`)
  147 + }
  148 +}
  149 +
  150 +function assertIdempotencyKey(idempotency_key, operation) {
  151 + if (typeof idempotency_key !== 'string' || idempotency_key === '') {
  152 + throw new Error(`${operation} requires a stable idempotency_key`)
  153 + }
  154 +}
  155 +
  156 +function sourceForLegacyDeduct(ref_type) {
  157 + switch (ref_type) {
  158 + case 'llm_chat':
  159 + case 'llm_operation':
  160 + return 'llm'
  161 + case 'tts_operation':
  162 + case 'visual_game_tts':
  163 + return 'tts'
  164 + case 'image_operation':
  165 + return 'image_generation'
  166 + case 'video_operation':
  167 + return 'video_generation'
  168 + case 'unlock':
  169 + return 'unlock'
  170 + default:
  171 + throw new Error(`deduct requires source for ref_type=${ref_type}`)
  172 + }
  173 +}
  174 +
  175 +function creditKindForLegacyGrant(source) {
  176 + switch (source) {
  177 + case 'apple':
  178 + case 'google':
  179 + case 'stripe':
  180 + return 'paid'
  181 + case 'visual_game_signup':
  182 + case 'admin':
  183 + case 'platform':
  184 + case 'legacy_admin':
  185 + case 'verification':
  186 + return 'admin'
  187 + default:
  188 + throw new Error(`grant requires credit_kind for source=${source}`)
  189 + }
  190 +}
  191 +
  192 +/**
  193 + * @param {unknown} amount
  194 + */
  195 +function assertAmount(amount) {
  196 + if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0) {
  197 + throw new BillingError({
  198 + code: BillingErrorCode.INSUFFICIENT_BALANCE,
  199 + message: `amount 必须是正整数,收到: ${String(amount)}`,
  200 + retryable: false,
  201 + })
  202 + }
  203 +}
... ...
packages/billing/src/model-billing.js 0 → 100644
  1 +++ a/packages/billing/src/model-billing.js
  1 +const MICROS_PER_YUAN = 1_000_000
  2 +
  3 +const POLICY_SETTLERS = {
  4 + record_only() {
  5 + return 0
  6 + },
  7 + fixed_points(policy) {
  8 + return policy.points
  9 + },
  10 + cost_ratio(policy, supplierCost) {
  11 + return Math.ceil(supplierCost.supplier_cost_micros * policy.points_per_yuan / MICROS_PER_YUAN)
  12 + },
  13 +}
  14 +
  15 +/**
  16 + * 模型供应商成本到用户积分的唯一结算入口。
  17 + * @param {{ deduct:(input:object)=>Promise<{balance_after:number,ledger_id:string}> }} deps
  18 + */
  19 +export function createModelBilling({ deduct }) {
  20 + return async function settleModelBilling(input) {
  21 + const settlement = modelSettlement(input.policy, input.supplier_cost)
  22 + if (settlement.charged_points === 0) return { ...settlement, ledger_id: null }
  23 + const ledger = await deduct({
  24 + user_id: input.user_id,
  25 + amount: settlement.charged_points,
  26 + reason: 'model_usage',
  27 + ref_type: input.call_ref.type,
  28 + ref_id: input.call_ref.id,
  29 + idempotency_key: input.idempotency_key,
  30 + })
  31 + return { ...settlement, ledger_id: ledger.ledger_id }
  32 + }
  33 +}
  34 +
  35 +/**
  36 + * 结算入口的唯一数据构造点。非法策略和金额在写 ledger 前立即失败。
  37 + * @param {Record<string, any>} input
  38 + */
  39 +function modelSettlement(policy, supplierCost) {
  40 + const billingPolicySnapshot = policySnapshot(policy)
  41 + const chargedPoints = POLICY_SETTLERS[policy.mode](billingPolicySnapshot, supplierCost)
  42 + return {
  43 + billing_mode: billingPolicySnapshot.mode,
  44 + billing_policy_snapshot: billingPolicySnapshot,
  45 + charged_points: chargedPoints,
  46 + }
  47 +}
  48 +
  49 +/** 校验并构造可审计的精确策略快照;不接受未声明字段。 */
  50 +function policySnapshot(policy) {
  51 + switch (policy.mode) {
  52 + case 'record_only':
  53 + return { mode: policy.mode, reason: policy.reason }
  54 + case 'fixed_points':
  55 + return { mode: policy.mode, points: policy.points }
  56 + case 'cost_ratio':
  57 + return { mode: policy.mode, points_per_yuan: policy.points_per_yuan }
  58 + default:
  59 + throw new Error(`Unhandled billing mode: ${policy.mode}`)
  60 + }
  61 +}
... ...
packages/billing/src/model-settlement-port.js 0 → 100644
  1 +++ a/packages/billing/src/model-settlement-port.js
  1 +import { createBilling } from './billing.js'
  2 +import { createBillingRepo } from './repo/index.js'
  3 +
  4 +/**
  5 + * 把模型积分流水与能力自己的调用日志结算结果写入同一个数据库事务。
  6 + * 能力包只提供 recordSettlement,不需要 billing 认识 LLM、图片或视频表。
  7 + *
  8 + * @param {object} deps
  9 + * @param {import('knex').Knex} deps.db
  10 + * @param {(trx:import('knex').Knex.Transaction,input:object,settlement:object)=>Promise<void>} deps.recordSettlement
  11 + */
  12 +export function createModelSettlementPort({ db, recordSettlement }) {
  13 + const repo = createBillingRepo(db)
  14 +
  15 + return {
  16 + async settle(input) {
  17 + return db.transaction(async (trx) => {
  18 + const billing = createBilling({ billingRepo: repo.forTransaction(trx) })
  19 + const settlement = await billing.settleModelBilling(input)
  20 + await recordSettlement(trx, input.call_id, settlement)
  21 + return settlement
  22 + })
  23 + },
  24 + }
  25 +}
... ...
packages/billing/src/paywall.js 0 → 100644
  1 +++ a/packages/billing/src/paywall.js
  1 +/**
  2 + * `paywall.js` —— 付费墙判定与解锁。
  3 + *
  4 + * `check` 是数据驱动的纯判定:取规则(`listPaywallRules`)+ 已解锁记录 + 免费用量,
  5 + * 调 `quota.js` 算出 `{ unlocked, reason, price_points?, free_quota_left? }`。
  6 + * `unlock` 走 `deduct` 完成扣分并落「已解锁」记录,重复解锁幂等(已解锁不再扣)。
  7 + *
  8 + * target 形态(§3):`{ kind:'episode'|'branch'|'ending'|'continue', key }`。
  9 + * `key` 对 episode 是集号字符串,对 branch/ending 是分支/结局 id,对 continue 是本次续写 id。
  10 + *
  11 + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo
  12 + * @typedef {{ kind:'episode'|'branch'|'ending'|'continue', key:string|number }} Target
  13 + */
  14 +
  15 +import { BillingError, BillingErrorCode } from './errors.js'
  16 +import {
  17 + evaluateEpisodeQuota,
  18 + evaluateContinueQuota,
  19 + priceForUnlock,
  20 + day_window_start,
  21 +} from './quota.js'
  22 +
  23 +/** 解锁判定原因(稳定枚举,供前端文案/埋点分流)。 */
  24 +export const PaywallReason = Object.freeze({
  25 + ALREADY_UNLOCKED: 'already_unlocked',
  26 + FREE_EPISODE: 'free_episode',
  27 + FREE_CONTINUE: 'free_continue',
  28 + PAYMENT_REQUIRED: 'payment_required',
  29 +})
  30 +
  31 +/** target.kind 与不需要持久解锁记录的「免费即放行」类型。 */
  32 +const QUOTA_KINDS = new Set(['episode', 'continue'])
  33 +
  34 +/**
  35 + * @param {object} deps
  36 + * @param {BillingRepo} deps.billingRepo
  37 + * @param {(p:any)=>Promise<{balance_after:number,ledger_id:string}>} deps.deduct ledger.deduct 注入
  38 + */
  39 +export function createPaywall({ billingRepo, deduct }) {
  40 + /**
  41 + * 判定 target 对该用户是否解锁。
  42 + * @param {object} p
  43 + * @param {string} p.user_id
  44 + * @param {string} p.content_id
  45 + * @param {Target} p.target
  46 + * @returns {Promise<{ unlocked:boolean, reason:string, price_points?:number, free_quota_left?:number }>}
  47 + */
  48 + async function check({ user_id, content_id, target }) {
  49 + const { kind, key } = normalizeTarget(target)
  50 + const rules = await loadRules({ billingRepo, content_id })
  51 +
  52 + // 已显式解锁过(持久记录)→ 直接放行(branch/ending/已付费 episode 都走这)。
  53 + const target_key = toTargetKey(kind, key)
  54 + const already = await billingRepo.findUnlock({ user_id, content_id, target_key })
  55 + if (already) {
  56 + return { unlocked: true, reason: PaywallReason.ALREADY_UNLOCKED }
  57 + }
  58 +
  59 + if (kind === 'episode') {
  60 + const ep = evaluateEpisodeQuota({ rules, episode_number: Number(key) })
  61 + return ep.free
  62 + ? { unlocked: true, reason: PaywallReason.FREE_EPISODE, free_quota_left: ep.free_quota_left }
  63 + : { unlocked: false, reason: PaywallReason.PAYMENT_REQUIRED, price_points: ep.price_points, free_quota_left: ep.free_quota_left }
  64 + }
  65 +
  66 + if (kind === 'continue') {
  67 + const used_today = await billingRepo.getFreeQuotaUsage({
  68 + user_id,
  69 + content_id,
  70 + kind: 'continue',
  71 + window: day_window_start(),
  72 + })
  73 + const c = evaluateContinueQuota({ rules, used_today })
  74 + return c.free
  75 + ? { unlocked: true, reason: PaywallReason.FREE_CONTINUE, free_quota_left: c.free_quota_left }
  76 + : { unlocked: false, reason: PaywallReason.PAYMENT_REQUIRED, price_points: c.price_points, free_quota_left: c.free_quota_left }
  77 + }
  78 +
  79 + // branch / ending:无免费额度概念,未解锁即需付费。
  80 + const { price_points } = priceForUnlock({ rules, kind: /** @type {'branch'|'ending'} */ (kind) })
  81 + return { unlocked: false, reason: PaywallReason.PAYMENT_REQUIRED, price_points }
  82 + }
  83 +
  84 + /**
  85 + * 解锁:扣分 + 落已解锁记录,幂等(已解锁不再扣)。
  86 + * @param {object} p
  87 + * @param {string} p.user_id
  88 + * @param {string} p.content_id
  89 + * @param {Target} p.target
  90 + * @param {string} p.idempotency_key
  91 + * @returns {Promise<{ unlocked:true, ledger_id?:string }>}
  92 + */
  93 + async function unlock({ user_id, content_id, target, idempotency_key }) {
  94 + const { kind, key } = normalizeTarget(target)
  95 + const target_key = toTargetKey(kind, key)
  96 +
  97 + const already = await billingRepo.findUnlock({ user_id, content_id, target_key })
  98 + if (already) {
  99 + // 幂等:已解锁不再扣分。
  100 + return { unlocked: true, ledger_id: already.ledger_id ?? undefined }
  101 + }
  102 +
  103 + const verdict = await check({ user_id, content_id, target })
  104 + if (verdict.unlocked) {
  105 + // 落在免费区间:直接记一条免费解锁,不扣分。
  106 + if (QUOTA_KINDS.has(kind)) {
  107 + await billingRepo.bumpFreeQuota({ user_id, content_id, kind, window: day_window_start() })
  108 + }
  109 + await billingRepo.insertUnlock({ user_id, content_id, target_key, ledger_id: null })
  110 + return { unlocked: true }
  111 + }
  112 +
  113 + const price = verdict.price_points
  114 + if (typeof price !== 'number' || price <= 0) {
  115 + throw new BillingError({
  116 + code: BillingErrorCode.RULE_NOT_FOUND,
  117 + message: `target ${target_key} 无可用价目,无法解锁`,
  118 + retryable: false,
  119 + })
  120 + }
  121 +
  122 + const { ledger_id } = await deduct({
  123 + user_id,
  124 + amount: price,
  125 + reason: `unlock_${kind}`,
  126 + ref_type: 'unlock',
  127 + ref_id: `${content_id}:${target_key}`,
  128 + idempotency_key,
  129 + })
  130 + await billingRepo.insertUnlock({ user_id, content_id, target_key, ledger_id })
  131 + return { unlocked: true, ledger_id }
  132 + }
  133 +
  134 + /**
  135 + * 列出某剧下该用户已解锁的 target keys。
  136 + * @param {object} p
  137 + * @param {string} p.user_id
  138 + * @param {string} p.content_id
  139 + * @returns {Promise<string[]>}
  140 + */
  141 + async function listUnlocked({ user_id, content_id }) {
  142 + const rows = await billingRepo.listUnlocks(user_id, content_id)
  143 + return rows.map((r) => r.target_key)
  144 + }
  145 +
  146 + return { check, unlock, listUnlocked }
  147 +}
  148 +
  149 +/**
  150 + * @param {object} p
  151 + * @param {BillingRepo} p.billingRepo
  152 + * @param {string} p.content_id
  153 + * @returns {Promise<import('./quota.js').PaywallRules>}
  154 + */
  155 +async function loadRules({ billingRepo, content_id }) {
  156 + const rules = await billingRepo.listPaywallRules(content_id)
  157 + if (!rules) {
  158 + throw new BillingError({
  159 + code: BillingErrorCode.RULE_NOT_FOUND,
  160 + message: `content_id ${content_id} 无付费墙规则`,
  161 + retryable: false,
  162 + })
  163 + }
  164 + return rules
  165 +}
  166 +
  167 +/**
  168 + * @param {Target} target
  169 + * @returns {{ kind:string, key:string|number }}
  170 + */
  171 +function normalizeTarget(target) {
  172 + if (!target || typeof target.kind !== 'string') {
  173 + throw new BillingError({
  174 + code: BillingErrorCode.RULE_NOT_FOUND,
  175 + message: 'target 需含 kind 与 key',
  176 + retryable: false,
  177 + })
  178 + }
  179 + if (!['episode', 'branch', 'ending', 'continue'].includes(target.kind)) {
  180 + throw new BillingError({
  181 + code: BillingErrorCode.RULE_NOT_FOUND,
  182 + message: `未知 target.kind: ${target.kind}`,
  183 + retryable: false,
  184 + })
  185 + }
  186 + return { kind: target.kind, key: target.key }
  187 +}
  188 +
  189 +/**
  190 + * @param {string} kind
  191 + * @param {string|number} key
  192 + */
  193 +function toTargetKey(kind, key) {
  194 + return `${kind}:${key}`
  195 +}
... ...
packages/billing/src/port.js 0 → 100644
  1 +++ a/packages/billing/src/port.js
  1 +/**
  2 + * `port.js` —— `asBillingPort`:把 `deduct` 收窄成 `BillingPort.charge`。
  3 + *
  4 + * llm/tts 在「续写 / 合成语音」时只需要「扣费,不足即中止」这一条能力,不该看到
  5 + * grant / paywall / products。本文件把 deduct 收窄成最小、稳定的 `BillingPort` 面,
  6 + * 供它们注入消费——llm/tts 不 import billing,billing 也不 import 它们(架构 §8)。
  7 + *
  8 + * @typedef {{ charge:(p:{user_id:string,amount:number,reason:string,ref_type?:string,ref_id?:string,idempotency_key:string})=>Promise<{balance_after:number}> }} BillingPort
  9 + */
  10 +
  11 +/**
  12 + * @param {object} deps
  13 + * @param {(p:any)=>Promise<{balance_after:number,ledger_id:string}>} deps.deduct
  14 + * @returns {BillingPort}
  15 + */
  16 +export function asBillingPort({ deduct }) {
  17 + return {
  18 + /**
  19 + * 扣费。余额不足时 deduct 抛 `BillingError({ code:'INSUFFICIENT_BALANCE' })`,
  20 + * 原样上抛供调用方据此中止生成。返回面收窄为只含 `balance_after`。
  21 + * @param {object} p
  22 + * @param {string} p.user_id
  23 + * @param {number} p.amount
  24 + * @param {string} p.reason
  25 + * @param {string} [p.ref_type]
  26 + * @param {string} [p.ref_id]
  27 + * @param {string} p.idempotency_key
  28 + * @returns {Promise<{ balance_after:number }>}
  29 + */
  30 + async charge({ user_id, amount, reason, ref_type, ref_id, idempotency_key }) {
  31 + const { balance_after } = await deduct({
  32 + user_id,
  33 + amount,
  34 + reason,
  35 + ref_type,
  36 + ref_id,
  37 + idempotency_key,
  38 + })
  39 + return { balance_after }
  40 + },
  41 + }
  42 +}
... ...
packages/billing/src/products.js 0 → 100644
  1 +++ a/packages/billing/src/products.js
  1 +/**
  2 + * `products.js` —— 产品目录读取(积分套餐 + 可购权益)。
  3 + *
  4 + * 产品目录,数据全来自 `billingRepo`(运营可改)。`save` 保存完整产品定义;调用方不传的
  5 + * 可选字段会按默认值或 null 入库,不做隐式局部 patch。
  6 + *
  7 + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo
  8 + */
  9 +
  10 +import { BillingError, BillingErrorCode } from './errors.js'
  11 +
  12 +/**
  13 + * @param {BillingRepo} billingRepo
  14 + */
  15 +export function createProducts(billingRepo) {
  16 + return {
  17 + /** @returns {Promise<Product[]>} */
  18 + async list() {
  19 + return billingRepo.listProducts()
  20 + },
  21 + async listAll() {
  22 + return billingRepo.listAllProducts()
  23 + },
  24 + /**
  25 + * @param {string} productId
  26 + * @returns {Promise<Product|null>}
  27 + */
  28 + async get(productId) {
  29 + const p = await billingRepo.findProduct(productId)
  30 + return p ?? null
  31 + },
  32 + /**
  33 + * @param {Record<string, any>} product
  34 + */
  35 + async save(product) {
  36 + if (!product?.id) {
  37 + throw new BillingError({
  38 + code: BillingErrorCode.PRODUCT_NOT_FOUND,
  39 + message: '产品 id 不能为空',
  40 + retryable: false,
  41 + })
  42 + }
  43 + return billingRepo.saveProduct(product)
  44 + },
  45 + /**
  46 + * 取产品,缺失即抛(供需要强存在性的编排用,如 payment 下单)。
  47 + * @param {string} productId
  48 + * @returns {Promise<Product>}
  49 + */
  50 + async require(productId) {
  51 + const p = await billingRepo.findProduct(productId)
  52 + if (!p) {
  53 + throw new BillingError({
  54 + code: BillingErrorCode.PRODUCT_NOT_FOUND,
  55 + message: `产品不存在: ${productId}`,
  56 + retryable: false,
  57 + })
  58 + }
  59 + return p
  60 + },
  61 + }
  62 +}
... ...
packages/billing/src/quota.js 0 → 100644
  1 +++ a/packages/billing/src/quota.js
  1 +/**
  2 + * `quota.js` —— 免费额度切点判定(纯逻辑,零 IO)。
  3 + *
  4 + * 两类切点,皆数据驱动(规则从 `billingRepo.listPaywallRules` 取,用量从
  5 + * `getFreeQuotaUsage` 取,本文件只做纯计算):
  6 + * 1. **前 N 集免费**:第 1..N 集(key 为序号)免费,N+1 起收费。
  7 + * 2. **每日 K 次免费续写**:当日窗口内前 K 次 `continue` 免费,第 K+1 次起收费。
  8 + *
  9 + * 设计成纯函数让全表可直接单测:给定规则 + 当前用量,返回是否免费、剩余额度、价目。
  10 + */
  11 +
  12 +/**
  13 + * @typedef {object} PaywallRules
  14 + * @property {number} [free_episodes] 前 N 集免费(N)
  15 + * @property {number} [free_continue_per_day] 每日免费续写次数(K)
  16 + * @property {Record<string, number>} [prices] 各 kind 的解锁价目(积分),如 { episode:30, branch:50, ending:80, continue:5 }
  17 + */
  18 +
  19 +/**
  20 + * 判定「第 N 集」是否落在免费区间。
  21 + * @param {object} p
  22 + * @param {PaywallRules} p.rules
  23 + * @param {number} p.episode_number 1-based 集号
  24 + * @returns {{ free:boolean, price_points:number, free_quota_left:number }}
  25 + */
  26 +export function evaluateEpisodeQuota({ rules, episode_number }) {
  27 + const free_episodes = numOr(rules.free_episodes, 0)
  28 + const price = priceFor(rules, 'episode')
  29 + const free = episode_number <= free_episodes
  30 + // 剩余免费集数:还能免费看到第几集(相对当前集),不为负。
  31 + const free_quota_left = Math.max(0, free_episodes - episode_number + 1)
  32 + return { free, price_points: free ? 0 : price, free_quota_left }
  33 +}
  34 +
  35 +/**
  36 + * 判定一次续写是否落在「每日 K 次免费」区间。
  37 + * @param {object} p
  38 + * @param {PaywallRules} p.rules
  39 + * @param {number} p.used_today 当日已用免费续写次数(窗口内)
  40 + * @returns {{ free:boolean, price_points:number, free_quota_left:number }}
  41 + */
  42 +export function evaluateContinueQuota({ rules, used_today }) {
  43 + const k = numOr(rules.free_continue_per_day, 0)
  44 + const price = priceFor(rules, 'continue')
  45 + const used = numOr(used_today, 0)
  46 + const free_quota_left = Math.max(0, k - used)
  47 + const free = free_quota_left > 0
  48 + return { free, price_points: free ? 0 : price, free_quota_left }
  49 +}
  50 +
  51 +/**
  52 + * 解锁价目(无免费额度概念的 branch / ending)。
  53 + * @param {object} p
  54 + * @param {PaywallRules} p.rules
  55 + * @param {'branch'|'ending'} p.kind
  56 + * @returns {{ price_points:number }}
  57 + */
  58 +export function priceForUnlock({ rules, kind }) {
  59 + return { price_points: priceFor(rules, kind) }
  60 +}
  61 +
  62 +/**
  63 + * 计算「日窗口」起点(UTC 当日 0 点),供 repo 按窗口聚合用量(DATETIME(3) 列,Date 出入)。
  64 + * @param {Date} [now]
  65 + * @returns {Date}
  66 + */
  67 +export function day_window_start(now = new Date()) {
  68 + const d = new Date(now)
  69 + d.setUTCHours(0, 0, 0, 0)
  70 + return d
  71 +}
  72 +
  73 +/**
  74 + * @param {PaywallRules} rules
  75 + * @param {string} kind
  76 + */
  77 +function priceFor(rules, kind) {
  78 + const prices = rules.prices || {}
  79 + return numOr(prices[kind], 0)
  80 +}
  81 +
  82 +/**
  83 + * @param {unknown} v
  84 + * @param {number} fallback
  85 + */
  86 +function numOr(v, fallback) {
  87 + return typeof v === 'number' && Number.isFinite(v) ? v : fallback
  88 +}
... ...
packages/billing/src/repo/action-policy-repo.js 0 → 100644
  1 +++ a/packages/billing/src/repo/action-policy-repo.js
  1 +import { billingActionPolicyTable } from '../db/index.js'
  2 +
  3 +const POLICY = billingActionPolicyTable.tableName
  4 +
  5 +export function createActionPolicyRepo(db, runner = db) {
  6 + async function findActionPolicy(action) {
  7 + const row = await runner(POLICY).where({ action }).first()
  8 + if (row === undefined) return null
  9 + return actionPolicyFromRow(row)
  10 + }
  11 +
  12 + async function listActionPolicies() {
  13 + const rows = await runner(POLICY).orderBy('action')
  14 + return rows.map(actionPolicyFromRow)
  15 + }
  16 +
  17 + async function updateActionPolicy(policy) {
  18 + await runner(POLICY).where({ action: policy.action }).update({
  19 + points: policy.points,
  20 + period_kind: policy.period_kind,
  21 + timezone: policy.timezone,
  22 + enabled: policy.enabled,
  23 + updated_by: policy.updated_by,
  24 + updated_at: new Date(),
  25 + })
  26 + return findActionPolicy(policy.action)
  27 + }
  28 +
  29 + return { findActionPolicy, listActionPolicies, updateActionPolicy }
  30 +}
  31 +
  32 +function actionPolicyFromRow(row) {
  33 + const action = requiredString(row.action, 'billing_action_policy.action')
  34 + const policy = {
  35 + action,
  36 + points: requiredInteger(row.points, 'billing_action_policy.points', action),
  37 + period_kind: requiredString(row.period_kind, 'billing_action_policy.period_kind', action),
  38 + timezone: nullableString(row.timezone, 'billing_action_policy.timezone', action),
  39 + enabled: Boolean(row.enabled),
  40 + updated_by: nullableString(row.updated_by, 'billing_action_policy.updated_by', action),
  41 + }
  42 + assertActionPolicy(policy)
  43 + return policy
  44 +}
  45 +
  46 +function assertActionPolicy(policy) {
  47 + switch (policy.period_kind) {
  48 + case 'daily':
  49 + if (policy.timezone === null) throw new Error(`Daily billing action requires timezone, id=${policy.action}`)
  50 + return
  51 + case 'none':
  52 + if (policy.timezone !== null) throw new Error(`Non-periodic billing action cannot have timezone, id=${policy.action}`)
  53 + return
  54 + default:
  55 + throw new Error(`Unhandled billing action period: ${policy.period_kind}, id: ${policy.action}`)
  56 + }
  57 +}
  58 +
  59 +function requiredString(value, field, id = 'unknown') {
  60 + if (typeof value !== 'string' || value === '') throw new Error(`Invalid ${field}, id=${id}`)
  61 + return value
  62 +}
  63 +
  64 +function nullableString(value, field, id) {
  65 + if (value === null) return null
  66 + return requiredString(value, field, id)
  67 +}
  68 +
  69 +function requiredInteger(value, field, id) {
  70 + const number = Number(value)
  71 + if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`Invalid ${field}, id=${id}`)
  72 + return number
  73 +}
... ...
packages/billing/src/repo/index.js 0 → 100644
  1 +++ a/packages/billing/src/repo/index.js
  1 +import { createActionPolicyRepo } from './action-policy-repo.js'
  2 +import { createLedgerRepo } from './ledger-repo.js'
  3 +import { createPaywallRepo } from './paywall-repo.js'
  4 +import { createProductRepo } from './product-repo.js'
  5 +
  6 +export function createBillingRepo(db, runner = db) {
  7 + if (typeof db.transaction !== 'function') throw new Error('[billing] createBillingRepo requires a Knex db')
  8 + return {
  9 + ...createLedgerRepo(db, runner),
  10 + ...createProductRepo(db, runner),
  11 + ...createActionPolicyRepo(db, runner),
  12 + ...createPaywallRepo(db, runner),
  13 + afterCommit(work) {
  14 + if (runner === db) {
  15 + globalThis.queueMicrotask(work)
  16 + return
  17 + }
  18 + runner.executionPromise.then(work, () => undefined)
  19 + },
  20 + forTransaction(trx) {
  21 + return createBillingRepo(db, trx)
  22 + },
  23 + }
  24 +}
... ...
packages/billing/src/repo/ledger-repo.js 0 → 100644
  1 +++ a/packages/billing/src/repo/ledger-repo.js
  1 +import { randomUUID } from 'node:crypto'
  2 +const LEDGER = 'billing_ledger'
  3 +
  4 +export function createLedgerRepo(db, runner = db) {
  5 + const query = runner
  6 + const transactionBound = query !== db
  7 +
  8 + async function inTransaction(work) {
  9 + if (transactionBound) return work(query)
  10 + return db.transaction(work)
  11 + }
  12 +
  13 + async function getBalance(user_id, trx = query) {
  14 + const row = await trx(LEDGER).where({ user_id }).sum({ balance: 'amount' }).first()
  15 + return Number(row.balance)
  16 + }
  17 +
  18 + async function applyDelta({ user_id, delta, require_sufficient, ledger_row }) {
  19 + assertIdempotencyKey(ledger_row.idempotency_key)
  20 + return inTransaction(async trx => {
  21 + const user = await trx('users').where({ id: user_id }).select('id').forUpdate().first()
  22 + if (user === undefined) throw new Error(`Cannot write billing_ledger: user_id=${user_id} does not exist`)
  23 + const balance_before = await getBalance(user_id, trx)
  24 + const balance_after = balance_before + delta
  25 + if (require_sufficient && balance_after < 0) {
  26 + return { kind: 'insufficient_balance', balance_before }
  27 + }
  28 + const id = randomUUID()
  29 + const row = { id, user_id, amount: delta, balance_after, created_at: new Date(), ...ledger_row }
  30 + try {
  31 + await trx(LEDGER).insert(row)
  32 + return { kind: 'applied', ledger_id: id, balance_after }
  33 + } catch (error) {
  34 + if (error.code !== 'ER_DUP_ENTRY') throw error
  35 + const existing = await trx(LEDGER).where({ idempotency_key: ledger_row.idempotency_key }).first()
  36 + assertSameLedger(existing, row, ledger_row.idempotency_key)
  37 + return { kind: 'replayed', ledger_id: existing.id, balance_after: Number(existing.balance_after) }
  38 + }
  39 + })
  40 + }
  41 +
  42 + async function reverseDelta({ user_id, total_points, ledger_row }) {
  43 + assertIdempotencyKey(ledger_row.idempotency_key)
  44 + return inTransaction(async trx => {
  45 + const user = await trx('users').where({ id: user_id }).select('id').forUpdate().first()
  46 + if (user === undefined) throw new Error(`Cannot write billing_ledger: user_id=${user_id} does not exist`)
  47 + const balance_before = await getBalance(user_id, trx)
  48 + const points_reversed = Math.min(balance_before, total_points)
  49 + const reversal_shortfall_points = total_points - points_reversed
  50 + if (points_reversed === 0) {
  51 + return {
  52 + kind: 'not_applied',
  53 + ledger_id: null,
  54 + balance_after: balance_before,
  55 + points_reversed,
  56 + reversal_shortfall_points,
  57 + }
  58 + }
  59 + const id = randomUUID()
  60 + const row = {
  61 + id,
  62 + user_id,
  63 + amount: -points_reversed,
  64 + balance_after: balance_before - points_reversed,
  65 + created_at: new Date(),
  66 + ...ledger_row,
  67 + }
  68 + try {
  69 + await trx(LEDGER).insert(row)
  70 + return { kind: 'applied', ledger_id: id, balance_after: row.balance_after, points_reversed, reversal_shortfall_points }
  71 + } catch (error) {
  72 + if (error.code !== 'ER_DUP_ENTRY') throw error
  73 + const existing = await trx(LEDGER).where({ idempotency_key: ledger_row.idempotency_key }).first()
  74 + assertSameLedger(existing, row, ledger_row.idempotency_key)
  75 + return {
  76 + kind: 'replayed',
  77 + ledger_id: existing.id,
  78 + balance_after: Number(existing.balance_after),
  79 + points_reversed: -Number(existing.amount),
  80 + reversal_shortfall_points: total_points + Number(existing.amount),
  81 + }
  82 + }
  83 + })
  84 + }
  85 +
  86 + async function findLedgerByIdempotencyKey(idempotency_key) {
  87 + if (idempotency_key === null) return null
  88 + const row = await query(LEDGER).where({ idempotency_key }).first()
  89 + if (row === undefined) return null
  90 + return ledgerFromRow(row)
  91 + }
  92 +
  93 + async function listLedger(user_id, filter) {
  94 + let rows = query(LEDGER).where({ user_id })
  95 + if (filter.type !== undefined) rows = rows.where({ type: filter.type })
  96 + if (filter.since !== undefined) rows = rows.where('created_at', '>=', filter.since)
  97 + if (filter.until !== undefined) rows = rows.where('created_at', '<=', filter.until)
  98 + const limit = filter.limit === undefined ? 20 : filter.limit
  99 + const result = await rows.orderBy('created_at', 'desc').orderBy('id', 'desc').limit(limit + 1)
  100 + const items = result.slice(0, limit).map(ledgerFromRow)
  101 + const last = items.at(-1)
  102 + return {
  103 + items,
  104 + next_cursor: result.length > limit ? `${last.created_at.toISOString()}|${last.id}` : null,
  105 + }
  106 + }
  107 +
  108 + return { getBalance, applyDelta, reverseDelta, findLedgerByIdempotencyKey, listLedger }
  109 +}
  110 +
  111 +function assertSameLedger(existing, row, idempotency_key) {
  112 + if (existing === undefined) {
  113 + throw new Error(`billing_ledger idempotency conflict: key=${idempotency_key}, field=existing_row`)
  114 + }
  115 + const existingLedger = ledgerFromRow(existing)
  116 + for (const field of ['user_id', 'amount', 'type', 'source', 'reason', 'ref_type', 'ref_id', 'credit_kind', 'note', 'failure_code']) {
  117 + if (existingLedger[field] !== row[field]) {
  118 + throw new Error(`billing_ledger idempotency conflict: key=${idempotency_key}, field=${field}`)
  119 + }
  120 + }
  121 +}
  122 +
  123 +function assertIdempotencyKey(idempotency_key) {
  124 + if (typeof idempotency_key !== 'string' || idempotency_key === '') {
  125 + throw new Error('billing_ledger requires a stable idempotency_key')
  126 + }
  127 +}
  128 +
  129 +function ledgerFromRow(row) {
  130 + const id = requiredString(row.id, 'billing_ledger.id')
  131 + const type = requiredString(row.type, 'billing_ledger.type', id)
  132 + const amount = requiredInteger(row.amount, 'billing_ledger.amount', id)
  133 + const credit_kind = nullableString(row.credit_kind, 'billing_ledger.credit_kind', id)
  134 + switch (type) {
  135 + case 'grant':
  136 + if (amount <= 0 || credit_kind === null) throw new Error(`Invalid billing_ledger grant, id=${id}`)
  137 + break
  138 + case 'deduct':
  139 + if (amount >= 0 || credit_kind !== null) throw new Error(`Invalid billing_ledger deduct, id=${id}`)
  140 + break
  141 + default:
  142 + throw new Error(`Unhandled billing_ledger.type: ${type}, id: ${id}`)
  143 + }
  144 + return {
  145 + id,
  146 + user_id: requiredString(row.user_id, 'billing_ledger.user_id', id),
  147 + type,
  148 + amount,
  149 + source: requiredString(row.source, 'billing_ledger.source', id),
  150 + credit_kind,
  151 + reason: requiredString(row.reason, 'billing_ledger.reason', id),
  152 + ref_type: nullableString(row.ref_type, 'billing_ledger.ref_type', id),
  153 + ref_id: nullableString(row.ref_id, 'billing_ledger.ref_id', id),
  154 + note: nullableString(row.note, 'billing_ledger.note', id),
  155 + failure_code: nullableString(row.failure_code, 'billing_ledger.failure_code', id),
  156 + balance_after: requiredInteger(row.balance_after, 'billing_ledger.balance_after', id),
  157 + idempotency_key: nullableString(row.idempotency_key, 'billing_ledger.idempotency_key', id),
  158 + created_at: requiredDate(row.created_at, 'billing_ledger.created_at', id),
  159 + }
  160 +}
  161 +
  162 +function requiredString(value, field, id = 'unknown') {
  163 + if (typeof value !== 'string' || value === '') throw new Error(`Invalid ${field}, id=${id}`)
  164 + return value
  165 +}
  166 +
  167 +function nullableString(value, field, id) {
  168 + if (value === null) return null
  169 + return requiredString(value, field, id)
  170 +}
  171 +
  172 +function requiredInteger(value, field, id) {
  173 + const number = Number(value)
  174 + if (!Number.isSafeInteger(number)) throw new Error(`Invalid ${field}, id=${id}`)
  175 + return number
  176 +}
  177 +
  178 +function requiredDate(value, field, id) {
  179 + const date = new Date(value)
  180 + if (Number.isNaN(date.getTime())) throw new Error(`Invalid ${field}, id=${id}`)
  181 + return date
  182 +}
... ...
packages/billing/src/repo/paywall-repo.js 0 → 100644
  1 +++ a/packages/billing/src/repo/paywall-repo.js
  1 +import { randomUUID } from 'node:crypto'
  2 +import {
  3 + billingFreeQuotaTable,
  4 + billingPaywallRuleTable,
  5 + billingUnlockTable,
  6 +} from '../db/index.js'
  7 +
  8 +const QUOTA = billingFreeQuotaTable.tableName
  9 +const RULE = billingPaywallRuleTable.tableName
  10 +const UNLOCK = billingUnlockTable.tableName
  11 +
  12 +export function createPaywallRepo(db, runner = db) {
  13 + const transactionBound = runner !== db
  14 +
  15 + async function inTransaction(work) {
  16 + if (transactionBound) return work(runner)
  17 + return db.transaction(work)
  18 + }
  19 +
  20 + async function findUnlock({ user_id, content_id, target_key }) {
  21 + const row = await runner(UNLOCK).where({ user_id, content_id, target_key }).first()
  22 + if (row === undefined) return null
  23 + return { ledger_id: row.ledger_id }
  24 + }
  25 +
  26 + async function insertUnlock({ user_id, content_id, target_key, ledger_id }) {
  27 + await runner(UNLOCK).insert({ id: randomUUID(), user_id, content_id, target_key, ledger_id, created_at: new Date() })
  28 + }
  29 +
  30 + async function listUnlocks(user_id, content_id) {
  31 + const rows = await runner(UNLOCK).where({ user_id, content_id })
  32 + return rows.map(row => ({ target_key: row.target_key, ledger_id: row.ledger_id }))
  33 + }
  34 +
  35 + async function getFreeQuotaUsage({ user_id, content_id, kind, window }) {
  36 + const row = await runner(QUOTA).where({ user_id, content_id, kind, window_start: window }).first()
  37 + if (row === undefined) return 0
  38 + return Number(row.used)
  39 + }
  40 +
  41 + async function bumpFreeQuota({ user_id, content_id, kind, window }) {
  42 + return inTransaction(async trx => {
  43 + const existing = await trx(QUOTA).where({ user_id, content_id, kind, window_start: window }).first().forUpdate()
  44 + if (existing === undefined) {
  45 + await trx(QUOTA).insert({ user_id, content_id, kind, window_start: window, used: 1 })
  46 + return
  47 + }
  48 + await trx(QUOTA).where({ user_id, content_id, kind, window_start: window }).update({ used: Number(existing.used) + 1 })
  49 + })
  50 + }
  51 +
  52 + async function upsertPaywallRule(rule) {
  53 + await runner(RULE).insert({
  54 + content_id: rule.content_id,
  55 + free_episodes: rule.free_episodes,
  56 + free_continue_per_day: rule.free_continue_per_day,
  57 + prices_json: JSON.stringify(rule.prices),
  58 + active: rule.active,
  59 + updated_at: new Date(),
  60 + }).onConflict('content_id').merge()
  61 + return listPaywallRules(rule.content_id)
  62 + }
  63 +
  64 + async function listPaywallRules(content_id) {
  65 + const row = await runner(RULE).where({ content_id }).first()
  66 + if (row === undefined) return null
  67 + return {
  68 + free_episodes: Number(row.free_episodes),
  69 + free_continue_per_day: Number(row.free_continue_per_day),
  70 + prices: JSON.parse(row.prices_json),
  71 + }
  72 + }
  73 +
  74 + return { findUnlock, insertUnlock, listUnlocks, getFreeQuotaUsage, bumpFreeQuota, upsertPaywallRule, listPaywallRules }
  75 +}
... ...
packages/billing/src/repo/product-repo.js 0 → 100644
  1 +++ a/packages/billing/src/repo/product-repo.js
  1 +import { billingProductTable } from '../db/index.js'
  2 +
  3 +const PRODUCT = billingProductTable.tableName
  4 +
  5 +export function createProductRepo(db, runner = db) {
  6 + async function listProducts() {
  7 + const rows = await runner(PRODUCT).where({ active: true }).orderBy('price_cents')
  8 + return rows.map(productFromRow)
  9 + }
  10 +
  11 + async function listAllProducts() {
  12 + const rows = await runner(PRODUCT).orderBy('price_cents')
  13 + return rows.map(productFromRow)
  14 + }
  15 +
  16 + async function findProduct(id) {
  17 + const row = await runner(PRODUCT).where({ id }).first()
  18 + if (row === undefined) return null
  19 + return productFromRow(row)
  20 + }
  21 +
  22 + async function saveProduct(product) {
  23 + await runner(PRODUCT).insert(productToRow(product)).onConflict('id').merge()
  24 + return findProduct(product.id)
  25 + }
  26 +
  27 + return { listProducts, listAllProducts, findProduct, saveProduct }
  28 +}
  29 +
  30 +function productFromRow(row) {
  31 + const id = requiredString(row.id, 'billing_product.id')
  32 + const kind = requiredString(row.kind, 'billing_product.kind', id)
  33 + const product = {
  34 + id,
  35 + kind,
  36 + title: requiredString(row.title, 'billing_product.title', id),
  37 + points: requiredInteger(row.points, 'billing_product.points', id),
  38 + paid_points: requiredInteger(row.paid_points, 'billing_product.paid_points', id),
  39 + bonus_points: requiredInteger(row.bonus_points, 'billing_product.bonus_points', id),
  40 + price_cents: requiredInteger(row.price_cents, 'billing_product.price_cents', id),
  41 + price_usd: Number(row.price_usd),
  42 + currency: requiredString(row.currency, 'billing_product.currency', id),
  43 + stripe_price_id: nullableString(row.stripe_price_id, 'billing_product.stripe_price_id', id),
  44 + active: Boolean(row.active),
  45 + }
  46 + if (kind !== 'points_pack' || product.points !== product.paid_points + product.bonus_points) {
  47 + throw new Error(`Invalid billing_product points_pack, id=${id}`)
  48 + }
  49 + return product
  50 +}
  51 +
  52 +function productToRow(product) {
  53 + if (product.kind !== 'points_pack') throw new Error(`Unsupported billing_product.kind: ${product.kind}, id: ${product.id}`)
  54 + if (product.points !== product.paid_points + product.bonus_points) throw new Error(`Invalid billing_product points total, id=${product.id}`)
  55 + return { ...product, updated_at: new Date() }
  56 +}
  57 +
  58 +function requiredString(value, field, id = 'unknown') {
  59 + if (typeof value !== 'string' || value === '') throw new Error(`Invalid ${field}, id=${id}`)
  60 + return value
  61 +}
  62 +
  63 +function nullableString(value, field, id) {
  64 + if (value === null) return null
  65 + return requiredString(value, field, id)
  66 +}
  67 +
  68 +function requiredInteger(value, field, id) {
  69 + const number = Number(value)
  70 + if (!Number.isSafeInteger(number)) throw new Error(`Invalid ${field}, id=${id}`)
  71 + return number
  72 +}
... ...
packages/billing/src/signup-reward.js 0 → 100644
  1 +++ a/packages/billing/src/signup-reward.js
  1 +import { BillingAction } from './action-policies.js'
  2 +
  3 +/** @param {{ actionPolicies:{ find:Function }, grant:Function }} billing */
  4 +export function createSignupReward(billing) {
  5 + async function apply({ user_id }) {
  6 + const policy = await billing.actionPolicies.find(BillingAction.SIGNUP_REWARD)
  7 + if (policy === null || !policy.enabled) return { applied: false }
  8 +
  9 + const result = await billing.grant({
  10 + user_id,
  11 + amount: policy.points,
  12 + source: 'visual_game_signup',
  13 + credit_kind: 'admin',
  14 + reason: 'signup_gift',
  15 + idempotency_key: `signup_reward:${user_id}`,
  16 + })
  17 + return {
  18 + applied: true,
  19 + replayed: result.replayed,
  20 + points: policy.points,
  21 + ledger_id: result.ledger_id,
  22 + }
  23 + }
  24 +
  25 + return { apply }
  26 +}
... ...
packages/billing/src/views.js 0 → 100644
  1 +++ a/packages/billing/src/views.js
  1 +import pointsBalanceView from './db/tables/points_balance_view.cjs'
  2 +
  3 +/**
  4 + * Recreate billing-owned derived views.
  5 + *
  6 + * This is explicit schema maintenance for dev/test sync scripts, not runtime app startup.
  7 + * @param {import('knex').Knex} db
  8 + */
  9 +export async function recreateBillingViews(db) {
  10 + await recreateView(db, pointsBalanceView)
  11 +}
  12 +
  13 +/**
  14 + * @param {import('knex').Knex} db
  15 + * @param {{ viewName: string, createViewSql: () => string }} view
  16 + */
  17 +async function recreateView(db, view) {
  18 + await db.raw(`DROP VIEW IF EXISTS ${quoteIdent(view.viewName)}`)
  19 + await db.raw(view.createViewSql())
  20 +}
  21 +
  22 +/**
  23 + * @param {string} name
  24 + */
  25 +function quoteIdent(name) {
  26 + if (!/^[A-Za-z0-9_]+$/.test(name)) throw new Error(`invalid SQL identifier: ${name}`)
  27 + return `\`${name}\``
  28 +}
... ...
packages/billing/test/quota.test.js 0 → 100644
  1 +++ a/packages/billing/test/quota.test.js
  1 +import { describe, it, expect } from 'vitest'
  2 +import {
  3 + evaluateEpisodeQuota,
  4 + evaluateContinueQuota,
  5 + priceForUnlock,
  6 + day_window_start,
  7 +} from '../src/quota.js'
  8 +
  9 +const rules = {
  10 + free_episodes: 3,
  11 + free_continue_per_day: 2,
  12 + prices: { episode: 30, branch: 50, ending: 80, continue: 5 },
  13 +}
  14 +
  15 +describe('quota: 前 N 集免费分界', () => {
  16 + it('第 1..N 集免费,价 0', () => {
  17 + for (let ep = 1; ep <= 3; ep++) {
  18 + const r = evaluateEpisodeQuota({ rules, episode_number: ep })
  19 + expect(r.free).toBe(true)
  20 + expect(r.price_points).toBe(0)
  21 + }
  22 + })
  23 + it('第 N+1 集起收费,价为 episode 价目', () => {
  24 + const r = evaluateEpisodeQuota({ rules, episode_number: 4 })
  25 + expect(r.free).toBe(false)
  26 + expect(r.price_points).toBe(30)
  27 + expect(r.free_quota_left).toBe(0)
  28 + })
  29 + it('free_episodes 缺省=0 时第 1 集即收费', () => {
  30 + const r = evaluateEpisodeQuota({ rules: { prices: { episode: 9 } }, episode_number: 1 })
  31 + expect(r.free).toBe(false)
  32 + expect(r.price_points).toBe(9)
  33 + })
  34 +})
  35 +
  36 +describe('quota: 每日 K 次免费续写耗尽后转收费 + 窗口重置', () => {
  37 + it('未用满 K 次免费', () => {
  38 + const r = evaluateContinueQuota({ rules, used_today: 0 })
  39 + expect(r.free).toBe(true)
  40 + expect(r.free_quota_left).toBe(2)
  41 + })
  42 + it('用到第 K 次仍剩 1 次时免费', () => {
  43 + expect(evaluateContinueQuota({ rules, used_today: 1 }).free).toBe(true)
  44 + })
  45 + it('用满 K 次后转收费', () => {
  46 + const r = evaluateContinueQuota({ rules, used_today: 2 })
  47 + expect(r.free).toBe(false)
  48 + expect(r.price_points).toBe(5)
  49 + expect(r.free_quota_left).toBe(0)
  50 + })
  51 + it('窗口起点是 UTC 当日 0 点(窗口重置依据)', () => {
  52 + const w = day_window_start(new Date('2026-06-15T13:22:00Z'))
  53 + expect(w).toEqual(new Date('2026-06-15T00:00:00.000Z'))
  54 + })
  55 +})
  56 +
  57 +describe('quota: branch/ending 解锁价目', () => {
  58 + it('branch/ending 取对应价目', () => {
  59 + expect(priceForUnlock({ rules, kind: 'branch' }).price_points).toBe(50)
  60 + expect(priceForUnlock({ rules, kind: 'ending' }).price_points).toBe(80)
  61 + })
  62 +})
... ...
packages/config-source/package.json 0 → 100644
  1 +++ a/packages/config-source/package.json
  1 +{
  2 + "name": "@drama/config-source",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "exports": {
  7 + ".": "./src/index.js"
  8 + }
  9 +}
... ...
packages/config-source/src/index.js 0 → 100644
  1 +++ a/packages/config-source/src/index.js
  1 +/**
  2 + * `@drama/config-source` 公共面(唯一对外入口)。
  3 + *
  4 + * 版本化配置缓存:`load` / `version` / `now` 全注入,包内不 import `@drama/db`、`ioredis`、
  5 + * 不读 `process.env`、不建连接、不用 `Date.now`。`import '@drama/config-source'` 零 IO。
  6 + *
  7 + * 导出:
  8 + * createVersionedSource({ load, version, ttlMs, now }) => { get, invalidate }
  9 + * createTableVersion({ db, table }) => version() 配置表版本令牌(按 id 排序后的完整行内容)
  10 + *
  11 + * 消费方各自提供表映射的 `load`,本包只提供「新鲜窗口 + 版本比对 + 并发去重」机制与配置表的
  12 + * 通用版本令牌,不认识 redis / vendor / 行结构;db 由消费方注入,包内不建连接。
  13 + *
  14 + * @typedef {import('./versioned-source.js').VersionedSource<any>} VersionedSource
  15 + * @typedef {import('./versioned-source.js').VersionedSourceDeps<any>} VersionedSourceDeps
  16 + */
  17 +
  18 +export { createVersionedSource } from './versioned-source.js'
  19 +export { createTableVersion } from './table-version.js'
... ...
packages/config-source/src/table-version.js 0 → 100644
  1 +++ a/packages/config-source/src/table-version.js
  1 +/**
  2 + * table-version.js —— `createTableVersion({ db, table })`:配置表的版本令牌(单一来源)。
  3 + *
  4 + * 版本令牌是 `createVersionedSource` 的失效信号:令牌变 → 重新 load。令牌直接来自按主键排序的
  5 + * 完整配置行,不依赖 `updated_at` 精度:同一毫秒连续 UPDATE、删除任意行、原值回写都按数据库
  6 + * 当前事实计算。配置表是小表,版本探测受外层 TTL 节流。
  7 + *
  8 + * 消费方仍自带 `load`(本包不认识行结构);本函数只产出令牌,db 注入,包内不建连接。
  9 + */
  10 +
  11 +/**
  12 + * @param {object} deps
  13 + * @param {import('knex').Knex} deps.db 注入的 knex(composition root 建连)。
  14 + * @param {string} deps.table 配置表名(须有稳定的 `id` 主键)。
  15 + * @returns {() => Promise<string>} `createVersionedSource` 的 `version` 实参。
  16 + */
  17 +export function createTableVersion({ db, table }) {
  18 + return async () => {
  19 + const rows = await db(table).select('*').orderBy('id', 'asc')
  20 + return JSON.stringify(rows)
  21 + }
  22 +}
... ...
packages/config-source/src/versioned-source.js 0 → 100644
  1 +++ a/packages/config-source/src/versioned-source.js
  1 +/**
  2 + * versioned-source.js —— 「DB 表 + 版本号 + TTL 新鲜窗口」的通用缓存机制。
  3 + *
  4 + * 不认识 DB、redis、行结构:`load` 每次返回归一后的单一形态 `T`,`version` 返回版本令牌,
  5 + * `now` 为注入时钟。三者是本模块与外界的全部接触面;构造期不调用它们(惰性),首个 `get()`
  6 + * 才触发,故 `import '@drama/config-source'` 零 IO。
  7 + *
  8 + * 失效沿单一路径逐级下探:新鲜窗口内直接命中 → 窗口外比对版本令牌 → 令牌变化才 `load`。
  9 + *
  10 + * @template T
  11 + * @typedef {Object} VersionedSourceDeps
  12 + * @property {() => Promise<T>} load 从真源全量拉取并归一为单一形态 T
  13 + * @property {() => Promise<string>} version 版本令牌;返回值变化即视为配置失效
  14 + * @property {number} ttlMs 新鲜窗口毫秒数;窗口内不探测 version、不 load
  15 + * @property {() => number} now 注入时钟
  16 + *
  17 + * @template T
  18 + * @typedef {Object} VersionedSource
  19 + * @property {() => Promise<T>} get 返回当前有效配置(必要时按版本失效后重载)
  20 + * @property {() => void} invalidate 清空缓存,强制下次 get 走 load
  21 + */
  22 +
  23 +/**
  24 + * @template T
  25 + * @param {VersionedSourceDeps<T>} deps
  26 + * @returns {VersionedSource<T>}
  27 + */
  28 +export function createVersionedSource({ load, version, ttlMs, now }) {
  29 + /** @type {T | null} */
  30 + let cached = null
  31 + /** @type {string | null} */
  32 + let cachedVersion = null
  33 + let checkedAt = 0
  34 + /** @type {Promise<T> | null} */
  35 + let inflight = null
  36 +
  37 + /** @returns {Promise<T>} */
  38 + async function get() {
  39 + if (cached !== null && now() - checkedAt < ttlMs) return cached
  40 + const v = await version()
  41 + if (cached !== null && v === cachedVersion) {
  42 + checkedAt = now()
  43 + return cached
  44 + }
  45 + if (inflight) return inflight
  46 + inflight = (async () => {
  47 + try {
  48 + const fresh = await load()
  49 + cached = fresh
  50 + cachedVersion = v
  51 + checkedAt = now()
  52 + return fresh
  53 + } finally {
  54 + inflight = null
  55 + }
  56 + })()
  57 + return inflight
  58 + }
  59 +
  60 + function invalidate() {
  61 + cached = null
  62 + cachedVersion = null
  63 + }
  64 +
  65 + return { get, invalidate }
  66 +}
... ...
packages/config/eslint/index.js 0 → 100644
  1 +++ a/packages/config/eslint/index.js
  1 +/**
  2 + * `@drama/config/eslint` 公共面 —— 导出 `presetNode` / `presetVue` 两套 flat config 预设。
  3 + *
  4 + * - `presetNode`:纯逻辑 / 后端包用。通用 JS 规则 + scaffold 四条架构红线规则(深导入 /
  5 + * 子域深导入 / 连接只在 composition root / 禁 app 互调)。
  6 + * - `presetVue`:Vue 包用。在 `presetNode` 基础上拼接 `eslint-plugin-vue` 与浏览器全局。
  7 + *
  8 + * 两者都是**数据**(flat config 数组),消费方 `export default presetNode`(或 `presetVue`)
  9 + * 即获得与全仓一致的 lint 行为,再按需补本包特例。
  10 + *
  11 + * 消费示例:
  12 + * // packages/foo/eslint.config.js
  13 + * import { presetNode } from '@drama/config/eslint'
  14 + * export default presetNode
  15 + *
  16 + * // apps/web/eslint.config.js
  17 + * import { presetVue } from '@drama/config/eslint'
  18 + * export default presetVue
  19 + */
  20 +
  21 +import {
  22 + JS_RECOMMENDED,
  23 + COMMON_IGNORES_BLOCK,
  24 + buildArchRulesBlock,
  25 + buildCommonjsArchRulesBlock,
  26 + ENFORCED_RULE_NAMES,
  27 +} from './rules.js'
  28 +import { buildVueBlocks } from './vue.js'
  29 +
  30 +export { ENFORCED_RULE_NAMES }
  31 +
  32 +/**
  33 + * 纯逻辑 / 后端包 flat config 预设。
  34 + * @type {import('eslint').Linter.Config[]}
  35 + */
  36 +export const presetNode = [
  37 + COMMON_IGNORES_BLOCK,
  38 + JS_RECOMMENDED,
  39 + buildArchRulesBlock(),
  40 + buildCommonjsArchRulesBlock(),
  41 +]
  42 +
  43 +/**
  44 + * Vue 包 flat config 预设:node 预设之上拼接 Vue 片段。
  45 + * @type {import('eslint').Linter.Config[]}
  46 + */
  47 +export const presetVue = [...presetNode, ...buildVueBlocks()]
... ...
packages/config/eslint/rules.js 0 → 100644
  1 +++ a/packages/config/eslint/rules.js
  1 +/**
  2 + * `@drama/config` ESLint 预设 —— 通用 JS 规则集合 + 装配架构红线规则的 flat config 片段。
  3 + *
  4 + * 红线装配(白名单 / 规则集 / globals)单源于仓库根 `tools/eslint-rules/config.js`,本文件只把
  5 + * 那份单源包装成 flat config 片段(`presetNode` / `presetVue` 消费)。规则集与白名单一旦在单源
  6 + * 变更,这里自动跟随——历史上根配置与本预设漂移过,现已收敛到单源。
  7 + */
  8 +
  9 +import js from '@eslint/js'
  10 +import {
  11 + CONN_WHITELIST,
  12 + buildArchRules,
  13 + ENFORCED_RULE_NAMES,
  14 + BASE_GLOBALS,
  15 + COMMONJS_GLOBALS,
  16 +} from '../../../tools/eslint-rules/config.js'
  17 +import { dramaPlugin } from '../../../tools/eslint-rules/index.js'
  18 +
  19 +/** 规则 C 文件白名单默认值(composition root + vite 配置),与根配置同源。 */
  20 +export const DEFAULT_COMPOSITION_ROOTS = CONN_WHITELIST
  21 +
  22 +/** 架构红线规则名(供测试与下游静态追溯「红线确实进了预设」)。 */
  23 +export { ENFORCED_RULE_NAMES }
  24 +
  25 +/** 全仓通用 JS 语言层默认(ESM、Node 全局)。 */
  26 +export const NODE_LANGUAGE_OPTIONS = {
  27 + ecmaVersion: 2023,
  28 + sourceType: 'module',
  29 + globals: { ...BASE_GLOBALS },
  30 +}
  31 +
  32 +/** CommonJS 脚本 / table definition 语言层默认,保证架构红线覆盖 `.cjs`。 */
  33 +export const COMMONJS_LANGUAGE_OPTIONS = {
  34 + ...NODE_LANGUAGE_OPTIONS,
  35 + sourceType: 'commonjs',
  36 + globals: { ...COMMONJS_GLOBALS },
  37 +}
  38 +
  39 +/**
  40 + * 装配架构红线 + 通用 JS 规则的 flat config 片段(ESM)。
  41 + * @param {{ compositionRoots?: string[] }} [opts]
  42 + * @returns {import('eslint').Linter.Config}
  43 + */
  44 +export function buildArchRulesBlock(opts = {}) {
  45 + return {
  46 + files: ['**/*.js'],
  47 + languageOptions: { ...NODE_LANGUAGE_OPTIONS },
  48 + plugins: { drama: dramaPlugin },
  49 + rules: buildArchRules(opts.compositionRoots || DEFAULT_COMPOSITION_ROOTS),
  50 + }
  51 +}
  52 +
  53 +/**
  54 + * 装配架构红线 + CommonJS 语言层的 flat config 片段。
  55 + * @param {{ compositionRoots?: string[] }} [opts]
  56 + * @returns {import('eslint').Linter.Config}
  57 + */
  58 +export function buildCommonjsArchRulesBlock(opts = {}) {
  59 + return {
  60 + files: ['**/*.cjs'],
  61 + languageOptions: { ...COMMONJS_LANGUAGE_OPTIONS },
  62 + plugins: { drama: dramaPlugin },
  63 + rules: buildArchRules(opts.compositionRoots || DEFAULT_COMPOSITION_ROOTS),
  64 + }
  65 +}
  66 +
  67 +/** 全仓通用 ignores(构建产物 / 依赖 / 覆盖率 / 临时目录)。 */
  68 +export const COMMON_IGNORES_BLOCK = {
  69 + ignores: ['**/node_modules/**', '**/dist/**', '**/coverage/**', 'tmp/**'],
  70 +}
  71 +
  72 +/** ESLint 官方推荐 JS 规则集(基础 JS 规则的默认来源)。 */
  73 +export const JS_RECOMMENDED = js.configs.recommended
... ...
packages/config/eslint/vue.js 0 → 100644
  1 +++ a/packages/config/eslint/vue.js
  1 +/**
  2 + * Vue 专属 flat config 片段 —— `eslint-plugin-vue` + 浏览器全局。
  3 + *
  4 + * 只持有「Vue 包相对 node 预设额外需要的」默认数据:plugin-vue 的 flat 推荐配置 +
  5 + * 浏览器运行时全局。与 node 预设是**数据拼接**关系(`presetVue = [...presetNode, ...vueBlocks]`),
  6 + * 无运行时逻辑耦合(§4 职责边界)。
  7 + */
  8 +
  9 +import pluginVue from 'eslint-plugin-vue'
  10 +import { BROWSER_GLOBALS } from '../../../tools/eslint-rules/config.js'
  11 +
  12 +export { BROWSER_GLOBALS }
  13 +
  14 +/**
  15 + * Vue flat config 片段数组(plugin-vue 的 flat 推荐 + 浏览器全局注入)。
  16 + * `eslint-plugin-vue` 的 `flat/recommended` 已是 flat config 数组,直接平铺。
  17 + * @returns {import('eslint').Linter.Config[]}
  18 + */
  19 +export function buildVueBlocks() {
  20 + const recommended = pluginVue.configs['flat/recommended'] || []
  21 + return [
  22 + ...recommended,
  23 + {
  24 + files: ['**/*.vue', '**/*.js'],
  25 + languageOptions: {
  26 + globals: { ...BROWSER_GLOBALS },
  27 + },
  28 + },
  29 + ]
  30 +}
  31 +
  32 +/** plugin-vue 是否真的进了预设(供测试静态断言)。 */
  33 +export const VUE_PLUGIN_NAME = 'eslint-plugin-vue'
... ...
packages/config/index.js 0 → 100644
  1 +++ a/packages/config/index.js
  1 +export { ENFORCED_RULE_NAMES, presetNode, presetVue } from './eslint/index.js'
  2 +export { defineWebPreset } from './vite/index.js'
  3 +export { COVERAGE_THRESHOLDS, defineNodePreset, defineVuePreset } from './vitest/index.js'
... ...
packages/config/package.json 0 → 100644
  1 +++ a/packages/config/package.json
  1 +{
  2 + "name": "@drama/config",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "exports": {
  7 + ".": "./index.js",
  8 + "./eslint": "./eslint/index.js",
  9 + "./vite": "./vite/index.js",
  10 + "./vitest": "./vitest/index.js"
  11 + },
  12 + "dependencies": {
  13 + "@eslint/js": "^9.13.0",
  14 + "@vitejs/plugin-vue": "^5.1.4",
  15 + "code-inspector-plugin": "^1.2.10",
  16 + "eslint-plugin-vue": "^9.30.0"
  17 + },
  18 + "peerDependencies": {
  19 + "eslint": "^9.13.0",
  20 + "vite": "^5.0.0 || ^6.0.0",
  21 + "vitest": "^2.0.0"
  22 + },
  23 + "peerDependenciesMeta": {
  24 + "eslint": { "optional": true },
  25 + "vite": { "optional": true },
  26 + "vitest": { "optional": true }
  27 + }
  28 +}
... ...
packages/config/vite/base.js 0 → 100644
  1 +++ a/packages/config/vite/base.js
  1 +/**
  2 + * Vite 预设的「默认值」与共享工具 —— base config + 深合并工具。
  3 + *
  4 + * `index.js` 持有工厂与合并逻辑,本文件持有默认数据(Vue 插件、workspace 解析、build 约定、
  5 + * SPA base)与无副作用的 `deepMerge`。
  6 + *
  7 + * 本文件读 `process.env`(构建期判 dev/prod、开关调试插件)——这是 Vite 配置的合理需求,不是散读
  8 + * env,故 `packages/config/vite/base.js` 已列入连接规则白名单(tools/eslint-rules/config.js)。
  9 + *
  10 + * visual-feedback 是开发期反馈组件,不再由本预设硬注入(否则 config 就得依赖它)。需要它的 app
  11 + * 在自己的 `vite.config.js` 里显式 `plugins.push(visualFeedbackPlugin())`,自选、自担依赖。
  12 + */
  13 +
  14 +import vue from '@vitejs/plugin-vue'
  15 +import { codeInspectorPlugin } from 'code-inspector-plugin'
  16 +
  17 +/**
  18 + * 是否为可深合并的纯对象(非数组、非 null、非类实例如 plugin)。
  19 + * @param {unknown} v
  20 + * @returns {boolean}
  21 + */
  22 +function isPlainObject(v) {
  23 + if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
  24 + const proto = Object.getPrototypeOf(v)
  25 + return proto === Object.prototype || proto === null
  26 +}
  27 +
  28 +/**
  29 + * 递归深合并:同名嵌套对象逐层合并(保留未被覆盖的默认子字段),数组与标量整体替换。
  30 + * 这是 §5 review 关注点 2「override 是深合并不是浅覆盖」的实现:app 传 `server.proxy`
  31 + * 只覆盖 `server.proxy`,不把整个 `server` 段(含默认 port 等)抹掉。
  32 + * @template T
  33 + * @param {T} base
  34 + * @param {Record<string, unknown>} override
  35 + * @returns {T}
  36 + */
  37 +export function deepMerge(base, override) {
  38 + if (!isPlainObject(override)) return /** @type {T} */ (override ?? base)
  39 + const out = isPlainObject(base) ? { ...base } : {}
  40 + for (const key of Object.keys(override)) {
  41 + const o = override[key]
  42 + const b = /** @type {Record<string, unknown>} */ (out)[key]
  43 + if (isPlainObject(b) && isPlainObject(o)) {
  44 + /** @type {Record<string, unknown>} */ (out)[key] = deepMerge(b, o)
  45 + } else {
  46 + /** @type {Record<string, unknown>} */ (out)[key] = o
  47 + }
  48 + }
  49 + return /** @type {T} */ (out)
  50 +}
  51 +
  52 +/** 标识值:base 默认携带的 Vue 插件实例(供测试断言「默认 config 含 Vue 插件」)。 */
  53 +export const VUE_PLUGIN_NAME = 'vite:vue'
  54 +
  55 +/**
  56 + * `@drama/*` / `@app/*` workspace 别名解析约定。
  57 + * monorepo 下包名经 workspace 解析即可,这里显式声明 `conditions` 让 Vite 优先走源码 `exports`,
  58 + * 保证 dev 直接跑源码、无编译步骤(架构基线 §3)。
  59 + * */
  60 +export const WORKSPACE_RESOLVE = {
  61 + // 让 @drama/* / @app/* 走包 exports 的 import 条件,命中各包 src/index.js。
  62 + conditions: ['import', 'module', 'browser', 'default'],
  63 + dedupe: ['vue'],
  64 +}
  65 +
  66 +/**
  67 + * 产出 base Vite config(不含 overrides)。每次调用返回新对象 + 新插件实例,避免跨 app 共享可变状态。
  68 + * @param {{ env?: Record<string, string | undefined>, command?: string }} [options]
  69 + * @returns {Record<string, unknown>}
  70 + */
  71 +export function buildBaseConfig(options = {}) {
  72 + const env = options.env ?? process.env
  73 + const command = options.command ?? commandFromProcessArgv(process.argv)
  74 + const isDev = command !== 'build' && env.VITEST !== 'true'
  75 + const enableInspector = isVisualFeedbackInspectorEnabled(env, command)
  76 + return {
  77 + plugins: [
  78 + vue(),
  79 + enableInspector && codeInspectorPlugin({
  80 + bundler: 'vite',
  81 + showSwitch: false,
  82 + hideConsole: true,
  83 + autoToggle: false,
  84 + behavior: {
  85 + locate: false,
  86 + copy: false,
  87 + target: '',
  88 + },
  89 + server: isDev ? 'open' : 'close',
  90 + dev: enableInspector,
  91 + skipSnippets: ['htmlScript', 'console'],
  92 + }),
  93 + ].filter(Boolean),
  94 + resolve: { ...WORKSPACE_RESOLVE },
  95 + base: '/',
  96 + build: {
  97 + outDir: 'dist',
  98 + emptyOutDir: true,
  99 + sourcemap: true,
  100 + target: 'es2022',
  101 + },
  102 + server: {
  103 + port: 5173,
  104 + strictPort: false,
  105 + allowedHosts: allowedHostsFromEnv(env),
  106 + },
  107 + }
  108 +}
  109 +
  110 +/**
  111 + * 反代进来的 Host 白名单。
  112 + *
  113 + * 部署成 `frontend_mode: proxy` 的环境里,nginx 用 `proxy_set_header Host $host` 把公网域名
  114 + * 原样透传给 dev server,而 Vite 只放行 localhost 和 IP,别的域名一律 403——页面打不开,
  115 + * 日志里也只有一行「This host is not allowed」。
  116 + *
  117 + * 域名是环境差异,所以只从各环境自己的 .env 读(`VITE_ALLOWED_HOSTS=a.example,b.example`),
  118 + * 仓库里不硬编码任何域名。不配就是空数组,行为和以前完全一样。
  119 + */
  120 +function allowedHostsFromEnv(env) {
  121 + const raw = env.VITE_ALLOWED_HOSTS
  122 + if (typeof raw !== 'string') return []
  123 + return raw.split(',').map(host => host.trim()).filter(host => host !== '')
  124 +}
  125 +
  126 +function isVisualFeedbackInspectorEnabled(env, _command) {
  127 + if (env.VITEST === 'true') return false
  128 + return env.VISUAL_FEEDBACK === 'true'
  129 +}
  130 +
  131 +function commandFromProcessArgv(argv) {
  132 + return argv.includes('build') ? 'build' : 'serve'
  133 +}
... ...
packages/config/vite/index.js 0 → 100644
  1 +++ a/packages/config/vite/index.js
  1 +/**
  2 + * `@drama/config/vite` 公共面 —— `defineWebPreset(overrides)` 工厂。
  3 + *
  4 + * 产出 `@app/web` / `@app/studio` / `@drama/ui` 通用的 Vite config(Vue 插件、`@drama/*`
  5 + * workspace 解析、build 产物约定、SPA base)。app 只传差异(入口、proxy、预渲染开关),
  6 + * 经**深合并**覆盖默认(嵌套字段精确覆盖,同级默认保留)。
  7 + *
  8 + * 消费示例:
  9 + * // apps/web/vite.config.js
  10 + * import { defineWebPreset } from '@drama/config/vite'
  11 + * export default defineWebPreset({ root: '.', server: { proxy: { '/api': 'http://localhost:9007' } } })
  12 + */
  13 +
  14 +import { buildBaseConfig, deepMerge } from './base.js'
  15 +
  16 +/**
  17 + * Vite config 工厂:base 默认 + 深合并 overrides。
  18 + * @param {Record<string, unknown>} [overrides] 用户差异(深合并进默认)。
  19 + * @param {{ env?: Record<string, string | undefined>, command?: string }} [options]
  20 + * @returns {Record<string, unknown>} 可被 Vite 直接消费的 config。
  21 + */
  22 +export function defineWebPreset(overrides = {}, options = {}) {
  23 + return deepMerge(buildBaseConfig(options), overrides)
  24 +}
  25 +
  26 +export { VUE_PLUGIN_NAME } from './base.js'
... ...
packages/config/vitest/base.js 0 → 100644
  1 +++ a/packages/config/vitest/base.js
  1 +/**
  2 + * Vitest 预设的「默认值」与共享工具 —— 共享 coverage 门槛 / include / reporter + 深合并。
  3 + *
  4 + * `index.js` 持有 node/vue 两个工厂,本文件持有跨工厂共享的默认数据与无副作用 `deepMerge`。
  5 + * coverage 门槛是「单一来源」:全仓收紧门槛改这一处,所有消费包下次跑即生效(§2 验收 5)。
  6 + * 预设为纯配置:不读 env、不建连接。
  7 + */
  8 +
  9 +/**
  10 + * @param {unknown} v
  11 + * @returns {boolean}
  12 + */
  13 +function isPlainObject(v) {
  14 + if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
  15 + const proto = Object.getPrototypeOf(v)
  16 + return proto === Object.prototype || proto === null
  17 +}
  18 +
  19 +/**
  20 + * 递归深合并(嵌套对象逐层合并、数组与标量整体替换)。
  21 + * 保证 app 传 `test.coverage.thresholds` 只覆盖门槛字段,不抹掉其余默认。
  22 + * @template T
  23 + * @param {T} base
  24 + * @param {Record<string, unknown>} override
  25 + * @returns {T}
  26 + */
  27 +export function deepMerge(base, override) {
  28 + if (!isPlainObject(override)) return /** @type {T} */ (override ?? base)
  29 + const out = isPlainObject(base) ? { ...base } : {}
  30 + for (const key of Object.keys(override)) {
  31 + const o = override[key]
  32 + const b = /** @type {Record<string, unknown>} */ (out)[key]
  33 + if (isPlainObject(b) && isPlainObject(o)) {
  34 + /** @type {Record<string, unknown>} */ (out)[key] = deepMerge(b, o)
  35 + } else {
  36 + /** @type {Record<string, unknown>} */ (out)[key] = o
  37 + }
  38 + }
  39 + return /** @type {T} */ (out)
  40 +}
  41 +
  42 +/** 全仓统一的覆盖率门槛(单一来源)。改这里 = 全仓消费包门槛同步变化。 */
  43 +export const COVERAGE_THRESHOLDS = Object.freeze({
  44 + lines: 80,
  45 + functions: 80,
  46 + branches: 75,
  47 + statements: 80,
  48 +})
  49 +
  50 +/** 全仓统一的 test 匹配 / 排除 / reporter / coverage 约定。 */
  51 +export function buildSharedTestConfig() {
  52 + return {
  53 + globals: true,
  54 + include: ['test/**/*.test.js', 'src/**/*.test.js'],
  55 + exclude: ['**/node_modules/**', '**/dist/**', '**/coverage/**'],
  56 + reporters: ['default'],
  57 + coverage: {
  58 + provider: 'v8',
  59 + reporter: ['text', 'lcov'],
  60 + include: ['src/**/*.js'],
  61 + exclude: ['**/*.test.js', '**/test/**'],
  62 + thresholds: { ...COVERAGE_THRESHOLDS },
  63 + },
  64 + }
  65 +}
... ...
packages/config/vitest/index.js 0 → 100644
  1 +++ a/packages/config/vitest/index.js
  1 +/**
  2 + * `@drama/config/vitest` 公共面 —— `defineNodePreset` / `defineVuePreset` 两个工厂。
  3 + *
  4 + * - `defineNodePreset(overrides)`:纯逻辑包用,`environment:'node'`。
  5 + * - `defineVuePreset(overrides)`:组件包用,`environment:'jsdom'` + `@vitejs/plugin-vue`。
  6 + *
  7 + * 两者共享 coverage 门槛、include/exclude、reporter(单一来源),overrides 深合并可改门槛。
  8 + *
  9 + * 消费示例:
  10 + * // packages/foo/vitest.config.js
  11 + * import { defineNodePreset } from '@drama/config/vitest'
  12 + * export default defineNodePreset()
  13 + *
  14 + * // packages/ui/vitest.config.js
  15 + * import { defineVuePreset } from '@drama/config/vitest'
  16 + * export default defineVuePreset()
  17 + */
  18 +
  19 +import { buildSharedTestConfig, deepMerge, COVERAGE_THRESHOLDS } from './base.js'
  20 +
  21 +export { COVERAGE_THRESHOLDS }
  22 +
  23 +/**
  24 + * node 环境 Vitest config(纯逻辑包)。
  25 + * @param {Record<string, unknown>} [overrides] 深合并进默认(可覆盖门槛 / include 等)。
  26 + * @returns {Record<string, unknown>}
  27 + */
  28 +export function defineNodePreset(overrides = {}) {
  29 + const base = {
  30 + test: {
  31 + ...buildSharedTestConfig(),
  32 + environment: 'node',
  33 + },
  34 + }
  35 + return deepMerge(base, overrides)
  36 +}
  37 +
  38 +/**
  39 + * jsdom + Vue 环境 Vitest config(组件包)。
  40 + * Vue 插件经动态 import 装配,避免纯 node 消费方加载本工厂时被强制拉起 plugin-vue。
  41 + * @param {Record<string, unknown>} [overrides] 深合并进默认。
  42 + * @returns {Promise<Record<string, unknown>>} 含 Vue 插件的 config(异步以惰性加载插件)。
  43 + */
  44 +export async function defineVuePreset(overrides = {}) {
  45 + const { default: vue } = await import('@vitejs/plugin-vue')
  46 + const base = {
  47 + plugins: [vue()],
  48 + test: {
  49 + ...buildSharedTestConfig(),
  50 + environment: 'jsdom',
  51 + },
  52 + }
  53 + return deepMerge(base, overrides)
  54 +}
... ...
packages/contracts/package.json 0 → 100644
  1 +++ a/packages/contracts/package.json
  1 +{
  2 + "name": "@drama/contracts",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "exports": {
  7 + ".": "./src/index.js",
  8 + "./drama": "./src/drama.js",
  9 + "./platform": "./src/platform.js",
  10 + "./visual-game": "./src/visual-game.js"
  11 + },
  12 + "scripts": {
  13 + "test": "vitest run"
  14 + },
  15 + "dependencies": {
  16 + "valibot": "^1.4.1"
  17 + }
  18 +}
... ...
packages/contracts/schema-snapshot.json 0 → 100644
Changes suppressed. Click to show
  1 +++ a/packages/contracts/schema-snapshot.json
  1 +{
  2 + "Account": {
  3 + "properties": {
  4 + "credits": {
  5 + "type": "number"
  6 + },
  7 + "display_name": {
  8 + "type": "string"
  9 + },
  10 + "email": {
  11 + "type": "optional",
  12 + "wrapped": {
  13 + "type": "string"
  14 + }
  15 + },
  16 + "favorites": {
  17 + "type": "optional",
  18 + "wrapped": {
  19 + "item": {
  20 + "properties": {
  21 + "cover": {
  22 + "type": "optional",
  23 + "wrapped": {
  24 + "type": "string"
  25 + }
  26 + },
  27 + "drama_id": {
  28 + "type": "optional",
  29 + "wrapped": {
  30 + "type": "string"
  31 + }
  32 + },
  33 + "id": {
  34 + "type": "optional",
  35 + "wrapped": {
  36 + "type": "string"
  37 + }
  38 + },
  39 + "progress": {
  40 + "type": "optional",
  41 + "wrapped": {
  42 + "type": "number"
  43 + }
  44 + },
  45 + "title": {
  46 + "type": "optional",
  47 + "wrapped": {
  48 + "type": "string"
  49 + }
  50 + }
  51 + },
  52 + "type": "object"
  53 + },
  54 + "type": "array"
  55 + }
  56 + },
  57 + "history": {
  58 + "type": "optional",
  59 + "wrapped": {
  60 + "item": {
  61 + "properties": {
  62 + "cover": {
  63 + "type": "optional",
  64 + "wrapped": {
  65 + "type": "string"
  66 + }
  67 + },
  68 + "drama_id": {
  69 + "type": "optional",
  70 + "wrapped": {
  71 + "type": "string"
  72 + }
  73 + },
  74 + "id": {
  75 + "type": "optional",
  76 + "wrapped": {
  77 + "type": "string"
  78 + }
  79 + },
  80 + "progress": {
  81 + "type": "optional",
  82 + "wrapped": {
  83 + "type": "number"
  84 + }
  85 + },
  86 + "title": {
  87 + "type": "optional",
  88 + "wrapped": {
  89 + "type": "string"
  90 + }
  91 + }
  92 + },
  93 + "type": "object"
  94 + },
  95 + "type": "array"
  96 + }
  97 + },
  98 + "id": {
  99 + "type": "string"
  100 + },
  101 + "locale": {
  102 + "type": "string"
  103 + },
  104 + "member_since": {
  105 + "type": "optional",
  106 + "wrapped": {
  107 + "type": "string"
  108 + }
  109 + },
  110 + "role": {
  111 + "type": "optional",
  112 + "wrapped": {
  113 + "options": [
  114 + "admin",
  115 + "operator",
  116 + "user"
  117 + ],
  118 + "type": "picklist"
  119 + }
  120 + },
  121 + "stats": {
  122 + "type": "optional",
  123 + "wrapped": {
  124 + "properties": {
  125 + "rewritten": {
  126 + "type": "optional",
  127 + "wrapped": {
  128 + "type": "number"
  129 + }
  130 + },
  131 + "watched": {
  132 + "type": "optional",
  133 + "wrapped": {
  134 + "type": "number"
  135 + }
  136 + }
  137 + },
  138 + "type": "object"
  139 + }
  140 + },
  141 + "subscription": {
  142 + "properties": {
  143 + "renews_at": {
  144 + "type": "optional",
  145 + "wrapped": {
  146 + "type": "string"
  147 + }
  148 + },
  149 + "status": {
  150 + "options": [
  151 + "active",
  152 + "inactive",
  153 + "past_due"
  154 + ],
  155 + "type": "picklist"
  156 + },
  157 + "tier": {
  158 + "type": "string"
  159 + }
  160 + },
  161 + "type": "object"
  162 + }
  163 + },
  164 + "type": "object"
  165 + },
  166 + "ApiError": {
  167 + "properties": {
  168 + "error": {
  169 + "properties": {
  170 + "code": {
  171 + "type": "string"
  172 + },
  173 + "details": {
  174 + "type": "optional",
  175 + "wrapped": {
  176 + "key": {
  177 + "type": "string"
  178 + },
  179 + "type": "record",
  180 + "value": {
  181 + "type": "unknown"
  182 + }
  183 + }
  184 + },
  185 + "message": {
  186 + "type": "string"
  187 + },
  188 + "retryable": {
  189 + "type": "boolean"
  190 + }
  191 + },
  192 + "type": "object"
  193 + }
  194 + },
  195 + "type": "object"
  196 + },
  197 + "Bundle": {
  198 + "properties": {
  199 + "drama_id": {
  200 + "type": "string"
  201 + },
  202 + "lines": {
  203 + "item": {
  204 + "properties": {
  205 + "emotion": {
  206 + "type": "optional",
  207 + "wrapped": {
  208 + "type": "string"
  209 + }
  210 + },
  211 + "mode": {
  212 + "options": [
  213 + "block",
  214 + "parallel"
  215 + ],
  216 + "type": "picklist"
  217 + },
  218 + "speaker": {
  219 + "type": "string"
  220 + },
  221 + "text": {
  222 + "type": "string"
  223 + },
  224 + "tts": {
  225 + "type": "optional",
  226 + "wrapped": {
  227 + "properties": {
  228 + "instruction": {
  229 + "type": "optional",
  230 + "wrapped": {
  231 + "type": "string"
  232 + }
  233 + },
  234 + "speech_rate": {
  235 + "type": "optional",
  236 + "wrapped": {
  237 + "type": "number"
  238 + }
  239 + }
  240 + },
  241 + "type": "object"
  242 + }
  243 + }
  244 + },
  245 + "type": "object"
  246 + },
  247 + "type": "array"
  248 + },
  249 + "meta": {
  250 + "properties": {
  251 + "duration": {
  252 + "type": "optional",
  253 + "wrapped": {
  254 + "type": "number"
  255 + }
  256 + },
  257 + "locale": {
  258 + "type": "string"
  259 + },
  260 + "version": {
  261 + "type": "number"
  262 + }
  263 + },
  264 + "type": "object"
  265 + },
  266 + "rig_ref": {
  267 + "type": "string"
  268 + },
  269 + "scenes": {
  270 + "item": {
  271 + "properties": {
  272 + "background": {
  273 + "type": "optional",
  274 + "wrapped": {
  275 + "type": "string"
  276 + }
  277 + },
  278 + "id": {
  279 + "type": "string"
  280 + },
  281 + "props": {
  282 + "type": "optional",
  283 + "wrapped": {
  284 + "item": {
  285 + "type": "string"
  286 + },
  287 + "type": "array"
  288 + }
  289 + }
  290 + },
  291 + "type": "object"
  292 + },
  293 + "type": "array"
  294 + },
  295 + "title": {
  296 + "type": "string"
  297 + }
  298 + },
  299 + "type": "object"
  300 + },
  301 + "Drama": {
  302 + "properties": {
  303 + "cover": {
  304 + "type": "string"
  305 + },
  306 + "heat": {
  307 + "type": "optional",
  308 + "wrapped": {
  309 + "type": "number"
  310 + }
  311 + },
  312 + "id": {
  313 + "type": "string"
  314 + },
  315 + "locale": {
  316 + "type": "string"
  317 + },
  318 + "paywall": {
  319 + "type": "optional",
  320 + "wrapped": {
  321 + "properties": {
  322 + "currency": {
  323 + "type": "optional",
  324 + "wrapped": {
  325 + "type": "string"
  326 + }
  327 + },
  328 + "locked": {
  329 + "type": "boolean"
  330 + },
  331 + "price": {
  332 + "type": "optional",
  333 + "wrapped": {
  334 + "type": "number"
  335 + }
  336 + },
  337 + "unlock_cost": {
  338 + "type": "optional",
  339 + "wrapped": {
  340 + "type": "number"
  341 + }
  342 + },
  343 + "user_credits": {
  344 + "type": "optional",
  345 + "wrapped": {
  346 + "type": "number"
  347 + }
  348 + },
  349 + "vip_tier": {
  350 + "type": "optional",
  351 + "wrapped": {
  352 + "type": "string"
  353 + }
  354 + }
  355 + },
  356 + "type": "object"
  357 + }
  358 + },
  359 + "title": {
  360 + "type": "string"
  361 + }
  362 + },
  363 + "type": "object"
  364 + },
  365 + "LedgerEntry": {
  366 + "properties": {
  367 + "created_at": {
  368 + "type": "string"
  369 + },
  370 + "delta": {
  371 + "type": "number"
  372 + },
  373 + "id": {
  374 + "type": "string"
  375 + },
  376 + "reason": {
  377 + "options": [
  378 + "grant",
  379 + "topup",
  380 + "unlock"
  381 + ],
  382 + "type": "picklist"
  383 + }
  384 + },
  385 + "type": "object"
  386 + },
  387 + "LoginRequest": {
  388 + "properties": {
  389 + "credential": {
  390 + "type": "string"
  391 + },
  392 + "device_id": {
  393 + "type": "optional",
  394 + "wrapped": {
  395 + "type": "string"
  396 + }
  397 + },
  398 + "oauth_flow": {
  399 + "type": "optional",
  400 + "wrapped": {
  401 + "options": [
  402 + "access_token",
  403 + "authorization_code"
  404 + ],
  405 + "type": "picklist"
  406 + }
  407 + },
  408 + "provider": {
  409 + "type": "string"
  410 + },
  411 + "redirect_uri": {
  412 + "type": "optional",
  413 + "wrapped": {
  414 + "type": "string"
  415 + }
  416 + }
  417 + },
  418 + "type": "object"
  419 + },
  420 + "LoginResponse": {
  421 + "properties": {
  422 + "access_token": {
  423 + "type": "string"
  424 + },
  425 + "account": {
  426 + "properties": {
  427 + "credits": {
  428 + "type": "number"
  429 + },
  430 + "display_name": {
  431 + "type": "string"
  432 + },
  433 + "email": {
  434 + "type": "optional",
  435 + "wrapped": {
  436 + "type": "string"
  437 + }
  438 + },
  439 + "favorites": {
  440 + "type": "optional",
  441 + "wrapped": {
  442 + "item": {
  443 + "properties": {
  444 + "cover": {
  445 + "type": "optional",
  446 + "wrapped": {
  447 + "type": "string"
  448 + }
  449 + },
  450 + "drama_id": {
  451 + "type": "optional",
  452 + "wrapped": {
  453 + "type": "string"
  454 + }
  455 + },
  456 + "id": {
  457 + "type": "optional",
  458 + "wrapped": {
  459 + "type": "string"
  460 + }
  461 + },
  462 + "progress": {
  463 + "type": "optional",
  464 + "wrapped": {
  465 + "type": "number"
  466 + }
  467 + },
  468 + "title": {
  469 + "type": "optional",
  470 + "wrapped": {
  471 + "type": "string"
  472 + }
  473 + }
  474 + },
  475 + "type": "object"
  476 + },
  477 + "type": "array"
  478 + }
  479 + },
  480 + "history": {
  481 + "type": "optional",
  482 + "wrapped": {
  483 + "item": {
  484 + "properties": {
  485 + "cover": {
  486 + "type": "optional",
  487 + "wrapped": {
  488 + "type": "string"
  489 + }
  490 + },
  491 + "drama_id": {
  492 + "type": "optional",
  493 + "wrapped": {
  494 + "type": "string"
  495 + }
  496 + },
  497 + "id": {
  498 + "type": "optional",
  499 + "wrapped": {
  500 + "type": "string"
  501 + }
  502 + },
  503 + "progress": {
  504 + "type": "optional",
  505 + "wrapped": {
  506 + "type": "number"
  507 + }
  508 + },
  509 + "title": {
  510 + "type": "optional",
  511 + "wrapped": {
  512 + "type": "string"
  513 + }
  514 + }
  515 + },
  516 + "type": "object"
  517 + },
  518 + "type": "array"
  519 + }
  520 + },
  521 + "id": {
  522 + "type": "string"
  523 + },
  524 + "locale": {
  525 + "type": "string"
  526 + },
  527 + "member_since": {
  528 + "type": "optional",
  529 + "wrapped": {
  530 + "type": "string"
  531 + }
  532 + },
  533 + "role": {
  534 + "type": "optional",
  535 + "wrapped": {
  536 + "options": [
  537 + "admin",
  538 + "operator",
  539 + "user"
  540 + ],
  541 + "type": "picklist"
  542 + }
  543 + },
  544 + "stats": {
  545 + "type": "optional",
  546 + "wrapped": {
  547 + "properties": {
  548 + "rewritten": {
  549 + "type": "optional",
  550 + "wrapped": {
  551 + "type": "number"
  552 + }
  553 + },
  554 + "watched": {
  555 + "type": "optional",
  556 + "wrapped": {
  557 + "type": "number"
  558 + }
  559 + }
  560 + },
  561 + "type": "object"
  562 + }
  563 + },
  564 + "subscription": {
  565 + "properties": {
  566 + "renews_at": {
  567 + "type": "optional",
  568 + "wrapped": {
  569 + "type": "string"
  570 + }
  571 + },
  572 + "status": {
  573 + "options": [
  574 + "active",
  575 + "inactive",
  576 + "past_due"
  577 + ],
  578 + "type": "picklist"
  579 + },
  580 + "tier": {
  581 + "type": "string"
  582 + }
  583 + },
  584 + "type": "object"
  585 + }
  586 + },
  587 + "type": "object"
  588 + },
  589 + "refresh_token": {
  590 + "type": "string"
  591 + }
  592 + },
  593 + "type": "object"
  594 + },
  595 + "PageOfDrama": {
  596 + "properties": {
  597 + "has_more": {
  598 + "type": "boolean"
  599 + },
  600 + "items": {
  601 + "item": {
  602 + "properties": {
  603 + "cover": {
  604 + "type": "string"
  605 + },
  606 + "heat": {
  607 + "type": "optional",
  608 + "wrapped": {
  609 + "type": "number"
  610 + }
  611 + },
  612 + "id": {
  613 + "type": "string"
  614 + },
  615 + "locale": {
  616 + "type": "string"
  617 + },
  618 + "paywall": {
  619 + "type": "optional",
  620 + "wrapped": {
  621 + "properties": {
  622 + "currency": {
  623 + "type": "optional",
  624 + "wrapped": {
  625 + "type": "string"
  626 + }
  627 + },
  628 + "locked": {
  629 + "type": "boolean"
  630 + },
  631 + "price": {
  632 + "type": "optional",
  633 + "wrapped": {
  634 + "type": "number"
  635 + }
  636 + },
  637 + "unlock_cost": {
  638 + "type": "optional",
  639 + "wrapped": {
  640 + "type": "number"
  641 + }
  642 + },
  643 + "user_credits": {
  644 + "type": "optional",
  645 + "wrapped": {
  646 + "type": "number"
  647 + }
  648 + },
  649 + "vip_tier": {
  650 + "type": "optional",
  651 + "wrapped": {
  652 + "type": "string"
  653 + }
  654 + }
  655 + },
  656 + "type": "object"
  657 + }
  658 + },
  659 + "title": {
  660 + "type": "string"
  661 + }
  662 + },
  663 + "type": "object"
  664 + },
  665 + "type": "array"
  666 + },
  667 + "page": {
  668 + "type": "number"
  669 + },
  670 + "page_size": {
  671 + "type": "number"
  672 + },
  673 + "total": {
  674 + "type": "number"
  675 + }
  676 + },
  677 + "type": "object"
  678 + },
  679 + "ScriptLine": {
  680 + "properties": {
  681 + "emotion": {
  682 + "type": "optional",
  683 + "wrapped": {
  684 + "type": "string"
  685 + }
  686 + },
  687 + "mode": {
  688 + "options": [
  689 + "block",
  690 + "parallel"
  691 + ],
  692 + "type": "picklist"
  693 + },
  694 + "speaker": {
  695 + "type": "string"
  696 + },
  697 + "text": {
  698 + "type": "string"
  699 + },
  700 + "tts": {
  701 + "type": "optional",
  702 + "wrapped": {
  703 + "properties": {
  704 + "instruction": {
  705 + "type": "optional",
  706 + "wrapped": {
  707 + "type": "string"
  708 + }
  709 + },
  710 + "speech_rate": {
  711 + "type": "optional",
  712 + "wrapped": {
  713 + "type": "number"
  714 + }
  715 + }
  716 + },
  717 + "type": "object"
  718 + }
  719 + }
  720 + },
  721 + "type": "object"
  722 + },
  723 + "ShareMeta": {
  724 + "properties": {
  725 + "description": {
  726 + "type": "string"
  727 + },
  728 + "image": {
  729 + "type": "string"
  730 + },
  731 + "title": {
  732 + "type": "string"
  733 + },
  734 + "type": {
  735 + "type": "optional",
  736 + "wrapped": {
  737 + "type": "string"
  738 + }
  739 + },
  740 + "url": {
  741 + "type": "string"
  742 + }
  743 + },
  744 + "type": "object"
  745 + },
  746 + "TelemetryEvent": {
  747 + "properties": {
  748 + "anon_id": {
  749 + "type": "nullable",
  750 + "wrapped": {
  751 + "type": "string"
  752 + }
  753 + },
  754 + "device_id": {
  755 + "type": "string"
  756 + },
  757 + "locale": {
  758 + "type": "string"
  759 + },
  760 + "name": {
  761 + "type": "string"
  762 + },
  763 + "props": {
  764 + "key": {
  765 + "type": "string"
  766 + },
  767 + "type": "record",
  768 + "value": {
  769 + "type": "unknown"
  770 + }
  771 + },
  772 + "sdk_version": {
  773 + "type": "string"
  774 + },
  775 + "session_id": {
  776 + "type": "string"
  777 + },
  778 + "ts": {
  779 + "type": "number"
  780 + }
  781 + },
  782 + "type": "object"
  783 + },
  784 + "TopupCheckoutReq": {
  785 + "properties": {
  786 + "pack_id": {
  787 + "type": "string"
  788 + }
  789 + },
  790 + "type": "object"
  791 + },
  792 + "UnlockReq": {
  793 + "properties": {
  794 + "drama_id": {
  795 + "type": "string"
  796 + },
  797 + "episode_id": {
  798 + "type": "optional",
  799 + "wrapped": {
  800 + "type": "string"
  801 + }
  802 + }
  803 + },
  804 + "type": "object"
  805 + },
  806 + "UnlockRes": {
  807 + "properties": {
  808 + "reason": {
  809 + "type": "optional",
  810 + "wrapped": {
  811 + "options": [
  812 + "already",
  813 + "insufficient"
  814 + ],
  815 + "type": "picklist"
  816 + }
  817 + },
  818 + "remaining_credits": {
  819 + "type": "number"
  820 + },
  821 + "unlocked": {
  822 + "type": "boolean"
  823 + }
  824 + },
  825 + "type": "object"
  826 + },
  827 + "VisualGameAdminAnalyticsDrilldownResponse": {
  828 + "properties": {
  829 + "generated_at": {
  830 + "type": "string"
  831 + },
  832 + "items": {
  833 + "item": {
  834 + "properties": {
  835 + "account_type": {
  836 + "options": [
  837 + "guest",
  838 + "registered"
  839 + ],
  840 + "type": "picklist"
  841 + },
  842 + "act_turns": {
  843 + "type": "number"
  844 + },
  845 + "credits_granted": {
  846 + "type": "number"
  847 + },
  848 + "credits_spent": {
  849 + "type": "number"
  850 + },
  851 + "display_name": {
  852 + "type": "nullable",
  853 + "wrapped": {
  854 + "type": "string"
  855 + }
  856 + },
  857 + "interactions": {
  858 + "type": "number"
  859 + },
  860 + "last_interaction_at": {
  861 + "type": "nullable",
  862 + "wrapped": {
  863 + "type": "string"
  864 + }
  865 + },
  866 + "paid_orders": {
  867 + "type": "number"
  868 + },
  869 + "runs_completed": {
  870 + "type": "number"
  871 + },
  872 + "runs_started": {
  873 + "type": "number"
  874 + },
  875 + "say_turns": {
  876 + "type": "number"
  877 + },
  878 + "seasons_completed": {
  879 + "type": "number"
  880 + },
  881 + "seasons_started": {
  882 + "type": "number"
  883 + },
  884 + "user_id": {
  885 + "type": "string"
  886 + }
  887 + },
  888 + "type": "object"
  889 + },
  890 + "type": "array"
  891 + },
  892 + "metric": {
  893 + "options": [
  894 + "act_turns",
  895 + "active_players",
  896 + "credits_granted",
  897 + "credits_spent",
  898 + "interactions",
  899 + "paying_players",
  900 + "runs_completed",
  901 + "runs_started",
  902 + "say_turns",
  903 + "seasons_completed",
  904 + "seasons_started"
  905 + ],
  906 + "type": "picklist"
  907 + },
  908 + "since": {
  909 + "type": "string"
  910 + },
  911 + "total": {
  912 + "type": "number"
  913 + },
  914 + "until": {
  915 + "type": "string"
  916 + }
  917 + },
  918 + "type": "object"
  919 + },
  920 + "VisualGameAdminAnalyticsOverviewResponse": {
  921 + "properties": {
  922 + "daily": {
  923 + "item": {
  924 + "properties": {
  925 + "active_players": {
  926 + "type": "number"
  927 + },
  928 + "date": {
  929 + "type": "string"
  930 + },
  931 + "interactions": {
  932 + "type": "number"
  933 + }
  934 + },
  935 + "type": "object"
  936 + },
  937 + "type": "array"
  938 + },
  939 + "generated_at": {
  940 + "type": "string"
  941 + },
  942 + "metrics": {
  943 + "properties": {
  944 + "act_turns": {
  945 + "type": "number"
  946 + },
  947 + "active_players": {
  948 + "type": "number"
  949 + },
  950 + "credits_granted": {
  951 + "type": "number"
  952 + },
  953 + "credits_spent": {
  954 + "type": "number"
  955 + },
  956 + "interactions": {
  957 + "type": "number"
  958 + },
  959 + "paying_players": {
  960 + "type": "number"
  961 + },
  962 + "runs_completed": {
  963 + "type": "number"
  964 + },
  965 + "runs_started": {
  966 + "type": "number"
  967 + },
  968 + "say_turns": {
  969 + "type": "number"
  970 + },
  971 + "seasons_completed": {
  972 + "type": "number"
  973 + },
  974 + "seasons_started": {
  975 + "type": "number"
  976 + }
  977 + },
  978 + "type": "object"
  979 + },
  980 + "since": {
  981 + "type": "string"
  982 + },
  983 + "until": {
  984 + "type": "string"
  985 + }
  986 + },
  987 + "type": "object"
  988 + },
  989 + "VisualGameAdminContentAnalyticsResponse": {
  990 + "properties": {
  991 + "generated_at": {
  992 + "type": "string"
  993 + },
  994 + "items": {
  995 + "item": {
  996 + "properties": {
  997 + "act_turns": {
  998 + "type": "number"
  999 + },
  1000 + "active_players": {
  1001 + "type": "number"
  1002 + },
  1003 + "completion_rate": {
  1004 + "type": "nullable",
  1005 + "wrapped": {
  1006 + "type": "number"
  1007 + }
  1008 + },
  1009 + "episode_no": {
  1010 + "type": "number"
  1011 + },
  1012 + "interactions": {
  1013 + "type": "number"
  1014 + },
  1015 + "players": {
  1016 + "type": "number"
  1017 + },
  1018 + "runs_completed": {
  1019 + "type": "number"
  1020 + },
  1021 + "runs_started": {
  1022 + "type": "number"
  1023 + },
  1024 + "say_turns": {
  1025 + "type": "number"
  1026 + },
  1027 + "scenario_id": {
  1028 + "type": "string"
  1029 + },
  1030 + "scenario_title": {
  1031 + "type": "string"
  1032 + },
  1033 + "series_id": {
  1034 + "type": "nullable",
  1035 + "wrapped": {
  1036 + "type": "string"
  1037 + }
  1038 + },
  1039 + "series_title": {
  1040 + "type": "nullable",
  1041 + "wrapped": {
  1042 + "type": "string"
  1043 + }
  1044 + }
  1045 + },
  1046 + "type": "object"
  1047 + },
  1048 + "type": "array"
  1049 + },
  1050 + "since": {
  1051 + "type": "string"
  1052 + },
  1053 + "until": {
  1054 + "type": "string"
  1055 + }
  1056 + },
  1057 + "type": "object"
  1058 + },
  1059 + "VisualGameAdminGa4BehaviorResponse": {
  1060 + "properties": {
  1061 + "clicks": {
  1062 + "item": {
  1063 + "properties": {
  1064 + "clicks": {
  1065 + "type": "number"
  1066 + },
  1067 + "element_id": {
  1068 + "type": "string"
  1069 + },
  1070 + "surface": {
  1071 + "type": "string"
  1072 + },
  1073 + "users": {
  1074 + "type": "number"
  1075 + }
  1076 + },
  1077 + "type": "object"
  1078 + },
  1079 + "type": "array"
  1080 + },
  1081 + "configured": {
  1082 + "type": "boolean"
  1083 + },
  1084 + "devices": {
  1085 + "item": {
  1086 + "properties": {
  1087 + "browser": {
  1088 + "type": "string"
  1089 + },
  1090 + "category": {
  1091 + "type": "string"
  1092 + },
  1093 + "operating_system": {
  1094 + "type": "string"
  1095 + },
  1096 + "sessions": {
  1097 + "type": "number"
  1098 + },
  1099 + "users": {
  1100 + "type": "number"
  1101 + }
  1102 + },
  1103 + "type": "object"
  1104 + },
  1105 + "type": "array"
  1106 + },
  1107 + "flows": {
  1108 + "item": {
  1109 + "properties": {
  1110 + "from": {
  1111 + "type": "string"
  1112 + },
  1113 + "to": {
  1114 + "type": "string"
  1115 + },
  1116 + "users": {
  1117 + "type": "number"
  1118 + },
  1119 + "views": {
  1120 + "type": "number"
  1121 + }
  1122 + },
  1123 + "type": "object"
  1124 + },
  1125 + "type": "array"
  1126 + },
  1127 + "generated_at": {
  1128 + "type": "string"
  1129 + },
  1130 + "inputs": {
  1131 + "item": {
  1132 + "properties": {
  1133 + "input_method": {
  1134 + "type": "string"
  1135 + },
  1136 + "mode": {
  1137 + "type": "string"
  1138 + },
  1139 + "submissions": {
  1140 + "type": "number"
  1141 + },
  1142 + "users": {
  1143 + "type": "number"
  1144 + }
  1145 + },
  1146 + "type": "object"
  1147 + },
  1148 + "type": "array"
  1149 + },
  1150 + "pages": {
  1151 + "item": {
  1152 + "properties": {
  1153 + "path": {
  1154 + "type": "string"
  1155 + },
  1156 + "title": {
  1157 + "type": "string"
  1158 + },
  1159 + "users": {
  1160 + "type": "number"
  1161 + },
  1162 + "views": {
  1163 + "type": "number"
  1164 + }
  1165 + },
  1166 + "type": "object"
  1167 + },
  1168 + "type": "array"
  1169 + },
  1170 + "scene_misses": {
  1171 + "item": {
  1172 + "properties": {
  1173 + "clicks": {
  1174 + "type": "number"
  1175 + },
  1176 + "grid_x": {
  1177 + "type": "string"
  1178 + },
  1179 + "grid_y": {
  1180 + "type": "string"
  1181 + },
  1182 + "pointer_type": {
  1183 + "type": "string"
  1184 + },
  1185 + "scene_id": {
  1186 + "type": "string"
  1187 + },
  1188 + "users": {
  1189 + "type": "number"
  1190 + }
  1191 + },
  1192 + "type": "object"
  1193 + },
  1194 + "type": "array"
  1195 + },
  1196 + "since": {
  1197 + "type": "string"
  1198 + },
  1199 + "sources": {
  1200 + "item": {
  1201 + "properties": {
  1202 + "campaign": {
  1203 + "type": "string"
  1204 + },
  1205 + "channel": {
  1206 + "type": "string"
  1207 + },
  1208 + "note_key": {
  1209 + "options": [
  1210 + "direct",
  1211 + "facebook_referral",
  1212 + "instagram_referral",
  1213 + "organic_search",
  1214 + "organic_social",
  1215 + "other",
  1216 + "paid_search",
  1217 + "paid_social",
  1218 + "referral",
  1219 + "tiktok_referral",
  1220 + "unassigned",
  1221 + "x_referral",
  1222 + "youtube_referral"
  1223 + ],
  1224 + "type": "picklist"
  1225 + },
  1226 + "sessions": {
  1227 + "type": "number"
  1228 + },
  1229 + "source_medium": {
  1230 + "type": "string"
  1231 + },
  1232 + "users": {
  1233 + "type": "number"
  1234 + }
  1235 + },
  1236 + "type": "object"
  1237 + },
  1238 + "type": "array"
  1239 + },
  1240 + "summary": {
  1241 + "properties": {
  1242 + "active_users": {
  1243 + "type": "number"
  1244 + },
  1245 + "page_views": {
  1246 + "type": "number"
  1247 + },
  1248 + "scene_clicks": {
  1249 + "type": "number"
  1250 + },
  1251 + "selected_inputs": {
  1252 + "type": "number"
  1253 + },
  1254 + "sessions": {
  1255 + "type": "number"
  1256 + },
  1257 + "typed_inputs": {
  1258 + "type": "number"
  1259 + },
  1260 + "ui_clicks": {
  1261 + "type": "number"
  1262 + },
  1263 + "unique_visitors": {
  1264 + "type": "number"
  1265 + },
  1266 + "video_completions": {
  1267 + "type": "number"
  1268 + },
  1269 + "video_starts": {
  1270 + "type": "number"
  1271 + },
  1272 + "video_viewers": {
  1273 + "type": "number"
  1274 + }
  1275 + },
  1276 + "type": "object"
  1277 + },
  1278 + "traffic_daily": {
  1279 + "item": {
  1280 + "properties": {
  1281 + "date": {
  1282 + "type": "string"
  1283 + },
  1284 + "page_views": {
  1285 + "type": "number"
  1286 + },
  1287 + "unique_visitors": {
  1288 + "type": "number"
  1289 + }
  1290 + },
  1291 + "type": "object"
  1292 + },
  1293 + "type": "array"
  1294 + },
  1295 + "until": {
  1296 + "type": "string"
  1297 + },
  1298 + "videos": {
  1299 + "item": {
  1300 + "properties": {
  1301 + "completions": {
  1302 + "type": "number"
  1303 + },
  1304 + "placement": {
  1305 + "type": "string"
  1306 + },
  1307 + "progress_events": {
  1308 + "type": "number"
  1309 + },
  1310 + "starts": {
  1311 + "type": "number"
  1312 + },
  1313 + "users": {
  1314 + "type": "number"
  1315 + },
  1316 + "video_id": {
  1317 + "type": "string"
  1318 + },
  1319 + "video_title": {
  1320 + "type": "nullable",
  1321 + "wrapped": {
  1322 + "type": "string"
  1323 + }
  1324 + },
  1325 + "video_url": {
  1326 + "type": "nullable",
  1327 + "wrapped": {
  1328 + "type": "string"
  1329 + }
  1330 + }
  1331 + },
  1332 + "type": "object"
  1333 + },
  1334 + "type": "array"
  1335 + }
  1336 + },
  1337 + "type": "object"
  1338 + },
  1339 + "VisualGameAdminPlayerInteractionsResponse": {
  1340 + "properties": {
  1341 + "generated_at": {
  1342 + "type": "string"
  1343 + },
  1344 + "items": {
  1345 + "item": {
  1346 + "properties": {
  1347 + "created_at": {
  1348 + "type": "string"
  1349 + },
  1350 + "episode_no": {
  1351 + "type": "nullable",
  1352 + "wrapped": {
  1353 + "type": "number"
  1354 + }
  1355 + },
  1356 + "id": {
  1357 + "type": "string"
  1358 + },
  1359 + "input_text": {
  1360 + "type": "string"
  1361 + },
  1362 + "mode": {
  1363 + "options": [
  1364 + "act",
  1365 + "say"
  1366 + ],
  1367 + "type": "picklist"
  1368 + },
  1369 + "scenario_id": {
  1370 + "type": "string"
  1371 + },
  1372 + "scenario_title": {
  1373 + "type": "nullable",
  1374 + "wrapped": {
  1375 + "type": "string"
  1376 + }
  1377 + },
  1378 + "season_no": {
  1379 + "type": "nullable",
  1380 + "wrapped": {
  1381 + "type": "number"
  1382 + }
  1383 + },
  1384 + "series_title": {
  1385 + "type": "nullable",
  1386 + "wrapped": {
  1387 + "type": "string"
  1388 + }
  1389 + },
  1390 + "status": {
  1391 + "type": "literal",
  1392 + "value": "completed"
  1393 + },
  1394 + "turn_no": {
  1395 + "type": "number"
  1396 + }
  1397 + },
  1398 + "type": "object"
  1399 + },
  1400 + "type": "array"
  1401 + },
  1402 + "since": {
  1403 + "type": "string"
  1404 + },
  1405 + "total": {
  1406 + "type": "number"
  1407 + },
  1408 + "until": {
  1409 + "type": "string"
  1410 + },
  1411 + "user_id": {
  1412 + "type": "string"
  1413 + }
  1414 + },
  1415 + "type": "object"
  1416 + },
  1417 + "VisualGameAdminUserActionResponse": {
  1418 + "properties": {
  1419 + "audit_id": {
  1420 + "type": "string"
  1421 + },
  1422 + "detail": {
  1423 + "properties": {
  1424 + "audit_logs": {
  1425 + "item": {
  1426 + "properties": {
  1427 + "action": {
  1428 + "options": [
  1429 + "account_provisioned",
  1430 + "credits_deducted",
  1431 + "credits_granted",
  1432 + "profile_updated",
  1433 + "subscription_updated"
  1434 + ],
  1435 + "type": "picklist"
  1436 + },
  1437 + "created_at": {
  1438 + "type": "string"
  1439 + },
  1440 + "id": {
  1441 + "type": "string"
  1442 + },
  1443 + "operator_id": {
  1444 + "type": "string"
  1445 + },
  1446 + "reason": {
  1447 + "type": "string"
  1448 + }
  1449 + },
  1450 + "type": "object"
  1451 + },
  1452 + "type": "array"
  1453 + },
  1454 + "credits": {
  1455 + "properties": {
  1456 + "balance": {
  1457 + "type": "number"
  1458 + },
  1459 + "ledger": {
  1460 + "item": {
  1461 + "properties": {
  1462 + "amount": {
  1463 + "type": "number"
  1464 + },
  1465 + "balance_after": {
  1466 + "type": "number"
  1467 + },
  1468 + "created_at": {
  1469 + "type": "string"
  1470 + },
  1471 + "id": {
  1472 + "type": "string"
  1473 + },
  1474 + "reason": {
  1475 + "type": "nullable",
  1476 + "wrapped": {
  1477 + "type": "string"
  1478 + }
  1479 + },
  1480 + "ref_id": {
  1481 + "type": "nullable",
  1482 + "wrapped": {
  1483 + "type": "string"
  1484 + }
  1485 + },
  1486 + "ref_type": {
  1487 + "type": "nullable",
  1488 + "wrapped": {
  1489 + "type": "string"
  1490 + }
  1491 + },
  1492 + "type": {
  1493 + "options": [
  1494 + "deduct",
  1495 + "grant"
  1496 + ],
  1497 + "type": "picklist"
  1498 + }
  1499 + },
  1500 + "type": "object"
  1501 + },
  1502 + "type": "array"
  1503 + }
  1504 + },
  1505 + "type": "object"
  1506 + },
  1507 + "guest_devices": {
  1508 + "item": {
  1509 + "properties": {
  1510 + "last_seen_at": {
  1511 + "type": "nullable",
  1512 + "wrapped": {
  1513 + "type": "string"
  1514 + }
  1515 + },
  1516 + "merged_user_id": {
  1517 + "type": "nullable",
  1518 + "wrapped": {
  1519 + "type": "string"
  1520 + }
  1521 + },
  1522 + "status": {
  1523 + "options": [
  1524 + "active",
  1525 + "merged"
  1526 + ],
  1527 + "type": "picklist"
  1528 + }
  1529 + },
  1530 + "type": "object"
  1531 + },
  1532 + "type": "array"
  1533 + },
  1534 + "orders": {
  1535 + "item": {
  1536 + "properties": {
  1537 + "amount_cents": {
  1538 + "type": "nullable",
  1539 + "wrapped": {
  1540 + "type": "number"
  1541 + }
  1542 + },
  1543 + "created_at": {
  1544 + "type": "string"
  1545 + },
  1546 + "currency": {
  1547 + "type": "nullable",
  1548 + "wrapped": {
  1549 + "type": "string"
  1550 + }
  1551 + },
  1552 + "id": {
  1553 + "type": "string"
  1554 + },
  1555 + "mode": {
  1556 + "options": [
  1557 + "payment",
  1558 + "subscription"
  1559 + ],
  1560 + "type": "picklist"
  1561 + },
  1562 + "product_id": {
  1563 + "type": "string"
  1564 + },
  1565 + "provider": {
  1566 + "options": [
  1567 + "apple",
  1568 + "google",
  1569 + "stripe"
  1570 + ],
  1571 + "type": "picklist"
  1572 + },
  1573 + "refund_amount": {
  1574 + "type": "nullable",
  1575 + "wrapped": {
  1576 + "type": "number"
  1577 + }
  1578 + },
  1579 + "status": {
  1580 + "options": [
  1581 + "canceled",
  1582 + "disputed",
  1583 + "failed",
  1584 + "paid",
  1585 + "pending",
  1586 + "refunded"
  1587 + ],
  1588 + "type": "picklist"
  1589 + },
  1590 + "updated_at": {
  1591 + "type": "nullable",
  1592 + "wrapped": {
  1593 + "type": "string"
  1594 + }
  1595 + }
  1596 + },
  1597 + "type": "object"
  1598 + },
  1599 + "type": "array"
  1600 + },
  1601 + "play": {
  1602 + "properties": {
  1603 + "recent_runs": {
  1604 + "item": {
  1605 + "properties": {
  1606 + "completed_at": {
  1607 + "type": "nullable",
  1608 + "wrapped": {
  1609 + "type": "string"
  1610 + }
  1611 + },
  1612 + "created_at": {
  1613 + "type": "string"
  1614 + },
  1615 + "current_location_id": {
  1616 + "type": "nullable",
  1617 + "wrapped": {
  1618 + "type": "string"
  1619 + }
  1620 + },
  1621 + "id": {
  1622 + "type": "string"
  1623 + },
  1624 + "phase": {
  1625 + "type": "nullable",
  1626 + "wrapped": {
  1627 + "type": "string"
  1628 + }
  1629 + },
  1630 + "scenario_id": {
  1631 + "type": "string"
  1632 + },
  1633 + "scenario_title": {
  1634 + "type": "nullable",
  1635 + "wrapped": {
  1636 + "type": "string"
  1637 + }
  1638 + },
  1639 + "status": {
  1640 + "options": [
  1641 + "active",
  1642 + "completed"
  1643 + ],
  1644 + "type": "picklist"
  1645 + },
  1646 + "turn_count": {
  1647 + "type": "number"
  1648 + },
  1649 + "updated_at": {
  1650 + "type": "nullable",
  1651 + "wrapped": {
  1652 + "type": "string"
  1653 + }
  1654 + }
  1655 + },
  1656 + "type": "object"
  1657 + },
  1658 + "type": "array"
  1659 + },
  1660 + "summary": {
  1661 + "properties": {
  1662 + "act_turns": {
  1663 + "type": "number"
  1664 + },
  1665 + "active_runs": {
  1666 + "type": "number"
  1667 + },
  1668 + "completed_runs": {
  1669 + "type": "number"
  1670 + },
  1671 + "completed_seasons": {
  1672 + "type": "number"
  1673 + },
  1674 + "credits_granted": {
  1675 + "type": "number"
  1676 + },
  1677 + "credits_spent": {
  1678 + "type": "number"
  1679 + },
  1680 + "endings": {
  1681 + "type": "number"
  1682 + },
  1683 + "last_active_at": {
  1684 + "type": "nullable",
  1685 + "wrapped": {
  1686 + "type": "string"
  1687 + }
  1688 + },
  1689 + "progress_records": {
  1690 + "type": "number"
  1691 + },
  1692 + "runs": {
  1693 + "type": "number"
  1694 + },
  1695 + "save_slots": {
  1696 + "type": "number"
  1697 + },
  1698 + "say_turns": {
  1699 + "type": "number"
  1700 + },
  1701 + "scenarios": {
  1702 + "type": "number"
  1703 + },
  1704 + "turns": {
  1705 + "type": "number"
  1706 + }
  1707 + },
  1708 + "type": "object"
  1709 + }
  1710 + },
  1711 + "type": "object"
  1712 + },
  1713 + "subscription": {
  1714 + "properties": {
  1715 + "expires_at": {
  1716 + "type": "nullable",
  1717 + "wrapped": {
  1718 + "type": "string"
  1719 + }
  1720 + },
  1721 + "level": {
  1722 + "type": "string"
  1723 + },
  1724 + "provider": {
  1725 + "type": "nullable",
  1726 + "wrapped": {
  1727 + "type": "string"
  1728 + }
  1729 + },
  1730 + "source": {
  1731 + "type": "nullable",
  1732 + "wrapped": {
  1733 + "type": "string"
  1734 + }
  1735 + },
  1736 + "status": {
  1737 + "options": [
  1738 + "active",
  1739 + "canceled",
  1740 + "expired",
  1741 + "none"
  1742 + ],
  1743 + "type": "picklist"
  1744 + }
  1745 + },
  1746 + "type": "object"
  1747 + },
  1748 + "user": {
  1749 + "properties": {
  1750 + "account_locale": {
  1751 + "type": "nullable",
  1752 + "wrapped": {
  1753 + "type": "string"
  1754 + }
  1755 + },
  1756 + "account_type": {
  1757 + "options": [
  1758 + "guest",
  1759 + "registered"
  1760 + ],
  1761 + "type": "picklist"
  1762 + },
  1763 + "avatar_url": {
  1764 + "type": "nullable",
  1765 + "wrapped": {
  1766 + "type": "string"
  1767 + }
  1768 + },
  1769 + "created_at": {
  1770 + "type": "string"
  1771 + },
  1772 + "display_name": {
  1773 + "type": "nullable",
  1774 + "wrapped": {
  1775 + "type": "string"
  1776 + }
  1777 + },
  1778 + "email": {
  1779 + "type": "nullable",
  1780 + "wrapped": {
  1781 + "type": "string"
  1782 + }
  1783 + },
  1784 + "email_verified_at": {
  1785 + "type": "nullable",
  1786 + "wrapped": {
  1787 + "type": "string"
  1788 + }
  1789 + },
  1790 + "id": {
  1791 + "type": "string"
  1792 + },
  1793 + "nickname": {
  1794 + "type": "nullable",
  1795 + "wrapped": {
  1796 + "type": "string"
  1797 + }
  1798 + },
  1799 + "profile_locale": {
  1800 + "type": "nullable",
  1801 + "wrapped": {
  1802 + "type": "string"
  1803 + }
  1804 + }
  1805 + },
  1806 + "type": "object"
  1807 + }
  1808 + },
  1809 + "type": "object"
  1810 + }
  1811 + },
  1812 + "type": "object"
  1813 + },
  1814 + "VisualGameAdminUserCreditsAdjustRequest": {
  1815 + "properties": {
  1816 + "amount": {
  1817 + "type": "number"
  1818 + },
  1819 + "mutation_id": {
  1820 + "type": "string"
  1821 + },
  1822 + "reason": {
  1823 + "type": "string"
  1824 + },
  1825 + "type": {
  1826 + "options": [
  1827 + "deduct",
  1828 + "grant"
  1829 + ],
  1830 + "type": "picklist"
  1831 + }
  1832 + },
  1833 + "type": "object"
  1834 + },
  1835 + "VisualGameAdminUserDetailResponse": {
  1836 + "properties": {
  1837 + "detail": {
  1838 + "properties": {
  1839 + "audit_logs": {
  1840 + "item": {
  1841 + "properties": {
  1842 + "action": {
  1843 + "options": [
  1844 + "account_provisioned",
  1845 + "credits_deducted",
  1846 + "credits_granted",
  1847 + "profile_updated",
  1848 + "subscription_updated"
  1849 + ],
  1850 + "type": "picklist"
  1851 + },
  1852 + "created_at": {
  1853 + "type": "string"
  1854 + },
  1855 + "id": {
  1856 + "type": "string"
  1857 + },
  1858 + "operator_id": {
  1859 + "type": "string"
  1860 + },
  1861 + "reason": {
  1862 + "type": "string"
  1863 + }
  1864 + },
  1865 + "type": "object"
  1866 + },
  1867 + "type": "array"
  1868 + },
  1869 + "credits": {
  1870 + "properties": {
  1871 + "balance": {
  1872 + "type": "number"
  1873 + },
  1874 + "ledger": {
  1875 + "item": {
  1876 + "properties": {
  1877 + "amount": {
  1878 + "type": "number"
  1879 + },
  1880 + "balance_after": {
  1881 + "type": "number"
  1882 + },
  1883 + "created_at": {
  1884 + "type": "string"
  1885 + },
  1886 + "id": {
  1887 + "type": "string"
  1888 + },
  1889 + "reason": {
  1890 + "type": "nullable",
  1891 + "wrapped": {
  1892 + "type": "string"
  1893 + }
  1894 + },
  1895 + "ref_id": {
  1896 + "type": "nullable",
  1897 + "wrapped": {
  1898 + "type": "string"
  1899 + }
  1900 + },
  1901 + "ref_type": {
  1902 + "type": "nullable",
  1903 + "wrapped": {
  1904 + "type": "string"
  1905 + }
  1906 + },
  1907 + "type": {
  1908 + "options": [
  1909 + "deduct",
  1910 + "grant"
  1911 + ],
  1912 + "type": "picklist"
  1913 + }
  1914 + },
  1915 + "type": "object"
  1916 + },
  1917 + "type": "array"
  1918 + }
  1919 + },
  1920 + "type": "object"
  1921 + },
  1922 + "guest_devices": {
  1923 + "item": {
  1924 + "properties": {
  1925 + "last_seen_at": {
  1926 + "type": "nullable",
  1927 + "wrapped": {
  1928 + "type": "string"
  1929 + }
  1930 + },
  1931 + "merged_user_id": {
  1932 + "type": "nullable",
  1933 + "wrapped": {
  1934 + "type": "string"
  1935 + }
  1936 + },
  1937 + "status": {
  1938 + "options": [
  1939 + "active",
  1940 + "merged"
  1941 + ],
  1942 + "type": "picklist"
  1943 + }
  1944 + },
  1945 + "type": "object"
  1946 + },
  1947 + "type": "array"
  1948 + },
  1949 + "orders": {
  1950 + "item": {
  1951 + "properties": {
  1952 + "amount_cents": {
  1953 + "type": "nullable",
  1954 + "wrapped": {
  1955 + "type": "number"
  1956 + }
  1957 + },
  1958 + "created_at": {
  1959 + "type": "string"
  1960 + },
  1961 + "currency": {
  1962 + "type": "nullable",
  1963 + "wrapped": {
  1964 + "type": "string"
  1965 + }
  1966 + },
  1967 + "id": {
  1968 + "type": "string"
  1969 + },
  1970 + "mode": {
  1971 + "options": [
  1972 + "payment",
  1973 + "subscription"
  1974 + ],
  1975 + "type": "picklist"
  1976 + },
  1977 + "product_id": {
  1978 + "type": "string"
  1979 + },
  1980 + "provider": {
  1981 + "options": [
  1982 + "apple",
  1983 + "google",
  1984 + "stripe"
  1985 + ],
  1986 + "type": "picklist"
  1987 + },
  1988 + "refund_amount": {
  1989 + "type": "nullable",
  1990 + "wrapped": {
  1991 + "type": "number"
  1992 + }
  1993 + },
  1994 + "status": {
  1995 + "options": [
  1996 + "canceled",
  1997 + "disputed",
  1998 + "failed",
  1999 + "paid",
  2000 + "pending",
  2001 + "refunded"
  2002 + ],
  2003 + "type": "picklist"
  2004 + },
  2005 + "updated_at": {
  2006 + "type": "nullable",
  2007 + "wrapped": {
  2008 + "type": "string"
  2009 + }
  2010 + }
  2011 + },
  2012 + "type": "object"
  2013 + },
  2014 + "type": "array"
  2015 + },
  2016 + "play": {
  2017 + "properties": {
  2018 + "recent_runs": {
  2019 + "item": {
  2020 + "properties": {
  2021 + "completed_at": {
  2022 + "type": "nullable",
  2023 + "wrapped": {
  2024 + "type": "string"
  2025 + }
  2026 + },
  2027 + "created_at": {
  2028 + "type": "string"
  2029 + },
  2030 + "current_location_id": {
  2031 + "type": "nullable",
  2032 + "wrapped": {
  2033 + "type": "string"
  2034 + }
  2035 + },
  2036 + "id": {
  2037 + "type": "string"
  2038 + },
  2039 + "phase": {
  2040 + "type": "nullable",
  2041 + "wrapped": {
  2042 + "type": "string"
  2043 + }
  2044 + },
  2045 + "scenario_id": {
  2046 + "type": "string"
  2047 + },
  2048 + "scenario_title": {
  2049 + "type": "nullable",
  2050 + "wrapped": {
  2051 + "type": "string"
  2052 + }
  2053 + },
  2054 + "status": {
  2055 + "options": [
  2056 + "active",
  2057 + "completed"
  2058 + ],
  2059 + "type": "picklist"
  2060 + },
  2061 + "turn_count": {
  2062 + "type": "number"
  2063 + },
  2064 + "updated_at": {
  2065 + "type": "nullable",
  2066 + "wrapped": {
  2067 + "type": "string"
  2068 + }
  2069 + }
  2070 + },
  2071 + "type": "object"
  2072 + },
  2073 + "type": "array"
  2074 + },
  2075 + "summary": {
  2076 + "properties": {
  2077 + "act_turns": {
  2078 + "type": "number"
  2079 + },
  2080 + "active_runs": {
  2081 + "type": "number"
  2082 + },
  2083 + "completed_runs": {
  2084 + "type": "number"
  2085 + },
  2086 + "completed_seasons": {
  2087 + "type": "number"
  2088 + },
  2089 + "credits_granted": {
  2090 + "type": "number"
  2091 + },
  2092 + "credits_spent": {
  2093 + "type": "number"
  2094 + },
  2095 + "endings": {
  2096 + "type": "number"
  2097 + },
  2098 + "last_active_at": {
  2099 + "type": "nullable",
  2100 + "wrapped": {
  2101 + "type": "string"
  2102 + }
  2103 + },
  2104 + "progress_records": {
  2105 + "type": "number"
  2106 + },
  2107 + "runs": {
  2108 + "type": "number"
  2109 + },
  2110 + "save_slots": {
  2111 + "type": "number"
  2112 + },
  2113 + "say_turns": {
  2114 + "type": "number"
  2115 + },
  2116 + "scenarios": {
  2117 + "type": "number"
  2118 + },
  2119 + "turns": {
  2120 + "type": "number"
  2121 + }
  2122 + },
  2123 + "type": "object"
  2124 + }
  2125 + },
  2126 + "type": "object"
  2127 + },
  2128 + "subscription": {
  2129 + "properties": {
  2130 + "expires_at": {
  2131 + "type": "nullable",
  2132 + "wrapped": {
  2133 + "type": "string"
  2134 + }
  2135 + },
  2136 + "level": {
  2137 + "type": "string"
  2138 + },
  2139 + "provider": {
  2140 + "type": "nullable",
  2141 + "wrapped": {
  2142 + "type": "string"
  2143 + }
  2144 + },
  2145 + "source": {
  2146 + "type": "nullable",
  2147 + "wrapped": {
  2148 + "type": "string"
  2149 + }
  2150 + },
  2151 + "status": {
  2152 + "options": [
  2153 + "active",
  2154 + "canceled",
  2155 + "expired",
  2156 + "none"
  2157 + ],
  2158 + "type": "picklist"
  2159 + }
  2160 + },
  2161 + "type": "object"
  2162 + },
  2163 + "user": {
  2164 + "properties": {
  2165 + "account_locale": {
  2166 + "type": "nullable",
  2167 + "wrapped": {
  2168 + "type": "string"
  2169 + }
  2170 + },
  2171 + "account_type": {
  2172 + "options": [
  2173 + "guest",
  2174 + "registered"
  2175 + ],
  2176 + "type": "picklist"
  2177 + },
  2178 + "avatar_url": {
  2179 + "type": "nullable",
  2180 + "wrapped": {
  2181 + "type": "string"
  2182 + }
  2183 + },
  2184 + "created_at": {
  2185 + "type": "string"
  2186 + },
  2187 + "display_name": {
  2188 + "type": "nullable",
  2189 + "wrapped": {
  2190 + "type": "string"
  2191 + }
  2192 + },
  2193 + "email": {
  2194 + "type": "nullable",
  2195 + "wrapped": {
  2196 + "type": "string"
  2197 + }
  2198 + },
  2199 + "email_verified_at": {
  2200 + "type": "nullable",
  2201 + "wrapped": {
  2202 + "type": "string"
  2203 + }
  2204 + },
  2205 + "id": {
  2206 + "type": "string"
  2207 + },
  2208 + "nickname": {
  2209 + "type": "nullable",
  2210 + "wrapped": {
  2211 + "type": "string"
  2212 + }
  2213 + },
  2214 + "profile_locale": {
  2215 + "type": "nullable",
  2216 + "wrapped": {
  2217 + "type": "string"
  2218 + }
  2219 + }
  2220 + },
  2221 + "type": "object"
  2222 + }
  2223 + },
  2224 + "type": "object"
  2225 + }
  2226 + },
  2227 + "type": "object"
  2228 + },
  2229 + "VisualGameAdminUserListResponse": {
  2230 + "properties": {
  2231 + "items": {
  2232 + "item": {
  2233 + "properties": {
  2234 + "account_type": {
  2235 + "options": [
  2236 + "guest",
  2237 + "registered"
  2238 + ],
  2239 + "type": "picklist"
  2240 + },
  2241 + "created_at": {
  2242 + "type": "string"
  2243 + },
  2244 + "credits": {
  2245 + "type": "number"
  2246 + },
  2247 + "display_name": {
  2248 + "type": "nullable",
  2249 + "wrapped": {
  2250 + "type": "string"
  2251 + }
  2252 + },
  2253 + "email": {
  2254 + "type": "nullable",
  2255 + "wrapped": {
  2256 + "type": "string"
  2257 + }
  2258 + },
  2259 + "id": {
  2260 + "type": "string"
  2261 + },
  2262 + "interaction_stats": {
  2263 + "properties": {
  2264 + "act_turns": {
  2265 + "type": "number"
  2266 + },
  2267 + "completed_runs": {
  2268 + "type": "number"
  2269 + },
  2270 + "completed_seasons": {
  2271 + "type": "number"
  2272 + },
  2273 + "credits_granted": {
  2274 + "type": "number"
  2275 + },
  2276 + "credits_spent": {
  2277 + "type": "number"
  2278 + },
  2279 + "interactions": {
  2280 + "type": "number"
  2281 + },
  2282 + "say_turns": {
  2283 + "type": "number"
  2284 + }
  2285 + },
  2286 + "type": "object"
  2287 + },
  2288 + "last_active_at": {
  2289 + "type": "nullable",
  2290 + "wrapped": {
  2291 + "type": "string"
  2292 + }
  2293 + },
  2294 + "locale": {
  2295 + "type": "nullable",
  2296 + "wrapped": {
  2297 + "type": "string"
  2298 + }
  2299 + },
  2300 + "stats": {
  2301 + "properties": {
  2302 + "endings": {
  2303 + "type": "number"
  2304 + },
  2305 + "orders": {
  2306 + "type": "number"
  2307 + },
  2308 + "runs": {
  2309 + "type": "number"
  2310 + },
  2311 + "turns": {
  2312 + "type": "number"
  2313 + }
  2314 + },
  2315 + "type": "object"
  2316 + },
  2317 + "subscription": {
  2318 + "properties": {
  2319 + "expires_at": {
  2320 + "type": "nullable",
  2321 + "wrapped": {
  2322 + "type": "string"
  2323 + }
  2324 + },
  2325 + "level": {
  2326 + "type": "string"
  2327 + },
  2328 + "provider": {
  2329 + "type": "nullable",
  2330 + "wrapped": {
  2331 + "type": "string"
  2332 + }
  2333 + },
  2334 + "source": {
  2335 + "type": "nullable",
  2336 + "wrapped": {
  2337 + "type": "string"
  2338 + }
  2339 + },
  2340 + "status": {
  2341 + "options": [
  2342 + "active",
  2343 + "canceled",
  2344 + "expired",
  2345 + "none"
  2346 + ],
  2347 + "type": "picklist"
  2348 + }
  2349 + },
  2350 + "type": "object"
  2351 + }
  2352 + },
  2353 + "type": "object"
  2354 + },
  2355 + "type": "array"
  2356 + },
  2357 + "next_cursor": {
  2358 + "type": "nullable",
  2359 + "wrapped": {
  2360 + "type": "string"
  2361 + }
  2362 + },
  2363 + "total": {
  2364 + "type": "number"
  2365 + }
  2366 + },
  2367 + "type": "object"
  2368 + },
  2369 + "VisualGameAdminUserProfileUpdateRequest": {
  2370 + "properties": {
  2371 + "avatar_url": {
  2372 + "type": "nullable",
  2373 + "wrapped": {
  2374 + "type": "string"
  2375 + }
  2376 + },
  2377 + "locale": {
  2378 + "options": [
  2379 + "en",
  2380 + "es",
  2381 + "ja",
  2382 + "ko",
  2383 + "pt",
  2384 + "zh"
  2385 + ],
  2386 + "type": "picklist"
  2387 + },
  2388 + "mutation_id": {
  2389 + "type": "string"
  2390 + },
  2391 + "nickname": {
  2392 + "type": "nullable",
  2393 + "wrapped": {
  2394 + "type": "string"
  2395 + }
  2396 + },
  2397 + "reason": {
  2398 + "type": "string"
  2399 + }
  2400 + },
  2401 + "type": "object"
  2402 + },
  2403 + "VisualGameAdminUserProvisionRequest": {
  2404 + "properties": {
  2405 + "email": {
  2406 + "type": "string"
  2407 + },
  2408 + "initial_credits": {
  2409 + "type": "number"
  2410 + },
  2411 + "locale": {
  2412 + "options": [
  2413 + "en",
  2414 + "es",
  2415 + "ja",
  2416 + "ko",
  2417 + "pt",
  2418 + "zh"
  2419 + ],
  2420 + "type": "picklist"
  2421 + },
  2422 + "mutation_id": {
  2423 + "type": "string"
  2424 + },
  2425 + "password": {
  2426 + "type": "string"
  2427 + },
  2428 + "reason": {
  2429 + "type": "string"
  2430 + }
  2431 + },
  2432 + "type": "strict_object"
  2433 + },
  2434 + "VisualGameAdminUserSubscriptionUpdateRequest": {
  2435 + "properties": {
  2436 + "expires_at": {
  2437 + "type": "nullable",
  2438 + "wrapped": {
  2439 + "type": "string"
  2440 + }
  2441 + },
  2442 + "level": {
  2443 + "type": "string"
  2444 + },
  2445 + "mutation_id": {
  2446 + "type": "string"
  2447 + },
  2448 + "reason": {
  2449 + "type": "string"
  2450 + },
  2451 + "status": {
  2452 + "options": [
  2453 + "active",
  2454 + "canceled",
  2455 + "expired",
  2456 + "none"
  2457 + ],
  2458 + "type": "picklist"
  2459 + }
  2460 + },
  2461 + "type": "object"
  2462 + },
  2463 + "VisualGameAdminUserSummary": {
  2464 + "properties": {
  2465 + "guest_users": {
  2466 + "type": "number"
  2467 + },
  2468 + "paying_users": {
  2469 + "type": "number"
  2470 + },
  2471 + "players": {
  2472 + "type": "number"
  2473 + },
  2474 + "registered_users": {
  2475 + "type": "number"
  2476 + },
  2477 + "total_users": {
  2478 + "type": "number"
  2479 + }
  2480 + },
  2481 + "type": "object"
  2482 + },
  2483 + "VisualGameAgentCall": {
  2484 + "properties": {
  2485 + "agent_role": {
  2486 + "type": "string"
  2487 + },
  2488 + "cache_write_tokens": {
  2489 + "type": "nullable",
  2490 + "wrapped": {
  2491 + "type": "number"
  2492 + }
  2493 + },
  2494 + "cached_tokens": {
  2495 + "type": "nullable",
  2496 + "wrapped": {
  2497 + "type": "number"
  2498 + }
  2499 + },
  2500 + "completed_at": {
  2501 + "type": "optional",
  2502 + "wrapped": {
  2503 + "type": "unknown"
  2504 + }
  2505 + },
  2506 + "completion_tokens": {
  2507 + "type": "nullable",
  2508 + "wrapped": {
  2509 + "type": "number"
  2510 + }
  2511 + },
  2512 + "cost_cents": {
  2513 + "type": "optional",
  2514 + "wrapped": {
  2515 + "type": "nullable",
  2516 + "wrapped": {
  2517 + "type": "number"
  2518 + }
  2519 + }
  2520 + },
  2521 + "created_at": {
  2522 + "type": "optional",
  2523 + "wrapped": {
  2524 + "type": "unknown"
  2525 + }
  2526 + },
  2527 + "error_message": {
  2528 + "type": "optional",
  2529 + "wrapped": {
  2530 + "type": "nullable",
  2531 + "wrapped": {
  2532 + "type": "string"
  2533 + }
  2534 + }
  2535 + },
  2536 + "id": {
  2537 + "type": "string"
  2538 + },
  2539 + "model": {
  2540 + "type": "optional",
  2541 + "wrapped": {
  2542 + "type": "nullable",
  2543 + "wrapped": {
  2544 + "type": "string"
  2545 + }
  2546 + }
  2547 + },
  2548 + "prompt_hash": {
  2549 + "type": "optional",
  2550 + "wrapped": {
  2551 + "type": "nullable",
  2552 + "wrapped": {
  2553 + "type": "string"
  2554 + }
  2555 + }
  2556 + },
  2557 + "prompt_tokens": {
  2558 + "type": "nullable",
  2559 + "wrapped": {
  2560 + "type": "number"
  2561 + }
  2562 + },
  2563 + "request": {
  2564 + "type": "optional",
  2565 + "wrapped": {
  2566 + "type": "unknown"
  2567 + }
  2568 + },
  2569 + "response": {
  2570 + "type": "optional",
  2571 + "wrapped": {
  2572 + "type": "unknown"
  2573 + }
  2574 + },
  2575 + "run_id": {
  2576 + "type": "string"
  2577 + },
  2578 + "status": {
  2579 + "type": "string"
  2580 + },
  2581 + "turn_id": {
  2582 + "type": "optional",
  2583 + "wrapped": {
  2584 + "type": "nullable",
  2585 + "wrapped": {
  2586 + "type": "string"
  2587 + }
  2588 + }
  2589 + }
  2590 + },
  2591 + "type": "object"
  2592 + },
  2593 + "VisualGameAgentCallList": {
  2594 + "properties": {
  2595 + "calls": {
  2596 + "item": {
  2597 + "properties": {
  2598 + "agent_role": {
  2599 + "type": "string"
  2600 + },
  2601 + "cache_write_tokens": {
  2602 + "type": "nullable",
  2603 + "wrapped": {
  2604 + "type": "number"
  2605 + }
  2606 + },
  2607 + "cached_tokens": {
  2608 + "type": "nullable",
  2609 + "wrapped": {
  2610 + "type": "number"
  2611 + }
  2612 + },
  2613 + "completed_at": {
  2614 + "type": "optional",
  2615 + "wrapped": {
  2616 + "type": "unknown"
  2617 + }
  2618 + },
  2619 + "completion_tokens": {
  2620 + "type": "nullable",
  2621 + "wrapped": {
  2622 + "type": "number"
  2623 + }
  2624 + },
  2625 + "cost_cents": {
  2626 + "type": "optional",
  2627 + "wrapped": {
  2628 + "type": "nullable",
  2629 + "wrapped": {
  2630 + "type": "number"
  2631 + }
  2632 + }
  2633 + },
  2634 + "created_at": {
  2635 + "type": "optional",
  2636 + "wrapped": {
  2637 + "type": "unknown"
  2638 + }
  2639 + },
  2640 + "error_message": {
  2641 + "type": "optional",
  2642 + "wrapped": {
  2643 + "type": "nullable",
  2644 + "wrapped": {
  2645 + "type": "string"
  2646 + }
  2647 + }
  2648 + },
  2649 + "id": {
  2650 + "type": "string"
  2651 + },
  2652 + "model": {
  2653 + "type": "optional",
  2654 + "wrapped": {
  2655 + "type": "nullable",
  2656 + "wrapped": {
  2657 + "type": "string"
  2658 + }
  2659 + }
  2660 + },
  2661 + "prompt_hash": {
  2662 + "type": "optional",
  2663 + "wrapped": {
  2664 + "type": "nullable",
  2665 + "wrapped": {
  2666 + "type": "string"
  2667 + }
  2668 + }
  2669 + },
  2670 + "prompt_tokens": {
  2671 + "type": "nullable",
  2672 + "wrapped": {
  2673 + "type": "number"
  2674 + }
  2675 + },
  2676 + "request": {
  2677 + "type": "optional",
  2678 + "wrapped": {
  2679 + "type": "unknown"
  2680 + }
  2681 + },
  2682 + "response": {
  2683 + "type": "optional",
  2684 + "wrapped": {
  2685 + "type": "unknown"
  2686 + }
  2687 + },
  2688 + "run_id": {
  2689 + "type": "string"
  2690 + },
  2691 + "status": {
  2692 + "type": "string"
  2693 + },
  2694 + "turn_id": {
  2695 + "type": "optional",
  2696 + "wrapped": {
  2697 + "type": "nullable",
  2698 + "wrapped": {
  2699 + "type": "string"
  2700 + }
  2701 + }
  2702 + }
  2703 + },
  2704 + "type": "object"
  2705 + },
  2706 + "type": "array"
  2707 + },
  2708 + "summary": {
  2709 + "properties": {
  2710 + "failed_calls": {
  2711 + "type": "number"
  2712 + },
  2713 + "models": {
  2714 + "item": {
  2715 + "properties": {
  2716 + "failed_calls": {
  2717 + "type": "number"
  2718 + },
  2719 + "model": {
  2720 + "type": "string"
  2721 + },
  2722 + "total_calls": {
  2723 + "type": "number"
  2724 + },
  2725 + "total_completion_tokens": {
  2726 + "type": "number"
  2727 + },
  2728 + "total_cost_cents": {
  2729 + "type": "number"
  2730 + },
  2731 + "total_prompt_tokens": {
  2732 + "type": "number"
  2733 + }
  2734 + },
  2735 + "type": "object"
  2736 + },
  2737 + "type": "array"
  2738 + },
  2739 + "roles": {
  2740 + "item": {
  2741 + "properties": {
  2742 + "failed_calls": {
  2743 + "type": "number"
  2744 + },
  2745 + "role": {
  2746 + "type": "string"
  2747 + },
  2748 + "total_calls": {
  2749 + "type": "number"
  2750 + },
  2751 + "total_completion_tokens": {
  2752 + "type": "number"
  2753 + },
  2754 + "total_cost_cents": {
  2755 + "type": "number"
  2756 + },
  2757 + "total_prompt_tokens": {
  2758 + "type": "number"
  2759 + }
  2760 + },
  2761 + "type": "object"
  2762 + },
  2763 + "type": "array"
  2764 + },
  2765 + "total_calls": {
  2766 + "type": "number"
  2767 + },
  2768 + "total_completion_tokens": {
  2769 + "type": "number"
  2770 + },
  2771 + "total_cost_cents": {
  2772 + "type": "number"
  2773 + },
  2774 + "total_prompt_tokens": {
  2775 + "type": "number"
  2776 + }
  2777 + },
  2778 + "type": "object"
  2779 + }
  2780 + },
  2781 + "type": "object"
  2782 + },
  2783 + "VisualGameAgentCallModelSummary": {
  2784 + "properties": {
  2785 + "failed_calls": {
  2786 + "type": "number"
  2787 + },
  2788 + "model": {
  2789 + "type": "string"
  2790 + },
  2791 + "total_calls": {
  2792 + "type": "number"
  2793 + },
  2794 + "total_completion_tokens": {
  2795 + "type": "number"
  2796 + },
  2797 + "total_cost_cents": {
  2798 + "type": "number"
  2799 + },
  2800 + "total_prompt_tokens": {
  2801 + "type": "number"
  2802 + }
  2803 + },
  2804 + "type": "object"
  2805 + },
  2806 + "VisualGameAgentCallRoleSummary": {
  2807 + "properties": {
  2808 + "failed_calls": {
  2809 + "type": "number"
  2810 + },
  2811 + "role": {
  2812 + "type": "string"
  2813 + },
  2814 + "total_calls": {
  2815 + "type": "number"
  2816 + },
  2817 + "total_completion_tokens": {
  2818 + "type": "number"
  2819 + },
  2820 + "total_cost_cents": {
  2821 + "type": "number"
  2822 + },
  2823 + "total_prompt_tokens": {
  2824 + "type": "number"
  2825 + }
  2826 + },
  2827 + "type": "object"
  2828 + },
  2829 + "VisualGameAgentCallSummary": {
  2830 + "properties": {
  2831 + "failed_calls": {
  2832 + "type": "number"
  2833 + },
  2834 + "models": {
  2835 + "item": {
  2836 + "properties": {
  2837 + "failed_calls": {
  2838 + "type": "number"
  2839 + },
  2840 + "model": {
  2841 + "type": "string"
  2842 + },
  2843 + "total_calls": {
  2844 + "type": "number"
  2845 + },
  2846 + "total_completion_tokens": {
  2847 + "type": "number"
  2848 + },
  2849 + "total_cost_cents": {
  2850 + "type": "number"
  2851 + },
  2852 + "total_prompt_tokens": {
  2853 + "type": "number"
  2854 + }
  2855 + },
  2856 + "type": "object"
  2857 + },
  2858 + "type": "array"
  2859 + },
  2860 + "roles": {
  2861 + "item": {
  2862 + "properties": {
  2863 + "failed_calls": {
  2864 + "type": "number"
  2865 + },
  2866 + "role": {
  2867 + "type": "string"
  2868 + },
  2869 + "total_calls": {
  2870 + "type": "number"
  2871 + },
  2872 + "total_completion_tokens": {
  2873 + "type": "number"
  2874 + },
  2875 + "total_cost_cents": {
  2876 + "type": "number"
  2877 + },
  2878 + "total_prompt_tokens": {
  2879 + "type": "number"
  2880 + }
  2881 + },
  2882 + "type": "object"
  2883 + },
  2884 + "type": "array"
  2885 + },
  2886 + "total_calls": {
  2887 + "type": "number"
  2888 + },
  2889 + "total_completion_tokens": {
  2890 + "type": "number"
  2891 + },
  2892 + "total_cost_cents": {
  2893 + "type": "number"
  2894 + },
  2895 + "total_prompt_tokens": {
  2896 + "type": "number"
  2897 + }
  2898 + },
  2899 + "type": "object"
  2900 + },
  2901 + "VisualGameAssetIndexItem": {
  2902 + "properties": {
  2903 + "file": {
  2904 + "type": "optional",
  2905 + "wrapped": {
  2906 + "type": "string"
  2907 + }
  2908 + },
  2909 + "hash": {
  2910 + "type": "optional",
  2911 + "wrapped": {
  2912 + "type": "nullable",
  2913 + "wrapped": {
  2914 + "type": "string"
  2915 + }
  2916 + }
  2917 + },
  2918 + "height": {
  2919 + "type": "optional",
  2920 + "wrapped": {
  2921 + "type": "nullable",
  2922 + "wrapped": {
  2923 + "type": "number"
  2924 + }
  2925 + }
  2926 + },
  2927 + "id": {
  2928 + "type": "optional",
  2929 + "wrapped": {
  2930 + "type": "string"
  2931 + }
  2932 + },
  2933 + "manifest_id": {
  2934 + "type": "string"
  2935 + },
  2936 + "mime": {
  2937 + "type": "optional",
  2938 + "wrapped": {
  2939 + "type": "nullable",
  2940 + "wrapped": {
  2941 + "type": "string"
  2942 + }
  2943 + }
  2944 + },
  2945 + "object_key": {
  2946 + "type": "optional",
  2947 + "wrapped": {
  2948 + "type": "nullable",
  2949 + "wrapped": {
  2950 + "type": "string"
  2951 + }
  2952 + }
  2953 + },
  2954 + "public_url": {
  2955 + "type": "optional",
  2956 + "wrapped": {
  2957 + "type": "string"
  2958 + }
  2959 + },
  2960 + "size": {
  2961 + "type": "optional",
  2962 + "wrapped": {
  2963 + "type": "nullable",
  2964 + "wrapped": {
  2965 + "type": "number"
  2966 + }
  2967 + }
  2968 + },
  2969 + "tag": {
  2970 + "type": "string"
  2971 + },
  2972 + "type": {
  2973 + "type": "string"
  2974 + },
  2975 + "width": {
  2976 + "type": "optional",
  2977 + "wrapped": {
  2978 + "type": "nullable",
  2979 + "wrapped": {
  2980 + "type": "number"
  2981 + }
  2982 + }
  2983 + }
  2984 + },
  2985 + "type": "object"
  2986 + },
  2987 + "VisualGameAssetManifest": {
  2988 + "properties": {
  2989 + "audio_rules": {
  2990 + "type": "optional",
  2991 + "wrapped": {
  2992 + "type": "unknown"
  2993 + }
  2994 + },
  2995 + "id": {
  2996 + "type": "string"
  2997 + },
  2998 + "stage_rules": {
  2999 + "type": "optional",
  3000 + "wrapped": {
  3001 + "type": "unknown"
  3002 + }
  3003 + },
  3004 + "tags": {
  3005 + "item": {
  3006 + "properties": {
  3007 + "desc": {
  3008 + "type": "optional",
  3009 + "wrapped": {
  3010 + "type": "string"
  3011 + }
  3012 + },
  3013 + "file": {
  3014 + "type": "string"
  3015 + },
  3016 + "hash": {
  3017 + "type": "optional",
  3018 + "wrapped": {
  3019 + "type": "string"
  3020 + }
  3021 + },
  3022 + "mime": {
  3023 + "type": "optional",
  3024 + "wrapped": {
  3025 + "type": "string"
  3026 + }
  3027 + },
  3028 + "object_key": {
  3029 + "type": "optional",
  3030 + "wrapped": {
  3031 + "type": "string"
  3032 + }
  3033 + },
  3034 + "public_url": {
  3035 + "type": "optional",
  3036 + "wrapped": {
  3037 + "type": "string"
  3038 + }
  3039 + },
  3040 + "size": {
  3041 + "type": "optional",
  3042 + "wrapped": {
  3043 + "type": "nullable",
  3044 + "wrapped": {
  3045 + "type": "number"
  3046 + }
  3047 + }
  3048 + },
  3049 + "tag": {
  3050 + "type": "string"
  3051 + },
  3052 + "type": {
  3053 + "type": "string"
  3054 + }
  3055 + },
  3056 + "type": "object"
  3057 + },
  3058 + "type": "array"
  3059 + }
  3060 + },
  3061 + "type": "object"
  3062 + },
  3063 + "VisualGameContentTranslationInventory": {
  3064 + "properties": {
  3065 + "base_version_id": {
  3066 + "type": "nullable",
  3067 + "wrapped": {
  3068 + "type": "string"
  3069 + }
  3070 + },
  3071 + "items": {
  3072 + "item": {
  3073 + "properties": {
  3074 + "field": {
  3075 + "type": "string"
  3076 + },
  3077 + "pointer": {
  3078 + "type": "string"
  3079 + },
  3080 + "section": {
  3081 + "type": "string"
  3082 + },
  3083 + "source_text": {
  3084 + "type": "string"
  3085 + },
  3086 + "status": {
  3087 + "options": [
  3088 + "pending",
  3089 + "reused",
  3090 + "source",
  3091 + "translated"
  3092 + ],
  3093 + "type": "picklist"
  3094 + },
  3095 + "translated_text": {
  3096 + "type": "nullable",
  3097 + "wrapped": {
  3098 + "type": "string"
  3099 + }
  3100 + }
  3101 + },
  3102 + "type": "object"
  3103 + },
  3104 + "type": "array"
  3105 + },
  3106 + "locale": {
  3107 + "options": [
  3108 + "en",
  3109 + "es",
  3110 + "ja",
  3111 + "ko",
  3112 + "pt",
  3113 + "zh-CN"
  3114 + ],
  3115 + "type": "picklist"
  3116 + },
  3117 + "pack_id": {
  3118 + "type": "string"
  3119 + },
  3120 + "status": {
  3121 + "options": [
  3122 + "missing",
  3123 + "ready",
  3124 + "source",
  3125 + "unsupported"
  3126 + ],
  3127 + "type": "picklist"
  3128 + },
  3129 + "version_id": {
  3130 + "type": "string"
  3131 + }
  3132 + },
  3133 + "type": "object"
  3134 + },
  3135 + "VisualGameContentTranslationInventoryItem": {
  3136 + "properties": {
  3137 + "field": {
  3138 + "type": "string"
  3139 + },
  3140 + "pointer": {
  3141 + "type": "string"
  3142 + },
  3143 + "section": {
  3144 + "type": "string"
  3145 + },
  3146 + "source_text": {
  3147 + "type": "string"
  3148 + },
  3149 + "status": {
  3150 + "options": [
  3151 + "pending",
  3152 + "reused",
  3153 + "source",
  3154 + "translated"
  3155 + ],
  3156 + "type": "picklist"
  3157 + },
  3158 + "translated_text": {
  3159 + "type": "nullable",
  3160 + "wrapped": {
  3161 + "type": "string"
  3162 + }
  3163 + }
  3164 + },
  3165 + "type": "object"
  3166 + },
  3167 + "VisualGameContentTranslationLocaleStatus": {
  3168 + "properties": {
  3169 + "base_version_id": {
  3170 + "type": "nullable",
  3171 + "wrapped": {
  3172 + "type": "string"
  3173 + }
  3174 + },
  3175 + "locale": {
  3176 + "options": [
  3177 + "en",
  3178 + "es",
  3179 + "ja",
  3180 + "ko",
  3181 + "pt",
  3182 + "zh-CN"
  3183 + ],
  3184 + "type": "picklist"
  3185 + },
  3186 + "pending_text_count": {
  3187 + "type": "number"
  3188 + },
  3189 + "replace_required": {
  3190 + "type": "boolean"
  3191 + },
  3192 + "reusable_text_count": {
  3193 + "type": "number"
  3194 + },
  3195 + "status": {
  3196 + "options": [
  3197 + "missing",
  3198 + "ready",
  3199 + "saving",
  3200 + "source",
  3201 + "translating",
  3202 + "unsupported"
  3203 + ],
  3204 + "type": "picklist"
  3205 + },
  3206 + "updated_at": {
  3207 + "type": "nullable",
  3208 + "wrapped": {
  3209 + "type": "unknown"
  3210 + }
  3211 + }
  3212 + },
  3213 + "type": "object"
  3214 + },
  3215 + "VisualGameContentTranslationProgress": {
  3216 + "properties": {
  3217 + "completed_text_count": {
  3218 + "type": "number"
  3219 + },
  3220 + "error_message": {
  3221 + "type": "optional",
  3222 + "wrapped": {
  3223 + "type": "nullable",
  3224 + "wrapped": {
  3225 + "type": "string"
  3226 + }
  3227 + }
  3228 + },
  3229 + "locale": {
  3230 + "options": [
  3231 + "en",
  3232 + "es",
  3233 + "ja",
  3234 + "ko",
  3235 + "pt",
  3236 + "zh-CN"
  3237 + ],
  3238 + "type": "picklist"
  3239 + },
  3240 + "pack_id": {
  3241 + "type": "string"
  3242 + },
  3243 + "status": {
  3244 + "options": [
  3245 + "completed",
  3246 + "failed",
  3247 + "idle",
  3248 + "saving",
  3249 + "translating"
  3250 + ],
  3251 + "type": "picklist"
  3252 + },
  3253 + "total_text_count": {
  3254 + "type": "number"
  3255 + },
  3256 + "version_id": {
  3257 + "type": "string"
  3258 + }
  3259 + },
  3260 + "type": "object"
  3261 + },
  3262 + "VisualGameContentTranslationResult": {
  3263 + "properties": {
  3264 + "base_version_id": {
  3265 + "type": "optional",
  3266 + "wrapped": {
  3267 + "type": "nullable",
  3268 + "wrapped": {
  3269 + "type": "string"
  3270 + }
  3271 + }
  3272 + },
  3273 + "locale": {
  3274 + "options": [
  3275 + "en",
  3276 + "es",
  3277 + "ja",
  3278 + "ko",
  3279 + "pt",
  3280 + "zh-CN"
  3281 + ],
  3282 + "type": "picklist"
  3283 + },
  3284 + "pack_id": {
  3285 + "type": "string"
  3286 + },
  3287 + "reused_text_count": {
  3288 + "type": "optional",
  3289 + "wrapped": {
  3290 + "type": "number"
  3291 + }
  3292 + },
  3293 + "status": {
  3294 + "options": [
  3295 + "skipped",
  3296 + "translated"
  3297 + ],
  3298 + "type": "picklist"
  3299 + },
  3300 + "text_count": {
  3301 + "type": "optional",
  3302 + "wrapped": {
  3303 + "type": "number"
  3304 + }
  3305 + },
  3306 + "translated_text_count": {
  3307 + "type": "optional",
  3308 + "wrapped": {
  3309 + "type": "number"
  3310 + }
  3311 + },
  3312 + "version_id": {
  3313 + "type": "string"
  3314 + }
  3315 + },
  3316 + "type": "object"
  3317 + },
  3318 + "VisualGameContentTranslationStatus": {
  3319 + "properties": {
  3320 + "locales": {
  3321 + "item": {
  3322 + "properties": {
  3323 + "base_version_id": {
  3324 + "type": "nullable",
  3325 + "wrapped": {
  3326 + "type": "string"
  3327 + }
  3328 + },
  3329 + "locale": {
  3330 + "options": [
  3331 + "en",
  3332 + "es",
  3333 + "ja",
  3334 + "ko",
  3335 + "pt",
  3336 + "zh-CN"
  3337 + ],
  3338 + "type": "picklist"
  3339 + },
  3340 + "pending_text_count": {
  3341 + "type": "number"
  3342 + },
  3343 + "replace_required": {
  3344 + "type": "boolean"
  3345 + },
  3346 + "reusable_text_count": {
  3347 + "type": "number"
  3348 + },
  3349 + "status": {
  3350 + "options": [
  3351 + "missing",
  3352 + "ready",
  3353 + "saving",
  3354 + "source",
  3355 + "translating",
  3356 + "unsupported"
  3357 + ],
  3358 + "type": "picklist"
  3359 + },
  3360 + "updated_at": {
  3361 + "type": "nullable",
  3362 + "wrapped": {
  3363 + "type": "unknown"
  3364 + }
  3365 + }
  3366 + },
  3367 + "type": "object"
  3368 + },
  3369 + "type": "array"
  3370 + },
  3371 + "pack_id": {
  3372 + "type": "string"
  3373 + },
  3374 + "source_locale": {
  3375 + "options": [
  3376 + "en",
  3377 + "es",
  3378 + "ja",
  3379 + "ko",
  3380 + "pt",
  3381 + "zh-CN"
  3382 + ],
  3383 + "type": "picklist"
  3384 + },
  3385 + "source_text_count": {
  3386 + "type": "number"
  3387 + },
  3388 + "supported": {
  3389 + "type": "boolean"
  3390 + },
  3391 + "version_id": {
  3392 + "type": "string"
  3393 + }
  3394 + },
  3395 + "type": "object"
  3396 + },
  3397 + "VisualGameCreateRunResponse": {
  3398 + "properties": {
  3399 + "available_clues": {
  3400 + "item": {
  3401 + "type": "unknown"
  3402 + },
  3403 + "type": "array"
  3404 + },
  3405 + "available_conclusions": {
  3406 + "type": "optional",
  3407 + "wrapped": {
  3408 + "item": {
  3409 + "type": "unknown"
  3410 + },
  3411 + "type": "array"
  3412 + }
  3413 + },
  3414 + "known_clues": {
  3415 + "item": {
  3416 + "type": "unknown"
  3417 + },
  3418 + "type": "array"
  3419 + },
  3420 + "known_conclusions": {
  3421 + "type": "optional",
  3422 + "wrapped": {
  3423 + "item": {
  3424 + "type": "unknown"
  3425 + },
  3426 + "type": "array"
  3427 + }
  3428 + },
  3429 + "run": {
  3430 + "type": "unknown"
  3431 + },
  3432 + "run_id": {
  3433 + "type": "string"
  3434 + },
  3435 + "scenario": {
  3436 + "type": "unknown"
  3437 + },
  3438 + "unlocked_info": {
  3439 + "item": {
  3440 + "type": "unknown"
  3441 + },
  3442 + "type": "array"
  3443 + }
  3444 + },
  3445 + "type": "object"
  3446 + },
  3447 + "VisualGameEditAuditLog": {
  3448 + "properties": {
  3449 + "action": {
  3450 + "type": "string"
  3451 + },
  3452 + "actor_id": {
  3453 + "type": "optional",
  3454 + "wrapped": {
  3455 + "type": "nullable",
  3456 + "wrapped": {
  3457 + "type": "string"
  3458 + }
  3459 + }
  3460 + },
  3461 + "after": {
  3462 + "type": "optional",
  3463 + "wrapped": {
  3464 + "type": "unknown"
  3465 + }
  3466 + },
  3467 + "before": {
  3468 + "type": "optional",
  3469 + "wrapped": {
  3470 + "type": "unknown"
  3471 + }
  3472 + },
  3473 + "created_at": {
  3474 + "type": "optional",
  3475 + "wrapped": {
  3476 + "type": "unknown"
  3477 + }
  3478 + },
  3479 + "draft_id": {
  3480 + "type": "string"
  3481 + },
  3482 + "id": {
  3483 + "type": "string"
  3484 + },
  3485 + "target_id": {
  3486 + "type": "optional",
  3487 + "wrapped": {
  3488 + "type": "nullable",
  3489 + "wrapped": {
  3490 + "type": "string"
  3491 + }
  3492 + }
  3493 + },
  3494 + "target_type": {
  3495 + "type": "string"
  3496 + }
  3497 + },
  3498 + "type": "object"
  3499 + },
  3500 + "VisualGameEditAuditLogList": {
  3501 + "properties": {
  3502 + "logs": {
  3503 + "item": {
  3504 + "properties": {
  3505 + "action": {
  3506 + "type": "string"
  3507 + },
  3508 + "actor_id": {
  3509 + "type": "optional",
  3510 + "wrapped": {
  3511 + "type": "nullable",
  3512 + "wrapped": {
  3513 + "type": "string"
  3514 + }
  3515 + }
  3516 + },
  3517 + "after": {
  3518 + "type": "optional",
  3519 + "wrapped": {
  3520 + "type": "unknown"
  3521 + }
  3522 + },
  3523 + "before": {
  3524 + "type": "optional",
  3525 + "wrapped": {
  3526 + "type": "unknown"
  3527 + }
  3528 + },
  3529 + "created_at": {
  3530 + "type": "optional",
  3531 + "wrapped": {
  3532 + "type": "unknown"
  3533 + }
  3534 + },
  3535 + "draft_id": {
  3536 + "type": "string"
  3537 + },
  3538 + "id": {
  3539 + "type": "string"
  3540 + },
  3541 + "target_id": {
  3542 + "type": "optional",
  3543 + "wrapped": {
  3544 + "type": "nullable",
  3545 + "wrapped": {
  3546 + "type": "string"
  3547 + }
  3548 + }
  3549 + },
  3550 + "target_type": {
  3551 + "type": "string"
  3552 + }
  3553 + },
  3554 + "type": "object"
  3555 + },
  3556 + "type": "array"
  3557 + }
  3558 + },
  3559 + "type": "object"
  3560 + },
  3561 + "VisualGameEndingText": {
  3562 + "properties": {
  3563 + "generated_at": {
  3564 + "type": "optional",
  3565 + "wrapped": {
  3566 + "type": "string"
  3567 + }
  3568 + },
  3569 + "poem": {
  3570 + "type": "string"
  3571 + },
  3572 + "stats": {
  3573 + "type": "unknown"
  3574 + },
  3575 + "summary": {
  3576 + "type": "string"
  3577 + },
  3578 + "title": {
  3579 + "type": "string"
  3580 + }
  3581 + },
  3582 + "type": "object"
  3583 + },
  3584 + "VisualGameKpAgentOutput": {
  3585 + "properties": {
  3586 + "action": {
  3587 + "properties": {
  3588 + "claims": {
  3589 + "item": {
  3590 + "type": "string"
  3591 + },
  3592 + "type": "array"
  3593 + },
  3594 + "intent": {
  3595 + "options": [
  3596 + "accept",
  3597 + "approach",
  3598 + "attack",
  3599 + "comfort",
  3600 + "confront",
  3601 + "deceive",
  3602 + "destroy",
  3603 + "escape",
  3604 + "investigate",
  3605 + "move",
  3606 + "question",
  3607 + "refuse",
  3608 + "retreat",
  3609 + "surrender",
  3610 + "wait"
  3611 + ],
  3612 + "type": "picklist"
  3613 + },
  3614 + "risk": {
  3615 + "options": [
  3616 + "fatal",
  3617 + "high",
  3618 + "low",
  3619 + "normal"
  3620 + ],
  3621 + "type": "picklist"
  3622 + },
  3623 + "stance": {
  3624 + "options": [
  3625 + "cautious",
  3626 + "deceptive",
  3627 + "hostile",
  3628 + "neutral",
  3629 + "trusting"
  3630 + ],
  3631 + "type": "picklist"
  3632 + },
  3633 + "targets": {
  3634 + "item": {
  3635 + "type": "string"
  3636 + },
  3637 + "type": "array"
  3638 + }
  3639 + },
  3640 + "type": "strict_object"
  3641 + },
  3642 + "branch_resolution": {
  3643 + "type": "optional",
  3644 + "wrapped": {
  3645 + "options": [
  3646 + {
  3647 + "properties": {
  3648 + "matched": {
  3649 + "type": "literal",
  3650 + "value": false
  3651 + }
  3652 + },
  3653 + "type": "strict_object"
  3654 + },
  3655 + {
  3656 + "properties": {
  3657 + "branch_id": {
  3658 + "type": "string"
  3659 + },
  3660 + "decision": {
  3661 + "type": "literal",
  3662 + "value": "continue"
  3663 + },
  3664 + "matched": {
  3665 + "type": "literal",
  3666 + "value": true
  3667 + },
  3668 + "summary": {
  3669 + "type": "string"
  3670 + }
  3671 + },
  3672 + "type": "strict_object"
  3673 + },
  3674 + {
  3675 + "properties": {
  3676 + "branch_id": {
  3677 + "type": "string"
  3678 + },
  3679 + "decision": {
  3680 + "type": "literal",
  3681 + "value": "resolve"
  3682 + },
  3683 + "matched": {
  3684 + "type": "literal",
  3685 + "value": true
  3686 + },
  3687 + "resolution_summary": {
  3688 + "type": "string"
  3689 + }
  3690 + },
  3691 + "type": "strict_object"
  3692 + }
  3693 + ],
  3694 + "type": "union"
  3695 + }
  3696 + },
  3697 + "clueJudgements": {
  3698 + "item": {
  3699 + "properties": {
  3700 + "basis": {
  3701 + "type": "string"
  3702 + },
  3703 + "clueId": {
  3704 + "type": "string"
  3705 + },
  3706 + "discovered": {
  3707 + "type": "boolean"
  3708 + }
  3709 + },
  3710 + "type": "strict_object"
  3711 + },
  3712 + "type": "array"
  3713 + },
  3714 + "expression_state": {
  3715 + "type": "nullable",
  3716 + "wrapped": {
  3717 + "type": "string"
  3718 + }
  3719 + },
  3720 + "judgementSuggestion": {
  3721 + "properties": {
  3722 + "evidenceAdded": {
  3723 + "type": "optional",
  3724 + "wrapped": {
  3725 + "item": {
  3726 + "type": "string"
  3727 + },
  3728 + "type": "array"
  3729 + }
  3730 + },
  3731 + "flagsSet": {
  3732 + "type": "optional",
  3733 + "wrapped": {
  3734 + "options": [
  3735 + {
  3736 + "type": "custom"
  3737 + },
  3738 + {
  3739 + "key": {
  3740 + "type": "string"
  3741 + },
  3742 + "type": "record",
  3743 + "value": {
  3744 + "options": [
  3745 + {
  3746 + "type": "string"
  3747 + },
  3748 + {
  3749 + "type": "number"
  3750 + },
  3751 + {
  3752 + "type": "boolean"
  3753 + }
  3754 + ],
  3755 + "type": "union"
  3756 + }
  3757 + }
  3758 + ],
  3759 + "type": "intersect"
  3760 + }
  3761 + },
  3762 + "inventoryChanges": {
  3763 + "type": "optional",
  3764 + "wrapped": {
  3765 + "properties": {
  3766 + "add": {
  3767 + "item": {
  3768 + "type": "string"
  3769 + },
  3770 + "type": "array"
  3771 + },
  3772 + "remove": {
  3773 + "item": {
  3774 + "type": "string"
  3775 + },
  3776 + "type": "array"
  3777 + }
  3778 + },
  3779 + "type": "strict_object"
  3780 + }
  3781 + },
  3782 + "locationChange": {
  3783 + "type": "optional",
  3784 + "wrapped": {
  3785 + "type": "string"
  3786 + }
  3787 + },
  3788 + "notes": {
  3789 + "type": "optional",
  3790 + "wrapped": {
  3791 + "item": {
  3792 + "type": "string"
  3793 + },
  3794 + "type": "array"
  3795 + }
  3796 + },
  3797 + "npcStateChanges": {
  3798 + "type": "optional",
  3799 + "wrapped": {
  3800 + "options": [
  3801 + {
  3802 + "type": "custom"
  3803 + },
  3804 + {
  3805 + "key": {
  3806 + "type": "string"
  3807 + },
  3808 + "type": "record",
  3809 + "value": {
  3810 + "properties": {
  3811 + "knownFacts": {
  3812 + "type": "optional",
  3813 + "wrapped": {
  3814 + "item": {
  3815 + "type": "string"
  3816 + },
  3817 + "type": "array"
  3818 + }
  3819 + },
  3820 + "locationId": {
  3821 + "type": "optional",
  3822 + "wrapped": {
  3823 + "type": "string"
  3824 + }
  3825 + },
  3826 + "mood": {
  3827 + "type": "optional",
  3828 + "wrapped": {
  3829 + "type": "string"
  3830 + }
  3831 + },
  3832 + "position": {
  3833 + "type": "optional",
  3834 + "wrapped": {
  3835 + "type": "string"
  3836 + }
  3837 + },
  3838 + "relationshipFlags": {
  3839 + "type": "optional",
  3840 + "wrapped": {
  3841 + "options": [
  3842 + {
  3843 + "type": "custom"
  3844 + },
  3845 + {
  3846 + "key": {
  3847 + "type": "string"
  3848 + },
  3849 + "type": "record",
  3850 + "value": {
  3851 + "options": [
  3852 + {
  3853 + "type": "string"
  3854 + },
  3855 + {
  3856 + "type": "number"
  3857 + },
  3858 + {
  3859 + "type": "boolean"
  3860 + }
  3861 + ],
  3862 + "type": "union"
  3863 + }
  3864 + }
  3865 + ],
  3866 + "type": "intersect"
  3867 + }
  3868 + },
  3869 + "revealedSecrets": {
  3870 + "type": "optional",
  3871 + "wrapped": {
  3872 + "item": {
  3873 + "type": "string"
  3874 + },
  3875 + "type": "array"
  3876 + }
  3877 + }
  3878 + },
  3879 + "type": "strict_object"
  3880 + }
  3881 + }
  3882 + ],
  3883 + "type": "intersect"
  3884 + }
  3885 + },
  3886 + "objectStateChanges": {
  3887 + "type": "optional",
  3888 + "wrapped": {
  3889 + "options": [
  3890 + {
  3891 + "type": "custom"
  3892 + },
  3893 + {
  3894 + "key": {
  3895 + "type": "string"
  3896 + },
  3897 + "type": "record",
  3898 + "value": {
  3899 + "properties": {
  3900 + "damaged": {
  3901 + "type": "optional",
  3902 + "wrapped": {
  3903 + "type": "boolean"
  3904 + }
  3905 + },
  3906 + "flags": {
  3907 + "type": "optional",
  3908 + "wrapped": {
  3909 + "options": [
  3910 + {
  3911 + "type": "custom"
  3912 + },
  3913 + {
  3914 + "key": {
  3915 + "type": "string"
  3916 + },
  3917 + "type": "record",
  3918 + "value": {
  3919 + "options": [
  3920 + {
  3921 + "type": "string"
  3922 + },
  3923 + {
  3924 + "type": "number"
  3925 + },
  3926 + {
  3927 + "type": "boolean"
  3928 + }
  3929 + ],
  3930 + "type": "union"
  3931 + }
  3932 + }
  3933 + ],
  3934 + "type": "intersect"
  3935 + }
  3936 + },
  3937 + "heldBy": {
  3938 + "type": "optional",
  3939 + "wrapped": {
  3940 + "type": "nullable",
  3941 + "wrapped": {
  3942 + "type": "string"
  3943 + }
  3944 + }
  3945 + },
  3946 + "inspected": {
  3947 + "type": "optional",
  3948 + "wrapped": {
  3949 + "type": "boolean"
  3950 + }
  3951 + },
  3952 + "known": {
  3953 + "type": "optional",
  3954 + "wrapped": {
  3955 + "type": "boolean"
  3956 + }
  3957 + },
  3958 + "location": {
  3959 + "type": "optional",
  3960 + "wrapped": {
  3961 + "type": "string"
  3962 + }
  3963 + },
  3964 + "state": {
  3965 + "type": "optional",
  3966 + "wrapped": {
  3967 + "type": "string"
  3968 + }
  3969 + },
  3970 + "usable": {
  3971 + "type": "optional",
  3972 + "wrapped": {
  3973 + "type": "boolean"
  3974 + }
  3975 + }
  3976 + },
  3977 + "type": "strict_object"
  3978 + }
  3979 + }
  3980 + ],
  3981 + "type": "intersect"
  3982 + }
  3983 + },
  3984 + "reason": {
  3985 + "type": "optional",
  3986 + "wrapped": {
  3987 + "type": "string"
  3988 + }
  3989 + },
  3990 + "statChanges": {
  3991 + "type": "optional",
  3992 + "wrapped": {
  3993 + "options": [
  3994 + {
  3995 + "type": "custom"
  3996 + },
  3997 + {
  3998 + "key": {
  3999 + "type": "string"
  4000 + },
  4001 + "type": "record",
  4002 + "value": {
  4003 + "type": "number"
  4004 + }
  4005 + }
  4006 + ],
  4007 + "type": "intersect"
  4008 + }
  4009 + },
  4010 + "valid": {
  4011 + "type": "boolean"
  4012 + }
  4013 + },
  4014 + "type": "strict_object"
  4015 + },
  4016 + "npcDirective": {
  4017 + "properties": {
  4018 + "mood": {
  4019 + "options": [
  4020 + "cold_gentle",
  4021 + "gentle",
  4022 + "gentle_but_alert",
  4023 + "neutral",
  4024 + "sad",
  4025 + "threatening"
  4026 + ],
  4027 + "type": "picklist"
  4028 + },
  4029 + "mustHide": {
  4030 + "item": {
  4031 + "type": "string"
  4032 + },
  4033 + "type": "array"
  4034 + },
  4035 + "mustReactTo": {
  4036 + "item": {
  4037 + "type": "string"
  4038 + },
  4039 + "type": "array"
  4040 + },
  4041 + "npcId": {
  4042 + "type": "string"
  4043 + },
  4044 + "tactic": {
  4045 + "type": "string"
  4046 + }
  4047 + },
  4048 + "type": "strict_object"
  4049 + },
  4050 + "responsePolicy": {
  4051 + "properties": {
  4052 + "npcReaction": {
  4053 + "options": [
  4054 + "none",
  4055 + "optional",
  4056 + "required"
  4057 + ],
  4058 + "type": "picklist"
  4059 + },
  4060 + "reason": {
  4061 + "type": "string"
  4062 + }
  4063 + },
  4064 + "type": "strict_object"
  4065 + },
  4066 + "sceneResult": {
  4067 + "properties": {
  4068 + "discoveredObjectIds": {
  4069 + "item": {
  4070 + "type": "string"
  4071 + },
  4072 + "type": "array"
  4073 + },
  4074 + "narration": {
  4075 + "type": "string"
  4076 + },
  4077 + "visibleFindings": {
  4078 + "item": {
  4079 + "type": "string"
  4080 + },
  4081 + "type": "array"
  4082 + }
  4083 + },
  4084 + "type": "strict_object"
  4085 + },
  4086 + "story_signal": {
  4087 + "type": "optional",
  4088 + "wrapped": {
  4089 + "options": [
  4090 + {
  4091 + "properties": {
  4092 + "reason": {
  4093 + "type": "string"
  4094 + },
  4095 + "should_track": {
  4096 + "type": "literal",
  4097 + "value": false
  4098 + }
  4099 + },
  4100 + "type": "strict_object"
  4101 + },
  4102 + {
  4103 + "properties": {
  4104 + "continuation_tracker_id": {
  4105 + "type": "nullable",
  4106 + "wrapped": {
  4107 + "type": "string"
  4108 + }
  4109 + },
  4110 + "decisive_action": {
  4111 + "type": "boolean"
  4112 + },
  4113 + "display_name": {
  4114 + "type": "string"
  4115 + },
  4116 + "distinct_progress": {
  4117 + "type": "boolean"
  4118 + },
  4119 + "player_intended_action": {
  4120 + "type": "string"
  4121 + },
  4122 + "progress_marker": {
  4123 + "type": "string"
  4124 + },
  4125 + "reason": {
  4126 + "type": "string"
  4127 + },
  4128 + "should_track": {
  4129 + "type": "literal",
  4130 + "value": true
  4131 + },
  4132 + "topic_cluster": {
  4133 + "type": "string"
  4134 + },
  4135 + "topic_kind": {
  4136 + "options": [
  4137 + "ally_intervention",
  4138 + "countermeasure",
  4139 + "external_entity",
  4140 + "identity",
  4141 + "other",
  4142 + "relationship",
  4143 + "ritual",
  4144 + "world_lore"
  4145 + ],
  4146 + "type": "picklist"
  4147 + },
  4148 + "topic_summary": {
  4149 + "type": "string"
  4150 + }
  4151 + },
  4152 + "type": "strict_object"
  4153 + }
  4154 + ],
  4155 + "type": "union"
  4156 + }
  4157 + }
  4158 + },
  4159 + "type": "strict_object"
  4160 + },
  4161 + "VisualGameNotification": {
  4162 + "properties": {
  4163 + "action_href": {
  4164 + "type": "string"
  4165 + },
  4166 + "created_at": {
  4167 + "type": "string"
  4168 + },
  4169 + "id": {
  4170 + "type": "string"
  4171 + },
  4172 + "read_at": {
  4173 + "type": "nullable",
  4174 + "wrapped": {
  4175 + "type": "string"
  4176 + }
  4177 + },
  4178 + "resource_id": {
  4179 + "type": "string"
  4180 + },
  4181 + "resource_type": {
  4182 + "type": "literal",
  4183 + "value": "run_video"
  4184 + },
  4185 + "scenario_title": {
  4186 + "type": "string"
  4187 + },
  4188 + "season_no": {
  4189 + "type": "nullable",
  4190 + "wrapped": {
  4191 + "type": "number"
  4192 + }
  4193 + },
  4194 + "series_title": {
  4195 + "type": "nullable",
  4196 + "wrapped": {
  4197 + "type": "string"
  4198 + }
  4199 + },
  4200 + "type": {
  4201 + "options": [
  4202 + "video_generation_failed",
  4203 + "video_generation_ready"
  4204 + ],
  4205 + "type": "picklist"
  4206 + },
  4207 + "video_title": {
  4208 + "type": "nullable",
  4209 + "wrapped": {
  4210 + "type": "string"
  4211 + }
  4212 + }
  4213 + },
  4214 + "type": "object"
  4215 + },
  4216 + "VisualGameNotificationList": {
  4217 + "properties": {
  4218 + "notifications": {
  4219 + "item": {
  4220 + "properties": {
  4221 + "action_href": {
  4222 + "type": "string"
  4223 + },
  4224 + "created_at": {
  4225 + "type": "string"
  4226 + },
  4227 + "id": {
  4228 + "type": "string"
  4229 + },
  4230 + "read_at": {
  4231 + "type": "nullable",
  4232 + "wrapped": {
  4233 + "type": "string"
  4234 + }
  4235 + },
  4236 + "resource_id": {
  4237 + "type": "string"
  4238 + },
  4239 + "resource_type": {
  4240 + "type": "literal",
  4241 + "value": "run_video"
  4242 + },
  4243 + "scenario_title": {
  4244 + "type": "string"
  4245 + },
  4246 + "season_no": {
  4247 + "type": "nullable",
  4248 + "wrapped": {
  4249 + "type": "number"
  4250 + }
  4251 + },
  4252 + "series_title": {
  4253 + "type": "nullable",
  4254 + "wrapped": {
  4255 + "type": "string"
  4256 + }
  4257 + },
  4258 + "type": {
  4259 + "options": [
  4260 + "video_generation_failed",
  4261 + "video_generation_ready"
  4262 + ],
  4263 + "type": "picklist"
  4264 + },
  4265 + "video_title": {
  4266 + "type": "nullable",
  4267 + "wrapped": {
  4268 + "type": "string"
  4269 + }
  4270 + }
  4271 + },
  4272 + "type": "object"
  4273 + },
  4274 + "type": "array"
  4275 + }
  4276 + },
  4277 + "type": "object"
  4278 + },
  4279 + "VisualGameNpcActionKpOutput": {
  4280 + "properties": {
  4281 + "approvedNpcActions": {
  4282 + "item": {
  4283 + "properties": {
  4284 + "result": {
  4285 + "type": "string"
  4286 + },
  4287 + "targetId": {
  4288 + "type": "string"
  4289 + },
  4290 + "type": {
  4291 + "type": "string"
  4292 + }
  4293 + },
  4294 + "type": "strict_object"
  4295 + },
  4296 + "type": "array"
  4297 + },
  4298 + "judgementSuggestion": {
  4299 + "properties": {
  4300 + "evidenceAdded": {
  4301 + "type": "optional",
  4302 + "wrapped": {
  4303 + "item": {
  4304 + "type": "string"
  4305 + },
  4306 + "type": "array"
  4307 + }
  4308 + },
  4309 + "flagsSet": {
  4310 + "type": "optional",
  4311 + "wrapped": {
  4312 + "options": [
  4313 + {
  4314 + "type": "custom"
  4315 + },
  4316 + {
  4317 + "key": {
  4318 + "type": "string"
  4319 + },
  4320 + "type": "record",
  4321 + "value": {
  4322 + "options": [
  4323 + {
  4324 + "type": "string"
  4325 + },
  4326 + {
  4327 + "type": "number"
  4328 + },
  4329 + {
  4330 + "type": "boolean"
  4331 + }
  4332 + ],
  4333 + "type": "union"
  4334 + }
  4335 + }
  4336 + ],
  4337 + "type": "intersect"
  4338 + }
  4339 + },
  4340 + "inventoryChanges": {
  4341 + "type": "optional",
  4342 + "wrapped": {
  4343 + "properties": {
  4344 + "add": {
  4345 + "item": {
  4346 + "type": "string"
  4347 + },
  4348 + "type": "array"
  4349 + },
  4350 + "remove": {
  4351 + "item": {
  4352 + "type": "string"
  4353 + },
  4354 + "type": "array"
  4355 + }
  4356 + },
  4357 + "type": "strict_object"
  4358 + }
  4359 + },
  4360 + "locationChange": {
  4361 + "type": "optional",
  4362 + "wrapped": {
  4363 + "type": "string"
  4364 + }
  4365 + },
  4366 + "notes": {
  4367 + "type": "optional",
  4368 + "wrapped": {
  4369 + "item": {
  4370 + "type": "string"
  4371 + },
  4372 + "type": "array"
  4373 + }
  4374 + },
  4375 + "npcStateChanges": {
  4376 + "type": "optional",
  4377 + "wrapped": {
  4378 + "options": [
  4379 + {
  4380 + "type": "custom"
  4381 + },
  4382 + {
  4383 + "key": {
  4384 + "type": "string"
  4385 + },
  4386 + "type": "record",
  4387 + "value": {
  4388 + "properties": {
  4389 + "knownFacts": {
  4390 + "type": "optional",
  4391 + "wrapped": {
  4392 + "item": {
  4393 + "type": "string"
  4394 + },
  4395 + "type": "array"
  4396 + }
  4397 + },
  4398 + "locationId": {
  4399 + "type": "optional",
  4400 + "wrapped": {
  4401 + "type": "string"
  4402 + }
  4403 + },
  4404 + "mood": {
  4405 + "type": "optional",
  4406 + "wrapped": {
  4407 + "type": "string"
  4408 + }
  4409 + },
  4410 + "position": {
  4411 + "type": "optional",
  4412 + "wrapped": {
  4413 + "type": "string"
  4414 + }
  4415 + },
  4416 + "relationshipFlags": {
  4417 + "type": "optional",
  4418 + "wrapped": {
  4419 + "options": [
  4420 + {
  4421 + "type": "custom"
  4422 + },
  4423 + {
  4424 + "key": {
  4425 + "type": "string"
  4426 + },
  4427 + "type": "record",
  4428 + "value": {
  4429 + "options": [
  4430 + {
  4431 + "type": "string"
  4432 + },
  4433 + {
  4434 + "type": "number"
  4435 + },
  4436 + {
  4437 + "type": "boolean"
  4438 + }
  4439 + ],
  4440 + "type": "union"
  4441 + }
  4442 + }
  4443 + ],
  4444 + "type": "intersect"
  4445 + }
  4446 + },
  4447 + "revealedSecrets": {
  4448 + "type": "optional",
  4449 + "wrapped": {
  4450 + "item": {
  4451 + "type": "string"
  4452 + },
  4453 + "type": "array"
  4454 + }
  4455 + }
  4456 + },
  4457 + "type": "strict_object"
  4458 + }
  4459 + }
  4460 + ],
  4461 + "type": "intersect"
  4462 + }
  4463 + },
  4464 + "objectStateChanges": {
  4465 + "type": "optional",
  4466 + "wrapped": {
  4467 + "options": [
  4468 + {
  4469 + "type": "custom"
  4470 + },
  4471 + {
  4472 + "key": {
  4473 + "type": "string"
  4474 + },
  4475 + "type": "record",
  4476 + "value": {
  4477 + "properties": {
  4478 + "damaged": {
  4479 + "type": "optional",
  4480 + "wrapped": {
  4481 + "type": "boolean"
  4482 + }
  4483 + },
  4484 + "flags": {
  4485 + "type": "optional",
  4486 + "wrapped": {
  4487 + "options": [
  4488 + {
  4489 + "type": "custom"
  4490 + },
  4491 + {
  4492 + "key": {
  4493 + "type": "string"
  4494 + },
  4495 + "type": "record",
  4496 + "value": {
  4497 + "options": [
  4498 + {
  4499 + "type": "string"
  4500 + },
  4501 + {
  4502 + "type": "number"
  4503 + },
  4504 + {
  4505 + "type": "boolean"
  4506 + }
  4507 + ],
  4508 + "type": "union"
  4509 + }
  4510 + }
  4511 + ],
  4512 + "type": "intersect"
  4513 + }
  4514 + },
  4515 + "heldBy": {
  4516 + "type": "optional",
  4517 + "wrapped": {
  4518 + "type": "nullable",
  4519 + "wrapped": {
  4520 + "type": "string"
  4521 + }
  4522 + }
  4523 + },
  4524 + "inspected": {
  4525 + "type": "optional",
  4526 + "wrapped": {
  4527 + "type": "boolean"
  4528 + }
  4529 + },
  4530 + "known": {
  4531 + "type": "optional",
  4532 + "wrapped": {
  4533 + "type": "boolean"
  4534 + }
  4535 + },
  4536 + "location": {
  4537 + "type": "optional",
  4538 + "wrapped": {
  4539 + "type": "string"
  4540 + }
  4541 + },
  4542 + "state": {
  4543 + "type": "optional",
  4544 + "wrapped": {
  4545 + "type": "string"
  4546 + }
  4547 + },
  4548 + "usable": {
  4549 + "type": "optional",
  4550 + "wrapped": {
  4551 + "type": "boolean"
  4552 + }
  4553 + }
  4554 + },
  4555 + "type": "strict_object"
  4556 + }
  4557 + }
  4558 + ],
  4559 + "type": "intersect"
  4560 + }
  4561 + },
  4562 + "reason": {
  4563 + "type": "optional",
  4564 + "wrapped": {
  4565 + "type": "string"
  4566 + }
  4567 + },
  4568 + "statChanges": {
  4569 + "type": "optional",
  4570 + "wrapped": {
  4571 + "options": [
  4572 + {
  4573 + "type": "custom"
  4574 + },
  4575 + {
  4576 + "key": {
  4577 + "type": "string"
  4578 + },
  4579 + "type": "record",
  4580 + "value": {
  4581 + "type": "number"
  4582 + }
  4583 + }
  4584 + ],
  4585 + "type": "intersect"
  4586 + }
  4587 + },
  4588 + "valid": {
  4589 + "type": "boolean"
  4590 + }
  4591 + },
  4592 + "type": "strict_object"
  4593 + },
  4594 + "npcId": {
  4595 + "type": "string"
  4596 + },
  4597 + "sceneResult": {
  4598 + "properties": {
  4599 + "discoveredObjectIds": {
  4600 + "item": {
  4601 + "type": "string"
  4602 + },
  4603 + "type": "array"
  4604 + },
  4605 + "narration": {
  4606 + "type": "string"
  4607 + },
  4608 + "visibleFindings": {
  4609 + "item": {
  4610 + "type": "string"
  4611 + },
  4612 + "type": "array"
  4613 + }
  4614 + },
  4615 + "type": "strict_object"
  4616 + }
  4617 + },
  4618 + "type": "strict_object"
  4619 + },
  4620 + "VisualGameNpcActorOutput": {
  4621 + "properties": {
  4622 + "expression_state": {
  4623 + "type": "nullable",
  4624 + "wrapped": {
  4625 + "type": "string"
  4626 + }
  4627 + },
  4628 + "narration": {
  4629 + "type": "string"
  4630 + },
  4631 + "npcLine": {
  4632 + "type": "string"
  4633 + },
  4634 + "playerOptions": {
  4635 + "properties": {
  4636 + "act": {
  4637 + "type": "string"
  4638 + },
  4639 + "say": {
  4640 + "type": "string"
  4641 + }
  4642 + },
  4643 + "type": "strict_object"
  4644 + },
  4645 + "playerPromptType": {
  4646 + "options": [
  4647 + "action_invitation",
  4648 + "none",
  4649 + "question",
  4650 + "warning"
  4651 + ],
  4652 + "type": "picklist"
  4653 + },
  4654 + "proposedNpcActions": {
  4655 + "item": {
  4656 + "properties": {
  4657 + "candidate_delta": {
  4658 + "type": "optional",
  4659 + "wrapped": {
  4660 + "properties": {
  4661 + "evidenceAdded": {
  4662 + "type": "optional",
  4663 + "wrapped": {
  4664 + "item": {
  4665 + "type": "string"
  4666 + },
  4667 + "type": "array"
  4668 + }
  4669 + },
  4670 + "flagsSet": {
  4671 + "type": "optional",
  4672 + "wrapped": {
  4673 + "options": [
  4674 + {
  4675 + "type": "custom"
  4676 + },
  4677 + {
  4678 + "key": {
  4679 + "type": "string"
  4680 + },
  4681 + "type": "record",
  4682 + "value": {
  4683 + "options": [
  4684 + {
  4685 + "type": "string"
  4686 + },
  4687 + {
  4688 + "type": "number"
  4689 + },
  4690 + {
  4691 + "type": "boolean"
  4692 + }
  4693 + ],
  4694 + "type": "union"
  4695 + }
  4696 + }
  4697 + ],
  4698 + "type": "intersect"
  4699 + }
  4700 + },
  4701 + "inventoryChanges": {
  4702 + "type": "optional",
  4703 + "wrapped": {
  4704 + "properties": {
  4705 + "add": {
  4706 + "item": {
  4707 + "type": "string"
  4708 + },
  4709 + "type": "array"
  4710 + },
  4711 + "remove": {
  4712 + "item": {
  4713 + "type": "string"
  4714 + },
  4715 + "type": "array"
  4716 + }
  4717 + },
  4718 + "type": "strict_object"
  4719 + }
  4720 + },
  4721 + "locationChange": {
  4722 + "type": "optional",
  4723 + "wrapped": {
  4724 + "type": "string"
  4725 + }
  4726 + },
  4727 + "notes": {
  4728 + "type": "optional",
  4729 + "wrapped": {
  4730 + "item": {
  4731 + "type": "string"
  4732 + },
  4733 + "type": "array"
  4734 + }
  4735 + },
  4736 + "npcStateChanges": {
  4737 + "type": "optional",
  4738 + "wrapped": {
  4739 + "options": [
  4740 + {
  4741 + "type": "custom"
  4742 + },
  4743 + {
  4744 + "key": {
  4745 + "type": "string"
  4746 + },
  4747 + "type": "record",
  4748 + "value": {
  4749 + "properties": {
  4750 + "knownFacts": {
  4751 + "type": "optional",
  4752 + "wrapped": {
  4753 + "item": {
  4754 + "type": "string"
  4755 + },
  4756 + "type": "array"
  4757 + }
  4758 + },
  4759 + "locationId": {
  4760 + "type": "optional",
  4761 + "wrapped": {
  4762 + "type": "string"
  4763 + }
  4764 + },
  4765 + "mood": {
  4766 + "type": "optional",
  4767 + "wrapped": {
  4768 + "type": "string"
  4769 + }
  4770 + },
  4771 + "position": {
  4772 + "type": "optional",
  4773 + "wrapped": {
  4774 + "type": "string"
  4775 + }
  4776 + },
  4777 + "relationshipFlags": {
  4778 + "type": "optional",
  4779 + "wrapped": {
  4780 + "options": [
  4781 + {
  4782 + "type": "custom"
  4783 + },
  4784 + {
  4785 + "key": {
  4786 + "type": "string"
  4787 + },
  4788 + "type": "record",
  4789 + "value": {
  4790 + "options": [
  4791 + {
  4792 + "type": "string"
  4793 + },
  4794 + {
  4795 + "type": "number"
  4796 + },
  4797 + {
  4798 + "type": "boolean"
  4799 + }
  4800 + ],
  4801 + "type": "union"
  4802 + }
  4803 + }
  4804 + ],
  4805 + "type": "intersect"
  4806 + }
  4807 + },
  4808 + "revealedSecrets": {
  4809 + "type": "optional",
  4810 + "wrapped": {
  4811 + "item": {
  4812 + "type": "string"
  4813 + },
  4814 + "type": "array"
  4815 + }
  4816 + }
  4817 + },
  4818 + "type": "strict_object"
  4819 + }
  4820 + }
  4821 + ],
  4822 + "type": "intersect"
  4823 + }
  4824 + },
  4825 + "objectStateChanges": {
  4826 + "type": "optional",
  4827 + "wrapped": {
  4828 + "options": [
  4829 + {
  4830 + "type": "custom"
  4831 + },
  4832 + {
  4833 + "key": {
  4834 + "type": "string"
  4835 + },
  4836 + "type": "record",
  4837 + "value": {
  4838 + "properties": {
  4839 + "damaged": {
  4840 + "type": "optional",
  4841 + "wrapped": {
  4842 + "type": "boolean"
  4843 + }
  4844 + },
  4845 + "flags": {
  4846 + "type": "optional",
  4847 + "wrapped": {
  4848 + "options": [
  4849 + {
  4850 + "type": "custom"
  4851 + },
  4852 + {
  4853 + "key": {
  4854 + "type": "string"
  4855 + },
  4856 + "type": "record",
  4857 + "value": {
  4858 + "options": [
  4859 + {
  4860 + "type": "string"
  4861 + },
  4862 + {
  4863 + "type": "number"
  4864 + },
  4865 + {
  4866 + "type": "boolean"
  4867 + }
  4868 + ],
  4869 + "type": "union"
  4870 + }
  4871 + }
  4872 + ],
  4873 + "type": "intersect"
  4874 + }
  4875 + },
  4876 + "heldBy": {
  4877 + "type": "optional",
  4878 + "wrapped": {
  4879 + "type": "nullable",
  4880 + "wrapped": {
  4881 + "type": "string"
  4882 + }
  4883 + }
  4884 + },
  4885 + "inspected": {
  4886 + "type": "optional",
  4887 + "wrapped": {
  4888 + "type": "boolean"
  4889 + }
  4890 + },
  4891 + "known": {
  4892 + "type": "optional",
  4893 + "wrapped": {
  4894 + "type": "boolean"
  4895 + }
  4896 + },
  4897 + "location": {
  4898 + "type": "optional",
  4899 + "wrapped": {
  4900 + "type": "string"
  4901 + }
  4902 + },
  4903 + "state": {
  4904 + "type": "optional",
  4905 + "wrapped": {
  4906 + "type": "string"
  4907 + }
  4908 + },
  4909 + "usable": {
  4910 + "type": "optional",
  4911 + "wrapped": {
  4912 + "type": "boolean"
  4913 + }
  4914 + }
  4915 + },
  4916 + "type": "strict_object"
  4917 + }
  4918 + }
  4919 + ],
  4920 + "type": "intersect"
  4921 + }
  4922 + },
  4923 + "statChanges": {
  4924 + "type": "optional",
  4925 + "wrapped": {
  4926 + "options": [
  4927 + {
  4928 + "type": "custom"
  4929 + },
  4930 + {
  4931 + "key": {
  4932 + "type": "string"
  4933 + },
  4934 + "type": "record",
  4935 + "value": {
  4936 + "type": "number"
  4937 + }
  4938 + }
  4939 + ],
  4940 + "type": "intersect"
  4941 + }
  4942 + }
  4943 + },
  4944 + "type": "strict_object"
  4945 + }
  4946 + },
  4947 + "description": {
  4948 + "type": "string"
  4949 + },
  4950 + "intent": {
  4951 + "type": "string"
  4952 + },
  4953 + "targetId": {
  4954 + "type": "string"
  4955 + },
  4956 + "type": {
  4957 + "type": "string"
  4958 + }
  4959 + },
  4960 + "type": "strict_object"
  4961 + },
  4962 + "type": "array"
  4963 + },
  4964 + "tacticUsed": {
  4965 + "type": "string"
  4966 + },
  4967 + "tone": {
  4968 + "type": "string"
  4969 + }
  4970 + },
  4971 + "type": "strict_object"
  4972 + },
  4973 + "VisualGamePack": {
  4974 + "properties": {
  4975 + "assets": {
  4976 + "type": "optional",
  4977 + "wrapped": {
  4978 + "item": {
  4979 + "properties": {
  4980 + "file": {
  4981 + "type": "optional",
  4982 + "wrapped": {
  4983 + "type": "string"
  4984 + }
  4985 + },
  4986 + "hash": {
  4987 + "type": "optional",
  4988 + "wrapped": {
  4989 + "type": "nullable",
  4990 + "wrapped": {
  4991 + "type": "string"
  4992 + }
  4993 + }
  4994 + },
  4995 + "height": {
  4996 + "type": "optional",
  4997 + "wrapped": {
  4998 + "type": "nullable",
  4999 + "wrapped": {
  5000 + "type": "number"
  5001 + }
  5002 + }
  5003 + },
  5004 + "id": {
  5005 + "type": "optional",
  5006 + "wrapped": {
  5007 + "type": "string"
  5008 + }
  5009 + },
  5010 + "manifest_id": {
  5011 + "type": "string"
  5012 + },
  5013 + "mime": {
  5014 + "type": "optional",
  5015 + "wrapped": {
  5016 + "type": "nullable",
  5017 + "wrapped": {
  5018 + "type": "string"
  5019 + }
  5020 + }
  5021 + },
  5022 + "object_key": {
  5023 + "type": "optional",
  5024 + "wrapped": {
  5025 + "type": "nullable",
  5026 + "wrapped": {
  5027 + "type": "string"
  5028 + }
  5029 + }
  5030 + },
  5031 + "public_url": {
  5032 + "type": "optional",
  5033 + "wrapped": {
  5034 + "type": "string"
  5035 + }
  5036 + },
  5037 + "size": {
  5038 + "type": "optional",
  5039 + "wrapped": {
  5040 + "type": "nullable",
  5041 + "wrapped": {
  5042 + "type": "number"
  5043 + }
  5044 + }
  5045 + },
  5046 + "tag": {
  5047 + "type": "string"
  5048 + },
  5049 + "type": {
  5050 + "type": "string"
  5051 + },
  5052 + "width": {
  5053 + "type": "optional",
  5054 + "wrapped": {
  5055 + "type": "nullable",
  5056 + "wrapped": {
  5057 + "type": "number"
  5058 + }
  5059 + }
  5060 + }
  5061 + },
  5062 + "type": "object"
  5063 + },
  5064 + "type": "array"
  5065 + }
  5066 + },
  5067 + "created_at": {
  5068 + "type": "optional",
  5069 + "wrapped": {
  5070 + "type": "nullable",
  5071 + "wrapped": {
  5072 + "type": "unknown"
  5073 + }
  5074 + }
  5075 + },
  5076 + "current_version_id": {
  5077 + "type": "optional",
  5078 + "wrapped": {
  5079 + "type": "nullable",
  5080 + "wrapped": {
  5081 + "type": "string"
  5082 + }
  5083 + }
  5084 + },
  5085 + "episode_no": {
  5086 + "type": "optional",
  5087 + "wrapped": {
  5088 + "type": "nullable",
  5089 + "wrapped": {
  5090 + "type": "number"
  5091 + }
  5092 + }
  5093 + },
  5094 + "id": {
  5095 + "type": "string"
  5096 + },
  5097 + "locale": {
  5098 + "type": "optional",
  5099 + "wrapped": {
  5100 + "type": "string"
  5101 + }
  5102 + },
  5103 + "owner_id": {
  5104 + "type": "optional",
  5105 + "wrapped": {
  5106 + "type": "nullable",
  5107 + "wrapped": {
  5108 + "type": "string"
  5109 + }
  5110 + }
  5111 + },
  5112 + "sections": {
  5113 + "type": "optional",
  5114 + "wrapped": {
  5115 + "key": {
  5116 + "type": "string"
  5117 + },
  5118 + "type": "record",
  5119 + "value": {
  5120 + "type": "unknown"
  5121 + }
  5122 + }
  5123 + },
  5124 + "series_id": {
  5125 + "type": "optional",
  5126 + "wrapped": {
  5127 + "type": "nullable",
  5128 + "wrapped": {
  5129 + "type": "string"
  5130 + }
  5131 + }
  5132 + },
  5133 + "series_title": {
  5134 + "type": "optional",
  5135 + "wrapped": {
  5136 + "type": "nullable",
  5137 + "wrapped": {
  5138 + "type": "string"
  5139 + }
  5140 + }
  5141 + },
  5142 + "status": {
  5143 + "type": "optional",
  5144 + "wrapped": {
  5145 + "type": "string"
  5146 + }
  5147 + },
  5148 + "tagline": {
  5149 + "type": "optional",
  5150 + "wrapped": {
  5151 + "type": "string"
  5152 + }
  5153 + },
  5154 + "tags": {
  5155 + "item": {
  5156 + "type": "string"
  5157 + },
  5158 + "type": "array"
  5159 + },
  5160 + "title": {
  5161 + "type": "string"
  5162 + },
  5163 + "updated_at": {
  5164 + "type": "optional",
  5165 + "wrapped": {
  5166 + "type": "nullable",
  5167 + "wrapped": {
  5168 + "type": "unknown"
  5169 + }
  5170 + }
  5171 + },
  5172 + "versions": {
  5173 + "type": "optional",
  5174 + "wrapped": {
  5175 + "item": {
  5176 + "properties": {
  5177 + "asset_manifest_id": {
  5178 + "type": "optional",
  5179 + "wrapped": {
  5180 + "type": "string"
  5181 + }
  5182 + },
  5183 + "compile_result": {
  5184 + "type": "optional",
  5185 + "wrapped": {
  5186 + "type": "unknown"
  5187 + }
  5188 + },
  5189 + "created_at": {
  5190 + "type": "optional",
  5191 + "wrapped": {
  5192 + "type": "nullable",
  5193 + "wrapped": {
  5194 + "type": "unknown"
  5195 + }
  5196 + }
  5197 + },
  5198 + "id": {
  5199 + "type": "string"
  5200 + },
  5201 + "is_current": {
  5202 + "type": "optional",
  5203 + "wrapped": {
  5204 + "type": "boolean"
  5205 + }
  5206 + },
  5207 + "pack_id": {
  5208 + "type": "string"
  5209 + },
  5210 + "published_at": {
  5211 + "type": "optional",
  5212 + "wrapped": {
  5213 + "type": "nullable",
  5214 + "wrapped": {
  5215 + "type": "unknown"
  5216 + }
  5217 + }
  5218 + },
  5219 + "scenario": {
  5220 + "type": "optional",
  5221 + "wrapped": {
  5222 + "type": "unknown"
  5223 + }
  5224 + },
  5225 + "status": {
  5226 + "type": "string"
  5227 + },
  5228 + "tagline": {
  5229 + "type": "optional",
  5230 + "wrapped": {
  5231 + "type": "string"
  5232 + }
  5233 + },
  5234 + "title": {
  5235 + "type": "string"
  5236 + },
  5237 + "version": {
  5238 + "type": "number"
  5239 + }
  5240 + },
  5241 + "type": "object"
  5242 + },
  5243 + "type": "array"
  5244 + }
  5245 + },
  5246 + "visibility": {
  5247 + "type": "optional",
  5248 + "wrapped": {
  5249 + "type": "string"
  5250 + }
  5251 + }
  5252 + },
  5253 + "type": "object"
  5254 + },
  5255 + "VisualGamePackAssetUploadResponse": {
  5256 + "properties": {
  5257 + "asset": {
  5258 + "properties": {
  5259 + "file": {
  5260 + "type": "optional",
  5261 + "wrapped": {
  5262 + "type": "string"
  5263 + }
  5264 + },
  5265 + "hash": {
  5266 + "type": "optional",
  5267 + "wrapped": {
  5268 + "type": "nullable",
  5269 + "wrapped": {
  5270 + "type": "string"
  5271 + }
  5272 + }
  5273 + },
  5274 + "height": {
  5275 + "type": "optional",
  5276 + "wrapped": {
  5277 + "type": "nullable",
  5278 + "wrapped": {
  5279 + "type": "number"
  5280 + }
  5281 + }
  5282 + },
  5283 + "id": {
  5284 + "type": "optional",
  5285 + "wrapped": {
  5286 + "type": "string"
  5287 + }
  5288 + },
  5289 + "manifest_id": {
  5290 + "type": "string"
  5291 + },
  5292 + "mime": {
  5293 + "type": "optional",
  5294 + "wrapped": {
  5295 + "type": "nullable",
  5296 + "wrapped": {
  5297 + "type": "string"
  5298 + }
  5299 + }
  5300 + },
  5301 + "object_key": {
  5302 + "type": "optional",
  5303 + "wrapped": {
  5304 + "type": "nullable",
  5305 + "wrapped": {
  5306 + "type": "string"
  5307 + }
  5308 + }
  5309 + },
  5310 + "public_url": {
  5311 + "type": "optional",
  5312 + "wrapped": {
  5313 + "type": "string"
  5314 + }
  5315 + },
  5316 + "size": {
  5317 + "type": "optional",
  5318 + "wrapped": {
  5319 + "type": "nullable",
  5320 + "wrapped": {
  5321 + "type": "number"
  5322 + }
  5323 + }
  5324 + },
  5325 + "tag": {
  5326 + "type": "string"
  5327 + },
  5328 + "type": {
  5329 + "type": "string"
  5330 + },
  5331 + "width": {
  5332 + "type": "optional",
  5333 + "wrapped": {
  5334 + "type": "nullable",
  5335 + "wrapped": {
  5336 + "type": "number"
  5337 + }
  5338 + }
  5339 + }
  5340 + },
  5341 + "type": "object"
  5342 + },
  5343 + "ok": {
  5344 + "type": "boolean"
  5345 + },
  5346 + "pack": {
  5347 + "properties": {
  5348 + "assets": {
  5349 + "type": "optional",
  5350 + "wrapped": {
  5351 + "item": {
  5352 + "properties": {
  5353 + "file": {
  5354 + "type": "optional",
  5355 + "wrapped": {
  5356 + "type": "string"
  5357 + }
  5358 + },
  5359 + "hash": {
  5360 + "type": "optional",
  5361 + "wrapped": {
  5362 + "type": "nullable",
  5363 + "wrapped": {
  5364 + "type": "string"
  5365 + }
  5366 + }
  5367 + },
  5368 + "height": {
  5369 + "type": "optional",
  5370 + "wrapped": {
  5371 + "type": "nullable",
  5372 + "wrapped": {
  5373 + "type": "number"
  5374 + }
  5375 + }
  5376 + },
  5377 + "id": {
  5378 + "type": "optional",
  5379 + "wrapped": {
  5380 + "type": "string"
  5381 + }
  5382 + },
  5383 + "manifest_id": {
  5384 + "type": "string"
  5385 + },
  5386 + "mime": {
  5387 + "type": "optional",
  5388 + "wrapped": {
  5389 + "type": "nullable",
  5390 + "wrapped": {
  5391 + "type": "string"
  5392 + }
  5393 + }
  5394 + },
  5395 + "object_key": {
  5396 + "type": "optional",
  5397 + "wrapped": {
  5398 + "type": "nullable",
  5399 + "wrapped": {
  5400 + "type": "string"
  5401 + }
  5402 + }
  5403 + },
  5404 + "public_url": {
  5405 + "type": "optional",
  5406 + "wrapped": {
  5407 + "type": "string"
  5408 + }
  5409 + },
  5410 + "size": {
  5411 + "type": "optional",
  5412 + "wrapped": {
  5413 + "type": "nullable",
  5414 + "wrapped": {
  5415 + "type": "number"
  5416 + }
  5417 + }
  5418 + },
  5419 + "tag": {
  5420 + "type": "string"
  5421 + },
  5422 + "type": {
  5423 + "type": "string"
  5424 + },
  5425 + "width": {
  5426 + "type": "optional",
  5427 + "wrapped": {
  5428 + "type": "nullable",
  5429 + "wrapped": {
  5430 + "type": "number"
  5431 + }
  5432 + }
  5433 + }
  5434 + },
  5435 + "type": "object"
  5436 + },
  5437 + "type": "array"
  5438 + }
  5439 + },
  5440 + "created_at": {
  5441 + "type": "optional",
  5442 + "wrapped": {
  5443 + "type": "nullable",
  5444 + "wrapped": {
  5445 + "type": "unknown"
  5446 + }
  5447 + }
  5448 + },
  5449 + "current_version_id": {
  5450 + "type": "optional",
  5451 + "wrapped": {
  5452 + "type": "nullable",
  5453 + "wrapped": {
  5454 + "type": "string"
  5455 + }
  5456 + }
  5457 + },
  5458 + "episode_no": {
  5459 + "type": "optional",
  5460 + "wrapped": {
  5461 + "type": "nullable",
  5462 + "wrapped": {
  5463 + "type": "number"
  5464 + }
  5465 + }
  5466 + },
  5467 + "id": {
  5468 + "type": "string"
  5469 + },
  5470 + "locale": {
  5471 + "type": "optional",
  5472 + "wrapped": {
  5473 + "type": "string"
  5474 + }
  5475 + },
  5476 + "owner_id": {
  5477 + "type": "optional",
  5478 + "wrapped": {
  5479 + "type": "nullable",
  5480 + "wrapped": {
  5481 + "type": "string"
  5482 + }
  5483 + }
  5484 + },
  5485 + "sections": {
  5486 + "type": "optional",
  5487 + "wrapped": {
  5488 + "key": {
  5489 + "type": "string"
  5490 + },
  5491 + "type": "record",
  5492 + "value": {
  5493 + "type": "unknown"
  5494 + }
  5495 + }
  5496 + },
  5497 + "series_id": {
  5498 + "type": "optional",
  5499 + "wrapped": {
  5500 + "type": "nullable",
  5501 + "wrapped": {
  5502 + "type": "string"
  5503 + }
  5504 + }
  5505 + },
  5506 + "series_title": {
  5507 + "type": "optional",
  5508 + "wrapped": {
  5509 + "type": "nullable",
  5510 + "wrapped": {
  5511 + "type": "string"
  5512 + }
  5513 + }
  5514 + },
  5515 + "status": {
  5516 + "type": "optional",
  5517 + "wrapped": {
  5518 + "type": "string"
  5519 + }
  5520 + },
  5521 + "tagline": {
  5522 + "type": "optional",
  5523 + "wrapped": {
  5524 + "type": "string"
  5525 + }
  5526 + },
  5527 + "tags": {
  5528 + "item": {
  5529 + "type": "string"
  5530 + },
  5531 + "type": "array"
  5532 + },
  5533 + "title": {
  5534 + "type": "string"
  5535 + },
  5536 + "updated_at": {
  5537 + "type": "optional",
  5538 + "wrapped": {
  5539 + "type": "nullable",
  5540 + "wrapped": {
  5541 + "type": "unknown"
  5542 + }
  5543 + }
  5544 + },
  5545 + "versions": {
  5546 + "type": "optional",
  5547 + "wrapped": {
  5548 + "item": {
  5549 + "properties": {
  5550 + "asset_manifest_id": {
  5551 + "type": "optional",
  5552 + "wrapped": {
  5553 + "type": "string"
  5554 + }
  5555 + },
  5556 + "compile_result": {
  5557 + "type": "optional",
  5558 + "wrapped": {
  5559 + "type": "unknown"
  5560 + }
  5561 + },
  5562 + "created_at": {
  5563 + "type": "optional",
  5564 + "wrapped": {
  5565 + "type": "nullable",
  5566 + "wrapped": {
  5567 + "type": "unknown"
  5568 + }
  5569 + }
  5570 + },
  5571 + "id": {
  5572 + "type": "string"
  5573 + },
  5574 + "is_current": {
  5575 + "type": "optional",
  5576 + "wrapped": {
  5577 + "type": "boolean"
  5578 + }
  5579 + },
  5580 + "pack_id": {
  5581 + "type": "string"
  5582 + },
  5583 + "published_at": {
  5584 + "type": "optional",
  5585 + "wrapped": {
  5586 + "type": "nullable",
  5587 + "wrapped": {
  5588 + "type": "unknown"
  5589 + }
  5590 + }
  5591 + },
  5592 + "scenario": {
  5593 + "type": "optional",
  5594 + "wrapped": {
  5595 + "type": "unknown"
  5596 + }
  5597 + },
  5598 + "status": {
  5599 + "type": "string"
  5600 + },
  5601 + "tagline": {
  5602 + "type": "optional",
  5603 + "wrapped": {
  5604 + "type": "string"
  5605 + }
  5606 + },
  5607 + "title": {
  5608 + "type": "string"
  5609 + },
  5610 + "version": {
  5611 + "type": "number"
  5612 + }
  5613 + },
  5614 + "type": "object"
  5615 + },
  5616 + "type": "array"
  5617 + }
  5618 + },
  5619 + "visibility": {
  5620 + "type": "optional",
  5621 + "wrapped": {
  5622 + "type": "string"
  5623 + }
  5624 + }
  5625 + },
  5626 + "type": "object"
  5627 + }
  5628 + },
  5629 + "type": "object"
  5630 + },
  5631 + "VisualGamePackCompileResponse": {
  5632 + "properties": {
  5633 + "compile_result": {
  5634 + "type": "unknown"
  5635 + },
  5636 + "ok": {
  5637 + "type": "boolean"
  5638 + },
  5639 + "pack": {
  5640 + "properties": {
  5641 + "assets": {
  5642 + "type": "optional",
  5643 + "wrapped": {
  5644 + "item": {
  5645 + "properties": {
  5646 + "file": {
  5647 + "type": "optional",
  5648 + "wrapped": {
  5649 + "type": "string"
  5650 + }
  5651 + },
  5652 + "hash": {
  5653 + "type": "optional",
  5654 + "wrapped": {
  5655 + "type": "nullable",
  5656 + "wrapped": {
  5657 + "type": "string"
  5658 + }
  5659 + }
  5660 + },
  5661 + "height": {
  5662 + "type": "optional",
  5663 + "wrapped": {
  5664 + "type": "nullable",
  5665 + "wrapped": {
  5666 + "type": "number"
  5667 + }
  5668 + }
  5669 + },
  5670 + "id": {
  5671 + "type": "optional",
  5672 + "wrapped": {
  5673 + "type": "string"
  5674 + }
  5675 + },
  5676 + "manifest_id": {
  5677 + "type": "string"
  5678 + },
  5679 + "mime": {
  5680 + "type": "optional",
  5681 + "wrapped": {
  5682 + "type": "nullable",
  5683 + "wrapped": {
  5684 + "type": "string"
  5685 + }
  5686 + }
  5687 + },
  5688 + "object_key": {
  5689 + "type": "optional",
  5690 + "wrapped": {
  5691 + "type": "nullable",
  5692 + "wrapped": {
  5693 + "type": "string"
  5694 + }
  5695 + }
  5696 + },
  5697 + "public_url": {
  5698 + "type": "optional",
  5699 + "wrapped": {
  5700 + "type": "string"
  5701 + }
  5702 + },
  5703 + "size": {
  5704 + "type": "optional",
  5705 + "wrapped": {
  5706 + "type": "nullable",
  5707 + "wrapped": {
  5708 + "type": "number"
  5709 + }
  5710 + }
  5711 + },
  5712 + "tag": {
  5713 + "type": "string"
  5714 + },
  5715 + "type": {
  5716 + "type": "string"
  5717 + },
  5718 + "width": {
  5719 + "type": "optional",
  5720 + "wrapped": {
  5721 + "type": "nullable",
  5722 + "wrapped": {
  5723 + "type": "number"
  5724 + }
  5725 + }
  5726 + }
  5727 + },
  5728 + "type": "object"
  5729 + },
  5730 + "type": "array"
  5731 + }
  5732 + },
  5733 + "created_at": {
  5734 + "type": "optional",
  5735 + "wrapped": {
  5736 + "type": "nullable",
  5737 + "wrapped": {
  5738 + "type": "unknown"
  5739 + }
  5740 + }
  5741 + },
  5742 + "current_version_id": {
  5743 + "type": "optional",
  5744 + "wrapped": {
  5745 + "type": "nullable",
  5746 + "wrapped": {
  5747 + "type": "string"
  5748 + }
  5749 + }
  5750 + },
  5751 + "episode_no": {
  5752 + "type": "optional",
  5753 + "wrapped": {
  5754 + "type": "nullable",
  5755 + "wrapped": {
  5756 + "type": "number"
  5757 + }
  5758 + }
  5759 + },
  5760 + "id": {
  5761 + "type": "string"
  5762 + },
  5763 + "locale": {
  5764 + "type": "optional",
  5765 + "wrapped": {
  5766 + "type": "string"
  5767 + }
  5768 + },
  5769 + "owner_id": {
  5770 + "type": "optional",
  5771 + "wrapped": {
  5772 + "type": "nullable",
  5773 + "wrapped": {
  5774 + "type": "string"
  5775 + }
  5776 + }
  5777 + },
  5778 + "sections": {
  5779 + "type": "optional",
  5780 + "wrapped": {
  5781 + "key": {
  5782 + "type": "string"
  5783 + },
  5784 + "type": "record",
  5785 + "value": {
  5786 + "type": "unknown"
  5787 + }
  5788 + }
  5789 + },
  5790 + "series_id": {
  5791 + "type": "optional",
  5792 + "wrapped": {
  5793 + "type": "nullable",
  5794 + "wrapped": {
  5795 + "type": "string"
  5796 + }
  5797 + }
  5798 + },
  5799 + "series_title": {
  5800 + "type": "optional",
  5801 + "wrapped": {
  5802 + "type": "nullable",
  5803 + "wrapped": {
  5804 + "type": "string"
  5805 + }
  5806 + }
  5807 + },
  5808 + "status": {
  5809 + "type": "optional",
  5810 + "wrapped": {
  5811 + "type": "string"
  5812 + }
  5813 + },
  5814 + "tagline": {
  5815 + "type": "optional",
  5816 + "wrapped": {
  5817 + "type": "string"
  5818 + }
  5819 + },
  5820 + "tags": {
  5821 + "item": {
  5822 + "type": "string"
  5823 + },
  5824 + "type": "array"
  5825 + },
  5826 + "title": {
  5827 + "type": "string"
  5828 + },
  5829 + "updated_at": {
  5830 + "type": "optional",
  5831 + "wrapped": {
  5832 + "type": "nullable",
  5833 + "wrapped": {
  5834 + "type": "unknown"
  5835 + }
  5836 + }
  5837 + },
  5838 + "versions": {
  5839 + "type": "optional",
  5840 + "wrapped": {
  5841 + "item": {
  5842 + "properties": {
  5843 + "asset_manifest_id": {
  5844 + "type": "optional",
  5845 + "wrapped": {
  5846 + "type": "string"
  5847 + }
  5848 + },
  5849 + "compile_result": {
  5850 + "type": "optional",
  5851 + "wrapped": {
  5852 + "type": "unknown"
  5853 + }
  5854 + },
  5855 + "created_at": {
  5856 + "type": "optional",
  5857 + "wrapped": {
  5858 + "type": "nullable",
  5859 + "wrapped": {
  5860 + "type": "unknown"
  5861 + }
  5862 + }
  5863 + },
  5864 + "id": {
  5865 + "type": "string"
  5866 + },
  5867 + "is_current": {
  5868 + "type": "optional",
  5869 + "wrapped": {
  5870 + "type": "boolean"
  5871 + }
  5872 + },
  5873 + "pack_id": {
  5874 + "type": "string"
  5875 + },
  5876 + "published_at": {
  5877 + "type": "optional",
  5878 + "wrapped": {
  5879 + "type": "nullable",
  5880 + "wrapped": {
  5881 + "type": "unknown"
  5882 + }
  5883 + }
  5884 + },
  5885 + "scenario": {
  5886 + "type": "optional",
  5887 + "wrapped": {
  5888 + "type": "unknown"
  5889 + }
  5890 + },
  5891 + "status": {
  5892 + "type": "string"
  5893 + },
  5894 + "tagline": {
  5895 + "type": "optional",
  5896 + "wrapped": {
  5897 + "type": "string"
  5898 + }
  5899 + },
  5900 + "title": {
  5901 + "type": "string"
  5902 + },
  5903 + "version": {
  5904 + "type": "number"
  5905 + }
  5906 + },
  5907 + "type": "object"
  5908 + },
  5909 + "type": "array"
  5910 + }
  5911 + },
  5912 + "visibility": {
  5913 + "type": "optional",
  5914 + "wrapped": {
  5915 + "type": "string"
  5916 + }
  5917 + }
  5918 + },
  5919 + "type": "object"
  5920 + }
  5921 + },
  5922 + "type": "object"
  5923 + },
  5924 + "VisualGamePackDeleteResponse": {
  5925 + "properties": {
  5926 + "mode": {
  5927 + "options": [
  5928 + "archived",
  5929 + "deleted"
  5930 + ],
  5931 + "type": "picklist"
  5932 + },
  5933 + "pack": {
  5934 + "type": "optional",
  5935 + "wrapped": {
  5936 + "type": "nullable",
  5937 + "wrapped": {
  5938 + "properties": {
  5939 + "assets": {
  5940 + "type": "optional",
  5941 + "wrapped": {
  5942 + "item": {
  5943 + "properties": {
  5944 + "file": {
  5945 + "type": "optional",
  5946 + "wrapped": {
  5947 + "type": "string"
  5948 + }
  5949 + },
  5950 + "hash": {
  5951 + "type": "optional",
  5952 + "wrapped": {
  5953 + "type": "nullable",
  5954 + "wrapped": {
  5955 + "type": "string"
  5956 + }
  5957 + }
  5958 + },
  5959 + "height": {
  5960 + "type": "optional",
  5961 + "wrapped": {
  5962 + "type": "nullable",
  5963 + "wrapped": {
  5964 + "type": "number"
  5965 + }
  5966 + }
  5967 + },
  5968 + "id": {
  5969 + "type": "optional",
  5970 + "wrapped": {
  5971 + "type": "string"
  5972 + }
  5973 + },
  5974 + "manifest_id": {
  5975 + "type": "string"
  5976 + },
  5977 + "mime": {
  5978 + "type": "optional",
  5979 + "wrapped": {
  5980 + "type": "nullable",
  5981 + "wrapped": {
  5982 + "type": "string"
  5983 + }
  5984 + }
  5985 + },
  5986 + "object_key": {
  5987 + "type": "optional",
  5988 + "wrapped": {
  5989 + "type": "nullable",
  5990 + "wrapped": {
  5991 + "type": "string"
  5992 + }
  5993 + }
  5994 + },
  5995 + "public_url": {
  5996 + "type": "optional",
  5997 + "wrapped": {
  5998 + "type": "string"
  5999 + }
  6000 + },
  6001 + "size": {
  6002 + "type": "optional",
  6003 + "wrapped": {
  6004 + "type": "nullable",
  6005 + "wrapped": {
  6006 + "type": "number"
  6007 + }
  6008 + }
  6009 + },
  6010 + "tag": {
  6011 + "type": "string"
  6012 + },
  6013 + "type": {
  6014 + "type": "string"
  6015 + },
  6016 + "width": {
  6017 + "type": "optional",
  6018 + "wrapped": {
  6019 + "type": "nullable",
  6020 + "wrapped": {
  6021 + "type": "number"
  6022 + }
  6023 + }
  6024 + }
  6025 + },
  6026 + "type": "object"
  6027 + },
  6028 + "type": "array"
  6029 + }
  6030 + },
  6031 + "created_at": {
  6032 + "type": "optional",
  6033 + "wrapped": {
  6034 + "type": "nullable",
  6035 + "wrapped": {
  6036 + "type": "unknown"
  6037 + }
  6038 + }
  6039 + },
  6040 + "current_version_id": {
  6041 + "type": "optional",
  6042 + "wrapped": {
  6043 + "type": "nullable",
  6044 + "wrapped": {
  6045 + "type": "string"
  6046 + }
  6047 + }
  6048 + },
  6049 + "episode_no": {
  6050 + "type": "optional",
  6051 + "wrapped": {
  6052 + "type": "nullable",
  6053 + "wrapped": {
  6054 + "type": "number"
  6055 + }
  6056 + }
  6057 + },
  6058 + "id": {
  6059 + "type": "string"
  6060 + },
  6061 + "locale": {
  6062 + "type": "optional",
  6063 + "wrapped": {
  6064 + "type": "string"
  6065 + }
  6066 + },
  6067 + "owner_id": {
  6068 + "type": "optional",
  6069 + "wrapped": {
  6070 + "type": "nullable",
  6071 + "wrapped": {
  6072 + "type": "string"
  6073 + }
  6074 + }
  6075 + },
  6076 + "sections": {
  6077 + "type": "optional",
  6078 + "wrapped": {
  6079 + "key": {
  6080 + "type": "string"
  6081 + },
  6082 + "type": "record",
  6083 + "value": {
  6084 + "type": "unknown"
  6085 + }
  6086 + }
  6087 + },
  6088 + "series_id": {
  6089 + "type": "optional",
  6090 + "wrapped": {
  6091 + "type": "nullable",
  6092 + "wrapped": {
  6093 + "type": "string"
  6094 + }
  6095 + }
  6096 + },
  6097 + "series_title": {
  6098 + "type": "optional",
  6099 + "wrapped": {
  6100 + "type": "nullable",
  6101 + "wrapped": {
  6102 + "type": "string"
  6103 + }
  6104 + }
  6105 + },
  6106 + "status": {
  6107 + "type": "optional",
  6108 + "wrapped": {
  6109 + "type": "string"
  6110 + }
  6111 + },
  6112 + "tagline": {
  6113 + "type": "optional",
  6114 + "wrapped": {
  6115 + "type": "string"
  6116 + }
  6117 + },
  6118 + "tags": {
  6119 + "item": {
  6120 + "type": "string"
  6121 + },
  6122 + "type": "array"
  6123 + },
  6124 + "title": {
  6125 + "type": "string"
  6126 + },
  6127 + "updated_at": {
  6128 + "type": "optional",
  6129 + "wrapped": {
  6130 + "type": "nullable",
  6131 + "wrapped": {
  6132 + "type": "unknown"
  6133 + }
  6134 + }
  6135 + },
  6136 + "versions": {
  6137 + "type": "optional",
  6138 + "wrapped": {
  6139 + "item": {
  6140 + "properties": {
  6141 + "asset_manifest_id": {
  6142 + "type": "optional",
  6143 + "wrapped": {
  6144 + "type": "string"
  6145 + }
  6146 + },
  6147 + "compile_result": {
  6148 + "type": "optional",
  6149 + "wrapped": {
  6150 + "type": "unknown"
  6151 + }
  6152 + },
  6153 + "created_at": {
  6154 + "type": "optional",
  6155 + "wrapped": {
  6156 + "type": "nullable",
  6157 + "wrapped": {
  6158 + "type": "unknown"
  6159 + }
  6160 + }
  6161 + },
  6162 + "id": {
  6163 + "type": "string"
  6164 + },
  6165 + "is_current": {
  6166 + "type": "optional",
  6167 + "wrapped": {
  6168 + "type": "boolean"
  6169 + }
  6170 + },
  6171 + "pack_id": {
  6172 + "type": "string"
  6173 + },
  6174 + "published_at": {
  6175 + "type": "optional",
  6176 + "wrapped": {
  6177 + "type": "nullable",
  6178 + "wrapped": {
  6179 + "type": "unknown"
  6180 + }
  6181 + }
  6182 + },
  6183 + "scenario": {
  6184 + "type": "optional",
  6185 + "wrapped": {
  6186 + "type": "unknown"
  6187 + }
  6188 + },
  6189 + "status": {
  6190 + "type": "string"
  6191 + },
  6192 + "tagline": {
  6193 + "type": "optional",
  6194 + "wrapped": {
  6195 + "type": "string"
  6196 + }
  6197 + },
  6198 + "title": {
  6199 + "type": "string"
  6200 + },
  6201 + "version": {
  6202 + "type": "number"
  6203 + }
  6204 + },
  6205 + "type": "object"
  6206 + },
  6207 + "type": "array"
  6208 + }
  6209 + },
  6210 + "visibility": {
  6211 + "type": "optional",
  6212 + "wrapped": {
  6213 + "type": "string"
  6214 + }
  6215 + }
  6216 + },
  6217 + "type": "object"
  6218 + }
  6219 + }
  6220 + }
  6221 + },
  6222 + "type": "object"
  6223 + },
  6224 + "VisualGamePackList": {
  6225 + "properties": {
  6226 + "packs": {
  6227 + "item": {
  6228 + "type": "unknown"
  6229 + },
  6230 + "type": "array"
  6231 + },
  6232 + "sections": {
  6233 + "item": {
  6234 + "properties": {
  6235 + "description": {
  6236 + "type": "optional",
  6237 + "wrapped": {
  6238 + "type": "string"
  6239 + }
  6240 + },
  6241 + "file": {
  6242 + "type": "string"
  6243 + },
  6244 + "id": {
  6245 + "type": "string"
  6246 + },
  6247 + "title": {
  6248 + "type": "string"
  6249 + },
  6250 + "value": {
  6251 + "type": "optional",
  6252 + "wrapped": {
  6253 + "type": "unknown"
  6254 + }
  6255 + }
  6256 + },
  6257 + "type": "object"
  6258 + },
  6259 + "type": "array"
  6260 + }
  6261 + },
  6262 + "type": "object"
  6263 + },
  6264 + "VisualGamePreviewTokenResponse": {
  6265 + "properties": {
  6266 + "expires_at": {
  6267 + "type": "string"
  6268 + },
  6269 + "token": {
  6270 + "type": "string"
  6271 + }
  6272 + },
  6273 + "type": "object"
  6274 + },
  6275 + "VisualGameRunDetail": {
  6276 + "properties": {
  6277 + "available_clues": {
  6278 + "item": {
  6279 + "type": "unknown"
  6280 + },
  6281 + "type": "array"
  6282 + },
  6283 + "available_conclusions": {
  6284 + "type": "optional",
  6285 + "wrapped": {
  6286 + "item": {
  6287 + "type": "unknown"
  6288 + },
  6289 + "type": "array"
  6290 + }
  6291 + },
  6292 + "ending_records": {
  6293 + "type": "optional",
  6294 + "wrapped": {
  6295 + "item": {
  6296 + "type": "unknown"
  6297 + },
  6298 + "type": "array"
  6299 + }
  6300 + },
  6301 + "known_clues": {
  6302 + "item": {
  6303 + "type": "unknown"
  6304 + },
  6305 + "type": "array"
  6306 + },
  6307 + "known_conclusions": {
  6308 + "type": "optional",
  6309 + "wrapped": {
  6310 + "item": {
  6311 + "type": "unknown"
  6312 + },
  6313 + "type": "array"
  6314 + }
  6315 + },
  6316 + "run": {
  6317 + "type": "unknown"
  6318 + },
  6319 + "scenario": {
  6320 + "type": "unknown"
  6321 + },
  6322 + "turn_video_stage": {
  6323 + "type": "nullable",
  6324 + "wrapped": {
  6325 + "properties": {
  6326 + "end_frame": {
  6327 + "properties": {
  6328 + "hash": {
  6329 + "type": "string"
  6330 + },
  6331 + "id": {
  6332 + "type": "string"
  6333 + },
  6334 + "mime": {
  6335 + "type": "string"
  6336 + },
  6337 + "url": {
  6338 + "type": "string"
  6339 + }
  6340 + },
  6341 + "type": "object"
  6342 + },
  6343 + "turn_id": {
  6344 + "type": "string"
  6345 + }
  6346 + },
  6347 + "type": "object"
  6348 + }
  6349 + },
  6350 + "unlocked_info": {
  6351 + "item": {
  6352 + "type": "unknown"
  6353 + },
  6354 + "type": "array"
  6355 + }
  6356 + },
  6357 + "type": "object"
  6358 + },
  6359 + "VisualGameScenarioListItem": {
  6360 + "properties": {
  6361 + "asset_manifest_id": {
  6362 + "type": "optional",
  6363 + "wrapped": {
  6364 + "type": "string"
  6365 + }
  6366 + },
  6367 + "author_avatar_url": {
  6368 + "type": "nullable",
  6369 + "wrapped": {
  6370 + "type": "string"
  6371 + }
  6372 + },
  6373 + "author_id": {
  6374 + "type": "nullable",
  6375 + "wrapped": {
  6376 + "type": "string"
  6377 + }
  6378 + },
  6379 + "author_name": {
  6380 + "type": "nullable",
  6381 + "wrapped": {
  6382 + "type": "string"
  6383 + }
  6384 + },
  6385 + "cover_url": {
  6386 + "type": "optional",
  6387 + "wrapped": {
  6388 + "type": "string"
  6389 + }
  6390 + },
  6391 + "created_at": {
  6392 + "type": "optional",
  6393 + "wrapped": {
  6394 + "type": "nullable",
  6395 + "wrapped": {
  6396 + "type": "unknown"
  6397 + }
  6398 + }
  6399 + },
  6400 + "ending_count": {
  6401 + "type": "number"
  6402 + },
  6403 + "episode_no": {
  6404 + "type": "nullable",
  6405 + "wrapped": {
  6406 + "type": "number"
  6407 + }
  6408 + },
  6409 + "id": {
  6410 + "type": "string"
  6411 + },
  6412 + "like_count": {
  6413 + "type": "number"
  6414 + },
  6415 + "play_count": {
  6416 + "type": "number"
  6417 + },
  6418 + "play_state": {
  6419 + "options": [
  6420 + "available",
  6421 + "coming_soon"
  6422 + ],
  6423 + "type": "picklist"
  6424 + },
  6425 + "portrait_cover_url": {
  6426 + "type": "nullable",
  6427 + "wrapped": {
  6428 + "type": "string"
  6429 + }
  6430 + },
  6431 + "series_cover_url": {
  6432 + "type": "optional",
  6433 + "wrapped": {
  6434 + "type": "nullable",
  6435 + "wrapped": {
  6436 + "type": "string"
  6437 + }
  6438 + }
  6439 + },
  6440 + "series_id": {
  6441 + "type": "nullable",
  6442 + "wrapped": {
  6443 + "type": "string"
  6444 + }
  6445 + },
  6446 + "series_title": {
  6447 + "type": "nullable",
  6448 + "wrapped": {
  6449 + "type": "string"
  6450 + }
  6451 + },
  6452 + "status": {
  6453 + "type": "optional",
  6454 + "wrapped": {
  6455 + "options": [
  6456 + "coming_soon",
  6457 + "published"
  6458 + ],
  6459 + "type": "picklist"
  6460 + }
  6461 + },
  6462 + "summary": {
  6463 + "type": "string"
  6464 + },
  6465 + "tagline": {
  6466 + "type": "optional",
  6467 + "wrapped": {
  6468 + "type": "string"
  6469 + }
  6470 + },
  6471 + "tags": {
  6472 + "item": {
  6473 + "type": "string"
  6474 + },
  6475 + "type": "array"
  6476 + },
  6477 + "title": {
  6478 + "type": "string"
  6479 + },
  6480 + "updated_at": {
  6481 + "type": "optional",
  6482 + "wrapped": {
  6483 + "type": "nullable",
  6484 + "wrapped": {
  6485 + "type": "unknown"
  6486 + }
  6487 + }
  6488 + }
  6489 + },
  6490 + "type": "object"
  6491 + },
  6492 + "VisualGameScenarioPage": {
  6493 + "properties": {
  6494 + "has_more": {
  6495 + "type": "boolean"
  6496 + },
  6497 + "items": {
  6498 + "item": {
  6499 + "properties": {
  6500 + "asset_manifest_id": {
  6501 + "type": "optional",
  6502 + "wrapped": {
  6503 + "type": "string"
  6504 + }
  6505 + },
  6506 + "author_avatar_url": {
  6507 + "type": "nullable",
  6508 + "wrapped": {
  6509 + "type": "string"
  6510 + }
  6511 + },
  6512 + "author_id": {
  6513 + "type": "nullable",
  6514 + "wrapped": {
  6515 + "type": "string"
  6516 + }
  6517 + },
  6518 + "author_name": {
  6519 + "type": "nullable",
  6520 + "wrapped": {
  6521 + "type": "string"
  6522 + }
  6523 + },
  6524 + "cover_url": {
  6525 + "type": "optional",
  6526 + "wrapped": {
  6527 + "type": "string"
  6528 + }
  6529 + },
  6530 + "created_at": {
  6531 + "type": "optional",
  6532 + "wrapped": {
  6533 + "type": "nullable",
  6534 + "wrapped": {
  6535 + "type": "unknown"
  6536 + }
  6537 + }
  6538 + },
  6539 + "ending_count": {
  6540 + "type": "number"
  6541 + },
  6542 + "episode_no": {
  6543 + "type": "nullable",
  6544 + "wrapped": {
  6545 + "type": "number"
  6546 + }
  6547 + },
  6548 + "id": {
  6549 + "type": "string"
  6550 + },
  6551 + "like_count": {
  6552 + "type": "number"
  6553 + },
  6554 + "play_count": {
  6555 + "type": "number"
  6556 + },
  6557 + "play_state": {
  6558 + "options": [
  6559 + "available",
  6560 + "coming_soon"
  6561 + ],
  6562 + "type": "picklist"
  6563 + },
  6564 + "portrait_cover_url": {
  6565 + "type": "nullable",
  6566 + "wrapped": {
  6567 + "type": "string"
  6568 + }
  6569 + },
  6570 + "series_cover_url": {
  6571 + "type": "optional",
  6572 + "wrapped": {
  6573 + "type": "nullable",
  6574 + "wrapped": {
  6575 + "type": "string"
  6576 + }
  6577 + }
  6578 + },
  6579 + "series_id": {
  6580 + "type": "nullable",
  6581 + "wrapped": {
  6582 + "type": "string"
  6583 + }
  6584 + },
  6585 + "series_title": {
  6586 + "type": "nullable",
  6587 + "wrapped": {
  6588 + "type": "string"
  6589 + }
  6590 + },
  6591 + "status": {
  6592 + "type": "optional",
  6593 + "wrapped": {
  6594 + "options": [
  6595 + "coming_soon",
  6596 + "published"
  6597 + ],
  6598 + "type": "picklist"
  6599 + }
  6600 + },
  6601 + "summary": {
  6602 + "type": "string"
  6603 + },
  6604 + "tagline": {
  6605 + "type": "optional",
  6606 + "wrapped": {
  6607 + "type": "string"
  6608 + }
  6609 + },
  6610 + "tags": {
  6611 + "item": {
  6612 + "type": "string"
  6613 + },
  6614 + "type": "array"
  6615 + },
  6616 + "title": {
  6617 + "type": "string"
  6618 + },
  6619 + "updated_at": {
  6620 + "type": "optional",
  6621 + "wrapped": {
  6622 + "type": "nullable",
  6623 + "wrapped": {
  6624 + "type": "unknown"
  6625 + }
  6626 + }
  6627 + }
  6628 + },
  6629 + "type": "object"
  6630 + },
  6631 + "type": "array"
  6632 + },
  6633 + "page": {
  6634 + "type": "number"
  6635 + },
  6636 + "page_size": {
  6637 + "type": "number"
  6638 + },
  6639 + "total": {
  6640 + "type": "number"
  6641 + }
  6642 + },
  6643 + "type": "object"
  6644 + },
  6645 + "VisualGameScenarioReleaseApplyResponse": {
  6646 + "properties": {
  6647 + "assets_copied": {
  6648 + "type": "number"
  6649 + },
  6650 + "assets_reused": {
  6651 + "type": "number"
  6652 + },
  6653 + "bytes_copied": {
  6654 + "type": "number"
  6655 + },
  6656 + "current_version_id": {
  6657 + "type": "string"
  6658 + },
  6659 + "ok": {
  6660 + "type": "literal",
  6661 + "value": true
  6662 + },
  6663 + "previous_version_id": {
  6664 + "type": "nullable",
  6665 + "wrapped": {
  6666 + "type": "string"
  6667 + }
  6668 + },
  6669 + "request_id": {
  6670 + "type": "string"
  6671 + },
  6672 + "scenario_id": {
  6673 + "type": "string"
  6674 + },
  6675 + "version": {
  6676 + "type": "number"
  6677 + }
  6678 + },
  6679 + "type": "strict_object"
  6680 + },
  6681 + "VisualGameScenarioReleaseBundle": {
  6682 + "properties": {
  6683 + "pack": {
  6684 + "properties": {
  6685 + "created_at": {
  6686 + "type": "string"
  6687 + },
  6688 + "current_version_id": {
  6689 + "type": "string"
  6690 + },
  6691 + "episode_no": {
  6692 + "type": "nullable",
  6693 + "wrapped": {
  6694 + "type": "number"
  6695 + }
  6696 + },
  6697 + "id": {
  6698 + "type": "string"
  6699 + },
  6700 + "locale": {
  6701 + "type": "string"
  6702 + },
  6703 + "series_id": {
  6704 + "type": "nullable",
  6705 + "wrapped": {
  6706 + "type": "string"
  6707 + }
  6708 + },
  6709 + "status": {
  6710 + "type": "literal",
  6711 + "value": "published"
  6712 + },
  6713 + "tagline": {
  6714 + "type": "nullable",
  6715 + "wrapped": {
  6716 + "type": "string"
  6717 + }
  6718 + },
  6719 + "tags_json": {
  6720 + "item": {
  6721 + "type": "string"
  6722 + },
  6723 + "type": "array"
  6724 + },
  6725 + "title": {
  6726 + "type": "string"
  6727 + },
  6728 + "updated_at": {
  6729 + "type": "string"
  6730 + },
  6731 + "visibility": {
  6732 + "type": "string"
  6733 + }
  6734 + },
  6735 + "type": "strict_object"
  6736 + },
  6737 + "publish_checks": {
  6738 + "item": {
  6739 + "properties": {
  6740 + "check_type": {
  6741 + "type": "string"
  6742 + },
  6743 + "created_at": {
  6744 + "type": "string"
  6745 + },
  6746 + "id": {
  6747 + "type": "string"
  6748 + },
  6749 + "message": {
  6750 + "type": "nullable",
  6751 + "wrapped": {
  6752 + "type": "string"
  6753 + }
  6754 + },
  6755 + "status": {
  6756 + "type": "string"
  6757 + },
  6758 + "target_ref": {
  6759 + "type": "nullable",
  6760 + "wrapped": {
  6761 + "type": "string"
  6762 + }
  6763 + },
  6764 + "updated_at": {
  6765 + "type": "string"
  6766 + },
  6767 + "version_id": {
  6768 + "type": "string"
  6769 + }
  6770 + },
  6771 + "type": "strict_object"
  6772 + },
  6773 + "type": "array"
  6774 + },
  6775 + "runtime_translation_manifests": {
  6776 + "item": {
  6777 + "properties": {
  6778 + "created_at": {
  6779 + "type": "string"
  6780 + },
  6781 + "entries_json": {
  6782 + "type": "string"
  6783 + },
  6784 + "locale": {
  6785 + "type": "string"
  6786 + },
  6787 + "schema_version": {
  6788 + "type": "number"
  6789 + },
  6790 + "updated_at": {
  6791 + "type": "string"
  6792 + },
  6793 + "version_id": {
  6794 + "type": "string"
  6795 + }
  6796 + },
  6797 + "type": "strict_object"
  6798 + },
  6799 + "type": "array"
  6800 + },
  6801 + "series": {
  6802 + "type": "nullable",
  6803 + "wrapped": {
  6804 + "properties": {
  6805 + "cover_url": {
  6806 + "type": "nullable",
  6807 + "wrapped": {
  6808 + "type": "string"
  6809 + }
  6810 + },
  6811 + "created_at": {
  6812 + "type": "string"
  6813 + },
  6814 + "id": {
  6815 + "type": "string"
  6816 + },
  6817 + "locale": {
  6818 + "type": "string"
  6819 + },
  6820 + "status": {
  6821 + "type": "string"
  6822 + },
  6823 + "tagline": {
  6824 + "type": "nullable",
  6825 + "wrapped": {
  6826 + "type": "string"
  6827 + }
  6828 + },
  6829 + "title": {
  6830 + "type": "string"
  6831 + },
  6832 + "updated_at": {
  6833 + "type": "string"
  6834 + },
  6835 + "visibility": {
  6836 + "type": "string"
  6837 + }
  6838 + },
  6839 + "type": "strict_object"
  6840 + }
  6841 + },
  6842 + "translations": {
  6843 + "item": {
  6844 + "properties": {
  6845 + "content": {
  6846 + "type": "string"
  6847 + },
  6848 + "created_at": {
  6849 + "type": "string"
  6850 + },
  6851 + "entity_id": {
  6852 + "type": "string"
  6853 + },
  6854 + "entity_type": {
  6855 + "options": [
  6856 + "ending",
  6857 + "scenario_pack",
  6858 + "scenario_version",
  6859 + "series"
  6860 + ],
  6861 + "type": "picklist"
  6862 + },
  6863 + "field": {
  6864 + "options": [
  6865 + "description",
  6866 + "runtime_texts",
  6867 + "summary",
  6868 + "tagline",
  6869 + "title"
  6870 + ],
  6871 + "type": "picklist"
  6872 + },
  6873 + "locale": {
  6874 + "options": [
  6875 + "en",
  6876 + "es",
  6877 + "ja",
  6878 + "ko",
  6879 + "pt",
  6880 + "zh-CN"
  6881 + ],
  6882 + "type": "picklist"
  6883 + },
  6884 + "updated_at": {
  6885 + "type": "string"
  6886 + }
  6887 + },
  6888 + "type": "strict_object"
  6889 + },
  6890 + "type": "array"
  6891 + },
  6892 + "version": {
  6893 + "properties": {
  6894 + "asset_manifest_id": {
  6895 + "type": "nullable",
  6896 + "wrapped": {
  6897 + "type": "string"
  6898 + }
  6899 + },
  6900 + "compile_result_json": {
  6901 + "type": "string"
  6902 + },
  6903 + "compiler_version": {
  6904 + "type": "nullable",
  6905 + "wrapped": {
  6906 + "type": "string"
  6907 + }
  6908 + },
  6909 + "created_at": {
  6910 + "type": "string"
  6911 + },
  6912 + "id": {
  6913 + "type": "string"
  6914 + },
  6915 + "pack_id": {
  6916 + "type": "string"
  6917 + },
  6918 + "portrait_cover_tag": {
  6919 + "type": "nullable",
  6920 + "wrapped": {
  6921 + "type": "string"
  6922 + }
  6923 + },
  6924 + "published_at": {
  6925 + "type": "string"
  6926 + },
  6927 + "scenario_snapshot_json": {
  6928 + "type": "string"
  6929 + },
  6930 + "status": {
  6931 + "type": "literal",
  6932 + "value": "published"
  6933 + },
  6934 + "tagline": {
  6935 + "type": "nullable",
  6936 + "wrapped": {
  6937 + "type": "string"
  6938 + }
  6939 + },
  6940 + "title": {
  6941 + "type": "string"
  6942 + },
  6943 + "updated_at": {
  6944 + "type": "string"
  6945 + },
  6946 + "version": {
  6947 + "type": "number"
  6948 + }
  6949 + },
  6950 + "type": "strict_object"
  6951 + },
  6952 + "version_assets": {
  6953 + "item": {
  6954 + "properties": {
  6955 + "asset_id": {
  6956 + "type": "string"
  6957 + },
  6958 + "created_at": {
  6959 + "type": "string"
  6960 + },
  6961 + "hash": {
  6962 + "type": "nullable",
  6963 + "wrapped": {
  6964 + "type": "string"
  6965 + }
  6966 + },
  6967 + "mime": {
  6968 + "type": "nullable",
  6969 + "wrapped": {
  6970 + "type": "string"
  6971 + }
  6972 + },
  6973 + "object_key": {
  6974 + "type": "nullable",
  6975 + "wrapped": {
  6976 + "type": "string"
  6977 + }
  6978 + },
  6979 + "public_url": {
  6980 + "type": "nullable",
  6981 + "wrapped": {
  6982 + "type": "string"
  6983 + }
  6984 + },
  6985 + "size": {
  6986 + "type": "nullable",
  6987 + "wrapped": {
  6988 + "type": "number"
  6989 + }
  6990 + },
  6991 + "tag": {
  6992 + "type": "string"
  6993 + },
  6994 + "updated_at": {
  6995 + "type": "string"
  6996 + },
  6997 + "version_id": {
  6998 + "type": "string"
  6999 + }
  7000 + },
  7001 + "type": "strict_object"
  7002 + },
  7003 + "type": "array"
  7004 + }
  7005 + },
  7006 + "type": "strict_object"
  7007 + },
  7008 + "VisualGameScenarioReleaseRecord": {
  7009 + "properties": {
  7010 + "applied_version_id": {
  7011 + "type": "nullable",
  7012 + "wrapped": {
  7013 + "type": "string"
  7014 + }
  7015 + },
  7016 + "asset_bytes": {
  7017 + "type": "number"
  7018 + },
  7019 + "asset_count": {
  7020 + "type": "number"
  7021 + },
  7022 + "assets_copied": {
  7023 + "type": "number"
  7024 + },
  7025 + "assets_reused": {
  7026 + "type": "number"
  7027 + },
  7028 + "bundle_checksum": {
  7029 + "type": "string"
  7030 + },
  7031 + "bytes_copied": {
  7032 + "type": "number"
  7033 + },
  7034 + "completed_at": {
  7035 + "type": "nullable",
  7036 + "wrapped": {
  7037 + "type": "string"
  7038 + }
  7039 + },
  7040 + "created_at": {
  7041 + "type": "string"
  7042 + },
  7043 + "direction": {
  7044 + "options": [
  7045 + "inbound",
  7046 + "outbound"
  7047 + ],
  7048 + "type": "picklist"
  7049 + },
  7050 + "error_code": {
  7051 + "type": "nullable",
  7052 + "wrapped": {
  7053 + "type": "string"
  7054 + }
  7055 + },
  7056 + "error_message": {
  7057 + "type": "nullable",
  7058 + "wrapped": {
  7059 + "type": "string"
  7060 + }
  7061 + },
  7062 + "id": {
  7063 + "type": "string"
  7064 + },
  7065 + "previous_version_id": {
  7066 + "type": "nullable",
  7067 + "wrapped": {
  7068 + "type": "string"
  7069 + }
  7070 + },
  7071 + "requested_by": {
  7072 + "type": "string"
  7073 + },
  7074 + "scenario_id": {
  7075 + "type": "string"
  7076 + },
  7077 + "source_version_id": {
  7078 + "type": "string"
  7079 + },
  7080 + "started_at": {
  7081 + "type": "string"
  7082 + },
  7083 + "status": {
  7084 + "options": [
  7085 + "applying",
  7086 + "failed",
  7087 + "prepared",
  7088 + "succeeded",
  7089 + "transferring"
  7090 + ],
  7091 + "type": "picklist"
  7092 + },
  7093 + "target_url": {
  7094 + "type": "nullable",
  7095 + "wrapped": {
  7096 + "type": "string"
  7097 + }
  7098 + },
  7099 + "updated_at": {
  7100 + "type": "nullable",
  7101 + "wrapped": {
  7102 + "type": "string"
  7103 + }
  7104 + }
  7105 + },
  7106 + "type": "strict_object"
  7107 + },
  7108 + "VisualGameScenarioReleaseRequest": {
  7109 + "properties": {
  7110 + "bundle": {
  7111 + "properties": {
  7112 + "pack": {
  7113 + "properties": {
  7114 + "created_at": {
  7115 + "type": "string"
  7116 + },
  7117 + "current_version_id": {
  7118 + "type": "string"
  7119 + },
  7120 + "episode_no": {
  7121 + "type": "nullable",
  7122 + "wrapped": {
  7123 + "type": "number"
  7124 + }
  7125 + },
  7126 + "id": {
  7127 + "type": "string"
  7128 + },
  7129 + "locale": {
  7130 + "type": "string"
  7131 + },
  7132 + "series_id": {
  7133 + "type": "nullable",
  7134 + "wrapped": {
  7135 + "type": "string"
  7136 + }
  7137 + },
  7138 + "status": {
  7139 + "type": "literal",
  7140 + "value": "published"
  7141 + },
  7142 + "tagline": {
  7143 + "type": "nullable",
  7144 + "wrapped": {
  7145 + "type": "string"
  7146 + }
  7147 + },
  7148 + "tags_json": {
  7149 + "item": {
  7150 + "type": "string"
  7151 + },
  7152 + "type": "array"
  7153 + },
  7154 + "title": {
  7155 + "type": "string"
  7156 + },
  7157 + "updated_at": {
  7158 + "type": "string"
  7159 + },
  7160 + "visibility": {
  7161 + "type": "string"
  7162 + }
  7163 + },
  7164 + "type": "strict_object"
  7165 + },
  7166 + "publish_checks": {
  7167 + "item": {
  7168 + "properties": {
  7169 + "check_type": {
  7170 + "type": "string"
  7171 + },
  7172 + "created_at": {
  7173 + "type": "string"
  7174 + },
  7175 + "id": {
  7176 + "type": "string"
  7177 + },
  7178 + "message": {
  7179 + "type": "nullable",
  7180 + "wrapped": {
  7181 + "type": "string"
  7182 + }
  7183 + },
  7184 + "status": {
  7185 + "type": "string"
  7186 + },
  7187 + "target_ref": {
  7188 + "type": "nullable",
  7189 + "wrapped": {
  7190 + "type": "string"
  7191 + }
  7192 + },
  7193 + "updated_at": {
  7194 + "type": "string"
  7195 + },
  7196 + "version_id": {
  7197 + "type": "string"
  7198 + }
  7199 + },
  7200 + "type": "strict_object"
  7201 + },
  7202 + "type": "array"
  7203 + },
  7204 + "runtime_translation_manifests": {
  7205 + "item": {
  7206 + "properties": {
  7207 + "created_at": {
  7208 + "type": "string"
  7209 + },
  7210 + "entries_json": {
  7211 + "type": "string"
  7212 + },
  7213 + "locale": {
  7214 + "type": "string"
  7215 + },
  7216 + "schema_version": {
  7217 + "type": "number"
  7218 + },
  7219 + "updated_at": {
  7220 + "type": "string"
  7221 + },
  7222 + "version_id": {
  7223 + "type": "string"
  7224 + }
  7225 + },
  7226 + "type": "strict_object"
  7227 + },
  7228 + "type": "array"
  7229 + },
  7230 + "series": {
  7231 + "type": "nullable",
  7232 + "wrapped": {
  7233 + "properties": {
  7234 + "cover_url": {
  7235 + "type": "nullable",
  7236 + "wrapped": {
  7237 + "type": "string"
  7238 + }
  7239 + },
  7240 + "created_at": {
  7241 + "type": "string"
  7242 + },
  7243 + "id": {
  7244 + "type": "string"
  7245 + },
  7246 + "locale": {
  7247 + "type": "string"
  7248 + },
  7249 + "status": {
  7250 + "type": "string"
  7251 + },
  7252 + "tagline": {
  7253 + "type": "nullable",
  7254 + "wrapped": {
  7255 + "type": "string"
  7256 + }
  7257 + },
  7258 + "title": {
  7259 + "type": "string"
  7260 + },
  7261 + "updated_at": {
  7262 + "type": "string"
  7263 + },
  7264 + "visibility": {
  7265 + "type": "string"
  7266 + }
  7267 + },
  7268 + "type": "strict_object"
  7269 + }
  7270 + },
  7271 + "translations": {
  7272 + "item": {
  7273 + "properties": {
  7274 + "content": {
  7275 + "type": "string"
  7276 + },
  7277 + "created_at": {
  7278 + "type": "string"
  7279 + },
  7280 + "entity_id": {
  7281 + "type": "string"
  7282 + },
  7283 + "entity_type": {
  7284 + "options": [
  7285 + "ending",
  7286 + "scenario_pack",
  7287 + "scenario_version",
  7288 + "series"
  7289 + ],
  7290 + "type": "picklist"
  7291 + },
  7292 + "field": {
  7293 + "options": [
  7294 + "description",
  7295 + "runtime_texts",
  7296 + "summary",
  7297 + "tagline",
  7298 + "title"
  7299 + ],
  7300 + "type": "picklist"
  7301 + },
  7302 + "locale": {
  7303 + "options": [
  7304 + "en",
  7305 + "es",
  7306 + "ja",
  7307 + "ko",
  7308 + "pt",
  7309 + "zh-CN"
  7310 + ],
  7311 + "type": "picklist"
  7312 + },
  7313 + "updated_at": {
  7314 + "type": "string"
  7315 + }
  7316 + },
  7317 + "type": "strict_object"
  7318 + },
  7319 + "type": "array"
  7320 + },
  7321 + "version": {
  7322 + "properties": {
  7323 + "asset_manifest_id": {
  7324 + "type": "nullable",
  7325 + "wrapped": {
  7326 + "type": "string"
  7327 + }
  7328 + },
  7329 + "compile_result_json": {
  7330 + "type": "string"
  7331 + },
  7332 + "compiler_version": {
  7333 + "type": "nullable",
  7334 + "wrapped": {
  7335 + "type": "string"
  7336 + }
  7337 + },
  7338 + "created_at": {
  7339 + "type": "string"
  7340 + },
  7341 + "id": {
  7342 + "type": "string"
  7343 + },
  7344 + "pack_id": {
  7345 + "type": "string"
  7346 + },
  7347 + "portrait_cover_tag": {
  7348 + "type": "nullable",
  7349 + "wrapped": {
  7350 + "type": "string"
  7351 + }
  7352 + },
  7353 + "published_at": {
  7354 + "type": "string"
  7355 + },
  7356 + "scenario_snapshot_json": {
  7357 + "type": "string"
  7358 + },
  7359 + "status": {
  7360 + "type": "literal",
  7361 + "value": "published"
  7362 + },
  7363 + "tagline": {
  7364 + "type": "nullable",
  7365 + "wrapped": {
  7366 + "type": "string"
  7367 + }
  7368 + },
  7369 + "title": {
  7370 + "type": "string"
  7371 + },
  7372 + "updated_at": {
  7373 + "type": "string"
  7374 + },
  7375 + "version": {
  7376 + "type": "number"
  7377 + }
  7378 + },
  7379 + "type": "strict_object"
  7380 + },
  7381 + "version_assets": {
  7382 + "item": {
  7383 + "properties": {
  7384 + "asset_id": {
  7385 + "type": "string"
  7386 + },
  7387 + "created_at": {
  7388 + "type": "string"
  7389 + },
  7390 + "hash": {
  7391 + "type": "nullable",
  7392 + "wrapped": {
  7393 + "type": "string"
  7394 + }
  7395 + },
  7396 + "mime": {
  7397 + "type": "nullable",
  7398 + "wrapped": {
  7399 + "type": "string"
  7400 + }
  7401 + },
  7402 + "object_key": {
  7403 + "type": "nullable",
  7404 + "wrapped": {
  7405 + "type": "string"
  7406 + }
  7407 + },
  7408 + "public_url": {
  7409 + "type": "nullable",
  7410 + "wrapped": {
  7411 + "type": "string"
  7412 + }
  7413 + },
  7414 + "size": {
  7415 + "type": "nullable",
  7416 + "wrapped": {
  7417 + "type": "number"
  7418 + }
  7419 + },
  7420 + "tag": {
  7421 + "type": "string"
  7422 + },
  7423 + "updated_at": {
  7424 + "type": "string"
  7425 + },
  7426 + "version_id": {
  7427 + "type": "string"
  7428 + }
  7429 + },
  7430 + "type": "strict_object"
  7431 + },
  7432 + "type": "array"
  7433 + }
  7434 + },
  7435 + "type": "strict_object"
  7436 + },
  7437 + "bundle_checksum": {
  7438 + "type": "string"
  7439 + },
  7440 + "release_id": {
  7441 + "type": "string"
  7442 + },
  7443 + "requested_by": {
  7444 + "type": "string"
  7445 + }
  7446 + },
  7447 + "type": "strict_object"
  7448 + },
  7449 + "VisualGameScenarioReleaseTargetState": {
  7450 + "properties": {
  7451 + "assets": {
  7452 + "type": "number"
  7453 + },
  7454 + "current_version_id": {
  7455 + "type": "nullable",
  7456 + "wrapped": {
  7457 + "type": "string"
  7458 + }
  7459 + },
  7460 + "exists": {
  7461 + "type": "boolean"
  7462 + },
  7463 + "published_at": {
  7464 + "type": "nullable",
  7465 + "wrapped": {
  7466 + "type": "string"
  7467 + }
  7468 + },
  7469 + "scenario_id": {
  7470 + "type": "string"
  7471 + },
  7472 + "title": {
  7473 + "type": "nullable",
  7474 + "wrapped": {
  7475 + "type": "string"
  7476 + }
  7477 + },
  7478 + "version": {
  7479 + "type": "nullable",
  7480 + "wrapped": {
  7481 + "type": "number"
  7482 + }
  7483 + }
  7484 + },
  7485 + "type": "strict_object"
  7486 + },
  7487 + "VisualGameScenarioVersion": {
  7488 + "properties": {
  7489 + "asset_manifest_id": {
  7490 + "type": "optional",
  7491 + "wrapped": {
  7492 + "type": "string"
  7493 + }
  7494 + },
  7495 + "compile_result": {
  7496 + "type": "optional",
  7497 + "wrapped": {
  7498 + "type": "unknown"
  7499 + }
  7500 + },
  7501 + "created_at": {
  7502 + "type": "optional",
  7503 + "wrapped": {
  7504 + "type": "nullable",
  7505 + "wrapped": {
  7506 + "type": "unknown"
  7507 + }
  7508 + }
  7509 + },
  7510 + "id": {
  7511 + "type": "string"
  7512 + },
  7513 + "is_current": {
  7514 + "type": "optional",
  7515 + "wrapped": {
  7516 + "type": "boolean"
  7517 + }
  7518 + },
  7519 + "pack_id": {
  7520 + "type": "string"
  7521 + },
  7522 + "published_at": {
  7523 + "type": "optional",
  7524 + "wrapped": {
  7525 + "type": "nullable",
  7526 + "wrapped": {
  7527 + "type": "unknown"
  7528 + }
  7529 + }
  7530 + },
  7531 + "scenario": {
  7532 + "type": "optional",
  7533 + "wrapped": {
  7534 + "type": "unknown"
  7535 + }
  7536 + },
  7537 + "status": {
  7538 + "type": "string"
  7539 + },
  7540 + "tagline": {
  7541 + "type": "optional",
  7542 + "wrapped": {
  7543 + "type": "string"
  7544 + }
  7545 + },
  7546 + "title": {
  7547 + "type": "string"
  7548 + },
  7549 + "version": {
  7550 + "type": "number"
  7551 + }
  7552 + },
  7553 + "type": "object"
  7554 + },
  7555 + "VisualGameStoryVideoLog": {
  7556 + "properties": {
  7557 + "run": {
  7558 + "type": "unknown"
  7559 + },
  7560 + "run_id": {
  7561 + "type": "string"
  7562 + },
  7563 + "turns": {
  7564 + "item": {
  7565 + "properties": {
  7566 + "playback_plan": {
  7567 + "properties": {
  7568 + "segments": {
  7569 + "item": {
  7570 + "options": [
  7571 + {
  7572 + "properties": {
  7573 + "kind": {
  7574 + "type": "literal",
  7575 + "value": "action"
  7576 + },
  7577 + "match_method": {
  7578 + "options": [
  7579 + "exact_transition",
  7580 + "intent_video",
  7581 + "semantic_reuse"
  7582 + ],
  7583 + "type": "picklist"
  7584 + },
  7585 + "order": {
  7586 + "type": "number"
  7587 + },
  7588 + "similarity": {
  7589 + "type": "nullable",
  7590 + "wrapped": {
  7591 + "type": "number"
  7592 + }
  7593 + },
  7594 + "video": {
  7595 + "properties": {
  7596 + "asset": {
  7597 + "properties": {
  7598 + "hash": {
  7599 + "type": "string"
  7600 + },
  7601 + "id": {
  7602 + "type": "string"
  7603 + },
  7604 + "mime": {
  7605 + "type": "string"
  7606 + },
  7607 + "url": {
  7608 + "type": "string"
  7609 + }
  7610 + },
  7611 + "type": "object"
  7612 + },
  7613 + "end_frame": {
  7614 + "properties": {
  7615 + "hash": {
  7616 + "type": "string"
  7617 + },
  7618 + "id": {
  7619 + "type": "string"
  7620 + },
  7621 + "mime": {
  7622 + "type": "string"
  7623 + },
  7624 + "url": {
  7625 + "type": "string"
  7626 + }
  7627 + },
  7628 + "type": "object"
  7629 + },
  7630 + "start_frame": {
  7631 + "properties": {
  7632 + "hash": {
  7633 + "type": "string"
  7634 + },
  7635 + "id": {
  7636 + "type": "string"
  7637 + },
  7638 + "mime": {
  7639 + "type": "string"
  7640 + },
  7641 + "url": {
  7642 + "type": "string"
  7643 + }
  7644 + },
  7645 + "type": "object"
  7646 + }
  7647 + },
  7648 + "type": "object"
  7649 + }
  7650 + },
  7651 + "type": "object"
  7652 + },
  7653 + {
  7654 + "properties": {
  7655 + "actor_id": {
  7656 + "type": "string"
  7657 + },
  7658 + "actor_type": {
  7659 + "options": [
  7660 + "npc",
  7661 + "player"
  7662 + ],
  7663 + "type": "picklist"
  7664 + },
  7665 + "expression_state": {
  7666 + "type": "string"
  7667 + },
  7668 + "kind": {
  7669 + "type": "literal",
  7670 + "value": "speech"
  7671 + },
  7672 + "loopable": {
  7673 + "type": "boolean"
  7674 + },
  7675 + "order": {
  7676 + "type": "number"
  7677 + },
  7678 + "text": {
  7679 + "type": "string"
  7680 + },
  7681 + "video": {
  7682 + "properties": {
  7683 + "asset": {
  7684 + "properties": {
  7685 + "hash": {
  7686 + "type": "string"
  7687 + },
  7688 + "id": {
  7689 + "type": "string"
  7690 + },
  7691 + "mime": {
  7692 + "type": "string"
  7693 + },
  7694 + "url": {
  7695 + "type": "string"
  7696 + }
  7697 + },
  7698 + "type": "object"
  7699 + },
  7700 + "end_frame": {
  7701 + "properties": {
  7702 + "hash": {
  7703 + "type": "string"
  7704 + },
  7705 + "id": {
  7706 + "type": "string"
  7707 + },
  7708 + "mime": {
  7709 + "type": "string"
  7710 + },
  7711 + "url": {
  7712 + "type": "string"
  7713 + }
  7714 + },
  7715 + "type": "object"
  7716 + },
  7717 + "start_frame": {
  7718 + "properties": {
  7719 + "hash": {
  7720 + "type": "string"
  7721 + },
  7722 + "id": {
  7723 + "type": "string"
  7724 + },
  7725 + "mime": {
  7726 + "type": "string"
  7727 + },
  7728 + "url": {
  7729 + "type": "string"
  7730 + }
  7731 + },
  7732 + "type": "object"
  7733 + }
  7734 + },
  7735 + "type": "object"
  7736 + }
  7737 + },
  7738 + "type": "object"
  7739 + }
  7740 + ],
  7741 + "type": "union"
  7742 + },
  7743 + "type": "array"
  7744 + },
  7745 + "turn_id": {
  7746 + "type": "string"
  7747 + }
  7748 + },
  7749 + "type": "object"
  7750 + },
  7751 + "replay_plan": {
  7752 + "type": "nullable",
  7753 + "wrapped": {
  7754 + "properties": {
  7755 + "segments": {
  7756 + "item": {
  7757 + "options": [
  7758 + {
  7759 + "properties": {
  7760 + "kind": {
  7761 + "type": "literal",
  7762 + "value": "action"
  7763 + },
  7764 + "match_method": {
  7765 + "options": [
  7766 + "exact_transition",
  7767 + "intent_video",
  7768 + "semantic_reuse"
  7769 + ],
  7770 + "type": "picklist"
  7771 + },
  7772 + "order": {
  7773 + "type": "number"
  7774 + },
  7775 + "similarity": {
  7776 + "type": "nullable",
  7777 + "wrapped": {
  7778 + "type": "number"
  7779 + }
  7780 + },
  7781 + "video": {
  7782 + "properties": {
  7783 + "asset": {
  7784 + "properties": {
  7785 + "hash": {
  7786 + "type": "string"
  7787 + },
  7788 + "id": {
  7789 + "type": "string"
  7790 + },
  7791 + "mime": {
  7792 + "type": "string"
  7793 + },
  7794 + "url": {
  7795 + "type": "string"
  7796 + }
  7797 + },
  7798 + "type": "object"
  7799 + },
  7800 + "end_frame": {
  7801 + "properties": {
  7802 + "hash": {
  7803 + "type": "string"
  7804 + },
  7805 + "id": {
  7806 + "type": "string"
  7807 + },
  7808 + "mime": {
  7809 + "type": "string"
  7810 + },
  7811 + "url": {
  7812 + "type": "string"
  7813 + }
  7814 + },
  7815 + "type": "object"
  7816 + },
  7817 + "start_frame": {
  7818 + "properties": {
  7819 + "hash": {
  7820 + "type": "string"
  7821 + },
  7822 + "id": {
  7823 + "type": "string"
  7824 + },
  7825 + "mime": {
  7826 + "type": "string"
  7827 + },
  7828 + "url": {
  7829 + "type": "string"
  7830 + }
  7831 + },
  7832 + "type": "object"
  7833 + }
  7834 + },
  7835 + "type": "object"
  7836 + }
  7837 + },
  7838 + "type": "object"
  7839 + },
  7840 + {
  7841 + "properties": {
  7842 + "actor_id": {
  7843 + "type": "string"
  7844 + },
  7845 + "actor_type": {
  7846 + "options": [
  7847 + "npc",
  7848 + "player"
  7849 + ],
  7850 + "type": "picklist"
  7851 + },
  7852 + "audio": {
  7853 + "properties": {
  7854 + "hash": {
  7855 + "type": "string"
  7856 + },
  7857 + "id": {
  7858 + "type": "string"
  7859 + },
  7860 + "mime": {
  7861 + "type": "string"
  7862 + },
  7863 + "url": {
  7864 + "type": "string"
  7865 + }
  7866 + },
  7867 + "type": "object"
  7868 + },
  7869 + "expression_state": {
  7870 + "type": "string"
  7871 + },
  7872 + "kind": {
  7873 + "type": "literal",
  7874 + "value": "speech"
  7875 + },
  7876 + "loopable": {
  7877 + "type": "boolean"
  7878 + },
  7879 + "order": {
  7880 + "type": "number"
  7881 + },
  7882 + "text": {
  7883 + "type": "string"
  7884 + },
  7885 + "tts_call_id": {
  7886 + "type": "string"
  7887 + },
  7888 + "video": {
  7889 + "properties": {
  7890 + "asset": {
  7891 + "properties": {
  7892 + "hash": {
  7893 + "type": "string"
  7894 + },
  7895 + "id": {
  7896 + "type": "string"
  7897 + },
  7898 + "mime": {
  7899 + "type": "string"
  7900 + },
  7901 + "url": {
  7902 + "type": "string"
  7903 + }
  7904 + },
  7905 + "type": "object"
  7906 + },
  7907 + "end_frame": {
  7908 + "properties": {
  7909 + "hash": {
  7910 + "type": "string"
  7911 + },
  7912 + "id": {
  7913 + "type": "string"
  7914 + },
  7915 + "mime": {
  7916 + "type": "string"
  7917 + },
  7918 + "url": {
  7919 + "type": "string"
  7920 + }
  7921 + },
  7922 + "type": "object"
  7923 + },
  7924 + "start_frame": {
  7925 + "properties": {
  7926 + "hash": {
  7927 + "type": "string"
  7928 + },
  7929 + "id": {
  7930 + "type": "string"
  7931 + },
  7932 + "mime": {
  7933 + "type": "string"
  7934 + },
  7935 + "url": {
  7936 + "type": "string"
  7937 + }
  7938 + },
  7939 + "type": "object"
  7940 + }
  7941 + },
  7942 + "type": "object"
  7943 + }
  7944 + },
  7945 + "type": "object"
  7946 + }
  7947 + ],
  7948 + "type": "union"
  7949 + },
  7950 + "type": "array"
  7951 + },
  7952 + "turn_id": {
  7953 + "type": "string"
  7954 + }
  7955 + },
  7956 + "type": "object"
  7957 + }
  7958 + },
  7959 + "turn_no": {
  7960 + "type": "number"
  7961 + }
  7962 + },
  7963 + "type": "object"
  7964 + },
  7965 + "type": "array"
  7966 + }
  7967 + },
  7968 + "type": "object"
  7969 + },
  7970 + "VisualGameTtsVoice": {
  7971 + "properties": {
  7972 + "display_name": {
  7973 + "type": "string"
  7974 + },
  7975 + "model": {
  7976 + "type": "string"
  7977 + },
  7978 + "provider": {
  7979 + "type": "string"
  7980 + },
  7981 + "voice_id": {
  7982 + "type": "string"
  7983 + },
  7984 + "voice_type": {
  7985 + "options": [
  7986 + "clone",
  7987 + "native"
  7988 + ],
  7989 + "type": "picklist"
  7990 + }
  7991 + },
  7992 + "type": "object"
  7993 + },
  7994 + "VisualGameTtsVoiceList": {
  7995 + "properties": {
  7996 + "voices": {
  7997 + "item": {
  7998 + "properties": {
  7999 + "display_name": {
  8000 + "type": "string"
  8001 + },
  8002 + "model": {
  8003 + "type": "string"
  8004 + },
  8005 + "provider": {
  8006 + "type": "string"
  8007 + },
  8008 + "voice_id": {
  8009 + "type": "string"
  8010 + },
  8011 + "voice_type": {
  8012 + "options": [
  8013 + "clone",
  8014 + "native"
  8015 + ],
  8016 + "type": "picklist"
  8017 + }
  8018 + },
  8019 + "type": "object"
  8020 + },
  8021 + "type": "array"
  8022 + }
  8023 + },
  8024 + "type": "object"
  8025 + },
  8026 + "VisualGameTurnEvent": {
  8027 + "options": [
  8028 + {
  8029 + "properties": {
  8030 + "action": {
  8031 + "type": "optional",
  8032 + "wrapped": {
  8033 + "type": "unknown"
  8034 + }
  8035 + },
  8036 + "available_clues": {
  8037 + "type": "optional",
  8038 + "wrapped": {
  8039 + "item": {
  8040 + "type": "unknown"
  8041 + },
  8042 + "type": "array"
  8043 + }
  8044 + },
  8045 + "available_conclusions": {
  8046 + "type": "optional",
  8047 + "wrapped": {
  8048 + "item": {
  8049 + "type": "unknown"
  8050 + },
  8051 + "type": "array"
  8052 + }
  8053 + },
  8054 + "current_balance": {
  8055 + "type": "optional",
  8056 + "wrapped": {
  8057 + "type": "number"
  8058 + }
  8059 + },
  8060 + "ending": {
  8061 + "type": "optional",
  8062 + "wrapped": {
  8063 + "type": "unknown"
  8064 + }
  8065 + },
  8066 + "ending_records": {
  8067 + "type": "optional",
  8068 + "wrapped": {
  8069 + "item": {
  8070 + "type": "unknown"
  8071 + },
  8072 + "type": "array"
  8073 + }
  8074 + },
  8075 + "error": {
  8076 + "type": "optional",
  8077 + "wrapped": {
  8078 + "type": "string"
  8079 + }
  8080 + },
  8081 + "error_code": {
  8082 + "type": "optional",
  8083 + "wrapped": {
  8084 + "options": [
  8085 + "AI_OUTPUT_INVALID",
  8086 + "ALREADY_UNLOCKED",
  8087 + "BAD_REQUEST",
  8088 + "CONFLICT",
  8089 + "FORBIDDEN",
  8090 + "INSUFFICIENT_BALANCE",
  8091 + "NOT_FOUND",
  8092 + "PRODUCT_NOT_FOUND",
  8093 + "RULE_NOT_FOUND",
  8094 + "SAVE_SLOT_CONFLICT",
  8095 + "content_filter",
  8096 + "internal_error"
  8097 + ],
  8098 + "type": "picklist"
  8099 + }
  8100 + },
  8101 + "judgement": {
  8102 + "type": "optional",
  8103 + "wrapped": {
  8104 + "type": "unknown"
  8105 + }
  8106 + },
  8107 + "known_clues": {
  8108 + "type": "optional",
  8109 + "wrapped": {
  8110 + "item": {
  8111 + "type": "unknown"
  8112 + },
  8113 + "type": "array"
  8114 + }
  8115 + },
  8116 + "known_conclusions": {
  8117 + "type": "optional",
  8118 + "wrapped": {
  8119 + "item": {
  8120 + "type": "unknown"
  8121 + },
  8122 + "type": "array"
  8123 + }
  8124 + },
  8125 + "kp_output": {
  8126 + "type": "optional",
  8127 + "wrapped": {
  8128 + "type": "unknown"
  8129 + }
  8130 + },
  8131 + "newly_unlocked": {
  8132 + "type": "optional",
  8133 + "wrapped": {
  8134 + "item": {
  8135 + "type": "unknown"
  8136 + },
  8137 + "type": "array"
  8138 + }
  8139 + },
  8140 + "newly_unlocked_clues": {
  8141 + "type": "optional",
  8142 + "wrapped": {
  8143 + "item": {
  8144 + "type": "unknown"
  8145 + },
  8146 + "type": "array"
  8147 + }
  8148 + },
  8149 + "npc_action_judgement": {
  8150 + "type": "optional",
  8151 + "wrapped": {
  8152 + "type": "unknown"
  8153 + }
  8154 + },
  8155 + "npc_action_kp_output": {
  8156 + "type": "optional",
  8157 + "wrapped": {
  8158 + "type": "unknown"
  8159 + }
  8160 + },
  8161 + "npc_action_result": {
  8162 + "type": "optional",
  8163 + "wrapped": {
  8164 + "type": "unknown"
  8165 + }
  8166 + },
  8167 + "required_points": {
  8168 + "type": "optional",
  8169 + "wrapped": {
  8170 + "type": "number"
  8171 + }
  8172 + },
  8173 + "response": {
  8174 + "type": "optional",
  8175 + "wrapped": {
  8176 + "type": "unknown"
  8177 + }
  8178 + },
  8179 + "run": {
  8180 + "type": "optional",
  8181 + "wrapped": {
  8182 + "type": "unknown"
  8183 + }
  8184 + },
  8185 + "scenario": {
  8186 + "type": "optional",
  8187 + "wrapped": {
  8188 + "type": "unknown"
  8189 + }
  8190 + },
  8191 + "scene_result": {
  8192 + "type": "optional",
  8193 + "wrapped": {
  8194 + "type": "unknown"
  8195 + }
  8196 + },
  8197 + "shortfall_points": {
  8198 + "type": "optional",
  8199 + "wrapped": {
  8200 + "type": "number"
  8201 + }
  8202 + },
  8203 + "turn_playback_plan": {
  8204 + "type": "optional",
  8205 + "wrapped": {
  8206 + "properties": {
  8207 + "segments": {
  8208 + "item": {
  8209 + "options": [
  8210 + {
  8211 + "properties": {
  8212 + "kind": {
  8213 + "type": "literal",
  8214 + "value": "action"
  8215 + },
  8216 + "match_method": {
  8217 + "options": [
  8218 + "exact_transition",
  8219 + "intent_video",
  8220 + "semantic_reuse"
  8221 + ],
  8222 + "type": "picklist"
  8223 + },
  8224 + "order": {
  8225 + "type": "number"
  8226 + },
  8227 + "similarity": {
  8228 + "type": "nullable",
  8229 + "wrapped": {
  8230 + "type": "number"
  8231 + }
  8232 + },
  8233 + "video": {
  8234 + "properties": {
  8235 + "asset": {
  8236 + "properties": {
  8237 + "hash": {
  8238 + "type": "string"
  8239 + },
  8240 + "id": {
  8241 + "type": "string"
  8242 + },
  8243 + "mime": {
  8244 + "type": "string"
  8245 + },
  8246 + "url": {
  8247 + "type": "string"
  8248 + }
  8249 + },
  8250 + "type": "object"
  8251 + },
  8252 + "end_frame": {
  8253 + "properties": {
  8254 + "hash": {
  8255 + "type": "string"
  8256 + },
  8257 + "id": {
  8258 + "type": "string"
  8259 + },
  8260 + "mime": {
  8261 + "type": "string"
  8262 + },
  8263 + "url": {
  8264 + "type": "string"
  8265 + }
  8266 + },
  8267 + "type": "object"
  8268 + },
  8269 + "start_frame": {
  8270 + "properties": {
  8271 + "hash": {
  8272 + "type": "string"
  8273 + },
  8274 + "id": {
  8275 + "type": "string"
  8276 + },
  8277 + "mime": {
  8278 + "type": "string"
  8279 + },
  8280 + "url": {
  8281 + "type": "string"
  8282 + }
  8283 + },
  8284 + "type": "object"
  8285 + }
  8286 + },
  8287 + "type": "object"
  8288 + }
  8289 + },
  8290 + "type": "object"
  8291 + },
  8292 + {
  8293 + "properties": {
  8294 + "actor_id": {
  8295 + "type": "string"
  8296 + },
  8297 + "actor_type": {
  8298 + "options": [
  8299 + "npc",
  8300 + "player"
  8301 + ],
  8302 + "type": "picklist"
  8303 + },
  8304 + "expression_state": {
  8305 + "type": "string"
  8306 + },
  8307 + "kind": {
  8308 + "type": "literal",
  8309 + "value": "speech"
  8310 + },
  8311 + "loopable": {
  8312 + "type": "boolean"
  8313 + },
  8314 + "order": {
  8315 + "type": "number"
  8316 + },
  8317 + "text": {
  8318 + "type": "string"
  8319 + },
  8320 + "video": {
  8321 + "properties": {
  8322 + "asset": {
  8323 + "properties": {
  8324 + "hash": {
  8325 + "type": "string"
  8326 + },
  8327 + "id": {
  8328 + "type": "string"
  8329 + },
  8330 + "mime": {
  8331 + "type": "string"
  8332 + },
  8333 + "url": {
  8334 + "type": "string"
  8335 + }
  8336 + },
  8337 + "type": "object"
  8338 + },
  8339 + "end_frame": {
  8340 + "properties": {
  8341 + "hash": {
  8342 + "type": "string"
  8343 + },
  8344 + "id": {
  8345 + "type": "string"
  8346 + },
  8347 + "mime": {
  8348 + "type": "string"
  8349 + },
  8350 + "url": {
  8351 + "type": "string"
  8352 + }
  8353 + },
  8354 + "type": "object"
  8355 + },
  8356 + "start_frame": {
  8357 + "properties": {
  8358 + "hash": {
  8359 + "type": "string"
  8360 + },
  8361 + "id": {
  8362 + "type": "string"
  8363 + },
  8364 + "mime": {
  8365 + "type": "string"
  8366 + },
  8367 + "url": {
  8368 + "type": "string"
  8369 + }
  8370 + },
  8371 + "type": "object"
  8372 + }
  8373 + },
  8374 + "type": "object"
  8375 + }
  8376 + },
  8377 + "type": "object"
  8378 + }
  8379 + ],
  8380 + "type": "union"
  8381 + },
  8382 + "type": "array"
  8383 + },
  8384 + "turn_id": {
  8385 + "type": "string"
  8386 + }
  8387 + },
  8388 + "type": "object"
  8389 + }
  8390 + },
  8391 + "type": {
  8392 + "options": [
  8393 + "action_rejected",
  8394 + "dynamic_story",
  8395 + "error",
  8396 + "final",
  8397 + "npc_action_kp",
  8398 + "npc_response",
  8399 + "player_kp"
  8400 + ],
  8401 + "type": "picklist"
  8402 + },
  8403 + "unlocked_info": {
  8404 + "type": "optional",
  8405 + "wrapped": {
  8406 + "item": {
  8407 + "type": "unknown"
  8408 + },
  8409 + "type": "array"
  8410 + }
  8411 + }
  8412 + },
  8413 + "type": "object"
  8414 + },
  8415 + {
  8416 + "properties": {
  8417 + "turn_playback_plan": {
  8418 + "properties": {
  8419 + "segments": {
  8420 + "item": {
  8421 + "options": [
  8422 + {
  8423 + "properties": {
  8424 + "kind": {
  8425 + "type": "literal",
  8426 + "value": "action"
  8427 + },
  8428 + "match_method": {
  8429 + "options": [
  8430 + "exact_transition",
  8431 + "intent_video",
  8432 + "semantic_reuse"
  8433 + ],
  8434 + "type": "picklist"
  8435 + },
  8436 + "order": {
  8437 + "type": "number"
  8438 + },
  8439 + "similarity": {
  8440 + "type": "nullable",
  8441 + "wrapped": {
  8442 + "type": "number"
  8443 + }
  8444 + },
  8445 + "video": {
  8446 + "properties": {
  8447 + "asset": {
  8448 + "properties": {
  8449 + "hash": {
  8450 + "type": "string"
  8451 + },
  8452 + "id": {
  8453 + "type": "string"
  8454 + },
  8455 + "mime": {
  8456 + "type": "string"
  8457 + },
  8458 + "url": {
  8459 + "type": "string"
  8460 + }
  8461 + },
  8462 + "type": "object"
  8463 + },
  8464 + "end_frame": {
  8465 + "properties": {
  8466 + "hash": {
  8467 + "type": "string"
  8468 + },
  8469 + "id": {
  8470 + "type": "string"
  8471 + },
  8472 + "mime": {
  8473 + "type": "string"
  8474 + },
  8475 + "url": {
  8476 + "type": "string"
  8477 + }
  8478 + },
  8479 + "type": "object"
  8480 + },
  8481 + "start_frame": {
  8482 + "properties": {
  8483 + "hash": {
  8484 + "type": "string"
  8485 + },
  8486 + "id": {
  8487 + "type": "string"
  8488 + },
  8489 + "mime": {
  8490 + "type": "string"
  8491 + },
  8492 + "url": {
  8493 + "type": "string"
  8494 + }
  8495 + },
  8496 + "type": "object"
  8497 + }
  8498 + },
  8499 + "type": "object"
  8500 + }
  8501 + },
  8502 + "type": "object"
  8503 + },
  8504 + {
  8505 + "properties": {
  8506 + "actor_id": {
  8507 + "type": "string"
  8508 + },
  8509 + "actor_type": {
  8510 + "options": [
  8511 + "npc",
  8512 + "player"
  8513 + ],
  8514 + "type": "picklist"
  8515 + },
  8516 + "expression_state": {
  8517 + "type": "string"
  8518 + },
  8519 + "kind": {
  8520 + "type": "literal",
  8521 + "value": "speech"
  8522 + },
  8523 + "loopable": {
  8524 + "type": "boolean"
  8525 + },
  8526 + "order": {
  8527 + "type": "number"
  8528 + },
  8529 + "text": {
  8530 + "type": "string"
  8531 + },
  8532 + "video": {
  8533 + "properties": {
  8534 + "asset": {
  8535 + "properties": {
  8536 + "hash": {
  8537 + "type": "string"
  8538 + },
  8539 + "id": {
  8540 + "type": "string"
  8541 + },
  8542 + "mime": {
  8543 + "type": "string"
  8544 + },
  8545 + "url": {
  8546 + "type": "string"
  8547 + }
  8548 + },
  8549 + "type": "object"
  8550 + },
  8551 + "end_frame": {
  8552 + "properties": {
  8553 + "hash": {
  8554 + "type": "string"
  8555 + },
  8556 + "id": {
  8557 + "type": "string"
  8558 + },
  8559 + "mime": {
  8560 + "type": "string"
  8561 + },
  8562 + "url": {
  8563 + "type": "string"
  8564 + }
  8565 + },
  8566 + "type": "object"
  8567 + },
  8568 + "start_frame": {
  8569 + "properties": {
  8570 + "hash": {
  8571 + "type": "string"
  8572 + },
  8573 + "id": {
  8574 + "type": "string"
  8575 + },
  8576 + "mime": {
  8577 + "type": "string"
  8578 + },
  8579 + "url": {
  8580 + "type": "string"
  8581 + }
  8582 + },
  8583 + "type": "object"
  8584 + }
  8585 + },
  8586 + "type": "object"
  8587 + }
  8588 + },
  8589 + "type": "object"
  8590 + }
  8591 + ],
  8592 + "type": "union"
  8593 + },
  8594 + "type": "array"
  8595 + },
  8596 + "turn_id": {
  8597 + "type": "string"
  8598 + }
  8599 + },
  8600 + "type": "object"
  8601 + },
  8602 + "type": {
  8603 + "type": "literal",
  8604 + "value": "turn_playback_plan"
  8605 + }
  8606 + },
  8607 + "type": "object"
  8608 + },
  8609 + {
  8610 + "properties": {
  8611 + "phase": {
  8612 + "options": [
  8613 + "npc",
  8614 + "player"
  8615 + ],
  8616 + "type": "picklist"
  8617 + },
  8618 + "turn_playback_plan": {
  8619 + "properties": {
  8620 + "segments": {
  8621 + "item": {
  8622 + "options": [
  8623 + {
  8624 + "properties": {
  8625 + "kind": {
  8626 + "type": "literal",
  8627 + "value": "action"
  8628 + },
  8629 + "match_method": {
  8630 + "options": [
  8631 + "exact_transition",
  8632 + "intent_video",
  8633 + "semantic_reuse"
  8634 + ],
  8635 + "type": "picklist"
  8636 + },
  8637 + "order": {
  8638 + "type": "number"
  8639 + },
  8640 + "similarity": {
  8641 + "type": "nullable",
  8642 + "wrapped": {
  8643 + "type": "number"
  8644 + }
  8645 + },
  8646 + "video": {
  8647 + "properties": {
  8648 + "asset": {
  8649 + "properties": {
  8650 + "hash": {
  8651 + "type": "string"
  8652 + },
  8653 + "id": {
  8654 + "type": "string"
  8655 + },
  8656 + "mime": {
  8657 + "type": "string"
  8658 + },
  8659 + "url": {
  8660 + "type": "string"
  8661 + }
  8662 + },
  8663 + "type": "object"
  8664 + },
  8665 + "end_frame": {
  8666 + "properties": {
  8667 + "hash": {
  8668 + "type": "string"
  8669 + },
  8670 + "id": {
  8671 + "type": "string"
  8672 + },
  8673 + "mime": {
  8674 + "type": "string"
  8675 + },
  8676 + "url": {
  8677 + "type": "string"
  8678 + }
  8679 + },
  8680 + "type": "object"
  8681 + },
  8682 + "start_frame": {
  8683 + "properties": {
  8684 + "hash": {
  8685 + "type": "string"
  8686 + },
  8687 + "id": {
  8688 + "type": "string"
  8689 + },
  8690 + "mime": {
  8691 + "type": "string"
  8692 + },
  8693 + "url": {
  8694 + "type": "string"
  8695 + }
  8696 + },
  8697 + "type": "object"
  8698 + }
  8699 + },
  8700 + "type": "object"
  8701 + }
  8702 + },
  8703 + "type": "object"
  8704 + },
  8705 + {
  8706 + "properties": {
  8707 + "actor_id": {
  8708 + "type": "string"
  8709 + },
  8710 + "actor_type": {
  8711 + "options": [
  8712 + "npc",
  8713 + "player"
  8714 + ],
  8715 + "type": "picklist"
  8716 + },
  8717 + "expression_state": {
  8718 + "type": "string"
  8719 + },
  8720 + "kind": {
  8721 + "type": "literal",
  8722 + "value": "speech"
  8723 + },
  8724 + "loopable": {
  8725 + "type": "boolean"
  8726 + },
  8727 + "order": {
  8728 + "type": "number"
  8729 + },
  8730 + "text": {
  8731 + "type": "string"
  8732 + },
  8733 + "video": {
  8734 + "properties": {
  8735 + "asset": {
  8736 + "properties": {
  8737 + "hash": {
  8738 + "type": "string"
  8739 + },
  8740 + "id": {
  8741 + "type": "string"
  8742 + },
  8743 + "mime": {
  8744 + "type": "string"
  8745 + },
  8746 + "url": {
  8747 + "type": "string"
  8748 + }
  8749 + },
  8750 + "type": "object"
  8751 + },
  8752 + "end_frame": {
  8753 + "properties": {
  8754 + "hash": {
  8755 + "type": "string"
  8756 + },
  8757 + "id": {
  8758 + "type": "string"
  8759 + },
  8760 + "mime": {
  8761 + "type": "string"
  8762 + },
  8763 + "url": {
  8764 + "type": "string"
  8765 + }
  8766 + },
  8767 + "type": "object"
  8768 + },
  8769 + "start_frame": {
  8770 + "properties": {
  8771 + "hash": {
  8772 + "type": "string"
  8773 + },
  8774 + "id": {
  8775 + "type": "string"
  8776 + },
  8777 + "mime": {
  8778 + "type": "string"
  8779 + },
  8780 + "url": {
  8781 + "type": "string"
  8782 + }
  8783 + },
  8784 + "type": "object"
  8785 + }
  8786 + },
  8787 + "type": "object"
  8788 + }
  8789 + },
  8790 + "type": "object"
  8791 + }
  8792 + ],
  8793 + "type": "union"
  8794 + },
  8795 + "type": "array"
  8796 + },
  8797 + "turn_id": {
  8798 + "type": "string"
  8799 + }
  8800 + },
  8801 + "type": "object"
  8802 + },
  8803 + "type": {
  8804 + "type": "literal",
  8805 + "value": "turn_playback_phase"
  8806 + }
  8807 + },
  8808 + "type": "object"
  8809 + }
  8810 + ],
  8811 + "type": "union"
  8812 + },
  8813 + "WaitlistActionResponse": {
  8814 + "properties": {
  8815 + "created_at": {
  8816 + "type": "string"
  8817 + },
  8818 + "display_name": {
  8819 + "type": "nullable",
  8820 + "wrapped": {
  8821 + "type": "string"
  8822 + }
  8823 + },
  8824 + "email": {
  8825 + "type": "nullable",
  8826 + "wrapped": {
  8827 + "type": "string"
  8828 + }
  8829 + },
  8830 + "id": {
  8831 + "type": "number"
  8832 + },
  8833 + "invite_expires_at": {
  8834 + "type": "nullable",
  8835 + "wrapped": {
  8836 + "type": "string"
  8837 + }
  8838 + },
  8839 + "invite_state": {
  8840 + "options": [
  8841 + "claimed",
  8842 + "closed",
  8843 + "expired",
  8844 + "failed",
  8845 + "none",
  8846 + "pending",
  8847 + "sent"
  8848 + ],
  8849 + "type": "picklist"
  8850 + },
  8851 + "invite_token": {
  8852 + "type": "nullable",
  8853 + "wrapped": {
  8854 + "type": "string"
  8855 + }
  8856 + },
  8857 + "invite_url": {
  8858 + "type": "nullable",
  8859 + "wrapped": {
  8860 + "type": "string"
  8861 + }
  8862 + },
  8863 + "invited_at": {
  8864 + "type": "nullable",
  8865 + "wrapped": {
  8866 + "type": "string"
  8867 + }
  8868 + },
  8869 + "invited_by": {
  8870 + "type": "nullable",
  8871 + "wrapped": {
  8872 + "type": "string"
  8873 + }
  8874 + },
  8875 + "last_invite_delivery_id": {
  8876 + "type": "nullable",
  8877 + "wrapped": {
  8878 + "type": "number"
  8879 + }
  8880 + },
  8881 + "last_invite_delivery_status": {
  8882 + "type": "nullable",
  8883 + "wrapped": {
  8884 + "options": [
  8885 + "failed",
  8886 + "pending",
  8887 + "sent"
  8888 + ],
  8889 + "type": "picklist"
  8890 + }
  8891 + },
  8892 + "last_invite_email_log_id": {
  8893 + "type": "nullable",
  8894 + "wrapped": {
  8895 + "type": "string"
  8896 + }
  8897 + },
  8898 + "locale": {
  8899 + "type": "string"
  8900 + },
  8901 + "marketing_consent_accepted_at": {
  8902 + "type": "nullable",
  8903 + "wrapped": {
  8904 + "type": "string"
  8905 + }
  8906 + },
  8907 + "marketing_consent_revoked_at": {
  8908 + "type": "nullable",
  8909 + "wrapped": {
  8910 + "type": "string"
  8911 + }
  8912 + },
  8913 + "note": {
  8914 + "type": "nullable",
  8915 + "wrapped": {
  8916 + "type": "string"
  8917 + }
  8918 + },
  8919 + "priority_score": {
  8920 + "type": "number"
  8921 + },
  8922 + "registered_at": {
  8923 + "type": "nullable",
  8924 + "wrapped": {
  8925 + "type": "string"
  8926 + }
  8927 + },
  8928 + "registered_user_id": {
  8929 + "type": "nullable",
  8930 + "wrapped": {
  8931 + "type": "string"
  8932 + }
  8933 + },
  8934 + "rejected_at": {
  8935 + "type": "nullable",
  8936 + "wrapped": {
  8937 + "type": "string"
  8938 + }
  8939 + },
  8940 + "rejected_by": {
  8941 + "type": "nullable",
  8942 + "wrapped": {
  8943 + "type": "string"
  8944 + }
  8945 + },
  8946 + "rejected_reason": {
  8947 + "type": "nullable",
  8948 + "wrapped": {
  8949 + "type": "string"
  8950 + }
  8951 + },
  8952 + "source": {
  8953 + "options": [
  8954 + "landing_footer",
  8955 + "landing_hero",
  8956 + "manual_admin",
  8957 + "pricing"
  8958 + ],
  8959 + "type": "picklist"
  8960 + },
  8961 + "status": {
  8962 + "options": [
  8963 + "invite_pending",
  8964 + "invited",
  8965 + "joined",
  8966 + "registered",
  8967 + "rejected"
  8968 + ],
  8969 + "type": "picklist"
  8970 + },
  8971 + "updated_at": {
  8972 + "type": "string"
  8973 + },
  8974 + "use_case": {
  8975 + "type": "string"
  8976 + }
  8977 + },
  8978 + "type": "object"
  8979 + },
  8980 + "WaitlistActorType": {
  8981 + "options": [
  8982 + "operator",
  8983 + "public",
  8984 + "system"
  8985 + ],
  8986 + "type": "picklist"
  8987 + },
  8988 + "WaitlistCreateManualEntryRequest": {
  8989 + "properties": {
  8990 + "display_name": {
  8991 + "type": "optional",
  8992 + "wrapped": {
  8993 + "type": "string"
  8994 + }
  8995 + },
  8996 + "email": {
  8997 + "type": "string"
  8998 + },
  8999 + "locale": {
  9000 + "type": "string"
  9001 + },
  9002 + "marketing_consent_accepted_at": {
  9003 + "type": "string"
  9004 + },
  9005 + "note": {
  9006 + "type": "optional",
  9007 + "wrapped": {
  9008 + "type": "string"
  9009 + }
  9010 + },
  9011 + "use_case": {
  9012 + "type": "string"
  9013 + }
  9014 + },
  9015 + "type": "object"
  9016 + },
  9017 + "WaitlistEntry": {
  9018 + "properties": {
  9019 + "created_at": {
  9020 + "type": "string"
  9021 + },
  9022 + "display_name": {
  9023 + "type": "nullable",
  9024 + "wrapped": {
  9025 + "type": "string"
  9026 + }
  9027 + },
  9028 + "email": {
  9029 + "type": "nullable",
  9030 + "wrapped": {
  9031 + "type": "string"
  9032 + }
  9033 + },
  9034 + "id": {
  9035 + "type": "number"
  9036 + },
  9037 + "invite_expires_at": {
  9038 + "type": "nullable",
  9039 + "wrapped": {
  9040 + "type": "string"
  9041 + }
  9042 + },
  9043 + "invite_state": {
  9044 + "options": [
  9045 + "claimed",
  9046 + "closed",
  9047 + "expired",
  9048 + "failed",
  9049 + "none",
  9050 + "pending",
  9051 + "sent"
  9052 + ],
  9053 + "type": "picklist"
  9054 + },
  9055 + "invite_token": {
  9056 + "type": "nullable",
  9057 + "wrapped": {
  9058 + "type": "string"
  9059 + }
  9060 + },
  9061 + "invite_url": {
  9062 + "type": "nullable",
  9063 + "wrapped": {
  9064 + "type": "string"
  9065 + }
  9066 + },
  9067 + "invited_at": {
  9068 + "type": "nullable",
  9069 + "wrapped": {
  9070 + "type": "string"
  9071 + }
  9072 + },
  9073 + "invited_by": {
  9074 + "type": "nullable",
  9075 + "wrapped": {
  9076 + "type": "string"
  9077 + }
  9078 + },
  9079 + "last_invite_delivery_id": {
  9080 + "type": "nullable",
  9081 + "wrapped": {
  9082 + "type": "number"
  9083 + }
  9084 + },
  9085 + "last_invite_delivery_status": {
  9086 + "type": "nullable",
  9087 + "wrapped": {
  9088 + "options": [
  9089 + "failed",
  9090 + "pending",
  9091 + "sent"
  9092 + ],
  9093 + "type": "picklist"
  9094 + }
  9095 + },
  9096 + "last_invite_email_log_id": {
  9097 + "type": "nullable",
  9098 + "wrapped": {
  9099 + "type": "string"
  9100 + }
  9101 + },
  9102 + "locale": {
  9103 + "type": "string"
  9104 + },
  9105 + "marketing_consent_accepted_at": {
  9106 + "type": "nullable",
  9107 + "wrapped": {
  9108 + "type": "string"
  9109 + }
  9110 + },
  9111 + "marketing_consent_revoked_at": {
  9112 + "type": "nullable",
  9113 + "wrapped": {
  9114 + "type": "string"
  9115 + }
  9116 + },
  9117 + "note": {
  9118 + "type": "nullable",
  9119 + "wrapped": {
  9120 + "type": "string"
  9121 + }
  9122 + },
  9123 + "priority_score": {
  9124 + "type": "number"
  9125 + },
  9126 + "registered_at": {
  9127 + "type": "nullable",
  9128 + "wrapped": {
  9129 + "type": "string"
  9130 + }
  9131 + },
  9132 + "registered_user_id": {
  9133 + "type": "nullable",
  9134 + "wrapped": {
  9135 + "type": "string"
  9136 + }
  9137 + },
  9138 + "rejected_at": {
  9139 + "type": "nullable",
  9140 + "wrapped": {
  9141 + "type": "string"
  9142 + }
  9143 + },
  9144 + "rejected_by": {
  9145 + "type": "nullable",
  9146 + "wrapped": {
  9147 + "type": "string"
  9148 + }
  9149 + },
  9150 + "rejected_reason": {
  9151 + "type": "nullable",
  9152 + "wrapped": {
  9153 + "type": "string"
  9154 + }
  9155 + },
  9156 + "source": {
  9157 + "options": [
  9158 + "landing_footer",
  9159 + "landing_hero",
  9160 + "manual_admin",
  9161 + "pricing"
  9162 + ],
  9163 + "type": "picklist"
  9164 + },
  9165 + "status": {
  9166 + "options": [
  9167 + "invite_pending",
  9168 + "invited",
  9169 + "joined",
  9170 + "registered",
  9171 + "rejected"
  9172 + ],
  9173 + "type": "picklist"
  9174 + },
  9175 + "updated_at": {
  9176 + "type": "string"
  9177 + },
  9178 + "use_case": {
  9179 + "type": "string"
  9180 + }
  9181 + },
  9182 + "type": "object"
  9183 + },
  9184 + "WaitlistEntryDetailResponse": {
  9185 + "properties": {
  9186 + "deliveries": {
  9187 + "item": {
  9188 + "properties": {
  9189 + "created_at": {
  9190 + "type": "string"
  9191 + },
  9192 + "email_log_id": {
  9193 + "type": "nullable",
  9194 + "wrapped": {
  9195 + "type": "string"
  9196 + }
  9197 + },
  9198 + "entry_id": {
  9199 + "type": "number"
  9200 + },
  9201 + "error_message": {
  9202 + "type": "nullable",
  9203 + "wrapped": {
  9204 + "type": "string"
  9205 + }
  9206 + },
  9207 + "failed_at": {
  9208 + "type": "nullable",
  9209 + "wrapped": {
  9210 + "type": "string"
  9211 + }
  9212 + },
  9213 + "id": {
  9214 + "type": "number"
  9215 + },
  9216 + "invite_token_hash": {
  9217 + "type": "string"
  9218 + },
  9219 + "message_id": {
  9220 + "type": "nullable",
  9221 + "wrapped": {
  9222 + "type": "string"
  9223 + }
  9224 + },
  9225 + "provider": {
  9226 + "type": "nullable",
  9227 + "wrapped": {
  9228 + "type": "string"
  9229 + }
  9230 + },
  9231 + "sent_at": {
  9232 + "type": "nullable",
  9233 + "wrapped": {
  9234 + "type": "string"
  9235 + }
  9236 + },
  9237 + "status": {
  9238 + "options": [
  9239 + "failed",
  9240 + "pending",
  9241 + "sent"
  9242 + ],
  9243 + "type": "picklist"
  9244 + }
  9245 + },
  9246 + "type": "object"
  9247 + },
  9248 + "type": "array"
  9249 + },
  9250 + "entry": {
  9251 + "properties": {
  9252 + "created_at": {
  9253 + "type": "string"
  9254 + },
  9255 + "display_name": {
  9256 + "type": "nullable",
  9257 + "wrapped": {
  9258 + "type": "string"
  9259 + }
  9260 + },
  9261 + "email": {
  9262 + "type": "nullable",
  9263 + "wrapped": {
  9264 + "type": "string"
  9265 + }
  9266 + },
  9267 + "id": {
  9268 + "type": "number"
  9269 + },
  9270 + "invite_expires_at": {
  9271 + "type": "nullable",
  9272 + "wrapped": {
  9273 + "type": "string"
  9274 + }
  9275 + },
  9276 + "invite_state": {
  9277 + "options": [
  9278 + "claimed",
  9279 + "closed",
  9280 + "expired",
  9281 + "failed",
  9282 + "none",
  9283 + "pending",
  9284 + "sent"
  9285 + ],
  9286 + "type": "picklist"
  9287 + },
  9288 + "invite_token": {
  9289 + "type": "nullable",
  9290 + "wrapped": {
  9291 + "type": "string"
  9292 + }
  9293 + },
  9294 + "invite_url": {
  9295 + "type": "nullable",
  9296 + "wrapped": {
  9297 + "type": "string"
  9298 + }
  9299 + },
  9300 + "invited_at": {
  9301 + "type": "nullable",
  9302 + "wrapped": {
  9303 + "type": "string"
  9304 + }
  9305 + },
  9306 + "invited_by": {
  9307 + "type": "nullable",
  9308 + "wrapped": {
  9309 + "type": "string"
  9310 + }
  9311 + },
  9312 + "last_invite_delivery_id": {
  9313 + "type": "nullable",
  9314 + "wrapped": {
  9315 + "type": "number"
  9316 + }
  9317 + },
  9318 + "last_invite_delivery_status": {
  9319 + "type": "nullable",
  9320 + "wrapped": {
  9321 + "options": [
  9322 + "failed",
  9323 + "pending",
  9324 + "sent"
  9325 + ],
  9326 + "type": "picklist"
  9327 + }
  9328 + },
  9329 + "last_invite_email_log_id": {
  9330 + "type": "nullable",
  9331 + "wrapped": {
  9332 + "type": "string"
  9333 + }
  9334 + },
  9335 + "locale": {
  9336 + "type": "string"
  9337 + },
  9338 + "marketing_consent_accepted_at": {
  9339 + "type": "nullable",
  9340 + "wrapped": {
  9341 + "type": "string"
  9342 + }
  9343 + },
  9344 + "marketing_consent_revoked_at": {
  9345 + "type": "nullable",
  9346 + "wrapped": {
  9347 + "type": "string"
  9348 + }
  9349 + },
  9350 + "note": {
  9351 + "type": "nullable",
  9352 + "wrapped": {
  9353 + "type": "string"
  9354 + }
  9355 + },
  9356 + "priority_score": {
  9357 + "type": "number"
  9358 + },
  9359 + "registered_at": {
  9360 + "type": "nullable",
  9361 + "wrapped": {
  9362 + "type": "string"
  9363 + }
  9364 + },
  9365 + "registered_user_id": {
  9366 + "type": "nullable",
  9367 + "wrapped": {
  9368 + "type": "string"
  9369 + }
  9370 + },
  9371 + "rejected_at": {
  9372 + "type": "nullable",
  9373 + "wrapped": {
  9374 + "type": "string"
  9375 + }
  9376 + },
  9377 + "rejected_by": {
  9378 + "type": "nullable",
  9379 + "wrapped": {
  9380 + "type": "string"
  9381 + }
  9382 + },
  9383 + "rejected_reason": {
  9384 + "type": "nullable",
  9385 + "wrapped": {
  9386 + "type": "string"
  9387 + }
  9388 + },
  9389 + "source": {
  9390 + "options": [
  9391 + "landing_footer",
  9392 + "landing_hero",
  9393 + "manual_admin",
  9394 + "pricing"
  9395 + ],
  9396 + "type": "picklist"
  9397 + },
  9398 + "status": {
  9399 + "options": [
  9400 + "invite_pending",
  9401 + "invited",
  9402 + "joined",
  9403 + "registered",
  9404 + "rejected"
  9405 + ],
  9406 + "type": "picklist"
  9407 + },
  9408 + "updated_at": {
  9409 + "type": "string"
  9410 + },
  9411 + "use_case": {
  9412 + "type": "string"
  9413 + }
  9414 + },
  9415 + "type": "object"
  9416 + },
  9417 + "events": {
  9418 + "item": {
  9419 + "properties": {
  9420 + "actor_id": {
  9421 + "type": "nullable",
  9422 + "wrapped": {
  9423 + "type": "string"
  9424 + }
  9425 + },
  9426 + "actor_type": {
  9427 + "options": [
  9428 + "operator",
  9429 + "public",
  9430 + "system"
  9431 + ],
  9432 + "type": "picklist"
  9433 + },
  9434 + "created_at": {
  9435 + "type": "string"
  9436 + },
  9437 + "entry_id": {
  9438 + "type": "nullable",
  9439 + "wrapped": {
  9440 + "type": "number"
  9441 + }
  9442 + },
  9443 + "event_type": {
  9444 + "options": [
  9445 + "duplicate_joined",
  9446 + "exported",
  9447 + "invite_email_failed",
  9448 + "invite_email_sent",
  9449 + "invite_requested",
  9450 + "joined",
  9451 + "note_added",
  9452 + "registered",
  9453 + "rejected"
  9454 + ],
  9455 + "type": "picklist"
  9456 + },
  9457 + "id": {
  9458 + "type": "number"
  9459 + },
  9460 + "metadata": {
  9461 + "type": "nullable",
  9462 + "wrapped": {
  9463 + "type": "unknown"
  9464 + }
  9465 + }
  9466 + },
  9467 + "type": "object"
  9468 + },
  9469 + "type": "array"
  9470 + }
  9471 + },
  9472 + "type": "object"
  9473 + },
  9474 + "WaitlistEntryListResponse": {
  9475 + "properties": {
  9476 + "items": {
  9477 + "item": {
  9478 + "properties": {
  9479 + "created_at": {
  9480 + "type": "string"
  9481 + },
  9482 + "display_name": {
  9483 + "type": "nullable",
  9484 + "wrapped": {
  9485 + "type": "string"
  9486 + }
  9487 + },
  9488 + "email": {
  9489 + "type": "nullable",
  9490 + "wrapped": {
  9491 + "type": "string"
  9492 + }
  9493 + },
  9494 + "id": {
  9495 + "type": "number"
  9496 + },
  9497 + "invite_expires_at": {
  9498 + "type": "nullable",
  9499 + "wrapped": {
  9500 + "type": "string"
  9501 + }
  9502 + },
  9503 + "invite_state": {
  9504 + "options": [
  9505 + "claimed",
  9506 + "closed",
  9507 + "expired",
  9508 + "failed",
  9509 + "none",
  9510 + "pending",
  9511 + "sent"
  9512 + ],
  9513 + "type": "picklist"
  9514 + },
  9515 + "invite_token": {
  9516 + "type": "nullable",
  9517 + "wrapped": {
  9518 + "type": "string"
  9519 + }
  9520 + },
  9521 + "invite_url": {
  9522 + "type": "nullable",
  9523 + "wrapped": {
  9524 + "type": "string"
  9525 + }
  9526 + },
  9527 + "invited_at": {
  9528 + "type": "nullable",
  9529 + "wrapped": {
  9530 + "type": "string"
  9531 + }
  9532 + },
  9533 + "invited_by": {
  9534 + "type": "nullable",
  9535 + "wrapped": {
  9536 + "type": "string"
  9537 + }
  9538 + },
  9539 + "last_invite_delivery_id": {
  9540 + "type": "nullable",
  9541 + "wrapped": {
  9542 + "type": "number"
  9543 + }
  9544 + },
  9545 + "last_invite_delivery_status": {
  9546 + "type": "nullable",
  9547 + "wrapped": {
  9548 + "options": [
  9549 + "failed",
  9550 + "pending",
  9551 + "sent"
  9552 + ],
  9553 + "type": "picklist"
  9554 + }
  9555 + },
  9556 + "last_invite_email_log_id": {
  9557 + "type": "nullable",
  9558 + "wrapped": {
  9559 + "type": "string"
  9560 + }
  9561 + },
  9562 + "locale": {
  9563 + "type": "string"
  9564 + },
  9565 + "marketing_consent_accepted_at": {
  9566 + "type": "nullable",
  9567 + "wrapped": {
  9568 + "type": "string"
  9569 + }
  9570 + },
  9571 + "marketing_consent_revoked_at": {
  9572 + "type": "nullable",
  9573 + "wrapped": {
  9574 + "type": "string"
  9575 + }
  9576 + },
  9577 + "note": {
  9578 + "type": "nullable",
  9579 + "wrapped": {
  9580 + "type": "string"
  9581 + }
  9582 + },
  9583 + "priority_score": {
  9584 + "type": "number"
  9585 + },
  9586 + "registered_at": {
  9587 + "type": "nullable",
  9588 + "wrapped": {
  9589 + "type": "string"
  9590 + }
  9591 + },
  9592 + "registered_user_id": {
  9593 + "type": "nullable",
  9594 + "wrapped": {
  9595 + "type": "string"
  9596 + }
  9597 + },
  9598 + "rejected_at": {
  9599 + "type": "nullable",
  9600 + "wrapped": {
  9601 + "type": "string"
  9602 + }
  9603 + },
  9604 + "rejected_by": {
  9605 + "type": "nullable",
  9606 + "wrapped": {
  9607 + "type": "string"
  9608 + }
  9609 + },
  9610 + "rejected_reason": {
  9611 + "type": "nullable",
  9612 + "wrapped": {
  9613 + "type": "string"
  9614 + }
  9615 + },
  9616 + "source": {
  9617 + "options": [
  9618 + "landing_footer",
  9619 + "landing_hero",
  9620 + "manual_admin",
  9621 + "pricing"
  9622 + ],
  9623 + "type": "picklist"
  9624 + },
  9625 + "status": {
  9626 + "options": [
  9627 + "invite_pending",
  9628 + "invited",
  9629 + "joined",
  9630 + "registered",
  9631 + "rejected"
  9632 + ],
  9633 + "type": "picklist"
  9634 + },
  9635 + "updated_at": {
  9636 + "type": "string"
  9637 + },
  9638 + "use_case": {
  9639 + "type": "string"
  9640 + }
  9641 + },
  9642 + "type": "object"
  9643 + },
  9644 + "type": "array"
  9645 + },
  9646 + "next_cursor": {
  9647 + "type": "nullable",
  9648 + "wrapped": {
  9649 + "type": "string"
  9650 + }
  9651 + }
  9652 + },
  9653 + "type": "object"
  9654 + },
  9655 + "WaitlistEvent": {
  9656 + "properties": {
  9657 + "actor_id": {
  9658 + "type": "nullable",
  9659 + "wrapped": {
  9660 + "type": "string"
  9661 + }
  9662 + },
  9663 + "actor_type": {
  9664 + "options": [
  9665 + "operator",
  9666 + "public",
  9667 + "system"
  9668 + ],
  9669 + "type": "picklist"
  9670 + },
  9671 + "created_at": {
  9672 + "type": "string"
  9673 + },
  9674 + "entry_id": {
  9675 + "type": "nullable",
  9676 + "wrapped": {
  9677 + "type": "number"
  9678 + }
  9679 + },
  9680 + "event_type": {
  9681 + "options": [
  9682 + "duplicate_joined",
  9683 + "exported",
  9684 + "invite_email_failed",
  9685 + "invite_email_sent",
  9686 + "invite_requested",
  9687 + "joined",
  9688 + "note_added",
  9689 + "registered",
  9690 + "rejected"
  9691 + ],
  9692 + "type": "picklist"
  9693 + },
  9694 + "id": {
  9695 + "type": "number"
  9696 + },
  9697 + "metadata": {
  9698 + "type": "nullable",
  9699 + "wrapped": {
  9700 + "type": "unknown"
  9701 + }
  9702 + }
  9703 + },
  9704 + "type": "object"
  9705 + },
  9706 + "WaitlistEventType": {
  9707 + "options": [
  9708 + "duplicate_joined",
  9709 + "exported",
  9710 + "invite_email_failed",
  9711 + "invite_email_sent",
  9712 + "invite_requested",
  9713 + "joined",
  9714 + "note_added",
  9715 + "registered",
  9716 + "rejected"
  9717 + ],
  9718 + "type": "picklist"
  9719 + },
  9720 + "WaitlistExportResponse": {
  9721 + "type": "string"
  9722 + },
  9723 + "WaitlistInviteCode": {
  9724 + "type": "string"
  9725 + },
  9726 + "WaitlistInviteDelivery": {
  9727 + "properties": {
  9728 + "created_at": {
  9729 + "type": "string"
  9730 + },
  9731 + "email_log_id": {
  9732 + "type": "nullable",
  9733 + "wrapped": {
  9734 + "type": "string"
  9735 + }
  9736 + },
  9737 + "entry_id": {
  9738 + "type": "number"
  9739 + },
  9740 + "error_message": {
  9741 + "type": "nullable",
  9742 + "wrapped": {
  9743 + "type": "string"
  9744 + }
  9745 + },
  9746 + "failed_at": {
  9747 + "type": "nullable",
  9748 + "wrapped": {
  9749 + "type": "string"
  9750 + }
  9751 + },
  9752 + "id": {
  9753 + "type": "number"
  9754 + },
  9755 + "invite_token_hash": {
  9756 + "type": "string"
  9757 + },
  9758 + "message_id": {
  9759 + "type": "nullable",
  9760 + "wrapped": {
  9761 + "type": "string"
  9762 + }
  9763 + },
  9764 + "provider": {
  9765 + "type": "nullable",
  9766 + "wrapped": {
  9767 + "type": "string"
  9768 + }
  9769 + },
  9770 + "sent_at": {
  9771 + "type": "nullable",
  9772 + "wrapped": {
  9773 + "type": "string"
  9774 + }
  9775 + },
  9776 + "status": {
  9777 + "options": [
  9778 + "failed",
  9779 + "pending",
  9780 + "sent"
  9781 + ],
  9782 + "type": "picklist"
  9783 + }
  9784 + },
  9785 + "type": "object"
  9786 + },
  9787 + "WaitlistInviteDeliveryStatus": {
  9788 + "options": [
  9789 + "claimed",
  9790 + "closed",
  9791 + "expired",
  9792 + "failed",
  9793 + "none",
  9794 + "pending",
  9795 + "sent"
  9796 + ],
  9797 + "type": "picklist"
  9798 + },
  9799 + "WaitlistInviteRequest": {
  9800 + "properties": {
  9801 + "note": {
  9802 + "type": "optional",
  9803 + "wrapped": {
  9804 + "type": "string"
  9805 + }
  9806 + }
  9807 + },
  9808 + "type": "object"
  9809 + },
  9810 + "WaitlistInviteValidateResponse": {
  9811 + "properties": {
  9812 + "email": {
  9813 + "type": "nullable",
  9814 + "wrapped": {
  9815 + "type": "string"
  9816 + }
  9817 + },
  9818 + "expires_at": {
  9819 + "type": "string"
  9820 + },
  9821 + "ok": {
  9822 + "type": "literal",
  9823 + "value": true
  9824 + }
  9825 + },
  9826 + "type": "object"
  9827 + },
  9828 + "WaitlistJoinRequest": {
  9829 + "properties": {
  9830 + "consent_marketing": {
  9831 + "type": "literal",
  9832 + "value": true
  9833 + },
  9834 + "display_name": {
  9835 + "type": "optional",
  9836 + "wrapped": {
  9837 + "type": "string"
  9838 + }
  9839 + },
  9840 + "email": {
  9841 + "type": "string"
  9842 + },
  9843 + "locale": {
  9844 + "type": "string"
  9845 + },
  9846 + "note": {
  9847 + "type": "optional",
  9848 + "wrapped": {
  9849 + "type": "string"
  9850 + }
  9851 + },
  9852 + "source": {
  9853 + "options": [
  9854 + "landing_footer",
  9855 + "landing_hero",
  9856 + "pricing"
  9857 + ],
  9858 + "type": "picklist"
  9859 + },
  9860 + "use_case": {
  9861 + "type": "optional",
  9862 + "wrapped": {
  9863 + "type": "string"
  9864 + }
  9865 + }
  9866 + },
  9867 + "type": "object"
  9868 + },
  9869 + "WaitlistJoinResponse": {
  9870 + "properties": {
  9871 + "message": {
  9872 + "type": "literal",
  9873 + "value": "joined"
  9874 + },
  9875 + "ok": {
  9876 + "type": "literal",
  9877 + "value": true
  9878 + }
  9879 + },
  9880 + "type": "object"
  9881 + },
  9882 + "WaitlistNoteRequest": {
  9883 + "properties": {
  9884 + "note": {
  9885 + "type": "string"
  9886 + }
  9887 + },
  9888 + "type": "object"
  9889 + },
  9890 + "WaitlistPublicSource": {
  9891 + "options": [
  9892 + "landing_footer",
  9893 + "landing_hero",
  9894 + "pricing"
  9895 + ],
  9896 + "type": "picklist"
  9897 + },
  9898 + "WaitlistRejectRequest": {
  9899 + "properties": {
  9900 + "reason": {
  9901 + "type": "string"
  9902 + }
  9903 + },
  9904 + "type": "object"
  9905 + },
  9906 + "WaitlistSendRegisterCodeResponse": {
  9907 + "properties": {
  9908 + "email": {
  9909 + "type": "string"
  9910 + },
  9911 + "ok": {
  9912 + "type": "literal",
  9913 + "value": true
  9914 + }
  9915 + },
  9916 + "type": "object"
  9917 + },
  9918 + "WaitlistSource": {
  9919 + "options": [
  9920 + "landing_footer",
  9921 + "landing_hero",
  9922 + "manual_admin",
  9923 + "pricing"
  9924 + ],
  9925 + "type": "picklist"
  9926 + },
  9927 + "WaitlistStatus": {
  9928 + "options": [
  9929 + "invite_pending",
  9930 + "invited",
  9931 + "joined",
  9932 + "registered",
  9933 + "rejected"
  9934 + ],
  9935 + "type": "picklist"
  9936 + }
  9937 +}
... ...
packages/contracts/src/drama.js 0 → 100644
  1 +++ a/packages/contracts/src/drama.js
  1 +/**
  2 + * `@drama/contracts/drama` —— drama 主站业务数据形态。
  3 + *
  4 + * 剧本行 / 配音、bundle 播放包、drama 信息流与付费墙。只有 drama 线(apps/server、
  5 + * apps/web、apps/studio 及 drama 播放链)消费。
  6 + */
  7 +
  8 +export { ScriptLine, TtsHint, TtsLineBody } from './schemas/script-line.js'
  9 +export { Bundle, SceneRef, BundleMeta } from './schemas/bundle.js'
  10 +export { Drama, Paywall, Page } from './schemas/drama.js'
  11 +
  12 +/**
  13 + * @typedef {import('./schemas/script-line.js').ScriptLineT} ScriptLineT
  14 + * @typedef {import('./schemas/script-line.js').TtsHintT} TtsHintT
  15 + * @typedef {import('./schemas/bundle.js').BundleT} BundleT
  16 + * @typedef {import('./schemas/bundle.js').SceneRefT} SceneRefT
  17 + * @typedef {import('./schemas/bundle.js').BundleMetaT} BundleMetaT
  18 + * @typedef {import('./schemas/drama.js').DramaT} DramaT
  19 + * @typedef {import('./schemas/drama.js').PaywallT} PaywallT
  20 + */
... ...
packages/contracts/src/dump.js 0 → 100644
  1 +++ a/packages/contracts/src/dump.js
  1 +import { ScriptLine } from './schemas/script-line.js'
  2 +import { Bundle } from './schemas/bundle.js'
  3 +import { Drama, Page } from './schemas/drama.js'
  4 +import { ApiError } from './schemas/error.js'
  5 +import { LoginRequest, LoginResponse } from './schemas/auth.js'
  6 +import { Account } from './schemas/account.js'
  7 +import { UnlockReq, UnlockRes, TopupCheckoutReq, LedgerEntry } from './schemas/credits.js'
  8 +import { TelemetryEvent } from './schemas/telemetry.js'
  9 +import { ShareMeta } from './schemas/share.js'
  10 +import {
  11 + VisualGameAdminAnalyticsOverviewResponse,
  12 + VisualGameAdminAnalyticsDrilldownResponse,
  13 + VisualGameAdminPlayerInteractionsResponse,
  14 + VisualGameAdminContentAnalyticsResponse,
  15 + VisualGameAdminGa4BehaviorResponse,
  16 +} from './schemas/visual-game-admin-analytics.js'
  17 +import {
  18 + WaitlistActionResponse,
  19 + WaitlistActorType,
  20 + WaitlistCreateManualEntryRequest,
  21 + WaitlistEntry,
  22 + WaitlistEntryDetailResponse,
  23 + WaitlistEntryListResponse,
  24 + WaitlistEvent,
  25 + WaitlistEventType,
  26 + WaitlistExportResponse,
  27 + WaitlistInviteDelivery,
  28 + WaitlistInviteDeliveryStatus,
  29 + WaitlistInviteCode,
  30 + WaitlistInviteRequest,
  31 + WaitlistInviteValidateResponse,
  32 + WaitlistSendRegisterCodeResponse,
  33 + WaitlistJoinRequest,
  34 + WaitlistJoinResponse,
  35 + WaitlistNoteRequest,
  36 + WaitlistPublicSource,
  37 + WaitlistRejectRequest,
  38 + WaitlistSource,
  39 + WaitlistStatus,
  40 +} from './schemas/waitlist.js'
  41 +import {
  42 + VisualGameAgentCall,
  43 + VisualGameAgentCallList,
  44 + VisualGameAgentCallModelSummary,
  45 + VisualGameAgentCallRoleSummary,
  46 + VisualGameAgentCallSummary,
  47 + VisualGameAssetIndexItem,
  48 + VisualGameAssetManifest,
  49 + VisualGameTtsVoice,
  50 + VisualGameTtsVoiceList,
  51 + VisualGameCreateRunResponse,
  52 + VisualGameEditAuditLog,
  53 + VisualGameEditAuditLogList,
  54 + VisualGameEndingText,
  55 + VisualGamePack,
  56 + VisualGamePackAssetUploadResponse,
  57 + VisualGamePackCompileResponse,
  58 + VisualGameContentTranslationLocaleStatus,
  59 + VisualGameContentTranslationStatus,
  60 + VisualGameContentTranslationResult,
  61 + VisualGameContentTranslationInventoryItem,
  62 + VisualGameContentTranslationInventory,
  63 + VisualGameContentTranslationProgress,
  64 + VisualGamePackDeleteResponse,
  65 + VisualGamePackList,
  66 + VisualGamePreviewTokenResponse,
  67 + VisualGameNotification,
  68 + VisualGameNotificationList,
  69 + VisualGameRunDetail,
  70 + VisualGameStoryVideoLog,
  71 + VisualGameScenarioVersion,
  72 + VisualGameScenarioListItem,
  73 + VisualGameScenarioPage,
  74 + VisualGameTurnEvent,
  75 +} from './schemas/visual-game.js'
  76 +import {
  77 + VisualGameAdminUserActionResponse,
  78 + VisualGameAdminUserCreditsAdjustRequest,
  79 + VisualGameAdminUserDetailResponse,
  80 + VisualGameAdminUserListResponse,
  81 + VisualGameAdminUserProvisionRequest,
  82 + VisualGameAdminUserProfileUpdateRequest,
  83 + VisualGameAdminUserSubscriptionUpdateRequest,
  84 + VisualGameAdminUserSummary,
  85 +} from './schemas/visual-game-admin-users.js'
  86 +import {
  87 + VisualGameKpAgentOutput,
  88 + VisualGameNpcActionKpOutput,
  89 + VisualGameNpcActorOutput,
  90 +} from './schemas/visual-game-agent.js'
  91 +import {
  92 + VisualGameScenarioReleaseApplyResponse,
  93 + VisualGameScenarioReleaseBundle,
  94 + VisualGameScenarioReleaseRequest,
  95 + VisualGameScenarioReleaseRecord,
  96 + VisualGameScenarioReleaseTargetState,
  97 +} from './schemas/visual-game-scenario-release.js'
  98 +
  99 +/**
  100 + * 把单个 Valibot schema 递归序列化为一个稳定、确定性的 JSON 描述。
  101 + *
  102 + * 这是 schema gate 的发动机:同一份 schema 必须逐字节产出同样的描述(键有序、
  103 + * 无时间戳、无随机顺序),否则 gate 会因伪漂移误报。本函数只覆盖本包实际使用
  104 + * 到的 Valibot schema 类型(object / array / record / optional / picklist /
  105 + * 基础标量),遇到未覆盖类型显式抛错——宁可 gate 失败,也不静默产出歧义描述。
  106 + *
  107 + * @param {any} schema Valibot schema 对象
  108 + * @returns {object} 该 schema 的稳定 JSON 描述
  109 + */
  110 +export function describeSchema(schema) {
  111 + switch (schema.type) {
  112 + case 'object':
  113 + case 'strict_object': {
  114 + /** @type {Record<string, object>} */
  115 + const properties = {}
  116 + for (const key of Object.keys(schema.entries).sort()) {
  117 + properties[key] = describeSchema(schema.entries[key])
  118 + }
  119 + return { type: schema.type, properties }
  120 + }
  121 + case 'array':
  122 + return { type: 'array', item: describeSchema(schema.item) }
  123 + case 'record':
  124 + return {
  125 + type: 'record',
  126 + key: describeSchema(schema.key),
  127 + value: describeSchema(schema.value),
  128 + }
  129 + case 'optional':
  130 + return { type: 'optional', wrapped: describeSchema(schema.wrapped) }
  131 + case 'nullable':
  132 + return { type: 'nullable', wrapped: describeSchema(schema.wrapped) }
  133 + case 'picklist':
  134 + return { type: 'picklist', options: [...schema.options].sort() }
  135 + case 'literal':
  136 + return { type: 'literal', value: schema.literal }
  137 + case 'union':
  138 + return { type: 'union', options: schema.options.map(describeSchema) }
  139 + case 'intersect':
  140 + return { type: 'intersect', options: schema.options.map(describeSchema) }
  141 + case 'custom':
  142 + return { type: 'custom' }
  143 + case 'string':
  144 + case 'number':
  145 + case 'boolean':
  146 + case 'unknown':
  147 + return { type: schema.type }
  148 + default:
  149 + throw new Error(`describeSchema: 未支持的 Valibot schema 类型 "${schema.type}"`)
  150 + }
  151 +}
  152 +
  153 +/**
  154 + * 导出全部公共 schema 的稳定 JSON 描述供 CI 比对。
  155 + *
  156 + * 返回对象键有序(按 schema 名排序),值为各 schema 的确定性描述;连续两次调用
  157 + * 输出逐字节相同。`Page` 是高阶工厂,故快照里以一个代表性实例化(`Page(Drama)`)
  158 + * 锁定其信封结构。
  159 + *
  160 + * @returns {Record<string, object>} `{ [schemaName]: jsonRepresentation }`,键有序
  161 + */
  162 +export function dumpSchemas() {
  163 + /** @type {Record<string, any>} */
  164 + const registry = {
  165 + Account,
  166 + ApiError,
  167 + Bundle,
  168 + Drama,
  169 + LedgerEntry,
  170 + LoginRequest,
  171 + LoginResponse,
  172 + PageOfDrama: Page(Drama),
  173 + ScriptLine,
  174 + TopupCheckoutReq,
  175 + UnlockReq,
  176 + UnlockRes,
  177 + ShareMeta,
  178 + TelemetryEvent,
  179 + VisualGameAgentCall,
  180 + VisualGameAgentCallList,
  181 + VisualGameAgentCallModelSummary,
  182 + VisualGameAgentCallRoleSummary,
  183 + VisualGameAgentCallSummary,
  184 + VisualGameKpAgentOutput,
  185 + VisualGameNpcActionKpOutput,
  186 + VisualGameNpcActorOutput,
  187 + VisualGameAssetIndexItem,
  188 + VisualGameAssetManifest,
  189 + VisualGameTtsVoice,
  190 + VisualGameTtsVoiceList,
  191 + VisualGameCreateRunResponse,
  192 + VisualGameEditAuditLog,
  193 + VisualGameEditAuditLogList,
  194 + VisualGameEndingText,
  195 + VisualGamePack,
  196 + VisualGamePackAssetUploadResponse,
  197 + VisualGamePackCompileResponse,
  198 + VisualGameContentTranslationLocaleStatus,
  199 + VisualGameContentTranslationStatus,
  200 + VisualGameContentTranslationResult,
  201 + VisualGameContentTranslationInventoryItem,
  202 + VisualGameContentTranslationInventory,
  203 + VisualGameContentTranslationProgress,
  204 + VisualGamePackDeleteResponse,
  205 + VisualGamePackList,
  206 + VisualGamePreviewTokenResponse,
  207 + VisualGameNotification,
  208 + VisualGameNotificationList,
  209 + VisualGameRunDetail,
  210 + VisualGameStoryVideoLog,
  211 + VisualGameScenarioVersion,
  212 + VisualGameScenarioListItem,
  213 + VisualGameScenarioPage,
  214 + VisualGameScenarioReleaseApplyResponse,
  215 + VisualGameScenarioReleaseBundle,
  216 + VisualGameScenarioReleaseRequest,
  217 + VisualGameScenarioReleaseRecord,
  218 + VisualGameScenarioReleaseTargetState,
  219 + VisualGameTurnEvent,
  220 + VisualGameAdminUserDetailResponse,
  221 + VisualGameAdminUserActionResponse,
  222 + VisualGameAdminUserCreditsAdjustRequest,
  223 + VisualGameAdminUserListResponse,
  224 + VisualGameAdminUserProvisionRequest,
  225 + VisualGameAdminUserProfileUpdateRequest,
  226 + VisualGameAdminUserSubscriptionUpdateRequest,
  227 + VisualGameAdminUserSummary,
  228 + VisualGameAdminAnalyticsOverviewResponse,
  229 + VisualGameAdminAnalyticsDrilldownResponse,
  230 + VisualGameAdminPlayerInteractionsResponse,
  231 + VisualGameAdminContentAnalyticsResponse,
  232 + VisualGameAdminGa4BehaviorResponse,
  233 + WaitlistActionResponse,
  234 + WaitlistActorType,
  235 + WaitlistCreateManualEntryRequest,
  236 + WaitlistEntry,
  237 + WaitlistEntryDetailResponse,
  238 + WaitlistEntryListResponse,
  239 + WaitlistEvent,
  240 + WaitlistEventType,
  241 + WaitlistExportResponse,
  242 + WaitlistInviteDelivery,
  243 + WaitlistInviteDeliveryStatus,
  244 + WaitlistInviteCode,
  245 + WaitlistInviteRequest,
  246 + WaitlistInviteValidateResponse,
  247 + WaitlistSendRegisterCodeResponse,
  248 + WaitlistJoinRequest,
  249 + WaitlistJoinResponse,
  250 + WaitlistNoteRequest,
  251 + WaitlistPublicSource,
  252 + WaitlistRejectRequest,
  253 + WaitlistSource,
  254 + WaitlistStatus,
  255 + }
  256 +
  257 + /** @type {Record<string, object>} */
  258 + const out = {}
  259 + for (const name of Object.keys(registry).sort()) {
  260 + out[name] = describeSchema(registry[name])
  261 + }
  262 + return out
  263 +}
... ...
packages/contracts/src/index.js 0 → 100644
  1 +++ a/packages/contracts/src/index.js
  1 +/**
  2 + * `@drama/contracts` 包根 —— 只暴露跨产品的工具函数。
  3 + *
  4 + * 业务 schema 按产品拆到三个子路径导入(深导入被 ESLint 拦截,子路径为声明的公共面):
  5 + * - `@drama/contracts/drama`:drama 主站业务 schema。
  6 + * - `@drama/contracts/visual-game`:visual-game 业务 schema。
  7 + * - `@drama/contracts/platform`:账号/鉴权/计费/支付等两条线共用的平台 schema。
  8 + *
  9 + * 包根只留两类工具函数:
  10 + * 1. 各 `parseXxx` 边界校验助手(HTTP 入口唯一校验入口)。
  11 + * 2. `dumpSchemas`(确定性 schema 快照,供 CI schema gate 比对)。
  12 + *
  13 + * 本包零 IO、零 `@drama/*` 依赖、零 `process.env`:纯定义 + 纯函数,可在
  14 + * 浏览器与 Node 同时 import。
  15 + */
  16 +
  17 +// === parse 助手(HTTP 入口边界校验唯一入口;失败抛 Valibot ValiError)===
  18 +export {
  19 + parseScriptLine,
  20 + parseBundle,
  21 + parseDrama,
  22 + parseApiError,
  23 + parseLogin,
  24 + parseLoginResponse,
  25 + parseAccount,
  26 + parseUnlockReq,
  27 + parseTopupCheckoutReq,
  28 + parseTelemetryEvent,
  29 + parseShareMeta,
  30 + parseBillingSummary,
  31 + parseVisualGameKpAgentOutput,
  32 + parseVisualGameNpcActorOutput,
  33 + parseVisualGameNpcActionKpOutput,
  34 + parseVisualGameScenarioListItem,
  35 + parseVisualGameScenarioPage,
  36 + parseVisualGameAssetManifest,
  37 + parseVisualGameTtsVoiceList,
  38 + parseVisualGameCreateRunResponse,
  39 + parseVisualGameRunDetail,
  40 + parseVisualGameRunList,
  41 + parseVisualGameRunVideo,
  42 + parseVisualGameRunVideoGeneration,
  43 + parseVisualGameRunVideoGenerationResult,
  44 + parseVisualGameStoryVideoLog,
  45 + parseVisualGameNotification,
  46 + parseVisualGameNotificationList,
  47 + parseVisualGameScenarioVideoList,
  48 + parseVisualGameShowcaseVideoPage,
  49 + parseVisualGameVideoEngagement,
  50 + parseVisualGameSeasonList,
  51 + parseVisualGameRunEvent,
  52 + parseVisualGameRunEventList,
  53 + parseVisualGameAgentCallList,
  54 + parseVisualGameProgress,
  55 + parseVisualGameProgressPatch,
  56 + parseVisualGamePaywallInfo,
  57 + parseVisualGameSaveSlot,
  58 + parseVisualGameSaveSlotList,
  59 + parseVisualGameDeleteSaveSlotResponse,
  60 + parseVisualGameTurnEvent,
  61 + parseVisualGameEndingText,
  62 + parseVisualGameScenarioVersion,
  63 + parseVisualGamePack,
  64 + parseVisualGamePackList,
  65 + parseVisualGamePackCompileResponse,
  66 + parseVisualGameContentTranslationStatus,
  67 + parseVisualGameContentTranslationResult,
  68 + parseVisualGameContentTranslationInventory,
  69 + parseVisualGameContentTranslationProgress,
  70 + parseVisualGamePackAssetUploadResponse,
  71 + parseVisualGamePackDeleteResponse,
  72 + parseVisualGamePreviewTokenResponse,
  73 + parseVisualGamePublishCheckList,
  74 + parseVisualGameEditAuditLogList,
  75 + parseVisualGameCreatorProject,
  76 + parseVisualGameCreatorProjectList,
  77 + parseVisualGameAdminUserSummary,
  78 + parseVisualGameAdminUserListResponse,
  79 + parseVisualGameAdminUserDetailResponse,
  80 + parseVisualGameAdminUserProvisionRequest,
  81 + parseVisualGameAdminUserProfileUpdateRequest,
  82 + parseVisualGameAdminUserCreditsAdjustRequest,
  83 + parseVisualGameAdminUserSubscriptionUpdateRequest,
  84 + parseVisualGameAdminUserActionResponse,
  85 + parseVisualGameAdminBillingActionPolicy,
  86 + parseVisualGameAdminBillingPolicyList,
  87 + parseVisualGameAdminBillingProduct,
  88 + parseVisualGameAdminBillingProductList,
  89 + parseVisualGameAdminBillingOrderPage,
  90 + parseVisualGameAdminBillingLedgerPage,
  91 + parseVisualGameAdminBillingDashboard,
  92 + parseVisualGameAdminAnalyticsOverviewResponse,
  93 + parseVisualGameAdminAnalyticsDrilldownResponse,
  94 + parseVisualGameAdminPlayerInteractionsResponse,
  95 + parseVisualGameAdminContentAnalyticsResponse,
  96 + parseVisualGameAdminGa4BehaviorResponse,
  97 + parseWaitlistJoinRequest,
  98 + parseWaitlistJoinResponse,
  99 + parseWaitlistInviteValidateResponse,
  100 + parseWaitlistSendRegisterCodeResponse,
  101 + parseWaitlistEntryListResponse,
  102 + parseWaitlistEntryDetailResponse,
  103 + parseWaitlistCreateManualEntryRequest,
  104 + parseWaitlistNoteRequest,
  105 + parseWaitlistInviteRequest,
  106 + parseWaitlistRejectRequest,
  107 + parseWaitlistActionResponse,
  108 + parseWaitlistExportResponse,
  109 +} from './parse.js'
  110 +
  111 +// === schema 快照导出(CI gate 用,确定性序列化)===
  112 +export { dumpSchemas, describeSchema } from './dump.js'
... ...
packages/contracts/src/parse.js 0 → 100644
  1 +++ a/packages/contracts/src/parse.js
  1 +import * as v from 'valibot'
  2 +import { ScriptLine } from './schemas/script-line.js'
  3 +import { Bundle } from './schemas/bundle.js'
  4 +import { Drama } from './schemas/drama.js'
  5 +import { ApiError } from './schemas/error.js'
  6 +import { LoginRequest, LoginResponse } from './schemas/auth.js'
  7 +import { Account } from './schemas/account.js'
  8 +import { UnlockReq, TopupCheckoutReq } from './schemas/credits.js'
  9 +import { TelemetryEvent } from './schemas/telemetry.js'
  10 +import { ShareMeta } from './schemas/share.js'
  11 +import { BillingSummary } from './schemas/commerce.js'
  12 +import {
  13 + VisualGameAdminAnalyticsOverviewResponse,
  14 + VisualGameAdminAnalyticsDrilldownResponse,
  15 + VisualGameAdminPlayerInteractionsResponse,
  16 + VisualGameAdminContentAnalyticsResponse,
  17 + VisualGameAdminGa4BehaviorResponse,
  18 +} from './schemas/visual-game-admin-analytics.js'
  19 +import {
  20 + WaitlistActionResponse,
  21 + WaitlistCreateManualEntryRequest,
  22 + WaitlistEntryDetailResponse,
  23 + WaitlistEntryListResponse,
  24 + WaitlistExportResponse,
  25 + WaitlistInviteRequest,
  26 + WaitlistInviteValidateResponse,
  27 + WaitlistSendRegisterCodeResponse,
  28 + WaitlistJoinRequest,
  29 + WaitlistJoinResponse,
  30 + WaitlistNoteRequest,
  31 + WaitlistRejectRequest,
  32 +} from './schemas/waitlist.js'
  33 +import {
  34 + VisualGameAgentCallList,
  35 + VisualGameAssetManifest,
  36 + VisualGameCreateRunResponse,
  37 + VisualGameDeleteSaveSlotResponse,
  38 + VisualGameEditAuditLogList,
  39 + VisualGameEndingText,
  40 + VisualGamePack,
  41 + VisualGamePackAssetUploadResponse,
  42 + VisualGamePackCompileResponse,
  43 + VisualGameContentTranslationStatus,
  44 + VisualGameContentTranslationResult,
  45 + VisualGameContentTranslationInventory,
  46 + VisualGameContentTranslationProgress,
  47 + VisualGamePackDeleteResponse,
  48 + VisualGamePackList,
  49 + VisualGamePaywallInfo,
  50 + VisualGamePreviewTokenResponse,
  51 + VisualGameProgress,
  52 + VisualGameProgressPatch,
  53 + VisualGamePublishCheckList,
  54 + VisualGameRunDetail,
  55 + VisualGameRunList,
  56 + VisualGameRunVideo,
  57 + VisualGameRunVideoGeneration,
  58 + VisualGameRunVideoGenerationResult,
  59 + VisualGameStoryVideoLog,
  60 + VisualGameNotification,
  61 + VisualGameNotificationList,
  62 + VisualGameRunEvent,
  63 + VisualGameSaveSlot,
  64 + VisualGameScenarioVideoList,
  65 + VisualGameScenarioVersion,
  66 + VisualGameShowcaseVideoPage,
  67 + VisualGameVideoEngagement,
  68 + VisualGameSeasonList,
  69 + VisualGameScenarioListItem,
  70 + VisualGameScenarioPage,
  71 + VisualGameTtsVoiceList,
  72 + VisualGameTurnEvent,
  73 + VisualGameCreatorProject,
  74 + VisualGameCreatorProjectList,
  75 +} from './schemas/visual-game.js'
  76 +import {
  77 + VisualGameAdminUserActionResponse,
  78 + VisualGameAdminUserCreditsAdjustRequest,
  79 + VisualGameAdminUserDetailResponse,
  80 + VisualGameAdminUserListResponse,
  81 + VisualGameAdminUserProvisionRequest,
  82 + VisualGameAdminUserProfileUpdateRequest,
  83 + VisualGameAdminUserSubscriptionUpdateRequest,
  84 + VisualGameAdminUserSummary,
  85 +} from './schemas/visual-game-admin-users.js'
  86 +import {
  87 + VisualGameAdminBillingActionPolicy,
  88 + VisualGameAdminBillingDashboard,
  89 + VisualGameAdminBillingLedgerPage,
  90 + VisualGameAdminBillingOrderPage,
  91 + VisualGameAdminBillingPolicyList,
  92 + VisualGameAdminBillingProduct,
  93 + VisualGameAdminBillingProductList,
  94 +} from './schemas/visual-game-admin-commerce.js'
  95 +import {
  96 + VisualGameKpAgentOutput,
  97 + VisualGameNpcActionKpOutput,
  98 + VisualGameNpcActorOutput,
  99 +} from './schemas/visual-game-agent.js'
  100 +
  101 +/**
  102 + * 各 parseXxx 助手:HTTP 入口边界校验的唯一入口,薄包一层 `v.parse`。
  103 + * 非法输入抛 Valibot `ValiError`,其 `issues` 指向具体字段路径,统一错误风格。
  104 + */
  105 +
  106 +/** @param {unknown} x @returns {import('./schemas/script-line.js').ScriptLineT} */
  107 +export const parseScriptLine = (x) => v.parse(ScriptLine, x)
  108 +
  109 +/** @param {unknown} x @returns {import('./schemas/bundle.js').BundleT} */
  110 +export const parseBundle = (x) => v.parse(Bundle, x)
  111 +
  112 +/** @param {unknown} x @returns {import('./schemas/drama.js').DramaT} */
  113 +export const parseDrama = (x) => v.parse(Drama, x)
  114 +
  115 +/** @param {unknown} x @returns {import('./schemas/error.js').ApiErrorT} */
  116 +export const parseApiError = (x) => v.parse(ApiError, x)
  117 +
  118 +/** @param {unknown} x @returns {import('./schemas/auth.js').LoginRequestT} */
  119 +export const parseLogin = (x) => v.parse(LoginRequest, x)
  120 +
  121 +/** @param {unknown} x @returns {import('./schemas/auth.js').LoginResponseT} */
  122 +export const parseLoginResponse = (x) => v.parse(LoginResponse, x)
  123 +
  124 +/** @param {unknown} x @returns {import('./schemas/account.js').AccountT} */
  125 +export const parseAccount = (x) => v.parse(Account, x)
  126 +
  127 +/** @param {unknown} x @returns {import('./schemas/credits.js').UnlockReqT} */
  128 +export const parseUnlockReq = (x) => v.parse(UnlockReq, x)
  129 +
  130 +/** @param {unknown} x @returns {import('./schemas/credits.js').TopupCheckoutReqT} */
  131 +export const parseTopupCheckoutReq = (x) => v.parse(TopupCheckoutReq, x)
  132 +
  133 +/** @param {unknown} x @returns {import('./schemas/telemetry.js').TelemetryEventT} */
  134 +export const parseTelemetryEvent = (x) => v.parse(TelemetryEvent, x)
  135 +
  136 +/** @param {unknown} x @returns {import('./schemas/share.js').ShareMetaT} */
  137 +export const parseShareMeta = (x) => v.parse(ShareMeta, x)
  138 +
  139 +/** @param {unknown} x */
  140 +export const parseBillingSummary = (x) => v.parse(BillingSummary, x)
  141 +
  142 +/** @param {unknown} x */
  143 +export const parseVisualGameKpAgentOutput = (x) => v.parse(VisualGameKpAgentOutput, x)
  144 +/** @param {unknown} x */
  145 +export const parseVisualGameNpcActorOutput = (x) => v.parse(VisualGameNpcActorOutput, x)
  146 +/** @param {unknown} x */
  147 +export const parseVisualGameNpcActionKpOutput = (x) => v.parse(VisualGameNpcActionKpOutput, x)
  148 +
  149 +/** @param {unknown} x */
  150 +export const parseVisualGameScenarioListItem = (x) => v.parse(VisualGameScenarioListItem, x)
  151 +/** @param {unknown} x */
  152 +export const parseVisualGameScenarioPage = (x) => v.parse(VisualGameScenarioPage, x)
  153 +/** @param {unknown} x */
  154 +export const parseVisualGameAssetManifest = (x) => v.parse(VisualGameAssetManifest, x)
  155 +/** @param {unknown} x */
  156 +export const parseVisualGameTtsVoiceList = (x) => v.parse(VisualGameTtsVoiceList, x)
  157 +/** @param {unknown} x */
  158 +export const parseVisualGameCreateRunResponse = (x) => v.parse(VisualGameCreateRunResponse, x)
  159 +/** @param {unknown} x */
  160 +export const parseVisualGameRunDetail = (x) => v.parse(VisualGameRunDetail, x)
  161 +/** @param {unknown} x */
  162 +export const parseVisualGameRunList = (x) => v.parse(VisualGameRunList, x)
  163 +/** @param {unknown} x */
  164 +export const parseVisualGameRunVideo = (x) => v.parse(VisualGameRunVideo, x)
  165 +export const parseVisualGameRunVideoGeneration = (x) => v.parse(VisualGameRunVideoGeneration, x)
  166 +export const parseVisualGameRunVideoGenerationResult = (x) => v.parse(VisualGameRunVideoGenerationResult, x)
  167 +/** @param {unknown} x */
  168 +export const parseVisualGameStoryVideoLog = (x) => v.parse(VisualGameStoryVideoLog, x)
  169 +export const parseVisualGameNotification = (x) => v.parse(VisualGameNotification, x)
  170 +export const parseVisualGameNotificationList = (x) => v.parse(VisualGameNotificationList, x)
  171 +/** @param {unknown} x */
  172 +export const parseVisualGameScenarioVideoList = (x) => v.parse(VisualGameScenarioVideoList, x)
  173 +export const parseVisualGameShowcaseVideoPage = (x) => v.parse(VisualGameShowcaseVideoPage, x)
  174 +export const parseVisualGameVideoEngagement = (x) => v.parse(VisualGameVideoEngagement, x)
  175 +export const parseVisualGameSeasonList = (x) => v.parse(VisualGameSeasonList, x)
  176 +/** @param {unknown} x */
  177 +export const parseVisualGameRunEvent = (x) => v.parse(VisualGameRunEvent, x)
  178 +/** @param {unknown} x */
  179 +export const parseVisualGameRunEventList = (x) => Array.isArray(x) ? x.map(parseVisualGameRunEvent) : []
  180 +/** @param {unknown} x */
  181 +export const parseVisualGameAgentCallList = (x) => v.parse(VisualGameAgentCallList, x)
  182 +/** @param {unknown} x */
  183 +export const parseVisualGameProgress = (x) => v.parse(VisualGameProgress, x)
  184 +/** @param {unknown} x */
  185 +export const parseVisualGameProgressPatch = (x) => v.parse(VisualGameProgressPatch, x)
  186 +/** @param {unknown} x */
  187 +export const parseVisualGamePaywallInfo = (x) => v.parse(VisualGamePaywallInfo, x)
  188 +/** @param {unknown} x */
  189 +export const parseVisualGameSaveSlot = (x) => v.parse(VisualGameSaveSlot, x)
  190 +/** @param {unknown} x */
  191 +export const parseVisualGameSaveSlotList = (x) => Array.isArray(x) ? x.map(parseVisualGameSaveSlot) : []
  192 +/** @param {unknown} x */
  193 +export const parseVisualGameDeleteSaveSlotResponse = (x) => v.parse(VisualGameDeleteSaveSlotResponse, x)
  194 +/** @param {unknown} x */
  195 +export const parseVisualGameTurnEvent = (x) => v.parse(VisualGameTurnEvent, x)
  196 +/** @param {unknown} x */
  197 +export const parseVisualGameEndingText = (x) => v.parse(VisualGameEndingText, x)
  198 +/** @param {unknown} x */
  199 +export const parseVisualGameScenarioVersion = (x) => v.parse(VisualGameScenarioVersion, x)
  200 +/** @param {unknown} x */
  201 +export const parseVisualGamePack = (x) => v.parse(VisualGamePack, x)
  202 +/** @param {unknown} x */
  203 +export const parseVisualGamePackList = (x) => v.parse(VisualGamePackList, x)
  204 +/** @param {unknown} x */
  205 +export const parseVisualGamePackCompileResponse = (x) => v.parse(VisualGamePackCompileResponse, x)
  206 +/** @param {unknown} x */
  207 +export const parseVisualGameContentTranslationStatus = (x) => v.parse(VisualGameContentTranslationStatus, x)
  208 +/** @param {unknown} x */
  209 +export const parseVisualGameContentTranslationResult = (x) => v.parse(VisualGameContentTranslationResult, x)
  210 +/** @param {unknown} x */
  211 +export const parseVisualGameContentTranslationInventory = (x) => v.parse(VisualGameContentTranslationInventory, x)
  212 +/** @param {unknown} x */
  213 +export const parseVisualGameContentTranslationProgress = (x) => v.parse(VisualGameContentTranslationProgress, x)
  214 +/** @param {unknown} x */
  215 +export const parseVisualGamePackAssetUploadResponse = (x) => v.parse(VisualGamePackAssetUploadResponse, x)
  216 +/** @param {unknown} x */
  217 +export const parseVisualGamePackDeleteResponse = (x) => v.parse(VisualGamePackDeleteResponse, x)
  218 +/** @param {unknown} x */
  219 +export const parseVisualGamePreviewTokenResponse = (x) => v.parse(VisualGamePreviewTokenResponse, x)
  220 +/** @param {unknown} x */
  221 +export const parseVisualGamePublishCheckList = (x) => v.parse(VisualGamePublishCheckList, x)
  222 +/** @param {unknown} x */
  223 +export const parseVisualGameEditAuditLogList = (x) => v.parse(VisualGameEditAuditLogList, x)
  224 +/** @param {unknown} x */
  225 +export const parseVisualGameCreatorProject = (x) => v.parse(VisualGameCreatorProject, x)
  226 +/** @param {unknown} x */
  227 +export const parseVisualGameCreatorProjectList = (x) => v.parse(VisualGameCreatorProjectList, x)
  228 +/** @param {unknown} x */
  229 +export const parseVisualGameAdminUserSummary = (x) => v.parse(VisualGameAdminUserSummary, x)
  230 +/** @param {unknown} x */
  231 +export const parseVisualGameAdminUserListResponse = (x) => v.parse(VisualGameAdminUserListResponse, x)
  232 +/** @param {unknown} x */
  233 +export const parseVisualGameAdminUserDetailResponse = (x) => v.parse(VisualGameAdminUserDetailResponse, x)
  234 +/** @param {unknown} x */
  235 +export const parseVisualGameAdminUserProvisionRequest = (x) => v.parse(VisualGameAdminUserProvisionRequest, x)
  236 +/** @param {unknown} x */
  237 +export const parseVisualGameAdminUserProfileUpdateRequest = (x) => v.parse(VisualGameAdminUserProfileUpdateRequest, x)
  238 +/** @param {unknown} x */
  239 +export const parseVisualGameAdminUserCreditsAdjustRequest = (x) => v.parse(VisualGameAdminUserCreditsAdjustRequest, x)
  240 +/** @param {unknown} x */
  241 +export const parseVisualGameAdminUserSubscriptionUpdateRequest = (x) => v.parse(VisualGameAdminUserSubscriptionUpdateRequest, x)
  242 +/** @param {unknown} x */
  243 +export const parseVisualGameAdminUserActionResponse = (x) => v.parse(VisualGameAdminUserActionResponse, x)
  244 +export const parseVisualGameAdminBillingActionPolicy = (x) => v.parse(VisualGameAdminBillingActionPolicy, x)
  245 +export const parseVisualGameAdminBillingPolicyList = (x) => v.parse(VisualGameAdminBillingPolicyList, x)
  246 +export const parseVisualGameAdminBillingProduct = (x) => v.parse(VisualGameAdminBillingProduct, x)
  247 +export const parseVisualGameAdminBillingProductList = (x) => v.parse(VisualGameAdminBillingProductList, x)
  248 +export const parseVisualGameAdminBillingOrderPage = (x) => v.parse(VisualGameAdminBillingOrderPage, x)
  249 +export const parseVisualGameAdminBillingLedgerPage = (x) => v.parse(VisualGameAdminBillingLedgerPage, x)
  250 +export const parseVisualGameAdminBillingDashboard = (x) => v.parse(VisualGameAdminBillingDashboard, x)
  251 +/** @param {unknown} x */
  252 +export const parseVisualGameAdminAnalyticsOverviewResponse = (x) => v.parse(VisualGameAdminAnalyticsOverviewResponse, x)
  253 +/** @param {unknown} x */
  254 +export const parseVisualGameAdminAnalyticsDrilldownResponse = (x) => v.parse(VisualGameAdminAnalyticsDrilldownResponse, x)
  255 +/** @param {unknown} x */
  256 +export const parseVisualGameAdminPlayerInteractionsResponse = (x) => v.parse(VisualGameAdminPlayerInteractionsResponse, x)
  257 +/** @param {unknown} x */
  258 +export const parseVisualGameAdminContentAnalyticsResponse = (x) => v.parse(VisualGameAdminContentAnalyticsResponse, x)
  259 +/** @param {unknown} x */
  260 +export const parseVisualGameAdminGa4BehaviorResponse = (x) => v.parse(VisualGameAdminGa4BehaviorResponse, x)
  261 +
  262 +/** @param {unknown} x */
  263 +export const parseWaitlistJoinRequest = (x) => v.parse(WaitlistJoinRequest, x)
  264 +/** @param {unknown} x */
  265 +export const parseWaitlistJoinResponse = (x) => v.parse(WaitlistJoinResponse, x)
  266 +/** @param {unknown} x */
  267 +export const parseWaitlistInviteValidateResponse = (x) => v.parse(WaitlistInviteValidateResponse, x)
  268 +/** @param {unknown} x */
  269 +export const parseWaitlistSendRegisterCodeResponse = (x) => v.parse(WaitlistSendRegisterCodeResponse, x)
  270 +/** @param {unknown} x */
  271 +export const parseWaitlistEntryListResponse = (x) => v.parse(WaitlistEntryListResponse, x)
  272 +/** @param {unknown} x */
  273 +export const parseWaitlistEntryDetailResponse = (x) => v.parse(WaitlistEntryDetailResponse, x)
  274 +/** @param {unknown} x */
  275 +export const parseWaitlistCreateManualEntryRequest = (x) => v.parse(WaitlistCreateManualEntryRequest, x)
  276 +/** @param {unknown} x */
  277 +export const parseWaitlistNoteRequest = (x) => v.parse(WaitlistNoteRequest, x)
  278 +/** @param {unknown} x */
  279 +export const parseWaitlistInviteRequest = (x) => v.parse(WaitlistInviteRequest, x)
  280 +/** @param {unknown} x */
  281 +export const parseWaitlistRejectRequest = (x) => v.parse(WaitlistRejectRequest, x)
  282 +/** @param {unknown} x */
  283 +export const parseWaitlistActionResponse = (x) => v.parse(WaitlistActionResponse, x)
  284 +/** @param {unknown} x */
  285 +export const parseWaitlistExportResponse = (x) => v.parse(WaitlistExportResponse, x)
... ...
packages/contracts/src/platform.js 0 → 100644
  1 +++ a/packages/contracts/src/platform.js
  1 +/**
  2 + * `@drama/contracts/platform` —— 两条产品线共用的平台数据形态。
  3 + *
  4 + * 账号 / 鉴权 / 计费 / 支付、通用错误信封、遥测事件、分享 meta。drama 线与
  5 + * visual-game 线都消费。
  6 + */
  7 +
  8 +export { Account, Subscription } from './schemas/account.js'
  9 +export { LoginRequest, LoginResponse, RegisterRequest, SendRegistrationCodeRequest } from './schemas/auth.js'
  10 +export { ApiError } from './schemas/error.js'
  11 +export { UnlockReq, UnlockRes, TopupCheckoutReq, LedgerEntry } from './schemas/credits.js'
  12 +export { TelemetryEvent } from './schemas/telemetry.js'
  13 +export { ShareMeta } from './schemas/share.js'
  14 +export { BillingSummary, DailyClaimResponse } from './schemas/commerce.js'
  15 +export {
  16 + VisualGameAdminBillingActionPolicy,
  17 + VisualGameAdminBillingLedgerPage,
  18 + VisualGameAdminBillingOrderPage,
  19 + VisualGameAdminBillingPolicyList,
  20 + VisualGameAdminBillingProduct,
  21 + VisualGameAdminBillingProductList,
  22 +} from './schemas/visual-game-admin-commerce.js'
  23 +export {
  24 + WAITLIST_INVITE_CODE_LENGTH,
  25 + WAITLIST_INVITE_CODE_ALPHABET,
  26 + WAITLIST_INVITE_CODE_PATTERN,
  27 + WaitlistInviteCode,
  28 + WaitlistStatus,
  29 + WaitlistInviteDeliveryStatus,
  30 + WaitlistSource,
  31 + WaitlistPublicSource,
  32 + WaitlistEventType,
  33 + WaitlistActorType,
  34 + WaitlistJoinRequest,
  35 + WaitlistJoinResponse,
  36 + WaitlistInviteValidateResponse,
  37 + WaitlistSendRegisterCodeResponse,
  38 + WaitlistEntry,
  39 + WaitlistEvent,
  40 + WaitlistInviteDelivery,
  41 + WaitlistEntryListResponse,
  42 + WaitlistEntryDetailResponse,
  43 + WaitlistCreateManualEntryRequest,
  44 + WaitlistNoteRequest,
  45 + WaitlistInviteRequest,
  46 + WaitlistRejectRequest,
  47 + WaitlistActionResponse,
  48 + WaitlistExportResponse,
  49 +} from './schemas/waitlist.js'
  50 +
  51 +/**
  52 + * @typedef {import('./schemas/account.js').AccountT} AccountT
  53 + * @typedef {import('./schemas/account.js').SubscriptionT} SubscriptionT
  54 + * @typedef {import('./schemas/auth.js').LoginRequestT} LoginRequestT
  55 + * @typedef {import('./schemas/auth.js').LoginResponseT} LoginResponseT
  56 + * @typedef {import('./schemas/auth.js').RegisterRequestT} RegisterRequestT
  57 + * @typedef {import('./schemas/auth.js').SendRegistrationCodeRequestT} SendRegistrationCodeRequestT
  58 + * @typedef {import('./schemas/error.js').ApiErrorT} ApiErrorT
  59 + * @typedef {import('./schemas/telemetry.js').TelemetryEventT} TelemetryEventT
  60 + * @typedef {import('./schemas/share.js').ShareMetaT} ShareMetaT
  61 + * @typedef {import('./schemas/waitlist.js').WaitlistEntryT} WaitlistEntryT
  62 + * @typedef {import('./schemas/waitlist.js').WaitlistEntryListResponseT} WaitlistEntryListResponseT
  63 + */
... ...
packages/contracts/src/schemas/account.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/account.js
  1 +import * as v from 'valibot'
  2 +
  3 +/**
  4 + * 订阅状态:账户当前订阅档位与状态。
  5 + * @typedef {{ tier: string, status: 'active' | 'inactive' | 'past_due', renews_at?: string }} SubscriptionT
  6 + */
  7 +export const Subscription = v.object({
  8 + tier: v.string(),
  9 + status: v.picklist(['active', 'inactive', 'past_due']),
  10 + renews_at: v.optional(v.string()),
  11 +})
  12 +
  13 +/**
  14 +/**
  15 + * 库目条目:个人中心「继续观看 / 已保存」列表项。
  16 + * @typedef {{ id?: string, drama_id?: string, title?: string, cover?: string, progress?: number }} LibraryItemT
  17 + */
  18 +export const LibraryItem = v.object({
  19 + id: v.optional(v.string()),
  20 + drama_id: v.optional(v.string()),
  21 + title: v.optional(v.string()),
  22 + cover: v.optional(v.string()),
  23 + progress: v.optional(v.number()),
  24 +})
  25 +
  26 +/**
  27 + * 账户行为统计:观看 / 改写计数(个人中心头部指标,缺失项前端不渲染)。
  28 + * @typedef {{ watched?: number, rewritten?: number }} AccountStatsT
  29 + */
  30 +export const AccountStats = v.object({
  31 + watched: v.optional(v.number()),
  32 + rewritten: v.optional(v.number()),
  33 +})
  34 +
  35 +/**
  36 + * 账户资料:跨端共享的用户资料 / 积分 / 订阅状态 / 语言。
  37 + * 个人中心扩展字段(history/favorites/stats/member_since)为可选:仅在 server 携带时渲染,
  38 + * 不携带时前端给真实空态。声明在 schema 内以免 valibot 静默 strip 掉这些字段。
  39 + * @typedef {{
  40 + * id: string,
  41 + * email?: string,
  42 + * display_name: string,
  43 + * credits: number,
  44 + * subscription: SubscriptionT,
  45 + * locale: string,
  46 + * member_since?: string,
  47 + * stats?: AccountStatsT,
  48 + * history?: LibraryItemT[],
  49 + * favorites?: LibraryItemT[]
  50 + * }} AccountT
  51 + */
  52 +export const Account = v.object({
  53 + id: v.string(),
  54 + email: v.optional(v.string()),
  55 + display_name: v.string(),
  56 + credits: v.number(),
  57 + subscription: Subscription,
  58 + locale: v.string(),
  59 + role: v.optional(v.picklist(['user', 'operator', 'admin'])),
  60 + member_since: v.optional(v.string()),
  61 + stats: v.optional(AccountStats),
  62 + history: v.optional(v.array(LibraryItem)),
  63 + favorites: v.optional(v.array(LibraryItem)),
  64 +})
... ...
packages/contracts/src/schemas/auth.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/auth.js
  1 +import * as v from 'valibot'
  2 +import { Account } from './account.js'
  3 +
  4 +/**
  5 + * 登录请求:provider(oauth 厂商 / 匿名)+ 凭据 + 可选设备标识(匿名身份合并用)。
  6 + * @typedef {{ provider: string, credential: string, oauth_flow?: 'access_token'|'authorization_code', redirect_uri?: string, device_id?: string }} LoginRequestT
  7 + */
  8 +export const LoginRequest = v.object({
  9 + provider: v.string(),
  10 + credential: v.string(),
  11 + oauth_flow: v.optional(v.picklist(['access_token', 'authorization_code'])),
  12 + redirect_uri: v.optional(v.string()),
  13 + device_id: v.optional(v.string()),
  14 +})
  15 +
  16 +/**
  17 + * 登录响应:访问 / 刷新 token 对 + 账户资料。
  18 + * @typedef {{ access_token: string, refresh_token: string, account: import('./account.js').AccountT }} LoginResponseT
  19 + */
  20 +export const LoginResponse = v.object({
  21 + access_token: v.string(),
  22 + refresh_token: v.string(),
  23 + account: Account,
  24 +})
  25 +
  26 +/**
  27 + * 开放注册请求:验证码只用于本次邮箱注册,邀请码不属于该契约。
  28 + * @typedef {{ email: string, password: string, verification_code: string, locale: 'en'|'zh'|'ja'|'ko'|'es'|'pt', device_id?: string }} RegisterRequestT
  29 + */
  30 +export const RegisterRequest = v.strictObject({
  31 + email: v.pipe(v.string(), v.trim(), v.toLowerCase(), v.email(), v.maxLength(254)),
  32 + password: v.pipe(v.string(), v.minLength(8), v.maxLength(256)),
  33 + verification_code: v.pipe(v.string(), v.regex(/^\d{6}$/)),
  34 + locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']),
  35 + device_id: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(255))),
  36 +})
  37 +
  38 +/** @typedef {{ channel: 'email', target: string, purpose: 'register', locale?: 'en'|'zh'|'ja'|'ko'|'es'|'pt' }} SendRegistrationCodeRequestT */
  39 +export const SendRegistrationCodeRequest = v.strictObject({
  40 + channel: v.literal('email'),
  41 + target: v.pipe(v.string(), v.trim(), v.toLowerCase(), v.email(), v.maxLength(254)),
  42 + purpose: v.literal('register'),
  43 + locale: v.optional(v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt'])),
  44 +})
... ...
packages/contracts/src/schemas/bundle.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/bundle.js
  1 +import * as v from 'valibot'
  2 +import { ScriptLine } from './script-line.js'
  3 +
  4 +/**
  5 + * 场景引用:Bundle 内联引用一个场景(按 id 指向 assets 库的场景资源),
  6 + * 仅在 Bundle 内部使用,不单独作为公共 schema 出包。
  7 + * @typedef {{ id: string, background?: string, props?: string[] }} SceneRefT
  8 + */
  9 +export const SceneRef = v.object({
  10 + id: v.string(),
  11 + background: v.optional(v.string()),
  12 + props: v.optional(v.array(v.string())),
  13 +})
  14 +
  15 +/**
  16 + * Bundle 元信息:drama 级别的播放元数据。
  17 + * @typedef {{ locale: string, version: number, duration?: number }} BundleMetaT
  18 + */
  19 +export const BundleMeta = v.object({
  20 + locale: v.string(),
  21 + version: v.number(),
  22 + duration: v.optional(v.number()),
  23 +})
  24 +
  25 +/**
  26 + * 播放包:`<DramaPlayer :bundle>` 的入参、`@drama/core` 消费、`@drama/bundle` 出包。
  27 + * 组合 drama 元信息 + rig 引用 + 场景序列 + 剧本行序列。
  28 + * @typedef {{
  29 + * drama_id: string,
  30 + * title: string,
  31 + * rig_ref: string,
  32 + * scenes: SceneRefT[],
  33 + * lines: import('./script-line.js').ScriptLineT[],
  34 + * meta: BundleMetaT
  35 + * }} BundleT
  36 + */
  37 +export const Bundle = v.object({
  38 + drama_id: v.string(),
  39 + title: v.string(),
  40 + rig_ref: v.string(),
  41 + scenes: v.array(SceneRef),
  42 + lines: v.array(ScriptLine),
  43 + meta: BundleMeta,
  44 +})
... ...
packages/contracts/src/schemas/commerce.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/commerce.js
  1 +import * as v from 'valibot'
  2 +
  3 +export const BillingSummary = v.object({
  4 + points: v.number(),
  5 + playable_turns: v.number(),
  6 + action_prices: v.object({ turn_play: v.number() }),
  7 + stripe_mode: v.picklist(['test', 'live']),
  8 + daily_claim: v.object({
  9 + claimed_today: v.boolean(),
  10 + next_available_at: v.string(),
  11 + points: v.number(),
  12 + }),
  13 +})
  14 +
  15 +export const DailyClaimResponse = v.object({
  16 + points: v.number(), claimed: v.boolean(), claimed_today: v.boolean(), next_available_at: v.string(),
  17 +})
... ...
packages/contracts/src/schemas/credits.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/credits.js
  1 +import * as v from 'valibot'
  2 +
  3 +/**
  4 + * Credits 经济契约 —— 扣点解锁 + 充值入账 + 流水条目的共享数据形态。
  5 + *
  6 + * 与 S3 的 `account.js`(role/subscription/credits 余额字段)严格分家:本文件只描述
  7 + * 「解锁请求/响应、充值下单请求、单条流水」四种形态,余额本身仍由 AccountT.credits 承载。
  8 + * 纯定义、零 IO,前后端共用。
  9 + */
  10 +
  11 +/**
  12 + * 解锁请求:解锁某剧(可选某集)。episode_id 省略 = 整剧级解锁。
  13 + * @typedef {{ drama_id: string, episode_id?: string }} UnlockReqT
  14 + */
  15 +export const UnlockReq = v.object({
  16 + drama_id: v.pipe(v.string(), v.minLength(1)),
  17 + episode_id: v.optional(v.string()),
  18 +})
  19 +
  20 +/**
  21 + * 解锁响应:是否已解锁 + 解锁后剩余点数 + 可选原因。
  22 + * reason='already' → 此前已解锁,幂等返回,未再扣点。
  23 + * reason='insufficient' → 余额不足,未扣点、未解锁。
  24 + * 无 reason → 本次成功扣点并解锁。
  25 + * @typedef {{ unlocked: boolean, remaining_credits: number, reason?: 'insufficient' | 'already' }} UnlockResT
  26 + */
  27 +export const UnlockRes = v.object({
  28 + unlocked: v.boolean(),
  29 + remaining_credits: v.number(),
  30 + reason: v.optional(v.picklist(['insufficient', 'already'])),
  31 +})
  32 +
  33 +/**
  34 + * 充值下单请求:以套餐 id 选点数包,server 据 pack_id 映射点数/价格发起真 Stripe checkout。
  35 + * @typedef {{ pack_id: string }} TopupCheckoutReqT
  36 + */
  37 +export const TopupCheckoutReq = v.object({
  38 + pack_id: v.pipe(v.string(), v.minLength(1)),
  39 +})
  40 +
  41 +/**
  42 + * 单条点数流水:账本对外展示形态(正入账 / 负扣减)。createdAt 为 ISO 8601 UTC 字符串。
  43 + * @typedef {{ id: string, delta: number, reason: 'topup' | 'unlock' | 'grant', created_at: string }} LedgerEntryT
  44 + */
  45 +export const LedgerEntry = v.object({
  46 + id: v.string(),
  47 + delta: v.number(),
  48 + reason: v.picklist(['topup', 'unlock', 'grant']),
  49 + created_at: v.string(),
  50 +})
... ...
packages/contracts/src/schemas/drama.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/drama.js
  1 +import * as v from 'valibot'
  2 +
  3 +/**
  4 + * 付费墙描述:drama 是否/如何收费(剧情线/结局解锁)。
  5 + * `unlock_cost`/`user_credits`/`vip_tier` 由 server(S2 sk-facade)按需下发,驱动解锁弹窗的
  6 + * 「需 N 点 · 你有 M 点」与「开 VIP」指向;全为可选,缺省时弹窗降级为纯订阅引导。
  7 + * @typedef {{
  8 + * locked: boolean,
  9 + * price?: number,
  10 + * currency?: string,
  11 + * unlock_cost?: number,
  12 + * user_credits?: number,
  13 + * vip_tier?: string
  14 + * }} PaywallT
  15 + */
  16 +export const Paywall = v.object({
  17 + locked: v.boolean(),
  18 + price: v.optional(v.number()),
  19 + currency: v.optional(v.string()),
  20 + /** 解锁本条剧情线所需点数(后端按规则计算下发)。 */
  21 + unlock_cost: v.optional(v.number()),
  22 + /** 当前账户点数余额(解锁弹窗实时显示,禁用判定依据)。 */
  23 + user_credits: v.optional(v.number()),
  24 + /** 「开通 VIP」指向的档位标识(如 vip-yearly)。 */
  25 + vip_tier: v.optional(v.string()),
  26 +})
  27 +
  28 +/**
  29 + * drama 列表项形状:Feed 刷剧与剧本库共用。
  30 + * @typedef {{
  31 + * id: string,
  32 + * title: string,
  33 + * cover: string,
  34 + * locale: string,
  35 + * heat?: number,
  36 + * paywall?: PaywallT
  37 + * }} DramaT
  38 + */
  39 +export const Drama = v.object({
  40 + id: v.string(),
  41 + title: v.string(),
  42 + cover: v.string(),
  43 + locale: v.string(),
  44 + heat: v.optional(v.number()),
  45 + paywall: v.optional(Paywall),
  46 +})
  47 +
  48 +/**
  49 + * 高阶分页信封工厂:`Page(itemSchema)` 产出该 item 的分页 schema,复用同一信封结构。
  50 + * 统一分页形状供 Feed / 剧本库等所有列表接口共用。
  51 + * @param {import('valibot').GenericSchema} itemSchema 任意 item 的 Valibot schema
  52 + * @returns {import('valibot').GenericSchema} 该 item 的分页信封 schema
  53 + */
  54 +export function Page(itemSchema) {
  55 + return v.object({
  56 + items: v.array(itemSchema),
  57 + page: v.number(),
  58 + page_size: v.number(),
  59 + total: v.number(),
  60 + has_more: v.boolean(),
  61 + })
  62 +}
  63 +
  64 +/**
  65 + * 分页信封类型(泛型 item)。
  66 + * @template T
  67 + * @typedef {{ items: T[], page: number, page_size: number, total: number, has_more: boolean }} PageT
  68 + */
... ...
packages/contracts/src/schemas/error.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/error.js
  1 +import * as v from 'valibot'
  2 +
  3 +/**
  4 + * 统一错误形状:全 API 失败响应同形。
  5 + * `code` 与 `retryable` 的语义与各能力包的结构化错误(如 `TtsError`)对齐,
  6 + * 前端可一套分支处理全部失败(401 / 限流 / 校验失败等)。
  7 + * @typedef {{
  8 + * error: { code: string, message: string, retryable: boolean, details?: object }
  9 + * }} ApiErrorT
  10 + */
  11 +export const ApiError = v.object({
  12 + error: v.object({
  13 + code: v.string(),
  14 + message: v.string(),
  15 + retryable: v.boolean(),
  16 + details: v.optional(v.record(v.string(), v.unknown())),
  17 + }),
  18 +})
... ...
packages/contracts/src/schemas/script-line.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/script-line.js
  1 +import * as v from 'valibot'
  2 +
  3 +/**
  4 + * TTS 提示:续写 LLM / 编排可附带的语音合成参数(全部可选)。
  5 + * @typedef {{ instruction?: string, speech_rate?: number }} TtsHintT
  6 + */
  7 +export const TtsHint = v.object({
  8 + instruction: v.optional(v.string()),
  9 + speech_rate: v.optional(v.number()),
  10 +})
  11 +
  12 +/**
  13 + * 剧本单行:播放引擎与续写 LLM 的共同语言。
  14 + * `mode` 区分阻塞(说完才进下一行)与并行(与相邻行同时进行)语义。
  15 + * @typedef {{
  16 + * speaker: string,
  17 + * text: string,
  18 + * emotion?: string,
  19 + * mode: 'block' | 'parallel',
  20 + * tts?: TtsHintT
  21 + * }} ScriptLineT
  22 + */
  23 +export const ScriptLine = v.object({
  24 + speaker: v.string(),
  25 + text: v.string(),
  26 + emotion: v.optional(v.string()),
  27 + mode: v.picklist(['block', 'parallel']),
  28 + tts: v.optional(TtsHint),
  29 +})
  30 +
  31 +/**
  32 + * 单行配音请求体(POST /api/sk/drama/:id/line/tts 的入参契约)。
  33 + *
  34 + * `speaker` 必须由调用方传入有效音色 id(复刻音色形如 `S_aAB0nUX42`)——后端不再
  35 + * 兜底为某个固定音色(旧实现兜底女声导致「钟爹也变女声」,见 TTS 链路修复)。
  36 + * `instruction` 是语气指令(如「生气地说」),复刻音色靠它控语气;未传时后端按
  37 + * `emotion` 派生默认语气词。`emotion` / `emotion_scale` 走普通音色情绪分支。
  38 + *
  39 + * @typedef {{
  40 + * text: string,
  41 + * speaker?: string,
  42 + * instruction?: string,
  43 + * emotion?: string,
  44 + * emotion_scale?: number,
  45 + * speech_rate?: number
  46 + * }} TtsLineBodyT
  47 + */
  48 +export const TtsLineBody = v.object({
  49 + text: v.pipe(v.string(), v.minLength(1)),
  50 + speaker: v.optional(v.string()),
  51 + instruction: v.optional(v.string()),
  52 + emotion: v.optional(v.string()),
  53 + emotion_scale: v.optional(v.number()),
  54 + speech_rate: v.optional(v.number()),
  55 +})
... ...
packages/contracts/src/schemas/share.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/share.js
  1 +import * as v from 'valibot'
  2 +
  3 +/**
  4 + * 可分享对象的元数据:社交爬虫 / SEO 注入 og/title 与首屏最小内容所需的最小形状。
  5 + * 单一事实来源,供 `@drama/share` 的 `buildMetaTags` / `buildPreview` / `createMetaInjector` 消费。
  6 + * @typedef {{
  7 + * title: string,
  8 + * description: string,
  9 + * image: string,
  10 + * url: string,
  11 + * type?: string
  12 + * }} ShareMetaT
  13 + */
  14 +export const ShareMeta = v.object({
  15 + title: v.string(),
  16 + description: v.string(),
  17 + image: v.string(),
  18 + url: v.string(),
  19 + type: v.optional(v.string()),
  20 +})
... ...
packages/contracts/src/schemas/telemetry.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/telemetry.js
  1 +import * as v from 'valibot'
  2 +
  3 +/**
  4 + * 埋点事件信封 —— 前端 `@drama/telemetry` SDK 上报的原始事件形态,
  5 + * 也是后端 `@drama/telemetry-ingest` 校验落库的单一事实来源。
  6 + *
  7 + * 字段与前端 `EventEnvelope`(`@drama/telemetry` 的 `envelope.js`)逐项对齐:
  8 + * 同一份契约前端产、后端校验,避免前后端事件形态漂移。`props` 用 `record` 以
  9 + * 容纳任意业务属性(一期不约束业务字段,只保证信封骨架完整)。
  10 + *
  11 + * `ts` 是客户端事件发生时刻的 epoch 毫秒数(number),非 DB DATETIME 字段,保持 number。
  12 + *
  13 + * @typedef {{
  14 + * name: string,
  15 + * props: Record<string, unknown>,
  16 + * ts: number,
  17 + * device_id: string,
  18 + * anon_id: string | null,
  19 + * locale: string,
  20 + * session_id: string,
  21 + * sdk_version: string
  22 + * }} TelemetryEventT
  23 + */
  24 +export const TelemetryEvent = v.object({
  25 + name: v.string(),
  26 + props: v.record(v.string(), v.unknown()),
  27 + ts: v.number(),
  28 + device_id: v.string(),
  29 + anon_id: v.nullable(v.string()),
  30 + locale: v.string(),
  31 + session_id: v.string(),
  32 + sdk_version: v.string(),
  33 +})
... ...
packages/contracts/src/schemas/visual-game-admin-analytics.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/visual-game-admin-analytics.js
  1 +import * as v from 'valibot'
  2 +
  3 +export const VisualGameAdminAnalyticsMetrics = v.object({
  4 + active_players: v.number(),
  5 + interactions: v.number(),
  6 + say_turns: v.number(),
  7 + act_turns: v.number(),
  8 + credits_spent: v.number(),
  9 + credits_granted: v.number(),
  10 + runs_started: v.number(),
  11 + runs_completed: v.number(),
  12 + seasons_started: v.number(),
  13 + seasons_completed: v.number(),
  14 + paying_players: v.number(),
  15 +})
  16 +
  17 +export const VisualGameAdminAnalyticsDailyItem = v.object({
  18 + date: v.string(),
  19 + active_players: v.number(),
  20 + interactions: v.number(),
  21 +})
  22 +
  23 +export const VisualGameAdminAnalyticsOverviewResponse = v.object({
  24 + generated_at: v.string(),
  25 + since: v.string(),
  26 + until: v.string(),
  27 + metrics: VisualGameAdminAnalyticsMetrics,
  28 + daily: v.array(VisualGameAdminAnalyticsDailyItem),
  29 +})
  30 +
  31 +export const VisualGameAdminAnalyticsDrilldownMetric = v.picklist([
  32 + 'active_players',
  33 + 'interactions',
  34 + 'say_turns',
  35 + 'act_turns',
  36 + 'credits_spent',
  37 + 'credits_granted',
  38 + 'runs_started',
  39 + 'runs_completed',
  40 + 'seasons_started',
  41 + 'seasons_completed',
  42 + 'paying_players',
  43 +])
  44 +
  45 +export const VisualGameAdminAnalyticsDrilldownItem = v.object({
  46 + user_id: v.string(),
  47 + display_name: v.nullable(v.string()),
  48 + account_type: v.picklist(['registered', 'guest']),
  49 + interactions: v.number(),
  50 + say_turns: v.number(),
  51 + act_turns: v.number(),
  52 + credits_spent: v.number(),
  53 + credits_granted: v.number(),
  54 + runs_started: v.number(),
  55 + runs_completed: v.number(),
  56 + seasons_started: v.number(),
  57 + seasons_completed: v.number(),
  58 + paid_orders: v.number(),
  59 + last_interaction_at: v.nullable(v.string()),
  60 +})
  61 +
  62 +export const VisualGameAdminAnalyticsDrilldownResponse = v.object({
  63 + generated_at: v.string(),
  64 + since: v.string(),
  65 + until: v.string(),
  66 + metric: VisualGameAdminAnalyticsDrilldownMetric,
  67 + total: v.number(),
  68 + items: v.array(VisualGameAdminAnalyticsDrilldownItem),
  69 +})
  70 +
  71 +export const VisualGameAdminPlayerInteractionItem = v.object({
  72 + id: v.string(),
  73 + turn_no: v.number(),
  74 + mode: v.picklist(['say', 'act']),
  75 + input_text: v.string(),
  76 + status: v.literal('completed'),
  77 + created_at: v.string(),
  78 + scenario_id: v.string(),
  79 + scenario_title: v.nullable(v.string()),
  80 + series_title: v.nullable(v.string()),
  81 + season_no: v.nullable(v.number()),
  82 + episode_no: v.nullable(v.number()),
  83 +})
  84 +
  85 +export const VisualGameAdminPlayerInteractionsResponse = v.object({
  86 + generated_at: v.string(),
  87 + since: v.string(),
  88 + until: v.string(),
  89 + user_id: v.string(),
  90 + total: v.number(),
  91 + items: v.array(VisualGameAdminPlayerInteractionItem),
  92 +})
  93 +
  94 +export const VisualGameAdminContentAnalyticsItem = v.object({
  95 + scenario_id: v.string(),
  96 + scenario_title: v.string(),
  97 + series_id: v.nullable(v.string()),
  98 + series_title: v.nullable(v.string()),
  99 + episode_no: v.number(),
  100 + players: v.number(),
  101 + active_players: v.number(),
  102 + runs_started: v.number(),
  103 + runs_completed: v.number(),
  104 + interactions: v.number(),
  105 + say_turns: v.number(),
  106 + act_turns: v.number(),
  107 + completion_rate: v.nullable(v.number()),
  108 +})
  109 +
  110 +export const VisualGameAdminContentAnalyticsResponse = v.object({
  111 + generated_at: v.string(),
  112 + since: v.string(),
  113 + until: v.string(),
  114 + items: v.array(VisualGameAdminContentAnalyticsItem),
  115 +})
  116 +
  117 +const Ga4Count = v.pipe(v.number(), v.integer(), v.minValue(0))
  118 +const Ga4Summary = v.object({
  119 + unique_visitors: Ga4Count,
  120 + active_users: Ga4Count,
  121 + page_views: Ga4Count,
  122 + sessions: Ga4Count,
  123 + video_viewers: Ga4Count,
  124 + video_starts: Ga4Count,
  125 + video_completions: Ga4Count,
  126 + ui_clicks: Ga4Count,
  127 + scene_clicks: Ga4Count,
  128 + typed_inputs: Ga4Count,
  129 + selected_inputs: Ga4Count,
  130 +})
  131 +
  132 +export const VisualGameAdminGa4BehaviorResponse = v.object({
  133 + configured: v.boolean(),
  134 + generated_at: v.string(),
  135 + since: v.string(),
  136 + until: v.string(),
  137 + summary: Ga4Summary,
  138 + traffic_daily: v.array(v.object({ date: v.string(), unique_visitors: Ga4Count, page_views: Ga4Count })),
  139 + sources: v.array(v.object({ channel: v.string(), source_medium: v.string(), campaign: v.string(), note_key: v.picklist(['x_referral', 'facebook_referral', 'instagram_referral', 'youtube_referral', 'tiktok_referral', 'direct', 'unassigned', 'organic_social', 'organic_search', 'referral', 'paid_social', 'paid_search', 'other']), sessions: Ga4Count, users: Ga4Count })),
  140 + pages: v.array(v.object({ path: v.string(), title: v.string(), views: Ga4Count, users: Ga4Count })),
  141 + flows: v.array(v.object({ from: v.string(), to: v.string(), views: Ga4Count, users: Ga4Count })),
  142 + devices: v.array(v.object({ category: v.string(), operating_system: v.string(), browser: v.string(), users: Ga4Count, sessions: Ga4Count })),
  143 + videos: v.array(v.object({ video_id: v.string(), video_title: v.nullable(v.string()), video_url: v.nullable(v.string()), placement: v.string(), starts: Ga4Count, progress_events: Ga4Count, completions: Ga4Count, users: Ga4Count })),
  144 + clicks: v.array(v.object({ element_id: v.string(), surface: v.string(), clicks: Ga4Count, users: Ga4Count })),
  145 + scene_misses: v.array(v.object({ scene_id: v.string(), grid_x: v.string(), grid_y: v.string(), pointer_type: v.string(), clicks: Ga4Count, users: Ga4Count })),
  146 + inputs: v.array(v.object({ input_method: v.string(), mode: v.string(), submissions: Ga4Count, users: Ga4Count })),
  147 +})
... ...
packages/contracts/src/schemas/visual-game-admin-commerce.js 0 → 100644
  1 +++ a/packages/contracts/src/schemas/visual-game-admin-commerce.js
  1 +import * as v from 'valibot'
  2 +
  3 +export const VisualGameAdminBillingActionPolicy = v.object({
  4 + action: v.string(),
  5 + points: v.number(),
  6 + period_kind: v.picklist(['daily', 'none']),
  7 + timezone: v.nullable(v.string()),
  8 + enabled: v.boolean(),
  9 + updated_by: v.nullable(v.string()),
  10 +})
  11 +
  12 +export const VisualGameAdminBillingProduct = v.object({
  13 + id: v.string(),
  14 + kind: v.literal('points_pack'),
  15 + title: v.string(),
  16 + points: v.number(),
  17 + paid_points: v.number(),
  18 + bonus_points: v.number(),
  19 + price_cents: v.number(),
  20 + price_usd: v.number(),
  21 + currency: v.string(),
  22 + stripe_price_id: v.nullable(v.string()),
  23 + active: v.boolean(),
  24 +})
  25 +
  26 +export const VisualGameAdminBillingOrder = v.object({
  27 + id: v.string(),
  28 + user_id: v.string(),
  29 + product_id: v.string(),
  30 + provider: v.string(),
  31 + status: v.picklist(['pending', 'paid', 'canceled', 'failed', 'refunded', 'disputed']),
  32 + amount_cents: v.number(),
  33 + currency: v.string(),
  34 + paid_points: v.number(),
  35 + bonus_points: v.number(),
  36 + total_points: v.number(),
  37 + points_reversed: v.number(),
  38 + reversal_shortfall_points: v.number(),
  39 + created_at: v.string(),
  40 +})
  41 +
  42 +export const VisualGameAdminBillingReference = v.object({
  43 + label: v.string(),
  44 + href: v.string(),
  45 +})
  46 +
  47 +export const VisualGameAdminBillingLedgerEntry = v.object({
  48 + id: v.string(),
  49 + user_id: v.string(),
  50 + type: v.picklist(['grant', 'deduct']),
  51 + amount: v.number(),
  52 + balance_after: v.number(),
  53 + source: v.string(),
  54 + credit_kind: v.nullable(v.string()),
  55 + reason: v.string(),
  56 + ref_type: v.nullable(v.string()),
  57 + ref_id: v.nullable(v.string()),
  58 + note: v.nullable(v.string()),
  59 + failure_code: v.nullable(v.string()),
  60 + reference: v.nullable(VisualGameAdminBillingReference),
  61 + created_at: v.string(),
  62 +})
  63 +
  64 +export const VisualGameAdminBillingPolicyList = v.object({
  65 + items: v.array(VisualGameAdminBillingActionPolicy),
  66 +})
  67 +
  68 +export const VisualGameAdminBillingProductList = v.object({
  69 + items: v.array(VisualGameAdminBillingProduct),
  70 +})
  71 +
  72 +export const VisualGameAdminBillingOrderPage = v.object({
  73 + items: v.array(VisualGameAdminBillingOrder),
  74 + next_cursor: v.nullable(v.string()),
  75 +})
  76 +
  77 +export const VisualGameAdminBillingLedgerPage = v.object({
  78 + items: v.array(VisualGameAdminBillingLedgerEntry),
  79 + next_cursor: v.nullable(v.string()),
  80 +})
  81 +
  82 +export const VisualGameAdminBillingDashboardDay = v.object({
  83 + date: v.string(),
  84 + start_at: v.string(),
  85 + end_at: v.string(),
  86 + revenue_cents: v.number(),
  87 + paid_orders: v.number(),
  88 + refund_orders: v.number(),
  89 + points_granted: v.number(),
  90 + points_spent: v.number(),
  91 +})
  92 +
  93 +export const VisualGameAdminBillingDashboardProduct = v.object({
  94 + product_id: v.string(),
  95 + title: v.nullable(v.string()),
  96 + orders: v.number(),
  97 + revenue_cents: v.number(),
  98 + points: v.number(),
  99 +})
  100 +
  101 +/** 消耗按 reason + 业务类型分块:model_usage 这种按调用计费的流水靠 ref_type 才分得出 LLM / 语音 / 图片。 */
  102 +export const VisualGameAdminBillingDashboardSpend = v.object({
  103 + reason: v.string(),
  104 + ref_type: v.nullable(v.string()),
  105 + points: v.number(),
  106 +})
  107 +
  108 +/** 商业化看板:一个时间窗内的收入、积分、转化、风控汇总 + 按自然日的序列。比率分母为 0 时给 null。 */
  109 +export const VisualGameAdminBillingDashboard = v.object({
  110 + since: v.string(),
  111 + until: v.string(),
  112 + start_at: v.string(),
  113 + end_at: v.string(),
  114 + timezone: v.string(),
  115 + generated_at: v.string(),
  116 + revenue: v.object({
  117 + gross_cents: v.number(),
  118 + refund_cents: v.number(),
  119 + net_cents: v.number(),
  120 + paid_orders: v.number(),
  121 + paying_users: v.number(),
  122 + first_time_buyers: v.number(),
  123 + }),
  124 + points: v.object({
  125 + purchased_paid: v.number(),
  126 + purchased_bonus: v.number(),
  127 + daily_claim: v.number(),
  128 + signup_reward: v.number(),
  129 + admin_grant: v.number(),
  130 + turn_spent: v.number(),
  131 + video_spent: v.number(),
  132 + other_spent: v.number(),
  133 + turn_refunded: v.number(),
  134 + video_refunded: v.number(),
  135 + }),
  136 + conversion: v.object({
  137 + new_users: v.number(),
  138 + new_users_paid: v.number(),
  139 + signup_to_paid_rate: v.nullable(v.number()),
  140 + claim_users: v.number(),
  141 + active_users: v.number(),
  142 + claim_rate: v.nullable(v.number()),
  143 + }),
  144 + risk: v.object({
  145 + refunded_orders: v.number(),
  146 + disputed_orders: v.number(),
  147 + refund_rate: v.nullable(v.number()),
  148 + dispute_rate: v.nullable(v.number()),
  149 + turn_refunds: v.number(),
  150 + video_refunds: v.number(),
  151 + shortfall_orders: v.number(),
  152 + shortfall_points: v.number(),
  153 + }),
  154 + daily: v.array(VisualGameAdminBillingDashboardDay),
  155 + products: v.array(VisualGameAdminBillingDashboardProduct),
  156 + spend_mix: v.array(VisualGameAdminBillingDashboardSpend),
  157 +})
... ...