From a3cd11bdc85ec7078f80f89421dcff03173a00c2 Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 10 Oct 2026 03:22:08 +0000 Subject: [PATCH] chore: 初始化 vokoworld 仓库骨架(初始化清单第 1–2 步) --- .gitignore | 18 ++++++++++++++++++ .gitlab-ci.yml | 21 +++++++++++++++++++++ .nvmrc | 1 + AGENTS.md | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 15 +++++++++++++++ docker-compose.yml | 25 +++++++++++++++++++++++++ docker/mysql/init/01-databases.sql | 3 +++ docs/voko/README.md | 12 ++++++++++++ docs/voko/upstream.md | 36 ++++++++++++++++++++++++++++++++++++ eslint.config.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 36 ++++++++++++++++++++++++++++++++++++ packages/auth/package.json | 28 ++++++++++++++++++++++++++++ packages/auth/src/auth.js | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/bruteforce.js | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/code.js | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/db/README.md | 14 ++++++++++++++ packages/auth/src/db/tables/auth_attempts.cjs | 11 +++++++++++ packages/auth/src/db/tables/auth_codes.cjs | 12 ++++++++++++ packages/auth/src/db/tables/auth_sessions.cjs | 15 +++++++++++++++ packages/auth/src/db/tables/index.cjs | 14 ++++++++++++++ packages/auth/src/db/tables/user_oauth.cjs | 11 +++++++++++ packages/auth/src/errors.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/fake-repo.js | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/index.js | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/login.js | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/oauth.js | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/refresh.js | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/register.js | 40 ++++++++++++++++++++++++++++++++++++++++ packages/auth/src/repo.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/src/request-password-reset.js | 26 ++++++++++++++++++++++++++ packages/auth/src/reset-password.js | 28 ++++++++++++++++++++++++++++ packages/auth/src/session.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/test/bruteforce.test.js | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/test/fakes.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/auth/test/oauth.test.js | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/package.json | 15 +++++++++++++++ packages/billing/src/action-policies.js | 34 ++++++++++++++++++++++++++++++++++ packages/billing/src/billing.js | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/calendar-day.js | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/commerce-operations.js | 20 ++++++++++++++++++++ packages/billing/src/daily-claim.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/db/README.md | 23 +++++++++++++++++++++++ packages/billing/src/db/index.js | 5 +++++ packages/billing/src/db/tables/billing_action_policy.cjs | 18 ++++++++++++++++++ packages/billing/src/db/tables/billing_free_quota.cjs | 12 ++++++++++++ packages/billing/src/db/tables/billing_ledger.cjs | 31 +++++++++++++++++++++++++++++++ packages/billing/src/db/tables/billing_paywall_rule.cjs | 12 ++++++++++++ packages/billing/src/db/tables/billing_product.cjs | 23 +++++++++++++++++++++++ packages/billing/src/db/tables/billing_unlock.cjs | 15 +++++++++++++++ packages/billing/src/db/tables/index.cjs | 21 +++++++++++++++++++++ packages/billing/src/db/tables/points_balance_view.cjs | 18 ++++++++++++++++++ packages/billing/src/errors.js | 38 ++++++++++++++++++++++++++++++++++++++ packages/billing/src/index.js | 30 ++++++++++++++++++++++++++++++ packages/billing/src/ledger.js | 203 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/model-billing.js | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/model-settlement-port.js | 25 +++++++++++++++++++++++++ packages/billing/src/paywall.js | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/port.js | 42 ++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/products.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/quota.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/repo/action-policy-repo.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/repo/index.js | 24 ++++++++++++++++++++++++ packages/billing/src/repo/ledger-repo.js | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/repo/paywall-repo.js | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/repo/product-repo.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/billing/src/signup-reward.js | 26 ++++++++++++++++++++++++++ packages/billing/src/views.js | 28 ++++++++++++++++++++++++++++ packages/billing/test/quota.test.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/config-source/package.json | 9 +++++++++ packages/config-source/src/index.js | 19 +++++++++++++++++++ packages/config-source/src/table-version.js | 22 ++++++++++++++++++++++ packages/config-source/src/versioned-source.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/config/eslint/index.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++ packages/config/eslint/rules.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/config/eslint/vue.js | 33 +++++++++++++++++++++++++++++++++ packages/config/index.js | 3 +++ packages/config/package.json | 28 ++++++++++++++++++++++++++++ packages/config/vite/base.js | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/config/vite/index.js | 26 ++++++++++++++++++++++++++ packages/config/vitest/base.js | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/config/vitest/index.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/package.json | 18 ++++++++++++++++++ packages/contracts/schema-snapshot.json | 9937 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/drama.js | 20 ++++++++++++++++++++ packages/contracts/src/dump.js | 263 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/index.js | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/parse.js | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/platform.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/account.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/auth.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/bundle.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/commerce.js | 17 +++++++++++++++++ packages/contracts/src/schemas/credits.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/drama.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/error.js | 18 ++++++++++++++++++ packages/contracts/src/schemas/script-line.js | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/share.js | 20 ++++++++++++++++++++ packages/contracts/src/schemas/telemetry.js | 33 +++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/visual-game-admin-analytics.js | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/visual-game-admin-commerce.js | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/visual-game-admin-users.js | 207 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/visual-game-agent.js | 249 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/visual-game-scenario-release.js | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/visual-game.js | 882 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/schemas/waitlist.js | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/src/visual-game.js | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/contracts/test/snapshot.test.js | 30 ++++++++++++++++++++++++++++++ packages/contracts/test/visual-game-agent.test.js | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/package.json | 15 +++++++++++++++ packages/core/src/anim-state.js | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/src/camera.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/src/evaluator.js | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/src/index.js | 22 ++++++++++++++++++++++ packages/core/src/line-handlers.js | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/src/matrix.js | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/src/sampling.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/src/scheduler.js | 301 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/src/types.js | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/test/anim-state.test.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/test/camera.test.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/test/fixtures.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/test/sampling.test.js | 41 +++++++++++++++++++++++++++++++++++++++++ packages/core/test/scheduler.test.js | 234 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/db/MODEL.md | 35 +++++++++++++++++++++++++++++++++++ packages/db/package.json | 23 +++++++++++++++++++++++ packages/db/src/cluster.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/db/src/create-db.js | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/db/src/current-schema.js | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/db/src/db/README.md | 6 ++++++ packages/db/src/db/tables/index.cjs | 11 +++++++++++ packages/db/src/db/tables/users.cjs | 20 ++++++++++++++++++++ packages/db/src/dev-url.js | 27 +++++++++++++++++++++++++++ packages/db/src/errors.js | 10 ++++++++++ packages/db/src/helpers.js | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/db/src/index.js | 18 ++++++++++++++++++ packages/db/src/knex-types.d.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/package.json | 20 ++++++++++++++++++++ packages/email/src/db/README.md | 10 ++++++++++ packages/email/src/db/tables/email_delivery_logs.cjs | 19 +++++++++++++++++++ packages/email/src/db/tables/index.cjs | 11 +++++++++++ packages/email/src/email.js | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/src/index.js | 30 ++++++++++++++++++++++++++++++ packages/email/src/map-error.js | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/src/providers/fake.js | 22 ++++++++++++++++++++++ packages/email/src/providers/nodemailer.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/src/providers/ses.js | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/src/templates/index.js | 42 ++++++++++++++++++++++++++++++++++++++++++ packages/email/src/templates/order-notice.js | 123 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/src/templates/verify-code.js | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/src/templates/video-ready.js | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/src/templates/waitlist-invite.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/src/validate.js | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/test/map-error.test.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/email/test/order-notice.test.js | 28 ++++++++++++++++++++++++++++ packages/email/test/transactional-locales.test.js | 30 ++++++++++++++++++++++++++++++ packages/email/test/video-ready.test.js | 29 +++++++++++++++++++++++++++++ packages/i18n/package.json | 17 +++++++++++++++++ packages/i18n/scripts/check-coverage.js | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/index.js | 7 +++++++ packages/i18n/src/locales.js | 41 +++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/account.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/auth.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/common.js | 37 +++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/drama.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/feed.js | 32 ++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/index.js | 15 +++++++++++++++ packages/i18n/src/messages/en/landing.js | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/legal.js | 33 +++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/library.js | 36 ++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/onboarding.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/pay.js | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/player.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/en/visual-game.js | 595 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/account.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/auth.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/common.js | 34 ++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/drama.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/feed.js | 30 ++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/index.js | 17 +++++++++++++++++ packages/i18n/src/messages/es/landing.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/legal.js | 16 ++++++++++++++++ packages/i18n/src/messages/es/library.js | 36 ++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/onboarding.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/pay.js | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/player.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/es/visual-game-overrides.generated.js | 26 ++++++++++++++++++++++++++ packages/i18n/src/messages/es/visual-game.js | 595 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/index.js | 13 +++++++++++++ packages/i18n/src/messages/ja/account.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/auth.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/common.js | 34 ++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/drama.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/feed.js | 30 ++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/index.js | 15 +++++++++++++++ packages/i18n/src/messages/ja/landing.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/legal.js | 16 ++++++++++++++++ packages/i18n/src/messages/ja/library.js | 36 ++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/onboarding.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/pay.js | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/player.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ja/visual-game.js | 595 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ko/account.js | 5 +++++ packages/i18n/src/messages/ko/auth.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ko/index.js | 426 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/ko/legal.js | 16 ++++++++++++++++ packages/i18n/src/messages/ko/pay.js | 5 +++++ packages/i18n/src/messages/ko/visual-game.generated.js | 788 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/merge-messages.js | 9 +++++++++ packages/i18n/src/messages/pt/account.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/auth.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/common.js | 34 ++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/drama.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/feed.js | 30 ++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/index.js | 17 +++++++++++++++++ packages/i18n/src/messages/pt/landing.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/legal.js | 16 ++++++++++++++++ packages/i18n/src/messages/pt/library.js | 36 ++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/onboarding.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/pay.js | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/player.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/visual-game-overrides.generated.js | 43 +++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/pt/visual-game.js | 595 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/account.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/auth.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/common.js | 34 ++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/drama.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/feed.js | 30 ++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/index.js | 15 +++++++++++++++ packages/i18n/src/messages/zh/landing.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/legal.js | 30 ++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/library.js | 36 ++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/onboarding.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/pay.js | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/player.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/messages/zh/visual-game.js | 595 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/src/runtime.js | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/i18n/test/coverage.test.js | 34 ++++++++++++++++++++++++++++++++++ packages/llm-router/package.json | 21 +++++++++++++++++++++ packages/llm-router/src/config.js | 29 +++++++++++++++++++++++++++++ packages/llm-router/src/db/tables/index.cjs | 12 ++++++++++++ packages/llm-router/src/db/tables/mode_model_config.cjs | 27 +++++++++++++++++++++++++++ packages/llm-router/src/fallback.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm-router/src/index.js | 20 ++++++++++++++++++++ packages/llm-router/src/llm-factory.js | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm-router/src/mode-router.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm-router/src/validate.js | 42 ++++++++++++++++++++++++++++++++++++++++++ packages/llm/README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/package.json | 17 +++++++++++++++++ packages/llm/src/base.js | 658 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/errors.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/index.js | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/llm.js | 318 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/map-error.js | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/media.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/ports/no-op.js | 29 +++++++++++++++++++++++++++++ packages/llm/src/providers/llm.anthropic.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/providers/llm.azure_foundry.js | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/providers/llm.codex.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/providers/llm.gemini.js | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/providers/llm.moonshot.js | 30 ++++++++++++++++++++++++++++++ packages/llm/src/providers/llm.openai.js | 253 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/providers/llm.openai_responses.js | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/providers/llm.openrouter.js | 32 ++++++++++++++++++++++++++++++++ packages/llm/src/providers/llm.volcengine.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/providers/llm.wanjie.js | 30 ++++++++++++++++++++++++++++++ packages/llm/src/registry.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/response-format.js | 43 +++++++++++++++++++++++++++++++++++++++++++ packages/llm/src/sse.js | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/test/azure-foundry.test.js | 25 +++++++++++++++++++++++++ packages/llm/test/errors.test.js | 16 ++++++++++++++++ packages/llm/test/map-error.test.js | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/llm/tools/scripts/llm-provider-smoke.mjs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/moderation/package.json | 9 +++++++++ packages/moderation/src/decision.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/moderation/src/errors.js | 41 +++++++++++++++++++++++++++++++++++++++++ packages/moderation/src/fake.js | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/moderation/src/guard.js | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/moderation/src/index.js | 28 ++++++++++++++++++++++++++++ packages/moderation/src/moderation.js | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/moderation/src/port.js | 37 +++++++++++++++++++++++++++++++++++++ packages/moderation/src/rules-check.js | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/observability/CONVENTION.md | 34 ++++++++++++++++++++++++++++++++++ packages/observability/package.json | 12 ++++++++++++ packages/observability/src/context.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ packages/observability/src/index.js | 18 ++++++++++++++++++ packages/observability/src/logger.js | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/observability/src/serialize.js | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/observability/src/span-sink-otlp.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/observability/src/span.js | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/observability/src/trace-port.js | 26 ++++++++++++++++++++++++++ packages/observability/src/with-trace.js | 35 +++++++++++++++++++++++++++++++++++ packages/observability/test/logger.test.js | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/observability/test/serialize.test.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/observability/test/with-trace.test.js | 27 +++++++++++++++++++++++++++ packages/ratelimit/package.json | 12 ++++++++++++ packages/ratelimit/src/errors.js | 14 ++++++++++++++ packages/ratelimit/src/index.js | 23 +++++++++++++++++++++++ packages/ratelimit/src/key.js | 25 +++++++++++++++++++++++++ packages/ratelimit/src/limiter.js | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ratelimit/src/memory-store.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ratelimit/src/rules.js | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ratelimit/src/token-bucket.js | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ratelimit/test/key.test.js | 23 +++++++++++++++++++++++ packages/ratelimit/test/memory-store.test.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ratelimit/test/token-bucket.test.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/README.md | 32 ++++++++++++++++++++++++++++++++ packages/render-pixi/package.json | 17 +++++++++++++++++ packages/render-pixi/src/actor.js | 475 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/src/assets.js | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/src/effects.js | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/src/geom.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/src/index.js | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/src/occluder.js | 43 +++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/src/renderer.js | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/src/stage.js | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/test/depth.test.js | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/test/mirror.test.js | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/render-pixi/test/stub-pixi.js | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/rig/package.json | 9 +++++++++ packages/rig/src/clips/arm.js | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/rig/src/clips/base.js | 12 ++++++++++++ packages/rig/src/clips/head.js | 33 +++++++++++++++++++++++++++++++++ packages/rig/src/clips/index.js | 39 +++++++++++++++++++++++++++++++++++++++ packages/rig/src/clips/lower-body.js | 42 ++++++++++++++++++++++++++++++++++++++++++ packages/rig/src/index.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/rig/src/poses.js | 26 ++++++++++++++++++++++++++ packages/rig/src/rigs/humanoid-chibi.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/rig/src/rigs/humanoid-v1.js | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/rig/src/rigs/index.js | 13 +++++++++++++ packages/rig/src/types.js | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server-kit/package.json | 21 +++++++++++++++++++++ packages/server-kit/src/errors.js | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server-kit/src/index.js | 13 +++++++++++++ packages/server-kit/src/ratelimit.js | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server-kit/src/raw-body.js | 26 ++++++++++++++++++++++++++ packages/server-kit/src/trace.js | 25 +++++++++++++++++++++++++ packages/server-kit/test/server-kit.test.js | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/service-call/package.json | 12 ++++++++++++ packages/service-call/src/combinators.js | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/service-call/src/http.js | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/service-call/src/index.js | 15 +++++++++++++++ packages/service-call/src/retry.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/service-call/test/retry.test.js | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/dev/UiGallery.vue | 646 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/dev/dev.css | 27 +++++++++++++++++++++++++++ packages/ui/dev/index.html | 12 ++++++++++++ packages/ui/dev/main.js | 15 +++++++++++++++ packages/ui/eslint.config.js | 17 +++++++++++++++++ packages/ui/package.json | 25 +++++++++++++++++++++++++ packages/ui/src/components/Badge.vue | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Button.vue | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Card.vue | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/ConfirmAction.vue | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Drawer.vue | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/EmptyState.vue | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/FormField.vue | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Input.vue | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Modal.vue | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/PageHeader.vue | 45 +++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Pagination.vue | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Section.vue | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/SegmentedControl.vue | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Select.vue | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Slider.vue | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Spinner.vue | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Stat.vue | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Table.vue | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Tabs.vue | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Textarea.vue | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/ToastHost.vue | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/components/Toolbar.vue | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/composables/use-focus-trap.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/composables/use-toast.js | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/index.js | 35 +++++++++++++++++++++++++++++++++++ packages/ui/src/theme/theme.css | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/theme/tokens.js | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/ui/src/vue-shim.d.ts | 12 ++++++++++++ packages/ui/test/card.test.js | 32 ++++++++++++++++++++++++++++++++ packages/ui/vite.config.js | 13 +++++++++++++ packages/ui/vitest.config.js | 3 +++ pnpm-lock.yaml | 4503 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 9 +++++++++ tools/eslint-rules/config.js | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tools/eslint-rules/index.js | 33 +++++++++++++++++++++++++++++++++ tools/eslint-rules/no-app-import.js | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tools/eslint-rules/no-conn-outside-root.js | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tools/eslint-rules/no-deep-import.js | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tools/eslint-rules/no-drama-rule-disable.js | 43 +++++++++++++++++++++++++++++++++++++++++++ tools/eslint-rules/no-subdomain-deep-import.js | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tools/eslint-rules/test/no-app-import.test.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tools/eslint-rules/test/no-conn-outside-root.test.js | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tools/eslint-rules/test/no-deep-import.test.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++ tools/eslint-rules/test/no-drama-rule-disable.test.js | 30 ++++++++++++++++++++++++++++++ tools/eslint-rules/test/no-subdomain-deep-import.test.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tools/scripts/mysql-dev.js | 220 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ turbo.json | 19 +++++++++++++++++++ vitest.workspace.js | 21 +++++++++++++++++++++ 397 files changed, 47055 insertions(+), 0 deletions(-) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .nvmrc create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docker/mysql/init/01-databases.sql create mode 100644 docs/voko/README.md create mode 100644 docs/voko/upstream.md create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 packages/auth/package.json create mode 100644 packages/auth/src/auth.js create mode 100644 packages/auth/src/bruteforce.js create mode 100644 packages/auth/src/code.js create mode 100644 packages/auth/src/db/README.md create mode 100644 packages/auth/src/db/tables/auth_attempts.cjs create mode 100644 packages/auth/src/db/tables/auth_codes.cjs create mode 100644 packages/auth/src/db/tables/auth_sessions.cjs create mode 100644 packages/auth/src/db/tables/index.cjs create mode 100644 packages/auth/src/db/tables/user_oauth.cjs create mode 100644 packages/auth/src/errors.js create mode 100644 packages/auth/src/fake-repo.js create mode 100644 packages/auth/src/index.js create mode 100644 packages/auth/src/login.js create mode 100644 packages/auth/src/oauth.js create mode 100644 packages/auth/src/refresh.js create mode 100644 packages/auth/src/register.js create mode 100644 packages/auth/src/repo.js create mode 100644 packages/auth/src/request-password-reset.js create mode 100644 packages/auth/src/reset-password.js create mode 100644 packages/auth/src/session.js create mode 100644 packages/auth/test/bruteforce.test.js create mode 100644 packages/auth/test/fakes.js create mode 100644 packages/auth/test/oauth.test.js create mode 100644 packages/billing/package.json create mode 100644 packages/billing/src/action-policies.js create mode 100644 packages/billing/src/billing.js create mode 100644 packages/billing/src/calendar-day.js create mode 100644 packages/billing/src/commerce-operations.js create mode 100644 packages/billing/src/daily-claim.js create mode 100644 packages/billing/src/db/README.md create mode 100644 packages/billing/src/db/index.js create mode 100644 packages/billing/src/db/tables/billing_action_policy.cjs create mode 100644 packages/billing/src/db/tables/billing_free_quota.cjs create mode 100644 packages/billing/src/db/tables/billing_ledger.cjs create mode 100644 packages/billing/src/db/tables/billing_paywall_rule.cjs create mode 100644 packages/billing/src/db/tables/billing_product.cjs create mode 100644 packages/billing/src/db/tables/billing_unlock.cjs create mode 100644 packages/billing/src/db/tables/index.cjs create mode 100644 packages/billing/src/db/tables/points_balance_view.cjs create mode 100644 packages/billing/src/errors.js create mode 100644 packages/billing/src/index.js create mode 100644 packages/billing/src/ledger.js create mode 100644 packages/billing/src/model-billing.js create mode 100644 packages/billing/src/model-settlement-port.js create mode 100644 packages/billing/src/paywall.js create mode 100644 packages/billing/src/port.js create mode 100644 packages/billing/src/products.js create mode 100644 packages/billing/src/quota.js create mode 100644 packages/billing/src/repo/action-policy-repo.js create mode 100644 packages/billing/src/repo/index.js create mode 100644 packages/billing/src/repo/ledger-repo.js create mode 100644 packages/billing/src/repo/paywall-repo.js create mode 100644 packages/billing/src/repo/product-repo.js create mode 100644 packages/billing/src/signup-reward.js create mode 100644 packages/billing/src/views.js create mode 100644 packages/billing/test/quota.test.js create mode 100644 packages/config-source/package.json create mode 100644 packages/config-source/src/index.js create mode 100644 packages/config-source/src/table-version.js create mode 100644 packages/config-source/src/versioned-source.js create mode 100644 packages/config/eslint/index.js create mode 100644 packages/config/eslint/rules.js create mode 100644 packages/config/eslint/vue.js create mode 100644 packages/config/index.js create mode 100644 packages/config/package.json create mode 100644 packages/config/vite/base.js create mode 100644 packages/config/vite/index.js create mode 100644 packages/config/vitest/base.js create mode 100644 packages/config/vitest/index.js create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/schema-snapshot.json create mode 100644 packages/contracts/src/drama.js create mode 100644 packages/contracts/src/dump.js create mode 100644 packages/contracts/src/index.js create mode 100644 packages/contracts/src/parse.js create mode 100644 packages/contracts/src/platform.js create mode 100644 packages/contracts/src/schemas/account.js create mode 100644 packages/contracts/src/schemas/auth.js create mode 100644 packages/contracts/src/schemas/bundle.js create mode 100644 packages/contracts/src/schemas/commerce.js create mode 100644 packages/contracts/src/schemas/credits.js create mode 100644 packages/contracts/src/schemas/drama.js create mode 100644 packages/contracts/src/schemas/error.js create mode 100644 packages/contracts/src/schemas/script-line.js create mode 100644 packages/contracts/src/schemas/share.js create mode 100644 packages/contracts/src/schemas/telemetry.js create mode 100644 packages/contracts/src/schemas/visual-game-admin-analytics.js create mode 100644 packages/contracts/src/schemas/visual-game-admin-commerce.js create mode 100644 packages/contracts/src/schemas/visual-game-admin-users.js create mode 100644 packages/contracts/src/schemas/visual-game-agent.js create mode 100644 packages/contracts/src/schemas/visual-game-scenario-release.js create mode 100644 packages/contracts/src/schemas/visual-game.js create mode 100644 packages/contracts/src/schemas/waitlist.js create mode 100644 packages/contracts/src/visual-game.js create mode 100644 packages/contracts/test/snapshot.test.js create mode 100644 packages/contracts/test/visual-game-agent.test.js create mode 100644 packages/core/package.json create mode 100644 packages/core/src/anim-state.js create mode 100644 packages/core/src/camera.js create mode 100644 packages/core/src/evaluator.js create mode 100644 packages/core/src/index.js create mode 100644 packages/core/src/line-handlers.js create mode 100644 packages/core/src/matrix.js create mode 100644 packages/core/src/sampling.js create mode 100644 packages/core/src/scheduler.js create mode 100644 packages/core/src/types.js create mode 100644 packages/core/test/anim-state.test.js create mode 100644 packages/core/test/camera.test.js create mode 100644 packages/core/test/fixtures.js create mode 100644 packages/core/test/sampling.test.js create mode 100644 packages/core/test/scheduler.test.js create mode 100644 packages/db/MODEL.md create mode 100644 packages/db/package.json create mode 100644 packages/db/src/cluster.js create mode 100644 packages/db/src/create-db.js create mode 100644 packages/db/src/current-schema.js create mode 100644 packages/db/src/db/README.md create mode 100644 packages/db/src/db/tables/index.cjs create mode 100644 packages/db/src/db/tables/users.cjs create mode 100644 packages/db/src/dev-url.js create mode 100644 packages/db/src/errors.js create mode 100644 packages/db/src/helpers.js create mode 100644 packages/db/src/index.js create mode 100644 packages/db/src/knex-types.d.ts create mode 100644 packages/email/package.json create mode 100644 packages/email/src/db/README.md create mode 100644 packages/email/src/db/tables/email_delivery_logs.cjs create mode 100644 packages/email/src/db/tables/index.cjs create mode 100644 packages/email/src/email.js create mode 100644 packages/email/src/index.js create mode 100644 packages/email/src/map-error.js create mode 100644 packages/email/src/providers/fake.js create mode 100644 packages/email/src/providers/nodemailer.js create mode 100644 packages/email/src/providers/ses.js create mode 100644 packages/email/src/templates/index.js create mode 100644 packages/email/src/templates/order-notice.js create mode 100644 packages/email/src/templates/verify-code.js create mode 100644 packages/email/src/templates/video-ready.js create mode 100644 packages/email/src/templates/waitlist-invite.js create mode 100644 packages/email/src/validate.js create mode 100644 packages/email/test/map-error.test.js create mode 100644 packages/email/test/order-notice.test.js create mode 100644 packages/email/test/transactional-locales.test.js create mode 100644 packages/email/test/video-ready.test.js create mode 100644 packages/i18n/package.json create mode 100644 packages/i18n/scripts/check-coverage.js create mode 100644 packages/i18n/src/index.js create mode 100644 packages/i18n/src/locales.js create mode 100644 packages/i18n/src/messages/en/account.js create mode 100644 packages/i18n/src/messages/en/auth.js create mode 100644 packages/i18n/src/messages/en/common.js create mode 100644 packages/i18n/src/messages/en/drama.js create mode 100644 packages/i18n/src/messages/en/feed.js create mode 100644 packages/i18n/src/messages/en/index.js create mode 100644 packages/i18n/src/messages/en/landing.js create mode 100644 packages/i18n/src/messages/en/legal.js create mode 100644 packages/i18n/src/messages/en/library.js create mode 100644 packages/i18n/src/messages/en/onboarding.js create mode 100644 packages/i18n/src/messages/en/pay.js create mode 100644 packages/i18n/src/messages/en/player.js create mode 100644 packages/i18n/src/messages/en/visual-game.js create mode 100644 packages/i18n/src/messages/es/account.js create mode 100644 packages/i18n/src/messages/es/auth.js create mode 100644 packages/i18n/src/messages/es/common.js create mode 100644 packages/i18n/src/messages/es/drama.js create mode 100644 packages/i18n/src/messages/es/feed.js create mode 100644 packages/i18n/src/messages/es/index.js create mode 100644 packages/i18n/src/messages/es/landing.js create mode 100644 packages/i18n/src/messages/es/legal.js create mode 100644 packages/i18n/src/messages/es/library.js create mode 100644 packages/i18n/src/messages/es/onboarding.js create mode 100644 packages/i18n/src/messages/es/pay.js create mode 100644 packages/i18n/src/messages/es/player.js create mode 100644 packages/i18n/src/messages/es/visual-game-overrides.generated.js create mode 100644 packages/i18n/src/messages/es/visual-game.js create mode 100644 packages/i18n/src/messages/index.js create mode 100644 packages/i18n/src/messages/ja/account.js create mode 100644 packages/i18n/src/messages/ja/auth.js create mode 100644 packages/i18n/src/messages/ja/common.js create mode 100644 packages/i18n/src/messages/ja/drama.js create mode 100644 packages/i18n/src/messages/ja/feed.js create mode 100644 packages/i18n/src/messages/ja/index.js create mode 100644 packages/i18n/src/messages/ja/landing.js create mode 100644 packages/i18n/src/messages/ja/legal.js create mode 100644 packages/i18n/src/messages/ja/library.js create mode 100644 packages/i18n/src/messages/ja/onboarding.js create mode 100644 packages/i18n/src/messages/ja/pay.js create mode 100644 packages/i18n/src/messages/ja/player.js create mode 100644 packages/i18n/src/messages/ja/visual-game.js create mode 100644 packages/i18n/src/messages/ko/account.js create mode 100644 packages/i18n/src/messages/ko/auth.js create mode 100644 packages/i18n/src/messages/ko/index.js create mode 100644 packages/i18n/src/messages/ko/legal.js create mode 100644 packages/i18n/src/messages/ko/pay.js create mode 100644 packages/i18n/src/messages/ko/visual-game.generated.js create mode 100644 packages/i18n/src/messages/merge-messages.js create mode 100644 packages/i18n/src/messages/pt/account.js create mode 100644 packages/i18n/src/messages/pt/auth.js create mode 100644 packages/i18n/src/messages/pt/common.js create mode 100644 packages/i18n/src/messages/pt/drama.js create mode 100644 packages/i18n/src/messages/pt/feed.js create mode 100644 packages/i18n/src/messages/pt/index.js create mode 100644 packages/i18n/src/messages/pt/landing.js create mode 100644 packages/i18n/src/messages/pt/legal.js create mode 100644 packages/i18n/src/messages/pt/library.js create mode 100644 packages/i18n/src/messages/pt/onboarding.js create mode 100644 packages/i18n/src/messages/pt/pay.js create mode 100644 packages/i18n/src/messages/pt/player.js create mode 100644 packages/i18n/src/messages/pt/visual-game-overrides.generated.js create mode 100644 packages/i18n/src/messages/pt/visual-game.js create mode 100644 packages/i18n/src/messages/zh/account.js create mode 100644 packages/i18n/src/messages/zh/auth.js create mode 100644 packages/i18n/src/messages/zh/common.js create mode 100644 packages/i18n/src/messages/zh/drama.js create mode 100644 packages/i18n/src/messages/zh/feed.js create mode 100644 packages/i18n/src/messages/zh/index.js create mode 100644 packages/i18n/src/messages/zh/landing.js create mode 100644 packages/i18n/src/messages/zh/legal.js create mode 100644 packages/i18n/src/messages/zh/library.js create mode 100644 packages/i18n/src/messages/zh/onboarding.js create mode 100644 packages/i18n/src/messages/zh/pay.js create mode 100644 packages/i18n/src/messages/zh/player.js create mode 100644 packages/i18n/src/messages/zh/visual-game.js create mode 100644 packages/i18n/src/runtime.js create mode 100644 packages/i18n/test/coverage.test.js create mode 100644 packages/llm-router/package.json create mode 100644 packages/llm-router/src/config.js create mode 100644 packages/llm-router/src/db/tables/index.cjs create mode 100644 packages/llm-router/src/db/tables/mode_model_config.cjs create mode 100644 packages/llm-router/src/fallback.js create mode 100644 packages/llm-router/src/index.js create mode 100644 packages/llm-router/src/llm-factory.js create mode 100644 packages/llm-router/src/mode-router.js create mode 100644 packages/llm-router/src/validate.js create mode 100644 packages/llm/README.md create mode 100644 packages/llm/package.json create mode 100644 packages/llm/src/base.js create mode 100644 packages/llm/src/errors.js create mode 100644 packages/llm/src/index.js create mode 100644 packages/llm/src/llm.js create mode 100644 packages/llm/src/map-error.js create mode 100644 packages/llm/src/media.js create mode 100644 packages/llm/src/ports/no-op.js create mode 100644 packages/llm/src/providers/llm.anthropic.js create mode 100644 packages/llm/src/providers/llm.azure_foundry.js create mode 100644 packages/llm/src/providers/llm.codex.js create mode 100644 packages/llm/src/providers/llm.gemini.js create mode 100644 packages/llm/src/providers/llm.moonshot.js create mode 100644 packages/llm/src/providers/llm.openai.js create mode 100644 packages/llm/src/providers/llm.openai_responses.js create mode 100644 packages/llm/src/providers/llm.openrouter.js create mode 100644 packages/llm/src/providers/llm.volcengine.js create mode 100644 packages/llm/src/providers/llm.wanjie.js create mode 100644 packages/llm/src/registry.js create mode 100644 packages/llm/src/response-format.js create mode 100644 packages/llm/src/sse.js create mode 100644 packages/llm/test/azure-foundry.test.js create mode 100644 packages/llm/test/errors.test.js create mode 100644 packages/llm/test/map-error.test.js create mode 100644 packages/llm/tools/scripts/llm-provider-smoke.mjs create mode 100644 packages/moderation/package.json create mode 100644 packages/moderation/src/decision.js create mode 100644 packages/moderation/src/errors.js create mode 100644 packages/moderation/src/fake.js create mode 100644 packages/moderation/src/guard.js create mode 100644 packages/moderation/src/index.js create mode 100644 packages/moderation/src/moderation.js create mode 100644 packages/moderation/src/port.js create mode 100644 packages/moderation/src/rules-check.js create mode 100644 packages/observability/CONVENTION.md create mode 100644 packages/observability/package.json create mode 100644 packages/observability/src/context.js create mode 100644 packages/observability/src/index.js create mode 100644 packages/observability/src/logger.js create mode 100644 packages/observability/src/serialize.js create mode 100644 packages/observability/src/span-sink-otlp.js create mode 100644 packages/observability/src/span.js create mode 100644 packages/observability/src/trace-port.js create mode 100644 packages/observability/src/with-trace.js create mode 100644 packages/observability/test/logger.test.js create mode 100644 packages/observability/test/serialize.test.js create mode 100644 packages/observability/test/with-trace.test.js create mode 100644 packages/ratelimit/package.json create mode 100644 packages/ratelimit/src/errors.js create mode 100644 packages/ratelimit/src/index.js create mode 100644 packages/ratelimit/src/key.js create mode 100644 packages/ratelimit/src/limiter.js create mode 100644 packages/ratelimit/src/memory-store.js create mode 100644 packages/ratelimit/src/rules.js create mode 100644 packages/ratelimit/src/token-bucket.js create mode 100644 packages/ratelimit/test/key.test.js create mode 100644 packages/ratelimit/test/memory-store.test.js create mode 100644 packages/ratelimit/test/token-bucket.test.js create mode 100644 packages/render-pixi/README.md create mode 100644 packages/render-pixi/package.json create mode 100644 packages/render-pixi/src/actor.js create mode 100644 packages/render-pixi/src/assets.js create mode 100644 packages/render-pixi/src/effects.js create mode 100644 packages/render-pixi/src/geom.js create mode 100644 packages/render-pixi/src/index.js create mode 100644 packages/render-pixi/src/occluder.js create mode 100644 packages/render-pixi/src/renderer.js create mode 100644 packages/render-pixi/src/stage.js create mode 100644 packages/render-pixi/test/depth.test.js create mode 100644 packages/render-pixi/test/mirror.test.js create mode 100644 packages/render-pixi/test/stub-pixi.js create mode 100644 packages/rig/package.json create mode 100644 packages/rig/src/clips/arm.js create mode 100644 packages/rig/src/clips/base.js create mode 100644 packages/rig/src/clips/head.js create mode 100644 packages/rig/src/clips/index.js create mode 100644 packages/rig/src/clips/lower-body.js create mode 100644 packages/rig/src/index.js create mode 100644 packages/rig/src/poses.js create mode 100644 packages/rig/src/rigs/humanoid-chibi.js create mode 100644 packages/rig/src/rigs/humanoid-v1.js create mode 100644 packages/rig/src/rigs/index.js create mode 100644 packages/rig/src/types.js create mode 100644 packages/server-kit/package.json create mode 100644 packages/server-kit/src/errors.js create mode 100644 packages/server-kit/src/index.js create mode 100644 packages/server-kit/src/ratelimit.js create mode 100644 packages/server-kit/src/raw-body.js create mode 100644 packages/server-kit/src/trace.js create mode 100644 packages/server-kit/test/server-kit.test.js create mode 100644 packages/service-call/package.json create mode 100644 packages/service-call/src/combinators.js create mode 100644 packages/service-call/src/http.js create mode 100644 packages/service-call/src/index.js create mode 100644 packages/service-call/src/retry.js create mode 100644 packages/service-call/test/retry.test.js create mode 100644 packages/ui/dev/UiGallery.vue create mode 100644 packages/ui/dev/dev.css create mode 100644 packages/ui/dev/index.html create mode 100644 packages/ui/dev/main.js create mode 100644 packages/ui/eslint.config.js create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/components/Badge.vue create mode 100644 packages/ui/src/components/Button.vue create mode 100644 packages/ui/src/components/Card.vue create mode 100644 packages/ui/src/components/ConfirmAction.vue create mode 100644 packages/ui/src/components/Drawer.vue create mode 100644 packages/ui/src/components/EmptyState.vue create mode 100644 packages/ui/src/components/FormField.vue create mode 100644 packages/ui/src/components/Input.vue create mode 100644 packages/ui/src/components/Modal.vue create mode 100644 packages/ui/src/components/PageHeader.vue create mode 100644 packages/ui/src/components/Pagination.vue create mode 100644 packages/ui/src/components/Section.vue create mode 100644 packages/ui/src/components/SegmentedControl.vue create mode 100644 packages/ui/src/components/Select.vue create mode 100644 packages/ui/src/components/Slider.vue create mode 100644 packages/ui/src/components/Spinner.vue create mode 100644 packages/ui/src/components/Stat.vue create mode 100644 packages/ui/src/components/Table.vue create mode 100644 packages/ui/src/components/Tabs.vue create mode 100644 packages/ui/src/components/Textarea.vue create mode 100644 packages/ui/src/components/ToastHost.vue create mode 100644 packages/ui/src/components/Toolbar.vue create mode 100644 packages/ui/src/composables/use-focus-trap.js create mode 100644 packages/ui/src/composables/use-toast.js create mode 100644 packages/ui/src/index.js create mode 100644 packages/ui/src/theme/theme.css create mode 100644 packages/ui/src/theme/tokens.js create mode 100644 packages/ui/src/vue-shim.d.ts create mode 100644 packages/ui/test/card.test.js create mode 100644 packages/ui/vite.config.js create mode 100644 packages/ui/vitest.config.js create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tools/eslint-rules/config.js create mode 100644 tools/eslint-rules/index.js create mode 100644 tools/eslint-rules/no-app-import.js create mode 100644 tools/eslint-rules/no-conn-outside-root.js create mode 100644 tools/eslint-rules/no-deep-import.js create mode 100644 tools/eslint-rules/no-drama-rule-disable.js create mode 100644 tools/eslint-rules/no-subdomain-deep-import.js create mode 100644 tools/eslint-rules/test/no-app-import.test.js create mode 100644 tools/eslint-rules/test/no-conn-outside-root.test.js create mode 100644 tools/eslint-rules/test/no-deep-import.test.js create mode 100644 tools/eslint-rules/test/no-drama-rule-disable.test.js create mode 100644 tools/eslint-rules/test/no-subdomain-deep-import.test.js create mode 100644 tools/scripts/mysql-dev.js create mode 100644 turbo.json create mode 100644 vitest.workspace.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fdb5c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +.DS_Store +dist/ +*.log +*.db +coverage/ +tmp/ +.out/ +.eslintcache +.pnpm-store/ +.turbo +*.timestamp-*.mjs + +# 真密钥只进本地 .env,永不入库 +.env +.env.* +!.env.example +!.env.*.example diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..5cab8e8 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,21 @@ +image: node:24 + +stages: + - check + +cache: + key: + files: + - pnpm-lock.yaml + paths: + - .pnpm-store/ + +check: + stage: check + before_script: + - corepack enable + - pnpm config set store-dir .pnpm-store + - pnpm install --frozen-lockfile + script: + - pnpm lint + - pnpm test diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4cc89e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,153 @@ +# Repository Guidelines + +VOKO Agent 世界(voko-world)。pnpm 11 + Turbo monorepo,Node >=24,ESM JavaScript + JSDoc。应用在 `apps/*`,可复用包在 `packages/*`。 + +本仓库于 2026-10-10 从 shadiao-2d 搬入 19 个包后独立演进;搬入来源与后续同步记录见 `docs/voko/upstream.md`。产品与设计文档见 `docs/voko/README.md`。 + +## 应用与端口 + +| app | 说明 | dev 端口 | +| --- | --- | --- | +| @voko/server | VOKO API(`/v1` 公开接口、`/api/voko` 网页与后台接口),连 `voko` 库 | 9201 | +| @voko/worker | 定时任务与 Webhook 投递(与 server 同目录,独立进程) | — | +| @voko/web | 员工端与 `/admin` 后台,`/api` → 9201 | 5201 | + +(以上 app 在 W1 初始化步骤 3、4 建立。) + +## 命令 + +- `pnpm install`;`pnpm mysql:up` 启动本地 MySQL 并确保 `voko` 库存在(与 shadiao-2d 共用同一个本地 MySQL 服务也可以,库名不同)。 +- `pnpm lint` / `pnpm test`:架构红线与全量测试。CI 即 install → lint → test(schema:check 在建立 voko-server 后加入)。 + +## 文档入口 + +- 产品、设计、计划:`docs/voko/README.md`。 +- 新增或修改可执行流程时,必须新增或更新运行手册(`docs/runbooks/`)并登记到索引。只增加脚本或 `package.json` 命令不算完成。 + +## 边界(ESLint 强制) + +- app 之间禁止源码级互相导入;跨应用只走 HTTP/API、contracts 或 packages 公开能力。 +- 包一律从 `@drama/` 公开入口导入(各包 API 从 `src/index.js` 汇出),禁止深挖别包 `src/*`。 +- 数据库和外部服务连接只在 composition root 创建;包内禁止顶层创建连接。运行参数在实际使用点读取 `process.env`,禁止为转发配置增加通用配置对象和无业务逻辑的参数链路。 +- 包职责:`contracts` 接口 schema 与快照;`auth`/`account`/`billing`/`payment` 业务域;`core`/`render-pixi`/`player-vue` 播放链路;`ui` 基础组件与主题 token;`config` 共享预设。 + +## 数据库 + +- 本产品使用独立库 `voko`,不连接 shadiao-2d 的 `idearun` / `idearun_visual_game`。 +- 结构事实源:`packages/*/src/db/tables/*.cjs`(当前完整结构)+ `apps/voko-server/src/db/migrations/*.js`(版本间变更)。迁移历史表 `voko_schema_migrations`。服务启动不执行 DDL。 +- 迁移只在合入主干后,才允许在 stage / 生产等共享库执行;开发只连本地库。 +- 时间列一律 `DATETIME(3)`,值为 UTC;写入传 Date 对象,API 返回 ISO 8601 字符串。 +- 全链路 snake_case:库列、函数参数、返回对象、contracts、请求/响应体、前端状态同名透传,不做任何大小写转换或多名兜底。 + +## 环境变量 + +- 连接与基础设施配置由各 app 的 `src/env.js` 统一解析;业务运行参数由所属模块直接读取。 +- 真实密钥放各 app gitignored 的 `.env`,不提交;也不提交本地数据库文件和 `node_modules`。 + +## 代码 + +- 两空格缩进、单引号、无分号;类型说明用 JSDoc,不写 TypeScript 语法。 +- 工艺规范唯一权威源 `~/.codex/code-standards.md`;写代码、改代码、写脚本、做评审前必须读取并遵守该文件。评审对照它逐条举反例,lint 干净不作质量证据。 +- 服务方法把 `ctx` 作为显式第一参数一路传到 service/repo(`packages/observability/CONVENTION.md`),不用 AsyncLocalStorage 或全局单例。 + +## 代码生成硬规则 + +### 心智模型(最高优先级,先读这个) + +系统分为入口和内部。入口有且只有四类: +1. HTTP controller 的请求入参 +2. 消息/任务消费的 payload +3. 第三方 API / 外部服务的响应 +4. DB row 读取后转成业务数据的那一次转换 + +数据在入口处被一次性校验并构造成正确形状,校验失败立即 throw。 +过了入口的数据无条件信任:内部代码不判空、不兜底、不猜字段、不怀疑。 +内部代码觉得数据可疑 = 入口漏了或构造函数没把不变量执行到位, +去修入口和构造函数,禁止在现场打补丁。 + +### 事实源 + +- 写任何代码前,先定位:数据生产者、schema/migration、现有调用方、相关测试。 + 以生产者和 schema 为唯一事实源。 +- 字段结构不确定时,读代码、读 migration、查真实数据。查完仍不确定就停下来问。 +- 禁止一切猜测式读取:`a || b || c`、`x?.foo ?? x?.bar`、 + `typeof x.foo === 'string'` 探测、`Array.isArray` 兜底分支,全部不许。 + +### 数据形状 + +- 每类核心业务数据(订单、会员段、积分流水等)有唯一构造点, + 普通函数即可(如 orderFromRow):非法输入当场 throw, + 带数据 id 和具体哪个字段的问题。 +- 状态相关的必有字段由构造点按 status 保证(paid 必有 paidAt,缺失即 throw), + 下游读取零判空——内部代码出现判空,视为构造点没做到位,回去修构造点。 +- 核心格式使用 JSDoc / d.ts 说明清楚。 + +### 状态消费 + +- 消费 status 的 switch 必须穷举所有已知状态,且必须写 default 分支, + default 只许 `throw new Error("Unhandled status: <值>, id: <数据id>")`。 + 禁止 default 里给默认行为或静默跳过。 +- 同一个 status 的 switch 散落超过两三处时,把行为收进该数据的变换函数 + 或 reducer,不许继续复制分支。 + +### 数据库约束 + +- 唯一性、非空、状态与字段的组合合法性,一律下沉为 DB 约束: + NOT NULL / UNIQUE / CHECK(如 `CHECK (status != 'refunded' OR refunded_at IS NOT NULL)`)。 +- 禁止用"先 SELECT 判断再 INSERT/UPDATE"在应用层模拟约束——有并发窗口,不可靠。 + +### 错误处理 + +- 区分两类失败: + - 预期失败(用户输错、余额不足、并发冲突):用显式返回值或明确的业务错误建模, + 调用方必须处理; + - 程序 bug(不变量被违反、不可能的状态):带完整上下文 throw, + 交给全局 error handler 记日志、报警、返回 500。 +- 中间层禁止 try-catch。禁止 catch 后吞掉、catch 后返回默认值、 + catch 后包一层泛化错误重抛。 +- try-catch 只允许出现在三处:全局 error handler、入口校验处、 + 对外部 IO(第三方 API、文件、网络)的最小包裹。 +- 原则:宁可当场 500,不许静默写脏数据。涉及金额、会员权益的路径尤其如此。 + +### 兼容与迁移 + +- 新代码禁止同时支持新旧两套数据形状。禁止:多字段兜底、大小写兜底、 + 旧字段别名、猜测式解析、默认值吞错、空数组/空对象兜底、遗留分支保留。 +- 发现存量数据、线上接口、已发版客户端依赖旧形状时:停止,报告冲突, + 给出迁移方案(migration 脚本 + 切换步骤 + 回滚点),等确认后执行。 + 禁止自行改线上 schema、删在用字段,也禁止用兼容层绕过。 + +### 测试与诊断 + +- 生产、开发、测试、诊断、验收代码一律禁止 fake / mock / stub / spy、内存替身、 + 伪造第三方响应、伪造数据库结果、手工插入业务终态,也禁止 broken implementation + 或测试专用开关。外部系统只用真实服务或官方 sandbox/test mode;真实依赖不可用就明确 + `BLOCKED` / 未验证,不准用替身补绿。任务触达范围内的既有替身必须删除并改走真实链路。 +- 禁止无用纯单元测试。纯单元测试只有在直接调用现有生产纯算法、零替身、验证明确业务 + 不变量/数学公式/完整状态全集,且同任务已有真实端到端链路时才允许;字段搬运、配置映射、 + 默认值、getter/setter、调用次数、mock 交互和代码存在性测试一律禁止。 +- 测试数据必须符合生产 schema 的真实形状,禁止 mock 出宽容的假结构 + 为兼容代码制造存在理由。 +- 涉及状态机的测试,必须覆盖全部已知 status 值—— + 没有编译器做穷尽检查,测试就是穷尽检查。 +- 诊断脚本必须验证具体问题,复用生产 schema 和生产函数; + 失败就直接失败,失败信息要能定位到数据,不写通用兼容层。负路径必须把真实非法输入 + 或真实失败状态送入同一套生产入口,禁止另造错误实现证明脚本会红。 + +## 前端 + +- 文案走 `vue-i18n`,新增 key 补齐 `en/zh/ja/es/id/pt`。 +- 颜色/间距/圆角/阴影用 `var(--drama-*)` token(`packages/ui/src/theme/`),不硬编码 hex/rgba。 +- 优先复用 `@drama/ui` 的 Button/Card/Modal/ToastHost;页面只做装配,不写鉴权协议、SQL、厂商 SDK。 +- 设计参考 `docs/design/IMPLEMENTATION-STANDARD.md`、`docs/design/screen-to-module-map.md`。 + +## 测试 + +- Vitest 是自动化测试套件的唯一 runner,用例放各 workspace 的 `test/*.test.js`;真实链路验收脚本放对应 app/package 的 `tools/scenarios/`,通过明确的 `node` 或 `pnpm` 命令运行,不伪装成纯单元测试。 +- 自动化测试禁止 `CREATE DATABASE` / `DROP DATABASE`,也禁止随机生成数据库名。真实链路场景只连接本地 `voko` 库,通过真实业务入口写入生产形状数据并按场景精确清理。 +- 改哪个包先 `pnpm --filter test`;改共享契约、路由守卫、支付、鉴权、播放链路再跑全量 `pnpm test` + `pnpm lint`。这些命令只作回归门禁,不能替代真实链路验收。 + +## 提交与 PR + +- 提交信息用短 scope 风格(`fix(server): ...`),写清变更对象和动作。 +- PR 带变更摘要与验证命令结果;UI 改动附截图;contract/schema 改动说明迁移影响。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..04aca35 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# vokoworld + +VOKO Agent 世界:任务交换 + 积分清算与确认 + 游戏化可视化。 + +## 本地启动 + +```bash +nvm use # Node 24(见 .nvmrc) +corepack enable # 使用 package.json 指定的 pnpm 11.5.3 +pnpm install +pnpm mysql:up # 本地 MySQL,确保 voko 库存在 +pnpm lint && pnpm test +``` + +开发规范见 `AGENTS.md`,产品与设计文档见 `docs/voko/README.md`。 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..75a9f44 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + mysql: + image: mysql:8.4 + ports: + - "${MYSQL_PORT:-3306}:3306" + # 口令没有默认值:容器口令必须和代码里连库用的口令是同一个来源,否则起来的库谁也连不上。 + # 唯一入口是 `pnpm mysql:up`(tools/scripts/mysql-dev.js),它从 @drama/db 的 devMysqlUrl() + # 解析出口令注进来。直接 `docker compose up mysql` 会明确报缺变量,而不是悄悄起一个错口令的库。 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?run `pnpm mysql:up` instead of `docker compose up mysql`} + MYSQL_DATABASE: ${MYSQL_DATABASE:?run `pnpm mysql:up` instead of `docker compose up mysql`} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - mysql-data:/var/lib/mysql + - ./docker/mysql/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"] + interval: 5s + timeout: 3s + retries: 30 + +volumes: + mysql-data: diff --git a/docker/mysql/init/01-databases.sql b/docker/mysql/init/01-databases.sql new file mode 100644 index 0000000..f645aae --- /dev/null +++ b/docker/mysql/init/01-databases.sql @@ -0,0 +1,3 @@ +CREATE DATABASE IF NOT EXISTS `voko` + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; diff --git a/docs/voko/README.md b/docs/voko/README.md new file mode 100644 index 0000000..abdc69e --- /dev/null +++ b/docs/voko/README.md @@ -0,0 +1,12 @@ +# VOKO 文档索引 + +| 文档 | 用途 | 链接 | +| --- | --- | --- | +| VOKO Alpha PRD | 用户故事与验收标准(F0–F4)、模块、页面 | https://claude.ai/code/artifact/247532d7-efb9-4d95-bf02-10f2c5fdc281 | +| VOKO Alpha SDD | 技术设计:数据模型、状态机、清算、接入层、worker | https://claude.ai/code/artifact/e3fa5c34-d943-404c-849f-9a2ad241491f | +| VOKO Alpha Planning | W1–W6 分周计划、54 个测试用例、验收脚本 | https://claude.ai/code/artifact/27b94ca4-ccfb-420b-9193-b81eaaca09cf | +| VOKO Agent 接入说明 | 公开 API、游戏规则参数、skill.md / heartbeat.md 草稿 | https://claude.ai/code/artifact/1c0e789b-7b54-4cd7-971c-31ec7db2bee4 | +| voko-world 仓库初始化清单 | 本仓库的搭建步骤与验收 | https://claude.ai/code/artifact/20a68ea3-4b05-49fd-9173-520b80733534 | +| VOKO Beta PRD | 公测阶段需求 | https://claude.ai/code/artifact/8260dfaa-ad11-4283-a651-c7db785a0c6b | + +以上为在线文档;进入开发后关键结论以本目录下的导出版为准(W1 内导出)。 diff --git a/docs/voko/upstream.md b/docs/voko/upstream.md new file mode 100644 index 0000000..0758d45 --- /dev/null +++ b/docs/voko/upstream.md @@ -0,0 +1,36 @@ +# 搬入来源与同步记录 + +## 来源 + +- 仓库:shadiao-2d +- 分支:stage +- 提交:5bae6b786ec2f7d961497690b004e463c07bd848(2026-09-09),搬入日 2026-10-10,被搬入文件在工作区无未提交改动 +- 搬入的包(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 +- 另搬入:`tools/eslint-rules/`、`eslint.config.js`、`turbo.json`、`vitest.workspace.js`、`docker-compose.yml`、`tools/scripts/mysql-dev.js`、`pnpm-lock.yaml`(安装时已按本仓库依赖裁剪) + +## 搬入时的改动 + +| 文件 | 改动 | +| --- | --- | +| `eslint.config.js` | 删除 callchain 包的忽略与规则块 | +| `tools/eslint-rules/config.js` | COMPOSITION_ROOTS 改为 `apps/voko-server` 下的入口 | +| `tools/scripts/mysql-dev.js` | 默认库 `voko`,默认 env 文件 `apps/voko-server/.env` | +| `packages/llm-router/package.json` | 测试脚本加 `--passWithNoTests`(上游该包目前没有测试文件,`vitest run` 会以 1 退出) | +| `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` | + +## 待裁剪(原样搬入,后续处理) + +- `@drama/contracts`:仍含 drama / visual-game / waitlist 等 schema 与 parse 助手;VOKO 自己的 schema 放新包,待 voko-server 只依赖 platform 子路径后再裁剪。 +- `@drama/auth` 的 `fake-repo.js`、`test/fakes.js` 与 `@drama/moderation` 的 `fake.js`:违反"禁止替身",需改写为真实 MySQL 测试后删除。 +- `@drama/email` 模板:`video-ready`、`order-notice` 与本产品无关;`waitlist-invite` 可改作加入申请通过通知。 +- `@drama/i18n`:保留全部语言(海外产品后续需要 ja / ko 等),删除 drama / visual-game 文案待前端成形后进行。 + +## 同步纪律 + +- 每两周对照一次 shadiao-2d 中 `billing`、`auth`、`db`、`email`、`server-kit` 的提交,修复类提交按需搬入,并在下方追加记录。 +- 本仓库对这些包的修复如 shadiao-2d 也需要,向 shadiao-2d 提 MR。 + +## 同步记录 + +| 日期 | 包 | 上游提交 | 说明 | +| --- | --- | --- | --- | diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3132687 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,60 @@ +/** + * ESLint flat config(根)—— 装配本地架构红线 plugin + 基础 JS 规则。 + * + * 声明式:判定逻辑全在 `tools/eslint-rules/*.js`,红线装配(白名单 / 规则集 / globals) + * 单源于 `tools/eslint-rules/config.js`,本文件与 `packages/config/eslint` 都从那里 import, + * 不各自再抄一份。根配置只 import 相对路径的 `tools/eslint-rules/*`,不 import 任何 workspace 包 + * (避免把 config 的依赖拖进 lint 链)。 + * + * composition-root / vite 配置白名单在单源 `config.js` 里维护,新增豁免点先改任务文档再改那里。 + */ + +import js from '@eslint/js' +import { dramaPlugin } from './tools/eslint-rules/index.js' +import { + buildArchRules, + BASE_GLOBALS, + BROWSER_GLOBALS, + COMMONJS_GLOBALS, +} from './tools/eslint-rules/config.js' + +const ARCH_RULES = buildArchRules() + +export default [ + { + ignores: [ + '**/node_modules/**', + '**/dist/**', + '**/coverage/**', + 'tmp/**', + ], + }, + js.configs.recommended, + { + files: ['**/*.js', '**/*.mjs'], + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + globals: { + ...BASE_GLOBALS, + ...BROWSER_GLOBALS, + }, + }, + plugins: { drama: dramaPlugin }, + rules: { ...ARCH_RULES }, + }, + { + files: ['**/*.cjs'], + languageOptions: { + ecmaVersion: 2023, + sourceType: 'commonjs', + globals: { ...COMMONJS_GLOBALS }, + }, + plugins: { drama: dramaPlugin }, + rules: { ...ARCH_RULES }, + }, + { + // 规则/预设单测 fixture 含故意违规代码与故意的 env 操作,不参与全仓 lint。 + ignores: ['tools/eslint-rules/test/**', 'packages/config/test/**'], + }, +] diff --git a/package.json b/package.json new file mode 100644 index 0000000..423dc67 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "vokoworld", + "version": "0.0.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.5.3", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "turbo run dev", + "mysql:up": "node tools/scripts/mysql-dev.js up", + "mysql:ensure": "node tools/scripts/mysql-dev.js ensure", + "mysql:status": "node tools/scripts/mysql-dev.js status", + "mysql:stop": "node tools/scripts/mysql-dev.js stop", + "build": "turbo run build", + "test": "turbo run test", + "test:all": "vitest run", + "lint": "eslint ." + }, + "devDependencies": { + "@drama/db": "workspace:*", + "@eslint/js": "^9.13.0", + "@types/estree": "^1.0.6", + "@types/node": "^24.0.0", + "eslint": "^9.13.0", + "ioredis": "^5.11.1", + "playwright": "^1.61.0", + "turbo": "^2.9.18", + "vitest": "^2.1.4" + }, + "dependencies": { + "vue": "^3.5.38", + "vue-i18n": "^11.4.5" + } +} diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..5b5b622 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,28 @@ +{ + "name": "@drama/auth", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + }, + "peerDependencies": { + "jose": "^5.9.0", + "argon2": "^0.41.0", + "google-auth-library": "^9.14.0" + }, + "peerDependenciesMeta": { + "jose": { + "optional": true + }, + "argon2": { + "optional": true + }, + "google-auth-library": { + "optional": true + } + } +} diff --git a/packages/auth/src/auth.js b/packages/auth/src/auth.js new file mode 100644 index 0000000..6bcdb78 --- /dev/null +++ b/packages/auth/src/auth.js @@ -0,0 +1,139 @@ +/** + * createAuth —— 唯一入口工厂,只做依赖装配 + 各能力编排。 + * + * 纪律:本文件不碰任何密码学(全在注入 Port);缺依赖在装配时即 assert 抛错 + * (不拖到运行某行);每条业务流程在独立文件(register/login/oauth/refresh/code)。 + * verifyAccessToken / logout / revokeAllSessions 是薄方法,直接落本文件。 + */ + +import { AuthError } from './errors.js' +import { makeRegister } from './register.js' +import { makeLogin } from './login.js' +import { makeOauthLogin } from './oauth.js' +import { makeRefresh } from './refresh.js' +import { makeSendCode, makeVerifyCode } from './code.js' +import { makeResetPassword } from './reset-password.js' +import { makeRequestPasswordReset } from './request-password-reset.js' +import { parseRefreshToken } from './session.js' + +/** + * @param {Object} [deps] + * @param {import('./index.js').UserRepo} [deps.userRepo] + * @param {import('./index.js').EmailPort} [deps.email] + * @param {import('./index.js').OauthPort} [deps.oauth] + * @param {import('./index.js').TokenSigner} [deps.tokenSigner] + * @param {import('./index.js').Hasher} [deps.hasher] + * @param {Object} [opts] 可选的业务规则覆写(TTL / 阈值 / 窗口)。 + * @returns {import('./index.js').Auth} + */ +export function createAuth({ userRepo, email, oauth, tokenSigner, hasher } = {}, opts = {}) { + assertDep('userRepo', userRepo, ['findByEmail', 'create', 'updatePasswordHash', 'saveSession', 'findSession']) + assertDep('email', email, ['sendCode']) + assertDep('oauth', oauth, ['exchange']) + assertDep('tokenSigner', tokenSigner, ['sign', 'verify']) + assertDep('hasher', hasher, ['hash', 'verify']) + + 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 }} */ ( + { userRepo, email, oauth, tokenSigner, hasher, ...opts } + ) + + const register = makeRegister(deps) + const login = makeLogin(deps) + const oauthLogin = makeOauthLogin(deps) + const refresh = makeRefresh(deps) + const sendCode = makeSendCode(deps) + const requestPasswordReset = makeRequestPasswordReset({ userRepo: deps.userRepo, sendCode }) + const verifyCode = makeVerifyCode(deps) + const resetPassword = makeResetPassword(deps) + + /** @type {import('./index.js').Auth['verifyAccessToken']} */ + async function verifyAccessToken(token) { + if (!token || typeof token !== 'string') { + throw new AuthError({ code: 'TOKEN_INVALID' }) + } + try { + const claims = await deps.tokenSigner.verify(token) + await assertAccessSessionActive(claims) + return claims + } catch (/** @type {any} */ err) { + if (err instanceof AuthError) throw err + // jose 用 'ERR_JWT_EXPIRED'(code 或 name)区分过期与无效签名。 + const tag = (err && (err.code || err.name)) || '' + const expired = /EXPIRED/i.test(String(tag)) + throw new AuthError({ code: expired ? 'TOKEN_EXPIRED' : 'TOKEN_INVALID', cause: err }) + } + } + + /** + * @param {Record} claims + */ + async function assertAccessSessionActive(claims) { + const sid = String(claims?.sid || '') + if (!sid) throw new AuthError({ code: 'TOKEN_INVALID' }) + const session = await deps.userRepo.findSession({ sid }) + if (!session || session.revoked) throw new AuthError({ code: 'TOKEN_INVALID' }) + if (Date.now() > session.expires_at.getTime()) { + throw new AuthError({ code: 'TOKEN_EXPIRED' }) + } + } + + /** @type {import('./index.js').Auth['logout']} */ + async function logout({ refresh_token }) { + const parsed = parseRefreshToken(refresh_token) + if (parsed) { + const session = await deps.userRepo.findSession({ sid: parsed.sid }) + if (session) await deps.userRepo.revokeFamily({ family: session.family }) + } + return { ok: true } + } + + /** @type {import('./index.js').Auth['revokeAllSessions']} */ + async function revokeAllSessions({ user_id }) { + if (!user_id) throw new AuthError({ code: 'USER_NOT_FOUND' }) + await deps.userRepo.revokeUserSessions({ user_id }) + return { ok: true } + } + + /** + * @param {{ user_id: string }} args + */ + async function getUser({ user_id }) { + if (!user_id || typeof deps.userRepo.findById !== 'function') return null + return deps.userRepo.findById(user_id) + } + + return { + register, + login, + oauthLogin, + refresh, + verifyCode, + sendCode, + requestPasswordReset, + resetPassword, + getUser, + verifyAccessToken, + logout, + revokeAllSessions, + } +} + +/** + * 装配时依赖自检:缺整个依赖或缺关键方法都立刻抛 AuthError。 + * @param {string} name + * @param {any} dep + * @param {string[]} methods + */ +function assertDep(name, dep, methods) { + if (!dep || typeof dep !== 'object') { + throw new AuthError({ code: 'TOKEN_INVALID', message: `createAuth: missing dependency "${name}"` }) + } + for (const m of methods) { + if (typeof dep[m] !== 'function') { + throw new AuthError({ + code: 'TOKEN_INVALID', + message: `createAuth: dependency "${name}" missing method "${m}"`, + }) + } + } +} diff --git a/packages/auth/src/bruteforce.js b/packages/auth/src/bruteforce.js new file mode 100644 index 0000000..fc9f356 --- /dev/null +++ b/packages/auth/src/bruteforce.js @@ -0,0 +1,53 @@ +/** + * 防爆破 —— 窗口计数 + 阈值判定的纯逻辑(计数存储走注入的 repo,不用进程内 Map)。 + * + * 判定逻辑(`evaluateAttempts`)是纯函数:给定窗口内已记录的失败次数、阈值、窗口长度, + * 返回是否锁定与 `retryAfterMs`。落库/读库由 repo 完成,本文件只决策。 + * + * 默认阈值/窗口可由调用方覆写(开闭原则:改规则只动这里的常量或注入 opts)。 + */ + +export const DEFAULT_MAX_ATTEMPTS = 5 +export const DEFAULT_WINDOW_MS = 15 * 60 * 1000 // 15 分钟 + +/** + * 把 email + ip 归一成防爆破计数 key(同标识合并计数)。 + * @param {string} email + * @param {string} [ip] + * @returns {string} + */ +export function attemptKey(email, ip) { + const e = String(email ?? '').trim().toLowerCase() + return ip ? `${e}|${ip}` : e +} + +/** + * 纯判定:当前窗口失败次数是否已达阈值;若是,给出还需等待多久。 + * + * `retryAfterMs` = 窗口长度(保守估计:最早一条失败记录在窗口边界时需等待整个窗口; + * 若提供 `oldestAt` 与 `now` 则给出精确剩余时间)。 + * + * @param {Object} args + * @param {number} args.recentCount 窗口内已记录的失败次数 + * @param {number} [args.maxAttempts] + * @param {number} [args.windowMs] + * @param {number} [args.oldestAt] 窗口内最早一条失败记录的时间戳(epoch ms),可选精确化 + * @param {number} [args.now] + * @returns {{ locked: boolean, retryAfterMs: number }} + */ +export function evaluateAttempts({ + recentCount, + maxAttempts = DEFAULT_MAX_ATTEMPTS, + windowMs = DEFAULT_WINDOW_MS, + oldestAt, + now = Date.now(), +}) { + const locked = recentCount >= maxAttempts + if (!locked) return { locked: false, retryAfterMs: 0 } + let retryAfterMs = windowMs + if (typeof oldestAt === 'number') { + // 最早一条失败记录滚出窗口后即可重试。 + retryAfterMs = Math.max(0, oldestAt + windowMs - now) + } + return { locked: true, retryAfterMs } +} diff --git a/packages/auth/src/code.js b/packages/auth/src/code.js new file mode 100644 index 0000000..ea83e89 --- /dev/null +++ b/packages/auth/src/code.js @@ -0,0 +1,140 @@ +/** + * 验证码 —— sendCode / verifyCode 编排 + 验证码生命周期纯逻辑。 + * + * 纯逻辑(`generateCode` / `evaluateCode`)可直接单测,无 IO;落库(saveCode/consumeCode)、 + * 投递(email Port)、哈希(hasher)由编排函数注入。验证码本身经 hasher 哈希落库, + * 库内 grep 不到明文验证码持久化。 + */ + +import { AuthError } from './errors.js' + +export const DEFAULT_CODE_TTL_MS = 10 * 60 * 1000 // 10 分钟 +export const DEFAULT_MAX_CODE_ATTEMPTS = 5 +export const DEFAULT_CODE_SEND_WINDOW_MS = 60 * 1000 +export const DEFAULT_MAX_CODE_SENDS = 3 +export const VALID_PURPOSES = new Set(['register', 'login', 'reset']) + +/** + * 生成 6 位数字验证码(密码学随机)。投递走 email Port,哈希后落库。 + * @param {() => number} [rand] 注入随机源(测试用,缺省 crypto 安全随机)。 + * @returns {string} + */ +export function generateCode(rand) { + if (typeof rand === 'function') { + const n = Math.floor(rand() * 1_000_000) + return String(((n % 1_000_000) + 1_000_000) % 1_000_000).padStart(6, '0') + } + // 无注入时用 Node 内置 CSPRNG(不自研随机)。 + const buf = globalThis.crypto.getRandomValues(new Uint32Array(1)) + return String(buf[0] % 1_000_000).padStart(6, '0') +} + +/** + * 纯判定:给定已落库的验证码记录与提交的明码校验结果,决定通过 / 抛哪种结构化错误。 + * + * 分支(各对应一个错误码): + * - 记录不存在 / purpose 不匹配 → CODE_INVALID + * - 已过期 → CODE_EXPIRED + * - 尝试次数超限 → CODE_INVALID(视为失效,调用方应要求重发) + * - 哈希不匹配 → CODE_INVALID + * + * @param {Object} args + * @param {import('./index.js').CodeRecord|null} args.record + * @param {string} args.purpose + * @param {boolean} args.matches hasher.verify(submitted, record.code_hash) 的结果 + * @param {number} [args.maxAttempts] + * @param {number} [args.now] + * @returns {{ ok: true }} + * @throws {AuthError} + */ +export function evaluateCode({ + record, + purpose, + matches, + maxAttempts = DEFAULT_MAX_CODE_ATTEMPTS, + now = Date.now(), +}) { + if (!record || record.purpose !== purpose) { + throw new AuthError({ code: 'CODE_INVALID' }) + } + if (now > record.expires_at.getTime()) { + throw new AuthError({ code: 'CODE_EXPIRED' }) + } + if (typeof record.attempts === 'number' && record.attempts >= maxAttempts) { + throw new AuthError({ code: 'CODE_INVALID' }) + } + if (!matches) { + throw new AuthError({ code: 'CODE_INVALID' }) + } + return { ok: true } +} + +/** + * sendCode 编排:生成码 → 哈希落库 → 后台经 email Port 投递。 + * @param {Object} deps + * @param {import('./index.js').UserRepo} deps.userRepo + * @param {import('./index.js').EmailPort} deps.email + * @param {import('./index.js').Hasher} deps.hasher + * @param {number} [deps.ttlMs] + * @param {number} [deps.sendWindowMs] + * @param {number} [deps.maxSends] + * @param {() => number} [deps.rand] + * @returns {(args: { email: string, purpose: string, locale?: string, ip?: string }) => Promise<{ ok: true }>} + */ +export function makeSendCode({ + userRepo, + email, + hasher, + ttlMs = DEFAULT_CODE_TTL_MS, + sendWindowMs = DEFAULT_CODE_SEND_WINDOW_MS, + maxSends = DEFAULT_MAX_CODE_SENDS, + rand, +}) { + return async function sendCode({ email: to, purpose, locale, ip }) { + if (!VALID_PURPOSES.has(purpose)) { + throw new AuthError({ code: 'CODE_INVALID', message: `unsupported purpose: ${purpose}` }) + } + const normalizedEmail = normEmail(to) + await guardCodeSend({ userRepo, email: normalizedEmail, purpose, ip, sendWindowMs, maxSends }) + const code = generateCode(rand) + const code_hash = await hasher.hash(code) + const expires_at = new Date(Date.now() + ttlMs) + await userRepo.saveCode({ email: normalizedEmail, purpose, code_hash, expires_at }) + email.sendCode({ to, code, purpose, locale }) + return { ok: true } + } +} + +/** + * verifyCode 编排:取记录 → hasher 校验 → evaluateCode 判定。 + * @param {Object} deps + * @param {import('./index.js').UserRepo} deps.userRepo + * @param {import('./index.js').Hasher} deps.hasher + * @param {number} [deps.maxAttempts] + * @returns {(args: { email: string, code: string, purpose: string }) => Promise<{ ok: true }>} + */ +export function makeVerifyCode({ userRepo, hasher, maxAttempts = DEFAULT_MAX_CODE_ATTEMPTS }) { + return async function verifyCode({ email: to, code, purpose }) { + const record = await userRepo.consumeCode({ email: normEmail(to), purpose }) + const matches = record ? await hasher.verify(code, record.code_hash) : false + return evaluateCode({ record, purpose, matches, maxAttempts }) + } +} + +/** @param {string} e */ +function normEmail(e) { + return String(e ?? '').trim().toLowerCase() +} + +async function guardCodeSend({ userRepo, email, purpose, ip, sendWindowMs, maxSends }) { + const key = codeSendAttemptKey(email, purpose, ip) + const count = await userRepo.countRecentAttempts({ key, windowMs: sendWindowMs }) + if (count >= maxSends) { + throw new AuthError({ code: 'TOO_MANY_ATTEMPTS', retryAfterMs: sendWindowMs }) + } + await userRepo.recordAttempt({ key, ip, at: new Date() }) +} + +function codeSendAttemptKey(email, purpose, ip) { + return `code:${purpose}:${email}:${ip || '*'}` +} diff --git a/packages/auth/src/db/README.md b/packages/auth/src/db/README.md new file mode 100644 index 0000000..088bb83 --- /dev/null +++ b/packages/auth/src/db/README.md @@ -0,0 +1,14 @@ +# @drama/auth Database Schema + +这里是 `@drama/auth` 当前表结构的唯一阅读入口。 + +- `tables/`:一个表一个文件,文件名等于表名。 +- `tables/index.cjs`:批量加载入口,只给显式初始化/测试/校验工具使用,不是人工阅读入口。 + +## 表 + +- `tables/users_auth_fields.cjs`:auth 对 `users` 根表追加的字段。 +- `tables/user_oauth.cjs` +- `tables/auth_sessions.cjs` +- `tables/auth_attempts.cjs` +- `tables/auth_codes.cjs` diff --git a/packages/auth/src/db/tables/auth_attempts.cjs b/packages/auth/src/db/tables/auth_attempts.cjs new file mode 100644 index 0000000..0768946 --- /dev/null +++ b/packages/auth/src/db/tables/auth_attempts.cjs @@ -0,0 +1,11 @@ +const tableName = 'auth_attempts' + +function buildTable(t) { + t.increments('id').primary() + t.string('key').notNullable() + t.string('ip', 64).nullable() + t.datetime('at', { precision: 3 }).notNullable() + t.index(['key', 'at']) +} + +module.exports = { tableName, buildTable } diff --git a/packages/auth/src/db/tables/auth_codes.cjs b/packages/auth/src/db/tables/auth_codes.cjs new file mode 100644 index 0000000..fa652b8 --- /dev/null +++ b/packages/auth/src/db/tables/auth_codes.cjs @@ -0,0 +1,12 @@ +const tableName = 'auth_codes' + +function buildTable(t) { + t.string('email', 320).notNullable() + t.string('purpose', 64).notNullable() + t.text('code_hash').notNullable() + t.datetime('expires_at', { precision: 3 }).notNullable() + t.integer('attempts').notNullable().defaultTo(0) + t.primary(['email', 'purpose']) +} + +module.exports = { tableName, buildTable } diff --git a/packages/auth/src/db/tables/auth_sessions.cjs b/packages/auth/src/db/tables/auth_sessions.cjs new file mode 100644 index 0000000..6438a78 --- /dev/null +++ b/packages/auth/src/db/tables/auth_sessions.cjs @@ -0,0 +1,15 @@ +const tableName = 'auth_sessions' + +function buildTable(t) { + t.string('sid').primary() + t.string('user_id').notNullable() + t.text('refresh_hash').notNullable() + t.string('family').notNullable() + t.datetime('expires_at', { precision: 3 }).notNullable() + t.boolean('used').notNullable().defaultTo(false) + t.boolean('revoked').notNullable().defaultTo(false) + t.index('user_id') + t.index('family') +} + +module.exports = { tableName, buildTable } diff --git a/packages/auth/src/db/tables/index.cjs b/packages/auth/src/db/tables/index.cjs new file mode 100644 index 0000000..423c4fe --- /dev/null +++ b/packages/auth/src/db/tables/index.cjs @@ -0,0 +1,14 @@ +const tableModules = [ + require('./user_oauth.cjs'), + require('./auth_sessions.cjs'), + require('./auth_attempts.cjs'), + require('./auth_codes.cjs'), +] + +const targetTables = tableModules.filter(({ buildTable }) => typeof buildTable === 'function').map(({ tableName, buildTable }) => [tableName, buildTable]) +const authTables = Object.fromEntries(tableModules.map(({ tableName }) => [tableName.replace(/^(auth_|account_|billing_|payment_|email_)/, ''), tableName])) + +module.exports = { + targetTables, + authTables, +} diff --git a/packages/auth/src/db/tables/user_oauth.cjs b/packages/auth/src/db/tables/user_oauth.cjs new file mode 100644 index 0000000..36330b8 --- /dev/null +++ b/packages/auth/src/db/tables/user_oauth.cjs @@ -0,0 +1,11 @@ +const tableName = 'user_oauth' + +function buildTable(t) { + t.string('user_id').notNullable() + t.string('provider', 64).notNullable() + t.string('provider_user_id', 255).notNullable() + t.unique(['provider', 'provider_user_id']) + t.index('user_id') +} + +module.exports = { tableName, buildTable } diff --git a/packages/auth/src/errors.js b/packages/auth/src/errors.js new file mode 100644 index 0000000..5819703 --- /dev/null +++ b/packages/auth/src/errors.js @@ -0,0 +1,46 @@ +/** + * 结构化认证错误 —— 本包唯一对外错误类型(§3)。 + * + * 所有失败一律抛 AuthError,禁止裸 Error / 静默吞错。`retryable` 给调用方决定 + * 是否提示重试;`retryAfterMs` 仅 TOO_MANY_ATTEMPTS 携带。 + */ + +/** + * @typedef {'EMAIL_TAKEN'|'INVALID_CREDENTIALS'|'TOO_MANY_ATTEMPTS'| + * 'OAUTH_VERIFY_FAILED'|'REFRESH_REUSED'|'CODE_EXPIRED'|'CODE_INVALID'| + * 'TOKEN_INVALID'|'TOKEN_EXPIRED'|'USER_NOT_FOUND'|'UNSUPPORTED_PROVIDER'} AuthCode + */ + +/** 默认可重试的错误码集合(瞬时/限流类)。 */ +const RETRYABLE_CODES = new Set(['TOO_MANY_ATTEMPTS']) + +export class AuthError extends Error { + /** + * @param {Object} info + * @param {AuthCode} info.code + * @param {boolean} [info.retryable] + * @param {number} [info.retryAfterMs] + * @param {unknown} [info.cause] + * @param {string} [info.message] + */ + constructor({ code, retryable, retryAfterMs, cause, message }) { + super(message ?? `auth failed: ${code}`) + this.name = 'AuthError' + /** @type {AuthCode} */ + this.code = code + this.retryable = retryable ?? RETRYABLE_CODES.has(code) + if (retryAfterMs != null) this.retryAfterMs = retryAfterMs + if (cause != null) this.cause = cause + } +} + +/** + * 把任意底层错误归一为 AuthError。已是 AuthError 的原样透传。 + * @param {unknown} err + * @param {AuthCode} fallbackCode + * @returns {AuthError} + */ +export function asAuthError(err, fallbackCode) { + if (err instanceof AuthError) return err + return new AuthError({ code: fallbackCode, cause: err }) +} diff --git a/packages/auth/src/fake-repo.js b/packages/auth/src/fake-repo.js new file mode 100644 index 0000000..6aa8927 --- /dev/null +++ b/packages/auth/src/fake-repo.js @@ -0,0 +1,137 @@ +/** + * createFakeUserRepo —— 全内存 UserRepo 实现,测试用(证明所有接缝可替换)。 + * + * 与真实 repo.js 行为对齐:会话族 / refresh hash / 防爆破计数 / 验证码 hash 全在内存 Map, + * 无网络无 DB 即可跑通 register / login / oauth / refresh / code 全流程。 + */ + +/** + * @param {Object} [seed] + * @param {Array} [seed.users] + * @returns {import('./index.js').UserRepo} + */ +export function createFakeUserRepo(seed = {}) { + /** @type {Map} */ + const usersById = new Map() + /** @type {Map} */ + const sessions = new Map() + /** @type {Array<{ key: string, ip?: string, at: Date }>} */ + const attempts = [] + /** @type {Map} */ + const codes = new Map() + + for (const u of seed.users ?? []) { + usersById.set(u.id, { ...u, oauth_providers: u.oauth_providers ?? [] }) + } + + /** @param {string} e */ + const norm = (e) => String(e ?? '').trim().toLowerCase() + /** @param {string} email @param {string} purpose */ + const codeKey = (email, purpose) => `${norm(email)}|${purpose}` + let idSeq = usersById.size + + /** @param {string} email */ + function findUserByEmail(email) { + const e = norm(email) + for (const u of usersById.values()) if (norm(u.email) === e) return u + return null + } + + return { + async findByEmail(email) { + const u = findUserByEmail(email) + return u ? clone(u) : null + }, + + async findById(id) { + const u = usersById.get(id) + return u ? clone(u) : null + }, + + async create({ email, password_hash, oauth_providers, locale }) { + idSeq += 1 + const id = `u${idSeq}` + const user = { + id, + email: norm(email), + password_hash, + locale, + oauth_providers: oauth_providers ?? [], + } + usersById.set(id, user) + return clone(user) + }, + + async linkOauth({ user_id, provider, provider_user_id }) { + const u = usersById.get(user_id) + if (!u) throw new Error('user not found') + u.oauth_providers = u.oauth_providers ?? [] + const dup = u.oauth_providers.some( + (p) => p.provider === provider && p.provider_user_id === provider_user_id, + ) + if (!dup) u.oauth_providers.push({ provider, provider_user_id }) + return clone(u) + }, + + async updatePasswordHash({ user_id, password_hash }) { + const u = usersById.get(user_id) + if (!u) throw new Error('user not found') + u.password_hash = password_hash + }, + + async saveSession({ user_id, sid, refresh_hash, family, expires_at }) { + sessions.set(sid, { sid, user_id, refresh_hash, family, expires_at, used: false, revoked: false }) + }, + + async findSession({ sid }) { + const s = sessions.get(sid) + return s ? { ...s } : null + }, + + async markRefreshUsed({ sid }) { + const s = sessions.get(sid) + if (s) s.used = true + }, + + async revokeFamily({ family }) { + for (const s of sessions.values()) if (s.family === family) s.revoked = true + }, + + async revokeUserSessions({ user_id }) { + for (const s of sessions.values()) if (s.user_id === user_id) s.revoked = true + }, + + async recordAttempt({ key, ip, at }) { + attempts.push({ key, ip, at }) + }, + + async countRecentAttempts({ key, windowMs }) { + const since = Date.now() - windowMs + return attempts.filter((a) => a.key === key && a.at.getTime() >= since).length + }, + + async saveCode({ email, purpose, code_hash, expires_at }) { + codes.set(codeKey(email, purpose), { + email: norm(email), + purpose, + code_hash, + expires_at, + attempts: 0, + }) + }, + + async consumeCode({ email, purpose }) { + const rec = codes.get(codeKey(email, purpose)) + if (!rec) return null + const snapshot = { ...rec } + rec.attempts += 1 + return snapshot + }, + } +} + +/** @param {any} o */ +function clone(o) { + // structuredClone 保留 Date(时间字段与真实 repo 一致返回 Date),JSON round-trip 会把它变字符串。 + return structuredClone(o) +} diff --git a/packages/auth/src/index.js b/packages/auth/src/index.js new file mode 100644 index 0000000..7dc7e41 --- /dev/null +++ b/packages/auth/src/index.js @@ -0,0 +1,104 @@ +/** + * `@drama/auth` 公共面 —— 身份认证域唯一对外导出口(任务文档 §3 契约)。 + * + * 设计纪律(架构 §9 红线 + §1 边界): + * - 包内不读 `process.env`、不在模块顶层建任何连接(db 由 `createUserRepo(db)` 注入, + * 密钥/JWKS/OAuth client 全由注入的 Port 在 composition root 持有)。 + * - 绝不自研密码学:JWT 签发/验证走 `tokenSigner`,OAuth 验签走 `oauth`, + * 密码哈希走 `hasher`。本包只编排这些 Port + 把会话/凭据/验证码状态落库。 + * - 唯一对外错误类型是 `AuthError`(结构化 `code`/`retryable`)。 + * + * ----- 注入式 Port 协议(实现在 composition root,本包只消费契约)----- + * + * @typedef {Object} TokenSigner 薄封装 jose。 + * @property {(claims: object, opts: { ttlMs: number }) => string|Promise} sign + * @property {(token: string) => object|Promise} verify 验签 + 过期校验,失败抛错。 + * + * @typedef {Object} Hasher 薄封装 argon2 / bcrypt。 + * @property {(plain: string) => string|Promise} hash + * @property {(plain: string, hash: string) => boolean|Promise} verify + * + * @typedef {Object} OauthExchangeResult + * @property {string} provider_user_id + * @property {string} email + * @property {boolean} email_verified + * + * @typedef {Object} OauthPort 薄封装各家 SDK(含验签)。 + * @property {(args: { provider: 'google'|'apple', code: string, oauth_flow: 'access_token'|'authorization_code', redirect_uri?: string }) => OauthExchangeResult|Promise} exchange + * + * 来自 @drama/email 的投递端口(验证码投递)。 + * @typedef {Object} EmailPort + * @property {(args: { to: string, code: string, purpose: string, locale?: string }) => void|Promise} sendCode + * + * ----- 领域 DTO(与 @drama/contracts 的 User/Session 对齐)----- + * + * @typedef {Object} User + * @property {string} id + * @property {string} email + * @property {string} [password_hash] + * @property {Array<{ provider: string, provider_user_id: string }>} [oauth_providers] + * @property {string} [locale] + * @property {Date} [email_verified_at] + * + * @typedef {Object} Session + * @property {string} sid + * @property {string} user_id + * @property {string} refresh_hash + * @property {string} family + * @property {Date} expires_at UTC + * @property {boolean} [used] + * @property {boolean} [revoked] + * + * @typedef {Object} CodeRecord + * @property {string} email + * @property {string} purpose + * @property {string} code_hash + * @property {Date} expires_at UTC + * @property {number} attempts + * + * ----- UserRepo 形态(createUserRepo(db) 返回,本包内消费)----- + * + * @typedef {Object} UserRepo + * @property {(email: string) => Promise} findByEmail + * @property {(id: string) => Promise} findById + * @property {(args: { email: string, password_hash?: string, oauth_providers?: Array<{provider:string,provider_user_id:string}>, locale?: string, email_verified_at?: Date }) => Promise} create + * @property {(args: { user_id: string, provider: string, provider_user_id: string }) => Promise} linkOauth + * @property {(args: { user_id: string, password_hash: string }) => Promise} updatePasswordHash + * @property {(args: { user_id: string, sid: string, refresh_hash: string, family: string, expires_at: Date }) => Promise} saveSession + * @property {(args: { sid: string }) => Promise} findSession + * @property {(args: { sid: string }) => Promise} markRefreshUsed + * @property {(args: { family: string }) => Promise} revokeFamily + * @property {(args: { user_id: string }) => Promise} revokeUserSessions + * @property {(args: { key: string, ip?: string, at: Date }) => Promise} recordAttempt + * @property {(args: { key: string, ip?: string, windowMs: number }) => Promise} countRecentAttempts + * @property {(args: { email: string, purpose: string, code_hash: string, expires_at: Date }) => Promise} saveCode + * @property {(args: { email: string, purpose: string }) => Promise} consumeCode + * @property {(args: { email: string, purpose: string, attempts: number }) => Promise} [bumpCodeAttempts] + * + * ----- Auth 形态(createAuth(...) 返回,全部返回 Promise)----- + * + * @typedef {Object} AuthTokens + * @property {User} user + * @property {string} access_token + * @property {string} refresh_token + * + * @typedef {Object} Auth + * @property {(args: { email: string, password: string, locale?: string, email_verified_at?: Date }) => Promise} register + * @property {(args: { email: string, password: string, ip?: string, device_id?: string }) => Promise} login + * @property {(args: { provider: 'google'|'apple', code: string, redirect_uri?: string, device_id?: string }) => Promise} oauthLogin + * @property {(args: { refresh_token: string }) => Promise<{ user: User|{id:string}, access_token: string, refresh_token: string }>} refresh + * @property {(args: { email: string, code: string, purpose: 'register'|'login'|'reset' }) => Promise<{ ok: true }>} verifyCode + * @property {(args: { email: string, purpose: 'register'|'login'|'reset', locale?: string, ip?: string }) => Promise<{ ok: true }>} sendCode + * @property {(args: { email: string, locale?: string, ip?: string }) => Promise<{ ok: true }>} requestPasswordReset + * @property {(args: { email: string, password: string }) => Promise<{ ok: true }>} resetPassword + * @property {(args: { user_id: string }) => Promise} getUser + * @property {(token: string) => Promise} verifyAccessToken + * @property {(args: { refresh_token: string }) => Promise<{ ok: true }>} logout + * @property {(args: { user_id: string }) => Promise<{ ok: true }>} revokeAllSessions + */ + +export { createAuth } from './auth.js' +export { createUserRepo } from './repo.js' +export { AuthError } from './errors.js' +export { createFakeUserRepo } from './fake-repo.js' +export { default as authDbSchema } from './db/tables/index.cjs' diff --git a/packages/auth/src/login.js b/packages/auth/src/login.js new file mode 100644 index 0000000..1e34965 --- /dev/null +++ b/packages/auth/src/login.js @@ -0,0 +1,55 @@ +/** + * 登录流程:防爆破前置检查 → hasher 验密 → 失败计数落库 / 成功签发会话。 + * + * 防爆破计数走注入 repo(不靠进程内 Map,多实例下仍生效);判定纯逻辑在 bruteforce.js。 + * 失败一律 INVALID_CREDENTIALS(不区分「用户不存在」与「密码错」以免账号枚举), + * 超阈值返 TOO_MANY_ATTEMPTS + retryAfterMs。 + */ + +import { AuthError } from './errors.js' +import { issueSession } from './session.js' +import { attemptKey, evaluateAttempts, DEFAULT_MAX_ATTEMPTS, DEFAULT_WINDOW_MS } from './bruteforce.js' + +/** + * @param {Object} deps + * @param {import('./index.js').UserRepo} deps.userRepo + * @param {import('./index.js').TokenSigner} deps.tokenSigner + * @param {import('./index.js').Hasher} deps.hasher + * @param {number} [deps.maxAttempts] + * @param {number} [deps.windowMs] + * @param {number} [deps.accessTtlMs] + * @param {number} [deps.refreshTtlMs] + * @returns {(args: { email: string, password: string, ip?: string, device_id?: string }) => Promise} + */ +export function makeLogin(deps) { + const { + userRepo, + hasher, + maxAttempts = DEFAULT_MAX_ATTEMPTS, + windowMs = DEFAULT_WINDOW_MS, + } = deps + + return async function login({ email, password, ip }) { + const normalized = String(email ?? '').trim().toLowerCase() + const key = attemptKey(normalized, ip) + + const recentCount = await userRepo.countRecentAttempts({ key, ip, windowMs }) + const gate = evaluateAttempts({ recentCount, maxAttempts, windowMs }) + if (gate.locked) { + throw new AuthError({ code: 'TOO_MANY_ATTEMPTS', retryAfterMs: gate.retryAfterMs }) + } + + const user = await userRepo.findByEmail(normalized) + const ok = user && user.password_hash + ? await hasher.verify(password, user.password_hash) + : false + + if (!ok || !user) { + await userRepo.recordAttempt({ key, ip, at: new Date() }) + throw new AuthError({ code: 'INVALID_CREDENTIALS' }) + } + + const issued = await issueSession(deps, { user_id: user.id }) + return { user, access_token: issued.access_token, refresh_token: issued.refresh_token } + } +} diff --git a/packages/auth/src/oauth.js b/packages/auth/src/oauth.js new file mode 100644 index 0000000..8b6421c --- /dev/null +++ b/packages/auth/src/oauth.js @@ -0,0 +1,81 @@ +/** + * OAuth 登录:经注入 oauth Port 换码 + 验签取邮箱 → 首登建号 / 已存在合并 → 签发会话。 + * + * 红线:包内不自写 JWKS 拉取 / RS256 验签 / id_token 解码——全在 oauth.exchange 内完成 + * (Google 走 google-auth-library 校验 id_token、Apple 走其 JWKS)。验签失败统一 + * OAUTH_VERIFY_FAILED。本文件只做 provider 校验 + 建号/合并的账户状态机。 + */ + +import { AuthError } from './errors.js' +import { issueSession } from './session.js' + +const SUPPORTED_PROVIDERS = new Set(['google', 'apple']) + +/** + * @param {Object} deps + * @param {import('./index.js').UserRepo} deps.userRepo + * @param {import('./index.js').OauthPort} deps.oauth + * @param {import('./index.js').TokenSigner} deps.tokenSigner + * @param {import('./index.js').Hasher} deps.hasher + * @param {number} [deps.accessTtlMs] + * @param {number} [deps.refreshTtlMs] + * @returns {(args: { provider: string, code: string, oauth_flow?: 'access_token'|'authorization_code', redirect_uri?: string, device_id?: string }) => Promise} + */ +export function makeOauthLogin(deps) { + const { userRepo, oauth } = deps + + return async function oauthLogin({ provider, code, oauth_flow = 'access_token', redirect_uri }) { + if (!SUPPORTED_PROVIDERS.has(provider)) { + throw new AuthError({ code: 'UNSUPPORTED_PROVIDER', message: `provider: ${provider}` }) + } + + let identity + try { + identity = await oauth.exchange({ + provider: /** @type {'google'|'apple'} */ (provider), + code, + oauth_flow, + redirect_uri, + }) + } catch (err) { + throw new AuthError({ code: 'OAUTH_VERIFY_FAILED', cause: err }) + } + if (!identity || !identity.email || !identity.provider_user_id) { + throw new AuthError({ code: 'OAUTH_VERIFY_FAILED' }) + } + + const email = String(identity.email).trim().toLowerCase() + + let user = await userRepo.findByEmail(email) + let is_new_user = false + if (!user) { + // 首登:自动建号并绑定 provider 身份。 + user = await userRepo.create({ + email, + oauth_providers: [{ provider, provider_user_id: identity.provider_user_id }], + ...(identity.email_verified ? { email_verified_at: new Date() } : {}), + }) + is_new_user = true + } else { + // 已存在:合并到同一 user(绑定该 provider,若未绑)。 + const linked = (user.oauth_providers ?? []).some( + (p) => p.provider === provider && p.provider_user_id === identity.provider_user_id, + ) + if (!linked) { + user = await userRepo.linkOauth({ + user_id: user.id, + provider, + provider_user_id: identity.provider_user_id, + }) + } + } + + const issued = await issueSession(deps, { user_id: user.id }) + return { + user, + access_token: issued.access_token, + refresh_token: issued.refresh_token, + is_new_user, + } + } +} diff --git a/packages/auth/src/refresh.js b/packages/auth/src/refresh.js new file mode 100644 index 0000000..e187192 --- /dev/null +++ b/packages/auth/src/refresh.js @@ -0,0 +1,61 @@ +/** + * refresh 轮转 + token-reuse 检测(本包最关键的安全路径)。 + * + * 正常轮转:旧 refresh 校验通过 → 立即标记 used → 沿用同一 family 签发新 access+refresh。 + * Token-reuse 检测:若提交的 refresh 对应会话已被标记 used(重放),视为凭据泄露, + * 抛 REFRESH_REUSED 并撤销整个会话族(family)全部 token——后续该族任何 refresh 全失效。 + * + * 密码学(refresh 比对)走注入 hasher,签发走 issueSession,本文件只做状态机决策。 + */ + +import { AuthError } from './errors.js' +import { issueSession, parseRefreshToken } from './session.js' + +/** + * @param {Object} deps + * @param {import('./index.js').UserRepo} deps.userRepo + * @param {import('./index.js').TokenSigner} deps.tokenSigner + * @param {import('./index.js').Hasher} deps.hasher + * @param {number} [deps.accessTtlMs] + * @param {number} [deps.refreshTtlMs] + * @returns {(args: { refresh_token: string }) => Promise<{ user: import('./index.js').User|{id:string}, access_token: string, refresh_token: string }>} + */ +export function makeRefresh(deps) { + const { userRepo, hasher } = deps + return async function refresh({ refresh_token }) { + const parsed = parseRefreshToken(refresh_token) + if (!parsed) throw new AuthError({ code: 'TOKEN_INVALID' }) + + const session = await userRepo.findSession({ sid: parsed.sid }) + if (!session || session.revoked) { + throw new AuthError({ code: 'TOKEN_INVALID' }) + } + + // 校验提交的 refresh 与落库 hash 匹配(防伪造 sid 重放)。 + const matches = await hasher.verify(parsed.secret, session.refresh_hash) + if (!matches) { + throw new AuthError({ code: 'TOKEN_INVALID' }) + } + + // Token-reuse:该会话的 refresh 已被用过却又被提交 → 凭据泄露,撤销整个会话族。 + if (session.used) { + await userRepo.revokeFamily({ family: session.family }) + throw new AuthError({ code: 'REFRESH_REUSED' }) + } + + if (Date.now() > session.expires_at.getTime()) { + throw new AuthError({ code: 'TOKEN_EXPIRED' }) + } + + // 正常轮转:旧 refresh 立即失效,沿用 family 签发新对。 + await userRepo.markRefreshUsed({ sid: session.sid }) + const issued = await issueSession(deps, { user_id: session.user_id, family: session.family }) + // 带回 user(前端 SK 门面映射 account 需要);findById 不可用时优雅降级为 { id }。 + let user = { id: session.user_id } + if (typeof userRepo.findById === 'function') { + const found = await userRepo.findById(session.user_id) + if (found) user = found + } + return { user, access_token: issued.access_token, refresh_token: issued.refresh_token } + } +} diff --git a/packages/auth/src/register.js b/packages/auth/src/register.js new file mode 100644 index 0000000..061ee2d --- /dev/null +++ b/packages/auth/src/register.js @@ -0,0 +1,40 @@ +/** + * 注册流程:密码经 hasher 哈希落库 + 首次签发会话。 + * + * 产品决策(§3 留口):本实现采用「注册即激活并签发会话」路径(欧美刷剧 MVP 追求转化、 + * 降低试看→注册摩擦)。验证码确认走独立 sendCode/verifyCode(用于敏感操作 / reset), + * 不阻塞注册主路径。库内 grep 不到明文密码持久化、不到自写哈希循环(全走 hasher)。 + */ + +import { AuthError } from './errors.js' +import { issueSession } from './session.js' + +/** + * @param {Object} deps + * @param {import('./index.js').UserRepo} deps.userRepo + * @param {import('./index.js').TokenSigner} deps.tokenSigner + * @param {import('./index.js').Hasher} deps.hasher + * @param {number} [deps.accessTtlMs] + * @param {number} [deps.refreshTtlMs] + * @returns {(args: { email: string, password: string, locale?: string, email_verified_at?: Date }) => Promise} + */ +export function makeRegister(deps) { + const { userRepo, hasher } = deps + return async function register({ email, password, locale, email_verified_at }) { + const normalized = String(email ?? '').trim().toLowerCase() + if (!normalized || !password) { + throw new AuthError({ code: 'INVALID_CREDENTIALS', message: 'email and password required' }) + } + + const existing = await userRepo.findByEmail(normalized) + if (existing) { + throw new AuthError({ code: 'EMAIL_TAKEN' }) + } + + const password_hash = await hasher.hash(password) + const user = await userRepo.create({ email: normalized, password_hash, locale, email_verified_at }) + + const issued = await issueSession(deps, { user_id: user.id }) + return { user, access_token: issued.access_token, refresh_token: issued.refresh_token } + } +} diff --git a/packages/auth/src/repo.js b/packages/auth/src/repo.js new file mode 100644 index 0000000..37db9e8 --- /dev/null +++ b/packages/auth/src/repo.js @@ -0,0 +1,208 @@ +/** + * createUserRepo(db) —— 本包唯一写 SQL 的地方,db(Knex 实例)由 composition root 注入。 + * + * 表结构定义在 `src/db/tables/`(users 归 `@drama/db`),本包不建连接: + * users(id, email, password_hash, locale, email_verified_at, created_at) + * user_oauth(user_id, provider, provider_user_id) + * auth_sessions(sid, user_id, refresh_hash, family, expires_at, used, revoked) + * auth_attempts(key, ip, at) + * auth_codes(email, purpose, code_hash, expires_at, attempts) + * + * 字段纪律(plan/db-root-solution.md 决定 2/3):出入参、返回对象与列名同名透传(snake_case); + * 时间列是 DATETIME(3) UTC,进出都是 JS Date。 + * + * 仅落库状态:会话族、refresh hash、防爆破计数、验证码 hash。密码学不在此处。 + */ + +import { AuthError } from './errors.js' +import authAttemptsTable from './db/tables/auth_attempts.cjs' +import authCodesTable from './db/tables/auth_codes.cjs' +import authSessionsTable from './db/tables/auth_sessions.cjs' +import userOauthTable from './db/tables/user_oauth.cjs' + +const USERS = 'users' +const USER_OAUTH = userOauthTable.tableName +const AUTH_SESSIONS = authSessionsTable.tableName +const AUTH_ATTEMPTS = authAttemptsTable.tableName +const AUTH_CODES = authCodesTable.tableName + +/** + * @param {import('knex').Knex} db + * @returns {import('./index.js').UserRepo} + */ +export function createUserRepo(db) { + if (!db || typeof db !== 'function') { + throw new AuthError({ code: 'USER_NOT_FOUND', message: 'createUserRepo: db (Knex) required' }) + } + + /** @param {any} row @param {any[]} [oauthRows] @returns {import('./index.js').User} */ + function rowToUser(row, oauthRows = []) { + return { + id: String(row.id), + email: row.email, + password_hash: row.password_hash ?? undefined, + locale: row.locale ?? undefined, + role: row.role ?? undefined, + email_verified_at: row.email_verified_at ?? undefined, + oauth_providers: oauthRows.map((o) => ({ + provider: o.provider, + provider_user_id: o.provider_user_id, + })), + } + } + + /** @param {string} user_id */ + async function loadOauth(user_id) { + return db(USER_OAUTH).where({ user_id }).select('provider', 'provider_user_id') + } + + return { + async findByEmail(email) { + const row = await db(USERS).where({ email: norm(email) }).first() + if (!row) return null + return rowToUser(row, await loadOauth(row.id)) + }, + + async findById(id) { + const row = await db(USERS).where({ id }).first() + if (!row) return null + return rowToUser(row, await loadOauth(row.id)) + }, + + async create({ email, password_hash, oauth_providers, locale, email_verified_at }) { + const id = newId() + try { + await db(USERS).insert({ + id, + email: norm(email), + password_hash: password_hash ?? null, + locale: locale ?? null, + email_verified_at: email_verified_at ?? null, + created_at: new Date(), + }) + } catch (error) { + if (error.code === 'ER_DUP_ENTRY') throw new AuthError({ code: 'EMAIL_TAKEN' }) + throw error + } + if (oauth_providers && oauth_providers.length) { + await db(USER_OAUTH).insert( + oauth_providers.map((p) => ({ + user_id: id, + provider: p.provider, + provider_user_id: p.provider_user_id, + })), + ) + } + const row = await db(USERS).where({ id }).first() + return rowToUser(row, await loadOauth(id)) + }, + + async linkOauth({ user_id, provider, provider_user_id }) { + const exists = await db(USER_OAUTH) + .where({ user_id, provider, provider_user_id }) + .first() + if (!exists) { + await db(USER_OAUTH).insert({ user_id, provider, provider_user_id }) + } + const row = await db(USERS).where({ id: user_id }).first() + if (!row) throw new AuthError({ code: 'USER_NOT_FOUND' }) + return rowToUser(row, await loadOauth(user_id)) + }, + + async updatePasswordHash({ user_id, password_hash }) { + const count = await db(USERS) + .where({ id: user_id }) + .update({ password_hash, updated_at: new Date() }) + if (!count) throw new AuthError({ code: 'USER_NOT_FOUND' }) + }, + + async saveSession({ user_id, sid, refresh_hash, family, expires_at }) { + await db(AUTH_SESSIONS).insert({ + sid, + user_id, + refresh_hash, + family, + expires_at, + used: false, + revoked: false, + }) + }, + + async findSession({ sid }) { + const row = await db(AUTH_SESSIONS).where({ sid }).first() + if (!row) return null + return { + sid: row.sid, + user_id: String(row.user_id), + refresh_hash: row.refresh_hash, + family: row.family, + expires_at: row.expires_at, + used: Boolean(row.used), + revoked: Boolean(row.revoked), + } + }, + + async markRefreshUsed({ sid }) { + await db(AUTH_SESSIONS).where({ sid }).update({ used: true }) + }, + + async revokeFamily({ family }) { + await db(AUTH_SESSIONS).where({ family }).update({ revoked: true }) + }, + + async revokeUserSessions({ user_id }) { + await db(AUTH_SESSIONS).where({ user_id }).update({ revoked: true }) + }, + + async recordAttempt({ key, ip, at }) { + await db(AUTH_ATTEMPTS).insert({ key, ip: ip ?? null, at }) + }, + + async countRecentAttempts({ key, windowMs }) { + const since = new Date(Date.now() - windowMs) + const res = await db(AUTH_ATTEMPTS) + .where({ key }) + .andWhere('at', '>=', since) + .count({ c: '*' }) + .first() + return Number(res?.c ?? 0) + }, + + async saveCode({ email, purpose, code_hash, expires_at }) { + // 同 email+purpose 只保留最新一条:先删旧。 + await db(AUTH_CODES).where({ email: norm(email), purpose }).del() + await db(AUTH_CODES).insert({ + email: norm(email), + purpose, + code_hash, + expires_at, + attempts: 0, + }) + }, + + async consumeCode({ email, purpose }) { + const row = await db(AUTH_CODES).where({ email: norm(email), purpose }).first() + if (!row) return null + // 读取即递增尝试次数(消费语义),并把超额删除留给下次 saveCode。 + await db(AUTH_CODES) + .where({ email: norm(email), purpose }) + .update({ attempts: Number(row.attempts) + 1 }) + return { + email: row.email, + purpose: row.purpose, + code_hash: row.code_hash, + expires_at: row.expires_at, + attempts: Number(row.attempts), + } + }, + } +} + +/** @param {string} e */ +function norm(e) { + return String(e ?? '').trim().toLowerCase() +} + +function newId() { + return globalThis.crypto.randomUUID() +} diff --git a/packages/auth/src/request-password-reset.js b/packages/auth/src/request-password-reset.js new file mode 100644 index 0000000..c3e63fb --- /dev/null +++ b/packages/auth/src/request-password-reset.js @@ -0,0 +1,26 @@ +/** + * 请求密码重置:只有已注册邮箱可以接收重置验证码。 + */ + +import { AuthError } from './errors.js' + +/** + * @param {Object} deps + * @param {import('./index.js').UserRepo} deps.userRepo + * @param {(args: { email: string, purpose: 'reset', locale?: string, ip?: string }) => Promise<{ ok: true }>} deps.sendCode + * @returns {(args: { email: string, locale?: string, ip?: string }) => Promise<{ ok: true }>} + */ +export function makeRequestPasswordReset({ userRepo, sendCode }) { + return async function requestPasswordReset({ email, locale, ip }) { + const normalized = String(email).trim().toLowerCase() + const user = await userRepo.findByEmail(normalized) + if (!user) throw new AuthError({ code: 'USER_NOT_FOUND' }) + + return sendCode({ + email: normalized, + purpose: 'reset', + locale, + ip, + }) + } +} diff --git a/packages/auth/src/reset-password.js b/packages/auth/src/reset-password.js new file mode 100644 index 0000000..ca6d0b2 --- /dev/null +++ b/packages/auth/src/reset-password.js @@ -0,0 +1,28 @@ +/** + * 密码重置:验证码校验由调用方先走 verifyCode,本流程只负责更新密码并撤销旧会话。 + */ + +import { AuthError } from './errors.js' + +/** + * @param {Object} deps + * @param {import('./index.js').UserRepo} deps.userRepo + * @param {import('./index.js').Hasher} deps.hasher + * @returns {(args: { email: string, password: string }) => Promise<{ ok: true }>} + */ +export function makeResetPassword({ userRepo, hasher }) { + return async function resetPassword({ email, password }) { + const normalized = String(email ?? '').trim().toLowerCase() + if (!normalized || !password) { + throw new AuthError({ code: 'INVALID_CREDENTIALS', message: 'email and password required' }) + } + + const user = await userRepo.findByEmail(normalized) + if (!user) throw new AuthError({ code: 'USER_NOT_FOUND' }) + + const password_hash = await hasher.hash(password) + await userRepo.updatePasswordHash({ user_id: user.id, password_hash }) + await userRepo.revokeUserSessions({ user_id: user.id }) + return { ok: true } + } +} diff --git a/packages/auth/src/session.js b/packages/auth/src/session.js new file mode 100644 index 0000000..43adb9a --- /dev/null +++ b/packages/auth/src/session.js @@ -0,0 +1,64 @@ +/** + * 会话与 token 签发的共享编排 —— register / login / oauth / refresh 共用。 + * + * 一次签发产出一对 access + refresh:access 是无状态 JWT(claims 含 sub/sid), + * refresh 是不透明随机串,其哈希连同会话族(family)落库以支持轮转与 token-reuse 检测。 + * 签名/哈希全走注入 Port,本文件不碰密码学,只生成随机标识与组织 claims。 + */ + +const DEFAULT_ACCESS_TTL_MS = 15 * 60 * 1000 // 15 分钟 +const DEFAULT_REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天 + +/** 生成不透明随机标识(CSPRNG,非自研)。 @returns {string} */ +export function randomId() { + return globalThis.crypto.randomUUID().replace(/-/g, '') +} + +/** + * 为某 user + 会话族签发新 access+refresh,并把会话落库。 + * 用于首次签发(family 新建)与轮转(沿用旧 family)。 + * + * @param {Object} deps + * @param {import('./index.js').UserRepo} deps.userRepo + * @param {import('./index.js').TokenSigner} deps.tokenSigner + * @param {import('./index.js').Hasher} deps.hasher + * @param {number} [deps.accessTtlMs] + * @param {number} [deps.refreshTtlMs] + * @param {Object} args + * @param {string} args.user_id + * @param {string} [args.family] 轮转时沿用旧族;缺省新建。 + * @returns {Promise<{ access_token: string, refresh_token: string, sid: string, family: string }>} + */ +export async function issueSession( + { userRepo, tokenSigner, hasher, accessTtlMs = DEFAULT_ACCESS_TTL_MS, refreshTtlMs = DEFAULT_REFRESH_TTL_MS }, + { user_id, family }, +) { + const sid = randomId() + const fam = family ?? randomId() + const refreshSecret = randomId() + randomId() + const refresh_hash = await hasher.hash(refreshSecret) + const expires_at = new Date(Date.now() + refreshTtlMs) + + await userRepo.saveSession({ user_id, sid, refresh_hash, family: fam, expires_at }) + + const access_token = await tokenSigner.sign( + { sub: user_id, sid, fam }, + { ttlMs: accessTtlMs }, + ) + // refresh 客户端态需携带 sid 以便 refresh 时定位会话(不暴露 hash)。 + return { access_token, refresh_token: `${sid}.${refreshSecret}`, sid, family: fam } +} + +/** + * 拆包客户端 refresh token → { sid, secret }。格式非法返 null。 + * @param {string} token + * @returns {{ sid: string, secret: string }|null} + */ +export function parseRefreshToken(token) { + if (typeof token !== 'string') return null + const idx = token.indexOf('.') + if (idx <= 0 || idx === token.length - 1) return null + return { sid: token.slice(0, idx), secret: token.slice(idx + 1) } +} + +export { DEFAULT_ACCESS_TTL_MS, DEFAULT_REFRESH_TTL_MS } diff --git a/packages/auth/test/bruteforce.test.js b/packages/auth/test/bruteforce.test.js new file mode 100644 index 0000000..42a0db2 --- /dev/null +++ b/packages/auth/test/bruteforce.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { + attemptKey, + evaluateAttempts, + DEFAULT_MAX_ATTEMPTS, + DEFAULT_WINDOW_MS, +} from '../src/bruteforce.js' + +describe('attemptKey', () => { + it('normalizes email and joins ip', () => { + expect(attemptKey(' A@B.com ', '1.2.3.4')).toBe('a@b.com|1.2.3.4') + }) + it('omits ip when absent', () => { + expect(attemptKey('a@b.com')).toBe('a@b.com') + }) +}) + +describe('evaluateAttempts', () => { + it('not locked below threshold', () => { + const r = evaluateAttempts({ recentCount: DEFAULT_MAX_ATTEMPTS - 1 }) + expect(r.locked).toBe(false) + expect(r.retryAfterMs).toBe(0) + }) + + it('locked at threshold, retryAfter = full window without oldestAt', () => { + const r = evaluateAttempts({ recentCount: DEFAULT_MAX_ATTEMPTS }) + expect(r.locked).toBe(true) + expect(r.retryAfterMs).toBe(DEFAULT_WINDOW_MS) + }) + + it('precise retryAfter when oldestAt given', () => { + const now = 1_000_000 + const oldestAt = now - 60_000 + const windowMs = 100_000 + const r = evaluateAttempts({ recentCount: 5, maxAttempts: 5, windowMs, oldestAt, now }) + expect(r.locked).toBe(true) + expect(r.retryAfterMs).toBe(40_000) + }) + + it('clamps retryAfter to 0 when oldest already aged out', () => { + const now = 1_000_000 + const r = evaluateAttempts({ + recentCount: 5, + maxAttempts: 5, + windowMs: 10_000, + oldestAt: now - 50_000, + now, + }) + expect(r.retryAfterMs).toBe(0) + }) + + it('respects custom maxAttempts', () => { + expect(evaluateAttempts({ recentCount: 2, maxAttempts: 3 }).locked).toBe(false) + expect(evaluateAttempts({ recentCount: 3, maxAttempts: 3 }).locked).toBe(true) + }) +}) diff --git a/packages/auth/test/fakes.js b/packages/auth/test/fakes.js new file mode 100644 index 0000000..cc512b9 --- /dev/null +++ b/packages/auth/test/fakes.js @@ -0,0 +1,66 @@ +/** + * 测试用 fake Port —— 无密码学副作用的薄替身(断言「编排走 Port」而非自研)。 + * 仅测试目录引用,不进公共面。 + */ + +/** fake hasher:哈希=可逆前缀(仅测试,证明编排调用了 hash/verify)。 */ +export function createFakeHasher() { + const calls = { hash: 0, verify: 0 } + return { + calls, + async hash(plain) { + calls.hash += 1 + return `h$${plain}` + }, + async verify(plain, hash) { + calls.verify += 1 + return hash === `h$${plain}` + }, + } +} + +/** fake tokenSigner:把 claims JSON 编码为 token(含 ttl 派生 exp)。 */ +export function createFakeSigner() { + return { + async sign(claims, { ttlMs }) { + const exp = Date.now() + ttlMs + return `tok.${Buffer.from(JSON.stringify({ ...claims, exp })).toString('base64url')}` + }, + async verify(token) { + if (typeof token !== 'string' || !token.startsWith('tok.')) { + const e = new Error('bad token') + e.code = 'ERR_JWT_INVALID' + throw e + } + const claims = JSON.parse(Buffer.from(token.slice(4), 'base64url').toString('utf8')) + if (claims.exp && Date.now() > claims.exp) { + const e = new Error('expired') + e.code = 'ERR_JWT_EXPIRED' + throw e + } + return claims + }, + } +} + +/** fake oauth:按注入表返回身份,或抛错模拟验签失败。 */ +export function createFakeOauth(table = {}, { throwOn } = {}) { + return { + async exchange({ provider, code }) { + if (throwOn && throwOn === code) throw new Error('verify failed') + const entry = table[`${provider}:${code}`] + if (!entry) throw new Error('unknown code') + return entry + }, + } +} + +/** fake email:记录投递的验证码,供断言。 */ +export function createFakeEmail(sink = []) { + return { + sent: sink, + async sendCode(args) { + sink.push(args) + }, + } +} diff --git a/packages/auth/test/oauth.test.js b/packages/auth/test/oauth.test.js new file mode 100644 index 0000000..1719238 --- /dev/null +++ b/packages/auth/test/oauth.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { createAuth, createFakeUserRepo } from '../src/index.js' +import { createFakeHasher, createFakeSigner, createFakeOauth, createFakeEmail } from './fakes.js' + +function build(oauth) { + const userRepo = createFakeUserRepo() + const auth = createAuth({ + userRepo, + hasher: createFakeHasher(), + tokenSigner: createFakeSigner(), + oauth, + email: createFakeEmail(), + }) + return { userRepo, auth } +} + +describe('oauthLogin', () => { + it('first login auto-creates user (is_new_user true)', async () => { + const oauth = createFakeOauth({ + 'google:CODE1': { provider_user_id: 'g-1', email: 'g@x.com', email_verified: true }, + }) + const { auth, userRepo } = build(oauth) + const res = await auth.oauthLogin({ provider: 'google', code: 'CODE1', redirect_uri: 'https://app/cb' }) + expect(res.is_new_user).toBe(true) + expect(res.user.email).toBe('g@x.com') + expect(res.access_token).toBeTruthy() + const stored = await userRepo.findByEmail('g@x.com') + expect(stored.oauth_providers).toContainEqual({ provider: 'google', provider_user_id: 'g-1' }) + }) + + it('existing email merges into same user (is_new_user false)', async () => { + const oauth = createFakeOauth({ + 'google:CODE1': { provider_user_id: 'g-1', email: 'merge@x.com', email_verified: true }, + }) + const { auth, userRepo } = build(oauth) + // pre-existing password user with same email + await userRepo.create({ email: 'merge@x.com', password_hash: 'h$pw' }) + const res = await auth.oauthLogin({ provider: 'google', code: 'CODE1' }) + expect(res.is_new_user).toBe(false) + const stored = await userRepo.findByEmail('merge@x.com') + expect(stored.oauth_providers).toContainEqual({ provider: 'google', provider_user_id: 'g-1' }) + }) + + it('verify failure -> OAUTH_VERIFY_FAILED', async () => { + const oauth = createFakeOauth({}, { throwOn: 'BAD' }) + const { auth } = build(oauth) + await expect(auth.oauthLogin({ provider: 'apple', code: 'BAD' })) + .rejects.toMatchObject({ code: 'OAUTH_VERIFY_FAILED' }) + }) + + it('unsupported provider rejected', async () => { + const { auth } = build(createFakeOauth()) + await expect(auth.oauthLogin({ provider: 'wechat', code: 'x' })) + .rejects.toMatchObject({ code: 'UNSUPPORTED_PROVIDER' }) + }) +}) diff --git a/packages/billing/package.json b/packages/billing/package.json new file mode 100644 index 0000000..e47482d --- /dev/null +++ b/packages/billing/package.json @@ -0,0 +1,15 @@ +{ + "name": "@drama/billing", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "knex": "^3.1.0" + } +} diff --git a/packages/billing/src/action-policies.js b/packages/billing/src/action-policies.js new file mode 100644 index 0000000..15252d7 --- /dev/null +++ b/packages/billing/src/action-policies.js @@ -0,0 +1,34 @@ +export const BillingAction = Object.freeze({ + DAILY_CLAIM: 'daily_claim', + TURN_PLAY: 'turn_play', + FULL_RUN_VIDEO: 'full_run_video', + SIGNUP_REWARD: 'signup_reward', +}) + +export function createActionPolicies(repo) { + async function find(action) { + assertActionName(action) + return repo.findActionPolicy(action) + } + + async function get(action) { + const policy = await find(action) + if (policy === null) throw new Error(`Missing billing_action_policy, action=${action}`) + return policy + } + + async function list() { + return repo.listActionPolicies() + } + + async function update(policy) { + assertActionName(policy.action) + return repo.updateActionPolicy(policy) + } + + return { find, get, list, update } +} + +function assertActionName(action) { + if (typeof action !== 'string' || action === '') throw new Error(`Invalid billing action: ${String(action)}`) +} diff --git a/packages/billing/src/billing.js b/packages/billing/src/billing.js new file mode 100644 index 0000000..96852f2 --- /dev/null +++ b/packages/billing/src/billing.js @@ -0,0 +1,110 @@ +/** + * `billing.js` —— `createBilling`:装配各能力 + 暴露 `asBillingPort`(§3 唯一编排入口)。 + * + * 只做装配:注入 `billingRepo` → 组出 ledger(deduct/grant)、paywall、products、port。 + * 装配时即自检(少注入 billingRepo 当场 assert,不拖到运行某行——架构 §10.3)。 + * 不读 env、不建连接。 + * + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo + * @typedef {import('./port.js').BillingPort} BillingPort + */ + +import { createLedger } from './ledger.js' +import { createActionPolicies } from './action-policies.js' +import { createPaywall } from './paywall.js' +import { createProducts } from './products.js' +import { asBillingPort } from './port.js' +import { createModelBilling } from './model-billing.js' +import { createDailyClaim } from './daily-claim.js' +import { createCommerceOperations } from './commerce-operations.js' +import { createSignupReward } from './signup-reward.js' + +/** + * 工厂:唯一入口(§3)。 + * @param {object} [deps] + * @param {BillingRepo} [deps.billingRepo] 注入真实数据库仓库(createBillingRepo) + * @returns {{ + * deduct:Function, grant:Function, reversePayment:Function, balance:Function, ledger:Function, findLedgerByIdempotencyKey:Function, + * settleModelBilling:Function, + * paywall:{ check:Function, unlock:Function, listUnlocked:Function }, + * products:{ list:Function, get:Function, save:Function }, + * withTransaction?:Function, + * asBillingPort:() => BillingPort + * }} + */ +export function createBilling({ billingRepo, onDeductCommitted = null } = {}) { + if (!billingRepo || typeof billingRepo.applyDelta !== 'function') { + throw new Error('[billing] createBilling 需要注入 billingRepo') + } + /** @type {BillingRepo} */ + const repo = billingRepo + + const ledgerOperations = createLedger(repo) + async function deduct(input) { + const result = await ledgerOperations.deduct(input) + if (!result.replayed && onDeductCommitted !== null) { + repo.afterCommit(() => onDeductCommitted({ + user_id: input.user_id, + balance_after: result.balance_after, + ledger_id: result.ledger_id, + })) + } + return result + } + const { grant, reversePayment } = ledgerOperations + const actionPolicies = createActionPolicies(repo) + const settleModelBilling = createModelBilling({ deduct }) + const paywall = createPaywall({ billingRepo: repo, deduct }) + const products = createProducts(repo) + + /** + * 当前余额。 + * @param {{ user_id:string }} p + * @returns {Promise<{ points:number }>} + */ + async function balance({ user_id }) { + const points = await repo.getBalance(user_id) + return { points: Number(points) } + } + + /** + * 可审计流水(按时间倒序,支持类型/时间过滤与分页)。 + * @param {object} p + * @param {string} p.user_id + * @param {{ type?:string, since?:string, until?:string, limit?:number, cursor?:string }} [p.filter] + * @returns {Promise<{ items:any[], next_cursor:string|null }>} + */ + async function ledger({ user_id, filter }) { + return repo.listLedger(user_id, filter ?? {}) + } + + async function findLedgerByIdempotencyKey(key) { + return repo.findLedgerByIdempotencyKey(key) + } + + function withTransaction(trx) { + if (typeof repo.forTransaction !== 'function') { + throw new Error('[billing] 当前 billingRepo 不支持事务绑定') + } + return createBilling({ billingRepo: repo.forTransaction(trx), onDeductCommitted }) + } + + const billing = { + deduct, + grant, + reversePayment, + balance, + ledger, + findLedgerByIdempotencyKey, + settleModelBilling, + paywall, + products, + actionPolicies, + withTransaction, + asBillingPort: () => asBillingPort({ deduct }), + } + billing.dailyClaim = createDailyClaim(billing) + billing.signupReward = createSignupReward(billing) + billing.commerce = createCommerceOperations(billing) + return billing +} diff --git a/packages/billing/src/calendar-day.js b/packages/billing/src/calendar-day.js new file mode 100644 index 0000000..fd8c2c5 --- /dev/null +++ b/packages/billing/src/calendar-day.js @@ -0,0 +1,59 @@ +/** + * 「某时区的自然日」三件套。每日领取按 America/Los_Angeles 自然日结算,商业化看板按同一口径分桶, + * 两边必须用同一份换算,否则领取日和报表日会差一天。 + */ + +const DAY_FORMATTERS = new Map() + +function dayFormatter(timezone) { + let formatter = DAY_FORMATTERS.get(timezone) + if (formatter === undefined) { + formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }) + DAY_FORMATTERS.set(timezone, formatter) + } + return formatter +} + +/** + * 一个时刻落在 `timezone` 的哪个自然日。 + * @param {Date} instant + * @param {string} timezone IANA 时区名 + * @returns {string} `YYYY-MM-DD` + */ +export function calendarDayOf(instant, timezone) { + const value = Object.fromEntries(dayFormatter(timezone).formatToParts(instant).map(part => [part.type, part.value])) + return `${value.year}-${value.month}-${value.day}` +} + +/** + * `timezone` 里某个自然日的零点对应的 UTC 时刻。 + * 先按 UTC 零点猜一个时刻,读出该时刻在目标时区的偏移再减回去;夏令时切换日的偏移以那一刻为准。 + * @param {string} date `YYYY-MM-DD` + * @param {string} timezone IANA 时区名 + * @returns {Date} + */ +export function midnightInTimezone(date, timezone) { + const guess = new Date(`${date}T00:00:00.000Z`) + const offset = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'longOffset' }) + .formatToParts(guess) + .find(part => part.type === 'timeZoneName') + .value + const match = /^GMT(?:([+-])(\d\d):(\d\d))?$/.exec(offset) + if (match === null) throw new Error(`Cannot resolve timezone offset: ${timezone}`) + if (match[1] === undefined) return guess + const minutes = Number(match[2]) * 60 + Number(match[3]) + const signedMinutes = match[1] === '+' ? minutes : -minutes + return new Date(guess.getTime() - signedMinutes * 60 * 1000) +} + +/** + * 自然日加减天数,纯日历运算,不涉及时区。 + * @param {string} date `YYYY-MM-DD` + * @param {number} days + * @returns {string} + */ +export function shiftCalendarDay(date, days) { + const at = new Date(`${date}T00:00:00.000Z`) + at.setUTCDate(at.getUTCDate() + days) + return at.toISOString().slice(0, 10) +} diff --git a/packages/billing/src/commerce-operations.js b/packages/billing/src/commerce-operations.js new file mode 100644 index 0000000..662846c --- /dev/null +++ b/packages/billing/src/commerce-operations.js @@ -0,0 +1,20 @@ +import { BillingAction } from './action-policies.js' + +/** @param {{ balance:Function, actionPolicies:{ get:Function }, dailyClaim:{ status:Function } }} billing */ +export function createCommerceOperations(billing) { + async function summary({ user_id, now = new Date() }) { + const [balance, turn, daily_claim] = await Promise.all([ + billing.balance({ user_id }), + billing.actionPolicies.get(BillingAction.TURN_PLAY), + billing.dailyClaim.status({ user_id, now }), + ]) + if (!turn.enabled) throw new Error('turn_play is disabled') + return { + points: balance.points, + playable_turns: Math.floor(balance.points / turn.points), + action_prices: { turn_play: turn.points }, + daily_claim, + } + } + return { summary } +} diff --git a/packages/billing/src/daily-claim.js b/packages/billing/src/daily-claim.js new file mode 100644 index 0000000..7bc5a53 --- /dev/null +++ b/packages/billing/src/daily-claim.js @@ -0,0 +1,50 @@ +import { BillingAction } from './action-policies.js' +import { calendarDayOf, midnightInTimezone, shiftCalendarDay } from './calendar-day.js' + +/** @param {{ actionPolicies:{ get:Function }, grant:Function, balance:Function }} billing */ +export function createDailyClaim(billing) { + async function claim({ user_id, now = new Date() }) { + const policy = await billing.actionPolicies.get(BillingAction.DAILY_CLAIM) + if (!policy.enabled) throw new Error('daily_claim is disabled') + const window = losAngelesDay(now, policy.timezone) + const idempotency_key = `daily_claim:${user_id}:${window.date}` + const granted = await billing.grant({ + user_id, + amount: policy.points, + source: 'daily_reward', + credit_kind: 'daily', + reason: 'daily_claim', + ref_type: 'daily_claim', + ref_id: window.date, + idempotency_key, + }) + return { + ...(await billing.balance({ user_id })), + claimed: !granted.replayed, + claimed_today: true, + next_available_at: window.next_available_at, + } + } + + async function status({ user_id, now = new Date() }) { + const policy = await billing.actionPolicies.get(BillingAction.DAILY_CLAIM) + const window = losAngelesDay(now, policy.timezone) + const entry = await billing.findLedgerByIdempotencyKey(`daily_claim:${user_id}:${window.date}`) + return { + claimed_today: entry !== null, + next_available_at: window.next_available_at, + points: policy.points, + } + } + + return { claim, status } +} + +function losAngelesDay(now, timezone) { + if (timezone !== 'America/Los_Angeles') throw new Error(`Unsupported daily claim timezone: ${timezone}`) + const date = calendarDayOf(now, timezone) + return { + date, + next_available_at: midnightInTimezone(shiftCalendarDay(date, 1), timezone).toISOString(), + } +} diff --git a/packages/billing/src/db/README.md b/packages/billing/src/db/README.md new file mode 100644 index 0000000..35c94e0 --- /dev/null +++ b/packages/billing/src/db/README.md @@ -0,0 +1,23 @@ +# @drama/billing Database Schema + +这里是 `@drama/billing` 当前表结构的唯一阅读入口。 + +- `tables/`:一个表一个文件,文件名等于表名。 +- `tables/index.cjs`:批量加载入口,只给显式初始化/测试/校验工具使用,不是人工阅读入口。 + +## 表 + +- `tables/billing_product.cjs` +- `tables/billing_ledger.cjs` +- `tables/billing_unlock.cjs` +- `tables/billing_free_quota.cjs` +- `tables/billing_paywall_rule.cjs` +- `tables/points_balance_view.cjs` + +## Stripe Price ID 约定 + +`billing_product.stripe_price_id` 是商品目录到 Stripe 的唯一绑定字段。它必须保存 Stripe 测试/生产环境真实存在的 `price_...`,不能保存本地占位值并期待 `@drama/payment` 自动转换。 + +`@drama/payment` 创建 Checkout Session 时只按 `productId` 读取 `billing.products.get(productId)`,然后把 `stripe_price_id` 作为 Stripe `line_items[].price` 传给官方 SDK。 + +本地 Stripe 测试流程见 `packages/payment/docs/stripe/README.md`。 diff --git a/packages/billing/src/db/index.js b/packages/billing/src/db/index.js new file mode 100644 index 0000000..cb3df9f --- /dev/null +++ b/packages/billing/src/db/index.js @@ -0,0 +1,5 @@ +export { default as billingActionPolicyTable } from './tables/billing_action_policy.cjs' +export { default as billingFreeQuotaTable } from './tables/billing_free_quota.cjs' +export { default as billingPaywallRuleTable } from './tables/billing_paywall_rule.cjs' +export { default as billingProductTable } from './tables/billing_product.cjs' +export { default as billingUnlockTable } from './tables/billing_unlock.cjs' diff --git a/packages/billing/src/db/tables/billing_action_policy.cjs b/packages/billing/src/db/tables/billing_action_policy.cjs new file mode 100644 index 0000000..2e5c956 --- /dev/null +++ b/packages/billing/src/db/tables/billing_action_policy.cjs @@ -0,0 +1,18 @@ +const tableName = 'billing_action_policy' + +function buildTable(t) { + t.string('action', 48).primary() + t.bigInteger('points').unsigned().notNullable() + t.string('period_kind', 16).notNullable() + t.string('timezone', 64).nullable() + t.boolean('enabled').notNullable() + t.string('updated_by', 64).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('updated_at', { precision: 3 }).notNullable() + t.check("action <> ''", [], 'ck_billing_action_policy_action') + t.check('points > 0', [], 'ck_billing_action_policy_points') + t.check("period_kind in ('daily','none')", [], 'ck_billing_action_policy_period_kind') + t.check("(period_kind = 'daily' and timezone is not null) or (period_kind = 'none' and timezone is null)", [], 'ck_billing_action_policy_shape') +} + +module.exports = { tableName, buildTable } diff --git a/packages/billing/src/db/tables/billing_free_quota.cjs b/packages/billing/src/db/tables/billing_free_quota.cjs new file mode 100644 index 0000000..32a0041 --- /dev/null +++ b/packages/billing/src/db/tables/billing_free_quota.cjs @@ -0,0 +1,12 @@ +const tableName = 'billing_free_quota' + +function buildTable(t) { + t.string('user_id', 64).notNullable() + t.string('content_id', 96).notNullable() + t.string('kind', 32).notNullable() + t.datetime('window_start', { precision: 3 }).notNullable() + t.integer('used').notNullable().defaultTo(0) + t.primary(['user_id', 'content_id', 'kind', 'window_start']) +} + +module.exports = { tableName, buildTable } diff --git a/packages/billing/src/db/tables/billing_ledger.cjs b/packages/billing/src/db/tables/billing_ledger.cjs new file mode 100644 index 0000000..9c82b24 --- /dev/null +++ b/packages/billing/src/db/tables/billing_ledger.cjs @@ -0,0 +1,31 @@ +const tableName = 'billing_ledger' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('user_id', 64).notNullable() + t.string('type', 24).notNullable() + t.bigInteger('amount').notNullable() + t.string('reason', 96).nullable() + t.string('source', 32).notNullable() + t.string('credit_kind', 24).nullable() + t.string('ref_type', 64).nullable() + t.string('ref_id', 191).nullable() + t.text('note').nullable() + t.string('failure_code', 64).nullable() + t.bigInteger('balance_after').notNullable().defaultTo(0) + t.string('idempotency_key', 191).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['user_id', 'created_at'], 'idx_billing_ledger_user_time') + t.index(['ref_type', 'ref_id'], 'idx_billing_ledger_ref') + t.index(['source', 'created_at', 'id'], 'idx_billing_ledger_source_time') + t.index(['reason', 'created_at', 'id'], 'idx_billing_ledger_reason_time') + t.unique(['idempotency_key'], { indexName: 'uq_billing_ledger_idem' }) + t.check("type in ('grant','deduct')", [], 'ck_billing_ledger_type') + 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') + 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') + t.check("reason <> 'admin_adjustment' or note is not null", [], 'ck_billing_ledger_admin_note') + t.check("reason not in ('turn_play_refund','full_run_video_refund') or failure_code is not null", [], 'ck_billing_ledger_refund_failure_code') + 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') +} + +module.exports = { tableName, buildTable } diff --git a/packages/billing/src/db/tables/billing_paywall_rule.cjs b/packages/billing/src/db/tables/billing_paywall_rule.cjs new file mode 100644 index 0000000..d22fa8c --- /dev/null +++ b/packages/billing/src/db/tables/billing_paywall_rule.cjs @@ -0,0 +1,12 @@ +const tableName = 'billing_paywall_rule' + +function buildTable(t) { + t.string('content_id', 96).primary() + t.integer('free_episodes').notNullable().defaultTo(0) + t.integer('free_continue_per_day').notNullable().defaultTo(0) + t.text('prices_json', 'longtext').notNullable() + t.boolean('active').notNullable().defaultTo(true) + t.datetime('updated_at', { precision: 3 }).nullable() +} + +module.exports = { tableName, buildTable } diff --git a/packages/billing/src/db/tables/billing_product.cjs b/packages/billing/src/db/tables/billing_product.cjs new file mode 100644 index 0000000..63d6ee3 --- /dev/null +++ b/packages/billing/src/db/tables/billing_product.cjs @@ -0,0 +1,23 @@ +const tableName = 'billing_product' + +function buildTable(t) { + t.string('id', 96).primary() + t.string('kind', 32).notNullable() + t.string('title', 160).nullable() + t.bigInteger('points').nullable() + t.bigInteger('paid_points').unsigned().nullable() + t.bigInteger('bonus_points').unsigned().nullable() + t.decimal('price_usd', 10, 2).nullable() + t.bigInteger('price_cents').nullable() + t.string('currency', 8).nullable() + t.string('stripe_price_id', 191).nullable() + t.text('metadata_json', 'longtext').nullable() + t.boolean('active').notNullable().defaultTo(true) + t.datetime('created_at', { precision: 3 }).nullable() + t.datetime('updated_at', { precision: 3 }).nullable() + t.index(['active', 'kind'], 'idx_billing_product_active_kind') + t.unique(['stripe_price_id'], { indexName: 'uq_billing_product_stripe_price' }) + 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') +} + +module.exports = { tableName, buildTable } diff --git a/packages/billing/src/db/tables/billing_unlock.cjs b/packages/billing/src/db/tables/billing_unlock.cjs new file mode 100644 index 0000000..4f7cd0c --- /dev/null +++ b/packages/billing/src/db/tables/billing_unlock.cjs @@ -0,0 +1,15 @@ +const tableName = 'billing_unlock' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('user_id', 64).notNullable() + t.string('content_id', 96).notNullable() + t.string('target_key', 191).notNullable() + t.string('ledger_id', 64).nullable() + t.string('target_kind', 32).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['user_id', 'content_id'], 'idx_billing_unlock_user_content') + t.unique(['user_id', 'content_id', 'target_key'], { indexName: 'uq_billing_unlock_target' }) +} + +module.exports = { tableName, buildTable } diff --git a/packages/billing/src/db/tables/index.cjs b/packages/billing/src/db/tables/index.cjs new file mode 100644 index 0000000..c5acb01 --- /dev/null +++ b/packages/billing/src/db/tables/index.cjs @@ -0,0 +1,21 @@ +const tableModules = [ + require('./billing_action_policy.cjs'), + require('./billing_product.cjs'), + require('./billing_ledger.cjs'), + require('./billing_unlock.cjs'), + require('./billing_free_quota.cjs'), + require('./billing_paywall_rule.cjs'), +] +const viewModules = [ + require('./points_balance_view.cjs'), +] + +const targetTables = tableModules.filter(({ buildTable }) => typeof buildTable === 'function').map(({ tableName, buildTable }) => [tableName, buildTable]) +const billingTables = Object.fromEntries(tableModules.map(({ tableName }) => [tableName.replace(/^(auth_|account_|billing_|payment_|email_)/, ''), tableName])) +const billingViews = Object.fromEntries(viewModules.map(({ viewName }) => [viewName, viewName])) + +module.exports = { + targetTables, + billingTables, + billingViews, +} diff --git a/packages/billing/src/db/tables/points_balance_view.cjs b/packages/billing/src/db/tables/points_balance_view.cjs new file mode 100644 index 0000000..cb5417e --- /dev/null +++ b/packages/billing/src/db/tables/points_balance_view.cjs @@ -0,0 +1,18 @@ +const viewName = 'points_balance_view' + +function createViewSql() { + return 'CREATE VIEW points_balance_view AS SELECT user_id, COALESCE(SUM(amount), 0) AS balance FROM billing_ledger GROUP BY user_id' +} + +function createEmptyViewSql(dialect) { + if (dialect === 'mysql2') { + return 'CREATE OR REPLACE VIEW points_balance_view AS SELECT NULL AS user_id, 0 AS balance FROM DUAL WHERE 1=0' + } + return 'CREATE VIEW IF NOT EXISTS points_balance_view AS SELECT NULL AS user_id, 0 AS balance WHERE 0' +} + +module.exports = { + viewName, + createViewSql, + createEmptyViewSql, +} diff --git a/packages/billing/src/errors.js b/packages/billing/src/errors.js new file mode 100644 index 0000000..c919a7d --- /dev/null +++ b/packages/billing/src/errors.js @@ -0,0 +1,38 @@ +/** + * 结构化计费错误(§3 契约)。所有可预期的业务失败(余额不足 / 产品缺失 / + * 规则缺失 / 重复解锁)都以 `BillingError` 抛出,绝不返回魔法值或静默吞错。 + * + * 字段语义: + * - `code`:稳定枚举(见 `BillingErrorCode`),调用方据此分流,不依赖 message 文案。 + * - `retryable`:是否值得重试。业务约束类(余额不足 / 产品缺失 / 已解锁)= false; + * 预留给底层瞬时故障(DB 抖动)时由调用方包成 retryable=true。 + * - `cause`:底层原始错误,保留链路。 + */ + +/** + * 失败原因枚举(稳定字符串)。 + * @readonly + */ +export const BillingErrorCode = Object.freeze({ + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + PRODUCT_NOT_FOUND: 'PRODUCT_NOT_FOUND', + RULE_NOT_FOUND: 'RULE_NOT_FOUND', + ALREADY_UNLOCKED: 'ALREADY_UNLOCKED', +}) + +export class BillingError extends Error { + /** + * @param {object} info + * @param {string} info.code 见 `BillingErrorCode` + * @param {boolean} [info.retryable=false] + * @param {unknown} [info.cause] + * @param {string} [info.message] + */ + constructor({ code, retryable = false, cause, message }) { + super(message || `[billing] ${code}`) + this.name = 'BillingError' + this.code = code + this.retryable = Boolean(retryable) + if (cause !== undefined) this.cause = cause + } +} diff --git a/packages/billing/src/index.js b/packages/billing/src/index.js new file mode 100644 index 0000000..1a95969 --- /dev/null +++ b/packages/billing/src/index.js @@ -0,0 +1,30 @@ +/** + * `@drama/billing` 公共面(唯一对外入口,§3 契约)。 + * + * 把「积分账册 + 付费墙」封装成注入式 package:扣费/入账(原子+幂等)、消费日志、 + * 产品目录、付费墙规则(数据驱动纯判定)、以及给 llm/tts 注入消费的 `BillingPort`。 + * + * 对外只暴露 §3 契约声明的符号:`createBilling`(工厂)、`createBillingRepo`(仓库工厂, + * db 注入)、`BillingError`(结构化错误)。 + * + * 边界纪律(架构红线):包内不读 `process.env`、不建任何顶层连接;`db` 只经 + * `createBillingRepo(db)` 注入;不 import 任何其他领域包 / 能力包,与 llm/tts 协作 + * 只经 `asBillingPort` 注入。 + * + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo + * @typedef {import('./port.js').BillingPort} BillingPort + * @typedef {import('./quota.js').PaywallRules} PaywallRules + */ + +export { createBilling } from './billing.js' +export { BillingAction, createActionPolicies } from './action-policies.js' +export { createLedger } from './ledger.js' +export { createProducts } from './products.js' +export { createDailyClaim } from './daily-claim.js' +export { calendarDayOf, midnightInTimezone, shiftCalendarDay } from './calendar-day.js' +export { createCommerceOperations } from './commerce-operations.js' +export { createBillingRepo } from './repo/index.js' +export { createModelSettlementPort } from './model-settlement-port.js' +export { BillingError } from './errors.js' +export { recreateBillingViews } from './views.js' +export { default as billingDbSchema } from './db/tables/index.cjs' diff --git a/packages/billing/src/ledger.js b/packages/billing/src/ledger.js new file mode 100644 index 0000000..0029b95 --- /dev/null +++ b/packages/billing/src/ledger.js @@ -0,0 +1,203 @@ +/** + * `ledger.js` —— 钱的原子性命门:`deduct` / `grant`。 + * + * 职责单一:把「余额检查 → 扣减/增加 → 写流水」编排成**同一事务**的原子操作, + * 并用 `idempotency_key` 兜重放幂等。真正的 SQL/事务在 `repo/ledger-repo.js`;本文件只编排, + * 不认识数据库具体形态(repo 注入)。 + * + * 幂等模型:扣分必须带 `idempotency_key`,由 ledger 唯一约束裁决并发竞争;唯一键冲突后 + * 回读首次结果并逐项核对用户、金额和引用,完全一致才作为重放返回。禁止 SELECT 后 INSERT。 + * + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo + */ + +import { BillingError, BillingErrorCode } from './errors.js' + +/** 流水类型枚举(写入 ledger.type,用于审计与过滤)。 */ +export const LedgerType = Object.freeze({ + DEDUCT: 'deduct', + GRANT: 'grant', +}) + +/** + * 装配 deduct / grant,闭包持有注入的 repo。 + * @param {BillingRepo} billingRepo + */ +export function createLedger(billingRepo) { + /** + * 扣费:事务内余额检查 + 扣减 + 写 ledger。余额不足抛 INSUFFICIENT_BALANCE 不写账。 + * 同 idempotency_key 重放返首次结果、不二次扣。 + * + * @param {object} p + * @param {string} p.user_id + * @param {number} p.amount 正整数积分 + * @param {string} p.reason 人类可读原因('continue' / 'unlock_branch' ...) + * @param {string} [p.ref_type] 关联对象类型('drama' / 'episode' / 'continue' ...) + * @param {string} [p.ref_id] + * @param {string} p.idempotency_key 扣费必须带 key(重放保护) + * @returns {Promise<{ balance_after:number, ledger_id:string }>} + */ + async function deduct({ user_id, amount, source, reason, ref_type = null, ref_id = null, idempotency_key, note = null, failure_code = null }) { + assertAmount(amount) + assertIdempotencyKey(idempotency_key, 'deduct') + + const result = await billingRepo.applyDelta({ + user_id, + delta: -Math.abs(amount), + require_sufficient: true, + ledger_row: { + type: LedgerType.DEDUCT, + source: source ?? sourceForLegacyDeduct(ref_type), + credit_kind: null, + reason, + ref_type, + ref_id, + note, + failure_code, + idempotency_key, + }, + }) + if (result.kind === 'insufficient_balance') { + throw new BillingError({ + code: BillingErrorCode.INSUFFICIENT_BALANCE, + message: `余额不足:当前 ${result.balance_before},需扣 ${amount}`, + }) + } + return { balance_after: result.balance_after, ledger_id: result.ledger_id, replayed: result.kind === 'replayed' } + } + + /** + * 入账:充值成功 / 新用户赠送 / 退款返还。写 ledger,余额增加。 + * 给了 idempotency_key 时同样幂等。 + * + * @param {object} p + * @param {string} p.user_id + * @param {number} p.amount + * @param {string} p.reason + * @param {string} [p.source] 来源('recharge' / 'signup_gift' / 'refund' ...) + * @param {string} [p.idempotency_key] + * @returns {Promise<{ balance_after:number, ledger_id:string }>} + */ + async function grant({ user_id, amount, source, credit_kind, reason, ref_type = null, ref_id = null, idempotency_key, note = null, failure_code = null }) { + assertAmount(amount) + if (source === undefined || source === '') throw new Error('grant requires source') + assertIdempotencyKey(idempotency_key, 'grant') + const result = await billingRepo.applyDelta({ + user_id, + delta: Math.abs(amount), + require_sufficient: false, + ledger_row: { + type: LedgerType.GRANT, + source, + credit_kind: credit_kind ?? creditKindForLegacyGrant(source), + reason, + ref_type, + ref_id, + note, + failure_code, + idempotency_key, + }, + }) + return { balance_after: result.balance_after, ledger_id: result.ledger_id, replayed: result.kind === 'replayed' } + } + + /** + * 支付退款或拒付冲正:只扣可用余额,绝不形成负余额。 + * @param {{ user_id:string, total_points:number, reason:'payment_refund_reversal'|'payment_dispute_reversal', ref_id:string, idempotency_key:string }} input + * @returns {Promise<{ ledger_id:string|null, balance_after:number, points_reversed:number, reversal_shortfall_points:number, replayed:boolean }>} + */ + async function reversePayment({ user_id, total_points, reason, ref_id, idempotency_key }) { + assertAmount(total_points) + assertIdempotencyKey(idempotency_key, 'reversePayment') + assertPaymentReversalReason(reason) + const result = await billingRepo.reverseDelta({ + user_id, + total_points, + ledger_row: { + type: LedgerType.DEDUCT, + source: 'stripe', + credit_kind: null, + reason, + ref_type: 'order', + ref_id, + note: null, + failure_code: null, + idempotency_key, + }, + }) + return { + ledger_id: result.ledger_id, + balance_after: result.balance_after, + points_reversed: result.points_reversed, + reversal_shortfall_points: result.reversal_shortfall_points, + replayed: result.kind === 'replayed', + } + } + + return { deduct, grant, reversePayment } +} + +function assertPaymentReversalReason(reason) { + switch (reason) { + case 'payment_refund_reversal': + case 'payment_dispute_reversal': + return + default: + throw new Error(`Unhandled payment reversal reason: ${reason}`) + } +} + +function assertIdempotencyKey(idempotency_key, operation) { + if (typeof idempotency_key !== 'string' || idempotency_key === '') { + throw new Error(`${operation} requires a stable idempotency_key`) + } +} + +function sourceForLegacyDeduct(ref_type) { + switch (ref_type) { + case 'llm_chat': + case 'llm_operation': + return 'llm' + case 'tts_operation': + case 'visual_game_tts': + return 'tts' + case 'image_operation': + return 'image_generation' + case 'video_operation': + return 'video_generation' + case 'unlock': + return 'unlock' + default: + throw new Error(`deduct requires source for ref_type=${ref_type}`) + } +} + +function creditKindForLegacyGrant(source) { + switch (source) { + case 'apple': + case 'google': + case 'stripe': + return 'paid' + case 'visual_game_signup': + case 'admin': + case 'platform': + case 'legacy_admin': + case 'verification': + return 'admin' + default: + throw new Error(`grant requires credit_kind for source=${source}`) + } +} + +/** + * @param {unknown} amount + */ +function assertAmount(amount) { + if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0) { + throw new BillingError({ + code: BillingErrorCode.INSUFFICIENT_BALANCE, + message: `amount 必须是正整数,收到: ${String(amount)}`, + retryable: false, + }) + } +} diff --git a/packages/billing/src/model-billing.js b/packages/billing/src/model-billing.js new file mode 100644 index 0000000..8ab5f5c --- /dev/null +++ b/packages/billing/src/model-billing.js @@ -0,0 +1,61 @@ +const MICROS_PER_YUAN = 1_000_000 + +const POLICY_SETTLERS = { + record_only() { + return 0 + }, + fixed_points(policy) { + return policy.points + }, + cost_ratio(policy, supplierCost) { + return Math.ceil(supplierCost.supplier_cost_micros * policy.points_per_yuan / MICROS_PER_YUAN) + }, +} + +/** + * 模型供应商成本到用户积分的唯一结算入口。 + * @param {{ deduct:(input:object)=>Promise<{balance_after:number,ledger_id:string}> }} deps + */ +export function createModelBilling({ deduct }) { + return async function settleModelBilling(input) { + const settlement = modelSettlement(input.policy, input.supplier_cost) + if (settlement.charged_points === 0) return { ...settlement, ledger_id: null } + const ledger = await deduct({ + user_id: input.user_id, + amount: settlement.charged_points, + reason: 'model_usage', + ref_type: input.call_ref.type, + ref_id: input.call_ref.id, + idempotency_key: input.idempotency_key, + }) + return { ...settlement, ledger_id: ledger.ledger_id } + } +} + +/** + * 结算入口的唯一数据构造点。非法策略和金额在写 ledger 前立即失败。 + * @param {Record} input + */ +function modelSettlement(policy, supplierCost) { + const billingPolicySnapshot = policySnapshot(policy) + const chargedPoints = POLICY_SETTLERS[policy.mode](billingPolicySnapshot, supplierCost) + return { + billing_mode: billingPolicySnapshot.mode, + billing_policy_snapshot: billingPolicySnapshot, + charged_points: chargedPoints, + } +} + +/** 校验并构造可审计的精确策略快照;不接受未声明字段。 */ +function policySnapshot(policy) { + switch (policy.mode) { + case 'record_only': + return { mode: policy.mode, reason: policy.reason } + case 'fixed_points': + return { mode: policy.mode, points: policy.points } + case 'cost_ratio': + return { mode: policy.mode, points_per_yuan: policy.points_per_yuan } + default: + throw new Error(`Unhandled billing mode: ${policy.mode}`) + } +} diff --git a/packages/billing/src/model-settlement-port.js b/packages/billing/src/model-settlement-port.js new file mode 100644 index 0000000..d6ddabb --- /dev/null +++ b/packages/billing/src/model-settlement-port.js @@ -0,0 +1,25 @@ +import { createBilling } from './billing.js' +import { createBillingRepo } from './repo/index.js' + +/** + * 把模型积分流水与能力自己的调用日志结算结果写入同一个数据库事务。 + * 能力包只提供 recordSettlement,不需要 billing 认识 LLM、图片或视频表。 + * + * @param {object} deps + * @param {import('knex').Knex} deps.db + * @param {(trx:import('knex').Knex.Transaction,input:object,settlement:object)=>Promise} deps.recordSettlement + */ +export function createModelSettlementPort({ db, recordSettlement }) { + const repo = createBillingRepo(db) + + return { + async settle(input) { + return db.transaction(async (trx) => { + const billing = createBilling({ billingRepo: repo.forTransaction(trx) }) + const settlement = await billing.settleModelBilling(input) + await recordSettlement(trx, input.call_id, settlement) + return settlement + }) + }, + } +} diff --git a/packages/billing/src/paywall.js b/packages/billing/src/paywall.js new file mode 100644 index 0000000..ae34f19 --- /dev/null +++ b/packages/billing/src/paywall.js @@ -0,0 +1,195 @@ +/** + * `paywall.js` —— 付费墙判定与解锁。 + * + * `check` 是数据驱动的纯判定:取规则(`listPaywallRules`)+ 已解锁记录 + 免费用量, + * 调 `quota.js` 算出 `{ unlocked, reason, price_points?, free_quota_left? }`。 + * `unlock` 走 `deduct` 完成扣分并落「已解锁」记录,重复解锁幂等(已解锁不再扣)。 + * + * target 形态(§3):`{ kind:'episode'|'branch'|'ending'|'continue', key }`。 + * `key` 对 episode 是集号字符串,对 branch/ending 是分支/结局 id,对 continue 是本次续写 id。 + * + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo + * @typedef {{ kind:'episode'|'branch'|'ending'|'continue', key:string|number }} Target + */ + +import { BillingError, BillingErrorCode } from './errors.js' +import { + evaluateEpisodeQuota, + evaluateContinueQuota, + priceForUnlock, + day_window_start, +} from './quota.js' + +/** 解锁判定原因(稳定枚举,供前端文案/埋点分流)。 */ +export const PaywallReason = Object.freeze({ + ALREADY_UNLOCKED: 'already_unlocked', + FREE_EPISODE: 'free_episode', + FREE_CONTINUE: 'free_continue', + PAYMENT_REQUIRED: 'payment_required', +}) + +/** target.kind 与不需要持久解锁记录的「免费即放行」类型。 */ +const QUOTA_KINDS = new Set(['episode', 'continue']) + +/** + * @param {object} deps + * @param {BillingRepo} deps.billingRepo + * @param {(p:any)=>Promise<{balance_after:number,ledger_id:string}>} deps.deduct ledger.deduct 注入 + */ +export function createPaywall({ billingRepo, deduct }) { + /** + * 判定 target 对该用户是否解锁。 + * @param {object} p + * @param {string} p.user_id + * @param {string} p.content_id + * @param {Target} p.target + * @returns {Promise<{ unlocked:boolean, reason:string, price_points?:number, free_quota_left?:number }>} + */ + async function check({ user_id, content_id, target }) { + const { kind, key } = normalizeTarget(target) + const rules = await loadRules({ billingRepo, content_id }) + + // 已显式解锁过(持久记录)→ 直接放行(branch/ending/已付费 episode 都走这)。 + const target_key = toTargetKey(kind, key) + const already = await billingRepo.findUnlock({ user_id, content_id, target_key }) + if (already) { + return { unlocked: true, reason: PaywallReason.ALREADY_UNLOCKED } + } + + if (kind === 'episode') { + const ep = evaluateEpisodeQuota({ rules, episode_number: Number(key) }) + return ep.free + ? { unlocked: true, reason: PaywallReason.FREE_EPISODE, free_quota_left: ep.free_quota_left } + : { unlocked: false, reason: PaywallReason.PAYMENT_REQUIRED, price_points: ep.price_points, free_quota_left: ep.free_quota_left } + } + + if (kind === 'continue') { + const used_today = await billingRepo.getFreeQuotaUsage({ + user_id, + content_id, + kind: 'continue', + window: day_window_start(), + }) + const c = evaluateContinueQuota({ rules, used_today }) + return c.free + ? { unlocked: true, reason: PaywallReason.FREE_CONTINUE, free_quota_left: c.free_quota_left } + : { unlocked: false, reason: PaywallReason.PAYMENT_REQUIRED, price_points: c.price_points, free_quota_left: c.free_quota_left } + } + + // branch / ending:无免费额度概念,未解锁即需付费。 + const { price_points } = priceForUnlock({ rules, kind: /** @type {'branch'|'ending'} */ (kind) }) + return { unlocked: false, reason: PaywallReason.PAYMENT_REQUIRED, price_points } + } + + /** + * 解锁:扣分 + 落已解锁记录,幂等(已解锁不再扣)。 + * @param {object} p + * @param {string} p.user_id + * @param {string} p.content_id + * @param {Target} p.target + * @param {string} p.idempotency_key + * @returns {Promise<{ unlocked:true, ledger_id?:string }>} + */ + async function unlock({ user_id, content_id, target, idempotency_key }) { + const { kind, key } = normalizeTarget(target) + const target_key = toTargetKey(kind, key) + + const already = await billingRepo.findUnlock({ user_id, content_id, target_key }) + if (already) { + // 幂等:已解锁不再扣分。 + return { unlocked: true, ledger_id: already.ledger_id ?? undefined } + } + + const verdict = await check({ user_id, content_id, target }) + if (verdict.unlocked) { + // 落在免费区间:直接记一条免费解锁,不扣分。 + if (QUOTA_KINDS.has(kind)) { + await billingRepo.bumpFreeQuota({ user_id, content_id, kind, window: day_window_start() }) + } + await billingRepo.insertUnlock({ user_id, content_id, target_key, ledger_id: null }) + return { unlocked: true } + } + + const price = verdict.price_points + if (typeof price !== 'number' || price <= 0) { + throw new BillingError({ + code: BillingErrorCode.RULE_NOT_FOUND, + message: `target ${target_key} 无可用价目,无法解锁`, + retryable: false, + }) + } + + const { ledger_id } = await deduct({ + user_id, + amount: price, + reason: `unlock_${kind}`, + ref_type: 'unlock', + ref_id: `${content_id}:${target_key}`, + idempotency_key, + }) + await billingRepo.insertUnlock({ user_id, content_id, target_key, ledger_id }) + return { unlocked: true, ledger_id } + } + + /** + * 列出某剧下该用户已解锁的 target keys。 + * @param {object} p + * @param {string} p.user_id + * @param {string} p.content_id + * @returns {Promise} + */ + async function listUnlocked({ user_id, content_id }) { + const rows = await billingRepo.listUnlocks(user_id, content_id) + return rows.map((r) => r.target_key) + } + + return { check, unlock, listUnlocked } +} + +/** + * @param {object} p + * @param {BillingRepo} p.billingRepo + * @param {string} p.content_id + * @returns {Promise} + */ +async function loadRules({ billingRepo, content_id }) { + const rules = await billingRepo.listPaywallRules(content_id) + if (!rules) { + throw new BillingError({ + code: BillingErrorCode.RULE_NOT_FOUND, + message: `content_id ${content_id} 无付费墙规则`, + retryable: false, + }) + } + return rules +} + +/** + * @param {Target} target + * @returns {{ kind:string, key:string|number }} + */ +function normalizeTarget(target) { + if (!target || typeof target.kind !== 'string') { + throw new BillingError({ + code: BillingErrorCode.RULE_NOT_FOUND, + message: 'target 需含 kind 与 key', + retryable: false, + }) + } + if (!['episode', 'branch', 'ending', 'continue'].includes(target.kind)) { + throw new BillingError({ + code: BillingErrorCode.RULE_NOT_FOUND, + message: `未知 target.kind: ${target.kind}`, + retryable: false, + }) + } + return { kind: target.kind, key: target.key } +} + +/** + * @param {string} kind + * @param {string|number} key + */ +function toTargetKey(kind, key) { + return `${kind}:${key}` +} diff --git a/packages/billing/src/port.js b/packages/billing/src/port.js new file mode 100644 index 0000000..6448d16 --- /dev/null +++ b/packages/billing/src/port.js @@ -0,0 +1,42 @@ +/** + * `port.js` —— `asBillingPort`:把 `deduct` 收窄成 `BillingPort.charge`。 + * + * llm/tts 在「续写 / 合成语音」时只需要「扣费,不足即中止」这一条能力,不该看到 + * grant / paywall / products。本文件把 deduct 收窄成最小、稳定的 `BillingPort` 面, + * 供它们注入消费——llm/tts 不 import billing,billing 也不 import 它们(架构 §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 + */ + +/** + * @param {object} deps + * @param {(p:any)=>Promise<{balance_after:number,ledger_id:string}>} deps.deduct + * @returns {BillingPort} + */ +export function asBillingPort({ deduct }) { + return { + /** + * 扣费。余额不足时 deduct 抛 `BillingError({ code:'INSUFFICIENT_BALANCE' })`, + * 原样上抛供调用方据此中止生成。返回面收窄为只含 `balance_after`。 + * @param {object} p + * @param {string} p.user_id + * @param {number} p.amount + * @param {string} p.reason + * @param {string} [p.ref_type] + * @param {string} [p.ref_id] + * @param {string} p.idempotency_key + * @returns {Promise<{ balance_after:number }>} + */ + async charge({ user_id, amount, reason, ref_type, ref_id, idempotency_key }) { + const { balance_after } = await deduct({ + user_id, + amount, + reason, + ref_type, + ref_id, + idempotency_key, + }) + return { balance_after } + }, + } +} diff --git a/packages/billing/src/products.js b/packages/billing/src/products.js new file mode 100644 index 0000000..ab695bb --- /dev/null +++ b/packages/billing/src/products.js @@ -0,0 +1,62 @@ +/** + * `products.js` —— 产品目录读取(积分套餐 + 可购权益)。 + * + * 产品目录,数据全来自 `billingRepo`(运营可改)。`save` 保存完整产品定义;调用方不传的 + * 可选字段会按默认值或 null 入库,不做隐式局部 patch。 + * + * @typedef {import('./repo/index.js').BillingRepo} BillingRepo + */ + +import { BillingError, BillingErrorCode } from './errors.js' + +/** + * @param {BillingRepo} billingRepo + */ +export function createProducts(billingRepo) { + return { + /** @returns {Promise} */ + async list() { + return billingRepo.listProducts() + }, + async listAll() { + return billingRepo.listAllProducts() + }, + /** + * @param {string} productId + * @returns {Promise} + */ + async get(productId) { + const p = await billingRepo.findProduct(productId) + return p ?? null + }, + /** + * @param {Record} product + */ + async save(product) { + if (!product?.id) { + throw new BillingError({ + code: BillingErrorCode.PRODUCT_NOT_FOUND, + message: '产品 id 不能为空', + retryable: false, + }) + } + return billingRepo.saveProduct(product) + }, + /** + * 取产品,缺失即抛(供需要强存在性的编排用,如 payment 下单)。 + * @param {string} productId + * @returns {Promise} + */ + async require(productId) { + const p = await billingRepo.findProduct(productId) + if (!p) { + throw new BillingError({ + code: BillingErrorCode.PRODUCT_NOT_FOUND, + message: `产品不存在: ${productId}`, + retryable: false, + }) + } + return p + }, + } +} diff --git a/packages/billing/src/quota.js b/packages/billing/src/quota.js new file mode 100644 index 0000000..81de56f --- /dev/null +++ b/packages/billing/src/quota.js @@ -0,0 +1,88 @@ +/** + * `quota.js` —— 免费额度切点判定(纯逻辑,零 IO)。 + * + * 两类切点,皆数据驱动(规则从 `billingRepo.listPaywallRules` 取,用量从 + * `getFreeQuotaUsage` 取,本文件只做纯计算): + * 1. **前 N 集免费**:第 1..N 集(key 为序号)免费,N+1 起收费。 + * 2. **每日 K 次免费续写**:当日窗口内前 K 次 `continue` 免费,第 K+1 次起收费。 + * + * 设计成纯函数让全表可直接单测:给定规则 + 当前用量,返回是否免费、剩余额度、价目。 + */ + +/** + * @typedef {object} PaywallRules + * @property {number} [free_episodes] 前 N 集免费(N) + * @property {number} [free_continue_per_day] 每日免费续写次数(K) + * @property {Record} [prices] 各 kind 的解锁价目(积分),如 { episode:30, branch:50, ending:80, continue:5 } + */ + +/** + * 判定「第 N 集」是否落在免费区间。 + * @param {object} p + * @param {PaywallRules} p.rules + * @param {number} p.episode_number 1-based 集号 + * @returns {{ free:boolean, price_points:number, free_quota_left:number }} + */ +export function evaluateEpisodeQuota({ rules, episode_number }) { + const free_episodes = numOr(rules.free_episodes, 0) + const price = priceFor(rules, 'episode') + const free = episode_number <= free_episodes + // 剩余免费集数:还能免费看到第几集(相对当前集),不为负。 + const free_quota_left = Math.max(0, free_episodes - episode_number + 1) + return { free, price_points: free ? 0 : price, free_quota_left } +} + +/** + * 判定一次续写是否落在「每日 K 次免费」区间。 + * @param {object} p + * @param {PaywallRules} p.rules + * @param {number} p.used_today 当日已用免费续写次数(窗口内) + * @returns {{ free:boolean, price_points:number, free_quota_left:number }} + */ +export function evaluateContinueQuota({ rules, used_today }) { + const k = numOr(rules.free_continue_per_day, 0) + const price = priceFor(rules, 'continue') + const used = numOr(used_today, 0) + const free_quota_left = Math.max(0, k - used) + const free = free_quota_left > 0 + return { free, price_points: free ? 0 : price, free_quota_left } +} + +/** + * 解锁价目(无免费额度概念的 branch / ending)。 + * @param {object} p + * @param {PaywallRules} p.rules + * @param {'branch'|'ending'} p.kind + * @returns {{ price_points:number }} + */ +export function priceForUnlock({ rules, kind }) { + return { price_points: priceFor(rules, kind) } +} + +/** + * 计算「日窗口」起点(UTC 当日 0 点),供 repo 按窗口聚合用量(DATETIME(3) 列,Date 出入)。 + * @param {Date} [now] + * @returns {Date} + */ +export function day_window_start(now = new Date()) { + const d = new Date(now) + d.setUTCHours(0, 0, 0, 0) + return d +} + +/** + * @param {PaywallRules} rules + * @param {string} kind + */ +function priceFor(rules, kind) { + const prices = rules.prices || {} + return numOr(prices[kind], 0) +} + +/** + * @param {unknown} v + * @param {number} fallback + */ +function numOr(v, fallback) { + return typeof v === 'number' && Number.isFinite(v) ? v : fallback +} diff --git a/packages/billing/src/repo/action-policy-repo.js b/packages/billing/src/repo/action-policy-repo.js new file mode 100644 index 0000000..a75c590 --- /dev/null +++ b/packages/billing/src/repo/action-policy-repo.js @@ -0,0 +1,73 @@ +import { billingActionPolicyTable } from '../db/index.js' + +const POLICY = billingActionPolicyTable.tableName + +export function createActionPolicyRepo(db, runner = db) { + async function findActionPolicy(action) { + const row = await runner(POLICY).where({ action }).first() + if (row === undefined) return null + return actionPolicyFromRow(row) + } + + async function listActionPolicies() { + const rows = await runner(POLICY).orderBy('action') + return rows.map(actionPolicyFromRow) + } + + async function updateActionPolicy(policy) { + await runner(POLICY).where({ action: policy.action }).update({ + points: policy.points, + period_kind: policy.period_kind, + timezone: policy.timezone, + enabled: policy.enabled, + updated_by: policy.updated_by, + updated_at: new Date(), + }) + return findActionPolicy(policy.action) + } + + return { findActionPolicy, listActionPolicies, updateActionPolicy } +} + +function actionPolicyFromRow(row) { + const action = requiredString(row.action, 'billing_action_policy.action') + const policy = { + action, + points: requiredInteger(row.points, 'billing_action_policy.points', action), + period_kind: requiredString(row.period_kind, 'billing_action_policy.period_kind', action), + timezone: nullableString(row.timezone, 'billing_action_policy.timezone', action), + enabled: Boolean(row.enabled), + updated_by: nullableString(row.updated_by, 'billing_action_policy.updated_by', action), + } + assertActionPolicy(policy) + return policy +} + +function assertActionPolicy(policy) { + switch (policy.period_kind) { + case 'daily': + if (policy.timezone === null) throw new Error(`Daily billing action requires timezone, id=${policy.action}`) + return + case 'none': + if (policy.timezone !== null) throw new Error(`Non-periodic billing action cannot have timezone, id=${policy.action}`) + return + default: + throw new Error(`Unhandled billing action period: ${policy.period_kind}, id: ${policy.action}`) + } +} + +function requiredString(value, field, id = 'unknown') { + if (typeof value !== 'string' || value === '') throw new Error(`Invalid ${field}, id=${id}`) + return value +} + +function nullableString(value, field, id) { + if (value === null) return null + return requiredString(value, field, id) +} + +function requiredInteger(value, field, id) { + const number = Number(value) + if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`Invalid ${field}, id=${id}`) + return number +} diff --git a/packages/billing/src/repo/index.js b/packages/billing/src/repo/index.js new file mode 100644 index 0000000..41483d6 --- /dev/null +++ b/packages/billing/src/repo/index.js @@ -0,0 +1,24 @@ +import { createActionPolicyRepo } from './action-policy-repo.js' +import { createLedgerRepo } from './ledger-repo.js' +import { createPaywallRepo } from './paywall-repo.js' +import { createProductRepo } from './product-repo.js' + +export function createBillingRepo(db, runner = db) { + if (typeof db.transaction !== 'function') throw new Error('[billing] createBillingRepo requires a Knex db') + return { + ...createLedgerRepo(db, runner), + ...createProductRepo(db, runner), + ...createActionPolicyRepo(db, runner), + ...createPaywallRepo(db, runner), + afterCommit(work) { + if (runner === db) { + globalThis.queueMicrotask(work) + return + } + runner.executionPromise.then(work, () => undefined) + }, + forTransaction(trx) { + return createBillingRepo(db, trx) + }, + } +} diff --git a/packages/billing/src/repo/ledger-repo.js b/packages/billing/src/repo/ledger-repo.js new file mode 100644 index 0000000..d3ba16d --- /dev/null +++ b/packages/billing/src/repo/ledger-repo.js @@ -0,0 +1,182 @@ +import { randomUUID } from 'node:crypto' +const LEDGER = 'billing_ledger' + +export function createLedgerRepo(db, runner = db) { + const query = runner + const transactionBound = query !== db + + async function inTransaction(work) { + if (transactionBound) return work(query) + return db.transaction(work) + } + + async function getBalance(user_id, trx = query) { + const row = await trx(LEDGER).where({ user_id }).sum({ balance: 'amount' }).first() + return Number(row.balance) + } + + async function applyDelta({ user_id, delta, require_sufficient, ledger_row }) { + assertIdempotencyKey(ledger_row.idempotency_key) + return inTransaction(async trx => { + const user = await trx('users').where({ id: user_id }).select('id').forUpdate().first() + if (user === undefined) throw new Error(`Cannot write billing_ledger: user_id=${user_id} does not exist`) + const balance_before = await getBalance(user_id, trx) + const balance_after = balance_before + delta + if (require_sufficient && balance_after < 0) { + return { kind: 'insufficient_balance', balance_before } + } + const id = randomUUID() + const row = { id, user_id, amount: delta, balance_after, created_at: new Date(), ...ledger_row } + try { + await trx(LEDGER).insert(row) + return { kind: 'applied', ledger_id: id, balance_after } + } catch (error) { + if (error.code !== 'ER_DUP_ENTRY') throw error + const existing = await trx(LEDGER).where({ idempotency_key: ledger_row.idempotency_key }).first() + assertSameLedger(existing, row, ledger_row.idempotency_key) + return { kind: 'replayed', ledger_id: existing.id, balance_after: Number(existing.balance_after) } + } + }) + } + + async function reverseDelta({ user_id, total_points, ledger_row }) { + assertIdempotencyKey(ledger_row.idempotency_key) + return inTransaction(async trx => { + const user = await trx('users').where({ id: user_id }).select('id').forUpdate().first() + if (user === undefined) throw new Error(`Cannot write billing_ledger: user_id=${user_id} does not exist`) + const balance_before = await getBalance(user_id, trx) + const points_reversed = Math.min(balance_before, total_points) + const reversal_shortfall_points = total_points - points_reversed + if (points_reversed === 0) { + return { + kind: 'not_applied', + ledger_id: null, + balance_after: balance_before, + points_reversed, + reversal_shortfall_points, + } + } + const id = randomUUID() + const row = { + id, + user_id, + amount: -points_reversed, + balance_after: balance_before - points_reversed, + created_at: new Date(), + ...ledger_row, + } + try { + await trx(LEDGER).insert(row) + return { kind: 'applied', ledger_id: id, balance_after: row.balance_after, points_reversed, reversal_shortfall_points } + } catch (error) { + if (error.code !== 'ER_DUP_ENTRY') throw error + const existing = await trx(LEDGER).where({ idempotency_key: ledger_row.idempotency_key }).first() + assertSameLedger(existing, row, ledger_row.idempotency_key) + return { + kind: 'replayed', + ledger_id: existing.id, + balance_after: Number(existing.balance_after), + points_reversed: -Number(existing.amount), + reversal_shortfall_points: total_points + Number(existing.amount), + } + } + }) + } + + async function findLedgerByIdempotencyKey(idempotency_key) { + if (idempotency_key === null) return null + const row = await query(LEDGER).where({ idempotency_key }).first() + if (row === undefined) return null + return ledgerFromRow(row) + } + + async function listLedger(user_id, filter) { + let rows = query(LEDGER).where({ user_id }) + if (filter.type !== undefined) rows = rows.where({ type: filter.type }) + if (filter.since !== undefined) rows = rows.where('created_at', '>=', filter.since) + if (filter.until !== undefined) rows = rows.where('created_at', '<=', filter.until) + const limit = filter.limit === undefined ? 20 : filter.limit + const result = await rows.orderBy('created_at', 'desc').orderBy('id', 'desc').limit(limit + 1) + const items = result.slice(0, limit).map(ledgerFromRow) + const last = items.at(-1) + return { + items, + next_cursor: result.length > limit ? `${last.created_at.toISOString()}|${last.id}` : null, + } + } + + return { getBalance, applyDelta, reverseDelta, findLedgerByIdempotencyKey, listLedger } +} + +function assertSameLedger(existing, row, idempotency_key) { + if (existing === undefined) { + throw new Error(`billing_ledger idempotency conflict: key=${idempotency_key}, field=existing_row`) + } + const existingLedger = ledgerFromRow(existing) + for (const field of ['user_id', 'amount', 'type', 'source', 'reason', 'ref_type', 'ref_id', 'credit_kind', 'note', 'failure_code']) { + if (existingLedger[field] !== row[field]) { + throw new Error(`billing_ledger idempotency conflict: key=${idempotency_key}, field=${field}`) + } + } +} + +function assertIdempotencyKey(idempotency_key) { + if (typeof idempotency_key !== 'string' || idempotency_key === '') { + throw new Error('billing_ledger requires a stable idempotency_key') + } +} + +function ledgerFromRow(row) { + const id = requiredString(row.id, 'billing_ledger.id') + const type = requiredString(row.type, 'billing_ledger.type', id) + const amount = requiredInteger(row.amount, 'billing_ledger.amount', id) + const credit_kind = nullableString(row.credit_kind, 'billing_ledger.credit_kind', id) + switch (type) { + case 'grant': + if (amount <= 0 || credit_kind === null) throw new Error(`Invalid billing_ledger grant, id=${id}`) + break + case 'deduct': + if (amount >= 0 || credit_kind !== null) throw new Error(`Invalid billing_ledger deduct, id=${id}`) + break + default: + throw new Error(`Unhandled billing_ledger.type: ${type}, id: ${id}`) + } + return { + id, + user_id: requiredString(row.user_id, 'billing_ledger.user_id', id), + type, + amount, + source: requiredString(row.source, 'billing_ledger.source', id), + credit_kind, + reason: requiredString(row.reason, 'billing_ledger.reason', id), + ref_type: nullableString(row.ref_type, 'billing_ledger.ref_type', id), + ref_id: nullableString(row.ref_id, 'billing_ledger.ref_id', id), + note: nullableString(row.note, 'billing_ledger.note', id), + failure_code: nullableString(row.failure_code, 'billing_ledger.failure_code', id), + balance_after: requiredInteger(row.balance_after, 'billing_ledger.balance_after', id), + idempotency_key: nullableString(row.idempotency_key, 'billing_ledger.idempotency_key', id), + created_at: requiredDate(row.created_at, 'billing_ledger.created_at', id), + } +} + +function requiredString(value, field, id = 'unknown') { + if (typeof value !== 'string' || value === '') throw new Error(`Invalid ${field}, id=${id}`) + return value +} + +function nullableString(value, field, id) { + if (value === null) return null + return requiredString(value, field, id) +} + +function requiredInteger(value, field, id) { + const number = Number(value) + if (!Number.isSafeInteger(number)) throw new Error(`Invalid ${field}, id=${id}`) + return number +} + +function requiredDate(value, field, id) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) throw new Error(`Invalid ${field}, id=${id}`) + return date +} diff --git a/packages/billing/src/repo/paywall-repo.js b/packages/billing/src/repo/paywall-repo.js new file mode 100644 index 0000000..399c47b --- /dev/null +++ b/packages/billing/src/repo/paywall-repo.js @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto' +import { + billingFreeQuotaTable, + billingPaywallRuleTable, + billingUnlockTable, +} from '../db/index.js' + +const QUOTA = billingFreeQuotaTable.tableName +const RULE = billingPaywallRuleTable.tableName +const UNLOCK = billingUnlockTable.tableName + +export function createPaywallRepo(db, runner = db) { + const transactionBound = runner !== db + + async function inTransaction(work) { + if (transactionBound) return work(runner) + return db.transaction(work) + } + + async function findUnlock({ user_id, content_id, target_key }) { + const row = await runner(UNLOCK).where({ user_id, content_id, target_key }).first() + if (row === undefined) return null + return { ledger_id: row.ledger_id } + } + + async function insertUnlock({ user_id, content_id, target_key, ledger_id }) { + await runner(UNLOCK).insert({ id: randomUUID(), user_id, content_id, target_key, ledger_id, created_at: new Date() }) + } + + async function listUnlocks(user_id, content_id) { + const rows = await runner(UNLOCK).where({ user_id, content_id }) + return rows.map(row => ({ target_key: row.target_key, ledger_id: row.ledger_id })) + } + + async function getFreeQuotaUsage({ user_id, content_id, kind, window }) { + const row = await runner(QUOTA).where({ user_id, content_id, kind, window_start: window }).first() + if (row === undefined) return 0 + return Number(row.used) + } + + async function bumpFreeQuota({ user_id, content_id, kind, window }) { + return inTransaction(async trx => { + const existing = await trx(QUOTA).where({ user_id, content_id, kind, window_start: window }).first().forUpdate() + if (existing === undefined) { + await trx(QUOTA).insert({ user_id, content_id, kind, window_start: window, used: 1 }) + return + } + await trx(QUOTA).where({ user_id, content_id, kind, window_start: window }).update({ used: Number(existing.used) + 1 }) + }) + } + + async function upsertPaywallRule(rule) { + await runner(RULE).insert({ + content_id: rule.content_id, + free_episodes: rule.free_episodes, + free_continue_per_day: rule.free_continue_per_day, + prices_json: JSON.stringify(rule.prices), + active: rule.active, + updated_at: new Date(), + }).onConflict('content_id').merge() + return listPaywallRules(rule.content_id) + } + + async function listPaywallRules(content_id) { + const row = await runner(RULE).where({ content_id }).first() + if (row === undefined) return null + return { + free_episodes: Number(row.free_episodes), + free_continue_per_day: Number(row.free_continue_per_day), + prices: JSON.parse(row.prices_json), + } + } + + return { findUnlock, insertUnlock, listUnlocks, getFreeQuotaUsage, bumpFreeQuota, upsertPaywallRule, listPaywallRules } +} diff --git a/packages/billing/src/repo/product-repo.js b/packages/billing/src/repo/product-repo.js new file mode 100644 index 0000000..5c35230 --- /dev/null +++ b/packages/billing/src/repo/product-repo.js @@ -0,0 +1,72 @@ +import { billingProductTable } from '../db/index.js' + +const PRODUCT = billingProductTable.tableName + +export function createProductRepo(db, runner = db) { + async function listProducts() { + const rows = await runner(PRODUCT).where({ active: true }).orderBy('price_cents') + return rows.map(productFromRow) + } + + async function listAllProducts() { + const rows = await runner(PRODUCT).orderBy('price_cents') + return rows.map(productFromRow) + } + + async function findProduct(id) { + const row = await runner(PRODUCT).where({ id }).first() + if (row === undefined) return null + return productFromRow(row) + } + + async function saveProduct(product) { + await runner(PRODUCT).insert(productToRow(product)).onConflict('id').merge() + return findProduct(product.id) + } + + return { listProducts, listAllProducts, findProduct, saveProduct } +} + +function productFromRow(row) { + const id = requiredString(row.id, 'billing_product.id') + const kind = requiredString(row.kind, 'billing_product.kind', id) + const product = { + id, + kind, + title: requiredString(row.title, 'billing_product.title', id), + points: requiredInteger(row.points, 'billing_product.points', id), + paid_points: requiredInteger(row.paid_points, 'billing_product.paid_points', id), + bonus_points: requiredInteger(row.bonus_points, 'billing_product.bonus_points', id), + price_cents: requiredInteger(row.price_cents, 'billing_product.price_cents', id), + price_usd: Number(row.price_usd), + currency: requiredString(row.currency, 'billing_product.currency', id), + stripe_price_id: nullableString(row.stripe_price_id, 'billing_product.stripe_price_id', id), + active: Boolean(row.active), + } + if (kind !== 'points_pack' || product.points !== product.paid_points + product.bonus_points) { + throw new Error(`Invalid billing_product points_pack, id=${id}`) + } + return product +} + +function productToRow(product) { + if (product.kind !== 'points_pack') throw new Error(`Unsupported billing_product.kind: ${product.kind}, id: ${product.id}`) + if (product.points !== product.paid_points + product.bonus_points) throw new Error(`Invalid billing_product points total, id=${product.id}`) + return { ...product, updated_at: new Date() } +} + +function requiredString(value, field, id = 'unknown') { + if (typeof value !== 'string' || value === '') throw new Error(`Invalid ${field}, id=${id}`) + return value +} + +function nullableString(value, field, id) { + if (value === null) return null + return requiredString(value, field, id) +} + +function requiredInteger(value, field, id) { + const number = Number(value) + if (!Number.isSafeInteger(number)) throw new Error(`Invalid ${field}, id=${id}`) + return number +} diff --git a/packages/billing/src/signup-reward.js b/packages/billing/src/signup-reward.js new file mode 100644 index 0000000..9f3f134 --- /dev/null +++ b/packages/billing/src/signup-reward.js @@ -0,0 +1,26 @@ +import { BillingAction } from './action-policies.js' + +/** @param {{ actionPolicies:{ find:Function }, grant:Function }} billing */ +export function createSignupReward(billing) { + async function apply({ user_id }) { + const policy = await billing.actionPolicies.find(BillingAction.SIGNUP_REWARD) + if (policy === null || !policy.enabled) return { applied: false } + + const result = await billing.grant({ + user_id, + amount: policy.points, + source: 'visual_game_signup', + credit_kind: 'admin', + reason: 'signup_gift', + idempotency_key: `signup_reward:${user_id}`, + }) + return { + applied: true, + replayed: result.replayed, + points: policy.points, + ledger_id: result.ledger_id, + } + } + + return { apply } +} diff --git a/packages/billing/src/views.js b/packages/billing/src/views.js new file mode 100644 index 0000000..5048a0c --- /dev/null +++ b/packages/billing/src/views.js @@ -0,0 +1,28 @@ +import pointsBalanceView from './db/tables/points_balance_view.cjs' + +/** + * Recreate billing-owned derived views. + * + * This is explicit schema maintenance for dev/test sync scripts, not runtime app startup. + * @param {import('knex').Knex} db + */ +export async function recreateBillingViews(db) { + await recreateView(db, pointsBalanceView) +} + +/** + * @param {import('knex').Knex} db + * @param {{ viewName: string, createViewSql: () => string }} view + */ +async function recreateView(db, view) { + await db.raw(`DROP VIEW IF EXISTS ${quoteIdent(view.viewName)}`) + await db.raw(view.createViewSql()) +} + +/** + * @param {string} name + */ +function quoteIdent(name) { + if (!/^[A-Za-z0-9_]+$/.test(name)) throw new Error(`invalid SQL identifier: ${name}`) + return `\`${name}\`` +} diff --git a/packages/billing/test/quota.test.js b/packages/billing/test/quota.test.js new file mode 100644 index 0000000..d9292dd --- /dev/null +++ b/packages/billing/test/quota.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { + evaluateEpisodeQuota, + evaluateContinueQuota, + priceForUnlock, + day_window_start, +} from '../src/quota.js' + +const rules = { + free_episodes: 3, + free_continue_per_day: 2, + prices: { episode: 30, branch: 50, ending: 80, continue: 5 }, +} + +describe('quota: 前 N 集免费分界', () => { + it('第 1..N 集免费,价 0', () => { + for (let ep = 1; ep <= 3; ep++) { + const r = evaluateEpisodeQuota({ rules, episode_number: ep }) + expect(r.free).toBe(true) + expect(r.price_points).toBe(0) + } + }) + it('第 N+1 集起收费,价为 episode 价目', () => { + const r = evaluateEpisodeQuota({ rules, episode_number: 4 }) + expect(r.free).toBe(false) + expect(r.price_points).toBe(30) + expect(r.free_quota_left).toBe(0) + }) + it('free_episodes 缺省=0 时第 1 集即收费', () => { + const r = evaluateEpisodeQuota({ rules: { prices: { episode: 9 } }, episode_number: 1 }) + expect(r.free).toBe(false) + expect(r.price_points).toBe(9) + }) +}) + +describe('quota: 每日 K 次免费续写耗尽后转收费 + 窗口重置', () => { + it('未用满 K 次免费', () => { + const r = evaluateContinueQuota({ rules, used_today: 0 }) + expect(r.free).toBe(true) + expect(r.free_quota_left).toBe(2) + }) + it('用到第 K 次仍剩 1 次时免费', () => { + expect(evaluateContinueQuota({ rules, used_today: 1 }).free).toBe(true) + }) + it('用满 K 次后转收费', () => { + const r = evaluateContinueQuota({ rules, used_today: 2 }) + expect(r.free).toBe(false) + expect(r.price_points).toBe(5) + expect(r.free_quota_left).toBe(0) + }) + it('窗口起点是 UTC 当日 0 点(窗口重置依据)', () => { + const w = day_window_start(new Date('2026-06-15T13:22:00Z')) + expect(w).toEqual(new Date('2026-06-15T00:00:00.000Z')) + }) +}) + +describe('quota: branch/ending 解锁价目', () => { + it('branch/ending 取对应价目', () => { + expect(priceForUnlock({ rules, kind: 'branch' }).price_points).toBe(50) + expect(priceForUnlock({ rules, kind: 'ending' }).price_points).toBe(80) + }) +}) diff --git a/packages/config-source/package.json b/packages/config-source/package.json new file mode 100644 index 0000000..a0f3d02 --- /dev/null +++ b/packages/config-source/package.json @@ -0,0 +1,9 @@ +{ + "name": "@drama/config-source", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + } +} diff --git a/packages/config-source/src/index.js b/packages/config-source/src/index.js new file mode 100644 index 0000000..5e2b518 --- /dev/null +++ b/packages/config-source/src/index.js @@ -0,0 +1,19 @@ +/** + * `@drama/config-source` 公共面(唯一对外入口)。 + * + * 版本化配置缓存:`load` / `version` / `now` 全注入,包内不 import `@drama/db`、`ioredis`、 + * 不读 `process.env`、不建连接、不用 `Date.now`。`import '@drama/config-source'` 零 IO。 + * + * 导出: + * createVersionedSource({ load, version, ttlMs, now }) => { get, invalidate } + * createTableVersion({ db, table }) => version() 配置表版本令牌(按 id 排序后的完整行内容) + * + * 消费方各自提供表映射的 `load`,本包只提供「新鲜窗口 + 版本比对 + 并发去重」机制与配置表的 + * 通用版本令牌,不认识 redis / vendor / 行结构;db 由消费方注入,包内不建连接。 + * + * @typedef {import('./versioned-source.js').VersionedSource} VersionedSource + * @typedef {import('./versioned-source.js').VersionedSourceDeps} VersionedSourceDeps + */ + +export { createVersionedSource } from './versioned-source.js' +export { createTableVersion } from './table-version.js' diff --git a/packages/config-source/src/table-version.js b/packages/config-source/src/table-version.js new file mode 100644 index 0000000..8f51bb4 --- /dev/null +++ b/packages/config-source/src/table-version.js @@ -0,0 +1,22 @@ +/** + * table-version.js —— `createTableVersion({ db, table })`:配置表的版本令牌(单一来源)。 + * + * 版本令牌是 `createVersionedSource` 的失效信号:令牌变 → 重新 load。令牌直接来自按主键排序的 + * 完整配置行,不依赖 `updated_at` 精度:同一毫秒连续 UPDATE、删除任意行、原值回写都按数据库 + * 当前事实计算。配置表是小表,版本探测受外层 TTL 节流。 + * + * 消费方仍自带 `load`(本包不认识行结构);本函数只产出令牌,db 注入,包内不建连接。 + */ + +/** + * @param {object} deps + * @param {import('knex').Knex} deps.db 注入的 knex(composition root 建连)。 + * @param {string} deps.table 配置表名(须有稳定的 `id` 主键)。 + * @returns {() => Promise} `createVersionedSource` 的 `version` 实参。 + */ +export function createTableVersion({ db, table }) { + return async () => { + const rows = await db(table).select('*').orderBy('id', 'asc') + return JSON.stringify(rows) + } +} diff --git a/packages/config-source/src/versioned-source.js b/packages/config-source/src/versioned-source.js new file mode 100644 index 0000000..e18efc5 --- /dev/null +++ b/packages/config-source/src/versioned-source.js @@ -0,0 +1,66 @@ +/** + * versioned-source.js —— 「DB 表 + 版本号 + TTL 新鲜窗口」的通用缓存机制。 + * + * 不认识 DB、redis、行结构:`load` 每次返回归一后的单一形态 `T`,`version` 返回版本令牌, + * `now` 为注入时钟。三者是本模块与外界的全部接触面;构造期不调用它们(惰性),首个 `get()` + * 才触发,故 `import '@drama/config-source'` 零 IO。 + * + * 失效沿单一路径逐级下探:新鲜窗口内直接命中 → 窗口外比对版本令牌 → 令牌变化才 `load`。 + * + * @template T + * @typedef {Object} VersionedSourceDeps + * @property {() => Promise} load 从真源全量拉取并归一为单一形态 T + * @property {() => Promise} version 版本令牌;返回值变化即视为配置失效 + * @property {number} ttlMs 新鲜窗口毫秒数;窗口内不探测 version、不 load + * @property {() => number} now 注入时钟 + * + * @template T + * @typedef {Object} VersionedSource + * @property {() => Promise} get 返回当前有效配置(必要时按版本失效后重载) + * @property {() => void} invalidate 清空缓存,强制下次 get 走 load + */ + +/** + * @template T + * @param {VersionedSourceDeps} deps + * @returns {VersionedSource} + */ +export function createVersionedSource({ load, version, ttlMs, now }) { + /** @type {T | null} */ + let cached = null + /** @type {string | null} */ + let cachedVersion = null + let checkedAt = 0 + /** @type {Promise | null} */ + let inflight = null + + /** @returns {Promise} */ + async function get() { + if (cached !== null && now() - checkedAt < ttlMs) return cached + const v = await version() + if (cached !== null && v === cachedVersion) { + checkedAt = now() + return cached + } + if (inflight) return inflight + inflight = (async () => { + try { + const fresh = await load() + cached = fresh + cachedVersion = v + checkedAt = now() + return fresh + } finally { + inflight = null + } + })() + return inflight + } + + function invalidate() { + cached = null + cachedVersion = null + } + + return { get, invalidate } +} diff --git a/packages/config/eslint/index.js b/packages/config/eslint/index.js new file mode 100644 index 0000000..82b9ad5 --- /dev/null +++ b/packages/config/eslint/index.js @@ -0,0 +1,47 @@ +/** + * `@drama/config/eslint` 公共面 —— 导出 `presetNode` / `presetVue` 两套 flat config 预设。 + * + * - `presetNode`:纯逻辑 / 后端包用。通用 JS 规则 + scaffold 四条架构红线规则(深导入 / + * 子域深导入 / 连接只在 composition root / 禁 app 互调)。 + * - `presetVue`:Vue 包用。在 `presetNode` 基础上拼接 `eslint-plugin-vue` 与浏览器全局。 + * + * 两者都是**数据**(flat config 数组),消费方 `export default presetNode`(或 `presetVue`) + * 即获得与全仓一致的 lint 行为,再按需补本包特例。 + * + * 消费示例: + * // packages/foo/eslint.config.js + * import { presetNode } from '@drama/config/eslint' + * export default presetNode + * + * // apps/web/eslint.config.js + * import { presetVue } from '@drama/config/eslint' + * export default presetVue + */ + +import { + JS_RECOMMENDED, + COMMON_IGNORES_BLOCK, + buildArchRulesBlock, + buildCommonjsArchRulesBlock, + ENFORCED_RULE_NAMES, +} from './rules.js' +import { buildVueBlocks } from './vue.js' + +export { ENFORCED_RULE_NAMES } + +/** + * 纯逻辑 / 后端包 flat config 预设。 + * @type {import('eslint').Linter.Config[]} + */ +export const presetNode = [ + COMMON_IGNORES_BLOCK, + JS_RECOMMENDED, + buildArchRulesBlock(), + buildCommonjsArchRulesBlock(), +] + +/** + * Vue 包 flat config 预设:node 预设之上拼接 Vue 片段。 + * @type {import('eslint').Linter.Config[]} + */ +export const presetVue = [...presetNode, ...buildVueBlocks()] diff --git a/packages/config/eslint/rules.js b/packages/config/eslint/rules.js new file mode 100644 index 0000000..08f5351 --- /dev/null +++ b/packages/config/eslint/rules.js @@ -0,0 +1,73 @@ +/** + * `@drama/config` ESLint 预设 —— 通用 JS 规则集合 + 装配架构红线规则的 flat config 片段。 + * + * 红线装配(白名单 / 规则集 / globals)单源于仓库根 `tools/eslint-rules/config.js`,本文件只把 + * 那份单源包装成 flat config 片段(`presetNode` / `presetVue` 消费)。规则集与白名单一旦在单源 + * 变更,这里自动跟随——历史上根配置与本预设漂移过,现已收敛到单源。 + */ + +import js from '@eslint/js' +import { + CONN_WHITELIST, + buildArchRules, + ENFORCED_RULE_NAMES, + BASE_GLOBALS, + COMMONJS_GLOBALS, +} from '../../../tools/eslint-rules/config.js' +import { dramaPlugin } from '../../../tools/eslint-rules/index.js' + +/** 规则 C 文件白名单默认值(composition root + vite 配置),与根配置同源。 */ +export const DEFAULT_COMPOSITION_ROOTS = CONN_WHITELIST + +/** 架构红线规则名(供测试与下游静态追溯「红线确实进了预设」)。 */ +export { ENFORCED_RULE_NAMES } + +/** 全仓通用 JS 语言层默认(ESM、Node 全局)。 */ +export const NODE_LANGUAGE_OPTIONS = { + ecmaVersion: 2023, + sourceType: 'module', + globals: { ...BASE_GLOBALS }, +} + +/** CommonJS 脚本 / table definition 语言层默认,保证架构红线覆盖 `.cjs`。 */ +export const COMMONJS_LANGUAGE_OPTIONS = { + ...NODE_LANGUAGE_OPTIONS, + sourceType: 'commonjs', + globals: { ...COMMONJS_GLOBALS }, +} + +/** + * 装配架构红线 + 通用 JS 规则的 flat config 片段(ESM)。 + * @param {{ compositionRoots?: string[] }} [opts] + * @returns {import('eslint').Linter.Config} + */ +export function buildArchRulesBlock(opts = {}) { + return { + files: ['**/*.js'], + languageOptions: { ...NODE_LANGUAGE_OPTIONS }, + plugins: { drama: dramaPlugin }, + rules: buildArchRules(opts.compositionRoots || DEFAULT_COMPOSITION_ROOTS), + } +} + +/** + * 装配架构红线 + CommonJS 语言层的 flat config 片段。 + * @param {{ compositionRoots?: string[] }} [opts] + * @returns {import('eslint').Linter.Config} + */ +export function buildCommonjsArchRulesBlock(opts = {}) { + return { + files: ['**/*.cjs'], + languageOptions: { ...COMMONJS_LANGUAGE_OPTIONS }, + plugins: { drama: dramaPlugin }, + rules: buildArchRules(opts.compositionRoots || DEFAULT_COMPOSITION_ROOTS), + } +} + +/** 全仓通用 ignores(构建产物 / 依赖 / 覆盖率 / 临时目录)。 */ +export const COMMON_IGNORES_BLOCK = { + ignores: ['**/node_modules/**', '**/dist/**', '**/coverage/**', 'tmp/**'], +} + +/** ESLint 官方推荐 JS 规则集(基础 JS 规则的默认来源)。 */ +export const JS_RECOMMENDED = js.configs.recommended diff --git a/packages/config/eslint/vue.js b/packages/config/eslint/vue.js new file mode 100644 index 0000000..597ea40 --- /dev/null +++ b/packages/config/eslint/vue.js @@ -0,0 +1,33 @@ +/** + * Vue 专属 flat config 片段 —— `eslint-plugin-vue` + 浏览器全局。 + * + * 只持有「Vue 包相对 node 预设额外需要的」默认数据:plugin-vue 的 flat 推荐配置 + + * 浏览器运行时全局。与 node 预设是**数据拼接**关系(`presetVue = [...presetNode, ...vueBlocks]`), + * 无运行时逻辑耦合(§4 职责边界)。 + */ + +import pluginVue from 'eslint-plugin-vue' +import { BROWSER_GLOBALS } from '../../../tools/eslint-rules/config.js' + +export { BROWSER_GLOBALS } + +/** + * Vue flat config 片段数组(plugin-vue 的 flat 推荐 + 浏览器全局注入)。 + * `eslint-plugin-vue` 的 `flat/recommended` 已是 flat config 数组,直接平铺。 + * @returns {import('eslint').Linter.Config[]} + */ +export function buildVueBlocks() { + const recommended = pluginVue.configs['flat/recommended'] || [] + return [ + ...recommended, + { + files: ['**/*.vue', '**/*.js'], + languageOptions: { + globals: { ...BROWSER_GLOBALS }, + }, + }, + ] +} + +/** plugin-vue 是否真的进了预设(供测试静态断言)。 */ +export const VUE_PLUGIN_NAME = 'eslint-plugin-vue' diff --git a/packages/config/index.js b/packages/config/index.js new file mode 100644 index 0000000..aa563c8 --- /dev/null +++ b/packages/config/index.js @@ -0,0 +1,3 @@ +export { ENFORCED_RULE_NAMES, presetNode, presetVue } from './eslint/index.js' +export { defineWebPreset } from './vite/index.js' +export { COVERAGE_THRESHOLDS, defineNodePreset, defineVuePreset } from './vitest/index.js' diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..a85980f --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,28 @@ +{ + "name": "@drama/config", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./index.js", + "./eslint": "./eslint/index.js", + "./vite": "./vite/index.js", + "./vitest": "./vitest/index.js" + }, + "dependencies": { + "@eslint/js": "^9.13.0", + "@vitejs/plugin-vue": "^5.1.4", + "code-inspector-plugin": "^1.2.10", + "eslint-plugin-vue": "^9.30.0" + }, + "peerDependencies": { + "eslint": "^9.13.0", + "vite": "^5.0.0 || ^6.0.0", + "vitest": "^2.0.0" + }, + "peerDependenciesMeta": { + "eslint": { "optional": true }, + "vite": { "optional": true }, + "vitest": { "optional": true } + } +} diff --git a/packages/config/vite/base.js b/packages/config/vite/base.js new file mode 100644 index 0000000..d8d6ee9 --- /dev/null +++ b/packages/config/vite/base.js @@ -0,0 +1,133 @@ +/** + * Vite 预设的「默认值」与共享工具 —— base config + 深合并工具。 + * + * `index.js` 持有工厂与合并逻辑,本文件持有默认数据(Vue 插件、workspace 解析、build 约定、 + * SPA base)与无副作用的 `deepMerge`。 + * + * 本文件读 `process.env`(构建期判 dev/prod、开关调试插件)——这是 Vite 配置的合理需求,不是散读 + * env,故 `packages/config/vite/base.js` 已列入连接规则白名单(tools/eslint-rules/config.js)。 + * + * visual-feedback 是开发期反馈组件,不再由本预设硬注入(否则 config 就得依赖它)。需要它的 app + * 在自己的 `vite.config.js` 里显式 `plugins.push(visualFeedbackPlugin())`,自选、自担依赖。 + */ + +import vue from '@vitejs/plugin-vue' +import { codeInspectorPlugin } from 'code-inspector-plugin' + +/** + * 是否为可深合并的纯对象(非数组、非 null、非类实例如 plugin)。 + * @param {unknown} v + * @returns {boolean} + */ +function isPlainObject(v) { + if (v === null || typeof v !== 'object' || Array.isArray(v)) return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + +/** + * 递归深合并:同名嵌套对象逐层合并(保留未被覆盖的默认子字段),数组与标量整体替换。 + * 这是 §5 review 关注点 2「override 是深合并不是浅覆盖」的实现:app 传 `server.proxy` + * 只覆盖 `server.proxy`,不把整个 `server` 段(含默认 port 等)抹掉。 + * @template T + * @param {T} base + * @param {Record} override + * @returns {T} + */ +export function deepMerge(base, override) { + if (!isPlainObject(override)) return /** @type {T} */ (override ?? base) + const out = isPlainObject(base) ? { ...base } : {} + for (const key of Object.keys(override)) { + const o = override[key] + const b = /** @type {Record} */ (out)[key] + if (isPlainObject(b) && isPlainObject(o)) { + /** @type {Record} */ (out)[key] = deepMerge(b, o) + } else { + /** @type {Record} */ (out)[key] = o + } + } + return /** @type {T} */ (out) +} + +/** 标识值:base 默认携带的 Vue 插件实例(供测试断言「默认 config 含 Vue 插件」)。 */ +export const VUE_PLUGIN_NAME = 'vite:vue' + +/** + * `@drama/*` / `@app/*` workspace 别名解析约定。 + * monorepo 下包名经 workspace 解析即可,这里显式声明 `conditions` 让 Vite 优先走源码 `exports`, + * 保证 dev 直接跑源码、无编译步骤(架构基线 §3)。 + * */ +export const WORKSPACE_RESOLVE = { + // 让 @drama/* / @app/* 走包 exports 的 import 条件,命中各包 src/index.js。 + conditions: ['import', 'module', 'browser', 'default'], + dedupe: ['vue'], +} + +/** + * 产出 base Vite config(不含 overrides)。每次调用返回新对象 + 新插件实例,避免跨 app 共享可变状态。 + * @param {{ env?: Record, command?: string }} [options] + * @returns {Record} + */ +export function buildBaseConfig(options = {}) { + const env = options.env ?? process.env + const command = options.command ?? commandFromProcessArgv(process.argv) + const isDev = command !== 'build' && env.VITEST !== 'true' + const enableInspector = isVisualFeedbackInspectorEnabled(env, command) + return { + plugins: [ + vue(), + enableInspector && codeInspectorPlugin({ + bundler: 'vite', + showSwitch: false, + hideConsole: true, + autoToggle: false, + behavior: { + locate: false, + copy: false, + target: '', + }, + server: isDev ? 'open' : 'close', + dev: enableInspector, + skipSnippets: ['htmlScript', 'console'], + }), + ].filter(Boolean), + resolve: { ...WORKSPACE_RESOLVE }, + base: '/', + build: { + outDir: 'dist', + emptyOutDir: true, + sourcemap: true, + target: 'es2022', + }, + server: { + port: 5173, + strictPort: false, + allowedHosts: allowedHostsFromEnv(env), + }, + } +} + +/** + * 反代进来的 Host 白名单。 + * + * 部署成 `frontend_mode: proxy` 的环境里,nginx 用 `proxy_set_header Host $host` 把公网域名 + * 原样透传给 dev server,而 Vite 只放行 localhost 和 IP,别的域名一律 403——页面打不开, + * 日志里也只有一行「This host is not allowed」。 + * + * 域名是环境差异,所以只从各环境自己的 .env 读(`VITE_ALLOWED_HOSTS=a.example,b.example`), + * 仓库里不硬编码任何域名。不配就是空数组,行为和以前完全一样。 + */ +function allowedHostsFromEnv(env) { + const raw = env.VITE_ALLOWED_HOSTS + if (typeof raw !== 'string') return [] + return raw.split(',').map(host => host.trim()).filter(host => host !== '') +} + +function isVisualFeedbackInspectorEnabled(env, _command) { + if (env.VITEST === 'true') return false + return env.VISUAL_FEEDBACK === 'true' +} + +function commandFromProcessArgv(argv) { + return argv.includes('build') ? 'build' : 'serve' +} diff --git a/packages/config/vite/index.js b/packages/config/vite/index.js new file mode 100644 index 0000000..c53882a --- /dev/null +++ b/packages/config/vite/index.js @@ -0,0 +1,26 @@ +/** + * `@drama/config/vite` 公共面 —— `defineWebPreset(overrides)` 工厂。 + * + * 产出 `@app/web` / `@app/studio` / `@drama/ui` 通用的 Vite config(Vue 插件、`@drama/*` + * workspace 解析、build 产物约定、SPA base)。app 只传差异(入口、proxy、预渲染开关), + * 经**深合并**覆盖默认(嵌套字段精确覆盖,同级默认保留)。 + * + * 消费示例: + * // apps/web/vite.config.js + * import { defineWebPreset } from '@drama/config/vite' + * export default defineWebPreset({ root: '.', server: { proxy: { '/api': 'http://localhost:9007' } } }) + */ + +import { buildBaseConfig, deepMerge } from './base.js' + +/** + * Vite config 工厂:base 默认 + 深合并 overrides。 + * @param {Record} [overrides] 用户差异(深合并进默认)。 + * @param {{ env?: Record, command?: string }} [options] + * @returns {Record} 可被 Vite 直接消费的 config。 + */ +export function defineWebPreset(overrides = {}, options = {}) { + return deepMerge(buildBaseConfig(options), overrides) +} + +export { VUE_PLUGIN_NAME } from './base.js' diff --git a/packages/config/vitest/base.js b/packages/config/vitest/base.js new file mode 100644 index 0000000..c59bda6 --- /dev/null +++ b/packages/config/vitest/base.js @@ -0,0 +1,65 @@ +/** + * Vitest 预设的「默认值」与共享工具 —— 共享 coverage 门槛 / include / reporter + 深合并。 + * + * `index.js` 持有 node/vue 两个工厂,本文件持有跨工厂共享的默认数据与无副作用 `deepMerge`。 + * coverage 门槛是「单一来源」:全仓收紧门槛改这一处,所有消费包下次跑即生效(§2 验收 5)。 + * 预设为纯配置:不读 env、不建连接。 + */ + +/** + * @param {unknown} v + * @returns {boolean} + */ +function isPlainObject(v) { + if (v === null || typeof v !== 'object' || Array.isArray(v)) return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + +/** + * 递归深合并(嵌套对象逐层合并、数组与标量整体替换)。 + * 保证 app 传 `test.coverage.thresholds` 只覆盖门槛字段,不抹掉其余默认。 + * @template T + * @param {T} base + * @param {Record} override + * @returns {T} + */ +export function deepMerge(base, override) { + if (!isPlainObject(override)) return /** @type {T} */ (override ?? base) + const out = isPlainObject(base) ? { ...base } : {} + for (const key of Object.keys(override)) { + const o = override[key] + const b = /** @type {Record} */ (out)[key] + if (isPlainObject(b) && isPlainObject(o)) { + /** @type {Record} */ (out)[key] = deepMerge(b, o) + } else { + /** @type {Record} */ (out)[key] = o + } + } + return /** @type {T} */ (out) +} + +/** 全仓统一的覆盖率门槛(单一来源)。改这里 = 全仓消费包门槛同步变化。 */ +export const COVERAGE_THRESHOLDS = Object.freeze({ + lines: 80, + functions: 80, + branches: 75, + statements: 80, +}) + +/** 全仓统一的 test 匹配 / 排除 / reporter / coverage 约定。 */ +export function buildSharedTestConfig() { + return { + globals: true, + include: ['test/**/*.test.js', 'src/**/*.test.js'], + exclude: ['**/node_modules/**', '**/dist/**', '**/coverage/**'], + reporters: ['default'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.js'], + exclude: ['**/*.test.js', '**/test/**'], + thresholds: { ...COVERAGE_THRESHOLDS }, + }, + } +} diff --git a/packages/config/vitest/index.js b/packages/config/vitest/index.js new file mode 100644 index 0000000..a187a5c --- /dev/null +++ b/packages/config/vitest/index.js @@ -0,0 +1,54 @@ +/** + * `@drama/config/vitest` 公共面 —— `defineNodePreset` / `defineVuePreset` 两个工厂。 + * + * - `defineNodePreset(overrides)`:纯逻辑包用,`environment:'node'`。 + * - `defineVuePreset(overrides)`:组件包用,`environment:'jsdom'` + `@vitejs/plugin-vue`。 + * + * 两者共享 coverage 门槛、include/exclude、reporter(单一来源),overrides 深合并可改门槛。 + * + * 消费示例: + * // packages/foo/vitest.config.js + * import { defineNodePreset } from '@drama/config/vitest' + * export default defineNodePreset() + * + * // packages/ui/vitest.config.js + * import { defineVuePreset } from '@drama/config/vitest' + * export default defineVuePreset() + */ + +import { buildSharedTestConfig, deepMerge, COVERAGE_THRESHOLDS } from './base.js' + +export { COVERAGE_THRESHOLDS } + +/** + * node 环境 Vitest config(纯逻辑包)。 + * @param {Record} [overrides] 深合并进默认(可覆盖门槛 / include 等)。 + * @returns {Record} + */ +export function defineNodePreset(overrides = {}) { + const base = { + test: { + ...buildSharedTestConfig(), + environment: 'node', + }, + } + return deepMerge(base, overrides) +} + +/** + * jsdom + Vue 环境 Vitest config(组件包)。 + * Vue 插件经动态 import 装配,避免纯 node 消费方加载本工厂时被强制拉起 plugin-vue。 + * @param {Record} [overrides] 深合并进默认。 + * @returns {Promise>} 含 Vue 插件的 config(异步以惰性加载插件)。 + */ +export async function defineVuePreset(overrides = {}) { + const { default: vue } = await import('@vitejs/plugin-vue') + const base = { + plugins: [vue()], + test: { + ...buildSharedTestConfig(), + environment: 'jsdom', + }, + } + return deepMerge(base, overrides) +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..7737cb5 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,18 @@ +{ + "name": "@drama/contracts", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js", + "./drama": "./src/drama.js", + "./platform": "./src/platform.js", + "./visual-game": "./src/visual-game.js" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "valibot": "^1.4.1" + } +} diff --git a/packages/contracts/schema-snapshot.json b/packages/contracts/schema-snapshot.json new file mode 100644 index 0000000..c6d2b84 --- /dev/null +++ b/packages/contracts/schema-snapshot.json @@ -0,0 +1,9937 @@ +{ + "Account": { + "properties": { + "credits": { + "type": "number" + }, + "display_name": { + "type": "string" + }, + "email": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "favorites": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "cover": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "drama_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "progress": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "title": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "history": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "cover": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "drama_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "progress": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "title": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "member_since": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "role": { + "type": "optional", + "wrapped": { + "options": [ + "admin", + "operator", + "user" + ], + "type": "picklist" + } + }, + "stats": { + "type": "optional", + "wrapped": { + "properties": { + "rewritten": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "watched": { + "type": "optional", + "wrapped": { + "type": "number" + } + } + }, + "type": "object" + } + }, + "subscription": { + "properties": { + "renews_at": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "active", + "inactive", + "past_due" + ], + "type": "picklist" + }, + "tier": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ApiError": { + "properties": { + "error": { + "properties": { + "code": { + "type": "string" + }, + "details": { + "type": "optional", + "wrapped": { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "type": "unknown" + } + } + }, + "message": { + "type": "string" + }, + "retryable": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "Bundle": { + "properties": { + "drama_id": { + "type": "string" + }, + "lines": { + "item": { + "properties": { + "emotion": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "mode": { + "options": [ + "block", + "parallel" + ], + "type": "picklist" + }, + "speaker": { + "type": "string" + }, + "text": { + "type": "string" + }, + "tts": { + "type": "optional", + "wrapped": { + "properties": { + "instruction": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "speech_rate": { + "type": "optional", + "wrapped": { + "type": "number" + } + } + }, + "type": "object" + } + } + }, + "type": "object" + }, + "type": "array" + }, + "meta": { + "properties": { + "duration": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "locale": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "type": "object" + }, + "rig_ref": { + "type": "string" + }, + "scenes": { + "item": { + "properties": { + "background": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "props": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + } + }, + "type": "object" + }, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, + "Drama": { + "properties": { + "cover": { + "type": "string" + }, + "heat": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "paywall": { + "type": "optional", + "wrapped": { + "properties": { + "currency": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "locked": { + "type": "boolean" + }, + "price": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "unlock_cost": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "user_credits": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "vip_tier": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + } + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, + "LedgerEntry": { + "properties": { + "created_at": { + "type": "string" + }, + "delta": { + "type": "number" + }, + "id": { + "type": "string" + }, + "reason": { + "options": [ + "grant", + "topup", + "unlock" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "LoginRequest": { + "properties": { + "credential": { + "type": "string" + }, + "device_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "oauth_flow": { + "type": "optional", + "wrapped": { + "options": [ + "access_token", + "authorization_code" + ], + "type": "picklist" + } + }, + "provider": { + "type": "string" + }, + "redirect_uri": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "LoginResponse": { + "properties": { + "access_token": { + "type": "string" + }, + "account": { + "properties": { + "credits": { + "type": "number" + }, + "display_name": { + "type": "string" + }, + "email": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "favorites": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "cover": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "drama_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "progress": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "title": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "history": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "cover": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "drama_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "progress": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "title": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "member_since": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "role": { + "type": "optional", + "wrapped": { + "options": [ + "admin", + "operator", + "user" + ], + "type": "picklist" + } + }, + "stats": { + "type": "optional", + "wrapped": { + "properties": { + "rewritten": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "watched": { + "type": "optional", + "wrapped": { + "type": "number" + } + } + }, + "type": "object" + } + }, + "subscription": { + "properties": { + "renews_at": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "active", + "inactive", + "past_due" + ], + "type": "picklist" + }, + "tier": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "refresh_token": { + "type": "string" + } + }, + "type": "object" + }, + "PageOfDrama": { + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "item": { + "properties": { + "cover": { + "type": "string" + }, + "heat": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "paywall": { + "type": "optional", + "wrapped": { + "properties": { + "currency": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "locked": { + "type": "boolean" + }, + "price": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "unlock_cost": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "user_credits": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "vip_tier": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + } + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "page": { + "type": "number" + }, + "page_size": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "type": "object" + }, + "ScriptLine": { + "properties": { + "emotion": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "mode": { + "options": [ + "block", + "parallel" + ], + "type": "picklist" + }, + "speaker": { + "type": "string" + }, + "text": { + "type": "string" + }, + "tts": { + "type": "optional", + "wrapped": { + "properties": { + "instruction": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "speech_rate": { + "type": "optional", + "wrapped": { + "type": "number" + } + } + }, + "type": "object" + } + } + }, + "type": "object" + }, + "ShareMeta": { + "properties": { + "description": { + "type": "string" + }, + "image": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "TelemetryEvent": { + "properties": { + "anon_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "device_id": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "name": { + "type": "string" + }, + "props": { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "type": "unknown" + } + }, + "sdk_version": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "ts": { + "type": "number" + } + }, + "type": "object" + }, + "TopupCheckoutReq": { + "properties": { + "pack_id": { + "type": "string" + } + }, + "type": "object" + }, + "UnlockReq": { + "properties": { + "drama_id": { + "type": "string" + }, + "episode_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "UnlockRes": { + "properties": { + "reason": { + "type": "optional", + "wrapped": { + "options": [ + "already", + "insufficient" + ], + "type": "picklist" + } + }, + "remaining_credits": { + "type": "number" + }, + "unlocked": { + "type": "boolean" + } + }, + "type": "object" + }, + "VisualGameAdminAnalyticsDrilldownResponse": { + "properties": { + "generated_at": { + "type": "string" + }, + "items": { + "item": { + "properties": { + "account_type": { + "options": [ + "guest", + "registered" + ], + "type": "picklist" + }, + "act_turns": { + "type": "number" + }, + "credits_granted": { + "type": "number" + }, + "credits_spent": { + "type": "number" + }, + "display_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "interactions": { + "type": "number" + }, + "last_interaction_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "paid_orders": { + "type": "number" + }, + "runs_completed": { + "type": "number" + }, + "runs_started": { + "type": "number" + }, + "say_turns": { + "type": "number" + }, + "seasons_completed": { + "type": "number" + }, + "seasons_started": { + "type": "number" + }, + "user_id": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "metric": { + "options": [ + "act_turns", + "active_players", + "credits_granted", + "credits_spent", + "interactions", + "paying_players", + "runs_completed", + "runs_started", + "say_turns", + "seasons_completed", + "seasons_started" + ], + "type": "picklist" + }, + "since": { + "type": "string" + }, + "total": { + "type": "number" + }, + "until": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameAdminAnalyticsOverviewResponse": { + "properties": { + "daily": { + "item": { + "properties": { + "active_players": { + "type": "number" + }, + "date": { + "type": "string" + }, + "interactions": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "generated_at": { + "type": "string" + }, + "metrics": { + "properties": { + "act_turns": { + "type": "number" + }, + "active_players": { + "type": "number" + }, + "credits_granted": { + "type": "number" + }, + "credits_spent": { + "type": "number" + }, + "interactions": { + "type": "number" + }, + "paying_players": { + "type": "number" + }, + "runs_completed": { + "type": "number" + }, + "runs_started": { + "type": "number" + }, + "say_turns": { + "type": "number" + }, + "seasons_completed": { + "type": "number" + }, + "seasons_started": { + "type": "number" + } + }, + "type": "object" + }, + "since": { + "type": "string" + }, + "until": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameAdminContentAnalyticsResponse": { + "properties": { + "generated_at": { + "type": "string" + }, + "items": { + "item": { + "properties": { + "act_turns": { + "type": "number" + }, + "active_players": { + "type": "number" + }, + "completion_rate": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "episode_no": { + "type": "number" + }, + "interactions": { + "type": "number" + }, + "players": { + "type": "number" + }, + "runs_completed": { + "type": "number" + }, + "runs_started": { + "type": "number" + }, + "say_turns": { + "type": "number" + }, + "scenario_id": { + "type": "string" + }, + "scenario_title": { + "type": "string" + }, + "series_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "series_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + }, + "since": { + "type": "string" + }, + "until": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameAdminGa4BehaviorResponse": { + "properties": { + "clicks": { + "item": { + "properties": { + "clicks": { + "type": "number" + }, + "element_id": { + "type": "string" + }, + "surface": { + "type": "string" + }, + "users": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "configured": { + "type": "boolean" + }, + "devices": { + "item": { + "properties": { + "browser": { + "type": "string" + }, + "category": { + "type": "string" + }, + "operating_system": { + "type": "string" + }, + "sessions": { + "type": "number" + }, + "users": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "flows": { + "item": { + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "users": { + "type": "number" + }, + "views": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "generated_at": { + "type": "string" + }, + "inputs": { + "item": { + "properties": { + "input_method": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "submissions": { + "type": "number" + }, + "users": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "pages": { + "item": { + "properties": { + "path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "users": { + "type": "number" + }, + "views": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "scene_misses": { + "item": { + "properties": { + "clicks": { + "type": "number" + }, + "grid_x": { + "type": "string" + }, + "grid_y": { + "type": "string" + }, + "pointer_type": { + "type": "string" + }, + "scene_id": { + "type": "string" + }, + "users": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "since": { + "type": "string" + }, + "sources": { + "item": { + "properties": { + "campaign": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "note_key": { + "options": [ + "direct", + "facebook_referral", + "instagram_referral", + "organic_search", + "organic_social", + "other", + "paid_search", + "paid_social", + "referral", + "tiktok_referral", + "unassigned", + "x_referral", + "youtube_referral" + ], + "type": "picklist" + }, + "sessions": { + "type": "number" + }, + "source_medium": { + "type": "string" + }, + "users": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "summary": { + "properties": { + "active_users": { + "type": "number" + }, + "page_views": { + "type": "number" + }, + "scene_clicks": { + "type": "number" + }, + "selected_inputs": { + "type": "number" + }, + "sessions": { + "type": "number" + }, + "typed_inputs": { + "type": "number" + }, + "ui_clicks": { + "type": "number" + }, + "unique_visitors": { + "type": "number" + }, + "video_completions": { + "type": "number" + }, + "video_starts": { + "type": "number" + }, + "video_viewers": { + "type": "number" + } + }, + "type": "object" + }, + "traffic_daily": { + "item": { + "properties": { + "date": { + "type": "string" + }, + "page_views": { + "type": "number" + }, + "unique_visitors": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "until": { + "type": "string" + }, + "videos": { + "item": { + "properties": { + "completions": { + "type": "number" + }, + "placement": { + "type": "string" + }, + "progress_events": { + "type": "number" + }, + "starts": { + "type": "number" + }, + "users": { + "type": "number" + }, + "video_id": { + "type": "string" + }, + "video_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "video_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "VisualGameAdminPlayerInteractionsResponse": { + "properties": { + "generated_at": { + "type": "string" + }, + "items": { + "item": { + "properties": { + "created_at": { + "type": "string" + }, + "episode_no": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "id": { + "type": "string" + }, + "input_text": { + "type": "string" + }, + "mode": { + "options": [ + "act", + "say" + ], + "type": "picklist" + }, + "scenario_id": { + "type": "string" + }, + "scenario_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "season_no": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "series_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "type": "literal", + "value": "completed" + }, + "turn_no": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "since": { + "type": "string" + }, + "total": { + "type": "number" + }, + "until": { + "type": "string" + }, + "user_id": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameAdminUserActionResponse": { + "properties": { + "audit_id": { + "type": "string" + }, + "detail": { + "properties": { + "audit_logs": { + "item": { + "properties": { + "action": { + "options": [ + "account_provisioned", + "credits_deducted", + "credits_granted", + "profile_updated", + "subscription_updated" + ], + "type": "picklist" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "operator_id": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "credits": { + "properties": { + "balance": { + "type": "number" + }, + "ledger": { + "item": { + "properties": { + "amount": { + "type": "number" + }, + "balance_after": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "reason": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "ref_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "ref_type": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "type": { + "options": [ + "deduct", + "grant" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "guest_devices": { + "item": { + "properties": { + "last_seen_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "merged_user_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "active", + "merged" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "type": "array" + }, + "orders": { + "item": { + "properties": { + "amount_cents": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "mode": { + "options": [ + "payment", + "subscription" + ], + "type": "picklist" + }, + "product_id": { + "type": "string" + }, + "provider": { + "options": [ + "apple", + "google", + "stripe" + ], + "type": "picklist" + }, + "refund_amount": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "status": { + "options": [ + "canceled", + "disputed", + "failed", + "paid", + "pending", + "refunded" + ], + "type": "picklist" + }, + "updated_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + }, + "play": { + "properties": { + "recent_runs": { + "item": { + "properties": { + "completed_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "current_location_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "phase": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "scenario_id": { + "type": "string" + }, + "scenario_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "active", + "completed" + ], + "type": "picklist" + }, + "turn_count": { + "type": "number" + }, + "updated_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + }, + "summary": { + "properties": { + "act_turns": { + "type": "number" + }, + "active_runs": { + "type": "number" + }, + "completed_runs": { + "type": "number" + }, + "completed_seasons": { + "type": "number" + }, + "credits_granted": { + "type": "number" + }, + "credits_spent": { + "type": "number" + }, + "endings": { + "type": "number" + }, + "last_active_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "progress_records": { + "type": "number" + }, + "runs": { + "type": "number" + }, + "save_slots": { + "type": "number" + }, + "say_turns": { + "type": "number" + }, + "scenarios": { + "type": "number" + }, + "turns": { + "type": "number" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "subscription": { + "properties": { + "expires_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "level": { + "type": "string" + }, + "provider": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "source": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "active", + "canceled", + "expired", + "none" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "user": { + "properties": { + "account_locale": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "account_type": { + "options": [ + "guest", + "registered" + ], + "type": "picklist" + }, + "avatar_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "email": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "email_verified_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "nickname": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "profile_locale": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "VisualGameAdminUserCreditsAdjustRequest": { + "properties": { + "amount": { + "type": "number" + }, + "mutation_id": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "type": { + "options": [ + "deduct", + "grant" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "VisualGameAdminUserDetailResponse": { + "properties": { + "detail": { + "properties": { + "audit_logs": { + "item": { + "properties": { + "action": { + "options": [ + "account_provisioned", + "credits_deducted", + "credits_granted", + "profile_updated", + "subscription_updated" + ], + "type": "picklist" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "operator_id": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "credits": { + "properties": { + "balance": { + "type": "number" + }, + "ledger": { + "item": { + "properties": { + "amount": { + "type": "number" + }, + "balance_after": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "reason": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "ref_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "ref_type": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "type": { + "options": [ + "deduct", + "grant" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "guest_devices": { + "item": { + "properties": { + "last_seen_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "merged_user_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "active", + "merged" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "type": "array" + }, + "orders": { + "item": { + "properties": { + "amount_cents": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "mode": { + "options": [ + "payment", + "subscription" + ], + "type": "picklist" + }, + "product_id": { + "type": "string" + }, + "provider": { + "options": [ + "apple", + "google", + "stripe" + ], + "type": "picklist" + }, + "refund_amount": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "status": { + "options": [ + "canceled", + "disputed", + "failed", + "paid", + "pending", + "refunded" + ], + "type": "picklist" + }, + "updated_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + }, + "play": { + "properties": { + "recent_runs": { + "item": { + "properties": { + "completed_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "current_location_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "phase": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "scenario_id": { + "type": "string" + }, + "scenario_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "active", + "completed" + ], + "type": "picklist" + }, + "turn_count": { + "type": "number" + }, + "updated_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + }, + "summary": { + "properties": { + "act_turns": { + "type": "number" + }, + "active_runs": { + "type": "number" + }, + "completed_runs": { + "type": "number" + }, + "completed_seasons": { + "type": "number" + }, + "credits_granted": { + "type": "number" + }, + "credits_spent": { + "type": "number" + }, + "endings": { + "type": "number" + }, + "last_active_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "progress_records": { + "type": "number" + }, + "runs": { + "type": "number" + }, + "save_slots": { + "type": "number" + }, + "say_turns": { + "type": "number" + }, + "scenarios": { + "type": "number" + }, + "turns": { + "type": "number" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "subscription": { + "properties": { + "expires_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "level": { + "type": "string" + }, + "provider": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "source": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "active", + "canceled", + "expired", + "none" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "user": { + "properties": { + "account_locale": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "account_type": { + "options": [ + "guest", + "registered" + ], + "type": "picklist" + }, + "avatar_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "email": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "email_verified_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "nickname": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "profile_locale": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "VisualGameAdminUserListResponse": { + "properties": { + "items": { + "item": { + "properties": { + "account_type": { + "options": [ + "guest", + "registered" + ], + "type": "picklist" + }, + "created_at": { + "type": "string" + }, + "credits": { + "type": "number" + }, + "display_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "email": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "interaction_stats": { + "properties": { + "act_turns": { + "type": "number" + }, + "completed_runs": { + "type": "number" + }, + "completed_seasons": { + "type": "number" + }, + "credits_granted": { + "type": "number" + }, + "credits_spent": { + "type": "number" + }, + "interactions": { + "type": "number" + }, + "say_turns": { + "type": "number" + } + }, + "type": "object" + }, + "last_active_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "locale": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "stats": { + "properties": { + "endings": { + "type": "number" + }, + "orders": { + "type": "number" + }, + "runs": { + "type": "number" + }, + "turns": { + "type": "number" + } + }, + "type": "object" + }, + "subscription": { + "properties": { + "expires_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "level": { + "type": "string" + }, + "provider": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "source": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "active", + "canceled", + "expired", + "none" + ], + "type": "picklist" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "next_cursor": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "total": { + "type": "number" + } + }, + "type": "object" + }, + "VisualGameAdminUserProfileUpdateRequest": { + "properties": { + "avatar_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh" + ], + "type": "picklist" + }, + "mutation_id": { + "type": "string" + }, + "nickname": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "reason": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameAdminUserProvisionRequest": { + "properties": { + "email": { + "type": "string" + }, + "initial_credits": { + "type": "number" + }, + "locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh" + ], + "type": "picklist" + }, + "mutation_id": { + "type": "string" + }, + "password": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "type": "strict_object" + }, + "VisualGameAdminUserSubscriptionUpdateRequest": { + "properties": { + "expires_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "level": { + "type": "string" + }, + "mutation_id": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "options": [ + "active", + "canceled", + "expired", + "none" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "VisualGameAdminUserSummary": { + "properties": { + "guest_users": { + "type": "number" + }, + "paying_users": { + "type": "number" + }, + "players": { + "type": "number" + }, + "registered_users": { + "type": "number" + }, + "total_users": { + "type": "number" + } + }, + "type": "object" + }, + "VisualGameAgentCall": { + "properties": { + "agent_role": { + "type": "string" + }, + "cache_write_tokens": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "cached_tokens": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "completed_at": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "completion_tokens": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "cost_cents": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "error_message": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "id": { + "type": "string" + }, + "model": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "prompt_hash": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "prompt_tokens": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "request": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "response": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "run_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "turn_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + } + }, + "type": "object" + }, + "VisualGameAgentCallList": { + "properties": { + "calls": { + "item": { + "properties": { + "agent_role": { + "type": "string" + }, + "cache_write_tokens": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "cached_tokens": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "completed_at": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "completion_tokens": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "cost_cents": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "error_message": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "id": { + "type": "string" + }, + "model": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "prompt_hash": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "prompt_tokens": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "request": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "response": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "run_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "turn_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + } + }, + "type": "object" + }, + "type": "array" + }, + "summary": { + "properties": { + "failed_calls": { + "type": "number" + }, + "models": { + "item": { + "properties": { + "failed_calls": { + "type": "number" + }, + "model": { + "type": "string" + }, + "total_calls": { + "type": "number" + }, + "total_completion_tokens": { + "type": "number" + }, + "total_cost_cents": { + "type": "number" + }, + "total_prompt_tokens": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "roles": { + "item": { + "properties": { + "failed_calls": { + "type": "number" + }, + "role": { + "type": "string" + }, + "total_calls": { + "type": "number" + }, + "total_completion_tokens": { + "type": "number" + }, + "total_cost_cents": { + "type": "number" + }, + "total_prompt_tokens": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "total_calls": { + "type": "number" + }, + "total_completion_tokens": { + "type": "number" + }, + "total_cost_cents": { + "type": "number" + }, + "total_prompt_tokens": { + "type": "number" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "VisualGameAgentCallModelSummary": { + "properties": { + "failed_calls": { + "type": "number" + }, + "model": { + "type": "string" + }, + "total_calls": { + "type": "number" + }, + "total_completion_tokens": { + "type": "number" + }, + "total_cost_cents": { + "type": "number" + }, + "total_prompt_tokens": { + "type": "number" + } + }, + "type": "object" + }, + "VisualGameAgentCallRoleSummary": { + "properties": { + "failed_calls": { + "type": "number" + }, + "role": { + "type": "string" + }, + "total_calls": { + "type": "number" + }, + "total_completion_tokens": { + "type": "number" + }, + "total_cost_cents": { + "type": "number" + }, + "total_prompt_tokens": { + "type": "number" + } + }, + "type": "object" + }, + "VisualGameAgentCallSummary": { + "properties": { + "failed_calls": { + "type": "number" + }, + "models": { + "item": { + "properties": { + "failed_calls": { + "type": "number" + }, + "model": { + "type": "string" + }, + "total_calls": { + "type": "number" + }, + "total_completion_tokens": { + "type": "number" + }, + "total_cost_cents": { + "type": "number" + }, + "total_prompt_tokens": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "roles": { + "item": { + "properties": { + "failed_calls": { + "type": "number" + }, + "role": { + "type": "string" + }, + "total_calls": { + "type": "number" + }, + "total_completion_tokens": { + "type": "number" + }, + "total_cost_cents": { + "type": "number" + }, + "total_prompt_tokens": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "total_calls": { + "type": "number" + }, + "total_completion_tokens": { + "type": "number" + }, + "total_cost_cents": { + "type": "number" + }, + "total_prompt_tokens": { + "type": "number" + } + }, + "type": "object" + }, + "VisualGameAssetIndexItem": { + "properties": { + "file": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "hash": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "height": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "manifest_id": { + "type": "string" + }, + "mime": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "object_key": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "public_url": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "size": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "tag": { + "type": "string" + }, + "type": { + "type": "string" + }, + "width": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + } + }, + "type": "object" + }, + "VisualGameAssetManifest": { + "properties": { + "audio_rules": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "id": { + "type": "string" + }, + "stage_rules": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "tags": { + "item": { + "properties": { + "desc": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "file": { + "type": "string" + }, + "hash": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "mime": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "object_key": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "public_url": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "size": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "tag": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "VisualGameContentTranslationInventory": { + "properties": { + "base_version_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "items": { + "item": { + "properties": { + "field": { + "type": "string" + }, + "pointer": { + "type": "string" + }, + "section": { + "type": "string" + }, + "source_text": { + "type": "string" + }, + "status": { + "options": [ + "pending", + "reused", + "source", + "translated" + ], + "type": "picklist" + }, + "translated_text": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + }, + "locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh-CN" + ], + "type": "picklist" + }, + "pack_id": { + "type": "string" + }, + "status": { + "options": [ + "missing", + "ready", + "source", + "unsupported" + ], + "type": "picklist" + }, + "version_id": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameContentTranslationInventoryItem": { + "properties": { + "field": { + "type": "string" + }, + "pointer": { + "type": "string" + }, + "section": { + "type": "string" + }, + "source_text": { + "type": "string" + }, + "status": { + "options": [ + "pending", + "reused", + "source", + "translated" + ], + "type": "picklist" + }, + "translated_text": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "VisualGameContentTranslationLocaleStatus": { + "properties": { + "base_version_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh-CN" + ], + "type": "picklist" + }, + "pending_text_count": { + "type": "number" + }, + "replace_required": { + "type": "boolean" + }, + "reusable_text_count": { + "type": "number" + }, + "status": { + "options": [ + "missing", + "ready", + "saving", + "source", + "translating", + "unsupported" + ], + "type": "picklist" + }, + "updated_at": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "type": "object" + }, + "VisualGameContentTranslationProgress": { + "properties": { + "completed_text_count": { + "type": "number" + }, + "error_message": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh-CN" + ], + "type": "picklist" + }, + "pack_id": { + "type": "string" + }, + "status": { + "options": [ + "completed", + "failed", + "idle", + "saving", + "translating" + ], + "type": "picklist" + }, + "total_text_count": { + "type": "number" + }, + "version_id": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameContentTranslationResult": { + "properties": { + "base_version_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh-CN" + ], + "type": "picklist" + }, + "pack_id": { + "type": "string" + }, + "reused_text_count": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "status": { + "options": [ + "skipped", + "translated" + ], + "type": "picklist" + }, + "text_count": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "translated_text_count": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "version_id": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameContentTranslationStatus": { + "properties": { + "locales": { + "item": { + "properties": { + "base_version_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh-CN" + ], + "type": "picklist" + }, + "pending_text_count": { + "type": "number" + }, + "replace_required": { + "type": "boolean" + }, + "reusable_text_count": { + "type": "number" + }, + "status": { + "options": [ + "missing", + "ready", + "saving", + "source", + "translating", + "unsupported" + ], + "type": "picklist" + }, + "updated_at": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "type": "object" + }, + "type": "array" + }, + "pack_id": { + "type": "string" + }, + "source_locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh-CN" + ], + "type": "picklist" + }, + "source_text_count": { + "type": "number" + }, + "supported": { + "type": "boolean" + }, + "version_id": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameCreateRunResponse": { + "properties": { + "available_clues": { + "item": { + "type": "unknown" + }, + "type": "array" + }, + "available_conclusions": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "known_clues": { + "item": { + "type": "unknown" + }, + "type": "array" + }, + "known_conclusions": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "run": { + "type": "unknown" + }, + "run_id": { + "type": "string" + }, + "scenario": { + "type": "unknown" + }, + "unlocked_info": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "type": "object" + }, + "VisualGameEditAuditLog": { + "properties": { + "action": { + "type": "string" + }, + "actor_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "after": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "before": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "draft_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "target_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "target_type": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameEditAuditLogList": { + "properties": { + "logs": { + "item": { + "properties": { + "action": { + "type": "string" + }, + "actor_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "after": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "before": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "draft_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "target_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "target_type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "VisualGameEndingText": { + "properties": { + "generated_at": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "poem": { + "type": "string" + }, + "stats": { + "type": "unknown" + }, + "summary": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameKpAgentOutput": { + "properties": { + "action": { + "properties": { + "claims": { + "item": { + "type": "string" + }, + "type": "array" + }, + "intent": { + "options": [ + "accept", + "approach", + "attack", + "comfort", + "confront", + "deceive", + "destroy", + "escape", + "investigate", + "move", + "question", + "refuse", + "retreat", + "surrender", + "wait" + ], + "type": "picklist" + }, + "risk": { + "options": [ + "fatal", + "high", + "low", + "normal" + ], + "type": "picklist" + }, + "stance": { + "options": [ + "cautious", + "deceptive", + "hostile", + "neutral", + "trusting" + ], + "type": "picklist" + }, + "targets": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "type": "strict_object" + }, + "branch_resolution": { + "type": "optional", + "wrapped": { + "options": [ + { + "properties": { + "matched": { + "type": "literal", + "value": false + } + }, + "type": "strict_object" + }, + { + "properties": { + "branch_id": { + "type": "string" + }, + "decision": { + "type": "literal", + "value": "continue" + }, + "matched": { + "type": "literal", + "value": true + }, + "summary": { + "type": "string" + } + }, + "type": "strict_object" + }, + { + "properties": { + "branch_id": { + "type": "string" + }, + "decision": { + "type": "literal", + "value": "resolve" + }, + "matched": { + "type": "literal", + "value": true + }, + "resolution_summary": { + "type": "string" + } + }, + "type": "strict_object" + } + ], + "type": "union" + } + }, + "clueJudgements": { + "item": { + "properties": { + "basis": { + "type": "string" + }, + "clueId": { + "type": "string" + }, + "discovered": { + "type": "boolean" + } + }, + "type": "strict_object" + }, + "type": "array" + }, + "expression_state": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "judgementSuggestion": { + "properties": { + "evidenceAdded": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "flagsSet": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "options": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "type": "union" + } + } + ], + "type": "intersect" + } + }, + "inventoryChanges": { + "type": "optional", + "wrapped": { + "properties": { + "add": { + "item": { + "type": "string" + }, + "type": "array" + }, + "remove": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "type": "strict_object" + } + }, + "locationChange": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "notes": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "npcStateChanges": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "properties": { + "knownFacts": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "locationId": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "mood": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "position": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "relationshipFlags": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "options": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "type": "union" + } + } + ], + "type": "intersect" + } + }, + "revealedSecrets": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + } + }, + "type": "strict_object" + } + } + ], + "type": "intersect" + } + }, + "objectStateChanges": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "properties": { + "damaged": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "flags": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "options": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "type": "union" + } + } + ], + "type": "intersect" + } + }, + "heldBy": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "inspected": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "known": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "location": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "state": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "usable": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + } + }, + "type": "strict_object" + } + } + ], + "type": "intersect" + } + }, + "reason": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "statChanges": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "type": "number" + } + } + ], + "type": "intersect" + } + }, + "valid": { + "type": "boolean" + } + }, + "type": "strict_object" + }, + "npcDirective": { + "properties": { + "mood": { + "options": [ + "cold_gentle", + "gentle", + "gentle_but_alert", + "neutral", + "sad", + "threatening" + ], + "type": "picklist" + }, + "mustHide": { + "item": { + "type": "string" + }, + "type": "array" + }, + "mustReactTo": { + "item": { + "type": "string" + }, + "type": "array" + }, + "npcId": { + "type": "string" + }, + "tactic": { + "type": "string" + } + }, + "type": "strict_object" + }, + "responsePolicy": { + "properties": { + "npcReaction": { + "options": [ + "none", + "optional", + "required" + ], + "type": "picklist" + }, + "reason": { + "type": "string" + } + }, + "type": "strict_object" + }, + "sceneResult": { + "properties": { + "discoveredObjectIds": { + "item": { + "type": "string" + }, + "type": "array" + }, + "narration": { + "type": "string" + }, + "visibleFindings": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "type": "strict_object" + }, + "story_signal": { + "type": "optional", + "wrapped": { + "options": [ + { + "properties": { + "reason": { + "type": "string" + }, + "should_track": { + "type": "literal", + "value": false + } + }, + "type": "strict_object" + }, + { + "properties": { + "continuation_tracker_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "decisive_action": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "distinct_progress": { + "type": "boolean" + }, + "player_intended_action": { + "type": "string" + }, + "progress_marker": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "should_track": { + "type": "literal", + "value": true + }, + "topic_cluster": { + "type": "string" + }, + "topic_kind": { + "options": [ + "ally_intervention", + "countermeasure", + "external_entity", + "identity", + "other", + "relationship", + "ritual", + "world_lore" + ], + "type": "picklist" + }, + "topic_summary": { + "type": "string" + } + }, + "type": "strict_object" + } + ], + "type": "union" + } + } + }, + "type": "strict_object" + }, + "VisualGameNotification": { + "properties": { + "action_href": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "read_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "resource_id": { + "type": "string" + }, + "resource_type": { + "type": "literal", + "value": "run_video" + }, + "scenario_title": { + "type": "string" + }, + "season_no": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "series_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "type": { + "options": [ + "video_generation_failed", + "video_generation_ready" + ], + "type": "picklist" + }, + "video_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "VisualGameNotificationList": { + "properties": { + "notifications": { + "item": { + "properties": { + "action_href": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "read_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "resource_id": { + "type": "string" + }, + "resource_type": { + "type": "literal", + "value": "run_video" + }, + "scenario_title": { + "type": "string" + }, + "season_no": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "series_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "type": { + "options": [ + "video_generation_failed", + "video_generation_ready" + ], + "type": "picklist" + }, + "video_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "VisualGameNpcActionKpOutput": { + "properties": { + "approvedNpcActions": { + "item": { + "properties": { + "result": { + "type": "string" + }, + "targetId": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + }, + "judgementSuggestion": { + "properties": { + "evidenceAdded": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "flagsSet": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "options": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "type": "union" + } + } + ], + "type": "intersect" + } + }, + "inventoryChanges": { + "type": "optional", + "wrapped": { + "properties": { + "add": { + "item": { + "type": "string" + }, + "type": "array" + }, + "remove": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "type": "strict_object" + } + }, + "locationChange": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "notes": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "npcStateChanges": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "properties": { + "knownFacts": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "locationId": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "mood": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "position": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "relationshipFlags": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "options": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "type": "union" + } + } + ], + "type": "intersect" + } + }, + "revealedSecrets": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + } + }, + "type": "strict_object" + } + } + ], + "type": "intersect" + } + }, + "objectStateChanges": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "properties": { + "damaged": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "flags": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "options": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "type": "union" + } + } + ], + "type": "intersect" + } + }, + "heldBy": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "inspected": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "known": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "location": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "state": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "usable": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + } + }, + "type": "strict_object" + } + } + ], + "type": "intersect" + } + }, + "reason": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "statChanges": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "type": "number" + } + } + ], + "type": "intersect" + } + }, + "valid": { + "type": "boolean" + } + }, + "type": "strict_object" + }, + "npcId": { + "type": "string" + }, + "sceneResult": { + "properties": { + "discoveredObjectIds": { + "item": { + "type": "string" + }, + "type": "array" + }, + "narration": { + "type": "string" + }, + "visibleFindings": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "type": "strict_object" + } + }, + "type": "strict_object" + }, + "VisualGameNpcActorOutput": { + "properties": { + "expression_state": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "narration": { + "type": "string" + }, + "npcLine": { + "type": "string" + }, + "playerOptions": { + "properties": { + "act": { + "type": "string" + }, + "say": { + "type": "string" + } + }, + "type": "strict_object" + }, + "playerPromptType": { + "options": [ + "action_invitation", + "none", + "question", + "warning" + ], + "type": "picklist" + }, + "proposedNpcActions": { + "item": { + "properties": { + "candidate_delta": { + "type": "optional", + "wrapped": { + "properties": { + "evidenceAdded": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "flagsSet": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "options": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "type": "union" + } + } + ], + "type": "intersect" + } + }, + "inventoryChanges": { + "type": "optional", + "wrapped": { + "properties": { + "add": { + "item": { + "type": "string" + }, + "type": "array" + }, + "remove": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "type": "strict_object" + } + }, + "locationChange": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "notes": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "npcStateChanges": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "properties": { + "knownFacts": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + }, + "locationId": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "mood": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "position": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "relationshipFlags": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "options": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "type": "union" + } + } + ], + "type": "intersect" + } + }, + "revealedSecrets": { + "type": "optional", + "wrapped": { + "item": { + "type": "string" + }, + "type": "array" + } + } + }, + "type": "strict_object" + } + } + ], + "type": "intersect" + } + }, + "objectStateChanges": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "properties": { + "damaged": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "flags": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "options": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "type": "union" + } + } + ], + "type": "intersect" + } + }, + "heldBy": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "inspected": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "known": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "location": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "state": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "usable": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + } + }, + "type": "strict_object" + } + } + ], + "type": "intersect" + } + }, + "statChanges": { + "type": "optional", + "wrapped": { + "options": [ + { + "type": "custom" + }, + { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "type": "number" + } + } + ], + "type": "intersect" + } + } + }, + "type": "strict_object" + } + }, + "description": { + "type": "string" + }, + "intent": { + "type": "string" + }, + "targetId": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + }, + "tacticUsed": { + "type": "string" + }, + "tone": { + "type": "string" + } + }, + "type": "strict_object" + }, + "VisualGamePack": { + "properties": { + "assets": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "file": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "hash": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "height": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "manifest_id": { + "type": "string" + }, + "mime": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "object_key": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "public_url": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "size": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "tag": { + "type": "string" + }, + "type": { + "type": "string" + }, + "width": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "current_version_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "episode_no": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "owner_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "sections": { + "type": "optional", + "wrapped": { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "type": "unknown" + } + } + }, + "series_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "series_title": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "status": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tags": { + "item": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "versions": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "asset_manifest_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "compile_result": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "id": { + "type": "string" + }, + "is_current": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "pack_id": { + "type": "string" + }, + "published_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "scenario": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "status": { + "type": "string" + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "visibility": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "VisualGamePackAssetUploadResponse": { + "properties": { + "asset": { + "properties": { + "file": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "hash": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "height": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "manifest_id": { + "type": "string" + }, + "mime": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "object_key": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "public_url": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "size": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "tag": { + "type": "string" + }, + "type": { + "type": "string" + }, + "width": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + } + }, + "type": "object" + }, + "ok": { + "type": "boolean" + }, + "pack": { + "properties": { + "assets": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "file": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "hash": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "height": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "manifest_id": { + "type": "string" + }, + "mime": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "object_key": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "public_url": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "size": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "tag": { + "type": "string" + }, + "type": { + "type": "string" + }, + "width": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "current_version_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "episode_no": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "owner_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "sections": { + "type": "optional", + "wrapped": { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "type": "unknown" + } + } + }, + "series_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "series_title": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "status": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tags": { + "item": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "versions": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "asset_manifest_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "compile_result": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "id": { + "type": "string" + }, + "is_current": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "pack_id": { + "type": "string" + }, + "published_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "scenario": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "status": { + "type": "string" + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "visibility": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + } + }, + "type": "object" + }, + "VisualGamePackCompileResponse": { + "properties": { + "compile_result": { + "type": "unknown" + }, + "ok": { + "type": "boolean" + }, + "pack": { + "properties": { + "assets": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "file": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "hash": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "height": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "manifest_id": { + "type": "string" + }, + "mime": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "object_key": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "public_url": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "size": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "tag": { + "type": "string" + }, + "type": { + "type": "string" + }, + "width": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "current_version_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "episode_no": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "owner_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "sections": { + "type": "optional", + "wrapped": { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "type": "unknown" + } + } + }, + "series_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "series_title": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "status": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tags": { + "item": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "versions": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "asset_manifest_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "compile_result": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "id": { + "type": "string" + }, + "is_current": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "pack_id": { + "type": "string" + }, + "published_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "scenario": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "status": { + "type": "string" + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "visibility": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + } + }, + "type": "object" + }, + "VisualGamePackDeleteResponse": { + "properties": { + "mode": { + "options": [ + "archived", + "deleted" + ], + "type": "picklist" + }, + "pack": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "properties": { + "assets": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "file": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "hash": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "height": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "manifest_id": { + "type": "string" + }, + "mime": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "object_key": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "public_url": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "size": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "tag": { + "type": "string" + }, + "type": { + "type": "string" + }, + "width": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "current_version_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "episode_no": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "owner_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "sections": { + "type": "optional", + "wrapped": { + "key": { + "type": "string" + }, + "type": "record", + "value": { + "type": "unknown" + } + } + }, + "series_id": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "series_title": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "status": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tags": { + "item": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "versions": { + "type": "optional", + "wrapped": { + "item": { + "properties": { + "asset_manifest_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "compile_result": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "id": { + "type": "string" + }, + "is_current": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "pack_id": { + "type": "string" + }, + "published_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "scenario": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "status": { + "type": "string" + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "visibility": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + } + } + } + }, + "type": "object" + }, + "VisualGamePackList": { + "properties": { + "packs": { + "item": { + "type": "unknown" + }, + "type": "array" + }, + "sections": { + "item": { + "properties": { + "description": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "file": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "value": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "VisualGamePreviewTokenResponse": { + "properties": { + "expires_at": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "type": "object" + }, + "VisualGameRunDetail": { + "properties": { + "available_clues": { + "item": { + "type": "unknown" + }, + "type": "array" + }, + "available_conclusions": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "ending_records": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "known_clues": { + "item": { + "type": "unknown" + }, + "type": "array" + }, + "known_conclusions": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "run": { + "type": "unknown" + }, + "scenario": { + "type": "unknown" + }, + "turn_video_stage": { + "type": "nullable", + "wrapped": { + "properties": { + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "turn_id": { + "type": "string" + } + }, + "type": "object" + } + }, + "unlocked_info": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "type": "object" + }, + "VisualGameScenarioListItem": { + "properties": { + "asset_manifest_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "author_avatar_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "author_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "author_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "cover_url": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "ending_count": { + "type": "number" + }, + "episode_no": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "id": { + "type": "string" + }, + "like_count": { + "type": "number" + }, + "play_count": { + "type": "number" + }, + "play_state": { + "options": [ + "available", + "coming_soon" + ], + "type": "picklist" + }, + "portrait_cover_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "series_cover_url": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "series_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "series_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "type": "optional", + "wrapped": { + "options": [ + "coming_soon", + "published" + ], + "type": "picklist" + } + }, + "summary": { + "type": "string" + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tags": { + "item": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + } + }, + "type": "object" + }, + "VisualGameScenarioPage": { + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "item": { + "properties": { + "asset_manifest_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "author_avatar_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "author_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "author_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "cover_url": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "ending_count": { + "type": "number" + }, + "episode_no": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "id": { + "type": "string" + }, + "like_count": { + "type": "number" + }, + "play_count": { + "type": "number" + }, + "play_state": { + "options": [ + "available", + "coming_soon" + ], + "type": "picklist" + }, + "portrait_cover_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "series_cover_url": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "series_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "series_title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "type": "optional", + "wrapped": { + "options": [ + "coming_soon", + "published" + ], + "type": "picklist" + } + }, + "summary": { + "type": "string" + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "tags": { + "item": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + } + }, + "type": "object" + }, + "type": "array" + }, + "page": { + "type": "number" + }, + "page_size": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "type": "object" + }, + "VisualGameScenarioReleaseApplyResponse": { + "properties": { + "assets_copied": { + "type": "number" + }, + "assets_reused": { + "type": "number" + }, + "bytes_copied": { + "type": "number" + }, + "current_version_id": { + "type": "string" + }, + "ok": { + "type": "literal", + "value": true + }, + "previous_version_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "request_id": { + "type": "string" + }, + "scenario_id": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "type": "strict_object" + }, + "VisualGameScenarioReleaseBundle": { + "properties": { + "pack": { + "properties": { + "created_at": { + "type": "string" + }, + "current_version_id": { + "type": "string" + }, + "episode_no": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "series_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "type": "literal", + "value": "published" + }, + "tagline": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "tags_json": { + "item": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "visibility": { + "type": "string" + } + }, + "type": "strict_object" + }, + "publish_checks": { + "item": { + "properties": { + "check_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "message": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "target_ref": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "updated_at": { + "type": "string" + }, + "version_id": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + }, + "runtime_translation_manifests": { + "item": { + "properties": { + "created_at": { + "type": "string" + }, + "entries_json": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "schema_version": { + "type": "number" + }, + "updated_at": { + "type": "string" + }, + "version_id": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + }, + "series": { + "type": "nullable", + "wrapped": { + "properties": { + "cover_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tagline": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "visibility": { + "type": "string" + } + }, + "type": "strict_object" + } + }, + "translations": { + "item": { + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "entity_id": { + "type": "string" + }, + "entity_type": { + "options": [ + "ending", + "scenario_pack", + "scenario_version", + "series" + ], + "type": "picklist" + }, + "field": { + "options": [ + "description", + "runtime_texts", + "summary", + "tagline", + "title" + ], + "type": "picklist" + }, + "locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh-CN" + ], + "type": "picklist" + }, + "updated_at": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + }, + "version": { + "properties": { + "asset_manifest_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "compile_result_json": { + "type": "string" + }, + "compiler_version": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "pack_id": { + "type": "string" + }, + "portrait_cover_tag": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "published_at": { + "type": "string" + }, + "scenario_snapshot_json": { + "type": "string" + }, + "status": { + "type": "literal", + "value": "published" + }, + "tagline": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "type": "strict_object" + }, + "version_assets": { + "item": { + "properties": { + "asset_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "hash": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "mime": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "object_key": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "public_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "size": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version_id": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + } + }, + "type": "strict_object" + }, + "VisualGameScenarioReleaseRecord": { + "properties": { + "applied_version_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "asset_bytes": { + "type": "number" + }, + "asset_count": { + "type": "number" + }, + "assets_copied": { + "type": "number" + }, + "assets_reused": { + "type": "number" + }, + "bundle_checksum": { + "type": "string" + }, + "bytes_copied": { + "type": "number" + }, + "completed_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "direction": { + "options": [ + "inbound", + "outbound" + ], + "type": "picklist" + }, + "error_code": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "error_message": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "previous_version_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "requested_by": { + "type": "string" + }, + "scenario_id": { + "type": "string" + }, + "source_version_id": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "options": [ + "applying", + "failed", + "prepared", + "succeeded", + "transferring" + ], + "type": "picklist" + }, + "target_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "updated_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "strict_object" + }, + "VisualGameScenarioReleaseRequest": { + "properties": { + "bundle": { + "properties": { + "pack": { + "properties": { + "created_at": { + "type": "string" + }, + "current_version_id": { + "type": "string" + }, + "episode_no": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "id": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "series_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "type": "literal", + "value": "published" + }, + "tagline": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "tags_json": { + "item": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "visibility": { + "type": "string" + } + }, + "type": "strict_object" + }, + "publish_checks": { + "item": { + "properties": { + "check_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "message": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "target_ref": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "updated_at": { + "type": "string" + }, + "version_id": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + }, + "runtime_translation_manifests": { + "item": { + "properties": { + "created_at": { + "type": "string" + }, + "entries_json": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "schema_version": { + "type": "number" + }, + "updated_at": { + "type": "string" + }, + "version_id": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + }, + "series": { + "type": "nullable", + "wrapped": { + "properties": { + "cover_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tagline": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "visibility": { + "type": "string" + } + }, + "type": "strict_object" + } + }, + "translations": { + "item": { + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "entity_id": { + "type": "string" + }, + "entity_type": { + "options": [ + "ending", + "scenario_pack", + "scenario_version", + "series" + ], + "type": "picklist" + }, + "field": { + "options": [ + "description", + "runtime_texts", + "summary", + "tagline", + "title" + ], + "type": "picklist" + }, + "locale": { + "options": [ + "en", + "es", + "ja", + "ko", + "pt", + "zh-CN" + ], + "type": "picklist" + }, + "updated_at": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + }, + "version": { + "properties": { + "asset_manifest_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "compile_result_json": { + "type": "string" + }, + "compiler_version": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "pack_id": { + "type": "string" + }, + "portrait_cover_tag": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "published_at": { + "type": "string" + }, + "scenario_snapshot_json": { + "type": "string" + }, + "status": { + "type": "literal", + "value": "published" + }, + "tagline": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "type": "strict_object" + }, + "version_assets": { + "item": { + "properties": { + "asset_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "hash": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "mime": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "object_key": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "public_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "size": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version_id": { + "type": "string" + } + }, + "type": "strict_object" + }, + "type": "array" + } + }, + "type": "strict_object" + }, + "bundle_checksum": { + "type": "string" + }, + "release_id": { + "type": "string" + }, + "requested_by": { + "type": "string" + } + }, + "type": "strict_object" + }, + "VisualGameScenarioReleaseTargetState": { + "properties": { + "assets": { + "type": "number" + }, + "current_version_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "exists": { + "type": "boolean" + }, + "published_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "scenario_id": { + "type": "string" + }, + "title": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "version": { + "type": "nullable", + "wrapped": { + "type": "number" + } + } + }, + "type": "strict_object" + }, + "VisualGameScenarioVersion": { + "properties": { + "asset_manifest_id": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "compile_result": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "created_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "id": { + "type": "string" + }, + "is_current": { + "type": "optional", + "wrapped": { + "type": "boolean" + } + }, + "pack_id": { + "type": "string" + }, + "published_at": { + "type": "optional", + "wrapped": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "scenario": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "status": { + "type": "string" + }, + "tagline": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "type": "object" + }, + "VisualGameStoryVideoLog": { + "properties": { + "run": { + "type": "unknown" + }, + "run_id": { + "type": "string" + }, + "turns": { + "item": { + "properties": { + "playback_plan": { + "properties": { + "segments": { + "item": { + "options": [ + { + "properties": { + "kind": { + "type": "literal", + "value": "action" + }, + "match_method": { + "options": [ + "exact_transition", + "intent_video", + "semantic_reuse" + ], + "type": "picklist" + }, + "order": { + "type": "number" + }, + "similarity": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + { + "properties": { + "actor_id": { + "type": "string" + }, + "actor_type": { + "options": [ + "npc", + "player" + ], + "type": "picklist" + }, + "expression_state": { + "type": "string" + }, + "kind": { + "type": "literal", + "value": "speech" + }, + "loopable": { + "type": "boolean" + }, + "order": { + "type": "number" + }, + "text": { + "type": "string" + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + ], + "type": "union" + }, + "type": "array" + }, + "turn_id": { + "type": "string" + } + }, + "type": "object" + }, + "replay_plan": { + "type": "nullable", + "wrapped": { + "properties": { + "segments": { + "item": { + "options": [ + { + "properties": { + "kind": { + "type": "literal", + "value": "action" + }, + "match_method": { + "options": [ + "exact_transition", + "intent_video", + "semantic_reuse" + ], + "type": "picklist" + }, + "order": { + "type": "number" + }, + "similarity": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + { + "properties": { + "actor_id": { + "type": "string" + }, + "actor_type": { + "options": [ + "npc", + "player" + ], + "type": "picklist" + }, + "audio": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "expression_state": { + "type": "string" + }, + "kind": { + "type": "literal", + "value": "speech" + }, + "loopable": { + "type": "boolean" + }, + "order": { + "type": "number" + }, + "text": { + "type": "string" + }, + "tts_call_id": { + "type": "string" + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + ], + "type": "union" + }, + "type": "array" + }, + "turn_id": { + "type": "string" + } + }, + "type": "object" + } + }, + "turn_no": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "VisualGameTtsVoice": { + "properties": { + "display_name": { + "type": "string" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "voice_id": { + "type": "string" + }, + "voice_type": { + "options": [ + "clone", + "native" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "VisualGameTtsVoiceList": { + "properties": { + "voices": { + "item": { + "properties": { + "display_name": { + "type": "string" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "voice_id": { + "type": "string" + }, + "voice_type": { + "options": [ + "clone", + "native" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "VisualGameTurnEvent": { + "options": [ + { + "properties": { + "action": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "available_clues": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "available_conclusions": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "current_balance": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "ending": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "ending_records": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "error": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "error_code": { + "type": "optional", + "wrapped": { + "options": [ + "AI_OUTPUT_INVALID", + "ALREADY_UNLOCKED", + "BAD_REQUEST", + "CONFLICT", + "FORBIDDEN", + "INSUFFICIENT_BALANCE", + "NOT_FOUND", + "PRODUCT_NOT_FOUND", + "RULE_NOT_FOUND", + "SAVE_SLOT_CONFLICT", + "content_filter", + "internal_error" + ], + "type": "picklist" + } + }, + "judgement": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "known_clues": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "known_conclusions": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "kp_output": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "newly_unlocked": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "newly_unlocked_clues": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + }, + "npc_action_judgement": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "npc_action_kp_output": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "npc_action_result": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "required_points": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "response": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "run": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "scenario": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "scene_result": { + "type": "optional", + "wrapped": { + "type": "unknown" + } + }, + "shortfall_points": { + "type": "optional", + "wrapped": { + "type": "number" + } + }, + "turn_playback_plan": { + "type": "optional", + "wrapped": { + "properties": { + "segments": { + "item": { + "options": [ + { + "properties": { + "kind": { + "type": "literal", + "value": "action" + }, + "match_method": { + "options": [ + "exact_transition", + "intent_video", + "semantic_reuse" + ], + "type": "picklist" + }, + "order": { + "type": "number" + }, + "similarity": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + { + "properties": { + "actor_id": { + "type": "string" + }, + "actor_type": { + "options": [ + "npc", + "player" + ], + "type": "picklist" + }, + "expression_state": { + "type": "string" + }, + "kind": { + "type": "literal", + "value": "speech" + }, + "loopable": { + "type": "boolean" + }, + "order": { + "type": "number" + }, + "text": { + "type": "string" + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + ], + "type": "union" + }, + "type": "array" + }, + "turn_id": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": { + "options": [ + "action_rejected", + "dynamic_story", + "error", + "final", + "npc_action_kp", + "npc_response", + "player_kp" + ], + "type": "picklist" + }, + "unlocked_info": { + "type": "optional", + "wrapped": { + "item": { + "type": "unknown" + }, + "type": "array" + } + } + }, + "type": "object" + }, + { + "properties": { + "turn_playback_plan": { + "properties": { + "segments": { + "item": { + "options": [ + { + "properties": { + "kind": { + "type": "literal", + "value": "action" + }, + "match_method": { + "options": [ + "exact_transition", + "intent_video", + "semantic_reuse" + ], + "type": "picklist" + }, + "order": { + "type": "number" + }, + "similarity": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + { + "properties": { + "actor_id": { + "type": "string" + }, + "actor_type": { + "options": [ + "npc", + "player" + ], + "type": "picklist" + }, + "expression_state": { + "type": "string" + }, + "kind": { + "type": "literal", + "value": "speech" + }, + "loopable": { + "type": "boolean" + }, + "order": { + "type": "number" + }, + "text": { + "type": "string" + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + ], + "type": "union" + }, + "type": "array" + }, + "turn_id": { + "type": "string" + } + }, + "type": "object" + }, + "type": { + "type": "literal", + "value": "turn_playback_plan" + } + }, + "type": "object" + }, + { + "properties": { + "phase": { + "options": [ + "npc", + "player" + ], + "type": "picklist" + }, + "turn_playback_plan": { + "properties": { + "segments": { + "item": { + "options": [ + { + "properties": { + "kind": { + "type": "literal", + "value": "action" + }, + "match_method": { + "options": [ + "exact_transition", + "intent_video", + "semantic_reuse" + ], + "type": "picklist" + }, + "order": { + "type": "number" + }, + "similarity": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + { + "properties": { + "actor_id": { + "type": "string" + }, + "actor_type": { + "options": [ + "npc", + "player" + ], + "type": "picklist" + }, + "expression_state": { + "type": "string" + }, + "kind": { + "type": "literal", + "value": "speech" + }, + "loopable": { + "type": "boolean" + }, + "order": { + "type": "number" + }, + "text": { + "type": "string" + }, + "video": { + "properties": { + "asset": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "end_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "start_frame": { + "properties": { + "hash": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + ], + "type": "union" + }, + "type": "array" + }, + "turn_id": { + "type": "string" + } + }, + "type": "object" + }, + "type": { + "type": "literal", + "value": "turn_playback_phase" + } + }, + "type": "object" + } + ], + "type": "union" + }, + "WaitlistActionResponse": { + "properties": { + "created_at": { + "type": "string" + }, + "display_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "email": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "number" + }, + "invite_expires_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invite_state": { + "options": [ + "claimed", + "closed", + "expired", + "failed", + "none", + "pending", + "sent" + ], + "type": "picklist" + }, + "invite_token": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invite_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invited_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invited_by": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "last_invite_delivery_id": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "last_invite_delivery_status": { + "type": "nullable", + "wrapped": { + "options": [ + "failed", + "pending", + "sent" + ], + "type": "picklist" + } + }, + "last_invite_email_log_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "locale": { + "type": "string" + }, + "marketing_consent_accepted_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "marketing_consent_revoked_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "note": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "priority_score": { + "type": "number" + }, + "registered_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "registered_user_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_by": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_reason": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "source": { + "options": [ + "landing_footer", + "landing_hero", + "manual_admin", + "pricing" + ], + "type": "picklist" + }, + "status": { + "options": [ + "invite_pending", + "invited", + "joined", + "registered", + "rejected" + ], + "type": "picklist" + }, + "updated_at": { + "type": "string" + }, + "use_case": { + "type": "string" + } + }, + "type": "object" + }, + "WaitlistActorType": { + "options": [ + "operator", + "public", + "system" + ], + "type": "picklist" + }, + "WaitlistCreateManualEntryRequest": { + "properties": { + "display_name": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "email": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "marketing_consent_accepted_at": { + "type": "string" + }, + "note": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "use_case": { + "type": "string" + } + }, + "type": "object" + }, + "WaitlistEntry": { + "properties": { + "created_at": { + "type": "string" + }, + "display_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "email": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "number" + }, + "invite_expires_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invite_state": { + "options": [ + "claimed", + "closed", + "expired", + "failed", + "none", + "pending", + "sent" + ], + "type": "picklist" + }, + "invite_token": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invite_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invited_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invited_by": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "last_invite_delivery_id": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "last_invite_delivery_status": { + "type": "nullable", + "wrapped": { + "options": [ + "failed", + "pending", + "sent" + ], + "type": "picklist" + } + }, + "last_invite_email_log_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "locale": { + "type": "string" + }, + "marketing_consent_accepted_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "marketing_consent_revoked_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "note": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "priority_score": { + "type": "number" + }, + "registered_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "registered_user_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_by": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_reason": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "source": { + "options": [ + "landing_footer", + "landing_hero", + "manual_admin", + "pricing" + ], + "type": "picklist" + }, + "status": { + "options": [ + "invite_pending", + "invited", + "joined", + "registered", + "rejected" + ], + "type": "picklist" + }, + "updated_at": { + "type": "string" + }, + "use_case": { + "type": "string" + } + }, + "type": "object" + }, + "WaitlistEntryDetailResponse": { + "properties": { + "deliveries": { + "item": { + "properties": { + "created_at": { + "type": "string" + }, + "email_log_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "entry_id": { + "type": "number" + }, + "error_message": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "failed_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "number" + }, + "invite_token_hash": { + "type": "string" + }, + "message_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "provider": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "sent_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "failed", + "pending", + "sent" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "type": "array" + }, + "entry": { + "properties": { + "created_at": { + "type": "string" + }, + "display_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "email": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "number" + }, + "invite_expires_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invite_state": { + "options": [ + "claimed", + "closed", + "expired", + "failed", + "none", + "pending", + "sent" + ], + "type": "picklist" + }, + "invite_token": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invite_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invited_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invited_by": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "last_invite_delivery_id": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "last_invite_delivery_status": { + "type": "nullable", + "wrapped": { + "options": [ + "failed", + "pending", + "sent" + ], + "type": "picklist" + } + }, + "last_invite_email_log_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "locale": { + "type": "string" + }, + "marketing_consent_accepted_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "marketing_consent_revoked_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "note": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "priority_score": { + "type": "number" + }, + "registered_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "registered_user_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_by": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_reason": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "source": { + "options": [ + "landing_footer", + "landing_hero", + "manual_admin", + "pricing" + ], + "type": "picklist" + }, + "status": { + "options": [ + "invite_pending", + "invited", + "joined", + "registered", + "rejected" + ], + "type": "picklist" + }, + "updated_at": { + "type": "string" + }, + "use_case": { + "type": "string" + } + }, + "type": "object" + }, + "events": { + "item": { + "properties": { + "actor_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "actor_type": { + "options": [ + "operator", + "public", + "system" + ], + "type": "picklist" + }, + "created_at": { + "type": "string" + }, + "entry_id": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "event_type": { + "options": [ + "duplicate_joined", + "exported", + "invite_email_failed", + "invite_email_sent", + "invite_requested", + "joined", + "note_added", + "registered", + "rejected" + ], + "type": "picklist" + }, + "id": { + "type": "number" + }, + "metadata": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "WaitlistEntryListResponse": { + "properties": { + "items": { + "item": { + "properties": { + "created_at": { + "type": "string" + }, + "display_name": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "email": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "number" + }, + "invite_expires_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invite_state": { + "options": [ + "claimed", + "closed", + "expired", + "failed", + "none", + "pending", + "sent" + ], + "type": "picklist" + }, + "invite_token": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invite_url": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invited_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "invited_by": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "last_invite_delivery_id": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "last_invite_delivery_status": { + "type": "nullable", + "wrapped": { + "options": [ + "failed", + "pending", + "sent" + ], + "type": "picklist" + } + }, + "last_invite_email_log_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "locale": { + "type": "string" + }, + "marketing_consent_accepted_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "marketing_consent_revoked_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "note": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "priority_score": { + "type": "number" + }, + "registered_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "registered_user_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_by": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "rejected_reason": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "source": { + "options": [ + "landing_footer", + "landing_hero", + "manual_admin", + "pricing" + ], + "type": "picklist" + }, + "status": { + "options": [ + "invite_pending", + "invited", + "joined", + "registered", + "rejected" + ], + "type": "picklist" + }, + "updated_at": { + "type": "string" + }, + "use_case": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "next_cursor": { + "type": "nullable", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "WaitlistEvent": { + "properties": { + "actor_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "actor_type": { + "options": [ + "operator", + "public", + "system" + ], + "type": "picklist" + }, + "created_at": { + "type": "string" + }, + "entry_id": { + "type": "nullable", + "wrapped": { + "type": "number" + } + }, + "event_type": { + "options": [ + "duplicate_joined", + "exported", + "invite_email_failed", + "invite_email_sent", + "invite_requested", + "joined", + "note_added", + "registered", + "rejected" + ], + "type": "picklist" + }, + "id": { + "type": "number" + }, + "metadata": { + "type": "nullable", + "wrapped": { + "type": "unknown" + } + } + }, + "type": "object" + }, + "WaitlistEventType": { + "options": [ + "duplicate_joined", + "exported", + "invite_email_failed", + "invite_email_sent", + "invite_requested", + "joined", + "note_added", + "registered", + "rejected" + ], + "type": "picklist" + }, + "WaitlistExportResponse": { + "type": "string" + }, + "WaitlistInviteCode": { + "type": "string" + }, + "WaitlistInviteDelivery": { + "properties": { + "created_at": { + "type": "string" + }, + "email_log_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "entry_id": { + "type": "number" + }, + "error_message": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "failed_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "id": { + "type": "number" + }, + "invite_token_hash": { + "type": "string" + }, + "message_id": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "provider": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "sent_at": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "status": { + "options": [ + "failed", + "pending", + "sent" + ], + "type": "picklist" + } + }, + "type": "object" + }, + "WaitlistInviteDeliveryStatus": { + "options": [ + "claimed", + "closed", + "expired", + "failed", + "none", + "pending", + "sent" + ], + "type": "picklist" + }, + "WaitlistInviteRequest": { + "properties": { + "note": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "WaitlistInviteValidateResponse": { + "properties": { + "email": { + "type": "nullable", + "wrapped": { + "type": "string" + } + }, + "expires_at": { + "type": "string" + }, + "ok": { + "type": "literal", + "value": true + } + }, + "type": "object" + }, + "WaitlistJoinRequest": { + "properties": { + "consent_marketing": { + "type": "literal", + "value": true + }, + "display_name": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "email": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "note": { + "type": "optional", + "wrapped": { + "type": "string" + } + }, + "source": { + "options": [ + "landing_footer", + "landing_hero", + "pricing" + ], + "type": "picklist" + }, + "use_case": { + "type": "optional", + "wrapped": { + "type": "string" + } + } + }, + "type": "object" + }, + "WaitlistJoinResponse": { + "properties": { + "message": { + "type": "literal", + "value": "joined" + }, + "ok": { + "type": "literal", + "value": true + } + }, + "type": "object" + }, + "WaitlistNoteRequest": { + "properties": { + "note": { + "type": "string" + } + }, + "type": "object" + }, + "WaitlistPublicSource": { + "options": [ + "landing_footer", + "landing_hero", + "pricing" + ], + "type": "picklist" + }, + "WaitlistRejectRequest": { + "properties": { + "reason": { + "type": "string" + } + }, + "type": "object" + }, + "WaitlistSendRegisterCodeResponse": { + "properties": { + "email": { + "type": "string" + }, + "ok": { + "type": "literal", + "value": true + } + }, + "type": "object" + }, + "WaitlistSource": { + "options": [ + "landing_footer", + "landing_hero", + "manual_admin", + "pricing" + ], + "type": "picklist" + }, + "WaitlistStatus": { + "options": [ + "invite_pending", + "invited", + "joined", + "registered", + "rejected" + ], + "type": "picklist" + } +} diff --git a/packages/contracts/src/drama.js b/packages/contracts/src/drama.js new file mode 100644 index 0000000..bee3507 --- /dev/null +++ b/packages/contracts/src/drama.js @@ -0,0 +1,20 @@ +/** + * `@drama/contracts/drama` —— drama 主站业务数据形态。 + * + * 剧本行 / 配音、bundle 播放包、drama 信息流与付费墙。只有 drama 线(apps/server、 + * apps/web、apps/studio 及 drama 播放链)消费。 + */ + +export { ScriptLine, TtsHint, TtsLineBody } from './schemas/script-line.js' +export { Bundle, SceneRef, BundleMeta } from './schemas/bundle.js' +export { Drama, Paywall, Page } from './schemas/drama.js' + +/** + * @typedef {import('./schemas/script-line.js').ScriptLineT} ScriptLineT + * @typedef {import('./schemas/script-line.js').TtsHintT} TtsHintT + * @typedef {import('./schemas/bundle.js').BundleT} BundleT + * @typedef {import('./schemas/bundle.js').SceneRefT} SceneRefT + * @typedef {import('./schemas/bundle.js').BundleMetaT} BundleMetaT + * @typedef {import('./schemas/drama.js').DramaT} DramaT + * @typedef {import('./schemas/drama.js').PaywallT} PaywallT + */ diff --git a/packages/contracts/src/dump.js b/packages/contracts/src/dump.js new file mode 100644 index 0000000..bf224ce --- /dev/null +++ b/packages/contracts/src/dump.js @@ -0,0 +1,263 @@ +import { ScriptLine } from './schemas/script-line.js' +import { Bundle } from './schemas/bundle.js' +import { Drama, Page } from './schemas/drama.js' +import { ApiError } from './schemas/error.js' +import { LoginRequest, LoginResponse } from './schemas/auth.js' +import { Account } from './schemas/account.js' +import { UnlockReq, UnlockRes, TopupCheckoutReq, LedgerEntry } from './schemas/credits.js' +import { TelemetryEvent } from './schemas/telemetry.js' +import { ShareMeta } from './schemas/share.js' +import { + VisualGameAdminAnalyticsOverviewResponse, + VisualGameAdminAnalyticsDrilldownResponse, + VisualGameAdminPlayerInteractionsResponse, + VisualGameAdminContentAnalyticsResponse, + VisualGameAdminGa4BehaviorResponse, +} from './schemas/visual-game-admin-analytics.js' +import { + WaitlistActionResponse, + WaitlistActorType, + WaitlistCreateManualEntryRequest, + WaitlistEntry, + WaitlistEntryDetailResponse, + WaitlistEntryListResponse, + WaitlistEvent, + WaitlistEventType, + WaitlistExportResponse, + WaitlistInviteDelivery, + WaitlistInviteDeliveryStatus, + WaitlistInviteCode, + WaitlistInviteRequest, + WaitlistInviteValidateResponse, + WaitlistSendRegisterCodeResponse, + WaitlistJoinRequest, + WaitlistJoinResponse, + WaitlistNoteRequest, + WaitlistPublicSource, + WaitlistRejectRequest, + WaitlistSource, + WaitlistStatus, +} from './schemas/waitlist.js' +import { + VisualGameAgentCall, + VisualGameAgentCallList, + VisualGameAgentCallModelSummary, + VisualGameAgentCallRoleSummary, + VisualGameAgentCallSummary, + VisualGameAssetIndexItem, + VisualGameAssetManifest, + VisualGameTtsVoice, + VisualGameTtsVoiceList, + VisualGameCreateRunResponse, + VisualGameEditAuditLog, + VisualGameEditAuditLogList, + VisualGameEndingText, + VisualGamePack, + VisualGamePackAssetUploadResponse, + VisualGamePackCompileResponse, + VisualGameContentTranslationLocaleStatus, + VisualGameContentTranslationStatus, + VisualGameContentTranslationResult, + VisualGameContentTranslationInventoryItem, + VisualGameContentTranslationInventory, + VisualGameContentTranslationProgress, + VisualGamePackDeleteResponse, + VisualGamePackList, + VisualGamePreviewTokenResponse, + VisualGameNotification, + VisualGameNotificationList, + VisualGameRunDetail, + VisualGameStoryVideoLog, + VisualGameScenarioVersion, + VisualGameScenarioListItem, + VisualGameScenarioPage, + VisualGameTurnEvent, +} from './schemas/visual-game.js' +import { + VisualGameAdminUserActionResponse, + VisualGameAdminUserCreditsAdjustRequest, + VisualGameAdminUserDetailResponse, + VisualGameAdminUserListResponse, + VisualGameAdminUserProvisionRequest, + VisualGameAdminUserProfileUpdateRequest, + VisualGameAdminUserSubscriptionUpdateRequest, + VisualGameAdminUserSummary, +} from './schemas/visual-game-admin-users.js' +import { + VisualGameKpAgentOutput, + VisualGameNpcActionKpOutput, + VisualGameNpcActorOutput, +} from './schemas/visual-game-agent.js' +import { + VisualGameScenarioReleaseApplyResponse, + VisualGameScenarioReleaseBundle, + VisualGameScenarioReleaseRequest, + VisualGameScenarioReleaseRecord, + VisualGameScenarioReleaseTargetState, +} from './schemas/visual-game-scenario-release.js' + +/** + * 把单个 Valibot schema 递归序列化为一个稳定、确定性的 JSON 描述。 + * + * 这是 schema gate 的发动机:同一份 schema 必须逐字节产出同样的描述(键有序、 + * 无时间戳、无随机顺序),否则 gate 会因伪漂移误报。本函数只覆盖本包实际使用 + * 到的 Valibot schema 类型(object / array / record / optional / picklist / + * 基础标量),遇到未覆盖类型显式抛错——宁可 gate 失败,也不静默产出歧义描述。 + * + * @param {any} schema Valibot schema 对象 + * @returns {object} 该 schema 的稳定 JSON 描述 + */ +export function describeSchema(schema) { + switch (schema.type) { + case 'object': + case 'strict_object': { + /** @type {Record} */ + const properties = {} + for (const key of Object.keys(schema.entries).sort()) { + properties[key] = describeSchema(schema.entries[key]) + } + return { type: schema.type, properties } + } + case 'array': + return { type: 'array', item: describeSchema(schema.item) } + case 'record': + return { + type: 'record', + key: describeSchema(schema.key), + value: describeSchema(schema.value), + } + case 'optional': + return { type: 'optional', wrapped: describeSchema(schema.wrapped) } + case 'nullable': + return { type: 'nullable', wrapped: describeSchema(schema.wrapped) } + case 'picklist': + return { type: 'picklist', options: [...schema.options].sort() } + case 'literal': + return { type: 'literal', value: schema.literal } + case 'union': + return { type: 'union', options: schema.options.map(describeSchema) } + case 'intersect': + return { type: 'intersect', options: schema.options.map(describeSchema) } + case 'custom': + return { type: 'custom' } + case 'string': + case 'number': + case 'boolean': + case 'unknown': + return { type: schema.type } + default: + throw new Error(`describeSchema: 未支持的 Valibot schema 类型 "${schema.type}"`) + } +} + +/** + * 导出全部公共 schema 的稳定 JSON 描述供 CI 比对。 + * + * 返回对象键有序(按 schema 名排序),值为各 schema 的确定性描述;连续两次调用 + * 输出逐字节相同。`Page` 是高阶工厂,故快照里以一个代表性实例化(`Page(Drama)`) + * 锁定其信封结构。 + * + * @returns {Record} `{ [schemaName]: jsonRepresentation }`,键有序 + */ +export function dumpSchemas() { + /** @type {Record} */ + const registry = { + Account, + ApiError, + Bundle, + Drama, + LedgerEntry, + LoginRequest, + LoginResponse, + PageOfDrama: Page(Drama), + ScriptLine, + TopupCheckoutReq, + UnlockReq, + UnlockRes, + ShareMeta, + TelemetryEvent, + VisualGameAgentCall, + VisualGameAgentCallList, + VisualGameAgentCallModelSummary, + VisualGameAgentCallRoleSummary, + VisualGameAgentCallSummary, + VisualGameKpAgentOutput, + VisualGameNpcActionKpOutput, + VisualGameNpcActorOutput, + VisualGameAssetIndexItem, + VisualGameAssetManifest, + VisualGameTtsVoice, + VisualGameTtsVoiceList, + VisualGameCreateRunResponse, + VisualGameEditAuditLog, + VisualGameEditAuditLogList, + VisualGameEndingText, + VisualGamePack, + VisualGamePackAssetUploadResponse, + VisualGamePackCompileResponse, + VisualGameContentTranslationLocaleStatus, + VisualGameContentTranslationStatus, + VisualGameContentTranslationResult, + VisualGameContentTranslationInventoryItem, + VisualGameContentTranslationInventory, + VisualGameContentTranslationProgress, + VisualGamePackDeleteResponse, + VisualGamePackList, + VisualGamePreviewTokenResponse, + VisualGameNotification, + VisualGameNotificationList, + VisualGameRunDetail, + VisualGameStoryVideoLog, + VisualGameScenarioVersion, + VisualGameScenarioListItem, + VisualGameScenarioPage, + VisualGameScenarioReleaseApplyResponse, + VisualGameScenarioReleaseBundle, + VisualGameScenarioReleaseRequest, + VisualGameScenarioReleaseRecord, + VisualGameScenarioReleaseTargetState, + VisualGameTurnEvent, + VisualGameAdminUserDetailResponse, + VisualGameAdminUserActionResponse, + VisualGameAdminUserCreditsAdjustRequest, + VisualGameAdminUserListResponse, + VisualGameAdminUserProvisionRequest, + VisualGameAdminUserProfileUpdateRequest, + VisualGameAdminUserSubscriptionUpdateRequest, + VisualGameAdminUserSummary, + VisualGameAdminAnalyticsOverviewResponse, + VisualGameAdminAnalyticsDrilldownResponse, + VisualGameAdminPlayerInteractionsResponse, + VisualGameAdminContentAnalyticsResponse, + VisualGameAdminGa4BehaviorResponse, + WaitlistActionResponse, + WaitlistActorType, + WaitlistCreateManualEntryRequest, + WaitlistEntry, + WaitlistEntryDetailResponse, + WaitlistEntryListResponse, + WaitlistEvent, + WaitlistEventType, + WaitlistExportResponse, + WaitlistInviteDelivery, + WaitlistInviteDeliveryStatus, + WaitlistInviteCode, + WaitlistInviteRequest, + WaitlistInviteValidateResponse, + WaitlistSendRegisterCodeResponse, + WaitlistJoinRequest, + WaitlistJoinResponse, + WaitlistNoteRequest, + WaitlistPublicSource, + WaitlistRejectRequest, + WaitlistSource, + WaitlistStatus, + } + + /** @type {Record} */ + const out = {} + for (const name of Object.keys(registry).sort()) { + out[name] = describeSchema(registry[name]) + } + return out +} diff --git a/packages/contracts/src/index.js b/packages/contracts/src/index.js new file mode 100644 index 0000000..8bf3c82 --- /dev/null +++ b/packages/contracts/src/index.js @@ -0,0 +1,112 @@ +/** + * `@drama/contracts` 包根 —— 只暴露跨产品的工具函数。 + * + * 业务 schema 按产品拆到三个子路径导入(深导入被 ESLint 拦截,子路径为声明的公共面): + * - `@drama/contracts/drama`:drama 主站业务 schema。 + * - `@drama/contracts/visual-game`:visual-game 业务 schema。 + * - `@drama/contracts/platform`:账号/鉴权/计费/支付等两条线共用的平台 schema。 + * + * 包根只留两类工具函数: + * 1. 各 `parseXxx` 边界校验助手(HTTP 入口唯一校验入口)。 + * 2. `dumpSchemas`(确定性 schema 快照,供 CI schema gate 比对)。 + * + * 本包零 IO、零 `@drama/*` 依赖、零 `process.env`:纯定义 + 纯函数,可在 + * 浏览器与 Node 同时 import。 + */ + +// === parse 助手(HTTP 入口边界校验唯一入口;失败抛 Valibot ValiError)=== +export { + parseScriptLine, + parseBundle, + parseDrama, + parseApiError, + parseLogin, + parseLoginResponse, + parseAccount, + parseUnlockReq, + parseTopupCheckoutReq, + parseTelemetryEvent, + parseShareMeta, + parseBillingSummary, + parseVisualGameKpAgentOutput, + parseVisualGameNpcActorOutput, + parseVisualGameNpcActionKpOutput, + parseVisualGameScenarioListItem, + parseVisualGameScenarioPage, + parseVisualGameAssetManifest, + parseVisualGameTtsVoiceList, + parseVisualGameCreateRunResponse, + parseVisualGameRunDetail, + parseVisualGameRunList, + parseVisualGameRunVideo, + parseVisualGameRunVideoGeneration, + parseVisualGameRunVideoGenerationResult, + parseVisualGameStoryVideoLog, + parseVisualGameNotification, + parseVisualGameNotificationList, + parseVisualGameScenarioVideoList, + parseVisualGameShowcaseVideoPage, + parseVisualGameVideoEngagement, + parseVisualGameSeasonList, + parseVisualGameRunEvent, + parseVisualGameRunEventList, + parseVisualGameAgentCallList, + parseVisualGameProgress, + parseVisualGameProgressPatch, + parseVisualGamePaywallInfo, + parseVisualGameSaveSlot, + parseVisualGameSaveSlotList, + parseVisualGameDeleteSaveSlotResponse, + parseVisualGameTurnEvent, + parseVisualGameEndingText, + parseVisualGameScenarioVersion, + parseVisualGamePack, + parseVisualGamePackList, + parseVisualGamePackCompileResponse, + parseVisualGameContentTranslationStatus, + parseVisualGameContentTranslationResult, + parseVisualGameContentTranslationInventory, + parseVisualGameContentTranslationProgress, + parseVisualGamePackAssetUploadResponse, + parseVisualGamePackDeleteResponse, + parseVisualGamePreviewTokenResponse, + parseVisualGamePublishCheckList, + parseVisualGameEditAuditLogList, + parseVisualGameCreatorProject, + parseVisualGameCreatorProjectList, + parseVisualGameAdminUserSummary, + parseVisualGameAdminUserListResponse, + parseVisualGameAdminUserDetailResponse, + parseVisualGameAdminUserProvisionRequest, + parseVisualGameAdminUserProfileUpdateRequest, + parseVisualGameAdminUserCreditsAdjustRequest, + parseVisualGameAdminUserSubscriptionUpdateRequest, + parseVisualGameAdminUserActionResponse, + parseVisualGameAdminBillingActionPolicy, + parseVisualGameAdminBillingPolicyList, + parseVisualGameAdminBillingProduct, + parseVisualGameAdminBillingProductList, + parseVisualGameAdminBillingOrderPage, + parseVisualGameAdminBillingLedgerPage, + parseVisualGameAdminBillingDashboard, + parseVisualGameAdminAnalyticsOverviewResponse, + parseVisualGameAdminAnalyticsDrilldownResponse, + parseVisualGameAdminPlayerInteractionsResponse, + parseVisualGameAdminContentAnalyticsResponse, + parseVisualGameAdminGa4BehaviorResponse, + parseWaitlistJoinRequest, + parseWaitlistJoinResponse, + parseWaitlistInviteValidateResponse, + parseWaitlistSendRegisterCodeResponse, + parseWaitlistEntryListResponse, + parseWaitlistEntryDetailResponse, + parseWaitlistCreateManualEntryRequest, + parseWaitlistNoteRequest, + parseWaitlistInviteRequest, + parseWaitlistRejectRequest, + parseWaitlistActionResponse, + parseWaitlistExportResponse, +} from './parse.js' + +// === schema 快照导出(CI gate 用,确定性序列化)=== +export { dumpSchemas, describeSchema } from './dump.js' diff --git a/packages/contracts/src/parse.js b/packages/contracts/src/parse.js new file mode 100644 index 0000000..2bd0757 --- /dev/null +++ b/packages/contracts/src/parse.js @@ -0,0 +1,285 @@ +import * as v from 'valibot' +import { ScriptLine } from './schemas/script-line.js' +import { Bundle } from './schemas/bundle.js' +import { Drama } from './schemas/drama.js' +import { ApiError } from './schemas/error.js' +import { LoginRequest, LoginResponse } from './schemas/auth.js' +import { Account } from './schemas/account.js' +import { UnlockReq, TopupCheckoutReq } from './schemas/credits.js' +import { TelemetryEvent } from './schemas/telemetry.js' +import { ShareMeta } from './schemas/share.js' +import { BillingSummary } from './schemas/commerce.js' +import { + VisualGameAdminAnalyticsOverviewResponse, + VisualGameAdminAnalyticsDrilldownResponse, + VisualGameAdminPlayerInteractionsResponse, + VisualGameAdminContentAnalyticsResponse, + VisualGameAdminGa4BehaviorResponse, +} from './schemas/visual-game-admin-analytics.js' +import { + WaitlistActionResponse, + WaitlistCreateManualEntryRequest, + WaitlistEntryDetailResponse, + WaitlistEntryListResponse, + WaitlistExportResponse, + WaitlistInviteRequest, + WaitlistInviteValidateResponse, + WaitlistSendRegisterCodeResponse, + WaitlistJoinRequest, + WaitlistJoinResponse, + WaitlistNoteRequest, + WaitlistRejectRequest, +} from './schemas/waitlist.js' +import { + VisualGameAgentCallList, + VisualGameAssetManifest, + VisualGameCreateRunResponse, + VisualGameDeleteSaveSlotResponse, + VisualGameEditAuditLogList, + VisualGameEndingText, + VisualGamePack, + VisualGamePackAssetUploadResponse, + VisualGamePackCompileResponse, + VisualGameContentTranslationStatus, + VisualGameContentTranslationResult, + VisualGameContentTranslationInventory, + VisualGameContentTranslationProgress, + VisualGamePackDeleteResponse, + VisualGamePackList, + VisualGamePaywallInfo, + VisualGamePreviewTokenResponse, + VisualGameProgress, + VisualGameProgressPatch, + VisualGamePublishCheckList, + VisualGameRunDetail, + VisualGameRunList, + VisualGameRunVideo, + VisualGameRunVideoGeneration, + VisualGameRunVideoGenerationResult, + VisualGameStoryVideoLog, + VisualGameNotification, + VisualGameNotificationList, + VisualGameRunEvent, + VisualGameSaveSlot, + VisualGameScenarioVideoList, + VisualGameScenarioVersion, + VisualGameShowcaseVideoPage, + VisualGameVideoEngagement, + VisualGameSeasonList, + VisualGameScenarioListItem, + VisualGameScenarioPage, + VisualGameTtsVoiceList, + VisualGameTurnEvent, + VisualGameCreatorProject, + VisualGameCreatorProjectList, +} from './schemas/visual-game.js' +import { + VisualGameAdminUserActionResponse, + VisualGameAdminUserCreditsAdjustRequest, + VisualGameAdminUserDetailResponse, + VisualGameAdminUserListResponse, + VisualGameAdminUserProvisionRequest, + VisualGameAdminUserProfileUpdateRequest, + VisualGameAdminUserSubscriptionUpdateRequest, + VisualGameAdminUserSummary, +} from './schemas/visual-game-admin-users.js' +import { + VisualGameAdminBillingActionPolicy, + VisualGameAdminBillingDashboard, + VisualGameAdminBillingLedgerPage, + VisualGameAdminBillingOrderPage, + VisualGameAdminBillingPolicyList, + VisualGameAdminBillingProduct, + VisualGameAdminBillingProductList, +} from './schemas/visual-game-admin-commerce.js' +import { + VisualGameKpAgentOutput, + VisualGameNpcActionKpOutput, + VisualGameNpcActorOutput, +} from './schemas/visual-game-agent.js' + +/** + * 各 parseXxx 助手:HTTP 入口边界校验的唯一入口,薄包一层 `v.parse`。 + * 非法输入抛 Valibot `ValiError`,其 `issues` 指向具体字段路径,统一错误风格。 + */ + +/** @param {unknown} x @returns {import('./schemas/script-line.js').ScriptLineT} */ +export const parseScriptLine = (x) => v.parse(ScriptLine, x) + +/** @param {unknown} x @returns {import('./schemas/bundle.js').BundleT} */ +export const parseBundle = (x) => v.parse(Bundle, x) + +/** @param {unknown} x @returns {import('./schemas/drama.js').DramaT} */ +export const parseDrama = (x) => v.parse(Drama, x) + +/** @param {unknown} x @returns {import('./schemas/error.js').ApiErrorT} */ +export const parseApiError = (x) => v.parse(ApiError, x) + +/** @param {unknown} x @returns {import('./schemas/auth.js').LoginRequestT} */ +export const parseLogin = (x) => v.parse(LoginRequest, x) + +/** @param {unknown} x @returns {import('./schemas/auth.js').LoginResponseT} */ +export const parseLoginResponse = (x) => v.parse(LoginResponse, x) + +/** @param {unknown} x @returns {import('./schemas/account.js').AccountT} */ +export const parseAccount = (x) => v.parse(Account, x) + +/** @param {unknown} x @returns {import('./schemas/credits.js').UnlockReqT} */ +export const parseUnlockReq = (x) => v.parse(UnlockReq, x) + +/** @param {unknown} x @returns {import('./schemas/credits.js').TopupCheckoutReqT} */ +export const parseTopupCheckoutReq = (x) => v.parse(TopupCheckoutReq, x) + +/** @param {unknown} x @returns {import('./schemas/telemetry.js').TelemetryEventT} */ +export const parseTelemetryEvent = (x) => v.parse(TelemetryEvent, x) + +/** @param {unknown} x @returns {import('./schemas/share.js').ShareMetaT} */ +export const parseShareMeta = (x) => v.parse(ShareMeta, x) + +/** @param {unknown} x */ +export const parseBillingSummary = (x) => v.parse(BillingSummary, x) + +/** @param {unknown} x */ +export const parseVisualGameKpAgentOutput = (x) => v.parse(VisualGameKpAgentOutput, x) +/** @param {unknown} x */ +export const parseVisualGameNpcActorOutput = (x) => v.parse(VisualGameNpcActorOutput, x) +/** @param {unknown} x */ +export const parseVisualGameNpcActionKpOutput = (x) => v.parse(VisualGameNpcActionKpOutput, x) + +/** @param {unknown} x */ +export const parseVisualGameScenarioListItem = (x) => v.parse(VisualGameScenarioListItem, x) +/** @param {unknown} x */ +export const parseVisualGameScenarioPage = (x) => v.parse(VisualGameScenarioPage, x) +/** @param {unknown} x */ +export const parseVisualGameAssetManifest = (x) => v.parse(VisualGameAssetManifest, x) +/** @param {unknown} x */ +export const parseVisualGameTtsVoiceList = (x) => v.parse(VisualGameTtsVoiceList, x) +/** @param {unknown} x */ +export const parseVisualGameCreateRunResponse = (x) => v.parse(VisualGameCreateRunResponse, x) +/** @param {unknown} x */ +export const parseVisualGameRunDetail = (x) => v.parse(VisualGameRunDetail, x) +/** @param {unknown} x */ +export const parseVisualGameRunList = (x) => v.parse(VisualGameRunList, x) +/** @param {unknown} x */ +export const parseVisualGameRunVideo = (x) => v.parse(VisualGameRunVideo, x) +export const parseVisualGameRunVideoGeneration = (x) => v.parse(VisualGameRunVideoGeneration, x) +export const parseVisualGameRunVideoGenerationResult = (x) => v.parse(VisualGameRunVideoGenerationResult, x) +/** @param {unknown} x */ +export const parseVisualGameStoryVideoLog = (x) => v.parse(VisualGameStoryVideoLog, x) +export const parseVisualGameNotification = (x) => v.parse(VisualGameNotification, x) +export const parseVisualGameNotificationList = (x) => v.parse(VisualGameNotificationList, x) +/** @param {unknown} x */ +export const parseVisualGameScenarioVideoList = (x) => v.parse(VisualGameScenarioVideoList, x) +export const parseVisualGameShowcaseVideoPage = (x) => v.parse(VisualGameShowcaseVideoPage, x) +export const parseVisualGameVideoEngagement = (x) => v.parse(VisualGameVideoEngagement, x) +export const parseVisualGameSeasonList = (x) => v.parse(VisualGameSeasonList, x) +/** @param {unknown} x */ +export const parseVisualGameRunEvent = (x) => v.parse(VisualGameRunEvent, x) +/** @param {unknown} x */ +export const parseVisualGameRunEventList = (x) => Array.isArray(x) ? x.map(parseVisualGameRunEvent) : [] +/** @param {unknown} x */ +export const parseVisualGameAgentCallList = (x) => v.parse(VisualGameAgentCallList, x) +/** @param {unknown} x */ +export const parseVisualGameProgress = (x) => v.parse(VisualGameProgress, x) +/** @param {unknown} x */ +export const parseVisualGameProgressPatch = (x) => v.parse(VisualGameProgressPatch, x) +/** @param {unknown} x */ +export const parseVisualGamePaywallInfo = (x) => v.parse(VisualGamePaywallInfo, x) +/** @param {unknown} x */ +export const parseVisualGameSaveSlot = (x) => v.parse(VisualGameSaveSlot, x) +/** @param {unknown} x */ +export const parseVisualGameSaveSlotList = (x) => Array.isArray(x) ? x.map(parseVisualGameSaveSlot) : [] +/** @param {unknown} x */ +export const parseVisualGameDeleteSaveSlotResponse = (x) => v.parse(VisualGameDeleteSaveSlotResponse, x) +/** @param {unknown} x */ +export const parseVisualGameTurnEvent = (x) => v.parse(VisualGameTurnEvent, x) +/** @param {unknown} x */ +export const parseVisualGameEndingText = (x) => v.parse(VisualGameEndingText, x) +/** @param {unknown} x */ +export const parseVisualGameScenarioVersion = (x) => v.parse(VisualGameScenarioVersion, x) +/** @param {unknown} x */ +export const parseVisualGamePack = (x) => v.parse(VisualGamePack, x) +/** @param {unknown} x */ +export const parseVisualGamePackList = (x) => v.parse(VisualGamePackList, x) +/** @param {unknown} x */ +export const parseVisualGamePackCompileResponse = (x) => v.parse(VisualGamePackCompileResponse, x) +/** @param {unknown} x */ +export const parseVisualGameContentTranslationStatus = (x) => v.parse(VisualGameContentTranslationStatus, x) +/** @param {unknown} x */ +export const parseVisualGameContentTranslationResult = (x) => v.parse(VisualGameContentTranslationResult, x) +/** @param {unknown} x */ +export const parseVisualGameContentTranslationInventory = (x) => v.parse(VisualGameContentTranslationInventory, x) +/** @param {unknown} x */ +export const parseVisualGameContentTranslationProgress = (x) => v.parse(VisualGameContentTranslationProgress, x) +/** @param {unknown} x */ +export const parseVisualGamePackAssetUploadResponse = (x) => v.parse(VisualGamePackAssetUploadResponse, x) +/** @param {unknown} x */ +export const parseVisualGamePackDeleteResponse = (x) => v.parse(VisualGamePackDeleteResponse, x) +/** @param {unknown} x */ +export const parseVisualGamePreviewTokenResponse = (x) => v.parse(VisualGamePreviewTokenResponse, x) +/** @param {unknown} x */ +export const parseVisualGamePublishCheckList = (x) => v.parse(VisualGamePublishCheckList, x) +/** @param {unknown} x */ +export const parseVisualGameEditAuditLogList = (x) => v.parse(VisualGameEditAuditLogList, x) +/** @param {unknown} x */ +export const parseVisualGameCreatorProject = (x) => v.parse(VisualGameCreatorProject, x) +/** @param {unknown} x */ +export const parseVisualGameCreatorProjectList = (x) => v.parse(VisualGameCreatorProjectList, x) +/** @param {unknown} x */ +export const parseVisualGameAdminUserSummary = (x) => v.parse(VisualGameAdminUserSummary, x) +/** @param {unknown} x */ +export const parseVisualGameAdminUserListResponse = (x) => v.parse(VisualGameAdminUserListResponse, x) +/** @param {unknown} x */ +export const parseVisualGameAdminUserDetailResponse = (x) => v.parse(VisualGameAdminUserDetailResponse, x) +/** @param {unknown} x */ +export const parseVisualGameAdminUserProvisionRequest = (x) => v.parse(VisualGameAdminUserProvisionRequest, x) +/** @param {unknown} x */ +export const parseVisualGameAdminUserProfileUpdateRequest = (x) => v.parse(VisualGameAdminUserProfileUpdateRequest, x) +/** @param {unknown} x */ +export const parseVisualGameAdminUserCreditsAdjustRequest = (x) => v.parse(VisualGameAdminUserCreditsAdjustRequest, x) +/** @param {unknown} x */ +export const parseVisualGameAdminUserSubscriptionUpdateRequest = (x) => v.parse(VisualGameAdminUserSubscriptionUpdateRequest, x) +/** @param {unknown} x */ +export const parseVisualGameAdminUserActionResponse = (x) => v.parse(VisualGameAdminUserActionResponse, x) +export const parseVisualGameAdminBillingActionPolicy = (x) => v.parse(VisualGameAdminBillingActionPolicy, x) +export const parseVisualGameAdminBillingPolicyList = (x) => v.parse(VisualGameAdminBillingPolicyList, x) +export const parseVisualGameAdminBillingProduct = (x) => v.parse(VisualGameAdminBillingProduct, x) +export const parseVisualGameAdminBillingProductList = (x) => v.parse(VisualGameAdminBillingProductList, x) +export const parseVisualGameAdminBillingOrderPage = (x) => v.parse(VisualGameAdminBillingOrderPage, x) +export const parseVisualGameAdminBillingLedgerPage = (x) => v.parse(VisualGameAdminBillingLedgerPage, x) +export const parseVisualGameAdminBillingDashboard = (x) => v.parse(VisualGameAdminBillingDashboard, x) +/** @param {unknown} x */ +export const parseVisualGameAdminAnalyticsOverviewResponse = (x) => v.parse(VisualGameAdminAnalyticsOverviewResponse, x) +/** @param {unknown} x */ +export const parseVisualGameAdminAnalyticsDrilldownResponse = (x) => v.parse(VisualGameAdminAnalyticsDrilldownResponse, x) +/** @param {unknown} x */ +export const parseVisualGameAdminPlayerInteractionsResponse = (x) => v.parse(VisualGameAdminPlayerInteractionsResponse, x) +/** @param {unknown} x */ +export const parseVisualGameAdminContentAnalyticsResponse = (x) => v.parse(VisualGameAdminContentAnalyticsResponse, x) +/** @param {unknown} x */ +export const parseVisualGameAdminGa4BehaviorResponse = (x) => v.parse(VisualGameAdminGa4BehaviorResponse, x) + +/** @param {unknown} x */ +export const parseWaitlistJoinRequest = (x) => v.parse(WaitlistJoinRequest, x) +/** @param {unknown} x */ +export const parseWaitlistJoinResponse = (x) => v.parse(WaitlistJoinResponse, x) +/** @param {unknown} x */ +export const parseWaitlistInviteValidateResponse = (x) => v.parse(WaitlistInviteValidateResponse, x) +/** @param {unknown} x */ +export const parseWaitlistSendRegisterCodeResponse = (x) => v.parse(WaitlistSendRegisterCodeResponse, x) +/** @param {unknown} x */ +export const parseWaitlistEntryListResponse = (x) => v.parse(WaitlistEntryListResponse, x) +/** @param {unknown} x */ +export const parseWaitlistEntryDetailResponse = (x) => v.parse(WaitlistEntryDetailResponse, x) +/** @param {unknown} x */ +export const parseWaitlistCreateManualEntryRequest = (x) => v.parse(WaitlistCreateManualEntryRequest, x) +/** @param {unknown} x */ +export const parseWaitlistNoteRequest = (x) => v.parse(WaitlistNoteRequest, x) +/** @param {unknown} x */ +export const parseWaitlistInviteRequest = (x) => v.parse(WaitlistInviteRequest, x) +/** @param {unknown} x */ +export const parseWaitlistRejectRequest = (x) => v.parse(WaitlistRejectRequest, x) +/** @param {unknown} x */ +export const parseWaitlistActionResponse = (x) => v.parse(WaitlistActionResponse, x) +/** @param {unknown} x */ +export const parseWaitlistExportResponse = (x) => v.parse(WaitlistExportResponse, x) diff --git a/packages/contracts/src/platform.js b/packages/contracts/src/platform.js new file mode 100644 index 0000000..c60008a --- /dev/null +++ b/packages/contracts/src/platform.js @@ -0,0 +1,63 @@ +/** + * `@drama/contracts/platform` —— 两条产品线共用的平台数据形态。 + * + * 账号 / 鉴权 / 计费 / 支付、通用错误信封、遥测事件、分享 meta。drama 线与 + * visual-game 线都消费。 + */ + +export { Account, Subscription } from './schemas/account.js' +export { LoginRequest, LoginResponse, RegisterRequest, SendRegistrationCodeRequest } from './schemas/auth.js' +export { ApiError } from './schemas/error.js' +export { UnlockReq, UnlockRes, TopupCheckoutReq, LedgerEntry } from './schemas/credits.js' +export { TelemetryEvent } from './schemas/telemetry.js' +export { ShareMeta } from './schemas/share.js' +export { BillingSummary, DailyClaimResponse } from './schemas/commerce.js' +export { + VisualGameAdminBillingActionPolicy, + VisualGameAdminBillingLedgerPage, + VisualGameAdminBillingOrderPage, + VisualGameAdminBillingPolicyList, + VisualGameAdminBillingProduct, + VisualGameAdminBillingProductList, +} from './schemas/visual-game-admin-commerce.js' +export { + WAITLIST_INVITE_CODE_LENGTH, + WAITLIST_INVITE_CODE_ALPHABET, + WAITLIST_INVITE_CODE_PATTERN, + WaitlistInviteCode, + WaitlistStatus, + WaitlistInviteDeliveryStatus, + WaitlistSource, + WaitlistPublicSource, + WaitlistEventType, + WaitlistActorType, + WaitlistJoinRequest, + WaitlistJoinResponse, + WaitlistInviteValidateResponse, + WaitlistSendRegisterCodeResponse, + WaitlistEntry, + WaitlistEvent, + WaitlistInviteDelivery, + WaitlistEntryListResponse, + WaitlistEntryDetailResponse, + WaitlistCreateManualEntryRequest, + WaitlistNoteRequest, + WaitlistInviteRequest, + WaitlistRejectRequest, + WaitlistActionResponse, + WaitlistExportResponse, +} from './schemas/waitlist.js' + +/** + * @typedef {import('./schemas/account.js').AccountT} AccountT + * @typedef {import('./schemas/account.js').SubscriptionT} SubscriptionT + * @typedef {import('./schemas/auth.js').LoginRequestT} LoginRequestT + * @typedef {import('./schemas/auth.js').LoginResponseT} LoginResponseT + * @typedef {import('./schemas/auth.js').RegisterRequestT} RegisterRequestT + * @typedef {import('./schemas/auth.js').SendRegistrationCodeRequestT} SendRegistrationCodeRequestT + * @typedef {import('./schemas/error.js').ApiErrorT} ApiErrorT + * @typedef {import('./schemas/telemetry.js').TelemetryEventT} TelemetryEventT + * @typedef {import('./schemas/share.js').ShareMetaT} ShareMetaT + * @typedef {import('./schemas/waitlist.js').WaitlistEntryT} WaitlistEntryT + * @typedef {import('./schemas/waitlist.js').WaitlistEntryListResponseT} WaitlistEntryListResponseT + */ diff --git a/packages/contracts/src/schemas/account.js b/packages/contracts/src/schemas/account.js new file mode 100644 index 0000000..6578424 --- /dev/null +++ b/packages/contracts/src/schemas/account.js @@ -0,0 +1,64 @@ +import * as v from 'valibot' + +/** + * 订阅状态:账户当前订阅档位与状态。 + * @typedef {{ tier: string, status: 'active' | 'inactive' | 'past_due', renews_at?: string }} SubscriptionT + */ +export const Subscription = v.object({ + tier: v.string(), + status: v.picklist(['active', 'inactive', 'past_due']), + renews_at: v.optional(v.string()), +}) + +/** +/** + * 库目条目:个人中心「继续观看 / 已保存」列表项。 + * @typedef {{ id?: string, drama_id?: string, title?: string, cover?: string, progress?: number }} LibraryItemT + */ +export const LibraryItem = v.object({ + id: v.optional(v.string()), + drama_id: v.optional(v.string()), + title: v.optional(v.string()), + cover: v.optional(v.string()), + progress: v.optional(v.number()), +}) + +/** + * 账户行为统计:观看 / 改写计数(个人中心头部指标,缺失项前端不渲染)。 + * @typedef {{ watched?: number, rewritten?: number }} AccountStatsT + */ +export const AccountStats = v.object({ + watched: v.optional(v.number()), + rewritten: v.optional(v.number()), +}) + +/** + * 账户资料:跨端共享的用户资料 / 积分 / 订阅状态 / 语言。 + * 个人中心扩展字段(history/favorites/stats/member_since)为可选:仅在 server 携带时渲染, + * 不携带时前端给真实空态。声明在 schema 内以免 valibot 静默 strip 掉这些字段。 + * @typedef {{ + * id: string, + * email?: string, + * display_name: string, + * credits: number, + * subscription: SubscriptionT, + * locale: string, + * member_since?: string, + * stats?: AccountStatsT, + * history?: LibraryItemT[], + * favorites?: LibraryItemT[] + * }} AccountT + */ +export const Account = v.object({ + id: v.string(), + email: v.optional(v.string()), + display_name: v.string(), + credits: v.number(), + subscription: Subscription, + locale: v.string(), + role: v.optional(v.picklist(['user', 'operator', 'admin'])), + member_since: v.optional(v.string()), + stats: v.optional(AccountStats), + history: v.optional(v.array(LibraryItem)), + favorites: v.optional(v.array(LibraryItem)), +}) diff --git a/packages/contracts/src/schemas/auth.js b/packages/contracts/src/schemas/auth.js new file mode 100644 index 0000000..3417223 --- /dev/null +++ b/packages/contracts/src/schemas/auth.js @@ -0,0 +1,44 @@ +import * as v from 'valibot' +import { Account } from './account.js' + +/** + * 登录请求:provider(oauth 厂商 / 匿名)+ 凭据 + 可选设备标识(匿名身份合并用)。 + * @typedef {{ provider: string, credential: string, oauth_flow?: 'access_token'|'authorization_code', redirect_uri?: string, device_id?: string }} LoginRequestT + */ +export const LoginRequest = v.object({ + provider: v.string(), + credential: v.string(), + oauth_flow: v.optional(v.picklist(['access_token', 'authorization_code'])), + redirect_uri: v.optional(v.string()), + device_id: v.optional(v.string()), +}) + +/** + * 登录响应:访问 / 刷新 token 对 + 账户资料。 + * @typedef {{ access_token: string, refresh_token: string, account: import('./account.js').AccountT }} LoginResponseT + */ +export const LoginResponse = v.object({ + access_token: v.string(), + refresh_token: v.string(), + account: Account, +}) + +/** + * 开放注册请求:验证码只用于本次邮箱注册,邀请码不属于该契约。 + * @typedef {{ email: string, password: string, verification_code: string, locale: 'en'|'zh'|'ja'|'ko'|'es'|'pt', device_id?: string }} RegisterRequestT + */ +export const RegisterRequest = v.strictObject({ + email: v.pipe(v.string(), v.trim(), v.toLowerCase(), v.email(), v.maxLength(254)), + password: v.pipe(v.string(), v.minLength(8), v.maxLength(256)), + verification_code: v.pipe(v.string(), v.regex(/^\d{6}$/)), + locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']), + device_id: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(255))), +}) + +/** @typedef {{ channel: 'email', target: string, purpose: 'register', locale?: 'en'|'zh'|'ja'|'ko'|'es'|'pt' }} SendRegistrationCodeRequestT */ +export const SendRegistrationCodeRequest = v.strictObject({ + channel: v.literal('email'), + target: v.pipe(v.string(), v.trim(), v.toLowerCase(), v.email(), v.maxLength(254)), + purpose: v.literal('register'), + locale: v.optional(v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt'])), +}) diff --git a/packages/contracts/src/schemas/bundle.js b/packages/contracts/src/schemas/bundle.js new file mode 100644 index 0000000..7b5c45f --- /dev/null +++ b/packages/contracts/src/schemas/bundle.js @@ -0,0 +1,44 @@ +import * as v from 'valibot' +import { ScriptLine } from './script-line.js' + +/** + * 场景引用:Bundle 内联引用一个场景(按 id 指向 assets 库的场景资源), + * 仅在 Bundle 内部使用,不单独作为公共 schema 出包。 + * @typedef {{ id: string, background?: string, props?: string[] }} SceneRefT + */ +export const SceneRef = v.object({ + id: v.string(), + background: v.optional(v.string()), + props: v.optional(v.array(v.string())), +}) + +/** + * Bundle 元信息:drama 级别的播放元数据。 + * @typedef {{ locale: string, version: number, duration?: number }} BundleMetaT + */ +export const BundleMeta = v.object({ + locale: v.string(), + version: v.number(), + duration: v.optional(v.number()), +}) + +/** + * 播放包:`` 的入参、`@drama/core` 消费、`@drama/bundle` 出包。 + * 组合 drama 元信息 + rig 引用 + 场景序列 + 剧本行序列。 + * @typedef {{ + * drama_id: string, + * title: string, + * rig_ref: string, + * scenes: SceneRefT[], + * lines: import('./script-line.js').ScriptLineT[], + * meta: BundleMetaT + * }} BundleT + */ +export const Bundle = v.object({ + drama_id: v.string(), + title: v.string(), + rig_ref: v.string(), + scenes: v.array(SceneRef), + lines: v.array(ScriptLine), + meta: BundleMeta, +}) diff --git a/packages/contracts/src/schemas/commerce.js b/packages/contracts/src/schemas/commerce.js new file mode 100644 index 0000000..748fe66 --- /dev/null +++ b/packages/contracts/src/schemas/commerce.js @@ -0,0 +1,17 @@ +import * as v from 'valibot' + +export const BillingSummary = v.object({ + points: v.number(), + playable_turns: v.number(), + action_prices: v.object({ turn_play: v.number() }), + stripe_mode: v.picklist(['test', 'live']), + daily_claim: v.object({ + claimed_today: v.boolean(), + next_available_at: v.string(), + points: v.number(), + }), +}) + +export const DailyClaimResponse = v.object({ + points: v.number(), claimed: v.boolean(), claimed_today: v.boolean(), next_available_at: v.string(), +}) diff --git a/packages/contracts/src/schemas/credits.js b/packages/contracts/src/schemas/credits.js new file mode 100644 index 0000000..48f12df --- /dev/null +++ b/packages/contracts/src/schemas/credits.js @@ -0,0 +1,50 @@ +import * as v from 'valibot' + +/** + * Credits 经济契约 —— 扣点解锁 + 充值入账 + 流水条目的共享数据形态。 + * + * 与 S3 的 `account.js`(role/subscription/credits 余额字段)严格分家:本文件只描述 + * 「解锁请求/响应、充值下单请求、单条流水」四种形态,余额本身仍由 AccountT.credits 承载。 + * 纯定义、零 IO,前后端共用。 + */ + +/** + * 解锁请求:解锁某剧(可选某集)。episode_id 省略 = 整剧级解锁。 + * @typedef {{ drama_id: string, episode_id?: string }} UnlockReqT + */ +export const UnlockReq = v.object({ + drama_id: v.pipe(v.string(), v.minLength(1)), + episode_id: v.optional(v.string()), +}) + +/** + * 解锁响应:是否已解锁 + 解锁后剩余点数 + 可选原因。 + * reason='already' → 此前已解锁,幂等返回,未再扣点。 + * reason='insufficient' → 余额不足,未扣点、未解锁。 + * 无 reason → 本次成功扣点并解锁。 + * @typedef {{ unlocked: boolean, remaining_credits: number, reason?: 'insufficient' | 'already' }} UnlockResT + */ +export const UnlockRes = v.object({ + unlocked: v.boolean(), + remaining_credits: v.number(), + reason: v.optional(v.picklist(['insufficient', 'already'])), +}) + +/** + * 充值下单请求:以套餐 id 选点数包,server 据 pack_id 映射点数/价格发起真 Stripe checkout。 + * @typedef {{ pack_id: string }} TopupCheckoutReqT + */ +export const TopupCheckoutReq = v.object({ + pack_id: v.pipe(v.string(), v.minLength(1)), +}) + +/** + * 单条点数流水:账本对外展示形态(正入账 / 负扣减)。createdAt 为 ISO 8601 UTC 字符串。 + * @typedef {{ id: string, delta: number, reason: 'topup' | 'unlock' | 'grant', created_at: string }} LedgerEntryT + */ +export const LedgerEntry = v.object({ + id: v.string(), + delta: v.number(), + reason: v.picklist(['topup', 'unlock', 'grant']), + created_at: v.string(), +}) diff --git a/packages/contracts/src/schemas/drama.js b/packages/contracts/src/schemas/drama.js new file mode 100644 index 0000000..bbc9e2a --- /dev/null +++ b/packages/contracts/src/schemas/drama.js @@ -0,0 +1,68 @@ +import * as v from 'valibot' + +/** + * 付费墙描述:drama 是否/如何收费(剧情线/结局解锁)。 + * `unlock_cost`/`user_credits`/`vip_tier` 由 server(S2 sk-facade)按需下发,驱动解锁弹窗的 + * 「需 N 点 · 你有 M 点」与「开 VIP」指向;全为可选,缺省时弹窗降级为纯订阅引导。 + * @typedef {{ + * locked: boolean, + * price?: number, + * currency?: string, + * unlock_cost?: number, + * user_credits?: number, + * vip_tier?: string + * }} PaywallT + */ +export const Paywall = v.object({ + locked: v.boolean(), + price: v.optional(v.number()), + currency: v.optional(v.string()), + /** 解锁本条剧情线所需点数(后端按规则计算下发)。 */ + unlock_cost: v.optional(v.number()), + /** 当前账户点数余额(解锁弹窗实时显示,禁用判定依据)。 */ + user_credits: v.optional(v.number()), + /** 「开通 VIP」指向的档位标识(如 vip-yearly)。 */ + vip_tier: v.optional(v.string()), +}) + +/** + * drama 列表项形状:Feed 刷剧与剧本库共用。 + * @typedef {{ + * id: string, + * title: string, + * cover: string, + * locale: string, + * heat?: number, + * paywall?: PaywallT + * }} DramaT + */ +export const Drama = v.object({ + id: v.string(), + title: v.string(), + cover: v.string(), + locale: v.string(), + heat: v.optional(v.number()), + paywall: v.optional(Paywall), +}) + +/** + * 高阶分页信封工厂:`Page(itemSchema)` 产出该 item 的分页 schema,复用同一信封结构。 + * 统一分页形状供 Feed / 剧本库等所有列表接口共用。 + * @param {import('valibot').GenericSchema} itemSchema 任意 item 的 Valibot schema + * @returns {import('valibot').GenericSchema} 该 item 的分页信封 schema + */ +export function Page(itemSchema) { + return v.object({ + items: v.array(itemSchema), + page: v.number(), + page_size: v.number(), + total: v.number(), + has_more: v.boolean(), + }) +} + +/** + * 分页信封类型(泛型 item)。 + * @template T + * @typedef {{ items: T[], page: number, page_size: number, total: number, has_more: boolean }} PageT + */ diff --git a/packages/contracts/src/schemas/error.js b/packages/contracts/src/schemas/error.js new file mode 100644 index 0000000..b294aab --- /dev/null +++ b/packages/contracts/src/schemas/error.js @@ -0,0 +1,18 @@ +import * as v from 'valibot' + +/** + * 统一错误形状:全 API 失败响应同形。 + * `code` 与 `retryable` 的语义与各能力包的结构化错误(如 `TtsError`)对齐, + * 前端可一套分支处理全部失败(401 / 限流 / 校验失败等)。 + * @typedef {{ + * error: { code: string, message: string, retryable: boolean, details?: object } + * }} ApiErrorT + */ +export const ApiError = v.object({ + error: v.object({ + code: v.string(), + message: v.string(), + retryable: v.boolean(), + details: v.optional(v.record(v.string(), v.unknown())), + }), +}) diff --git a/packages/contracts/src/schemas/script-line.js b/packages/contracts/src/schemas/script-line.js new file mode 100644 index 0000000..0425784 --- /dev/null +++ b/packages/contracts/src/schemas/script-line.js @@ -0,0 +1,55 @@ +import * as v from 'valibot' + +/** + * TTS 提示:续写 LLM / 编排可附带的语音合成参数(全部可选)。 + * @typedef {{ instruction?: string, speech_rate?: number }} TtsHintT + */ +export const TtsHint = v.object({ + instruction: v.optional(v.string()), + speech_rate: v.optional(v.number()), +}) + +/** + * 剧本单行:播放引擎与续写 LLM 的共同语言。 + * `mode` 区分阻塞(说完才进下一行)与并行(与相邻行同时进行)语义。 + * @typedef {{ + * speaker: string, + * text: string, + * emotion?: string, + * mode: 'block' | 'parallel', + * tts?: TtsHintT + * }} ScriptLineT + */ +export const ScriptLine = v.object({ + speaker: v.string(), + text: v.string(), + emotion: v.optional(v.string()), + mode: v.picklist(['block', 'parallel']), + tts: v.optional(TtsHint), +}) + +/** + * 单行配音请求体(POST /api/sk/drama/:id/line/tts 的入参契约)。 + * + * `speaker` 必须由调用方传入有效音色 id(复刻音色形如 `S_aAB0nUX42`)——后端不再 + * 兜底为某个固定音色(旧实现兜底女声导致「钟爹也变女声」,见 TTS 链路修复)。 + * `instruction` 是语气指令(如「生气地说」),复刻音色靠它控语气;未传时后端按 + * `emotion` 派生默认语气词。`emotion` / `emotion_scale` 走普通音色情绪分支。 + * + * @typedef {{ + * text: string, + * speaker?: string, + * instruction?: string, + * emotion?: string, + * emotion_scale?: number, + * speech_rate?: number + * }} TtsLineBodyT + */ +export const TtsLineBody = v.object({ + text: v.pipe(v.string(), v.minLength(1)), + speaker: v.optional(v.string()), + instruction: v.optional(v.string()), + emotion: v.optional(v.string()), + emotion_scale: v.optional(v.number()), + speech_rate: v.optional(v.number()), +}) diff --git a/packages/contracts/src/schemas/share.js b/packages/contracts/src/schemas/share.js new file mode 100644 index 0000000..889ade9 --- /dev/null +++ b/packages/contracts/src/schemas/share.js @@ -0,0 +1,20 @@ +import * as v from 'valibot' + +/** + * 可分享对象的元数据:社交爬虫 / SEO 注入 og/title 与首屏最小内容所需的最小形状。 + * 单一事实来源,供 `@drama/share` 的 `buildMetaTags` / `buildPreview` / `createMetaInjector` 消费。 + * @typedef {{ + * title: string, + * description: string, + * image: string, + * url: string, + * type?: string + * }} ShareMetaT + */ +export const ShareMeta = v.object({ + title: v.string(), + description: v.string(), + image: v.string(), + url: v.string(), + type: v.optional(v.string()), +}) diff --git a/packages/contracts/src/schemas/telemetry.js b/packages/contracts/src/schemas/telemetry.js new file mode 100644 index 0000000..16802dc --- /dev/null +++ b/packages/contracts/src/schemas/telemetry.js @@ -0,0 +1,33 @@ +import * as v from 'valibot' + +/** + * 埋点事件信封 —— 前端 `@drama/telemetry` SDK 上报的原始事件形态, + * 也是后端 `@drama/telemetry-ingest` 校验落库的单一事实来源。 + * + * 字段与前端 `EventEnvelope`(`@drama/telemetry` 的 `envelope.js`)逐项对齐: + * 同一份契约前端产、后端校验,避免前后端事件形态漂移。`props` 用 `record` 以 + * 容纳任意业务属性(一期不约束业务字段,只保证信封骨架完整)。 + * + * `ts` 是客户端事件发生时刻的 epoch 毫秒数(number),非 DB DATETIME 字段,保持 number。 + * + * @typedef {{ + * name: string, + * props: Record, + * ts: number, + * device_id: string, + * anon_id: string | null, + * locale: string, + * session_id: string, + * sdk_version: string + * }} TelemetryEventT + */ +export const TelemetryEvent = v.object({ + name: v.string(), + props: v.record(v.string(), v.unknown()), + ts: v.number(), + device_id: v.string(), + anon_id: v.nullable(v.string()), + locale: v.string(), + session_id: v.string(), + sdk_version: v.string(), +}) diff --git a/packages/contracts/src/schemas/visual-game-admin-analytics.js b/packages/contracts/src/schemas/visual-game-admin-analytics.js new file mode 100644 index 0000000..08be741 --- /dev/null +++ b/packages/contracts/src/schemas/visual-game-admin-analytics.js @@ -0,0 +1,147 @@ +import * as v from 'valibot' + +export const VisualGameAdminAnalyticsMetrics = v.object({ + active_players: v.number(), + interactions: v.number(), + say_turns: v.number(), + act_turns: v.number(), + credits_spent: v.number(), + credits_granted: v.number(), + runs_started: v.number(), + runs_completed: v.number(), + seasons_started: v.number(), + seasons_completed: v.number(), + paying_players: v.number(), +}) + +export const VisualGameAdminAnalyticsDailyItem = v.object({ + date: v.string(), + active_players: v.number(), + interactions: v.number(), +}) + +export const VisualGameAdminAnalyticsOverviewResponse = v.object({ + generated_at: v.string(), + since: v.string(), + until: v.string(), + metrics: VisualGameAdminAnalyticsMetrics, + daily: v.array(VisualGameAdminAnalyticsDailyItem), +}) + +export const VisualGameAdminAnalyticsDrilldownMetric = v.picklist([ + 'active_players', + 'interactions', + 'say_turns', + 'act_turns', + 'credits_spent', + 'credits_granted', + 'runs_started', + 'runs_completed', + 'seasons_started', + 'seasons_completed', + 'paying_players', +]) + +export const VisualGameAdminAnalyticsDrilldownItem = v.object({ + user_id: v.string(), + display_name: v.nullable(v.string()), + account_type: v.picklist(['registered', 'guest']), + interactions: v.number(), + say_turns: v.number(), + act_turns: v.number(), + credits_spent: v.number(), + credits_granted: v.number(), + runs_started: v.number(), + runs_completed: v.number(), + seasons_started: v.number(), + seasons_completed: v.number(), + paid_orders: v.number(), + last_interaction_at: v.nullable(v.string()), +}) + +export const VisualGameAdminAnalyticsDrilldownResponse = v.object({ + generated_at: v.string(), + since: v.string(), + until: v.string(), + metric: VisualGameAdminAnalyticsDrilldownMetric, + total: v.number(), + items: v.array(VisualGameAdminAnalyticsDrilldownItem), +}) + +export const VisualGameAdminPlayerInteractionItem = v.object({ + id: v.string(), + turn_no: v.number(), + mode: v.picklist(['say', 'act']), + input_text: v.string(), + status: v.literal('completed'), + created_at: v.string(), + scenario_id: v.string(), + scenario_title: v.nullable(v.string()), + series_title: v.nullable(v.string()), + season_no: v.nullable(v.number()), + episode_no: v.nullable(v.number()), +}) + +export const VisualGameAdminPlayerInteractionsResponse = v.object({ + generated_at: v.string(), + since: v.string(), + until: v.string(), + user_id: v.string(), + total: v.number(), + items: v.array(VisualGameAdminPlayerInteractionItem), +}) + +export const VisualGameAdminContentAnalyticsItem = v.object({ + scenario_id: v.string(), + scenario_title: v.string(), + series_id: v.nullable(v.string()), + series_title: v.nullable(v.string()), + episode_no: v.number(), + players: v.number(), + active_players: v.number(), + runs_started: v.number(), + runs_completed: v.number(), + interactions: v.number(), + say_turns: v.number(), + act_turns: v.number(), + completion_rate: v.nullable(v.number()), +}) + +export const VisualGameAdminContentAnalyticsResponse = v.object({ + generated_at: v.string(), + since: v.string(), + until: v.string(), + items: v.array(VisualGameAdminContentAnalyticsItem), +}) + +const Ga4Count = v.pipe(v.number(), v.integer(), v.minValue(0)) +const Ga4Summary = v.object({ + unique_visitors: Ga4Count, + active_users: Ga4Count, + page_views: Ga4Count, + sessions: Ga4Count, + video_viewers: Ga4Count, + video_starts: Ga4Count, + video_completions: Ga4Count, + ui_clicks: Ga4Count, + scene_clicks: Ga4Count, + typed_inputs: Ga4Count, + selected_inputs: Ga4Count, +}) + +export const VisualGameAdminGa4BehaviorResponse = v.object({ + configured: v.boolean(), + generated_at: v.string(), + since: v.string(), + until: v.string(), + summary: Ga4Summary, + traffic_daily: v.array(v.object({ date: v.string(), unique_visitors: Ga4Count, page_views: Ga4Count })), + 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 })), + pages: v.array(v.object({ path: v.string(), title: v.string(), views: Ga4Count, users: Ga4Count })), + flows: v.array(v.object({ from: v.string(), to: v.string(), views: Ga4Count, users: Ga4Count })), + devices: v.array(v.object({ category: v.string(), operating_system: v.string(), browser: v.string(), users: Ga4Count, sessions: Ga4Count })), + 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 })), + clicks: v.array(v.object({ element_id: v.string(), surface: v.string(), clicks: Ga4Count, users: Ga4Count })), + 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 })), + inputs: v.array(v.object({ input_method: v.string(), mode: v.string(), submissions: Ga4Count, users: Ga4Count })), +}) diff --git a/packages/contracts/src/schemas/visual-game-admin-commerce.js b/packages/contracts/src/schemas/visual-game-admin-commerce.js new file mode 100644 index 0000000..7f0a489 --- /dev/null +++ b/packages/contracts/src/schemas/visual-game-admin-commerce.js @@ -0,0 +1,157 @@ +import * as v from 'valibot' + +export const VisualGameAdminBillingActionPolicy = v.object({ + action: v.string(), + points: v.number(), + period_kind: v.picklist(['daily', 'none']), + timezone: v.nullable(v.string()), + enabled: v.boolean(), + updated_by: v.nullable(v.string()), +}) + +export const VisualGameAdminBillingProduct = v.object({ + id: v.string(), + kind: v.literal('points_pack'), + title: v.string(), + points: v.number(), + paid_points: v.number(), + bonus_points: v.number(), + price_cents: v.number(), + price_usd: v.number(), + currency: v.string(), + stripe_price_id: v.nullable(v.string()), + active: v.boolean(), +}) + +export const VisualGameAdminBillingOrder = v.object({ + id: v.string(), + user_id: v.string(), + product_id: v.string(), + provider: v.string(), + status: v.picklist(['pending', 'paid', 'canceled', 'failed', 'refunded', 'disputed']), + amount_cents: v.number(), + currency: v.string(), + paid_points: v.number(), + bonus_points: v.number(), + total_points: v.number(), + points_reversed: v.number(), + reversal_shortfall_points: v.number(), + created_at: v.string(), +}) + +export const VisualGameAdminBillingReference = v.object({ + label: v.string(), + href: v.string(), +}) + +export const VisualGameAdminBillingLedgerEntry = v.object({ + id: v.string(), + user_id: v.string(), + type: v.picklist(['grant', 'deduct']), + amount: v.number(), + balance_after: v.number(), + source: v.string(), + credit_kind: v.nullable(v.string()), + reason: v.string(), + ref_type: v.nullable(v.string()), + ref_id: v.nullable(v.string()), + note: v.nullable(v.string()), + failure_code: v.nullable(v.string()), + reference: v.nullable(VisualGameAdminBillingReference), + created_at: v.string(), +}) + +export const VisualGameAdminBillingPolicyList = v.object({ + items: v.array(VisualGameAdminBillingActionPolicy), +}) + +export const VisualGameAdminBillingProductList = v.object({ + items: v.array(VisualGameAdminBillingProduct), +}) + +export const VisualGameAdminBillingOrderPage = v.object({ + items: v.array(VisualGameAdminBillingOrder), + next_cursor: v.nullable(v.string()), +}) + +export const VisualGameAdminBillingLedgerPage = v.object({ + items: v.array(VisualGameAdminBillingLedgerEntry), + next_cursor: v.nullable(v.string()), +}) + +export const VisualGameAdminBillingDashboardDay = v.object({ + date: v.string(), + start_at: v.string(), + end_at: v.string(), + revenue_cents: v.number(), + paid_orders: v.number(), + refund_orders: v.number(), + points_granted: v.number(), + points_spent: v.number(), +}) + +export const VisualGameAdminBillingDashboardProduct = v.object({ + product_id: v.string(), + title: v.nullable(v.string()), + orders: v.number(), + revenue_cents: v.number(), + points: v.number(), +}) + +/** 消耗按 reason + 业务类型分块:model_usage 这种按调用计费的流水靠 ref_type 才分得出 LLM / 语音 / 图片。 */ +export const VisualGameAdminBillingDashboardSpend = v.object({ + reason: v.string(), + ref_type: v.nullable(v.string()), + points: v.number(), +}) + +/** 商业化看板:一个时间窗内的收入、积分、转化、风控汇总 + 按自然日的序列。比率分母为 0 时给 null。 */ +export const VisualGameAdminBillingDashboard = v.object({ + since: v.string(), + until: v.string(), + start_at: v.string(), + end_at: v.string(), + timezone: v.string(), + generated_at: v.string(), + revenue: v.object({ + gross_cents: v.number(), + refund_cents: v.number(), + net_cents: v.number(), + paid_orders: v.number(), + paying_users: v.number(), + first_time_buyers: v.number(), + }), + points: v.object({ + purchased_paid: v.number(), + purchased_bonus: v.number(), + daily_claim: v.number(), + signup_reward: v.number(), + admin_grant: v.number(), + turn_spent: v.number(), + video_spent: v.number(), + other_spent: v.number(), + turn_refunded: v.number(), + video_refunded: v.number(), + }), + conversion: v.object({ + new_users: v.number(), + new_users_paid: v.number(), + signup_to_paid_rate: v.nullable(v.number()), + claim_users: v.number(), + active_users: v.number(), + claim_rate: v.nullable(v.number()), + }), + risk: v.object({ + refunded_orders: v.number(), + disputed_orders: v.number(), + refund_rate: v.nullable(v.number()), + dispute_rate: v.nullable(v.number()), + turn_refunds: v.number(), + video_refunds: v.number(), + shortfall_orders: v.number(), + shortfall_points: v.number(), + }), + daily: v.array(VisualGameAdminBillingDashboardDay), + products: v.array(VisualGameAdminBillingDashboardProduct), + spend_mix: v.array(VisualGameAdminBillingDashboardSpend), +}) diff --git a/packages/contracts/src/schemas/visual-game-admin-users.js b/packages/contracts/src/schemas/visual-game-admin-users.js new file mode 100644 index 0000000..2d0e01f --- /dev/null +++ b/packages/contracts/src/schemas/visual-game-admin-users.js @@ -0,0 +1,207 @@ +import * as v from 'valibot' + +export const VisualGameAdminUserAccountType = v.picklist(['registered', 'guest']) + +export const VisualGameAdminUserSubscriptionStatus = v.picklist([ + 'none', + 'active', + 'expired', + 'canceled', +]) + +export const VisualGameAdminUserSubscription = v.object({ + level: v.string(), + status: VisualGameAdminUserSubscriptionStatus, + expires_at: v.nullable(v.string()), + provider: v.nullable(v.string()), + source: v.nullable(v.string()), +}) + +export const VisualGameAdminUserListStats = v.object({ + runs: v.number(), + turns: v.number(), + endings: v.number(), + orders: v.number(), +}) + +export const VisualGameAdminUserInteractionStats = v.object({ + credits_spent: v.number(), + credits_granted: v.number(), + say_turns: v.number(), + act_turns: v.number(), + interactions: v.number(), + completed_runs: v.number(), + completed_seasons: v.number(), +}) + +export const VisualGameAdminUserListItem = v.object({ + id: v.string(), + account_type: VisualGameAdminUserAccountType, + email: v.nullable(v.string()), + display_name: v.nullable(v.string()), + locale: v.nullable(v.string()), + created_at: v.string(), + last_active_at: v.nullable(v.string()), + credits: v.number(), + subscription: VisualGameAdminUserSubscription, + stats: VisualGameAdminUserListStats, + interaction_stats: VisualGameAdminUserInteractionStats, +}) + +export const VisualGameAdminUserSummary = v.object({ + total_users: v.number(), + registered_users: v.number(), + guest_users: v.number(), + players: v.number(), + paying_users: v.number(), +}) + +export const VisualGameAdminUserListResponse = v.object({ + items: v.array(VisualGameAdminUserListItem), + next_cursor: v.nullable(v.string()), + total: v.number(), +}) + +export const VisualGameAdminGuestDevice = v.object({ + status: v.picklist(['active', 'merged']), + last_seen_at: v.nullable(v.string()), + merged_user_id: v.nullable(v.string()), +}) + +export const VisualGameAdminLedgerEntry = v.object({ + id: v.string(), + type: v.picklist(['grant', 'deduct']), + amount: v.number(), + balance_after: v.number(), + reason: v.nullable(v.string()), + ref_type: v.nullable(v.string()), + ref_id: v.nullable(v.string()), + created_at: v.string(), +}) + +export const VisualGameAdminPaymentOrder = v.object({ + id: v.string(), + product_id: v.string(), + mode: v.picklist(['payment', 'subscription']), + provider: v.picklist(['stripe', 'apple', 'google']), + status: v.picklist(['pending', 'paid', 'canceled', 'failed', 'refunded', 'disputed']), + amount_cents: v.nullable(v.number()), + refund_amount: v.nullable(v.number()), + currency: v.nullable(v.string()), + created_at: v.string(), + updated_at: v.nullable(v.string()), +}) + +export const VisualGameAdminRun = v.object({ + id: v.string(), + scenario_id: v.string(), + scenario_title: v.nullable(v.string()), + status: v.picklist(['active', 'completed']), + turn_count: v.number(), + current_location_id: v.nullable(v.string()), + phase: v.nullable(v.string()), + created_at: v.string(), + updated_at: v.nullable(v.string()), + completed_at: v.nullable(v.string()), +}) + +export const VisualGameAdminPlaySummary = v.object({ + runs: v.number(), + active_runs: v.number(), + completed_runs: v.number(), + turns: v.number(), + scenarios: v.number(), + progress_records: v.number(), + save_slots: v.number(), + endings: v.number(), + last_active_at: v.nullable(v.string()), + say_turns: v.number(), + act_turns: v.number(), + credits_spent: v.number(), + credits_granted: v.number(), + completed_seasons: v.number(), +}) + +export const VisualGameAdminUserAuditAction = v.picklist([ + 'account_provisioned', + 'profile_updated', + 'credits_granted', + 'credits_deducted', + 'subscription_updated', +]) + +export const VisualGameAdminUserProvisionRequest = v.strictObject({ + mutation_id: v.pipe(v.string(), v.regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)), + email: v.pipe(v.string(), v.trim(), v.toLowerCase(), v.email(), v.maxLength(254)), + password: v.pipe(v.string(), v.minLength(8), v.maxLength(256)), + locale: v.picklist(['zh', 'en', 'ja', 'ko', 'es', 'pt']), + initial_credits: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(10000000)), + reason: v.pipe(v.string(), v.trim(), v.minLength(3), v.maxLength(500)), +}) + +export const VisualGameAdminUserAuditEntry = v.object({ + id: v.string(), + operator_id: v.string(), + action: VisualGameAdminUserAuditAction, + reason: v.string(), + created_at: v.string(), +}) + +export const VisualGameAdminUserDetail = v.object({ + user: v.object({ + id: v.string(), + account_type: VisualGameAdminUserAccountType, + email: v.nullable(v.string()), + display_name: v.nullable(v.string()), + nickname: v.nullable(v.string()), + avatar_url: v.nullable(v.string()), + account_locale: v.nullable(v.string()), + profile_locale: v.nullable(v.string()), + email_verified_at: v.nullable(v.string()), + created_at: v.string(), + }), + guest_devices: v.array(VisualGameAdminGuestDevice), + subscription: VisualGameAdminUserSubscription, + credits: v.object({ + balance: v.number(), + ledger: v.array(VisualGameAdminLedgerEntry), + }), + orders: v.array(VisualGameAdminPaymentOrder), + audit_logs: v.array(VisualGameAdminUserAuditEntry), + play: v.object({ + summary: VisualGameAdminPlaySummary, + recent_runs: v.array(VisualGameAdminRun), + }), +}) + +export const VisualGameAdminUserDetailResponse = v.object({ + detail: VisualGameAdminUserDetail, +}) + +export const VisualGameAdminUserProfileUpdateRequest = v.object({ + mutation_id: v.string(), + nickname: v.nullable(v.string()), + avatar_url: v.nullable(v.string()), + locale: v.picklist(['zh', 'en', 'ja', 'ko', 'es', 'pt']), + reason: v.string(), +}) + +export const VisualGameAdminUserCreditsAdjustRequest = v.object({ + mutation_id: v.string(), + type: v.picklist(['grant', 'deduct']), + amount: v.number(), + reason: v.string(), +}) + +export const VisualGameAdminUserSubscriptionUpdateRequest = v.object({ + mutation_id: v.string(), + level: v.string(), + status: VisualGameAdminUserSubscriptionStatus, + expires_at: v.nullable(v.string()), + reason: v.string(), +}) + +export const VisualGameAdminUserActionResponse = v.object({ + detail: VisualGameAdminUserDetail, + audit_id: v.string(), +}) diff --git a/packages/contracts/src/schemas/visual-game-agent.js b/packages/contracts/src/schemas/visual-game-agent.js new file mode 100644 index 0000000..46959ca --- /dev/null +++ b/packages/contracts/src/schemas/visual-game-agent.js @@ -0,0 +1,249 @@ +import * as v from 'valibot' + +function strictRecord(key, value) { + return v.intersect([ + v.custom(input => Boolean(input) && typeof input === 'object' && !Array.isArray(input), 'Expected an object record'), + v.record(key, value), + ]) +} + +export const VISUAL_GAME_STATE_SCALAR_VALUE_TYPES = Object.freeze(['string', 'number', 'boolean']) + +const STATE_SCALAR_SCHEMAS = Object.freeze({ + boolean: v.boolean(), + number: v.number(), + string: v.string(), +}) +const StateScalar = v.union(VISUAL_GAME_STATE_SCALAR_VALUE_TYPES.map(valueType => ( + STATE_SCALAR_SCHEMAS[valueType] +))) +const StateScalarRecord = strictRecord(v.string(), StateScalar) +const StringArray = v.array(v.string()) + +function patchField(definition) { + switch (definition.schemaKind) { + case 'boolean': + return v.optional(v.boolean()) + case 'nullable_string': + return v.optional(v.nullable(v.string())) + case 'state_scalar_record': + return v.optional(StateScalarRecord) + case 'string': + return v.optional(v.string()) + case 'string_array': + return v.optional(StringArray) + default: + throw new Error(`Unhandled visual-game patch schema kind: ${definition.schemaKind}`) + } +} + +function patchEntries(definitions) { + return Object.fromEntries(Object.entries(definitions).map(([field, definition]) => [ + field, + patchField(definition), + ])) +} + +function patchDefinition(schemaKind, registryScope, registryPathKind, registryValueTypes) { + return Object.freeze({ + schemaKind, + registryScope, + registryPathKind, + registryValueTypes: Object.freeze([...registryValueTypes]), + }) +} + +/** + * CandidateDelta object patch fields and their stateRegistry representation. + * Both the Valibot boundary and compile-time registry audit consume this table. + */ +export const VISUAL_GAME_OBJECT_STATE_PATCH_FIELDS = Object.freeze({ + known: patchDefinition('boolean', 'object_state', 'field', ['boolean']), + inspected: patchDefinition('boolean', 'object_state', 'field', ['boolean']), + usable: patchDefinition('boolean', 'object_state', 'field', ['boolean']), + damaged: patchDefinition('boolean', 'object_state', 'field', ['boolean']), + heldBy: patchDefinition('nullable_string', 'object_state', 'field', ['string']), + location: patchDefinition('string', 'object_state', 'field', ['string']), + state: patchDefinition('string', 'object_state', 'field', ['string']), + flags: patchDefinition( + 'state_scalar_record', + 'object_state', + 'record_entry', + VISUAL_GAME_STATE_SCALAR_VALUE_TYPES, + ), +}) + +/** + * CandidateDelta NPC patch fields and their stateRegistry representation. + */ +export const VISUAL_GAME_NPC_STATE_PATCH_FIELDS = Object.freeze({ + locationId: patchDefinition('string', 'npc_state', 'field', ['string']), + position: patchDefinition('string', 'npc_state', 'field', ['string']), + mood: patchDefinition('string', 'npc_state', 'field', ['string']), + knownFacts: patchDefinition('string_array', 'npc_state', 'array_item', ['string']), + revealedSecrets: patchDefinition('string_array', 'npc_state', 'array_item', ['string']), + relationshipFlags: patchDefinition( + 'state_scalar_record', + 'npc_relationship_flag', + 'record_entry', + VISUAL_GAME_STATE_SCALAR_VALUE_TYPES, + ), +}) + +const ObjectStatePatch = v.strictObject(patchEntries(VISUAL_GAME_OBJECT_STATE_PATCH_FIELDS)) +const NpcStatePatch = v.strictObject(patchEntries(VISUAL_GAME_NPC_STATE_PATCH_FIELDS)) + +const InventoryChanges = v.strictObject({ + add: StringArray, + remove: StringArray, +}) + +const DeltaSuggestionEntries = { + statChanges: v.optional(strictRecord(v.string(), v.number())), + flagsSet: v.optional(StateScalarRecord), + evidenceAdded: v.optional(StringArray), + objectStateChanges: v.optional(strictRecord(v.string(), ObjectStatePatch)), + npcStateChanges: v.optional(strictRecord(v.string(), NpcStatePatch)), + inventoryChanges: v.optional(InventoryChanges), + locationChange: v.optional(v.string()), + notes: v.optional(StringArray), +} + +const CandidateDelta = v.strictObject(DeltaSuggestionEntries) + +const JudgementSuggestion = v.strictObject({ + valid: v.boolean(), + reason: v.optional(v.string()), + ...DeltaSuggestionEntries, +}) + +const SceneResult = v.strictObject({ + narration: v.string(), + visibleFindings: StringArray, + discoveredObjectIds: StringArray, +}) + +const StorySignal = v.union([ + v.strictObject({ + should_track: v.literal(false), + reason: v.string(), + }), + v.strictObject({ + should_track: v.literal(true), + continuation_tracker_id: v.nullable(v.string()), + topic_cluster: v.string(), + display_name: v.string(), + topic_summary: v.string(), + topic_kind: v.picklist([ + 'relationship', + 'external_entity', + 'ally_intervention', + 'ritual', + 'countermeasure', + 'identity', + 'world_lore', + 'other', + ]), + player_intended_action: v.string(), + decisive_action: v.boolean(), + distinct_progress: v.boolean(), + progress_marker: v.string(), + reason: v.string(), + }), +]) + +const BranchResolution = v.union([ + v.strictObject({ matched: v.literal(false) }), + v.strictObject({ + branch_id: v.string(), + matched: v.literal(true), + decision: v.literal('continue'), + summary: v.string(), + }), + v.strictObject({ + branch_id: v.string(), + matched: v.literal(true), + decision: v.literal('resolve'), + resolution_summary: v.string(), + }), +]) + +export const VisualGameKpAgentOutput = v.strictObject({ + expression_state: v.nullable(v.string()), + action: v.strictObject({ + intent: v.picklist([ + 'investigate', + 'accept', + 'refuse', + 'approach', + 'retreat', + 'question', + 'confront', + 'deceive', + 'destroy', + 'escape', + 'move', + 'comfort', + 'surrender', + 'attack', + 'wait', + ]), + targets: StringArray, + stance: v.picklist(['cautious', 'trusting', 'hostile', 'neutral', 'deceptive']), + risk: v.picklist(['low', 'normal', 'high', 'fatal']), + claims: StringArray, + }), + judgementSuggestion: JudgementSuggestion, + clueJudgements: v.array(v.strictObject({ + clueId: v.string(), + discovered: v.boolean(), + basis: v.string(), + })), + sceneResult: SceneResult, + responsePolicy: v.strictObject({ + npcReaction: v.picklist(['none', 'optional', 'required']), + reason: v.string(), + }), + npcDirective: v.strictObject({ + npcId: v.string(), + tactic: v.string(), + mood: v.picklist(['neutral', 'gentle', 'gentle_but_alert', 'cold_gentle', 'sad', 'threatening']), + mustReactTo: StringArray, + mustHide: StringArray, + }), + story_signal: v.optional(StorySignal), + branch_resolution: v.optional(BranchResolution), +}) + +const ProposedNpcAction = v.strictObject({ + type: v.string(), + targetId: v.string(), + intent: v.string(), + description: v.string(), + candidate_delta: v.optional(CandidateDelta), +}) + +export const VisualGameNpcActorOutput = v.strictObject({ + expression_state: v.nullable(v.string()), + narration: v.string(), + npcLine: v.string(), + proposedNpcActions: v.array(ProposedNpcAction), + tacticUsed: v.string(), + tone: v.string(), + playerPromptType: v.picklist(['none', 'question', 'action_invitation', 'warning']), + playerOptions: v.strictObject({ + say: v.string(), + act: v.string(), + }), +}) + +export const VisualGameNpcActionKpOutput = v.strictObject({ + npcId: v.string(), + approvedNpcActions: v.array(v.strictObject({ + type: v.string(), + targetId: v.string(), + result: v.string(), + })), + judgementSuggestion: JudgementSuggestion, + sceneResult: SceneResult, +}) diff --git a/packages/contracts/src/schemas/visual-game-scenario-release.js b/packages/contracts/src/schemas/visual-game-scenario-release.js new file mode 100644 index 0000000..c50b2f5 --- /dev/null +++ b/packages/contracts/src/schemas/visual-game-scenario-release.js @@ -0,0 +1,159 @@ +import * as v from 'valibot' + +const DateTime = v.pipe(v.string(), v.isoTimestamp()) +const NullableString = v.nullable(v.string()) +const NullableNumber = v.nullable(v.number()) + +const ScenarioReleasePack = v.strictObject({ + id: v.pipe(v.string(), v.minLength(1), v.maxLength(96)), + title: v.pipe(v.string(), v.minLength(1), v.maxLength(160)), + locale: v.pipe(v.string(), v.minLength(1), v.maxLength(16)), + series_id: NullableString, + episode_no: NullableNumber, + status: v.literal('published'), + visibility: v.pipe(v.string(), v.minLength(1), v.maxLength(24)), + current_version_id: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), + tagline: NullableString, + tags_json: v.array(v.pipe( + v.string(), + v.minLength(1), + v.check(tag => tag.trim() === tag, 'tag must not have surrounding whitespace'), + )), + created_at: DateTime, + updated_at: DateTime, +}) + +const ScenarioReleaseSeries = v.nullable(v.strictObject({ + id: v.pipe(v.string(), v.minLength(1), v.maxLength(96)), + title: v.pipe(v.string(), v.minLength(1), v.maxLength(160)), + locale: v.pipe(v.string(), v.minLength(1), v.maxLength(16)), + tagline: NullableString, + status: v.pipe(v.string(), v.minLength(1), v.maxLength(24)), + visibility: v.pipe(v.string(), v.minLength(1), v.maxLength(24)), + cover_url: NullableString, + created_at: DateTime, + updated_at: DateTime, +})) + +const ScenarioReleaseVersion = v.strictObject({ + id: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), + pack_id: v.pipe(v.string(), v.minLength(1), v.maxLength(96)), + version: v.pipe(v.number(), v.integer(), v.minValue(1)), + status: v.literal('published'), + title: v.pipe(v.string(), v.minLength(1), v.maxLength(160)), + tagline: NullableString, + asset_manifest_id: NullableString, + portrait_cover_tag: NullableString, + scenario_snapshot_json: v.string(), + compile_result_json: v.string(), + compiler_version: NullableString, + published_at: DateTime, + created_at: DateTime, + updated_at: DateTime, +}) + +const ScenarioReleasePublishCheck = v.strictObject({ + id: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), + version_id: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), + check_type: v.pipe(v.string(), v.minLength(1), v.maxLength(64)), + status: v.pipe(v.string(), v.minLength(1), v.maxLength(24)), + message: NullableString, + target_ref: NullableString, + created_at: DateTime, + updated_at: DateTime, +}) + +const ScenarioReleaseVersionAsset = v.strictObject({ + version_id: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), + asset_id: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), + tag: v.pipe(v.string(), v.minLength(1), v.maxLength(96)), + object_key: NullableString, + public_url: NullableString, + mime: NullableString, + size: NullableNumber, + hash: NullableString, + created_at: DateTime, + updated_at: DateTime, +}) + +const ScenarioReleaseTranslation = v.strictObject({ + entity_type: v.picklist(['scenario_pack', 'scenario_version', 'series', 'ending']), + entity_id: v.pipe(v.string(), v.minLength(1), v.maxLength(160)), + locale: v.picklist(['zh-CN', 'en', 'ja', 'ko', 'es', 'pt']), + field: v.picklist(['title', 'tagline', 'summary', 'description', 'runtime_texts']), + content: v.pipe(v.string(), v.minLength(1)), + created_at: DateTime, + updated_at: DateTime, +}) + +const ScenarioReleaseRuntimeTranslationManifest = v.strictObject({ + version_id: v.pipe(v.string(), v.minLength(1), v.maxLength(160)), + locale: v.pipe(v.string(), v.minLength(1), v.maxLength(16)), + schema_version: v.pipe(v.number(), v.integer(), v.minValue(1)), + entries_json: v.string(), + created_at: DateTime, + updated_at: DateTime, +}) + +export const VisualGameScenarioReleaseBundle = v.strictObject({ + pack: ScenarioReleasePack, + series: ScenarioReleaseSeries, + version: ScenarioReleaseVersion, + publish_checks: v.array(ScenarioReleasePublishCheck), + version_assets: v.array(ScenarioReleaseVersionAsset), + translations: v.array(ScenarioReleaseTranslation), + runtime_translation_manifests: v.array(ScenarioReleaseRuntimeTranslationManifest), +}) + +export const VisualGameScenarioReleaseRequest = v.strictObject({ + release_id: v.pipe(v.string(), v.uuid()), + requested_by: v.pipe(v.string(), v.minLength(1), v.maxLength(64)), + bundle_checksum: v.pipe(v.string(), v.regex(/^[a-f0-9]{64}$/)), + bundle: VisualGameScenarioReleaseBundle, +}) + +export const VisualGameScenarioReleaseTargetState = v.strictObject({ + exists: v.boolean(), + scenario_id: v.pipe(v.string(), v.minLength(1)), + title: NullableString, + current_version_id: NullableString, + version: NullableNumber, + published_at: v.nullable(DateTime), + assets: v.number(), +}) + +export const VisualGameScenarioReleaseApplyResponse = v.strictObject({ + ok: v.literal(true), + request_id: v.pipe(v.string(), v.uuid()), + scenario_id: v.pipe(v.string(), v.minLength(1)), + previous_version_id: NullableString, + current_version_id: v.pipe(v.string(), v.minLength(1)), + version: v.number(), + assets_copied: v.number(), + assets_reused: v.number(), + bytes_copied: v.number(), +}) + +export const VisualGameScenarioReleaseRecord = v.strictObject({ + id: v.pipe(v.string(), v.uuid()), + direction: v.picklist(['outbound', 'inbound']), + scenario_id: v.pipe(v.string(), v.minLength(1), v.maxLength(96)), + source_version_id: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), + bundle_checksum: v.pipe(v.string(), v.regex(/^[a-f0-9]{64}$/)), + requested_by: v.pipe(v.string(), v.minLength(1), v.maxLength(64)), + target_url: NullableString, + status: v.picklist(['prepared', 'transferring', 'applying', 'succeeded', 'failed']), + asset_count: v.number(), + asset_bytes: v.number(), + assets_copied: v.number(), + assets_reused: v.number(), + bytes_copied: v.number(), + previous_version_id: NullableString, + applied_version_id: NullableString, + error_code: NullableString, + error_message: NullableString, + started_at: DateTime, + completed_at: v.nullable(DateTime), + created_at: DateTime, + updated_at: v.nullable(DateTime), +}) diff --git a/packages/contracts/src/schemas/visual-game.js b/packages/contracts/src/schemas/visual-game.js new file mode 100644 index 0000000..83705c4 --- /dev/null +++ b/packages/contracts/src/schemas/visual-game.js @@ -0,0 +1,882 @@ +import * as v from 'valibot' + +export const VISUAL_GAME_NOTE_IMPORTANCE_VALUES = Object.freeze([ + 'minor', + 'major', + 'critical', +]) + +export const VISUAL_GAME_SECTION_DEFINITIONS = Object.freeze([ + { + id: 'meta', + title: '基础信息', + file: 'meta.json', + description: '关卡身份、列表展示、开场文字和关联素材清单。', + defaultValue: { id: '', title: '', tagline: '', assetManifestId: '', portrait_cover_tag: null, opening: [], openingOptions: {} }, + }, + { + id: 'flow', + title: '场景流程', + file: 'flow.json', + description: '关卡初始位置和初始剧情阶段。地点目录由 map.json 维护。', + defaultValue: { initialLocation: '', initialPhase: 'entry' }, + }, + { + id: 'stats', + title: '核心数值', + file: 'stats.json', + description: '初始数值、数值含义和 KP 调整数值时的规则。', + defaultValue: { initialStats: {}, definitions: [], rules: [] }, + }, + { + id: 'state', + title: '初始状态', + file: 'state.json', + description: '开局 flags、NPC 状态、任务状态和场景事件状态初值。', + defaultValue: { initialFlags: {}, initialNpcStates: {}, initialQuestStates: {}, initialSceneEventStates: {} }, + }, + { + id: 'stateRegistry', + title: '状态字典', + file: 'state_registry.json', + description: '登记运行状态 key 的来源、写入者、读取者和运行时影响。', + defaultValue: { entries: [] }, + }, + { + id: 'map', + title: '地图', + file: 'map.json', + description: '多场景地图、连接关系、场景内 NPC / 物件挂载和解锁条件。', + defaultValue: { initialLocation: '', locations: [] }, + }, + { + id: 'quests', + title: '任务', + file: 'quests.json', + description: '主线 / 支线任务、步骤、完成条件、失败条件和影响。', + defaultValue: { mainQuestId: '', quests: [] }, + }, + { + id: 'sceneEvents', + title: '场景事件', + file: 'scene_events.json', + description: '进入场景、首次调查、阶段变化等场景事件。', + defaultValue: { events: [] }, + }, + { + id: 'kp', + title: 'KP 规则', + file: 'kp.json', + description: '写给 KP Agent 的剧本级裁判原则。', + defaultValue: { keeperRole: '', principles: [], forbidden: [] }, + }, + { + id: 'dynamicStory', + title: '动态剧情', + file: 'dynamic_story.json', + description: '单场景自由剧情的语义追踪、旁白事实、生成结局和一致性边界。', + defaultValue: { + enabled: false, + single_scene: false, + min_distinct_turns: 2, + max_active_branches: 1, + max_generated_endings_per_run: 1, + tracker_expires_after_turns: 8, + principles: [], + forbidden: [], + reference_endings: [], + }, + }, + { id: 'npcs', title: 'NPC', file: 'npcs.json', description: 'NPC 配置。', defaultValue: [] }, + { id: 'objects', title: '道具', file: 'objects.json', description: '物件配置。', defaultValue: [] }, + { + id: 'clues', + title: '线索', + file: 'clues.json', + description: '线索定义;剧本不需要线索时可为空数组。importance 可留空,非空时只能为 minor、major 或 critical。', + defaultValue: [], + }, + { + id: 'conclusions', + title: '结论', + file: 'conclusions.json', + description: '由一个或多个线索指向的调查结论,用于笔记页归纳已发现信息;剧本不需要结论时可为空数组。importance 可留空,非空时只能为 minor、major 或 critical。', + defaultValue: [], + }, + { + id: 'infoBlocks', + title: '可得知信息', + file: 'info_blocks.json', + description: 'NPC 可说信息、隐藏事实和解锁条件。', + defaultValue: [], + }, + { + id: 'actionRules', + title: '行动规则', + file: 'action_rules.json', + files: ['action_rules/core.json', 'action_rules/social.json'], + description: '玩家明确做某些事时产生稳定 delta。', + defaultValue: [], + }, + { id: 'endings', title: '结局', file: 'endings.json', description: '结局定义和触发条件。', defaultValue: [] }, + { + id: 'players', + title: '玩家角色', + file: 'players.json', + description: '本关可选玩家人物卡。', + defaultValue: { defaultCharacterId: '', characters: [] }, + }, + { + id: 'media', + title: '素材清单', + file: 'media.json', + description: '图片、视频、音效和 BGM 的 tag 到文件映射。', + defaultValue: { id: '', tags: [] }, + }, +]) + +export const VisualGameScenarioListItem = v.object({ + id: v.string(), + title: v.string(), + tagline: v.optional(v.string()), + summary: v.string(), + tags: v.array(v.string()), + ending_count: v.number(), + play_count: v.number(), + like_count: v.number(), + author_id: v.nullable(v.string()), + author_name: v.nullable(v.string()), + author_avatar_url: v.nullable(v.string()), + status: v.optional(v.picklist(['published', 'coming_soon'])), + asset_manifest_id: v.optional(v.string()), + play_state: v.picklist(['available', 'coming_soon']), + cover_url: v.optional(v.string()), + portrait_cover_url: v.nullable(v.string()), + series_id: v.nullable(v.string()), + series_title: v.nullable(v.string()), + series_cover_url: v.optional(v.nullable(v.string())), + episode_no: v.nullable(v.number()), + created_at: v.optional(v.nullable(v.unknown())), + updated_at: v.optional(v.nullable(v.unknown())), +}) + +export const VisualGameScenarioPage = v.object({ + items: v.array(VisualGameScenarioListItem), + page: v.pipe(v.number(), v.integer(), v.minValue(1)), + page_size: v.pipe(v.number(), v.integer(), v.minValue(1)), + total: v.pipe(v.number(), v.integer(), v.minValue(0)), + has_more: v.boolean(), +}) + +export const VisualGameAsset = v.object({ + tag: v.string(), + type: v.string(), + file: v.string(), + public_url: v.optional(v.string()), + object_key: v.optional(v.string()), + mime: v.optional(v.string()), + size: v.optional(v.nullable(v.number())), + hash: v.optional(v.string()), + desc: v.optional(v.string()), +}) + +export const VisualGameAssetManifest = v.object({ + id: v.string(), + stage_rules: v.optional(v.unknown()), + audio_rules: v.optional(v.unknown()), + tags: v.array(VisualGameAsset), +}) + +export const VisualGameTtsVoice = v.object({ + voice_id: v.string(), + display_name: v.string(), + voice_type: v.picklist(['native', 'clone']), + model: v.string(), + provider: v.string(), +}) + +export const VisualGameTtsVoiceList = v.object({ + voices: v.array(VisualGameTtsVoice), +}) + +export const VisualGameAssetIndexItem = v.object({ + id: v.optional(v.string()), + manifest_id: v.string(), + tag: v.string(), + type: v.string(), + file: v.optional(v.string()), + public_url: v.optional(v.string()), + object_key: v.optional(v.nullable(v.string())), + mime: v.optional(v.nullable(v.string())), + size: v.optional(v.nullable(v.number())), + width: v.optional(v.nullable(v.number())), + height: v.optional(v.nullable(v.number())), + hash: v.optional(v.nullable(v.string())), +}) + +export const VisualGameCreateRunResponse = v.object({ + run_id: v.string(), + scenario: v.unknown(), + run: v.unknown(), + unlocked_info: v.array(v.unknown()), + available_clues: v.array(v.unknown()), + known_clues: v.array(v.unknown()), + available_conclusions: v.optional(v.array(v.unknown())), + known_conclusions: v.optional(v.array(v.unknown())), +}) + +export const VisualGameRunDetail = v.object({ + run: v.unknown(), + scenario: v.unknown(), + unlocked_info: v.array(v.unknown()), + available_clues: v.array(v.unknown()), + known_clues: v.array(v.unknown()), + available_conclusions: v.optional(v.array(v.unknown())), + known_conclusions: v.optional(v.array(v.unknown())), + ending_records: v.optional(v.array(v.unknown())), + turn_video_stage: v.nullable(v.object({ + turn_id: v.string(), + end_frame: v.object({ + id: v.string(), + url: v.string(), + mime: v.string(), + hash: v.string(), + }), + })), +}) + +export const VisualGameRunVideo = v.object({ + id: v.string(), + run_id: v.string(), + ending_id: v.string(), + variant: v.string(), + status: v.picklist(['queued', 'running', 'ready', 'failed']), + generation_request_id: v.nullable(v.string()), + video_copy_status: v.picklist(['pending', 'running', 'ready', 'failed']), + video_copy_error: v.nullable(v.string()), + video_title: v.nullable(v.string()), + video_summary: v.nullable(v.string()), + video_copy_generated_at: v.optional(v.unknown()), + is_public: v.boolean(), + public_url: v.optional(v.nullable(v.string())), + duration_ms: v.optional(v.nullable(v.number())), + width: v.optional(v.nullable(v.number())), + height: v.optional(v.nullable(v.number())), + error_message: v.optional(v.nullable(v.string())), + requested_at: v.optional(v.unknown()), + started_at: v.optional(v.unknown()), + completed_at: v.optional(v.unknown()), + created_at: v.optional(v.unknown()), + updated_at: v.optional(v.unknown()), +}) + +export const VisualGameRunVideoQuote = v.object({ + action: v.literal('full_run_video'), + required_points: v.number(), + current_balance: v.number(), + shortfall_points: v.number(), + balance_after: v.number(), +}) + +export const VisualGameRunVideoGeneration = v.object({ + video: v.nullable(VisualGameRunVideo), + quote: VisualGameRunVideoQuote, +}) + +export const VisualGameRunVideoGenerationResult = v.object({ + video: VisualGameRunVideo, + quote: VisualGameRunVideoQuote, + replayed: v.boolean(), +}) + +export const VisualGameNotification = v.object({ + id: v.string(), + type: v.picklist(['video_generation_ready', 'video_generation_failed']), + resource_type: v.literal('run_video'), + resource_id: v.string(), + video_title: v.nullable(v.string()), + scenario_title: v.string(), + series_title: v.nullable(v.string()), + season_no: v.nullable(v.number()), + action_href: v.string(), + read_at: v.nullable(v.string()), + created_at: v.string(), +}) + +export const VisualGameNotificationList = v.object({ + notifications: v.array(VisualGameNotification), +}) + +export const VisualGameRunListItem = v.object({ + run_id: v.string(), + scenario_id: v.string(), + scenario_title: v.string(), + scenario_cover_url: v.string(), + series_id: v.nullable(v.string()), + series_title: v.nullable(v.string()), + episode_no: v.nullable(v.number()), + season_id: v.nullable(v.string()), + season_no: v.nullable(v.number()), + parent_season_id: v.nullable(v.string()), + fork_episode_no: v.nullable(v.number()), + scenario_version_id: v.optional(v.nullable(v.string())), + status: v.picklist(['active', 'completed']), + current_location_id: v.optional(v.nullable(v.string())), + phase: v.optional(v.nullable(v.string())), + turn_no: v.number(), + ending_id: v.optional(v.nullable(v.string())), + ending_title: v.optional(v.nullable(v.string())), + ending_description: v.optional(v.nullable(v.string())), + last_action: v.string(), + created_at: v.optional(v.unknown()), + updated_at: v.optional(v.unknown()), + completed_at: v.optional(v.unknown()), + video: v.nullable(VisualGameRunVideo), +}) + +export const VisualGameRunList = v.object({ + runs: v.array(VisualGameRunListItem), +}) + +export const VisualGameScenarioVideo = v.object({ + id: v.string(), + run_id: v.string(), + scenario_id: v.string(), + season_id: v.nullable(v.string()), + season_no: v.nullable(v.number()), + season_title: v.nullable(v.string()), + season_summary: v.nullable(v.string()), + video_title: v.string(), + video_summary: v.string(), + scenario_title: v.string(), + series_id: v.nullable(v.string()), + series_title: v.nullable(v.string()), + episode_no: v.nullable(v.number()), + scenario_version_id: v.optional(v.nullable(v.string())), + ending_id: v.string(), + ending_title: v.optional(v.nullable(v.string())), + variant: v.string(), + is_public: v.boolean(), + player_id: v.nullable(v.string()), + player_name: v.string(), + series_summary: v.string(), + run_status: v.picklist(['active', 'completed']), + source_url: v.string(), + poster_url: v.optional(v.nullable(v.string())), + duration_ms: v.optional(v.nullable(v.number())), + width: v.optional(v.nullable(v.number())), + height: v.optional(v.nullable(v.number())), + play_count: v.number(), + like_count: v.number(), + favorite_count: v.number(), + created_at: v.optional(v.unknown()), + completed_at: v.optional(v.unknown()), +}) + +export const VisualGameScenarioVideoList = v.object({ + videos: v.array(VisualGameScenarioVideo), +}) + +export const VisualGameShowcaseVideoPage = v.object({ + videos: v.array(VisualGameScenarioVideo), + next_cursor: v.nullable(v.string()), +}) + +export const VisualGameVideoEngagement = v.object({ + video_id: v.string(), + play_count: v.number(), + like_count: v.number(), + favorite_count: v.number(), + is_liked: v.boolean(), + is_favorited: v.boolean(), +}) + +export const VisualGameSeason = v.object({ + id: v.string(), + series_id: v.string(), + season_no: v.number(), + status: v.picklist(['active', 'completed', 'abandoned']), + previous_completed_at: v.unknown(), + created_at: v.unknown(), + updated_at: v.unknown(), +}) + +export const VisualGameSeasonList = v.object({ + seasons: v.array(VisualGameSeason), +}) + +export const VisualGameRunEvent = v.object({ + id: v.optional(v.string()), + run_id: v.optional(v.string()), + turn_id: v.optional(v.nullable(v.string())), + seq: v.number(), + type: v.string(), + payload: v.unknown(), + created_at: v.optional(v.unknown()), +}) + +export const VisualGameAgentCall = v.object({ + id: v.string(), + run_id: v.string(), + turn_id: v.optional(v.nullable(v.string())), + agent_role: v.string(), + model: v.optional(v.nullable(v.string())), + prompt_hash: v.optional(v.nullable(v.string())), + request: v.optional(v.unknown()), + response: v.optional(v.unknown()), + prompt_tokens: v.nullable(v.number()), + completion_tokens: v.nullable(v.number()), + cached_tokens: v.nullable(v.number()), + cache_write_tokens: v.nullable(v.number()), + cost_cents: v.optional(v.nullable(v.number())), + status: v.string(), + error_message: v.optional(v.nullable(v.string())), + created_at: v.optional(v.unknown()), + completed_at: v.optional(v.unknown()), +}) + +export const VisualGameAgentCallRoleSummary = v.object({ + role: v.string(), + total_calls: v.number(), + failed_calls: v.number(), + total_prompt_tokens: v.number(), + total_completion_tokens: v.number(), + total_cost_cents: v.number(), +}) + +export const VisualGameAgentCallModelSummary = v.object({ + model: v.string(), + total_calls: v.number(), + failed_calls: v.number(), + total_prompt_tokens: v.number(), + total_completion_tokens: v.number(), + total_cost_cents: v.number(), +}) + +export const VisualGameAgentCallSummary = v.object({ + total_calls: v.number(), + failed_calls: v.number(), + total_prompt_tokens: v.number(), + total_completion_tokens: v.number(), + total_cost_cents: v.number(), + roles: v.array(VisualGameAgentCallRoleSummary), + models: v.array(VisualGameAgentCallModelSummary), +}) + +export const VisualGameAgentCallList = v.object({ + calls: v.array(VisualGameAgentCall), + summary: VisualGameAgentCallSummary, +}) + +export const VisualGameProgress = v.nullable(v.object({ + user_id: v.string(), + scenario_id: v.string(), + current_run_id: v.optional(v.nullable(v.string())), + selected_character_id: v.optional(v.nullable(v.string())), + onboarding_dismissed: v.optional(v.boolean()), + ending_records: v.optional(v.array(v.unknown())), + prefs: v.optional(v.unknown()), + created_at: v.optional(v.unknown()), + updated_at: v.optional(v.unknown()), +})) + +export const VisualGameProgressPatch = v.object({ + current_run_id: v.optional(v.nullable(v.string())), + selected_character_id: v.optional(v.nullable(v.string())), + onboarding_dismissed: v.optional(v.boolean()), + prefs: v.optional(v.unknown()), +}) + +export const VisualGamePaywallTarget = v.object({ + kind: v.picklist(['episode', 'branch', 'ending', 'continue']), + key: v.union([v.string(), v.number()]), +}) + +export const VisualGamePaywallInfo = v.object({ + scenario_id: v.string(), + target: VisualGamePaywallTarget, + unlocked: v.boolean(), + reason: v.string(), + price_points: v.optional(v.number()), + free_quota_left: v.optional(v.number()), + balance: v.optional(v.number()), +}) + +export const VisualGameSaveSlot = v.object({ + id: v.optional(v.string()), + user_id: v.optional(v.string()), + scenario_id: v.optional(v.string()), + run_id: v.string(), + slot_no: v.number(), + label: v.optional(v.nullable(v.string())), + snapshot: v.optional(v.unknown()), + created_at: v.optional(v.unknown()), + updated_at: v.optional(v.unknown()), +}) + +export const VisualGameDeleteSaveSlotResponse = v.object({ + deleted: v.number(), +}) + +export const VisualGameTurnVideo = v.object({ + turn_id: v.string(), + match_method: v.picklist(['exact_transition', 'semantic_reuse', 'intent_video']), + similarity: v.nullable(v.number()), + speech: v.array(v.object({ + start: v.number(), + speaker: v.picklist(['player', 'npc']), + })), + asset: v.object({ + id: v.string(), + url: v.string(), + mime: v.string(), + hash: v.string(), + duration_ms: v.number(), + }), + start_frame: v.object({ id: v.string(), url: v.string(), mime: v.string(), hash: v.string() }), + end_frame: v.object({ id: v.string(), url: v.string(), mime: v.string(), hash: v.string() }), +}) + +const VisualGamePlaybackAsset = v.object({ + id: v.string(), + url: v.string(), + mime: v.string(), + hash: v.string(), +}) + +const VisualGamePlaybackVideo = v.object({ + asset: VisualGamePlaybackAsset, + start_frame: VisualGamePlaybackAsset, + end_frame: VisualGamePlaybackAsset, +}) + +const VisualGameActionPlaybackSegment = v.object({ + order: v.number(), + kind: v.literal('action'), + match_method: v.picklist(['exact_transition', 'semantic_reuse', 'intent_video']), + similarity: v.nullable(v.number()), + video: VisualGamePlaybackVideo, +}) + +const VisualGameSpeechPlaybackSegment = v.object({ + order: v.number(), + kind: v.literal('speech'), + actor_type: v.picklist(['player', 'npc']), + actor_id: v.string(), + expression_state: v.string(), + text: v.string(), + loopable: v.boolean(), + video: VisualGamePlaybackVideo, +}) + +export const VisualGameTurnPlaybackPlan = v.object({ + turn_id: v.string(), + segments: v.array(v.union([ + VisualGameActionPlaybackSegment, + VisualGameSpeechPlaybackSegment, + ])), +}) + +const VisualGameReplayPlan = v.object({ + turn_id: v.string(), + segments: v.array(v.union([ + VisualGameActionPlaybackSegment, + v.object({ + order: v.number(), + kind: v.literal('speech'), + actor_type: v.picklist(['player', 'npc']), + actor_id: v.string(), + expression_state: v.string(), + text: v.string(), + loopable: v.boolean(), + video: VisualGamePlaybackVideo, + audio: VisualGamePlaybackAsset, + tts_call_id: v.string(), + }), + ])), +}) + +export const VisualGameStoryVideoLog = v.object({ + run_id: v.string(), + run: v.unknown(), + turns: v.array(v.object({ + turn_no: v.number(), + playback_plan: VisualGameTurnPlaybackPlan, + replay_plan: v.nullable(VisualGameReplayPlan), + })), +}) + +const VisualGameEngineTurnEvent = v.object({ + type: v.picklist([ + 'error', + 'action_rejected', + 'player_kp', + 'dynamic_story', + 'npc_response', + 'npc_action_kp', + 'final', + ]), + kp_output: v.optional(v.unknown()), + action: v.optional(v.unknown()), + judgement: v.optional(v.unknown()), + scene_result: v.optional(v.unknown()), + response: v.optional(v.unknown()), + run: v.optional(v.unknown()), + ending: v.optional(v.unknown()), + npc_action_kp_output: v.optional(v.unknown()), + npc_action_judgement: v.optional(v.unknown()), + npc_action_result: v.optional(v.unknown()), + newly_unlocked: v.optional(v.array(v.unknown())), + newly_unlocked_clues: v.optional(v.array(v.unknown())), + unlocked_info: v.optional(v.array(v.unknown())), + available_clues: v.optional(v.array(v.unknown())), + known_clues: v.optional(v.array(v.unknown())), + available_conclusions: v.optional(v.array(v.unknown())), + known_conclusions: v.optional(v.array(v.unknown())), + ending_records: v.optional(v.array(v.unknown())), + scenario: v.optional(v.unknown()), + error: v.optional(v.string()), + error_code: v.optional(v.picklist([ + 'content_filter', + 'BAD_REQUEST', + 'NOT_FOUND', + 'FORBIDDEN', + 'CONFLICT', + 'SAVE_SLOT_CONFLICT', + 'INSUFFICIENT_BALANCE', + 'PRODUCT_NOT_FOUND', + 'RULE_NOT_FOUND', + 'ALREADY_UNLOCKED', + 'AI_OUTPUT_INVALID', + 'internal_error', + ])), + required_points: v.optional(v.number()), + current_balance: v.optional(v.number()), + shortfall_points: v.optional(v.number()), + turn_playback_plan: v.optional(VisualGameTurnPlaybackPlan), +}) + +const VisualGameTurnPlaybackPlanEvent = v.object({ + type: v.literal('turn_playback_plan'), + turn_playback_plan: VisualGameTurnPlaybackPlan, +}) + +const VisualGameTurnPlaybackPhaseEvent = v.object({ + type: v.literal('turn_playback_phase'), + phase: v.picklist(['player', 'npc']), + turn_playback_plan: VisualGameTurnPlaybackPlan, +}) + +export const VisualGameTurnEvent = v.union([ + VisualGameEngineTurnEvent, + VisualGameTurnPlaybackPlanEvent, + VisualGameTurnPlaybackPhaseEvent, +]) + +export const VisualGameEndingText = v.object({ + title: v.string(), + summary: v.string(), + poem: v.string(), + stats: v.unknown(), + generated_at: v.optional(v.string()), +}) + +export const VisualGamePackSection = v.object({ + id: v.string(), + title: v.string(), + file: v.string(), + description: v.optional(v.string()), + value: v.optional(v.unknown()), +}) + +export const VisualGameScenarioVersion = v.object({ + id: v.string(), + pack_id: v.string(), + version: v.number(), + status: v.string(), + title: v.string(), + tagline: v.optional(v.string()), + asset_manifest_id: v.optional(v.string()), + scenario: v.optional(v.unknown()), + compile_result: v.optional(v.unknown()), + published_at: v.optional(v.nullable(v.unknown())), + created_at: v.optional(v.nullable(v.unknown())), + is_current: v.optional(v.boolean()), +}) + +export const VisualGamePack = v.object({ + id: v.string(), + title: v.string(), + tagline: v.optional(v.string()), + tags: v.array(v.string()), + owner_id: v.optional(v.nullable(v.string())), + locale: v.optional(v.string()), + status: v.optional(v.string()), + visibility: v.optional(v.string()), + current_version_id: v.optional(v.nullable(v.string())), + series_id: v.optional(v.nullable(v.string())), + series_title: v.optional(v.nullable(v.string())), + episode_no: v.optional(v.nullable(v.number())), + created_at: v.optional(v.nullable(v.unknown())), + updated_at: v.optional(v.nullable(v.unknown())), + versions: v.optional(v.array(VisualGameScenarioVersion)), + assets: v.optional(v.array(VisualGameAssetIndexItem)), + sections: v.optional(v.record(v.string(), v.unknown())), +}) + +export const VisualGamePackList = v.object({ + sections: v.array(VisualGamePackSection), + packs: v.array(v.unknown()), +}) + +export const VisualGamePackCompileResponse = v.object({ + ok: v.boolean(), + compile_result: v.unknown(), + pack: VisualGamePack, +}) + +export const VisualGameContentTranslationLocaleStatus = v.object({ + locale: v.picklist(['zh-CN', 'en', 'ja', 'ko', 'es', 'pt']), + status: v.picklist(['source', 'missing', 'ready', 'translating', 'saving', 'unsupported']), + updated_at: v.nullable(v.unknown()), + reusable_text_count: v.number(), + pending_text_count: v.number(), + base_version_id: v.nullable(v.string()), + replace_required: v.boolean(), +}) + +export const VisualGameContentTranslationStatus = v.object({ + pack_id: v.string(), + version_id: v.string(), + source_locale: v.picklist(['zh-CN', 'en', 'ja', 'ko', 'es', 'pt']), + supported: v.boolean(), + source_text_count: v.number(), + locales: v.array(VisualGameContentTranslationLocaleStatus), +}) + +export const VisualGameContentTranslationResult = v.object({ + pack_id: v.string(), + version_id: v.string(), + locale: v.picklist(['zh-CN', 'en', 'ja', 'ko', 'es', 'pt']), + status: v.picklist(['skipped', 'translated']), + text_count: v.optional(v.number()), + reused_text_count: v.optional(v.number()), + translated_text_count: v.optional(v.number()), + base_version_id: v.optional(v.nullable(v.string())), +}) + +export const VisualGameContentTranslationInventoryItem = v.object({ + pointer: v.string(), + section: v.string(), + field: v.string(), + source_text: v.string(), + translated_text: v.nullable(v.string()), + status: v.picklist(['source', 'pending', 'reused', 'translated']), +}) + +export const VisualGameContentTranslationInventory = v.object({ + pack_id: v.string(), + version_id: v.string(), + locale: v.picklist(['zh-CN', 'en', 'ja', 'ko', 'es', 'pt']), + status: v.picklist(['source', 'missing', 'ready', 'unsupported']), + base_version_id: v.nullable(v.string()), + items: v.array(VisualGameContentTranslationInventoryItem), +}) + +export const VisualGameContentTranslationProgress = v.object({ + pack_id: v.string(), + version_id: v.string(), + locale: v.picklist(['zh-CN', 'en', 'ja', 'ko', 'es', 'pt']), + status: v.picklist(['idle', 'translating', 'saving', 'completed', 'failed']), + completed_text_count: v.number(), + total_text_count: v.number(), + error_message: v.optional(v.nullable(v.string())), +}) + +export const VisualGamePackAssetUploadResponse = v.object({ + ok: v.boolean(), + asset: VisualGameAssetIndexItem, + pack: VisualGamePack, +}) + +export const VisualGamePackDeleteResponse = v.object({ + mode: v.picklist(['archived', 'deleted']), + pack: v.optional(v.nullable(VisualGamePack)), +}) + +export const VisualGamePreviewTokenResponse = v.object({ + token: v.string(), + expires_at: v.string(), +}) + +export const VisualGameEditAuditLog = v.object({ + id: v.string(), + draft_id: v.string(), + actor_id: v.optional(v.nullable(v.string())), + action: v.string(), + target_type: v.string(), + target_id: v.optional(v.nullable(v.string())), + before: v.optional(v.unknown()), + after: v.optional(v.unknown()), + created_at: v.optional(v.unknown()), +}) + +export const VisualGameEditAuditLogList = v.object({ + logs: v.array(VisualGameEditAuditLog), +}) + +export const VisualGamePublishCheck = v.object({ + id: v.string(), + version_id: v.string(), + check_type: v.string(), + status: v.string(), + message: v.optional(v.string()), + target_ref: v.optional(v.string()), + updated_at: v.optional(v.unknown()), +}) + +export const VisualGamePublishCheckList = v.object({ + checks: v.array(VisualGamePublishCheck), +}) + +export const VisualGameCreatorProject = v.object({ + id: v.string(), + title: v.string(), + idea_text: v.string(), + language: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']), + status: v.picklist(['queued', 'generating', 'generation_failed', 'pending_review', 'review_rejected', 'published', 'archived']), + current_task_id: v.nullable(v.string()), + current_candidate_pack_id: v.nullable(v.string()), + current_candidate_version_id: v.nullable(v.string()), + published_version_id: v.nullable(v.string()), + review_rejection_reason: v.nullable(v.string()), + cover_url: v.nullable(v.string()), + created_at: v.string(), + updated_at: v.nullable(v.string()), +}) + +export const VisualGameCreatorProjectList = v.object({ projects: v.array(VisualGameCreatorProject) }) + +/** + * @typedef {import('valibot').InferOutput} VisualGameCreateRunResponseT + * @typedef {import('valibot').InferOutput} VisualGameRunDetailT + * @typedef {import('valibot').InferOutput} VisualGameRunEventT + * @typedef {import('valibot').InferOutput} VisualGameAgentCallT + * @typedef {import('valibot').InferOutput} VisualGameAgentCallSummaryT + * @typedef {import('valibot').InferOutput} VisualGameAgentCallListT + * @typedef {import('valibot').InferOutput} VisualGameProgressT + * @typedef {import('valibot').InferOutput} VisualGameProgressPatchT + * @typedef {import('valibot').InferOutput} VisualGamePaywallInfoT + * @typedef {import('valibot').InferOutput} VisualGameSaveSlotT + * @typedef {import('valibot').InferOutput} VisualGameTurnEventT + * @typedef {import('valibot').InferOutput} VisualGameAssetManifestT + * @typedef {import('valibot').InferOutput} VisualGameAssetIndexItemT + * @typedef {import('valibot').InferOutput} VisualGameTtsVoiceT + * @typedef {import('valibot').InferOutput} VisualGameTtsVoiceListT + * @typedef {import('valibot').InferOutput} VisualGameEndingTextT + * @typedef {import('valibot').InferOutput} VisualGamePackAssetUploadResponseT + * @typedef {import('valibot').InferOutput} VisualGamePackDeleteResponseT + * @typedef {import('valibot').InferOutput} VisualGamePreviewTokenResponseT + * @typedef {import('valibot').InferOutput} VisualGamePublishCheckT + * @typedef {import('valibot').InferOutput} VisualGamePublishCheckListT + */ diff --git a/packages/contracts/src/schemas/waitlist.js b/packages/contracts/src/schemas/waitlist.js new file mode 100644 index 0000000..2c85736 --- /dev/null +++ b/packages/contracts/src/schemas/waitlist.js @@ -0,0 +1,162 @@ +import * as v from 'valibot' + +export const WAITLIST_INVITE_CODE_LENGTH = 8 +export const WAITLIST_INVITE_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' +export const WAITLIST_INVITE_CODE_PATTERN = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/ + +export const WaitlistInviteCode = v.pipe( + v.string(), + v.regex(WAITLIST_INVITE_CODE_PATTERN, 'invite_token must be an 8-character invite code'), +) + +export const WaitlistStatus = v.picklist(['joined', 'invite_pending', 'invited', 'registered', 'rejected']) + +export const WaitlistInviteDeliveryStatus = v.picklist(['none', 'pending', 'sent', 'failed', 'expired', 'claimed', 'closed']) + +export const WaitlistDeliveryStatus = v.picklist(['pending', 'sent', 'failed']) + +export const WaitlistSource = v.picklist(['landing_hero', 'landing_footer', 'pricing', 'manual_admin']) + +export const WaitlistPublicSource = v.picklist(['landing_hero', 'landing_footer', 'pricing']) + +export const WaitlistEventType = v.picklist([ + 'joined', + 'duplicate_joined', + 'note_added', + 'invite_requested', + 'invite_email_sent', + 'invite_email_failed', + 'registered', + 'rejected', + 'exported', +]) + +export const WaitlistActorType = v.picklist(['public', 'operator', 'system']) + +export const WaitlistJoinRequest = v.object({ + email: v.string(), + display_name: v.optional(v.string()), + locale: v.string(), + source: WaitlistPublicSource, + use_case: v.optional(v.string(), ''), + note: v.optional(v.string()), + consent_marketing: v.literal(true), +}) + +export const WaitlistJoinResponse = v.object({ + ok: v.literal(true), + message: v.literal('joined'), +}) + +export const WaitlistInviteValidateResponse = v.object({ + ok: v.literal(true), + email: v.nullable(v.string()), + expires_at: v.string(), +}) + +export const WaitlistSendRegisterCodeResponse = v.object({ + ok: v.literal(true), + email: v.string(), +}) + +export const WaitlistEntry = v.object({ + id: v.number(), + email: v.nullable(v.string()), + display_name: v.nullable(v.string()), + locale: v.string(), + source: WaitlistSource, + use_case: v.string(), + note: v.nullable(v.string()), + marketing_consent_accepted_at: v.nullable(v.string()), + marketing_consent_revoked_at: v.nullable(v.string()), + status: WaitlistStatus, + invite_state: WaitlistInviteDeliveryStatus, + invite_token: v.nullable(WaitlistInviteCode), + invite_url: v.nullable(v.string()), + priority_score: v.number(), + invite_expires_at: v.nullable(v.string()), + invited_at: v.nullable(v.string()), + invited_by: v.nullable(v.string()), + last_invite_delivery_id: v.nullable(v.number()), + last_invite_delivery_status: v.nullable(WaitlistDeliveryStatus), + last_invite_email_log_id: v.nullable(v.string()), + registered_user_id: v.nullable(v.string()), + registered_at: v.nullable(v.string()), + rejected_at: v.nullable(v.string()), + rejected_by: v.nullable(v.string()), + rejected_reason: v.nullable(v.string()), + created_at: v.string(), + updated_at: v.string(), +}) + +export const WaitlistEvent = v.object({ + id: v.number(), + entry_id: v.nullable(v.number()), + event_type: WaitlistEventType, + actor_type: WaitlistActorType, + actor_id: v.nullable(v.string()), + metadata: v.nullable(v.unknown()), + created_at: v.string(), +}) + +export const WaitlistInviteDelivery = v.object({ + id: v.number(), + entry_id: v.number(), + email_log_id: v.nullable(v.string()), + status: WaitlistDeliveryStatus, + invite_token_hash: v.string(), + provider: v.nullable(v.string()), + message_id: v.nullable(v.string()), + error_message: v.nullable(v.string()), + created_at: v.string(), + sent_at: v.nullable(v.string()), + failed_at: v.nullable(v.string()), +}) + +export const WaitlistEntryListResponse = v.object({ + items: v.array(WaitlistEntry), + next_cursor: v.nullable(v.string()), +}) + +export const WaitlistEntryDetailResponse = v.object({ + entry: WaitlistEntry, + events: v.array(WaitlistEvent), + deliveries: v.array(WaitlistInviteDelivery), +}) + +export const WaitlistCreateManualEntryRequest = v.object({ + email: v.string(), + display_name: v.optional(v.string()), + locale: v.string(), + use_case: v.string(), + note: v.optional(v.string()), + marketing_consent_accepted_at: v.string(), +}) + +export const WaitlistNoteRequest = v.object({ + note: v.string(), +}) + +export const WaitlistInviteRequest = v.object({ + note: v.optional(v.string()), +}) + +export const WaitlistRejectRequest = v.object({ + reason: v.string(), +}) + +export const WaitlistActionResponse = WaitlistEntry + +export const WaitlistExportResponse = v.string() + +/** + * @typedef {import('valibot').InferOutput} WaitlistStatusT + * @typedef {import('valibot').InferOutput} WaitlistInviteDeliveryStatusT + * @typedef {import('valibot').InferOutput} WaitlistJoinRequestT + * @typedef {import('valibot').InferOutput} WaitlistJoinResponseT + * @typedef {import('valibot').InferOutput} WaitlistInviteValidateResponseT + * @typedef {import('valibot').InferOutput} WaitlistSendRegisterCodeResponseT + * @typedef {import('valibot').InferOutput} WaitlistEntryT + * @typedef {import('valibot').InferOutput} WaitlistEntryListResponseT + * @typedef {import('valibot').InferOutput} WaitlistEntryDetailResponseT + */ diff --git a/packages/contracts/src/visual-game.js b/packages/contracts/src/visual-game.js new file mode 100644 index 0000000..4ab2a08 --- /dev/null +++ b/packages/contracts/src/visual-game.js @@ -0,0 +1,124 @@ +/** + * `@drama/contracts/visual-game` —— visual-game 业务数据形态。 + * + * 剧本 / 存档 / 回合事件 / pack 编辑 / 计费墙 / agent 调用等。只有 visual-game 线 + * (apps/visual-game-*、vg-* 包)消费。 + */ + +export { + VISUAL_GAME_NPC_STATE_PATCH_FIELDS, + VISUAL_GAME_OBJECT_STATE_PATCH_FIELDS, + VISUAL_GAME_STATE_SCALAR_VALUE_TYPES, + VisualGameKpAgentOutput, + VisualGameNpcActorOutput, + VisualGameNpcActionKpOutput, +} from './schemas/visual-game-agent.js' + +export { + VisualGameAdminAnalyticsDailyItem, + VisualGameAdminAnalyticsDrilldownItem, + VisualGameAdminAnalyticsDrilldownMetric, + VisualGameAdminAnalyticsDrilldownResponse, + VisualGameAdminAnalyticsMetrics, + VisualGameAdminAnalyticsOverviewResponse, + VisualGameAdminPlayerInteractionItem, + VisualGameAdminPlayerInteractionsResponse, + VisualGameAdminContentAnalyticsItem, + VisualGameAdminContentAnalyticsResponse, + VisualGameAdminGa4BehaviorResponse, +} from './schemas/visual-game-admin-analytics.js' + +export { + VisualGameAgentCall, + VisualGameCreatorProject, + VisualGameCreatorProjectList, + VisualGameAgentCallList, + VisualGameAgentCallModelSummary, + VisualGameAgentCallRoleSummary, + VisualGameAgentCallSummary, + VisualGameScenarioListItem, + VisualGameScenarioPage, + VisualGameAssetIndexItem, + VisualGameAssetManifest, + VisualGameTtsVoice, + VisualGameTtsVoiceList, + VisualGameCreateRunResponse, + VisualGameRunDetail, + VisualGameRunList, + VisualGameRunListItem, + VisualGameRunVideo, + VisualGameRunVideoQuote, + VisualGameRunVideoGeneration, + VisualGameRunVideoGenerationResult, + VisualGameNotification, + VisualGameNotificationList, + VisualGameScenarioVideo, + VisualGameScenarioVideoList, + VisualGameShowcaseVideoPage, + VisualGameVideoEngagement, + VisualGameSeason, + VisualGameSeasonList, + VisualGameRunEvent, + VisualGameProgress, + VisualGameProgressPatch, + VisualGamePaywallTarget, + VisualGamePaywallInfo, + VisualGameSaveSlot, + VisualGameDeleteSaveSlotResponse, + VisualGameTurnEvent, + VisualGameTurnVideo, + VisualGameStoryVideoLog, + VisualGameEndingText, + VisualGameScenarioVersion, + VisualGamePack, + VisualGamePackAssetUploadResponse, + VisualGamePackList, + VisualGamePackCompileResponse, + VisualGameContentTranslationLocaleStatus, + VisualGameContentTranslationStatus, + VisualGameContentTranslationResult, + VisualGameContentTranslationInventoryItem, + VisualGameContentTranslationInventory, + VisualGameContentTranslationProgress, + VisualGamePackDeleteResponse, + VisualGamePreviewTokenResponse, + VisualGameEditAuditLog, + VisualGameEditAuditLogList, + VisualGamePublishCheck, + VisualGamePublishCheckList, + VISUAL_GAME_NOTE_IMPORTANCE_VALUES, + VISUAL_GAME_SECTION_DEFINITIONS, +} from './schemas/visual-game.js' + +export { + VisualGameAdminGuestDevice, + VisualGameAdminLedgerEntry, + VisualGameAdminPaymentOrder, + VisualGameAdminPlaySummary, + VisualGameAdminRun, + VisualGameAdminUserAccountType, + VisualGameAdminUserActionResponse, + VisualGameAdminUserAuditAction, + VisualGameAdminUserAuditEntry, + VisualGameAdminUserCreditsAdjustRequest, + VisualGameAdminUserDetail, + VisualGameAdminUserDetailResponse, + VisualGameAdminUserListItem, + VisualGameAdminUserListResponse, + VisualGameAdminUserListStats, + VisualGameAdminUserInteractionStats, + VisualGameAdminUserProvisionRequest, + VisualGameAdminUserSubscription, + VisualGameAdminUserSubscriptionStatus, + VisualGameAdminUserSubscriptionUpdateRequest, + VisualGameAdminUserProfileUpdateRequest, + VisualGameAdminUserSummary, +} from './schemas/visual-game-admin-users.js' + +export { + VisualGameScenarioReleaseApplyResponse, + VisualGameScenarioReleaseBundle, + VisualGameScenarioReleaseRequest, + VisualGameScenarioReleaseRecord, + VisualGameScenarioReleaseTargetState, +} from './schemas/visual-game-scenario-release.js' diff --git a/packages/contracts/test/snapshot.test.js b/packages/contracts/test/snapshot.test.js new file mode 100644 index 0000000..49163c2 --- /dev/null +++ b/packages/contracts/test/snapshot.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dumpSchemas } from '../src/index.js' + +/** + * 包内 schema gate 镜像(CI 同时跑根级 `pnpm schema:check`,双保险)。 + * 当前 `dumpSchemas()` 必须与 git 内 `schema-snapshot.json` 一致,漂移即失败。 + */ + +const snapshotUrl = new URL('../schema-snapshot.json', import.meta.url) + +/** 与 tools/scripts/schema-check.js 的 stableStringify 一致:键排序 + 2 空格缩进。 */ +function sortKeys(value) { + if (Array.isArray(value)) return value.map(sortKeys) + if (value && typeof value === 'object') { + const out = {} + for (const k of Object.keys(value).sort()) out[k] = sortKeys(value[k]) + return out + } + return value +} + +describe('schema 快照锁定', () => { + it('dumpSchemas() 与 git 内 schema-snapshot.json 一致', () => { + const committed = readFileSync(fileURLToPath(snapshotUrl), 'utf8').replace(/\r\n/g, '\n').trim() + const current = JSON.stringify(sortKeys(dumpSchemas()), null, 2).trim() + expect(current).toBe(committed) + }) +}) diff --git a/packages/contracts/test/visual-game-agent.test.js b/packages/contracts/test/visual-game-agent.test.js new file mode 100644 index 0000000..e87db84 --- /dev/null +++ b/packages/contracts/test/visual-game-agent.test.js @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' + +import { + parseVisualGameKpAgentOutput, + parseVisualGameNpcActionKpOutput, + parseVisualGameNpcActorOutput, +} from '../src/index.js' +import { + VISUAL_GAME_NPC_STATE_PATCH_FIELDS, + VISUAL_GAME_OBJECT_STATE_PATCH_FIELDS, +} from '../src/visual-game.js' + +function kpOutput() { + return { + expression_state: null, + action: { + intent: 'investigate', + targets: ['bronze_mirror'], + stance: 'cautious', + risk: 'normal', + claims: [], + }, + judgementSuggestion: { valid: true }, + clueJudgements: [], + sceneResult: { narration: '', visibleFindings: [], discoveredObjectIds: [] }, + responsePolicy: { npcReaction: 'none', reason: '玩家只检查环境。' }, + npcDirective: { + npcId: 'painted_woman', + tactic: 'observe', + mood: 'gentle', + mustReactTo: [], + mustHide: [], + }, + } +} + +describe('visual-game Agent output contracts', () => { + it('exports frozen CandidateDelta patch field definitions', () => { + expect(Object.isFrozen(VISUAL_GAME_OBJECT_STATE_PATCH_FIELDS)).toBe(true) + expect(Object.isFrozen(VISUAL_GAME_NPC_STATE_PATCH_FIELDS)).toBe(true) + expect(VISUAL_GAME_NPC_STATE_PATCH_FIELDS.mood).toEqual({ + schemaKind: 'string', + registryScope: 'npc_state', + registryPathKind: 'field', + registryValueTypes: ['string'], + }) + }) + + it('accepts the complete KP boundary shape', () => { + expect(parseVisualGameKpAgentOutput(kpOutput())).toEqual(kpOutput()) + }) + + it('accepts a branch resolution decision', () => { + const kp = kpOutput() + kp.branch_resolution = { + branch_id: 'branch_capture', + matched: true, + decision: 'resolve', + resolution_summary: '精灵球完成三次摇晃并锁定,女鬼已经被收服。', + } + + expect(parseVisualGameKpAgentOutput(kp)).toEqual(kp) + }) + + it('requires an explicit dynamic-story tracker continuation choice', () => { + const kp = kpOutput() + kp.story_signal = { + should_track: true, + continuation_tracker_id: null, + topic_cluster: 'capture_supernatural_entity', + display_name: '异类收服', + topic_summary: '玩家尝试收服屋内的超自然存在。', + topic_kind: 'countermeasure', + player_intended_action: '收服屋内的超自然存在。', + decisive_action: false, + distinct_progress: true, + progress_marker: 'prepare_capture_device', + reason: '玩家正在推进新的收服方向。', + } + expect(parseVisualGameKpAgentOutput(kp)).toEqual(kp) + + delete kp.story_signal.continuation_tracker_id + expect(() => parseVisualGameKpAgentOutput(kp)).toThrow() + }) + + it('rejects missing valid and misplaced judgement fields', () => { + const missingValid = kpOutput() + missingValid.judgementSuggestion = {} + expect(() => parseVisualGameKpAgentOutput(missingValid)).toThrow() + + const misplaced = kpOutput() + misplaced.judgementSuggestion.sceneResult = misplaced.sceneResult + expect(() => parseVisualGameKpAgentOutput(misplaced)).toThrow() + }) + + it('rejects unknown NPC Actor patch fields', () => { + expect(() => parseVisualGameNpcActorOutput({ + expression_state: 'cold_threatening', + narration: '她伸手去拿镜子。', + npcLine: '给我。', + proposedNpcActions: [{ + type: 'take_object', + targetId: 'bronze_mirror', + intent: '夺走镜子', + description: '她试图夺走镜子。', + candidate_delta: { + objectStateChanges: { + bronze_mirror: { obtained: true }, + }, + }, + }], + tacticUsed: 'seize_mirror', + tone: 'threatening', + playerPromptType: 'warning', + playerOptions: { say: '你为什么要拿?', act: '把镜子收回身后。' }, + })).toThrow() + }) + + it('accepts the built-in NPC mood patch field', () => { + const output = { + expression_state: 'cold_threatening', + narration: '守门人收起笑意。', + npcLine: '到此为止。', + proposedNpcActions: [{ + type: 'change_mood', + targetId: 'keeper', + intent: '转为警戒', + description: '守门人进入警戒状态。', + candidate_delta: { + npcStateChanges: { + keeper: { mood: 'alert' }, + }, + }, + }], + tacticUsed: 'warn_player', + tone: 'threatening', + playerPromptType: 'warning', + playerOptions: { say: '发生什么了?', act: '后退一步。' }, + } + + expect(parseVisualGameNpcActorOutput(output)).toEqual(output) + }) + + it('rejects an NPC-action relationshipFlags array', () => { + expect(() => parseVisualGameNpcActionKpOutput({ + npcId: 'painted_woman', + approvedNpcActions: [{ type: 'move', targetId: 'door', result: '她走到门边。' }], + judgementSuggestion: { + valid: true, + npcStateChanges: { + painted_woman: { relationshipFlags: ['blocking_exit'] }, + }, + }, + sceneResult: { narration: '她挡在门前。', visibleFindings: [], discoveredObjectIds: [] }, + })).toThrow() + }) + + it('rejects possibleEnding from every model delta', () => { + const kp = kpOutput() + kp.judgementSuggestion.possibleEnding = 'ending_escape' + expect(() => parseVisualGameKpAgentOutput(kp)).toThrow() + + expect(() => parseVisualGameNpcActorOutput({ + expression_state: 'cold_threatening', + narration: '她伸手挡住门扇。', + npcLine: '你不能走。', + proposedNpcActions: [{ + type: 'block', + targetId: 'door', + intent: '阻止玩家离开', + description: '她试图挡住门。', + candidate_delta: { possibleEnding: 'ending_trapped' }, + }], + tacticUsed: 'block_exit', + tone: 'threatening', + playerPromptType: 'warning', + playerOptions: { say: '让开。', act: '试着绕过她。' }, + })).toThrow() + + expect(() => parseVisualGameNpcActionKpOutput({ + npcId: 'painted_woman', + approvedNpcActions: [{ type: 'block', targetId: 'door', result: '她挡住门。' }], + judgementSuggestion: { + valid: true, + reason: '她就在门边。', + possibleEnding: 'ending_trapped', + }, + sceneResult: { narration: '她挡在门前。', visibleFindings: [], discoveredObjectIds: [] }, + })).toThrow() + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..3c01aad --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@drama/core", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@drama/rig": "workspace:*" + } +} diff --git a/packages/core/src/anim-state.js b/packages/core/src/anim-state.js new file mode 100644 index 0000000..f5e26d8 --- /dev/null +++ b/packages/core/src/anim-state.js @@ -0,0 +1,132 @@ +// anim-state.js —— 每角色的多轨道动画状态机。移植自原型 evaluator.js 的 AnimationState。 +// +// 核心算法(「走路 + 说话 + 指人」并行的根): +// · 每角色维护 base / lower_body / arm_l / arm_r / head 五条轨道,各自有当前 clip +// 与播放进度; +// · 轨道切换做交叉淡入(mix)——在过渡窗口内把旧 clip 与新 clip 按权重混合; +// · oneshot 播完自动回落 fallback; +// · sampleBones() 逐轨道采样,再用「后写覆盖」叠成每根 bone 的局部增量—— +// 每条轨道**只写它拥有的 bone 子集**(TRACK_BONES),所以三条轨道互不串扰。 +// +// 依赖注入:clip 库(getClip / TRACKS / TRACK_BONES / TRACK_DEFAULTS)从构造参数传入, +// 默认取 @drama/rig。这样 core 既不在包内顶层 import 死数据、又能在 Node 喂假 clip 库 +// 做确定性单测。 + +import { sampleClip, blendOffset } from './sampling.js' +import { PROP_DEFAULT } from './matrix.js' + +/** @typedef {import('./types.js').ClipLib} ClipLib */ + +export class AnimationState { + /** + * @param {import('./types.js').ClipLib} clipLib 注入的 clip 库(@drama/rig 或测试假库)。 + */ + constructor(clipLib) { + /** @type {import('./types.js').ClipLib} */ + this.lib = clipLib + /** @type {Record} */ + this.tracks = {} + for (const name of clipLib.TRACKS) { + this.tracks[name] = { + clipId: clipLib.TRACK_DEFAULTS[name], + elapsed: 0, + // 交叉淡入:从 prevClipId 过渡到 clipId + prevClipId: null, + prevElapsed: 0, + mixDuration: 0, + mixElapsed: 0, + // oneshot 播完后回落的目标 + fallbackId: null, + } + } + } + + /** + * 切换某轨道动作。 + * @param {string} trackName + * @param {string} clipId + * @param {{mix?:number,fallback?:(string|null)}=} opts mix=交叉淡入时长(ms),fallback=oneshot 回落目标。 + */ + setAnimation(trackName, clipId, { mix = 160, fallback = null } = {}) { + const tr = this.tracks[trackName] + if (!tr || !this.lib.getClip(clipId)) return + if (tr.clipId === clipId && !tr.prevClipId) return // 已在播且无过渡,不打断 + tr.prevClipId = tr.clipId + tr.prevElapsed = tr.elapsed + tr.mixDuration = mix + tr.mixElapsed = 0 + tr.clipId = clipId + tr.elapsed = 0 + tr.fallbackId = fallback + } + + /** 复位到各轨道默认 clip(打断 / 退场时用)。 */ + reset() { + for (const name of this.lib.TRACKS) { + this.setAnimation(name, this.lib.TRACK_DEFAULTS[name], { mix: 120 }) + } + } + + /** + * 推进时间,处理 mix 结束与 oneshot 回落。 + * @param {number} dtMs + */ + update(dtMs) { + for (const name of this.lib.TRACKS) { + const tr = this.tracks[name] + tr.elapsed += dtMs + if (tr.prevClipId) { + tr.prevElapsed += dtMs + tr.mixElapsed += dtMs + if (tr.mixElapsed >= tr.mixDuration) { + tr.prevClipId = null // 过渡结束 + } + } + // oneshot 播完 → 回落 + const clip = this.lib.getClip(tr.clipId) + if (clip && clip.kind === 'oneshot' && tr.elapsed >= clip.duration && tr.fallbackId) { + const fb = tr.fallbackId + tr.fallbackId = null + this.setAnimation(name, fb, { mix: 160 }) + } + } + } + + /** + * 采样所有轨道 → 每根 bone 的最终局部增量(后写覆盖叠加结果)。 + * @returns {Record} + */ + sampleBones() { + /** @type {Record} */ + const offsets = {} + for (const name of this.lib.TRACKS) { + const tr = this.tracks[name] + const owned = this.lib.TRACK_BONES[name] + const clip = this.lib.getClip(tr.clipId) + if (!clip) continue + let sampled = sampleClip(clip, tr.elapsed) + // 交叉淡入:与上一个 clip 混合 + if (tr.prevClipId) { + const prevClip = this.lib.getClip(tr.prevClipId) + if (prevClip) { + const prevSampled = sampleClip(prevClip, tr.prevElapsed) + const w = tr.mixDuration > 0 ? Math.min(tr.mixElapsed / tr.mixDuration, 1) : 1 + /** @type {Record} */ + const merged = {} + const bones = new Set([...Object.keys(sampled), ...Object.keys(prevSampled)]) + for (const b of bones) { + const cur = sampled[b] || { ...PROP_DEFAULT } + const prv = prevSampled[b] || { ...PROP_DEFAULT } + merged[b] = blendOffset(prv, cur, w) + } + sampled = merged + } + } + // 后写覆盖:只写该轨道拥有的 bone + for (const boneName of owned) { + if (sampled[boneName]) offsets[boneName] = sampled[boneName] + } + } + return offsets + } +} diff --git a/packages/core/src/camera.js b/packages/core/src/camera.js new file mode 100644 index 0000000..77cc2ac --- /dev/null +++ b/packages/core/src/camera.js @@ -0,0 +1,208 @@ +// camera.js —— createCamera:五指令补间 + 阻尼跟随 + 抖动 → getRenderState。 +// 移植自原型 camera.js 的纯数学,**剥离 Pixi 容器写入**。 +// +// 相机状态 {x,y,zoom} 看向世界点 (x,y) 并缩放 zoom。本包只**算出**相机状态与抖动偏移, +// 通过 getRenderState() 交给 Renderer.applyCamera 落地——core 不碰 worldContainer, +// 这样换 Skia / 换分辨率时镜头数学一行不动(架构 §6.8)。 +// +// 指令 CameraCommand:cut / follow / pan / zoom / shake(见 types.js)。 + +/** @type {Record number>} */ +const EASINGS = { + linear: (t) => t, + easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2), + easeOutCubic: (t) => 1 - Math.pow(1 - t, 3), + easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2), +} + +/** @param {string=} name @returns {(t:number) => number} */ +function easingByName(name) { + return (name && EASINGS[name]) || EASINGS.easeInOutCubic +} + +/** + * 镜头工厂。 + * @param {{getViewport: () => {w:number,h:number}, random?: () => number}} deps + * getViewport 给视口宽高;random 可注入(默认 Math.random),测试注入确定性序列以测 shake。 + * @returns {Camera} + */ +export function createCamera({ getViewport, random = Math.random }) { + return new Camera(getViewport, random) +} + +export class Camera { + /** + * @param {() => {w:number,h:number}} getViewport + * @param {() => number} random + */ + constructor(getViewport, random) { + this.getViewport = getViewport + this.random = random + /** @type {{x:number,y:number,zoom:number}} 当前相机状态(只读取用)。 */ + this.cam = { x: 0, y: 0, zoom: 1 } + /** @type {(string|null)} 当前跟随的演员别名(焦点由 applyCommand 时的 lookup 提供)。 */ + this.followAlias = null + this.followDamping = 0.08 // 0..1,越大越紧跟 + /** @type {(string|null)} */ + this._focusLookupAlias = null + /** @type {(((alias:string) => ({x:number,y:number}|null))|null)} */ + this._focusLookup = null + /** @type {({from:{x:number,y:number,zoom:number},to:{x:number,y:number,zoom:number},dur:number,t:number,easing:(t:number)=>number}|null)} */ + this.tween = null + this.shakeT = 0 + this.shakeDur = 0 + this.shakeMag = 0 + this._shakeX = 0 + this._shakeY = 0 + } + + /** @param {{x?:number,y?:number,zoom?:number}=} p */ + _cut({ x, y, zoom } = {}) { + this.tween = null + if (x != null) this.cam.x = x + if (y != null) this.cam.y = y + if (zoom != null) this.cam.zoom = zoom + } + + /** + * @param {{x?:number,y?:number,zoom?:number}} target + * @param {number} duration + * @param {string=} easing + */ + _tweenTo(target, duration, easing) { + const from = { ...this.cam } + const to = { ...this.cam, ...target } + this.tween = { + from, + to, + dur: Math.max(duration, 1), + t: 0, + easing: easingByName(easing), + } + } + + /** @param {number=} magnitude @param {number=} duration */ + _shake(magnitude = 14, duration = 350) { + this.shakeMag = magnitude + this.shakeDur = duration + this.shakeT = 0 + } + + /** + * 执行一条镜头指令。 + * @param {import('./types.js').CameraCommand} cmd + * @param {((alias:string) => ({x:number,y:number}|null))=} actorFocusLookup + * 别名 → 演员焦点世界坐标;follow / cut(follow) 用。 + */ + applyCommand(cmd, actorFocusLookup) { + if (!cmd) return + const lookup = actorFocusLookup || (() => null) + const dur = cmd.duration ?? 0 + switch (cmd.type) { + case 'cut': { + if (cmd.follow) { + this.followAlias = cmd.follow + this._focusLookup = lookup + const f = lookup(cmd.follow) + this._cut({ x: f?.x, y: f?.y, zoom: cmd.zoom }) + } else { + this.followAlias = null + this._focusLookup = null + this._cut({ x: cmd.target?.x, y: cmd.target?.y, zoom: cmd.zoom }) + } + break + } + case 'follow': { + this.followAlias = cmd.follow ?? null + this._focusLookup = lookup + if (cmd.zoom != null) this._tweenTo({ zoom: cmd.zoom }, dur || 600, cmd.easing) + break + } + case 'pan': { + this.followAlias = null + this._focusLookup = null + /** @type {{x?:number,y?:number,zoom?:number}} */ + const t = {} + if (cmd.target?.x != null) t.x = cmd.target.x + if (cmd.target?.y != null) t.y = cmd.target.y + if (cmd.zoom != null) t.zoom = cmd.zoom + this._tweenTo(t, dur || 800, cmd.easing) + break + } + case 'zoom': { + this._tweenTo({ zoom: cmd.zoom ?? this.cam.zoom }, dur || 600, cmd.easing) + break + } + case 'shake': + this._shake(cmd.magnitude ?? 14, dur || 350) + break + } + } + + /** @param {{x:number,y:number}=} centerWorld */ + reset(centerWorld) { + this.followAlias = null + this._focusLookup = null + this.tween = null + this.shakeMag = 0 + this.shakeDur = 0 + this.shakeT = 0 + this._shakeX = 0 + this._shakeY = 0 + if (centerWorld) this._cut({ ...centerWorld, zoom: 1 }) + } + + /** + * 推进补间 / 阻尼跟随 / 抖动,更新 cam 与抖动偏移。 + * @param {number} dtMs + */ + update(dtMs) { + // 跟随:tween 不活动时才接管 x/y,每帧把焦点 lerp 向目标(帧率无关阻尼) + if (this.followAlias && this._focusLookup && !this.tween) { + const f = this._focusLookup(this.followAlias) + if (f) { + const k = 1 - Math.pow(1 - this.followDamping, dtMs / 16.67) + this.cam.x += (f.x - this.cam.x) * k + this.cam.y += (f.y - this.cam.y) * k + } + } + + // 补间 + if (this.tween) { + this.tween.t += dtMs + const f = Math.min(this.tween.t / this.tween.dur, 1) + const e = this.tween.easing(f) + this.cam.x = this.tween.from.x + (this.tween.to.x - this.tween.from.x) * e + this.cam.y = this.tween.from.y + (this.tween.to.y - this.tween.from.y) * e + this.cam.zoom = this.tween.from.zoom + (this.tween.to.zoom - this.tween.from.zoom) * e + if (f >= 1) this.tween = null + } + + // 抖动(随时间衰减;duration 后归零) + this._shakeX = 0 + this._shakeY = 0 + if (this.shakeT < this.shakeDur) { + this.shakeT += dtMs + const decay = Math.max(0, 1 - this.shakeT / this.shakeDur) + this._shakeX = (this.random() * 2 - 1) * this.shakeMag * decay + this._shakeY = (this.random() * 2 - 1) * this.shakeMag * decay + } + } + + /** + * 产出交给 Renderer.applyCamera 的纯状态。core 不写任何容器。 + * @returns {import('./types.js').CameraRenderState} + */ + getRenderState() { + const vp = this.getViewport() + return { + x: this.cam.x, + y: this.cam.y, + zoom: this.cam.zoom, + shakeX: this._shakeX, + shakeY: this._shakeY, + viewW: vp.w, + viewH: vp.h, + } + } +} diff --git a/packages/core/src/evaluator.js b/packages/core/src/evaluator.js new file mode 100644 index 0000000..7abfaa3 --- /dev/null +++ b/packages/core/src/evaluator.js @@ -0,0 +1,92 @@ +// evaluator.js —— createEvaluator:世界矩阵链式求值。移植自原型 evaluator.js + humanoid-v1.js 的 indexRig。 +// +// 职责:吃一个 RigDefinition(来自 @drama/rig),把骨骼按「父先于子」拓扑排序, +// 然后给定一个 AnimationState,逐根 bone 算出世界矩阵(角色局部空间,root 在原点): +// boneWorld = parentWorld × boneLocal, boneLocal = fromTRS(setup+增量)。 +// 不碰像素、不碰 slot 贴图——那是 @drama/render-pixi 的事,core 只产出 Map。 + +import { fromTRS, mul, decompose, degToRad, PROP_DEFAULT, matrix } from './matrix.js' +import { AnimationState } from './anim-state.js' + +/** + * 把 rig 的 bones 按「父先于子」拓扑排序(保证世界变换可一次遍历算完), + * 并建 name→bone 索引。移植自原型 indexRig。环会在 visit 中被 visited 截断(rig 保证无环)。 + * @param {import('./types.js').RigDefinition} rig + * @returns {{boneByName:Map, orderedBones:import('./types.js').RigBone[]}} + */ +export function indexRig(rig) { + /** @type {Map} */ + const boneByName = new Map() + for (const b of rig.bones) boneByName.set(b.name, b) + /** @type {import('./types.js').RigBone[]} */ + const ordered = [] + const visited = new Set() + /** @param {import('./types.js').RigBone|undefined} b */ + const visit = (b) => { + if (!b || visited.has(b.name)) return + if (b.parent) visit(boneByName.get(b.parent)) + visited.add(b.name) + ordered.push(b) + } + for (const b of rig.bones) visit(b) + return { boneByName, orderedBones: ordered } +} + +/** + * 世界变换求值。 + * @param {{orderedBones:import('./types.js').RigBone[]}} rigIndex indexRig 产物 + * @param {AnimationState} animState + * @param {(Record|null)=} overrides + * 可选覆盖,叠加到动画采样之上(姿势编辑 / 微调用)。 + * @returns {Map} 角色局部空间,root 在原点 + */ +export function evaluateWorld(rigIndex, animState, overrides = null) { + const offsets = animState.sampleBones() + /** @type {Map} */ + const world = new Map() + for (const bone of rigIndex.orderedBones) { + const off = offsets[bone.name] || PROP_DEFAULT + const ov = overrides && overrides[bone.name] + const rot = degToRad(bone.rotation + off.drot + (ov?.drot || 0)) + const x = bone.pos.x + off.dx + (ov?.dx || 0) + const y = bone.pos.y + off.dy + (ov?.dy || 0) + const scale = 1 * off.dscale + const local = fromTRS(x, y, rot, scale) + const parentW = bone.parent ? world.get(bone.parent) : null + world.set(bone.name, parentW ? mul(parentW, local) : local) + } + return world +} + +/** + * 求值器工厂。 + * @param {import('./types.js').RigDefinition} rig + * @param {import('./types.js').ClipLib=} clipLib clip 库注入(默认应由装配层注入 @drama/rig)。 + * @returns {{ + * state: () => AnimationState, + * evaluateWorld: (animState:AnimationState, overrides?:(Record|null)) => Map, + * matrix: typeof matrix, + * indexRig: () => {orderedBones:import('./types.js').RigBone[]} + * }} + */ +export function createEvaluator(rig, clipLib) { + const rigIndex = indexRig(rig) + return { + /** 每角色一个 AnimationState。 */ + state() { + if (!clipLib) { + throw new Error('createEvaluator: clipLib 未注入;state() 需要 clip 库(@drama/rig)') + } + return new AnimationState(clipLib) + }, + evaluateWorld(animState, overrides = null) { + return evaluateWorld(rigIndex, animState, overrides) + }, + matrix, + indexRig() { + return rigIndex + }, + } +} + +export { decompose } diff --git a/packages/core/src/index.js b/packages/core/src/index.js new file mode 100644 index 0000000..8daac02 --- /dev/null +++ b/packages/core/src/index.js @@ -0,0 +1,22 @@ +// @drama/core —— 平台无关纯逻辑引擎公共面。 +// +// 唯一公共入口(架构「禁止深导入」红线)。只导出 T-core §3 声明的符号: +// 工厂:createEvaluator / createCamera / createScheduler +// 类: AnimationState +// 原语:matrix({fromTRS, mul, decompose}),以及单独导出的 fromTRS / mul / decompose +// 类型:见 types.js 的 JSDoc @typedef(Mat2D / CameraCommand / ScriptLine / +// Renderer / AudioPort / TtsPort / SlotTransform / CameraRenderState 等) +// +// 三接口(Renderer / AudioPort / TtsPort)只在 types.js 以 typedef 形式存在, +// 词汇停在「角色 / slot / 变换 / 音频 / 文本」抽象,零 Pixi 概念——这是「换 Skia +// core 不动」的命门。 + +export { createEvaluator } from './evaluator.js' +export { AnimationState } from './anim-state.js' +export { createCamera } from './camera.js' +export { createScheduler } from './scheduler.js' + +export { matrix, fromTRS, mul, decompose, lerp } from './matrix.js' + +// 重新导出 types.js 以便消费方 `import('@drama/core').Renderer` 拿到 typedef(无运行时副作用)。 +export * from './types.js' diff --git a/packages/core/src/line-handlers.js b/packages/core/src/line-handlers.js new file mode 100644 index 0000000..b0af0f4 --- /dev/null +++ b/packages/core/src/line-handlers.js @@ -0,0 +1,104 @@ +// line-handlers.js —— 各指令到三接口调用的纯映射。 +// +// 这里只把「一条 ScriptLine 的某个字段」翻译成「对 renderer / camera / animState 的一次调用」, +// **不决定时序**(并行还是阻塞)——时序归 scheduler。本文件按 §3.4 归类: +// 非阻塞、行首即发:cam / fx / sfx / pose / trigger / emotion / facing / headFacing / prop / propPlace。 +// 阻塞项(moveTo / say / transition)由 scheduler 直接编排,不放这里。 +// +// ctx 形状(由装配层 / 测试提供,scheduler 透传): +// { +// renderer, audio, tts, +// animStateOf(alias) => AnimationState|null, +// resolvePoint(nameOrXY) => {x,y}|null, +// getActorWorldPos(alias) => {x,y}|null, +// focusLookup(alias) => {x,y}|null, +// castVoice(alias) => { voice?, speechRate?, lang? }, +// } + +/** + * 行首即发的非阻塞副作用:把不需要 await 的指令一次性发出。 + * @param {import('./types.js').ScriptLine} line + * @param {{renderer:import('./types.js').Renderer, audio:import('./types.js').AudioPort, camera:(import('./types.js').Camera|null)}} ports + * @param {import('./types.js').SchedulerContext} ctx + * @param {import('./types.js').LineSfxState} sfxState 本行级 sfx/fx 跟踪(供行末清理 until:lineEnd) + */ +export function emitNonBlocking(line, ports, ctx, sfxState) { + const { renderer, audio, camera } = ports + const alias = line.who || null + const anim = alias && ctx.animStateOf ? ctx.animStateOf(alias) : null + + // 独立道具放置 / 移除(不依赖人) + if (line.propPlace) { + const pp = /** @type {{id:string,name?:string,x?:number,y?:number,scale?:number,remove?:boolean}} */ (line.propPlace) + if (pp.remove) renderer.stopEffect(`prop:${pp.id}`) + else renderer.playEffect(`prop:${pp.id}`, pp) + } + + // 屏幕特效 + if (line.fxStop) renderer.stopEffect(typeof line.fx === 'object' ? line.fx.name : 'fx') + if (line.fx) { + const name = typeof line.fx === 'string' ? line.fx : line.fx.name + const opts = typeof line.fx === 'object' ? line.fx : undefined + renderer.playEffect(name, opts) + } + + // 音效 / BGM + if (line.sfxStop) audio.stopAll() + if (line.sfx) { + const url = typeof line.sfx === 'string' ? line.sfx : line.sfx.url + const volume = typeof line.sfx === 'object' ? line.sfx.volume : undefined + // 默认不循环(对齐 OLD playSfx:单次播放;要循环需显式 sfx.loop:true)。 + const loop = typeof line.sfx === 'object' && line.sfx.loop === true + const handle = audio.play({ url }, { volume, loop }) + if (typeof line.sfx === 'object' && line.sfx.until === 'lineEnd') { + sfxState.untilLineEnd = handle + } + } + + // 镜头(行首即发,不阻塞、不计入本行时长) + if (line.cam && camera) camera.applyCommand(line.cam, ctx.focusLookup) + + // 朝向 / 头朝向:拆开互不耦合(移植自 OLD play-demo.html:203-204 + character-actor setFacing/setHeadFacing)。 + // 仅当行有 line.facing 才改 body;仅当行有 line.headFacing 才改头。 + // applyFacing(facing, headFacing):facing=undefined → 不动身体;headFacing=undefined → 不动头。 + if (alias && line.facing) renderer.applyFacing(alias, line.facing, undefined) + if (alias && line.headFacing !== undefined) { + renderer.applyFacing(alias, undefined, line.headFacing) + } + if (alias && line.emotion) renderer.applyFace(alias, line.emotion, false) + + // 姿势(多轨道并行设置) + if (alias && anim && line.pose) { + for (const [track, clip] of Object.entries(line.pose)) { + anim.setAnimation(track, clip, { mix: 200 }) + } + } + + // 触发(oneshot) + if (alias && anim && line.trigger) { + anim.setAnimation(line.trigger.track, line.trigger.clip, { mix: 120 }) + } + + // 角色携带 / 放下 / 扔道具:真挂到骨骼(renderer.holdProp/releaseProp,移植自 OLD holdProp)。 + if (alias && line.prop && typeof renderer.holdProp === 'function') { + const prop = /** @type {{name?:string,hold?:string,drop?:boolean,throw?:boolean}} */ (line.prop) + if (prop.drop) renderer.releaseProp(alias) + else if (prop.throw) { + if (anim) anim.setAnimation('arm_r', 'arm_r.punch', { mix: 120 }) + renderer.releaseProp(alias) + } else if (prop.name) { + renderer.holdProp(alias, prop.name, prop.hold || 'carry') + } + } +} + +/** + * 无 voice 时按字数估算台词时长(移植自原型 sayDuration 思路)。 + * @param {string} text + * @returns {number} ms + */ +export function estimateSayDuration(text) { + if (!text) return 0 + // 对齐 OLD play-demo.html:120 `sayDuration = Math.max(900, text.length*55 + 500)`。 + return Math.max(900, text.length * 55 + 500) +} diff --git a/packages/core/src/matrix.js b/packages/core/src/matrix.js new file mode 100644 index 0000000..cd7ca8b --- /dev/null +++ b/packages/core/src/matrix.js @@ -0,0 +1,84 @@ +// matrix.js —— 2D 仿射矩阵原语(纯函数,零依赖,最易测)。 +// 移植自原型 evaluator.js 的 matFromTRS / matMul / decompose。 +// +// 约定:point' = M·point,M = [[a,c,tx],[b,d,ty],[0,0,1]]。 +// 部件均为均匀缩放、无切变(skew),所以 decompose 可忽略 skew 安全还原。 + +const DEG = Math.PI / 180 + +/** @type {import('./types.js').BoneOffset} 默认增量:无旋转 / 无位移 / 缩放 1。 */ +export const PROP_DEFAULT = { drot: 0, dx: 0, dy: 0, dscale: 1 } + +/** + * 度 → 弧度。 + * @param {number} deg + * @returns {number} + */ +export function degToRad(deg) { + return deg * DEG +} + +/** + * 线性插值。 + * @param {number} a + * @param {number} b + * @param {number} t + * @returns {number} + */ +export function lerp(a, b, t) { + return a + (b - a) * t +} + +/** + * 由平移 / 旋转(弧度)/ 均匀缩放构造仿射矩阵。 + * @param {number} x + * @param {number} y + * @param {number} rotRad 旋转(弧度,顺时针为正,与屏幕坐标系一致) + * @param {number} scale 均匀缩放 + * @returns {import('./types.js').Mat2D} + */ +export function fromTRS(x, y, rotRad, scale) { + const cos = Math.cos(rotRad) + const sin = Math.sin(rotRad) + return { + a: cos * scale, + b: sin * scale, + c: -sin * scale, + d: cos * scale, + tx: x, + ty: y, + } +} + +/** + * 矩阵相乘(父 × 子 = 子的世界变换;满足结合律)。 + * @param {import('./types.js').Mat2D} A + * @param {import('./types.js').Mat2D} B + * @returns {import('./types.js').Mat2D} + */ +export function mul(A, B) { + return { + a: A.a * B.a + A.c * B.b, + b: A.b * B.a + A.d * B.b, + c: A.a * B.c + A.c * B.d, + d: A.b * B.c + A.d * B.d, + tx: A.a * B.tx + A.c * B.ty + A.tx, + ty: A.b * B.tx + A.d * B.ty + A.ty, + } +} + +/** + * 把矩阵分解回 {x,y,rotation,scaleX,scaleY}(忽略 skew)。 + * 满足 decompose(fromTRS(x,y,r,s)) 还原(浮点容差内)。 + * @param {import('./types.js').Mat2D} M + * @returns {{x:number,y:number,rotation:number,scaleX:number,scaleY:number}} + */ +export function decompose(M) { + const scaleX = Math.hypot(M.a, M.b) + const scaleY = Math.hypot(M.c, M.d) + const rotation = Math.atan2(M.b, M.a) + return { x: M.tx, y: M.ty, rotation, scaleX, scaleY } +} + +/** §3 要求的 matrix 命名空间(也单独导出各函数)。 */ +export const matrix = { fromTRS, mul, decompose } diff --git a/packages/core/src/sampling.js b/packages/core/src/sampling.js new file mode 100644 index 0000000..fd7e1da --- /dev/null +++ b/packages/core/src/sampling.js @@ -0,0 +1,82 @@ +// sampling.js —— 关键帧采样(纯函数)。移植自原型 evaluator.js。 +// +// clip 的关键帧值是**相对 setup pose 的增量**(drot/dx/dy/dscale),未给的属性 +// 回落默认(drot/dx/dy=0、dscale=1)。本文件只把「一个 clip + 已播时长」算成 +// 「每根 bone 的当前增量」,不碰轨道叠加、不碰世界矩阵——那些在 anim-state / evaluator。 + +import { lerp, PROP_DEFAULT } from './matrix.js' + +/** + * 在一组关键帧里采样某个属性(线性插值,区间外夹到首 / 末帧)。 + * @param {import('./types.js').Keyframe[]} keys + * @param {number} t 归一化时间 0..1 + * @param {'drot'|'dx'|'dy'|'dscale'} prop + * @returns {number} + */ +export function sampleProp(keys, t, prop) { + const def = PROP_DEFAULT[prop] + if (keys.length === 0) return def + if (t <= keys[0].t) return keys[0][prop] ?? def + const last = keys[keys.length - 1] + if (t >= last.t) return last[prop] ?? def + for (let i = 0; i < keys.length - 1; i++) { + const k0 = keys[i] + const k1 = keys[i + 1] + if (t >= k0.t && t <= k1.t) { + const span = k1.t - k0.t || 1 + const f = (t - k0.t) / span + return lerp(k0[prop] ?? def, k1[prop] ?? def, f) + } + } + return def +} + +/** + * 归一化播放进度。pose 恒为 0;loop 取模循环;oneshot 停在末帧(夹到 1)。 + * @param {import('./types.js').AnimationClip} clip + * @param {number} elapsed 已播时长 ms + * @returns {number} 0..1 + */ +export function clipPhase(clip, elapsed) { + if (clip.kind === 'pose' || clip.duration <= 0) return 0 + if (clip.kind === 'loop') return (elapsed % clip.duration) / clip.duration + // oneshot:停在末帧 + return Math.min(elapsed / clip.duration, 1) +} + +/** + * 采样一个 clip 的当前帧,返回 { boneName: BoneOffset }。 + * @param {import('./types.js').AnimationClip} clip + * @param {number} elapsed + * @returns {Record} + */ +export function sampleClip(clip, elapsed) { + const t = clipPhase(clip, elapsed) + /** @type {Record} */ + const out = {} + for (const [boneName, keys] of Object.entries(clip.bones)) { + out[boneName] = { + drot: sampleProp(keys, t, 'drot'), + dx: sampleProp(keys, t, 'dx'), + dy: sampleProp(keys, t, 'dy'), + dscale: sampleProp(keys, t, 'dscale'), + } + } + return out +} + +/** + * 按权重 w 混合两个增量(交叉淡入用)。w=0 取 a,w=1 取 b。 + * @param {import('./types.js').BoneOffset} a + * @param {import('./types.js').BoneOffset} b + * @param {number} w + * @returns {import('./types.js').BoneOffset} + */ +export function blendOffset(a, b, w) { + return { + drot: lerp(a.drot, b.drot, w), + dx: lerp(a.dx, b.dx, w), + dy: lerp(a.dy, b.dy, w), + dscale: lerp(a.dscale, b.dscale, w), + } +} diff --git a/packages/core/src/scheduler.js b/packages/core/src/scheduler.js new file mode 100644 index 0000000..ebc3093 --- /dev/null +++ b/packages/core/src/scheduler.js @@ -0,0 +1,301 @@ +// scheduler.js —— createScheduler:逐行剧本调度(并行 / 阻塞编排 + play + interrupt)。 +// 移植自原型 play-demo.html 的 playLine / play,落实 T-core §3.4 的并行 / 阻塞硬约定。 +// +// 这是 core 内**唯一**碰三接口(renderer / audio / tts)的地方——把指令翻译成接口调用, +// 但自己不实现接口。求值(anim-state / camera)只产状态,scheduler 负责编排时序。 +// +// §3.4 并行 / 阻塞契约: +// · 不阻塞、行首即发:cam / fx / sfx / pose / trigger / emotion / facing / headFacing / +// prop / propPlace(见 line-handlers.emitNonBlocking)。 +// · 阻塞、必须 await:moveTo(走位补间,时长 = moveDur);say(有 voice 走 +// tts.synthesize → audio.play 等 durationMs 并驱动口型 applyFace(talking); +// 无 voice 按字数估时长)。 +// · 本行总时长 = max(moveTo 时长, say 时长) + wait,多个阻塞项并发 await(Promise.all), +// 不串行累加。 +// · transition:阻塞,走 renderer.runTransition + 换班底。 +// · interrupt():audio.stopAll + 每演员 resetActor + animState.reset + camera.reset, +// 并让正在进行的 play() 提前 resolve、不再推进后续行。 + +import { emitNonBlocking, estimateSayDuration } from './line-handlers.js' + +const VOICE_PRESET_IDS = { + '女声·年轻清亮': 'zh_female_xiaohe_uranus_bigtts', + '女声·甜美活泼': 'zh_female_tianmeixiaoyuan_uranus_bigtts', + '女声·温柔自然': 'zh_female_qingxinnvsheng_uranus_bigtts', + '女声·青春俏皮': 'zh_female_tianmeitaozi_uranus_bigtts', + '女声·可爱元气': 'zh_female_shuangkuaisisi_uranus_bigtts', + '女声·轻柔少女': 'zh_female_xiaoxue_uranus_bigtts', + '男声·阳光青年': 'zh_male_shaonianzixin_uranus_bigtts', + '男声·温柔青年': 'zh_male_liufei_uranus_bigtts', + '男声·活泼少年': 'zh_male_taocheng_uranus_bigtts', + '男声·清爽青年': 'zh_male_ruyayichen_uranus_bigtts', + '男声·元气少年': 'zh_male_dayi_uranus_bigtts', + '男声·自然青年': 'zh_male_m191_uranus_bigtts', + '女声·温柔': 'S_9AB0nUX42', + '女声·清丽': 'S_6wA0nUX42', + '女声·甜美': 'S_6wA0nUX42', + '女声·柔和': 'S_9AB0nUX42', + '男声·年长': 'S_aAB0nUX42', + '男声·青年': 'S_5wA0nUX42', + '男声·深沉': 'S_aAB0nUX42', + '男声·磁性': 'S_5wA0nUX42', +} +const FALLBACK_VOICE_ID = VOICE_PRESET_IDS['男声·青年'] + +function normalizeVoiceId(value) { + const voice = String(value || '').trim() + if (!voice) return '' + if (/^S_[A-Za-z0-9]+$/.test(voice)) return voice + return VOICE_PRESET_IDS[voice] || voice +} + +function normalizeSpeechRate(value) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value + if (typeof value === 'string') { + const n = Number(value) + if (Number.isFinite(n) && n > 0) return n + } + return undefined +} + +/** 默认时钟:真实计时器。测试注入假时钟以确定性推进。 */ +const REAL_CLOCK = { + /** @param {number} ms @returns {Promise} */ + sleep(ms) { + return new Promise((res) => setTimeout(res, Math.max(0, ms))) + }, +} + +/** + * 调度器工厂。 + * @param {{ + * renderer: import('./types.js').Renderer, + * audio: import('./types.js').AudioPort, + * tts: import('./types.js').TtsPort, + * camera?: (import('./camera.js').Camera|null), + * clock?: import('./types.js').Clock + * }} deps + * @returns {{ + * playLine: (line:import('./types.js').ScriptLine, ctx:import('./types.js').SchedulerContext) => Promise, + * play: (lines:import('./types.js').ScriptLine[], ctx:import('./types.js').SchedulerContext) => Promise, + * interrupt: () => void + * }} + */ +export function createScheduler({ renderer, audio, tts, camera = null, clock = REAL_CLOCK }) { + if (!renderer || !audio || !tts) { + throw new Error('createScheduler: 必须注入 renderer / audio / tts') + } + + // runToken:每次 play() 自增;interrupt() 也自增。任何 await 醒来后比对 token, + // 不一致即说明被打断 / 被新一轮 play 接管,立刻停止推进。移植自原型 runToken 机制。 + let runToken = 0 + + /** @param {import('./types.js').SchedulerContext} ctx */ + function actorsOf(ctx) { + return ctx.actors && typeof ctx.actors === 'object' ? Object.keys(ctx.actors) : [] + } + + /** + * say 阻塞分支:有 voice 走 TTS → audio.play 等 durationMs,并驱动口型; + * 缺 voice 时自动回退到默认音色,避免后半段新角色转场出现静音。 + * @param {import('./types.js').ScriptLine} line + * @param {import('./types.js').SchedulerContext} ctx + * @param {number} my + * @param {{current:(import('./types.js').PlayingAudio|null)}} audioRef + * @returns {Promise} + */ + async function runSay(line, ctx, my, audioRef) { + const alias = line.who + if (!alias || !line.say) return + const anim = ctx.animStateOf ? ctx.animStateOf(alias) : null + const cv = (ctx.castVoice && ctx.castVoice(alias)) || {} + const voice = normalizeVoiceId(line.voice) || normalizeVoiceId(cv.voice) || FALLBACK_VOICE_ID + + // 口型 + 头部说话动作 + if (anim) anim.setAnimation('head', 'head.talk_shake', { mix: 120 }) + renderer.applyFace(alias, line.emotion || 'normal', true) + + let durMs = estimateSayDuration(line.say) + const speechRate = normalizeSpeechRate( + Object.prototype.hasOwnProperty.call(line, 'speechRate') && line.speechRate !== undefined + ? line.speechRate + : cv && cv.speechRate, + ) + if (voice) { + let result = null + try { + result = await tts.synthesize({ + text: line.say, + speaker: voice, + instruction: line.instruction, + emotion: line.emotion, + speechRate, + lang: cv.lang, + }) + } catch { + result = null // TTS 失败:回落估算时长,不中断本行 + } + if (my !== runToken) { + audio.stopAll() + return + } + if (result) { + if (typeof audio.unlock === 'function') { + try { + await audio.unlock() + } catch { + // 某些浏览器会在转场/静默后把 AudioContext 置为 suspended;解锁失败不应阻塞本行。 + } + } + const handle = audio.play(result.audio, { volume: 1 }) + audioRef.current = handle + const reported = result.durationMs ?? (await handle.durationMs) + durMs = reported && isFinite(reported) ? reported : estimateSayDuration(line.say) + } + } + + // 把本行台词目标时长暴露给宿主,供打字机字幕铺字(移植自 OLD typewriter,per=(dur-150)/len)。 + if (ctx.onSay) ctx.onSay(alias, line.say, durMs, line.emotion || 'normal') + + await clock.sleep(durMs) + if (my !== runToken) { + audio.stopAll() + return + } + if (audioRef.current) { + audioRef.current.stop() + audioRef.current = null + } + renderer.applyFace(alias, line.emotion || 'normal', false) + if (anim) anim.setAnimation('head', 'head.idle', { mix: 150 }) + } + + /** + * moveTo 阻塞分支:朝行进方向、走路动画、等走位补间结束(时长 = moveDur),到点切站立。 + * @param {import('./types.js').ScriptLine} line + * @param {import('./types.js').SchedulerContext} ctx + * @param {number} my + * @returns {Promise} + */ + async function runMove(line, ctx, my) { + const alias = line.who + if (!alias) return + const anim = ctx.animStateOf ? ctx.animStateOf(alias) : null + const target = + typeof line.moveTo === 'string' + ? ctx.resolvePoint && ctx.resolvePoint(line.moveTo) + : line.moveTo + if (!target) return + const from = ctx.getActorWorldPos && ctx.getActorWorldPos(alias) + if (from) renderer.applyFacing(alias, target.x < from.x ? 'left' : 'right', null) + if (anim) anim.setAnimation('lower_body', 'leg.walk', { mix: 150 }) + const dur = line.moveDur || 1500 + // 走位补间(lerp),而非瞬移到终点(移植自 OLD play-demo.html:197 `a.moveTo(p.x,p.y,dur,easing)`)。 + // renderer 缺 moveActor(如假 renderer)时回落到瞬移,保证契约不破。 + if (typeof renderer.moveActor === 'function') { + renderer.moveActor(alias, target.x, target.y, dur, line.moveEasing || 'linear') + } else { + renderer.setActorWorldPos(alias, target.x, target.y) + } + await clock.sleep(dur) + if (my !== runToken) return + if (anim) anim.setAnimation('lower_body', 'leg.stand_together', { mix: 200 }) + } + + /** + * 执行一行(§3.4 并行 / 阻塞语义)。 + * @param {import('./types.js').ScriptLine} line + * @param {import('./types.js').SchedulerContext} ctx + * @returns {Promise} + */ + async function playLine(line, ctx) { + const my = runToken + const cam = camera || ctx.camera || null + + // transition:阻塞,走黑场切换 + 换班底。 + // 把同行新班底连同 transition 一起交给 runTransition,使渲染实现可在黑场期间换班底 + // (移植自 OLD transitionTo:play-demo.html:103 spawnCast 在淡黑后、淡回前,避免淡回后 pop-in)。 + // 渲染实现未消费 __applyCast 时,由 scheduler 在 runTransition 后兜底换班底(契约不破)。 + if (line.transition) { + let castApplied = false + const applyCast = () => { + if (!castApplied && line.cast && ctx.applyCast) { + castApplied = true + ctx.applyCast(line.cast) + } + } + const t = { ...line.transition, __applyCast: applyCast } + await renderer.runTransition(t) + if (my !== runToken) return + applyCast() // 兜底:renderer 未在黑场调 __applyCast 时这里补上 + } + + // 行首即发的全部非阻塞副作用 + /** @type {import('./types.js').LineSfxState} */ + const sfxState = { untilLineEnd: null } + emitNonBlocking(line, { renderer, audio, camera: cam }, ctx, sfxState) + + // 阻塞项:moveTo 与 say 并发 await(Promise.all),本行时长取 max + /** @type {{current:(import('./types.js').PlayingAudio|null)}} */ + const audioRef = { current: null } + const blocking = [] + if (line.who && line.moveTo) blocking.push(runMove(line, ctx, my)) + if (line.who && line.say) blocking.push(runSay(line, ctx, my, audioRef)) + if (blocking.length) await Promise.all(blocking) + if (my !== runToken) return + + // wait 叠加在阻塞时长之上 + if (line.wait) { + await clock.sleep(line.wait) + if (my !== runToken) return + } + + // 本行专属 BGM:行结束即停 + if (sfxState.untilLineEnd) sfxState.untilLineEnd.stop() + } + + /** + * 顺序播全部行。被 interrupt() 或新一轮 play() 接管时提前 resolve。 + * @param {import('./types.js').ScriptLine[]} lines + * @param {import('./types.js').SchedulerContext} ctx + * @returns {Promise} + */ + async function play(lines, ctx) { + const my = ++runToken + for (const line of lines) { + if (my !== runToken) return + await playLine(line, ctx) + if (my !== runToken) return + } + } + + /** + * 打断 / 复位:停当前 say 音频、复位每演员动画与 renderer、复位相机,并中止后续行。 + * 需通过 ctx 拿到演员集 / animState;为支持无 ctx 调用,最近一次 play 的 ctx 被记住。 + */ + /** @type {import('./types.js').SchedulerContext|null} */ + let lastCtx = null + const origPlay = play + /** @param {import('./types.js').ScriptLine[]} lines @param {import('./types.js').SchedulerContext} ctx */ + async function playTracked(lines, ctx) { + lastCtx = ctx + return origPlay(lines, ctx) + } + + function interrupt() { + runToken++ // 使所有进行中的 await 醒来后比对失败,停止推进 + audio.stopAll() + const ctx = lastCtx + if (ctx) { + for (const alias of actorsOf(ctx)) { + renderer.resetActor(alias) + const anim = ctx.animStateOf && ctx.animStateOf(alias) + if (anim) anim.reset() + } + } + const cam = camera || (ctx && ctx.camera) + if (cam) cam.reset() + } + + return { playLine, play: playTracked, interrupt } +} diff --git a/packages/core/src/types.js b/packages/core/src/types.js new file mode 100644 index 0000000..1457869 --- /dev/null +++ b/packages/core/src/types.js @@ -0,0 +1,188 @@ +// types.js —— @drama/core 契约的单一文本来源(JSDoc @typedef 集中地)。 +// +// 这里只定义**形状**,不含任何实现。三个外设接口(Renderer / AudioPort / TtsPort) +// 是 core 与外部世界唯一的接缝:词汇必须停在「角色 / slot / 变换 / 音频 / 文本」级别的 +// 抽象上,禁止出现任何渲染实现类型(贴图 / 精灵 / 容器 / 图元 / 渲染引擎专有类型)。 +// render-pixi 在它自己包内把这些抽象映射到具体渲染对象——这是「换 Skia 只加 render-skia, +// core 一行不动」的命门(架构 §6.8)。 + +/** @typedef {import('./camera.js').Camera} Camera */ +/** @typedef {import('./anim-state.js').AnimationState} AnimationStateRef */ + +// ───────────────────────── 几何 / 数据形状 ───────────────────────── + +/** + * @typedef {{a:number,b:number,c:number,d:number,tx:number,ty:number}} Mat2D + * 2D 仿射:point' = M·point,M = [[a,c,tx],[b,d,ty],[0,0,1]]。 + */ + +/** + * @typedef {{drot:number,dx:number,dy:number,dscale:number}} BoneOffset + * 相对 setup pose 的局部增量(旋转度 / 位移像素 / 缩放乘数)。 + */ + +/** + * @typedef {Object} CameraCommand 对应 PRD §6 的五种镜头指令。 + * @property {'cut'|'follow'|'pan'|'zoom'|'shake'} type + * @property {{x:number,y:number}=} target + * @property {string=} follow 跟随的演员别名 + * @property {number=} zoom + * @property {number=} duration ms + * @property {string=} easing linear|easeInOut|easeOutCubic|easeInOutCubic + * @property {number=} magnitude shake 用 + */ + +/** + * @typedef {Object} ScriptLine 对应 PRD §4 逐行指令(字段全可选)。 + * @property {string=} who 说话 / 动作演员别名 + * @property {string=} say 台词 + * @property {string=} emotion + * @property {string=} instruction TTS 语气指令 + * @property {number=} speechRate + * @property {(string|{x:number,y:number})=} moveTo + * @property {number=} moveDur + * @property {'linear'|'easeOut'|'easeInOut'=} moveEasing + * @property {'left'|'right'=} facing + * @property {('left'|'right'|null)=} headFacing + * @property {{lower_body?:string,arm_l?:string,arm_r?:string,head?:string}=} pose + * @property {{track:string,clip:string}=} trigger + * @property {Object=} prop {name,hold} | {drop} | {throw} + * @property {Object=} propPlace {id,name,x,y,scale} | {id,remove} + * @property {CameraCommand=} cam + * @property {(string|{name:string,loop?:boolean})=} fx + * @property {(string|{url:string,volume?:number,until?:'lineEnd'})=} sfx + * @property {boolean=} sfxStop + * @property {boolean=} fxStop + * @property {number=} wait 额外停顿 ms + * @property {{to:string,type?:string,duration?:number}=} transition + * @property {Array=} cast 转场同行给新班底 + * @property {string=} voice TTS 音色 id(决定是否走 TTS 出声) + */ + +// ───────────────────── 三个外设接口(禁泄漏 Pixi 概念) ───────────────────── + +/** + * @typedef {{x:number,y:number,rotation:number,scaleX:number,scaleY:number,zOrder?:number,mirror?:boolean}} SlotTransform + * 一个部件 slot 落地后的最终 2D 变换。全是普通数字 / 布尔,零渲染类型。 + */ + +/** + * @typedef {{x:number,y:number,zoom:number,shakeX:number,shakeY:number,viewW:number,viewH:number}} CameraRenderState + * 镜头求值产出的纯状态,交给 Renderer.applyCamera 落地。core 不写任何容器。 + */ + +/** + * @typedef {Object} Renderer 渲染落地接口;@drama/render-pixi 实现之。 + * 注意:以下所有参数都是普通数字 / 字符串 / 普通对象,零 Pixi 类型。 + * @property {(actorId:string, slotTransforms:Record) => void} applyActorPose + * 把一个角色每个 slot 的最终 2D 变换落地(renderer 内部决定怎么画)。 + * @property {(actorId:string, emotion:string, talking:boolean) => void} applyFace + * @property {(actorId:string, facing:'left'|'right', headFacing:('left'|'right'|null)) => void} applyFacing + * @property {(state:CameraRenderState) => void} applyCamera 写镜头(缩放 / 平移 / 抖动)。 + * @property {(actorId:string, worldX:number, worldY:number) => void} setActorWorldPos + * @property {(name:string, opts?:Object) => void} playEffect 屏幕特效。 + * @property {(name:string) => void} stopEffect + * @property {(actorId:string) => void} resetActor + * @property {(transition:Object) => Promise} runTransition 黑场 → 换场 → 淡回。 + */ + +/** + * @typedef {Object} PlayingAudio 一段正在播放的音频句柄。 + * @property {() => void} stop + * @property {Promise} durationMs 音频时长(ms),用于阻塞等待。 + */ + +/** + * @typedef {Object} AudioPort 放音接口;player-vue 用 WebAudio 实现。 + * @property {(audio:(ArrayBuffer|{url:string}), opts?:{volume?:number,loop?:boolean}) => PlayingAudio} play + * @property {() => void} stopAll + */ + +/** + * @typedef {Object} TtsResult + * @property {ArrayBuffer} audio + * @property {number=} durationMs + */ + +/** + * @typedef {Object} TtsPort 文本转语音接口;player-vue 用 api-sdk 实现。 + * @property {(req:{text:string,speaker:string,instruction?:string,emotion?:string,speechRate?:number,lang?:string}) => Promise} synthesize + */ + +/** + * @typedef {Object} Clock 可注入时钟(测试注入假时钟以确定性推进)。 + * @property {(ms:number) => Promise} sleep + */ + +// ───────────── rig 数据形状(与 @drama/rig §3 契约对齐,本地镜像供 checkJs) ───────────── +// core 不 import rig 的运行时代码(数据靠注入);这里只复刻其**形状**,让类型检查自洽, +// 不引入构建顺序依赖。rig 改形状先改 T-rig §3,再同步这里。 + +/** + * @typedef {Object} RigBone + * @property {string} name + * @property {string|null} parent + * @property {{x:number,y:number}} pos + * @property {number} rotation + */ +/** + * @typedef {Object} RigSlot + * @property {string} name + * @property {string} bone + * @property {number} zOrder + */ +/** + * @typedef {Object} RigDefinition + * @property {string} id + * @property {{width:number,height:number}} canvas + * @property {RigBone[]} bones + * @property {RigSlot[]} slots + */ +/** + * @typedef {Object} Keyframe + * @property {number} t + * @property {number=} drot + * @property {number=} dx + * @property {number=} dy + * @property {number=} dscale + */ +/** + * @typedef {Object} AnimationClip + * @property {string} id + * @property {'base'|'lower_body'|'arm_l'|'arm_r'|'head'} track + * @property {'pose'|'loop'|'oneshot'} kind + * @property {number} duration + * @property {Record} bones + * @property {Record=} zOrder + * @property {Record=} mirror + */ + +/** + * clip 库注入形状(与 @drama/rig 公共面对齐)。 + * @typedef {{ + * getClip: (id:string) => (AnimationClip|null), + * TRACKS: string[], + * TRACK_BONES: Record, + * TRACK_DEFAULTS: Record + * }} ClipLib + */ + +/** + * @typedef {Object} SchedulerContext 调度上下文(装配层 / 测试提供,scheduler 透传)。 + * @property {Record=} actors 演员别名集(用于 interrupt 遍历) + * @property {(alias:string) => (AnimationStateRef|null)=} animStateOf 别名 → 该演员的 AnimationState + * @property {(nameOrXY:(string|{x:number,y:number})) => ({x:number,y:number}|null)=} resolvePoint + * @property {(alias:string) => ({x:number,y:number}|null)=} getActorWorldPos + * @property {(alias:string) => ({x:number,y:number}|null)=} focusLookup + * @property {(alias:string) => ({voice?:string,speechRate?:number,lang?:string})=} castVoice + * @property {(cast:Array) => void=} applyCast transition 同行换班底 + * @property {(alias:string, text:string, durationMs:number, emotion?:string) => void=} onSay 台词起播:暴露目标时长给宿主做打字机字幕 + * @property {Camera=} camera 可改由 ctx 提供相机 + */ + +/** + * @typedef {Object} LineSfxState 本行级 sfx 跟踪(行末清理 until:lineEnd)。 + * @property {PlayingAudio|null} untilLineEnd + */ + +export {} diff --git a/packages/core/test/anim-state.test.js b/packages/core/test/anim-state.test.js new file mode 100644 index 0000000..960e77d --- /dev/null +++ b/packages/core/test/anim-state.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { AnimationState } from '../src/anim-state.js' +import { makeClipLib } from './fixtures.js' + +describe('AnimationState 多轨道状态机', () => { + it('多轨道后写覆盖互不串扰(验收 3)', () => { + const s = new AnimationState(makeClipLib()) + s.setAnimation('lower_body', 'leg.walk', { mix: 0 }) + s.setAnimation('head', 'head.talk_shake', { mix: 0 }) + s.setAnimation('arm_r', 'arm_r.point_right', { mix: 0 }) + s.update(150) // 推进 + const off = s.sampleBones() + // 腿骨带到增量 + expect(off.thigh_l).toBeDefined() + expect(off.thigh_r).toBeDefined() + // 头骨带到增量 + expect(off.head).toBeDefined() + // 右臂骨带到 point_right 增量 + expect(off.upper_arm_r.drot).toBeCloseTo(90, 6) + // 互不串扰:head track 不写腿骨;lower_body 不写头骨——各自只在自己集合里 + // upper_arm_r 的增量来自 arm_r,不被 head/leg 覆盖 + expect(off.upper_arm_r).toBeDefined() + }) + + it('交叉淡入中点混合 ≈0.5(验收 4)', () => { + const s = new AnimationState(makeClipLib()) + // 从 point_right(drot=90) 切到 down(drot=0),mix=160 + s.setAnimation('arm_r', 'arm_r.point_right', { mix: 0 }) + s.update(10) + s.setAnimation('arm_r', 'arm_r.down', { mix: 160 }) + s.update(80) // t=80ms,权重≈0.5 + const off = s.sampleBones() + // 新 clip(down=0) 与旧 clip(point_right=90) 混合,权重 0.5 → ≈45 + expect(off.upper_arm_r.drot).toBeGreaterThan(30) + expect(off.upper_arm_r.drot).toBeLessThan(60) + }) + + it('mix 走完只剩新 clip(验收 4)', () => { + const s = new AnimationState(makeClipLib()) + s.setAnimation('arm_r', 'arm_r.point_right', { mix: 0 }) + s.update(10) + s.setAnimation('arm_r', 'arm_r.down', { mix: 160 }) + s.update(200) // 过 mix + const off = s.sampleBones() + expect(off.upper_arm_r.drot).toBeCloseTo(0, 6) + expect(s.tracks.arm_r.prevClipId).toBeNull() + }) + + it('oneshot 超时回落 fallback(验收 5)', () => { + const s = new AnimationState(makeClipLib()) + s.setAnimation('head', 'head.nod', { mix: 0, fallback: 'head.idle' }) + expect(s.tracks.head.clipId).toBe('head.nod') + s.update(500) // head.nod duration=400,过时 + expect(s.tracks.head.clipId).toBe('head.idle') + }) + + it('reset 回各轨道默认 clip', () => { + const s = new AnimationState(makeClipLib()) + s.setAnimation('lower_body', 'leg.walk', { mix: 0 }) + s.reset() + expect(s.tracks.lower_body.clipId).toBe('leg.stand_together') + expect(s.tracks.head.clipId).toBe('head.idle') + }) + + it('未知 clip / 已在播不打断', () => { + const s = new AnimationState(makeClipLib()) + s.setAnimation('head', 'no.such.clip') + expect(s.tracks.head.clipId).toBe('head.idle') // 未变 + const before = s.tracks.head.elapsed + s.update(100) + s.setAnimation('head', 'head.idle') // 已在播且无过渡 → 不打断(不归零 elapsed) + expect(s.tracks.head.elapsed).toBeGreaterThanOrEqual(before) + }) +}) diff --git a/packages/core/test/camera.test.js b/packages/core/test/camera.test.js new file mode 100644 index 0000000..104ab7b --- /dev/null +++ b/packages/core/test/camera.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { createCamera } from '../src/camera.js' + +const vp = () => ({ w: 1920, h: 1080 }) + +describe('camera 镜头数学', () => { + it('cut 瞬变状态(验收 6)', () => { + const cam = createCamera({ getViewport: vp }) + cam.applyCommand({ type: 'cut', target: { x: 100, y: 50 }, zoom: 2 }) + expect(cam.cam).toEqual({ x: 100, y: 50, zoom: 2 }) + }) + + it('pan 在 duration 内单调逼近目标,中点介于起止(验收 6)', () => { + const cam = createCamera({ getViewport: vp }) + cam.applyCommand({ type: 'cut', target: { x: 0, y: 0 }, zoom: 1 }) + cam.applyCommand({ type: 'pan', target: { x: 1000, y: 0 }, duration: 1000, easing: 'linear' }) + cam.update(500) + expect(cam.cam.x).toBeGreaterThan(0) + expect(cam.cam.x).toBeLessThan(1000) + expect(cam.cam.x).toBeCloseTo(500, 6) // linear 中点 + cam.update(500) + expect(cam.cam.x).toBeCloseTo(1000, 6) + }) + + it('zoom 补间逼近目标(验收 6)', () => { + const cam = createCamera({ getViewport: vp }) + cam.applyCommand({ type: 'cut', zoom: 1 }) + cam.applyCommand({ type: 'zoom', zoom: 3, duration: 1000, easing: 'linear' }) + cam.update(1000) + expect(cam.cam.zoom).toBeCloseTo(3, 6) + }) + + it('follow 阻尼跟随帧率无关:不同 dt 切分到同总时长结果一致(验收 6)', () => { + const lookup = () => ({ x: 1000, y: 0 }) + const makeFollow = () => { + const c = createCamera({ getViewport: vp }) + c.applyCommand({ type: 'cut', target: { x: 0, y: 0 }, zoom: 1 }) + c.applyCommand({ type: 'follow', follow: 'hero' }, lookup) + return c + } + const cA = makeFollow() + for (let i = 0; i < 100; i++) cA.update(10) // 100×10ms + const cB = makeFollow() + for (let i = 0; i < 20; i++) cB.update(50) // 20×50ms(同 1000ms 总时长) + expect(cA.cam.x).toBeCloseTo(cB.cam.x, 0) // 帧率无关,容差内一致 + }) + + it('shake 随时间衰减、duration 后归零(验收 6)', () => { + let seq = [0.9, 0.9, 0.9, 0.9, 0.9, 0.9] + let i = 0 + const cam = createCamera({ getViewport: vp, random: () => seq[i++ % seq.length] }) + cam.applyCommand({ type: 'shake', magnitude: 20, duration: 100 }) + cam.update(10) + const s1 = Math.abs(cam.getRenderState().shakeX) + cam.update(50) + const s2 = Math.abs(cam.getRenderState().shakeX) + expect(s2).toBeLessThan(s1) // 衰减 + cam.update(100) // 过 duration + const rs = cam.getRenderState() + expect(rs.shakeX).toBe(0) + expect(rs.shakeY).toBe(0) + }) + + it('reset 归零跟随/补间/抖动', () => { + const cam = createCamera({ getViewport: vp }) + cam.applyCommand({ type: 'pan', target: { x: 500, y: 0 }, duration: 1000 }) + cam.reset({ x: 7, y: 8 }) + expect(cam.tween).toBeNull() + expect(cam.cam).toEqual({ x: 7, y: 8, zoom: 1 }) + }) + + it('getRenderState 只产状态,含视口尺寸(验收 6:只产状态)', () => { + const cam = createCamera({ getViewport: vp }) + cam.applyCommand({ type: 'cut', target: { x: 3, y: 4 }, zoom: 1.5 }) + cam.update(16) + const rs = cam.getRenderState() + expect(rs).toMatchObject({ x: 3, y: 4, zoom: 1.5, viewW: 1920, viewH: 1080 }) + expect(typeof rs.shakeX).toBe('number') + }) +}) diff --git a/packages/core/test/fixtures.js b/packages/core/test/fixtures.js new file mode 100644 index 0000000..c9a295d --- /dev/null +++ b/packages/core/test/fixtures.js @@ -0,0 +1,67 @@ +// fixtures.js —— 测试用假 rig / clip 库(不依赖 @drama/rig 已构建,纯 Node 可跑)。 +// 形状严格对齐 @drama/rig §3 契约(RigDefinition / AnimationClip / TRACKS / TRACK_BONES / TRACK_DEFAULTS)。 + +export const TRACKS = ['base', 'lower_body', 'arm_l', 'arm_r', 'head'] + +export const TRACK_BONES = { + base: ['root', 'hip', 'torso'], + lower_body: ['thigh_l', 'shin_l', 'thigh_r', 'shin_r'], + arm_l: ['upper_arm_l', 'lower_arm_l'], + arm_r: ['upper_arm_r', 'lower_arm_r'], + head: ['neck', 'head'], +} + +export const TRACK_DEFAULTS = { + base: 'base.idle', + lower_body: 'leg.stand_together', + arm_l: 'arm_l.down', + arm_r: 'arm_r.down', + head: 'head.idle', +} + +/** @type {Record} */ +export const CLIPS = { + 'base.idle': { id: 'base.idle', track: 'base', kind: 'loop', duration: 2000, bones: { torso: [{ t: 0, dy: 0 }, { t: 0.5, dy: -4 }, { t: 1, dy: 0 }] } }, + + 'leg.stand_together': { id: 'leg.stand_together', track: 'lower_body', kind: 'pose', duration: 0, bones: { thigh_l: [{ t: 0, drot: 0 }], thigh_r: [{ t: 0, drot: 0 }] } }, + 'leg.walk': { id: 'leg.walk', track: 'lower_body', kind: 'loop', duration: 600, bones: { thigh_l: [{ t: 0, drot: -20 }, { t: 0.5, drot: 20 }, { t: 1, drot: -20 }], thigh_r: [{ t: 0, drot: 20 }, { t: 0.5, drot: -20 }, { t: 1, drot: 20 }] } }, + + 'arm_l.down': { id: 'arm_l.down', track: 'arm_l', kind: 'pose', duration: 0, bones: { upper_arm_l: [{ t: 0, drot: 0 }] } }, + 'arm_r.down': { id: 'arm_r.down', track: 'arm_r', kind: 'pose', duration: 0, bones: { upper_arm_r: [{ t: 0, drot: 0 }] } }, + 'arm_r.point_right': { id: 'arm_r.point_right', track: 'arm_r', kind: 'pose', duration: 0, bones: { upper_arm_r: [{ t: 0, drot: 90 }] } }, + 'arm_r.punch': { id: 'arm_r.punch', track: 'arm_r', kind: 'oneshot', duration: 200, bones: { upper_arm_r: [{ t: 0, drot: 0 }, { t: 1, drot: 120 }] } }, + + 'head.idle': { id: 'head.idle', track: 'head', kind: 'loop', duration: 3000, bones: { head: [{ t: 0, drot: 0 }] } }, + 'head.talk_shake': { id: 'head.talk_shake', track: 'head', kind: 'loop', duration: 300, bones: { head: [{ t: 0, drot: -3 }, { t: 0.5, drot: 3 }, { t: 1, drot: -3 }] } }, + 'head.nod': { id: 'head.nod', track: 'head', kind: 'oneshot', duration: 400, bones: { head: [{ t: 0, drot: 0 }, { t: 0.5, drot: 15 }, { t: 1, drot: 0 }] } }, +} + +/** @param {string} id @returns {import('@drama/rig').AnimationClip|null} */ +export function getClip(id) { + return CLIPS[id] || null +} + +/** @param {Object} extra 覆盖默认字段 @returns {import('./../src/anim-state.js').ClipLib} */ +export function makeClipLib(extra = {}) { + return { getClip, TRACKS, TRACK_BONES, TRACK_DEFAULTS, ...extra } +} + +/** 一个最小 humanoid 骨架:root → hip → torso → neck → head,及一条腿链。 */ +export const RIG = { + id: 'humanoid_v1', + canvas: { width: 1024, height: 1536 }, + bones: [ + // 故意把子骨放在父骨之前,验证 core 能拓扑排序 + { name: 'head', parent: 'neck', pos: { x: 0, y: -40 }, rotation: 0 }, + { name: 'neck', parent: 'torso', pos: { x: 0, y: -60 }, rotation: 0 }, + { name: 'torso', parent: 'hip', pos: { x: 0, y: -80 }, rotation: 0 }, + { name: 'hip', parent: 'root', pos: { x: 0, y: -100 }, rotation: 0 }, + { name: 'root', parent: null, pos: { x: 0, y: 0 }, rotation: 0 }, + { name: 'thigh_r', parent: 'hip', pos: { x: 20, y: 0 }, rotation: 0 }, + { name: 'shin_r', parent: 'thigh_r', pos: { x: 0, y: 50 }, rotation: 0 }, + ], + slots: [ + { name: 'head', bone: 'head', zOrder: 40 }, + { name: 'torso', bone: 'torso', zOrder: 20 }, + ], +} diff --git a/packages/core/test/sampling.test.js b/packages/core/test/sampling.test.js new file mode 100644 index 0000000..25cec4e --- /dev/null +++ b/packages/core/test/sampling.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { sampleProp, clipPhase, sampleClip, blendOffset } from '../src/sampling.js' + +describe('sampling 关键帧采样', () => { + const keys = [{ t: 0, drot: 0 }, { t: 0.5, drot: 10 }, { t: 1, drot: 30 }] + + it('sampleProp 前/中/后/单帧取值', () => { + expect(sampleProp([], 0.5, 'drot')).toBe(0) // 空 → 默认 + expect(sampleProp(keys, -0.1, 'drot')).toBe(0) // 前夹首帧 + expect(sampleProp(keys, 1.5, 'drot')).toBe(30) // 后夹末帧 + expect(sampleProp(keys, 0.25, 'drot')).toBeCloseTo(5, 9) // 区间内线性 + expect(sampleProp(keys, 0.75, 'drot')).toBeCloseTo(20, 9) + expect(sampleProp([{ t: 0, drot: 7 }], 0.9, 'drot')).toBe(7) // 单帧 + }) + + it('未给属性回落默认(dscale=1)', () => { + expect(sampleProp(keys, 0.5, 'dscale')).toBe(1) + expect(sampleProp(keys, 0.5, 'dx')).toBe(0) + }) + + it('clipPhase:pose=0、loop 取模、oneshot 夹 1', () => { + expect(clipPhase({ kind: 'pose', duration: 0 }, 999)).toBe(0) + expect(clipPhase({ kind: 'loop', duration: 1000 }, 1500)).toBeCloseTo(0.5, 9) + expect(clipPhase({ kind: 'oneshot', duration: 400 }, 600)).toBe(1) + expect(clipPhase({ kind: 'oneshot', duration: 400 }, 200)).toBeCloseTo(0.5, 9) + }) + + it('sampleClip 输出每根 bone 的四属性', () => { + const clip = { kind: 'loop', duration: 100, bones: { head: keys } } + const out = sampleClip(clip, 50) // phase=0.5 → 命中 t=0.5 关键帧 drot=10 + expect(out.head).toEqual({ drot: 10, dx: 0, dy: 0, dscale: 1 }) + }) + + it('blendOffset 权重 0/0.5/1', () => { + const a = { drot: 0, dx: 0, dy: 0, dscale: 1 } + const b = { drot: 10, dx: 4, dy: -2, dscale: 2 } + expect(blendOffset(a, b, 0)).toEqual(a) + expect(blendOffset(a, b, 1)).toEqual(b) + expect(blendOffset(a, b, 0.5)).toEqual({ drot: 5, dx: 2, dy: -1, dscale: 1.5 }) + }) +}) diff --git a/packages/core/test/scheduler.test.js b/packages/core/test/scheduler.test.js new file mode 100644 index 0000000..588731e --- /dev/null +++ b/packages/core/test/scheduler.test.js @@ -0,0 +1,234 @@ +import { describe, it, expect } from 'vitest' +import { createScheduler } from '../src/scheduler.js' +import { AnimationState } from '../src/anim-state.js' +import { createCamera } from '../src/camera.js' +import { makeClipLib } from './fixtures.js' + +// ───────────────────────── 假时钟(虚拟时间) ───────────────────────── +// 维护待唤醒队列;advance(ms) 推进虚拟钟,resolve 到期的 sleep。多个并发 sleep +// 同步推进,使 Promise.all 取 max 可被确定性断言。 +function makeClock() { + let now = 0 + /** @type {{at:number,res:()=>void}[]} */ + let waiters = [] + return { + clock: { + sleep(ms) { + return new Promise((res) => { + waiters.push({ at: now + Math.max(0, ms), res }) + }) + }, + }, + now: () => now, + async advance(ms) { + // 先把当前挂起的非时钟 await(如 tts.synthesize)跑完,让 sleep 注册进队列 + for (let i = 0; i < 5; i++) await Promise.resolve() + const target = now + ms + // 逐个唤醒到期者,给微任务机会链上下一个 sleep + // 反复扫描直到没有 <= target 的待唤醒项 + for (;;) { + waiters.sort((a, b) => a.at - b.at) + const next = waiters.find((w) => w.at <= target) + if (!next) break + now = next.at + waiters = waiters.filter((w) => w !== next) + next.res() + await Promise.resolve() + await Promise.resolve() + } + now = target + }, + } +} + +// ───────────────────────── 假三接口(记录调用 + 时间戳) ───────────────────────── +function makePorts(nowFn) { + /** @type {{t:number,call:string,args:any[]}[]} */ + const log = [] + const rec = (call) => (...args) => log.push({ t: nowFn(), call, args }) + const renderer = { + applyActorPose: rec('applyActorPose'), + applyFace: rec('applyFace'), + applyFacing: rec('applyFacing'), + applyCamera: rec('applyCamera'), + setActorWorldPos: rec('setActorWorldPos'), + playEffect: rec('playEffect'), + stopEffect: rec('stopEffect'), + resetActor: rec('resetActor'), + runTransition: async (...a) => { + log.push({ t: nowFn(), call: 'runTransition', args: a }) + }, + } + const audio = { + play: (...a) => { + log.push({ t: nowFn(), call: 'audio.play', args: a }) + return { stop: rec('audio.stop'), durationMs: Promise.resolve(1000) } + }, + stopAll: rec('audio.stopAll'), + } + const tts = { + synthesize: async (req) => { + log.push({ t: nowFn(), call: 'tts.synthesize', args: [req] }) + return { audio: new ArrayBuffer(8), durationMs: 1000 } + }, + } + return { renderer, audio, tts, log } +} + +function makeCtx() { + const anim = new AnimationState(makeClipLib()) + return { + actors: { hero: true }, + animStateOf: (a) => (a === 'hero' ? anim : null), + resolvePoint: (n) => (n === 'door' ? { x: 500, y: 0 } : null), + getActorWorldPos: () => ({ x: 0, y: 0 }), + focusLookup: () => ({ x: 0, y: 0 }), + castVoice: () => ({ voice: 'v1' }), + _anim: anim, + } +} + +describe('scheduler 并行/阻塞调度', () => { + it('say+moveTo 并发取 max,cam/fx 行首即发不延长,wait 叠加(验收 7)', async () => { + const { clock, advance, now } = makeClock() + const { renderer, audio, tts, log } = makePorts(now) + const sch = createScheduler({ renderer, audio, tts, clock }) + const ctx = makeCtx() + const line = { + who: 'hero', + say: 'hello there', // voice → TTS durationMs=1000 + moveTo: 'door', + moveDur: 600, // 走位 600ms + cam: { type: 'cut', target: { x: 1, y: 1 } }, + fx: 'sparkle', + wait: 200, + } + const p = sch.playLine(line, ctx) + // cam/fx 在行首即发(虚拟时间 0) + expect(log.find((e) => e.call === 'playEffect' && e.args[0] === 'sparkle').t).toBe(0) + expect(log.find((e) => e.call === 'tts.synthesize').t).toBe(0) + expect(log.find((e) => e.call === 'setActorWorldPos').t).toBe(0) + + await advance(600) // 走位结束,但 say(1000) 仍阻塞 + await advance(400) // say 到 1000ms + await advance(200) // wait 叠加 + await p + // audio.play 发生过;say 等满 1000;总时长 = max(600,1000)+200 = 1200 + const stopAll0 = log.filter((e) => e.call === 'audio.stopAll') + expect(stopAll0.length).toBe(0) // 正常播完不调 stopAll + expect(log.some((e) => e.call === 'audio.play')).toBe(true) + }) + + it('say 有 voice 走 tts.synthesize→audio.play 并驱动 applyFace(talking)(验收 7)', async () => { + const { clock, advance, now } = makeClock() + const { renderer, audio, tts, log } = makePorts(now) + const sch = createScheduler({ renderer, audio, tts, clock }) + const ctx = makeCtx() + const p = sch.playLine({ who: 'hero', say: 'hi', voice: 'v1' }, ctx) + // 行首:applyFace(talking=true) + const faceTrue = log.find((e) => e.call === 'applyFace' && e.args[2] === true) + expect(faceTrue).toBeDefined() + await advance(1000) + await p + // 收尾:applyFace(talking=false) + expect(log.some((e) => e.call === 'applyFace' && e.args[2] === false)).toBe(true) + expect(log.some((e) => e.call === 'tts.synthesize')).toBe(true) + expect(log.some((e) => e.call === 'audio.play')).toBe(true) + }) + + it('无 cast voice 时回退默认音色并走 TTS,按 TTS duration 阻塞(验收 7)', async () => { + const { clock, advance, now } = makeClock() + const { renderer, audio, tts, log } = makePorts(now) + const sch = createScheduler({ renderer, audio, tts, clock }) + const ctx = makeCtx() + ctx.castVoice = () => ({}) // 无 voice,走默认音色兜底 + let done = false + const p = sch.playLine({ who: 'hero', say: 'x' }, ctx).then(() => { done = true }) + await advance(100) + expect(done).toBe(false) // TTS duration 1000ms,还没到 + expect(log.filter((e) => e.call === 'tts.synthesize').length).toBe(1) + expect(log.find((e) => e.call === 'tts.synthesize').args[0].speaker).toBe('S_5wA0nUX42') + await advance(1000) + await p + expect(done).toBe(true) + }) + + it('interrupt 调 audio.stopAll + resetActor + camera.reset,后续行不执行(验收 8)', async () => { + const { clock, advance, now } = makeClock() + const { renderer, audio, tts, log } = makePorts(now) + const camera = createCamera({ getViewport: () => ({ w: 100, h: 100 }) }) + const sch = createScheduler({ renderer, audio, tts, camera, clock }) + const ctx = makeCtx() + const lines = [ + { who: 'hero', say: 'long line', voice: 'v1' }, + { who: 'hero', say: 'second line', voice: 'v1' }, + ] + const p = sch.play(lines, ctx) + await advance(200) // 第一行播到中途 + sch.interrupt() + await advance(5000) + await p + expect(log.some((e) => e.call === 'audio.stopAll')).toBe(true) + expect(log.some((e) => e.call === 'resetActor' && e.args[0] === 'hero')).toBe(true) + // 第二行不应执行:tts.synthesize 只被第一行调用一次 + expect(log.filter((e) => e.call === 'tts.synthesize').length).toBe(1) + }) + + it('transition 阻塞走 runTransition + 换班底(验收/§3.4)', async () => { + const { clock, advance, now } = makeClock() + const { renderer, audio, tts, log } = makePorts(now) + const sch = createScheduler({ renderer, audio, tts, clock }) + let newCast = null + const ctx = makeCtx() + ctx.applyCast = (c) => { newCast = c } + const p = sch.playLine( + { transition: { to: 'scene2', type: 'fade', duration: 400 }, cast: [{ alias: 'villain' }] }, + ctx, + ) + await advance(0) + await p + expect(log.some((e) => e.call === 'runTransition')).toBe(true) + expect(newCast).toEqual([{ alias: 'villain' }]) + }) + + it('transition 行若同时带 say,也会在切场后继续播台词而不是静音跳过', async () => { + const { clock, advance, now } = makeClock() + const { renderer, audio, tts, log } = makePorts(now) + const sch = createScheduler({ renderer, audio, tts, clock }) + const ctx = makeCtx() + const p = sch.playLine( + { + who: 'hero', + transition: { to: 'scene2', type: 'fade', duration: 400 }, + cast: [{ alias: 'hero', voice: 'v1' }], + say: 'after transition', + }, + ctx, + ) + await advance(1000) + await p + expect(log.some((e) => e.call === 'runTransition')).toBe(true) + expect(log.some((e) => e.call === 'tts.synthesize')).toBe(true) + expect(log.some((e) => e.call === 'audio.play')).toBe(true) + }) + + it('cast 中空字符串 speechRate 不会污染 tts.synthesize(转场加人不再静音)', async () => { + const { clock, advance, now } = makeClock() + const { renderer, audio, tts, log } = makePorts(now) + const sch = createScheduler({ renderer, audio, tts, clock }) + const ctx = makeCtx() + ctx.castVoice = () => ({ voice: 'v1', speechRate: '' }) + + const p = sch.playLine({ who: 'hero', say: 'after transition', transition: { to: 'scene2', type: 'fade', duration: 200 } }, ctx) + await advance(1000) + await p + + const call = log.find((e) => e.call === 'tts.synthesize') + expect(call).toBeDefined() + expect(call.args[0].speechRate).toBeUndefined() + }) + + it('createScheduler 缺依赖即抛(装配时崩,§10.3)', () => { + expect(() => createScheduler({ renderer: {}, audio: {} })).toThrow() + }) +}) diff --git a/packages/db/MODEL.md b/packages/db/MODEL.md new file mode 100644 index 0000000..88e13bf --- /dev/null +++ b/packages/db/MODEL.md @@ -0,0 +1,35 @@ +# `@drama/db` 表关系设计纪律 + +> 本文件兑现任务文档 §2 验收标准 6:列出每张业务表到 `users` 表的外键可达路径,证明「user 级联可整体删除 / 可导出」的口子(架构基线 §10.1「数据可删除」)在一期就以**纯纪律**留好——二期实现 `cascadeDeleteUser(db, userId)` / `exportUser(db, userId)` 时无需穿透改表结构。 + +## 根锚:`users` + +`users` 是全库唯一的级联根。本包 `src/db/tables/users.cjs` 提供这一张跨域共享的根表。其余业务表由各领域包(`@drama/auth` / `@drama/account` / `@drama/billing` / `@drama/payment` / `@drama/email` / ...)自己的 `src/db/tables/*` 提供。 + +## 硬约束(领域包建表时必须遵守) + +每张业务表必须满足下列其一,保证到 `users.id` 有可遍历的外键路径: + +1. **直接挂**:表含 `user_id BIGINT` 外键,`references users.id`。适用于 user 直属数据(资料、积分账户、订阅、播放历史、收藏、创建的 drama)。 +2. **一层可达**:表经父表的外键间接可达 `users`。例如 `episode.drama_id → drama.id`,而 `drama.user_id → users.id`;则 `episode` 经 `drama` 一层可达。 +3. **全局共享、无属主**:不挂 user 的纯字典 / 素材池(如官方种子素材、系统枚举)显式标注「无属主」,级联删除时跳过、导出时不含。这类表必须在其领域包 `MODEL.md` 中明示,避免被误判为「漏挂 user_id」。 + +## 级联可达路径示意(领域表二期落地后填充) + +一期本包只锚定根表,下表是约定示意(实际由各领域包 table definition 落地并在各自文档登记其可达路径): + +- `users` ← `drama.user_id`(直接) +- `drama` ← `episode.drama_id`(一层经 drama 到 users) +- `drama` ← `play_record.drama_id` + `play_record.user_id`(直接 + 冗余直挂,便于按 user 整段删) +- `users` ← `subscription.user_id` / `credit_account.user_id` / `favorite.user_id`(直接) + +## 软删除约定 + +`deleted_at TIMESTAMP NULL` 为软删除标记,与 `helpers.notDeleted(qb)`(`where deleted_at is null`)配套。需要软删除语义的表统一用此列名,保证 helper 复用。 + +## 二期实现留口子(本包不实现,仅此设计支撑) + +- `cascadeDeleteUser(db, userId)`:从 `users.id` 出发,沿上述外键路径自顶向下删除(或软删)所有归属该 user 的行。 +- `exportUser(db, userId)`:同一遍历,收集而非删除,产出可导出的 user 数据快照(GDPR/CCPA 数据导出)。 + +只要领域包建表时遵守上述硬约束,这两个遍历器二期可纯靠外键元数据驱动实现,无需回头改表结构。 diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..c1f8c21 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,23 @@ +{ + "name": "@drama/db", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "dependencies": { + "knex": "^3.1.0" + }, + "peerDependencies": { + "mysql2": "^3.11.0" + }, + "peerDependenciesMeta": { + "mysql2": { + "optional": true + } + }, + "devDependencies": { + "mysql2": "^3.22.5" + } +} diff --git a/packages/db/src/cluster.js b/packages/db/src/cluster.js new file mode 100644 index 0000000..1303a4f --- /dev/null +++ b/packages/db/src/cluster.js @@ -0,0 +1,57 @@ +/** + * createDbCluster —— 多连接装配 + 降级 + destroy 去重。 + * + * 一期单连接够用:replica / analytics 未注入时降级到 primary(**同一引用**,可用 + * `===` 断言),调用方读写分离 / 分析分流的写法不变,二期换真副本零改动。 + * 本文件只做装配与降级,不碰任何具体表,不读 env。 + */ + +import { createDb } from './create-db.js' +import { DbConfigError } from './errors.js' + +/** + * @typedef {Object} DbCluster + * @property {import('knex').Knex} primary 主库(读写) + * @property {import('knex').Knex} replica 只读副本(一期 === primary) + * @property {import('knex').Knex} analytics 分析库(一期 === primary) + * @property {() => Promise} destroy 关闭所有去重后的底层连接 + */ + +/** + * 构造多连接集群。一期 replica/analytics 可省略,省略则降级到 primary(同一引用)。 + * @param {Object} cfg + * @param {{url:string,pool?:Object,client?:'mysql2'}} cfg.primary 主库(读写) + * @param {{url:string,pool?:Object,client?:'mysql2'}} [cfg.replica] 只读副本 + * @param {{url:string,pool?:Object,client?:'mysql2'}} [cfg.analytics] 分析库 + * @returns {DbCluster} + * @throws {DbConfigError} primary 缺失时立即抛 + */ +export function createDbCluster(cfg) { + if (!cfg || typeof cfg !== 'object' || !cfg.primary) { + throw new DbConfigError('createDbCluster: cfg.primary is required') + } + + const primary = createDb(cfg.primary) + + // 降级:未注入副本/分析库 → 显式指向 primary 同一引用(不新建第二个连同库的句柄, + // 避免连接数翻倍)。二期注入真副本/分析库时各自独立。 + const replica = cfg.replica ? createDb(cfg.replica) : primary + const analytics = cfg.analytics ? createDb(cfg.analytics) : primary + + /** + * 关闭所有去重后的底层连接。同一引用只 destroy 一次,绝不 double-close。 + * @returns {Promise} + */ + async function destroy() { + const seen = new Set() + const tasks = [] + for (const handle of [primary, replica, analytics]) { + if (seen.has(handle)) continue + seen.add(handle) + tasks.push(handle.destroy()) + } + await Promise.all(tasks) + } + + return { primary, replica, analytics, destroy } +} diff --git a/packages/db/src/create-db.js b/packages/db/src/create-db.js new file mode 100644 index 0000000..23a8b68 --- /dev/null +++ b/packages/db/src/create-db.js @@ -0,0 +1,140 @@ +/** + * createDb —— 构造单个 Knex 句柄(领域无关)。 + * + * 职责单一:解析并校验连接配置、选择 driver、构造 Knex 实例。**不连接**—— + * Knex 默认惰性建连,句柄的构造只发生在本函数被调用时,绝不在 import 期。 + * 本文件不读 `process.env`:连接串与池配置全部由参数注入。 + */ + +import knexFactory from 'knex' +import { DbConfigError } from './errors.js' + +/** url scheme → Knex client 名映射(仅支持本包声明依赖的 driver)。 */ +const SCHEME_TO_CLIENT = { + mysql: 'mysql2', + mysql2: 'mysql2', +} + +/** + * 解析连接 url。当前运行态和测试态都只支持 MySQL。 + * @param {string} url + * @param {string} [explicitClient] + * @returns {{ client: string, connection: any }} + */ +function parseUrl(url, explicitClient) { + if (typeof url !== 'string' || url.trim() === '') { + throw new DbConfigError('createDb: cfg.url is required and must be a non-empty string') + } + + let parsed + try { + parsed = new URL(url) + } catch { + throw new DbConfigError(`createDb: cfg.url is not a valid URL: ${url}`) + } + + const scheme = parsed.protocol.replace(/:$/, '') + const client = + explicitClient || + (Object.prototype.hasOwnProperty.call(SCHEME_TO_CLIENT, scheme) + ? SCHEME_TO_CLIENT[/** @type {keyof typeof SCHEME_TO_CLIENT} */ (scheme)] + : undefined) + if (!client) { + throw new DbConfigError( + `createDb: unsupported url scheme "${scheme}". Supported: mysql`, + ) + } + + if (!parsed.hostname) { + throw new DbConfigError(`createDb: cfg.url missing host: ${url}`) + } + + /** @type {Record} */ + const connection = { + host: parsed.hostname, + port: parsed.port ? Number(parsed.port) : 3306, + user: decodeURIComponent(parsed.username || ''), + password: decodeURIComponent(parsed.password || ''), + database: parsed.pathname.replace(/^\//, ''), + } + const ssl = parseMysqlSsl(parsed) + if (ssl) connection.ssl = ssl + // 时间纪律(plan/db-root-solution.md 决定 3):所有 DATETIME(3) 值语义固定 UTC。 + // timezone 'Z' 让 mysql2 写入序列化 Date、读取构造 Date 都按 UTC,杜绝隐式本地时区转换。 + connection.timezone = 'Z' + // DECIMAL 和 SUM()/AVG() 的结果 mysql2 默认返回字符串,于是每个读到它的地方都要自己 Number() 一次, + // 漏一处就变成字符串参与比较排序('9' > '10')。在驱动这层转干净,业务代码不再关心。 + connection.decimalNumbers = true + + return { + client: 'mysql2', + connection, + } +} + +/** + * RDS / 云数据库常见 URL 参数: + * - ssl=true / ssl=1 / sslmode=require:开启证书校验。 + * - ssl=skip-verify / sslmode=no-verify:只加密不校验证书,限临时联调。 + * - ssl=false / sslmode=disable:不启用 TLS。 + * @param {URL} parsed + * @returns {{ rejectUnauthorized: boolean }|undefined} + */ +function parseMysqlSsl(parsed) { + const raw = parsed.searchParams.get('ssl') || parsed.searchParams.get('sslmode') + if (!raw) return undefined + const value = raw.toLowerCase() + if (value === 'false' || value === '0' || value === 'disable' || value === 'disabled') return undefined + if (value === 'skip-verify' || value === 'no-verify' || value === 'insecure') { + return { rejectUnauthorized: false } + } + if ( + value === 'true' || + value === '1' || + value === 'required' || + value === 'require' || + value === 'verify-ca' || + value === 'verify-full' + ) { + return { rejectUnauthorized: true } + } + throw new DbConfigError(`createDb: unsupported mysql ssl mode "${raw}"`) +} + +/** + * 构造单个 Knex 句柄。连接串由调用方注入,包内不读 env。 + * @param {Object} cfg + * @param {string} cfg.url 连接串,如 mysql://user:pass@host:3306/db + * @param {{min?:number,max?:number}} [cfg.pool] 连接池配置 + * @param {'mysql2'} [cfg.client] 显式 client + * @returns {import('knex').Knex} Knex 句柄(领域无关) + * @throws {DbConfigError} url 缺失 / 非法时立即抛 + */ +export function createDb(cfg) { + if (!cfg || typeof cfg !== 'object') { + throw new DbConfigError('createDb: cfg object is required') + } + + const explicitClient = cfg.client === 'mysql2' ? 'mysql2' : undefined + + const { client, connection } = parseUrl(cfg.url, explicitClient) + + /** @type {import('knex').Knex.Config} */ + const knexConfig = { client, connection } + // mysql2 连接池默认:min:0 + 60s 空闲回收 → 空闲连接在 MySQL wait_timeout 断它之前先被池关闭, + // 避免「拿到已被服务端断开的连接」偶发 500(client disconnected by inactivity)。可被 cfg.pool 覆盖。 + knexConfig.pool = { + min: 0, + max: 10, + idleTimeoutMillis: 60000, + // 会话时区固定 UTC:让 DB 侧 NOW(3) 默认值也生成 UTC,配合 connection.timezone 'Z' + // 保证读写两端都没有隐式时区转换。 + afterCreate: (/** @type {any} */ conn, /** @type {(err: any, c: any) => void} */ done) => { + conn.query("SET time_zone = '+00:00'", (/** @type {any} */ err) => done(err, conn)) + }, + ...(cfg.pool || {}), + } + + // knex() 是工厂调用(非 `new Knex`),且发生在函数体内——不触发连接(惰性)。 + return knexFactory(knexConfig) +} diff --git a/packages/db/src/current-schema.js b/packages/db/src/current-schema.js new file mode 100644 index 0000000..f9ee616 --- /dev/null +++ b/packages/db/src/current-schema.js @@ -0,0 +1,159 @@ +/** + * Development current-schema applier. + * + * The project has not shipped production data yet, so dev/test databases are + * initialized from current table definitions. + */ + +import { DbConfigError } from './errors.js' + +/** + * @param {import('knex').Knex} db + * @param {Array<[string, Function]>} targetTables + * @returns {Promise} created table names + */ +export async function applyCurrentSchema(db, targetTables) { + if (!db || !db.schema) throw new DbConfigError('applyCurrentSchema: db handle is required') + assertTargetTables(targetTables, 'applyCurrentSchema') + + const created = [] + for (const [tableName, buildTable] of targetTables) { + if (!(await db.schema.hasTable(tableName))) { + await db.schema.createTable(tableName, (table) => buildTable(table, db)) + created.push(tableName) + } + } + return created +} + +/** + * Development-only schema sync. + * + * Non-destructive by design: + * - create missing tables + * - add missing columns to existing tables + * - never drop target tables or truncate data + * + * @param {import('knex').Knex} db + * @param {Array<[string, Function]>} targetTables + * @returns {Promise<{ createdTables: string[], alteredTables: Array<{ tableName: string, addedColumns: string[] }> }>} + */ +export async function syncCurrentSchema(db, targetTables) { + if (!db || !db.schema) throw new DbConfigError('syncCurrentSchema: db handle is required') + assertTargetTables(targetTables, 'syncCurrentSchema') + + const createdTables = [] + const alteredTables = [] + for (const [tableName, buildTable] of targetTables) { + if (!(await db.schema.hasTable(tableName))) { + await db.schema.createTable(tableName, (table) => buildTable(table, db)) + createdTables.push(tableName) + continue + } + const addedColumns = await addMissingColumns(db, tableName, buildTable) + if (addedColumns.length > 0) alteredTables.push({ tableName, addedColumns }) + } + return { createdTables, alteredTables } +} + +/** + * @param {Array<[string, Function]>} targetTables + * @param {string} caller + */ +function assertTargetTables(targetTables, caller) { + if (!Array.isArray(targetTables)) throw new DbConfigError(`${caller}: targetTables must be an array`) + for (const [tableName, buildTable] of targetTables) { + if (!tableName || typeof buildTable !== 'function') { + throw new DbConfigError(`${caller}: invalid table definition`) + } + } +} + +/** + * @param {import('knex').Knex} db + * @param {string} tableName + * @param {Function} buildTable + * @returns {Promise} + */ +async function addMissingColumns(db, tableName, buildTable) { + const client = db.client && db.client.config && db.client.config.client + if (client !== 'mysql' && client !== 'mysql2') return [] + + const existing = await db(tableName).columnInfo() + const shadowName = shadowTableName() + await db.schema.dropTableIfExists(shadowName) + try { + await createShadowTable(db, shadowName, buildTable) + const shadow = await db(shadowName).columnInfo() + const columnSql = await readCreateTableColumns(db, shadowName) + const added = [] + for (const columnName of Object.keys(shadow)) { + if (existing[columnName]) continue + const definition = columnSql.get(columnName) + if (!definition) continue + await db.raw(`ALTER TABLE ${quoteIdent(tableName)} ADD COLUMN ${definition}`) + added.push(columnName) + } + return added + } finally { + await db.schema.dropTableIfExists(shadowName) + } +} + +/** + * MySQL constraint names are unique within a schema. A shadow table therefore + * cannot reuse the named constraints already owned by the real table. + * + * @param {import('knex').Knex} db + * @param {string} shadowName + * @param {Function} buildTable + */ +async function createShadowTable(db, shadowName, buildTable) { + const statements = db.schema + .createTable(shadowName, (table) => buildTable(table, db)) + .toSQL() + + let constraintIndex = 0 + for (const statement of statements) { + const sql = statement.sql.replace(/\bconstraint\s+`[^`]+`/gi, () => { + const name = `${shadowName}_c${constraintIndex}` + constraintIndex += 1 + return `constraint ${quoteIdent(name)}` + }) + await db.raw(sql, statement.bindings) + } +} + +/** + * @param {import('knex').Knex} db + * @param {string} tableName + * @returns {Promise>} + */ +async function readCreateTableColumns(db, tableName) { + const [rows] = await db.raw(`SHOW CREATE TABLE ${quoteIdent(tableName)}`) + const createSql = rows && rows[0] && rows[0]['Create Table'] + if (!createSql) return new Map() + const map = new Map() + for (const rawLine of createSql.split('\n')) { + const line = rawLine.trim().replace(/,$/, '') + const match = line.match(/^`([^`]+)`\s+/) + if (match) map.set(match[1], line) + } + return map +} + +/** + * @param {string} tableName + */ +function shadowTableName() { + const suffix = Math.random().toString(36).slice(2, 10) + return `__s_${suffix}` +} + +/** + * @param {string} name + */ +function quoteIdent(name) { + if (!/^[A-Za-z0-9_]+$/.test(name)) throw new DbConfigError(`invalid SQL identifier: ${name}`) + return `\`${name}\`` +} diff --git a/packages/db/src/db/README.md b/packages/db/src/db/README.md new file mode 100644 index 0000000..150bbf0 --- /dev/null +++ b/packages/db/src/db/README.md @@ -0,0 +1,6 @@ +# @drama/db Database Schema + +这里是 `@drama/db` 自己拥有的 platform core 表结构。 + +- `tables/users.cjs`:跨 app 的用户根表。 +- 业务表不属于 `@drama/db`,应放在对应业务包的 `src/db/tables/` 下。 diff --git a/packages/db/src/db/tables/index.cjs b/packages/db/src/db/tables/index.cjs new file mode 100644 index 0000000..5cb525f --- /dev/null +++ b/packages/db/src/db/tables/index.cjs @@ -0,0 +1,11 @@ +const tableModules = [ + require('./users.cjs'), +] + +const targetTables = tableModules.filter(({ buildTable }) => typeof buildTable === 'function').map(({ tableName, buildTable }) => [tableName, buildTable]) +const dbTables = Object.fromEntries(tableModules.map(({ tableName }) => [tableName, tableName])) + +module.exports = { + targetTables, + dbTables, +} diff --git a/packages/db/src/db/tables/users.cjs b/packages/db/src/db/tables/users.cjs new file mode 100644 index 0000000..721c055 --- /dev/null +++ b/packages/db/src/db/tables/users.cjs @@ -0,0 +1,20 @@ +const tableName = 'users' + +function buildTable(t, knex) { + t.string('id', 64).primary() + t.string('email', 320).notNullable().unique() + t.string('display_name', 120).nullable() + t.string('status', 32).notNullable().defaultTo('active') + t.string('role', 32).nullable() + t.text('password_hash').nullable() + t.string('locale', 16).nullable() + t.datetime('email_verified_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable().defaultTo(knex.fn.now(3)) + t.datetime('updated_at', { precision: 3 }).nullable() + t.datetime('deleted_at', { precision: 3 }).nullable() +} + +module.exports = { + tableName, + buildTable, +} diff --git a/packages/db/src/dev-url.js b/packages/db/src/dev-url.js new file mode 100644 index 0000000..eca284f --- /dev/null +++ b/packages/db/src/dev-url.js @@ -0,0 +1,27 @@ +/** + * 本机开发 MySQL 的连接串 —— 全仓唯一的一份默认值。 + * + * 「开发机上跑着哪个 MySQL、用什么口令连」是一条规则,只能写一次。以前每个脚本各写一份 + * `process.env.DATABASE_URL || 'mysql://root:<猜的口令>@...'`,猜错了也没人发现(因为没人跑), + * 于是真链路脚本集体腐烂。 + * + * 这个实例由 `docker-compose.yml` 的 mysql 服务提供,`tools/scripts/mysql-dev.js` 用本函数解析出 + * 的口令去 provision 它 —— 容器口令和代码口令是同一个字面量,不会再分叉。 + * + * 部署环境一律由 `DATABASE_URL` 覆盖:`process.env.DATABASE_URL || devMysqlUrl('idearun')`。 + * 本函数是纯的,不读 `process.env`(保持 @drama/db「不读环境变量」的边界纪律)。 + */ + +/** 本机开发 MySQL 的 server 端点(不含库名)。 */ +const DEV_MYSQL_SERVER = 'mysql://root:idearun_dev_pw@127.0.0.1:3306' + +/** + * @param {string} database 库名。建库 / 删库这类管理连接传一个已存在的库名即可(如 `idearun`)。 + * @returns {string} 形如 mysql://root:***@127.0.0.1:3306/ + */ +export function devMysqlUrl(database) { + if (!/^[A-Za-z0-9_]+$/.test(String(database || ''))) { + throw new Error(`devMysqlUrl: invalid database name: ${database}`) + } + return `${DEV_MYSQL_SERVER}/${database}` +} diff --git a/packages/db/src/errors.js b/packages/db/src/errors.js new file mode 100644 index 0000000..b9ad75f --- /dev/null +++ b/packages/db/src/errors.js @@ -0,0 +1,10 @@ +/** + * 配置错误(工厂入口校验失败时抛)。单独成文件,避免循环引用。 + */ +export class DbConfigError extends Error { + /** @param {string} message */ + constructor(message) { + super(message) + this.name = 'DbConfigError' + } +} diff --git a/packages/db/src/helpers.js b/packages/db/src/helpers.js new file mode 100644 index 0000000..f735b89 --- /dev/null +++ b/packages/db/src/helpers.js @@ -0,0 +1,52 @@ +/** + * helpers —— 共享查询助手:纯函数,不持有连接,作用于传入的 query builder 或行对象。 + * + * 零 IO、最易测。`paginate` / `notDeleted` 在 builder 上施加约束并返回 builder(链式)。 + * 无任何领域语义。全链路统一 snake_case(plan/db-root-solution.md 决定 2),不存在任何大小写转换助手。 + */ + +/** + * 游标分页(keyset):在 builder 上施加 keyset 分页,返回 builder。 + * + * 语义:按 `orderBy.column` 排序,取 `limit` 行;若给了 `cursor`(上一页最后一行的 + * 排序列值),则只取该游标之后的行(升序 `>`,降序 `<`)。多取一行由调用方判断 hasMore + * 不在此处兜——本函数职责单一,只施加 where + orderBy + limit。 + * + * @param {import('knex').Knex.QueryBuilder} qb Knex query builder + * @param {Object} [opts] + * @param {string|number|null} [opts.cursor] 上一页最后一行的排序列值;首页传 null/undefined + * @param {number} [opts.limit] 每页条数(默认 20) + * @param {{column?:string,direction?:('asc'|'desc')}} [opts.orderBy] 排序列与方向 + * @returns {import('knex').Knex.QueryBuilder} 同一个 builder(链式) + */ +export function paginate(qb, opts = {}) { + const limit = + typeof opts.limit === 'number' && Number.isInteger(opts.limit) && opts.limit > 0 + ? opts.limit + : 20 + const column = (opts.orderBy && opts.orderBy.column) || 'id' + const direction = (opts.orderBy && opts.orderBy.direction) === 'desc' ? 'desc' : 'asc' + + if (opts.cursor !== undefined && opts.cursor !== null) { + const operator = direction === 'desc' ? '<' : '>' + qb.where(column, operator, opts.cursor) + } + + return qb.orderBy(column, direction).limit(limit) +} + +/** + * 软删除过滤:where deleted_at is null。 + * @param {import('knex').Knex.QueryBuilder} qb + * @param {string} [column] 软删除列名(默认 deleted_at) + * @returns {import('knex').Knex.QueryBuilder} + */ +export function notDeleted(qb, column = 'deleted_at') { + return qb.whereNull(column) +} + +/** 共享查询助手集合。 */ +export const helpers = { + paginate, + notDeleted, +} diff --git a/packages/db/src/index.js b/packages/db/src/index.js new file mode 100644 index 0000000..0bb39a0 --- /dev/null +++ b/packages/db/src/index.js @@ -0,0 +1,18 @@ +/** + * @drama/db —— 公共面(唯一对外入口)。 + * + * 只导出契约(任务文档 §3)声明的符号:连接工厂(含多连接集群)、本机开发库的默认连接串、 + * 当前 schema 应用器、纯函数查询助手、配置错误类。**不导出** Knex 实例、不导出任何内部解析函数、 + * 不持有任何领域 repo(无 findById/save 之类领域方法)。 + * + * 边界纪律:本包不读 process.env、不在模块作用域建连接;`import '@drama/db'` 不触发 + * 任何网络 / DB 连接。 + */ + +export { createDb } from './create-db.js' +export { devMysqlUrl } from './dev-url.js' +export { createDbCluster } from './cluster.js' +export { applyCurrentSchema, syncCurrentSchema } from './current-schema.js' +export { helpers } from './helpers.js' +export { DbConfigError } from './errors.js' +export { default as dbSchema } from './db/tables/index.cjs' diff --git a/packages/db/src/knex-types.d.ts b/packages/db/src/knex-types.d.ts new file mode 100644 index 0000000..04a495b --- /dev/null +++ b/packages/db/src/knex-types.d.ts @@ -0,0 +1,64 @@ +declare module 'knex' { + export namespace Knex { + interface SchemaBuilder { + hasTable(tableName: string): Promise + } + + interface RawBuilder { + (sql: string, bindings?: unknown[] | Record): unknown + } + + interface Fn { + now(): unknown + } + + interface QueryBuilder extends PromiseLike { + where(where: Record): QueryBuilder + where(column: string, value: unknown): QueryBuilder + andWhere(column: string, operator: string, value: unknown): QueryBuilder + andWhere(where: Record): QueryBuilder + whereIn(column: string, values: unknown[]): QueryBuilder + first(...columns: string[]): Promise + insert(data: Record | Array>): QueryBuilder + update(data: Record): QueryBuilder + del(): QueryBuilder + delete(): QueryBuilder + select(...columns: string[]): QueryBuilder + orderBy(column: string, direction?: 'asc' | 'desc'): QueryBuilder + limit(value: number): QueryBuilder + offset(value: number): QueryBuilder + count(alias?: string | Record): QueryBuilder + sum(alias: string | Record): QueryBuilder + max(alias: string | Record): QueryBuilder + increment(column: string, amount?: number): QueryBuilder + decrement(column: string, amount?: number): QueryBuilder + onConflict(columns: string | string[]): QueryBuilder + merge(data?: Record): QueryBuilder + ignore(): QueryBuilder + returning(columns: string | string[]): QueryBuilder + transacting(trx: Transaction): QueryBuilder + clone(): QueryBuilder + } + + interface Transaction extends Knex {} + } + + export interface Knex { + (tableName: string): Knex.QueryBuilder + schema: Knex.SchemaBuilder + fn: Knex.Fn + raw: Knex.RawBuilder + transaction(handler: (trx: Knex.Transaction) => Promise): Promise + destroy(): Promise + } + + export interface Config { + client?: string + connection?: string | Record + useNullAsDefault?: boolean + migrations?: Record + pool?: Record + } + + export default function knex(config: Config): Knex +} diff --git a/packages/email/package.json b/packages/email/package.json new file mode 100644 index 0000000..7a0f3a5 --- /dev/null +++ b/packages/email/package.json @@ -0,0 +1,20 @@ +{ + "name": "@drama/email", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + }, + "peerDependencies": { + "nodemailer": "^6.9.0" + }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + } + } +} diff --git a/packages/email/src/db/README.md b/packages/email/src/db/README.md new file mode 100644 index 0000000..a7a48e0 --- /dev/null +++ b/packages/email/src/db/README.md @@ -0,0 +1,10 @@ +# @drama/email Database Schema + +这里是 `@drama/email` 当前表结构的唯一阅读入口。 + +- `tables/`:一个表一个文件,文件名等于表名。 +- `tables/index.cjs`:批量加载入口,只给显式初始化/测试/校验工具使用,不是人工阅读入口。 + +## 表 + +- `tables/email_delivery_logs.cjs` diff --git a/packages/email/src/db/tables/email_delivery_logs.cjs b/packages/email/src/db/tables/email_delivery_logs.cjs new file mode 100644 index 0000000..847fc53 --- /dev/null +++ b/packages/email/src/db/tables/email_delivery_logs.cjs @@ -0,0 +1,19 @@ +const tableName = 'email_delivery_logs' + +function buildTable(t, knex) { + t.string('id', 120).primary() + t.string('provider', 48).nullable() + t.string('message_id', 191).nullable().index() + t.string('purpose', 64).notNullable().index() + t.string('to_email', 320).notNullable().index() + t.string('status', 24).notNullable().defaultTo('sent') + t.integer('attempt_count').unsigned().notNullable().defaultTo(0) + t.datetime('next_attempt_at', { precision: 3 }).nullable() + t.text('template').nullable() + t.text('error_message').nullable() + t.text('metadata_json', 'longtext').nullable() + t.datetime('created_at', { precision: 3 }).notNullable().defaultTo(knex.fn.now(3)) + t.datetime('updated_at', { precision: 3 }).nullable() +} + +module.exports = { tableName, buildTable } diff --git a/packages/email/src/db/tables/index.cjs b/packages/email/src/db/tables/index.cjs new file mode 100644 index 0000000..bba75e4 --- /dev/null +++ b/packages/email/src/db/tables/index.cjs @@ -0,0 +1,11 @@ +const tableModules = [ + require('./email_delivery_logs.cjs'), +] + +const targetTables = tableModules.filter(({ buildTable }) => typeof buildTable === 'function').map(({ tableName, buildTable }) => [tableName, buildTable]) +const emailTables = Object.fromEntries(tableModules.map(({ tableName }) => [tableName.replace(/^(auth_|account_|billing_|payment_|email_)/, ''), tableName])) + +module.exports = { + targetTables, + emailTables, +} diff --git a/packages/email/src/email.js b/packages/email/src/email.js new file mode 100644 index 0000000..d0c0913 --- /dev/null +++ b/packages/email/src/email.js @@ -0,0 +1,111 @@ +/** + * createEmail —— 编排层。 + * 职责:按 provider 选实现、send 前校验、sendTemplate = 渲染 + send、把厂商错误归一为 EmailError。 + * 不碰具体投递协议(那在 providers/.js),不生成/校验验证码(那是 @drama/auth)。 + */ + +import { EmailError, mapError } from './map-error.js' +import { assertSendable } from './validate.js' +import { renderTemplate } from './templates/index.js' +import { createNodemailerProvider } from './providers/nodemailer.js' +import { createSesProvider } from './providers/ses.js' + +/** provider 名 → 工厂。加厂商 = 加 providers/.js + 这里登记一行。 */ +const PROVIDERS = { + nodemailer: createNodemailerProvider, + ses: createSesProvider, +} + +/** + * @typedef {Object} Email + * @property {(msg: import('./index.js').SendMessage) => Promise} send + * @property {(name: string, data: object) => Promise} sendTemplate + */ + +/** + * 唯一入口工厂。SMTP/SES 凭据与发件域由调用方(composition root)注入,包内不读 env。 + * + * @param {Object} [opts] + * @param {string} [opts.provider] 'nodemailer' | 'ses'(缺省 'nodemailer') + * @param {any} [opts.credentials] + * @param {import('./index.js').Provider} [opts.providerImpl] 直接注入 provider(测试用,绕过内建表) + * @returns {Email} + */ +export function createEmail({ provider = 'nodemailer', credentials, providerImpl } = {}) { + const impl = resolveProvider({ provider, credentials, providerImpl }) + const providerName = provider + + /** @type {Email['send']} */ + async function send(msg) { + assertSendable(msg, providerName) + try { + const result = await impl.send(msg) + return result + } catch (err) { + throw mapError(err, providerName) + } + } + + /** + * @param {string} name + * @param {any} data + * @returns {Promise} + */ + async function sendTemplate(name, data) { + if (!data || typeof data !== 'object') { + throw new EmailError({ provider: providerName, reason: 'badTemplate', retryable: false, message: `template "${name}": data required` }) + } + // 渲染是纯函数,badTemplate / 缺字段在此抛出(早于任何投递)。 + const rendered = renderTemplate(name, data) + return send({ + to: data.to, + subject: rendered.subject, + html: rendered.html, + text: rendered.text, + from: data.from, + reply_to: data.reply_to, + cc: data.cc, + bcc: data.bcc, + }) + } + + return { send, sendTemplate } +} + +/** + * @param {{ provider: string, credentials: any, providerImpl?: import('./index.js').Provider }} args + * @returns {import('./index.js').Provider} + */ +function resolveProvider({ provider, credentials, providerImpl }) { + if (providerImpl) { + if (typeof providerImpl.send !== 'function') { + throw new EmailError({ provider: 'unknown', reason: 'unknown', retryable: false, message: 'providerImpl must expose send()' }) + } + return providerImpl + } + + const factory = PROVIDERS[provider] + if (!factory) { + const supported = Object.keys(PROVIDERS).join(', ') + throw new EmailError({ + provider: String(provider), + reason: 'unknown', + retryable: false, + message: `unknown provider "${provider}". supported: ${supported}`, + }) + } + if (!credentials || typeof credentials !== 'object') { + throw new EmailError({ + provider, + reason: 'auth', + retryable: false, + message: `provider "${provider}" requires credentials`, + }) + } + return factory(credentials) +} + +/** @returns {string[]} 支持的内建 provider 名 */ +export function supportedProviders() { + return Object.keys(PROVIDERS) +} diff --git a/packages/email/src/index.js b/packages/email/src/index.js new file mode 100644 index 0000000..fd4278d --- /dev/null +++ b/packages/email/src/index.js @@ -0,0 +1,30 @@ +/** + * `@drama/email` 公共面 —— 唯一对外导出口(§3 契约)。 + * + * 设计纪律:包内不读 `process.env`、不在模块顶层建任何投递连接 + * (transport / SES client 只在 provider 工厂内、由注入凭据构造)。 + * 本包只负责「渲染并投递单封事务性邮件」,不持有验证码的生成/校验/频控逻辑。 + * + * @typedef {Object} SendMessage + * @property {string|string[]} to + * @property {string} subject + * @property {string} [html] + * @property {string} [text] + * @property {string} [from] 缺省取 credentials.defaultFrom + * @property {string} [reply_to] + * @property {string[]} [cc] + * @property {string[]} [bcc] + * + * @typedef {Object} SendResult + * @property {string} message_id + * @property {string} provider + * @property {string|string[]} to + * + * @typedef {Object} Provider + * @property {(msg: SendMessage) => Promise} send + */ + +export { createEmail } from './email.js' +export { EmailError } from './map-error.js' +export { createFakeProvider } from './providers/fake.js' +export { default as emailDbSchema } from './db/tables/index.cjs' diff --git a/packages/email/src/map-error.js b/packages/email/src/map-error.js new file mode 100644 index 0000000..400b922 --- /dev/null +++ b/packages/email/src/map-error.js @@ -0,0 +1,107 @@ +/** + * 结构化邮件错误 + 厂商错误 → EmailError 的纯函数归一。 + * + * reason 取值固定为 §3 声明的集合: + * 'invalidRecipient' | 'badTemplate' | 'transient' | 'auth' | 'unknown' + * retryable 规则:限流/超时/5xx = transient+retryable;鉴权失败 = auth 不重试; + * 非法收件人 = invalidRecipient 不重试。 + */ + +/** @typedef {'invalidRecipient'|'badTemplate'|'transient'|'auth'|'unknown'} EmailReason */ + +export class EmailError extends Error { + /** + * @param {Object} info + * @param {string} info.provider + * @param {EmailReason} info.reason + * @param {boolean} info.retryable + * @param {unknown} [info.cause] + * @param {string} [info.message] + */ + constructor({ provider, reason, retryable, cause, message }) { + super(message ?? `email failed: ${reason}`) + this.name = 'EmailError' + this.provider = provider + this.reason = reason + this.retryable = retryable + this.cause = cause + } +} + +const RETRYABLE = new Set(['transient']) + +/** + * 把任意厂商错误归一成 EmailError。已是 EmailError 的原样透传。 + * @param {unknown} err + * @param {string} provider + * @returns {EmailError} + */ +export function mapError(err, provider) { + if (err instanceof EmailError) return err + const reason = classify(err) + return new EmailError({ + provider, + reason, + retryable: RETRYABLE.has(reason), + cause: err, + message: extractMessage(err, reason), + }) +} + +/** + * 厂商错误 → reason 分类(纯函数,全表可测)。 + * 优先看显式标识(SMTP `responseCode`、SES `Code`/`statusCode`、`code`), + * 再退化到消息文本启发式。 + * @param {any} err + * @returns {EmailReason} + */ +export function classify(err) { + if (!err || typeof err !== 'object') return 'unknown' + + const status = numOf(err.statusCode) ?? numOf(err.responseCode) + if (status != null) { + if (status === 429) return 'transient' + if (status >= 500) return 'transient' + if (status === 401 || status === 403) return 'auth' + } + + // Nodemailer / SMTP 错误码 + const code = typeof err.code === 'string' ? err.code : undefined + if (code === 'EAUTH') return 'auth' + if (code === 'EENVELOPE' || code === 'ENVELOPE') return 'invalidRecipient' + if (code === 'ETIMEDOUT' || code === 'ECONNECTION' || code === 'ESOCKET' || code === 'ECONNRESET') { + return 'transient' + } + + // AWS SES 错误名(err.name / err.Code) + const name = typeof err.name === 'string' ? err.name : (typeof err.Code === 'string' ? err.Code : undefined) + if (name) { + if (/Throttl|TooManyRequests|ServiceUnavailable|InternalFailure|RequestTimeout/i.test(name)) { + return 'transient' + } + if (/InvalidClientTokenId|SignatureDoesNotMatch|UnrecognizedClient|AccessDenied|AuthFailure/i.test(name)) { + return 'auth' + } + if (/InvalidParameterValue.*destination|MessageRejected.*address|InvalidRecipient/i.test(name)) { + return 'invalidRecipient' + } + } + + const msg = typeof err.message === 'string' ? err.message : '' + if (/timeout|timed out|temporarily|rate limit|throttl/i.test(msg)) return 'transient' + if (/auth|credential|unauthor|access denied|signature/i.test(msg)) return 'auth' + if (/recipient|invalid.*address|no recipients/i.test(msg)) return 'invalidRecipient' + + return 'unknown' +} + +/** @param {unknown} v @returns {number|undefined} */ +function numOf(v) { + return typeof v === 'number' && Number.isFinite(v) ? v : undefined +} + +/** @param {any} err @param {string} reason @returns {string} */ +function extractMessage(err, reason) { + const m = err && typeof err === 'object' && typeof err.message === 'string' ? err.message : '' + return m ? `email failed (${reason}): ${m}` : `email failed: ${reason}` +} diff --git a/packages/email/src/providers/fake.js b/packages/email/src/providers/fake.js new file mode 100644 index 0000000..fd68179 --- /dev/null +++ b/packages/email/src/providers/fake.js @@ -0,0 +1,22 @@ +/** + * 测试用假 provider:把投递写入注入的 sink,而不真发出。 + * 让 send / sendTemplate 编排无需网络即可测,并断言投出的内容正确。 + */ + +/** + * @param {(record: { msg: import('../index.js').SendMessage, message_id: string }) => void} [sink] + * 每次投递回调(也可传入数组的 push)。 + * @returns {import('../index.js').Provider} + */ +export function createFakeProvider(sink) { + let n = 0 + const emit = typeof sink === 'function' ? sink : () => {} + return { + async send(msg) { + n += 1 + const message_id = `fake-${n}-${Date.now()}` + emit({ msg, message_id }) + return { message_id, provider: 'fake', to: msg.to } + }, + } +} diff --git a/packages/email/src/providers/nodemailer.js b/packages/email/src/providers/nodemailer.js new file mode 100644 index 0000000..e6acf76 --- /dev/null +++ b/packages/email/src/providers/nodemailer.js @@ -0,0 +1,74 @@ +/** + * Nodemailer / SMTP provider。 + * + * 纪律:transport 在本工厂内构造(注入凭据),**不在模块顶层 createTransport**、 + * 不读 env。`nodemailer` 是可选 peer 依赖,按需 dynamic import,缺失时给出明确报错。 + */ + +import { EmailError } from '../map-error.js' + +/** + * @param {Object} credentials + * @param {string} credentials.host + * @param {number} credentials.port + * @param {boolean} credentials.secure + * @param {{ user: string, pass: string }} credentials.auth + * @param {string} credentials.defaultFrom + * @returns {import('../index.js').Provider} + */ +export function createNodemailerProvider({ host, port, secure, auth, defaultFrom }) { + if (!host || !port || !auth || !defaultFrom) { + throw new EmailError({ + provider: 'nodemailer', + reason: 'auth', + retryable: false, + message: 'nodemailer credentials require { host, port, auth, defaultFrom }', + }) + } + + // 惰性持有 transport:首次 send 时才构造,避免无谓 import;仍在工厂闭包内、非顶层。 + /** @type {Promise|null} */ + let transportPromise = null + function getTransport() { + if (!transportPromise) { + transportPromise = import('nodemailer') + .then((mod) => { + const nodemailer = mod.default ?? mod + return nodemailer.createTransport({ host, port, secure, auth }) + }) + .catch((err) => { + transportPromise = null + throw new EmailError({ + provider: 'nodemailer', + reason: 'unknown', + retryable: false, + cause: err, + message: 'nodemailer is not installed; add it as a dependency in the composition root', + }) + }) + } + return transportPromise + } + + return { + async send(msg) { + const transport = await getTransport() + try { + const info = await transport.sendMail({ + from: msg.from ?? defaultFrom, + to: msg.to, + subject: msg.subject, + html: msg.html, + text: msg.text, + replyTo: msg.reply_to, + cc: msg.cc, + bcc: msg.bcc, + }) + return { message_id: info.messageId, provider: 'nodemailer', to: msg.to } + } catch (err) { + if (err instanceof EmailError) throw err + throw err // 由 email.js 编排层统一 mapError 归一 + } + }, + } +} diff --git a/packages/email/src/providers/ses.js b/packages/email/src/providers/ses.js new file mode 100644 index 0000000..aad1207 --- /dev/null +++ b/packages/email/src/providers/ses.js @@ -0,0 +1,183 @@ +/** + * AWS SES provider —— 直接对 SESv2 `SendEmail` REST 接口做 SigV4 签名投递, + * 不引入 AWS SDK(保持包薄)。client 状态全在工厂闭包内构造,**非模块顶层**、不读 env。 + * + * 仅用 Node 内置 `crypto` 与全局 `fetch`(Node 24)。 + */ + +import { createHash, createHmac } from 'node:crypto' +import { EmailError } from '../map-error.js' + +const SERVICE = 'ses' + +/** + * @param {Object} credentials + * @param {string} credentials.region + * @param {string} credentials.accessKeyId + * @param {string} credentials.accessKeySecret + * @param {string} credentials.defaultFrom + * @returns {import('../index.js').Provider} + */ +export function createSesProvider({ region, accessKeyId, accessKeySecret, defaultFrom }) { + if (!region || !accessKeyId || !accessKeySecret || !defaultFrom) { + throw new EmailError({ + provider: 'ses', + reason: 'auth', + retryable: false, + message: 'ses credentials require { region, accessKeyId, accessKeySecret, defaultFrom }', + }) + } + + const host = `email.${region}.amazonaws.com` + const endpoint = `https://${host}/v2/email/outbound-emails` + + return { + async send(msg) { + const toAddrs = Array.isArray(msg.to) ? msg.to : [msg.to] + const payload = { + FromEmailAddress: msg.from ?? defaultFrom, + Destination: { + ToAddresses: toAddrs, + CcAddresses: msg.cc, + BccAddresses: msg.bcc, + }, + ReplyToAddresses: msg.reply_to ? [msg.reply_to] : undefined, + Content: { + Simple: { + Subject: { Data: msg.subject, Charset: 'UTF-8' }, + Body: { + Html: msg.html ? { Data: msg.html, Charset: 'UTF-8' } : undefined, + Text: msg.text ? { Data: msg.text, Charset: 'UTF-8' } : undefined, + }, + }, + }, + } + const body = JSON.stringify(stripUndefined(payload)) + + const headers = signRequest({ + method: 'POST', + host, + path: '/v2/email/outbound-emails', + body, + region, + accessKeyId, + accessKeySecret, + }) + + let res + try { + res = await fetch(endpoint, { method: 'POST', headers, body }) + } catch (err) { + // 网络层失败 = transient + throw new EmailError({ provider: 'ses', reason: 'transient', retryable: true, cause: err, message: 'ses request failed' }) + } + + const respText = await res.text() + if (!res.ok) { + // 把 HTTP 状态喂给 email.js 的 mapError 归一(429/5xx=transient、401/403=auth) + const parsed = safeJson(respText) + const err = Object.assign(new Error(parsed?.message ?? respText ?? `SES HTTP ${res.status}`), { + statusCode: res.status, + name: parsed?.__type ?? parsed?.type, + Code: parsed?.__type, + }) + throw err + } + + const json = safeJson(respText) ?? {} + return { message_id: json.MessageId ?? '', provider: 'ses', to: msg.to } + }, + } +} + +/** + * SigV4 签名,返回完整请求头(含 Authorization)。 + * @param {{ method: string, host: string, path: string, body: string, region: string, accessKeyId: string, accessKeySecret: string }} args + * @returns {Record} + */ +function signRequest({ method, host, path, body, region, accessKeyId, accessKeySecret }) { + const now = new Date() + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '') // YYYYMMDDTHHMMSSZ + const dateStamp = amzDate.slice(0, 8) + + const payloadHash = sha256Hex(body) + const canonicalHeaders = + `content-type:application/json\n` + + `host:${host}\n` + + `x-amz-content-sha256:${payloadHash}\n` + + `x-amz-date:${amzDate}\n` + const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date' + + const canonicalRequest = [ + method, + path, + '', // canonical query string (none) + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n') + + const scope = `${dateStamp}/${region}/${SERVICE}/aws4_request` + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + scope, + sha256Hex(canonicalRequest), + ].join('\n') + + const signingKey = getSignatureKey(accessKeySecret, dateStamp, region, SERVICE) + const signature = hmac(signingKey, stringToSign).toString('hex') + + const authorization = + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${signature}` + + return { + 'Content-Type': 'application/json', + Host: host, + 'X-Amz-Content-Sha256': payloadHash, + 'X-Amz-Date': amzDate, + Authorization: authorization, + } +} + +/** @param {string} secret @param {string} dateStamp @param {string} region @param {string} service */ +function getSignatureKey(secret, dateStamp, region, service) { + const kDate = hmac(`AWS4${secret}`, dateStamp) + const kRegion = hmac(kDate, region) + const kService = hmac(kRegion, service) + return hmac(kService, 'aws4_request') +} + +/** @param {import('node:crypto').BinaryLike} key @param {string} data @returns {Buffer} */ +function hmac(key, data) { + return createHmac('sha256', key).update(data, 'utf8').digest() +} + +/** @param {string} data @returns {string} */ +function sha256Hex(data) { + return createHash('sha256').update(data, 'utf8').digest('hex') +} + +/** @param {any} obj @returns {any} */ +function stripUndefined(obj) { + if (Array.isArray(obj)) return obj.map(stripUndefined) + if (obj && typeof obj === 'object') { + const out = {} + for (const [k, v] of Object.entries(obj)) { + if (v === undefined) continue + out[k] = stripUndefined(v) + } + return out + } + return obj +} + +/** @param {string} text @returns {any} */ +function safeJson(text) { + try { + return JSON.parse(text) + } catch { + return null + } +} diff --git a/packages/email/src/templates/index.js b/packages/email/src/templates/index.js new file mode 100644 index 0000000..1ef6537 --- /dev/null +++ b/packages/email/src/templates/index.js @@ -0,0 +1,42 @@ +/** + * 模板注册表:name → render(data) => { subject, html, text }(纯函数)。 + * 加新模板 = 加 `templates/.js` + 这里登记一行,不改 provider、不改调用方。 + */ + +import * as verifyCode from './verify-code.js' +import * as videoReady from './video-ready.js' +import * as waitlistInvite from './waitlist-invite.js' +import * as orderNotice from './order-notice.js' +import { EmailError } from '../map-error.js' + +const REGISTRY = { + 'verify-code': verifyCode.render, + 'video-ready': videoReady.render, + 'waitlist-invite': waitlistInvite.render, + 'order-notice': orderNotice.render, +} + +/** + * 渲染指定模板。未知模板名抛 badTemplate;render 自身的缺字段错误透传。 + * @param {string} name + * @param {object} data + * @returns {{ subject: string, html: string, text: string }} + */ +export function renderTemplate(name, data) { + const render = REGISTRY[name] + if (!render) { + const known = Object.keys(REGISTRY).join(', ') + throw new EmailError({ + provider: 'template', + reason: 'badTemplate', + retryable: false, + message: `unknown template "${name}". known: ${known}`, + }) + } + return render(data) +} + +/** @returns {string[]} 已注册模板名 */ +export function knownTemplates() { + return Object.keys(REGISTRY) +} diff --git a/packages/email/src/templates/order-notice.js b/packages/email/src/templates/order-notice.js new file mode 100644 index 0000000..7063b7e --- /dev/null +++ b/packages/email/src/templates/order-notice.js @@ -0,0 +1,123 @@ +import { EmailError } from '../map-error.js' + +const COPY = Object.freeze({ + zh: { + purposes: { + order_paid: ['订单已完成', '我们已收到您的付款。'], + subscription_active: ['订阅已生效', '您的 VOKO 订阅现已生效。'], + subscription_canceled: ['订阅已取消', '您的 VOKO 订阅已取消。'], + order_refunded: ['订单已退款', '您的订单退款已处理。'], + order_disputed: ['订单正在审核', '您的订单存在争议,目前正在审核。'], + }, + order: '订单编号', product: '商品', amount: '金额', status: '状态', footer: '感谢您使用 VOKO。', + statuses: { pending: '待支付', paid: '已支付', refunded: '已退款', disputed: '争议处理中' }, + }, + en: { + purposes: { + order_paid: ['Your order is complete', 'We have received your payment.'], + subscription_active: ['Your subscription is active', 'Your VOKO subscription is now active.'], + subscription_canceled: ['Your subscription was canceled', 'Your VOKO subscription has been canceled.'], + order_refunded: ['Your order was refunded', 'Your order refund has been processed.'], + order_disputed: ['Your order is under review', 'A dispute was opened for your order and is now under review.'], + }, + order: 'Order', product: 'Product', amount: 'Amount', status: 'Status', footer: 'Thank you for using VOKO.', + statuses: { pending: 'Pending', paid: 'Paid', refunded: 'Refunded', disputed: 'Under review' }, + }, + ja: { + purposes: { + order_paid: ['ご注文が完了しました', 'お支払いを確認しました。'], + subscription_active: ['サブスクリプションが有効になりました', 'VOKOのサブスクリプションをご利用いただけます。'], + subscription_canceled: ['サブスクリプションが解約されました', 'VOKOのサブスクリプションが解約されました。'], + order_refunded: ['ご注文の返金が完了しました', 'ご注文の返金手続きが完了しました。'], + order_disputed: ['ご注文を確認中です', 'ご注文に対する異議申し立てがあり、現在確認中です。'], + }, + order: '注文番号', product: '商品', amount: '金額', status: 'ステータス', footer: 'VOKOをご利用いただきありがとうございます。', + statuses: { pending: '支払い待ち', paid: '支払い済み', refunded: '返金済み', disputed: '確認中' }, + }, + ko: { + purposes: { + order_paid: ['주문이 완료되었습니다', '결제가 확인되었습니다.'], + subscription_active: ['구독이 활성화되었습니다', 'VOKO 구독이 활성화되었습니다.'], + subscription_canceled: ['구독이 취소되었습니다', 'VOKO 구독이 취소되었습니다.'], + order_refunded: ['주문이 환불되었습니다', '주문 환불 처리가 완료되었습니다.'], + order_disputed: ['주문을 검토 중입니다', '주문에 대한 이의 제기가 접수되어 현재 검토 중입니다.'], + }, + order: '주문 번호', product: '상품', amount: '금액', status: '상태', footer: 'VOKO를 이용해 주셔서 감사합니다.', + statuses: { pending: '결제 대기', paid: '결제 완료', refunded: '환불 완료', disputed: '검토 중' }, + }, + es: { + purposes: { + order_paid: ['Tu pedido se ha completado', 'Hemos recibido tu pago.'], + subscription_active: ['Tu suscripción está activa', 'Tu suscripción a VOKO ya está activa.'], + subscription_canceled: ['Tu suscripción fue cancelada', 'Tu suscripción a VOKO ha sido cancelada.'], + order_refunded: ['Tu pedido fue reembolsado', 'El reembolso de tu pedido ha sido procesado.'], + order_disputed: ['Tu pedido está en revisión', 'Se ha abierto una disputa sobre tu pedido y está en revisión.'], + }, + order: 'Pedido', product: 'Producto', amount: 'Importe', status: 'Estado', footer: 'Gracias por usar VOKO.', + statuses: { pending: 'Pendiente', paid: 'Pagado', refunded: 'Reembolsado', disputed: 'En revisión' }, + }, + pt: { + purposes: { + order_paid: ['Seu pedido foi concluído', 'Recebemos seu pagamento.'], + subscription_active: ['Sua assinatura está ativa', 'Sua assinatura do VOKO está ativa.'], + subscription_canceled: ['Sua assinatura foi cancelada', 'Sua assinatura do VOKO foi cancelada.'], + order_refunded: ['Seu pedido foi reembolsado', 'O reembolso do seu pedido foi processado.'], + order_disputed: ['Seu pedido está em análise', 'Uma contestação foi aberta para seu pedido, que agora está em análise.'], + }, + order: 'Pedido', product: 'Produto', amount: 'Valor', status: 'Status', footer: 'Obrigado por usar o VOKO.', + statuses: { pending: 'Pendente', paid: 'Pago', refunded: 'Reembolsado', disputed: 'Em análise' }, + }, +}) + +export function render(data) { + if (!data || typeof data !== 'object') fail('data is required') + const copy = COPY[data.locale] + if (!copy) fail(`unsupported locale: ${String(data.locale)}`) + const purposeCopy = copy.purposes[data.purpose] + if (!purposeCopy) fail(`unsupported purpose: ${String(data.purpose)}`) + const orderId = requiredText(data.orderId, 'orderId') + const productId = requiredText(data.productId, 'productId') + const status = copy.statuses[data.status] + if (!status) fail(`unsupported status: ${String(data.status)}`) + const amount = optionalText(data.amount) + const [subject, intro] = purposeCopy + const details = [ + `${copy.order}: ${orderId}`, + `${copy.product}: ${productId}`, + amount ? `${copy.amount}: ${amount}` : null, + `${copy.status}: ${status}`, + ].filter(Boolean) + const text = [subject, '', intro, '', ...details, '', copy.footer].join('\n') + const html = [ + '', + `

${escapeHtml(subject)}

`, + `

${escapeHtml(intro)}

`, + ...details.map(detail => `

${escapeHtml(detail)}

`), + `

${escapeHtml(copy.footer)}

`, + '', + ].join('') + return { subject, html, text } +} + +function requiredText(value, field) { + if (typeof value === 'string' && value.trim()) return value.trim() + fail(`missing field "${field}"`) +} + +function optionalText(value) { + if (value === undefined || value === null || value === '') return '' + return requiredText(value, 'amount') +} + +function fail(message) { + throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: `order-notice: ${message}` }) +} + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} diff --git a/packages/email/src/templates/verify-code.js b/packages/email/src/templates/verify-code.js new file mode 100644 index 0000000..14cc329 --- /dev/null +++ b/packages/email/src/templates/verify-code.js @@ -0,0 +1,103 @@ +/** + * 验证码模板(纯函数):data → { subject, html, text }。 + * 本模板不生成、不校验验证码——只渲染 auth 传入的 `code` 与 `ttlMin`。 + * + * @typedef {Object} VerifyCodeData + * @property {string} code 验证码(auth 生成) + * @property {number} ttlMin 有效期(分钟) + * @property {'en'|'zh'|'ja'|'ko'|'es'|'pt'} locale + */ + +import { EmailError } from '../map-error.js' + +const COPY = { + en: { + subject: (code) => `VOKO verification code: ${code}`, + heading: 'Verify your VOKO email', + intro: 'Use the code below to continue. Do not share it with anyone.', + ttl: (min) => `This code expires in ${min} minute${min === 1 ? '' : 's'}.`, + ignore: 'If you did not request this, you can safely ignore this email.', + }, + zh: { + subject: (code) => `VOKO 验证码:${code}`, + heading: '验证您的 VOKO 邮箱', + intro: '请使用下方验证码继续操作,请勿向任何人泄露。', + ttl: (min) => `该验证码将在 ${min} 分钟后失效。`, + ignore: '如果这不是您本人的操作,请忽略本邮件。', + }, + ja: { + subject: (code) => `VOKO認証コード:${code}`, + heading: 'VOKOのメールアドレスを認証', + intro: '続行するには、以下のコードを使用してください。他の人と共有しないでください。', + ttl: (min) => `このコードは${min}分後に無効になります。`, + ignore: 'このリクエストに心当たりがない場合は、このメールを無視してください。', + }, + ko: { + subject: (code) => `VOKO 인증 코드: ${code}`, + heading: 'VOKO 이메일 인증', + intro: '계속하려면 아래 코드를 사용하세요. 다른 사람과 공유하지 마세요.', + ttl: (min) => `이 코드는 ${min}분 후에 만료됩니다.`, + ignore: '본인이 요청하지 않았다면 이 이메일을 무시하세요.', + }, + es: { + subject: (code) => `Código de verificación de VOKO: ${code}`, + heading: 'Verifica tu correo de VOKO', + intro: 'Usa el siguiente código para continuar. No lo compartas con nadie.', + ttl: (min) => `Este código caduca en ${min} minuto${min === 1 ? '' : 's'}.`, + ignore: 'Si no solicitaste este código, puedes ignorar este correo.', + }, + pt: { + subject: (code) => `Código de verificação do VOKO: ${code}`, + heading: 'Verifique seu e-mail do VOKO', + intro: 'Use o código abaixo para continuar. Não o compartilhe com ninguém.', + ttl: (min) => `Este código expira em ${min} minuto${min === 1 ? '' : 's'}.`, + ignore: 'Se você não solicitou este código, ignore este e-mail.', + }, +} + +/** + * @param {VerifyCodeData} data + * @returns {{ subject: string, html: string, text: string }} + */ +export function render(data) { + if (!data || typeof data !== 'object') { + throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: 'verify-code: data required' }) + } + const { code, ttlMin } = data + if (typeof code !== 'string' || code.trim() === '') { + throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: 'verify-code: missing field "code"' }) + } + if (typeof ttlMin !== 'number' || !Number.isFinite(ttlMin) || ttlMin <= 0) { + throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: 'verify-code: missing or invalid field "ttlMin"' }) + } + + const c = COPY[data.locale] + if (!c) { + throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: `verify-code: unsupported locale: ${String(data.locale)}` }) + } + + const subject = c.subject(code) + const ttlLine = c.ttl(ttlMin) + + const text = [c.heading, '', c.intro, '', code, '', ttlLine, '', c.ignore].join('\n') + + const html = [ + '', + `

${esc(c.heading)}

`, + `

${esc(c.intro)}

`, + `

${esc(code)}

`, + `

${esc(ttlLine)}

`, + `

${esc(c.ignore)}

`, + '', + ].join('') + + return { subject, html, text } +} + +function esc(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/packages/email/src/templates/video-ready.js b/packages/email/src/templates/video-ready.js new file mode 100644 index 0000000..f75a003 --- /dev/null +++ b/packages/email/src/templates/video-ready.js @@ -0,0 +1,117 @@ +import { EmailError } from '../map-error.js' + +const COPY = Object.freeze({ + 'zh-CN': { + subject: title => `您的 VOKO 视频已生成:${title}`, + heading: '您的视频已生成', + intro: '视频和多语言文案均已处理完成。', + title: '视频标题', + summary: '视频简介', + watch: '观看视频', + footer: '感谢您使用 VOKO。', + }, + en: { + subject: title => `Your VOKO video is ready: ${title}`, + heading: 'Your video is ready', + intro: 'Your video and localized details are ready.', + title: 'Video title', + summary: 'Video description', + watch: 'Watch your video', + footer: 'Thank you for using VOKO.', + }, + ja: { + subject: title => `VOKOの動画が完成しました:${title}`, + heading: '動画が完成しました', + intro: '動画と多言語コンテンツの準備が完了しました。', + title: '動画タイトル', + summary: '動画の紹介', + watch: '動画を見る', + footer: 'VOKOをご利用いただきありがとうございます。', + }, + ko: { + subject: title => `VOKO 동영상이 준비되었습니다: ${title}`, + heading: '동영상이 준비되었습니다', + intro: '동영상과 다국어 콘텐츠 처리가 모두 완료되었습니다.', + title: '동영상 제목', + summary: '동영상 소개', + watch: '동영상 보기', + footer: 'VOKO를 이용해 주셔서 감사합니다.', + }, + es: { + subject: title => `Tu video de VOKO está listo: ${title}`, + heading: 'Tu video está listo', + intro: 'Tu video y sus textos localizados ya están listos.', + title: 'Título del video', + summary: 'Descripción del video', + watch: 'Ver tu video', + footer: 'Gracias por usar VOKO.', + }, + pt: { + subject: title => `Seu vídeo do VOKO está pronto: ${title}`, + heading: 'Seu vídeo está pronto', + intro: 'Seu vídeo e os textos localizados estão prontos.', + title: 'Título do vídeo', + summary: 'Descrição do vídeo', + watch: 'Assistir ao vídeo', + footer: 'Obrigado por usar o VOKO.', + }, +}) + +/** + * @param {{ locale: string, videoTitle: string, videoSummary: string, videoUrl: string }} data + */ +export function render(data) { + if (!data || typeof data !== 'object') fail('data is required') + const copy = COPY[data.locale] + if (!copy) fail(`unsupported locale: ${String(data.locale)}`) + const videoTitle = requiredText(data.videoTitle, 'videoTitle') + const videoSummary = requiredText(data.videoSummary, 'videoSummary') + const videoUrl = requiredUrl(data.videoUrl) + const subject = copy.subject(videoTitle) + const text = [ + copy.heading, + '', + copy.intro, + '', + `${copy.title}: ${videoTitle}`, + `${copy.summary}: ${videoSummary}`, + `${copy.watch}: ${videoUrl}`, + '', + copy.footer, + ].join('\n') + const html = [ + '', + `

${escapeHtml(copy.heading)}

`, + `

${escapeHtml(copy.intro)}

`, + `

${escapeHtml(copy.title)}: ${escapeHtml(videoTitle)}

`, + `

${escapeHtml(copy.summary)}: ${escapeHtml(videoSummary)}

`, + `

${escapeHtml(copy.watch)}

`, + `

${escapeHtml(copy.footer)}

`, + '', + ].join('') + return { subject, html, text } +} + +function requiredText(value, field) { + if (typeof value === 'string' && value.trim().length > 0) return value.trim() + fail(`missing field "${field}"`) +} + +function requiredUrl(value) { + const url = requiredText(value, 'videoUrl') + if (new URL(url).protocol === 'https:') return url + fail('videoUrl must use https') +} + +function fail(message) { + throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: `video-ready: ${message}` }) +} + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} diff --git a/packages/email/src/templates/waitlist-invite.js b/packages/email/src/templates/waitlist-invite.js new file mode 100644 index 0000000..c7209c9 --- /dev/null +++ b/packages/email/src/templates/waitlist-invite.js @@ -0,0 +1,90 @@ +import { EmailError } from '../map-error.js' + +const COPY = Object.freeze({ + en: { + subject: 'Your VOKO invite', + heading: 'Your VOKO invite is ready', + intro: 'Use the link below to finish registration.', + action: 'Finish registration', + warning: 'This invite link expires and should not be shared.', + }, + zh: { + subject: '您的 VOKO 邀请', + heading: '您的 VOKO 邀请已准备就绪', + intro: '请使用下方链接完成注册。', + action: '完成注册', + warning: '该邀请链接会过期,请勿分享给他人。', + }, + ja: { + subject: 'VOKOへの招待', + heading: 'VOKOへの招待の準備ができました', + intro: '以下のリンクから登録を完了してください。', + action: '登録を完了', + warning: 'この招待リンクには有効期限があります。他の人と共有しないでください。', + }, + ko: { + subject: 'VOKO 초대', + heading: 'VOKO 초대가 준비되었습니다', + intro: '아래 링크를 사용해 가입을 완료하세요.', + action: '가입 완료', + warning: '이 초대 링크는 만료되며 다른 사람과 공유하면 안 됩니다.', + }, + es: { + subject: 'Tu invitación a VOKO', + heading: 'Tu invitación a VOKO está lista', + intro: 'Usa el siguiente enlace para completar el registro.', + action: 'Completar registro', + warning: 'Este enlace de invitación caduca y no debe compartirse.', + }, + pt: { + subject: 'Seu convite para o VOKO', + heading: 'Seu convite para o VOKO está pronto', + intro: 'Use o link abaixo para concluir o cadastro.', + action: 'Concluir cadastro', + warning: 'Este link de convite expira e não deve ser compartilhado.', + }, +}) + +/** + * @param {{ locale: string, inviteUrl: string }} data + */ +export function render(data) { + if (!data || typeof data !== 'object') fail('data is required') + const copy = COPY[data.locale] + if (!copy) fail(`unsupported locale: ${String(data.locale)}`) + const inviteUrl = requiredHttpsUrl(data.inviteUrl) + const text = [copy.heading, '', copy.intro, inviteUrl, '', copy.warning].join('\n') + const html = [ + '', + `

${escapeHtml(copy.heading)}

`, + `

${escapeHtml(copy.intro)}

`, + `

${escapeHtml(copy.action)}

`, + `

${escapeHtml(copy.warning)}

`, + '', + ].join('') + return { subject: copy.subject, text, html } +} + +function requiredHttpsUrl(value) { + if (typeof value !== 'string' || value.trim() === '') fail('missing field "inviteUrl"') + const url = value.trim() + try { + if (new URL(url).protocol === 'https:') return url + } catch (error) { + fail(`inviteUrl is invalid: ${error instanceof Error ? error.message : String(error)}`) + } + fail('inviteUrl must use https') +} + +function fail(message) { + throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: `waitlist-invite: ${message}` }) +} + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} diff --git a/packages/email/src/validate.js b/packages/email/src/validate.js new file mode 100644 index 0000000..d5cfdbf --- /dev/null +++ b/packages/email/src/validate.js @@ -0,0 +1,56 @@ +/** + * 投递前基本校验(纯函数,零网络)。 + * 只做「畸形请求拦截」:非法邮箱、空 subject。 + * 不做业务规则(验证码、退订等不属于本包)。 + */ + +import { EmailError } from './map-error.js' + +// 务实的邮箱形态校验:localpart@domain.tld,不含空白,domain 至少一个点。 +// 不追求 RFC 5322 全量正确(那是投递通道的事),只拦明显畸形。 +const EMAIL_RE = /^[^\s@"']+@[^\s@.]+(\.[^\s@.]+)+$/ + +/** + * @param {string} addr + * @returns {boolean} + */ +export function isValidEmail(addr) { + return typeof addr === 'string' && addr.length <= 254 && EMAIL_RE.test(addr) +} + +/** + * 校验一条 SendMessage 的收件人与 subject。非法即抛 EmailError。 + * @param {import('./index.js').SendMessage} msg + * @param {string} provider 用于错误归属 + * @returns {{ to: string|string[] }} + */ +export function assertSendable(msg, provider) { + if (!msg || typeof msg !== 'object') { + throw new EmailError({ provider, reason: 'invalidRecipient', retryable: false, message: 'message must be an object' }) + } + + const recipients = msg.to == null ? [] : (Array.isArray(msg.to) ? msg.to : [msg.to]) + if (recipients.length === 0) { + throw new EmailError({ provider, reason: 'invalidRecipient', retryable: false, message: 'no recipient' }) + } + for (const r of recipients) { + if (!isValidEmail(r)) { + throw new EmailError({ + provider, + reason: 'invalidRecipient', + retryable: false, + message: `invalid recipient address: ${String(r)}`, + }) + } + } + + if (typeof msg.subject !== 'string' || msg.subject.trim() === '') { + throw new EmailError({ provider, reason: 'invalidRecipient', retryable: false, message: 'empty subject' }) + } + + if ((msg.html == null || msg.html === '') && (msg.text == null || msg.text === '')) { + throw new EmailError({ provider, reason: 'invalidRecipient', retryable: false, message: 'message has no html or text body' }) + } + + return { to: msg.to } +} diff --git a/packages/email/test/map-error.test.js b/packages/email/test/map-error.test.js new file mode 100644 index 0000000..c25c80f --- /dev/null +++ b/packages/email/test/map-error.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { EmailError, mapError, classify } from '../src/map-error.js' + +describe('classify (vendor error -> reason)', () => { + it('429 -> transient', () => { + expect(classify({ statusCode: 429 })).toBe('transient') + expect(classify({ responseCode: 429 })).toBe('transient') + }) + + it('5xx -> transient', () => { + expect(classify({ statusCode: 500 })).toBe('transient') + expect(classify({ statusCode: 503 })).toBe('transient') + }) + + it('401/403 -> auth', () => { + expect(classify({ statusCode: 401 })).toBe('auth') + expect(classify({ statusCode: 403 })).toBe('auth') + }) + + it('SMTP codes', () => { + expect(classify({ code: 'EAUTH' })).toBe('auth') + expect(classify({ code: 'EENVELOPE' })).toBe('invalidRecipient') + expect(classify({ code: 'ETIMEDOUT' })).toBe('transient') + expect(classify({ code: 'ECONNECTION' })).toBe('transient') + expect(classify({ code: 'ESOCKET' })).toBe('transient') + }) + + it('SES error names', () => { + expect(classify({ name: 'Throttling' })).toBe('transient') + expect(classify({ name: 'TooManyRequestsException' })).toBe('transient') + expect(classify({ name: 'ServiceUnavailable' })).toBe('transient') + expect(classify({ name: 'InvalidClientTokenId' })).toBe('auth') + expect(classify({ name: 'SignatureDoesNotMatch' })).toBe('auth') + expect(classify({ name: 'AccessDenied' })).toBe('auth') + }) + + it('falls back to message heuristics', () => { + expect(classify({ message: 'Request timed out' })).toBe('transient') + expect(classify({ message: 'rate limit exceeded' })).toBe('transient') + expect(classify({ message: 'invalid credential' })).toBe('auth') + expect(classify({ message: 'No recipients defined' })).toBe('invalidRecipient') + }) + + it('unknown by default', () => { + expect(classify({})).toBe('unknown') + expect(classify(null)).toBe('unknown') + expect(classify('oops')).toBe('unknown') + }) +}) + +describe('mapError', () => { + it('passes EmailError through untouched', () => { + const e = new EmailError({ provider: 'ses', reason: 'auth', retryable: false }) + expect(mapError(e, 'ses')).toBe(e) + }) + + it('wraps vendor error and sets retryable from reason', () => { + const wrapped = mapError({ statusCode: 503, message: 'boom' }, 'ses') + expect(wrapped).toBeInstanceOf(EmailError) + expect(wrapped.provider).toBe('ses') + expect(wrapped.reason).toBe('transient') + expect(wrapped.retryable).toBe(true) + expect(wrapped.cause).toBeTruthy() + }) + + it('auth / invalidRecipient are not retryable', () => { + expect(mapError({ code: 'EAUTH' }, 'nodemailer').retryable).toBe(false) + expect(mapError({ code: 'EENVELOPE' }, 'nodemailer').retryable).toBe(false) + }) + + it('unknown is not retryable', () => { + expect(mapError({}, 'fake').retryable).toBe(false) + }) +}) diff --git a/packages/email/test/order-notice.test.js b/packages/email/test/order-notice.test.js new file mode 100644 index 0000000..50818ea --- /dev/null +++ b/packages/email/test/order-notice.test.js @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { render } from '../src/templates/order-notice.js' + +const LOCALES = ['en', 'zh', 'ja', 'ko', 'es', 'pt'] +const PURPOSES = ['order_paid', 'subscription_active', 'subscription_canceled', 'order_refunded', 'order_disputed'] + +describe('order notice email locale coverage', () => { + it.each(LOCALES.flatMap(locale => PURPOSES.map(purpose => [locale, purpose])))('renders %s %s', (locale, purpose) => { + const message = render({ + locale, + purpose, + orderId: 'ord_123', + productId: 'credits_100', + amount: 'USD 9.99', + status: 'paid', + }) + expect(message.subject).not.toBe('') + expect(message.text).toContain('ord_123') + expect(message.html).toContain('credits_100') + }) + + it('rejects unsupported locales and purposes', () => { + const data = { orderId: 'ord_123', productId: 'credits_100', status: 'paid' } + expect(() => render({ ...data, locale: 'id', purpose: 'order_paid' })).toThrow('unsupported locale: id') + expect(() => render({ ...data, locale: 'en', purpose: 'unknown' })).toThrow('unsupported purpose: unknown') + }) +}) diff --git a/packages/email/test/transactional-locales.test.js b/packages/email/test/transactional-locales.test.js new file mode 100644 index 0000000..8cba0c2 --- /dev/null +++ b/packages/email/test/transactional-locales.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { render as renderVerifyCode } from '../src/templates/verify-code.js' +import { render as renderWaitlistInvite } from '../src/templates/waitlist-invite.js' + +const SITE_LOCALES = ['en', 'zh', 'ja', 'ko', 'es', 'pt'] + +describe('transactional email locale coverage', () => { + it.each(SITE_LOCALES)('renders the %s verification code email', locale => { + const message = renderVerifyCode({ locale, code: '123456', ttlMin: 10 }) + expect(message.subject).toContain('123456') + expect(message.text).toContain('123456') + expect(message.html).toContain('123456') + }) + + it.each(SITE_LOCALES)('renders the %s waitlist invitation email', locale => { + const message = renderWaitlistInvite({ + locale, + inviteUrl: 'https://voko.world/invite/ABC12345', + }) + expect(message.text).toContain('https://voko.world/invite/ABC12345') + expect(message.html).toContain('https://voko.world/invite/ABC12345') + }) + + it('rejects missing and unsupported locales instead of silently using English', () => { + expect(() => renderVerifyCode({ code: '123456', ttlMin: 10 })).toThrow('unsupported locale: undefined') + expect(() => renderVerifyCode({ locale: 'id', code: '123456', ttlMin: 10 })).toThrow('unsupported locale: id') + expect(() => renderWaitlistInvite({ locale: 'id', inviteUrl: 'https://voko.world/invite/ABC12345' })).toThrow('unsupported locale: id') + }) +}) diff --git a/packages/email/test/video-ready.test.js b/packages/email/test/video-ready.test.js new file mode 100644 index 0000000..140325d --- /dev/null +++ b/packages/email/test/video-ready.test.js @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { render } from '../src/templates/video-ready.js' + +const SITE_LOCALES = ['zh-CN', 'en', 'ja', 'ko', 'es', 'pt'] + +describe('video-ready email locale coverage', () => { + it.each(SITE_LOCALES)('renders the %s notification', locale => { + const message = render({ + locale, + videoTitle: 'Localized title', + videoSummary: 'Localized summary', + videoUrl: 'https://voko.world/video-center.html', + }) + + expect(message.subject).toContain('Localized title') + expect(message.text).toContain('Localized summary') + expect(message.html).toContain('https://voko.world/video-center.html') + }) + + it('rejects Indonesian because it is not a visual-game site locale', () => { + expect(() => render({ + locale: 'id', + videoTitle: 'Title', + videoSummary: 'Summary', + videoUrl: 'https://voko.world/video-center.html', + })).toThrow('video-ready: unsupported locale: id') + }) +}) diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 0000000..888ec9c --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,17 @@ +{ + "name": "@drama/i18n", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run", + "check:coverage": "node scripts/check-coverage.js" + }, + "peerDependencies": { + "vue": "^3.5.0", + "vue-i18n": "^11.0.0" + } +} diff --git a/packages/i18n/scripts/check-coverage.js b/packages/i18n/scripts/check-coverage.js new file mode 100644 index 0000000..e5bcb59 --- /dev/null +++ b/packages/i18n/scripts/check-coverage.js @@ -0,0 +1,79 @@ +/** + * check-coverage.js —— 英语第一公民门禁。 + * 以 `en` 为基准列出各语言缺失 key + 私有(多余)key。 + * CI 用:`en` 自身零缺失为硬约束(en 是基准,无对照故恒为空); + * 其余语言「缺失 key」一期允许非空(仅告警);「私有 key」(en 没有的 key)任何语言出现都判失败——防 key 树漂移。 + * + * 退出码:发现任何语言有私有 key → 退出码 1(key 树漂移,硬失败)。否则 0。 + * 缺失 key 仅打印告警,不改退出码(一期允许部分语言不全)。 + */ +import { messages } from '../src/messages/index.js' +import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../src/locales.js' + +/** + * 把嵌套 message 对象拍平成点路径 key 集合(叶子为字符串)。 + * @param {Record} obj + * @param {string} [prefix] + * @returns {Set} + */ +export function flattenKeys(obj, prefix = '') { + /** @type {Set} */ + const out = new Set() + for (const [k, v] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${k}` : k + if (v && typeof v === 'object' && !Array.isArray(v)) { + for (const nested of flattenKeys(/** @type {Record} */ (v), path)) { + out.add(nested) + } + } else { + out.add(path) + } + } + return out +} + +/** + * 以 en 为基准计算各语言的缺失 / 私有 key。 + * @returns {{ baseKeyCount:number, perLocale: Record }} + */ +export function computeCoverage() { + const baseKeys = flattenKeys(messages[DEFAULT_LOCALE]) + /** @type {Record} */ + const perLocale = {} + for (const code of SUPPORTED_LOCALES) { + const keys = flattenKeys(messages[code]) + const missing = [...baseKeys].filter((k) => !keys.has(k)) + const extra = [...keys].filter((k) => !baseKeys.has(k)) + perLocale[code] = { missing, extra } + } + return { baseKeyCount: baseKeys.size, perLocale } +} + +function main() { + const { baseKeyCount, perLocale } = computeCoverage() + let hasDrift = false + console.log(`[i18n coverage] 基准 en 共 ${baseKeyCount} 个 key`) + for (const code of SUPPORTED_LOCALES) { + const { missing, extra } = perLocale[code] + if (extra.length > 0) { + hasDrift = true + console.error(` ✗ ${code} 含 ${extra.length} 个私有 key(en 无): ${extra.join(', ')}`) + } + if (missing.length > 0 && code !== DEFAULT_LOCALE) { + console.warn(` ◐ ${code} 缺失 ${missing.length} 个 key: ${missing.join(', ')}`) + } + if (missing.length === 0 && extra.length === 0) { + console.log(` ✓ ${code} 完整且同构`) + } + } + if (perLocale[DEFAULT_LOCALE].missing.length > 0) { + hasDrift = true + console.error(` ✗ en 基准缺失 key(不应发生): ${perLocale[DEFAULT_LOCALE].missing.join(', ')}`) + } + process.exit(hasDrift ? 1 : 0) +} + +// 直接执行时跑门禁;被 import 时只暴露纯函数。 +if (import.meta.url === `file://${process.argv[1]}`) { + main() +} diff --git a/packages/i18n/src/index.js b/packages/i18n/src/index.js new file mode 100644 index 0000000..2891e2f --- /dev/null +++ b/packages/i18n/src/index.js @@ -0,0 +1,7 @@ +/** + * `@drama/i18n` 公共面 —— 唯一对外入口。 + * 只导出契约符号:工厂 + 枚举常量 + locale 元数据。 + * 语言资源(`messages/*`)为内部聚合,禁止深导入。 + */ +export { createI18n } from './runtime.js' +export { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_META } from './locales.js' diff --git a/packages/i18n/src/locales.js b/packages/i18n/src/locales.js new file mode 100644 index 0000000..26ea1fe --- /dev/null +++ b/packages/i18n/src/locales.js @@ -0,0 +1,41 @@ +/** + * Locale 元数据 —— 供 app 渲染语言切换器。 + * 顺序:英语第一公民(`en` 居首,是基准/fallback)。 + * + * @typedef {'en'|'zh'|'ja'|'ko'|'es'|'pt'} LocaleCode + * @typedef {{ code:LocaleCode, label:string, nativeLabel:string, dir:'ltr'|'rtl' }} LocaleMeta + */ + +/** + * 支持语言枚举。与 api-sdk 的 `x-locale` 取值、后端约定共用同一套枚举。 + * `en` 为基准/fallback(英语第一公民)。 + * @type {LocaleCode[]} + */ +export const SUPPORTED_LOCALES = ['en', 'zh', 'ja', 'ko', 'es', 'pt'] + +/** 默认 locale(缺省初值与 fallback)。 @type {LocaleCode} */ +export const DEFAULT_LOCALE = 'en' + +/** + * 6 语言元数据。`label` 为英文显示名(app 在英语 UI 下用), + * `nativeLabel` 为该语言自称(语言切换器优先展示),`dir` 书写方向。 + * 当前 6 语言均 `ltr`,结构预留 `rtl` 以便后续接入阿拉伯语等。 + * @type {LocaleMeta[]} + */ +export const LOCALE_META = [ + { code: 'en', label: 'English', nativeLabel: 'English', dir: 'ltr' }, + { code: 'zh', label: 'Chinese', nativeLabel: '中文', dir: 'ltr' }, + { code: 'ja', label: 'Japanese', nativeLabel: '日本語', dir: 'ltr' }, + { code: 'ko', label: 'Korean', nativeLabel: '한국어', dir: 'ltr' }, + { code: 'es', label: 'Spanish', nativeLabel: 'Español', dir: 'ltr' }, + { code: 'pt', label: 'Portuguese', nativeLabel: 'Português', dir: 'ltr' }, +] + +/** + * 判断给定值是否为受支持的 locale 码。 + * @param {unknown} code + * @returns {code is LocaleCode} + */ +export function isSupportedLocale(code) { + return typeof code === 'string' && SUPPORTED_LOCALES.includes(/** @type {LocaleCode} */ (code)) +} diff --git a/packages/i18n/src/messages/en/account.js b/packages/i18n/src/messages/en/account.js new file mode 100644 index 0000000..d21dc27 --- /dev/null +++ b/packages/i18n/src/messages/en/account.js @@ -0,0 +1,88 @@ +/** account.* —— 个人中心(资料 / 积分 / 订阅 / 历史 / 收藏 / 设置)。基准语言 en。 */ +export default { + pageTitle: 'Profile', + backToApp: 'Back to App', + + header: { + memberSince: 'Member since {date}', + editAvatar: 'Edit profile photo', + vip: 'VIP', + guestName: 'Guest', + }, + + stats: { + credits: 'Available Credits', + watched: 'Dramas Watched', + rewritten: 'Stories Rewritten', + }, + + history: { + title: 'Continue Watching', + pageTitle: 'Watch History', + subtitle: 'Pick up where you left off.', + viewAll: 'View All', + empty: 'Nothing in progress yet. Pick a drama to start watching.', + browse: 'Browse dramas', + episodeLine: 'Ep {ep} • {left} left', + resume: 'Resume {title}', + }, + + favorites: { + title: 'Saved Dramas', + pageTitle: 'Saved Dramas', + subtitle: 'Dramas you bookmarked for later.', + manage: 'Manage', + empty: 'No saved dramas yet. Bookmark dramas to find them here.', + browse: 'Browse dramas', + discoverMore: 'Discover More', + open: 'Open {title}', + }, + + back: 'Back to profile', + + preferences: { + title: 'Preferences', + language: 'Language', + theme: 'Theme', + light: 'Light', + dark: 'Dark', + }, + + subscription: { + renews: 'Renews on {date}', + inactive: 'No active subscription', + pastDue: 'Payment past due', + topUp: 'Top-up Credits', + manage: 'Manage Subscription', + paymentMethod: 'Payment Method', + upgrade: 'Upgrade plan', + tierFree: 'Free plan', + }, + + account: { + title: 'Account', + notifications: 'Notifications', + privacy: "Privacy {'@'} Data", + downloadData: 'Download data', + deleteData: 'Delete data', + signOut: 'Sign out', + }, + + actions: { + notificationsSoon: 'Notification settings are coming soon.', + downloadStarted: 'Preparing your data export. We will email you a link.', + deleteTitle: 'Delete your data?', + deleteBody: 'This permanently removes your account data and cannot be undone.', + deleteConfirm: 'Delete data', + deleteCancel: 'Keep my data', + deleteRequested: 'Data deletion requested. We will process it shortly.', + signedOut: 'You have been signed out.', + languageChanged: 'Language updated.', + }, + + points: { + dailyClaimTitle: 'Daily points', dailyClaimAvailable: 'Claim {points} points today.', dailyClaimed: 'Today\'s points have been claimed.', claim: 'Claim points', claimed: 'Claimed', nextAvailable: 'Available again {time}', balanceTurns: '{points} points · {turns} playable turns', + }, + loading: 'Loading your profile…', + loadError: 'We could not load your profile. Please try again.', +} diff --git a/packages/i18n/src/messages/en/auth.js b/packages/i18n/src/messages/en/auth.js new file mode 100644 index 0000000..9f8361a --- /dev/null +++ b/packages/i18n/src/messages/en/auth.js @@ -0,0 +1,72 @@ +/** auth.* —— 登录 / 注册 / 身份合并(路由 /login /register)。基准语言 en。 */ +export default { + login: { + title: 'Welcome back', + subtitle: 'Enter your details to access your account.', + submit: 'Log in', + submitting: 'Signing in…', + submitError: 'Could not sign you in. Check your details and try again.', + email: 'Email address', + emailPlaceholder: "name{'@'}example.com", + emailRequired: 'Email is required.', + invalidEmail: 'Please enter a valid email.', + password: 'Password', + passwordRequired: 'Password is required.', + forgot: 'Forgot password?', + showPassword: 'Show password', + hidePassword: 'Hide password', + }, + register: { + title: 'Create your account', + subtitle: 'Verify your email to get started.', + submit: 'Sign up', + haveAccount: 'Already have an account?', + codeSent: 'Verification code sent. Check your inbox.', + }, + // 分段切换与社交登录(设计稿 auth)。 + segment: { + signIn: 'Sign in', + createAccount: 'Create account', + }, + social: { + divider: 'Or continue with', + google: 'Continue with Google', + googleEnglish: 'Continue with Google', + email: 'Continue with email', + apple: 'Continue with Apple', + }, + // 忘记密码:发起重置验证码到邮箱。 + forgot: { + title: 'Reset your password', + body: 'Enter the email tied to your account and we will send you a reset code.', + emailLabel: 'Email address', + submit: 'Send reset code', + sent: 'Reset code sent. Check your inbox.', + needEmail: 'Please enter a valid email.', + error: 'Could not send the reset code. Please try again.', + }, + legal: { + agree: 'By continuing you agree to our', + terms: 'Terms', + and: 'and', + privacy: 'Privacy Policy', + termsTitle: 'Terms of Service', + privacyTitle: 'Privacy Policy', + termsBody: + 'By using IdearRun you agree to use the service for lawful, personal entertainment only. Interactive dramas and the storylines you generate are licensed to you for viewing within the app and may not be redistributed. We may update these terms as the product evolves; continued use means you accept the latest version.', + privacyBody: + 'We collect the minimum data needed to run IdearRun: your account email, viewing history, and interaction choices used to personalize recommendations. We never sell your personal data. Anonymous viewing is merged into your account only when you choose to register. You can request export or deletion of your data at any time.', + }, + hero: { + brand: 'IdearRun', + titleA: 'Step into the story.', + titleB: 'Rewrite the ending.', + body: 'Join the next generation of interactive drama. Your choices shape the narrative in real-time.', + }, + convert: { + title: 'Save your progress', + body: 'Sign up to keep watching {name} and unlock all episodes.', + }, + logout: 'Log out', + guest: 'Continue as guest', +} diff --git a/packages/i18n/src/messages/en/common.js b/packages/i18n/src/messages/en/common.js new file mode 100644 index 0000000..86ec696 --- /dev/null +++ b/packages/i18n/src/messages/en/common.js @@ -0,0 +1,37 @@ +/** common.* —— 跨页面通用文案(按钮 / 状态 / 通用提示)。基准语言 en。 */ +export default { + appName: 'IdearRun', + ok: 'OK', + cancel: 'Cancel', + confirm: 'Confirm', + back: 'Back', + next: 'Next', + retry: 'Retry', + loading: 'Loading…', + save: 'Save', + close: 'Close', + error: { + generic: 'Something went wrong. Please try again.', + network: 'Network error. Check your connection.', + }, + // 顶部导航(NavBar)。 + nav: { + explore: 'Explore', + library: 'Library', + pricing: 'Pricing', + login: 'Log in', + getStarted: 'Get started', + profile: 'Profile', + logout: 'Log out', + }, + // 主题切换(NavBar 控件 + 无障碍标签)。 + theme: { + toLight: 'Switch to light theme', + toDark: 'Switch to dark theme', + }, + // 语言切换(NavBar 控件)。 + language: { + label: 'Language', + options: { en: 'English', zh: 'Chinese', ja: 'Japanese', ko: 'Korean', es: 'Spanish', pt: 'Portuguese' }, + }, +} diff --git a/packages/i18n/src/messages/en/drama.js b/packages/i18n/src/messages/en/drama.js new file mode 100644 index 0000000..3b19b7d --- /dev/null +++ b/packages/i18n/src/messages/en/drama.js @@ -0,0 +1,60 @@ +/** drama.* —— 剧详情页(封面 / 简介 / 剧集列表 / 演员 / 相关推荐 / 开播 CTA)。基准语言 en。 */ +export default { + loading: 'Loading drama…', + error: 'Could not load this drama.', + retry: 'Try again', + notFound: 'This drama is no longer available.', + breadcrumb: { + library: 'Library', + }, + badge: { + interactive: 'Interactive', + }, + meta: { + episodes: '{count} Episodes', + perEpisode: '~{minutes} min each', + rating: '{score}', + plays: '{count} Plays', + }, + hero: { + start: 'Start Watching', + resume: 'Resume', + favorite: 'Favorite', + favorited: 'In Favorites', + share: 'Share', + shareCopied: 'Link copied to clipboard', + favoriteAdded: 'Added to your favorites', + favoriteRemoved: 'Removed from your favorites', + }, + episodes: { + title: 'Episodes', + season: 'Season {number}', + label: 'Episode {number}', + viewAll: 'View all {count} episodes', + collapse: 'Show fewer episodes', + empty: 'Episodes for this drama are being prepared.', + locked: 'Unlock to watch', + }, + interactivity: { + title: 'Rewrite the Code', + body: "This drama is fully interactive. Don't like a character's decision? Use AI to generate a new scene instantly.", + cta: 'Try interactive mode', + generating: 'Generating new scene…', + }, + cast: { + title: 'Core Cast', + empty: 'Cast details coming soon.', + }, + details: { + title: 'Details', + director: 'Director', + release: 'Release', + audio: 'Audio', + format: 'Format', + language: 'Language', + }, + related: { + title: 'You might also like', + empty: 'No recommendations yet.', + }, +} diff --git a/packages/i18n/src/messages/en/feed.js b/packages/i18n/src/messages/en/feed.js new file mode 100644 index 0000000..28c06a9 --- /dev/null +++ b/packages/i18n/src/messages/en/feed.js @@ -0,0 +1,32 @@ +/** feed.* —— 刷剧信息流(路由 / 落地页 CTA / 空态 / 中央剧台 / Up Next 轨)。基准语言 en。 */ +export default { + cta: 'Start watching free', + empty: 'No dramas here yet. Check back soon.', + emptyHint: 'New AI dramas drop every day — come back in a bit.', + loading: 'Loading dramas…', + loadMore: 'Load more', + trending: 'Trending now', + likeCount: 'no likes | {count} like | {count} likes', + + // Center stage (featured drama) + episodeOf: 'Ep {current} of {total}', + byCreator: 'by {handle}', + rewrite: 'Rewrite Scene', + rewriteAria: 'Rewrite this scene with your own twist', + share: 'Share', + shareAria: 'Share this drama', + shareCopied: 'Link copied to clipboard', + shareFailed: "Couldn't share right now. Try again.", + liked: 'Added to your favorites', + unliked: 'Removed from your favorites', + likeAria: 'Like this drama', + prevAria: 'Previous drama', + nextAria: 'Next drama', + openDrama: 'Open {title}', + + // Up Next rail + upNext: 'Up Next', + viewAll: 'View all', + defaultCreator: 'IdearRun Studio', + defaultSynopsis: 'An AI-woven drama you can pause and rewrite at any beat.', +} diff --git a/packages/i18n/src/messages/en/index.js b/packages/i18n/src/messages/en/index.js new file mode 100644 index 0000000..dac2a37 --- /dev/null +++ b/packages/i18n/src/messages/en/index.js @@ -0,0 +1,15 @@ +/** en message 聚合 —— 分命名空间文件聚合,与 en 同构 key 树。内部聚合,不对外深导入。 */ +import common from './common.js' +import auth from './auth.js' +import feed from './feed.js' +import player from './player.js' +import pay from './pay.js' +import drama from './drama.js' +import library from './library.js' +import onboarding from './onboarding.js' +import landing from './landing.js' +import account from './account.js' +import visualGame from './visual-game.js' +import legal from './legal.js' + +export default { common, auth, feed, player, pay, drama, library, onboarding, landing, account, visualGame, legal } diff --git a/packages/i18n/src/messages/en/landing.js b/packages/i18n/src/messages/en/landing.js new file mode 100644 index 0000000..d3b911c --- /dev/null +++ b/packages/i18n/src/messages/en/landing.js @@ -0,0 +1,69 @@ +/** landing.* —— 营销落地页(Hero / Bento 卖点 / How It Works / Featured / Footer)。基准语言 en。 */ +export default { + // 通用 + openDrama: 'Open {title}', + + // Hero + hero: { + badge: 'IdearRun Player v2.0 Live', + titleLead: 'Watch dramas.', + titleAccent: 'Rewrite the story.', + subtitle: + 'The first AI interactive 2D drama platform. Interrupt any scene, rewrite the dialogue, and watch the AI branch the narrative in real time.', + ctaPrimary: 'Start watching free', + ctaSecondary: 'How it works', + rewriteActive: 'AI Rewrite Active', + rewriteSample: '"No, I won\'t hand over the documents. You\'ll have to take them."', + }, + + // Bento 卖点网格 + bento: { + heading: 'Unleash Cinematic Intelligence', + rewriteTitle: 'Interrupt & rewrite with AI', + rewriteDesc: + 'Pause any scene, type a new direction, and the skeletal engine instantly regenerates the performance.', + rewriteBubble: '"I think we should reconsider the deal."', + rewriteGenerating: 'Generating alternative path…', + animTitle: 'Cinematic 2D puppet animation', + animDesc: 'Dynamic rigging meets moody lighting.', + voicesTitle: 'AI Voices', + branchTitle: 'Endless storylines', + trending: 'Trending Dramas', + }, + + // How It Works + how: { + browse: { + title: '1. Browse', + desc: 'Discover a library of ongoing 2D cinematic stories across various genres.', + }, + interrupt: { + title: '2. Interrupt', + desc: 'Hit rewrite at any moment. Change the dialogue, mood, or character actions.', + }, + rewrite: { + title: '3. AI Rewrites', + desc: 'Watch the story instantly evolve as the engine generates new branching paths.', + }, + }, + + // Featured Dramas + featured: { + heading: 'Featured Dramas', + browseAll: 'Browse all', + rewrites: '{count} Rewrites', + rewritesUnknown: 'New release', + genrePlaceholder: 'AI Interactive Drama', + }, + + // Footer + footer: { + copyright: '© 2024 IdearRun AI. All rights reserved. Cinematic Intelligence.', + terms: 'Terms', + privacy: 'Privacy', + discord: 'Discord', + twitter: 'Twitter', + docs: 'Documentation', + language: 'Language:', + }, +} diff --git a/packages/i18n/src/messages/en/legal.js b/packages/i18n/src/messages/en/legal.js new file mode 100644 index 0000000..f854d20 --- /dev/null +++ b/packages/i18n/src/messages/en/legal.js @@ -0,0 +1,33 @@ +export default { + privacy: { + title: 'Privacy Policy', updated: 'Last updated: August 18, 2026', intro: 'voko.world is an interactive generative drama experience. This policy explains what we collect, how we use it, and the choices you have.', + sections: { + information: { title: 'Information we collect', body: 'We collect account information such as your email address, profile name, sign-in data, gameplay progress, story choices, generated video records, and basic device or log data needed to keep the service secure and reliable.' }, + usage: { title: 'How we use information', body: 'We use information to provide the game, save your progress, generate and display story videos, send product or video status notifications, improve safety and quality, prevent abuse, and respond to support requests.' }, + generated: { title: 'Generated content', body: 'Your prompts, choices, story logs, and generated videos may be processed by our systems and service providers to create, moderate, store, and deliver the experience.' }, + voice: { title: 'Voice input', body: 'When you use voice input, your microphone recording is sent to ByteDance Doubao speech-to-text service to produce a transcript. voko.world does not store the recording after transcription completes. The transcript remains editable before you send it as a game action.' }, + sharing: { title: 'Sharing', body: 'We do not sell personal information. We share information only with service providers, infrastructure vendors, analytics or safety tools, legal authorities when required, or when you choose to publish or share content.' }, + retention: { title: 'Retention', body: 'We keep information for as long as needed to provide voko.world, comply with legal obligations, resolve disputes, and protect the service. You may contact us to request account or data deletion.' }, + choices: { title: 'Your choices', body: 'You can manage your account, choose whether to publish generated videos, unsubscribe from optional communications, and contact us about access, correction, or deletion requests.' }, + contact: { title: 'Contact', body: "For privacy questions, email us at service{'@'}voko.world." }, + }, + }, + terms: { + title: 'Terms of Service', updated: 'Last updated: August 13, 2026', intro: 'These terms govern your access to and use of voko.world. By using the service, you agree to these terms.', + sections: { + usage: { title: 'Using voko.world', body: 'You may use voko.world for lawful, personal entertainment and creative purposes. You are responsible for your account, your choices, and any content you submit or publish.' }, + accounts: { title: 'Accounts and access', body: 'You must provide accurate account information and keep your login credentials secure. We may suspend access if we detect abuse, security risks, or violations of these terms.' }, + generated: { title: 'Generated stories and videos', body: 'voko.world may generate text, images, audio, or video based on your interactions. Generated results can vary and may be imperfect. Do not rely on generated content as factual, legal, medical, financial, or professional advice.' }, + rules: { title: 'Content rules', body: 'Do not submit, request, publish, or distribute content that is unlawful, abusive, infringing, deceptive, sexually exploitative, or harmful to others.' }, + property: { title: 'Intellectual property', body: 'voko.world, its software, design, branding, and service content are owned by us or our licensors. You retain responsibility for content you provide, and you grant us the rights needed to operate, display, improve, and protect the service.' }, + availability: { title: 'Availability', body: 'We may change, pause, or discontinue parts of the service. We are not liable for interruptions, data loss caused by events outside our control, or third-party service failures.' }, + contact: { title: 'Contact', body: "Questions about these terms can be sent to contact{'@'}idearrun.com." }, + }, + }, + contact: { + title: 'Contact us', updated: 'voko.world support and community', intro: 'Have a question, partnership idea, bug report, or content concern? We would love to hear from you.', + sections: { + email: { title: 'Email', body: "service{'@'}voko.world" }, community: { title: 'Community', body: 'Join our Discord: https://discord.gg/WeESGyuUSX' }, social: { title: 'Social', body: 'Follow voko.world on X, YouTube, Instagram, TikTok, and Facebook from the footer links.' }, support: { title: 'Support notes', body: 'For account or video issues, include your account email, the page URL, and a short description of what happened. Please do not send passwords or payment credentials.' }, + }, + }, +} diff --git a/packages/i18n/src/messages/en/library.js b/packages/i18n/src/messages/en/library.js new file mode 100644 index 0000000..ee44116 --- /dev/null +++ b/packages/i18n/src/messages/en/library.js @@ -0,0 +1,36 @@ +/** library.* —— 剧本库(分类 / 搜索 / 筛选 / 网格)。基准语言 en。 */ +export default { + title: 'Trending Library', + searchPlaceholder: 'Search dramas, creators, genres...', + searchAria: 'Search the library', + genresAria: 'Filter by genre', + genres: { + all: 'All', + sciFi: 'Sci-Fi', + romance: 'Romance', + thriller: 'Thriller', + comedy: 'Comedy', + drama: 'Drama', + fantasy: 'Fantasy', + }, + sortLabel: 'Sort: {value}', + sort: { + trending: 'Trending', + newest: 'Newest', + mostViewed: 'Most viewed', + }, + loading: 'Loading dramas...', + error: 'Could not load the library.', + retry: 'Retry', + empty: 'No dramas match your search.', + viewsCount: 'no views | {count} view | {count} views', + aiBadge: 'AI', + locked: 'Premium drama', + openAria: 'Open {title}', + moreAria: 'More options for {title}', + brand: 'IdearRun', + footerTerms: 'Terms', + footerPrivacy: 'Privacy', + footerSupport: 'Support', + copyright: '© {year} IdearRun AI. All rights reserved.', +} diff --git a/packages/i18n/src/messages/en/onboarding.js b/packages/i18n/src/messages/en/onboarding.js new file mode 100644 index 0000000..b5c0b9e --- /dev/null +++ b/packages/i18n/src/messages/en/onboarding.js @@ -0,0 +1,60 @@ +/** onboarding.* —— 选型问卷(题材多选 / 进度 / 跳过)。基准语言 en。 */ +export default { + brand: 'IdearRun', + stepOf: 'Step {current} of {total}', + progressLabel: 'Onboarding progress', + continue: 'Continue', + finish: 'Finish', + back: 'Back', + skip: 'Skip for now', + timeHint: 'Takes less than 30 seconds', + lineupTitle: 'Building your personalized lineup...', + step: { + genres: { + title: 'What genres do you love?', + subtitle: 'Select a few to help us tune your AI theater engine.', + }, + pacing: { + title: 'How fast should it move?', + subtitle: 'We pace the story to match your taste.', + }, + length: { + title: 'How long do you like to watch?', + subtitle: 'We size each session to fit your schedule.', + }, + tone: { + title: "What's your kind of vibe?", + subtitle: 'We tune the narration to set the mood.', + }, + }, + option: { + genres: { + romance: 'Romance', + thriller: 'Thriller', + scifi: 'Sci-Fi', + comedy: 'Comedy', + mystery: 'Mystery', + drama: 'Drama', + fantasy: 'Fantasy', + horror: 'Horror', + }, + pacing: { + slowburn: 'Slow burn', + steady: 'Steady', + fast: 'Fast-paced', + twisty: 'Twist-heavy', + }, + length: { + short: 'Quick bites', + medium: 'Medium', + long: 'Deep dives', + binge: 'Binge-ready', + }, + tone: { + witty: 'Witty', + intense: 'Intense', + cozy: 'Cozy', + epic: 'Epic', + }, + }, +} diff --git a/packages/i18n/src/messages/en/pay.js b/packages/i18n/src/messages/en/pay.js new file mode 100644 index 0000000..6ca436b --- /dev/null +++ b/packages/i18n/src/messages/en/pay.js @@ -0,0 +1,106 @@ +/** pay.* —— 解锁 / 订阅 / 支付(Stripe Web)。基准语言 en。 */ +export default { + unlock: 'Unlock this episode', + subscribe: 'Subscribe', + price: '{amount} / month', + coins: 'no coins | {count} coin | {count} coins', + restore: 'Restore purchases', + success: 'Payment successful', + failed: 'Payment failed. You were not charged.', + + checkout: { + back: 'Back to plans', + brand: 'IdearRun', + subscribeTo: 'Subscribe to', + perYear: '/year', + perMonth: '/month', + lineItem: '{plan} — {amount}', + subtotal: 'Subtotal', + tax: 'Tax', + total: 'Total', + renewNotice: 'Renews at {amount} on {date}', + secure: 'Secure checkout', + + paymentDetails: 'Payment details', + expressApple: 'Apple Pay', + expressGoogle: 'Google Pay', + expressLink: 'Link', + orPayWithCard: 'or pay with card', + + emailLabel: 'Email', + emailPlaceholder: 'you{at}example.com', + emailInvalid: 'Enter a valid email address.', + + cardLabel: 'Card information', + cardSecuredHeading: 'Card entry is handled securely by Stripe', + cardSecuredBody: 'We never see or store your card number. You will complete payment on Stripe\'s secure form.', + cardElementLoading: 'Loading secure card field…', + + nameLabel: 'Cardholder name', + namePlaceholder: 'Full name on card', + + billingLabel: 'Billing address', + countryUS: 'United States', + countryCA: 'Canada', + countryGB: 'United Kingdom', + countryAU: 'Australia', + zipPlaceholder: 'ZIP / Postal code', + + pay: 'Pay {amount}', + paying: 'Redirecting to secure payment…', + poweredBy: 'Powered by {provider}', + + cancelled: 'Checkout cancelled. You were not charged.', + error: 'Could not start checkout. Please try again.', + }, + + paywall: { + title: 'Unlock this storyline', + body: 'This path requires credits. Unlock to reveal a new narrative branch and interact with the scene via AI.', + unlock: 'Unlock with {count} credits', + goVip: 'Go VIP for unlimited unlocks', + balancePre: 'You have', + balancePost: 'credits', + insufficient: 'Not enough credits — top up or go VIP.', + later: 'Maybe later', + }, + + plans: { + title: 'Top Up or Go Premium', + subtitle: 'Choose a credit pack for occasional unlocks, or subscribe to VIP for full access to all branching storylines and AI generation features.', + confirm: 'Confirm selection', + confirmHint: 'Continue to secure checkout.', + selectHint: 'Select a credit pack or VIP plan to continue.', + }, + + packs: { + credits: '{count} Credits', + standard: 'Standard pack', + popular: 'Popular', + bonus: 'Bonus {count} credits included', + bestValue: 'Best Value - Save {pct}%', + }, + + points: { + paid: 'Paid points: {points}', bonus: 'Bonus points: {points}', total: 'Total points: {points}', buy: 'Buy points', signInToBuy: 'Sign in to buy', insufficient: 'Your point balance is insufficient.', shortfall: 'You need {points} more points.', orderCanceled: 'Canceled', orderFailed: 'Payment failed', + }, + vip: { + title: 'IdearRun VIP', + subtitle: 'Experience the full potential of AI theater.', + billingCycle: 'Billing cycle', + monthly: 'Monthly', + yearly: 'Yearly', + save: 'Save {pct}%', + perMonth: '/ month', + billedYearly: 'Billed annually at {amount}', + billedMonthly: 'Billed monthly, cancel anytime', + cta: 'Start VIP', + selected: 'VIP selected', + perks: { + unlimited: 'Unlimited storyline unlocks', + endings: 'Access to exclusive endings', + early: 'Early access to new dramas', + adfree: 'Ad-free browsing experience', + }, + }, +} diff --git a/packages/i18n/src/messages/en/player.js b/packages/i18n/src/messages/en/player.js new file mode 100644 index 0000000..0a18cb8 --- /dev/null +++ b/packages/i18n/src/messages/en/player.js @@ -0,0 +1,51 @@ +/** player.* —— 播放与互动续写(影院舞台 + Rewrite composer + Cast/Up Next)。基准语言 en。 */ +export default { + play: 'Play', + pause: 'Pause', + captions: 'Captions', + volume: 'Volume', + mute: 'Mute', + unmute: 'Unmute', + fullscreen: 'Enter fullscreen', + exitFullscreen: 'Exit fullscreen', + settings: { + title: 'Playback settings', + captionLanguage: 'Caption language', + }, + episode: 'Episode {n}', + nextEpisode: 'Next episode', + replay: 'Replay', + progress: 'Playback progress', + voiceLoading: 'Voicing', + generating: 'Writing the next scene…', + ended: 'You reached the end. Want more?', + subtitleEmpty: 'The scene is unfolding…', + like: 'Like', + tip: 'Tip', + share: 'Share', + report: 'Report', + shareCopied: 'Link copied to clipboard', + reported: 'Thanks — we received your report', + rewrite: { + title: 'Rewrite this scene', + engineActive: 'AI Engine Active', + hint: 'Interrupt the flow. Change the dialogue, mood, or actions.', + placeholder: "Tell the AI how to change this scene… e.g. 'Make Mira sound more desperate and bring out a weapon.'", + generate: 'Generate Alternate Scene', + renderingHint: 'Rendering skeletal rig {\'@\'} AI voice', + done: 'New scene generated', + failed: 'Could not generate the scene. Try again.', + }, + cast: { + title: 'Current Cast', + add: 'Add Char', + addPrompt: 'Add a new character to this scene: ', + viewAll: 'View All', + viewLess: 'View Less', + }, + upNext: { + title: "Up Next in '{drama}'", + empty: 'No more episodes yet — interrupt to keep the story going.', + untitled: 'Untitled episode', + }, +} diff --git a/packages/i18n/src/messages/en/visual-game.js b/packages/i18n/src/messages/en/visual-game.js new file mode 100644 index 0000000..0d9a257 --- /dev/null +++ b/packages/i18n/src/messages/en/visual-game.js @@ -0,0 +1,595 @@ +export default { + appName: 'voko.world', + productBrandSub: 'Your kosmo is calling.', + mobileCredit: { title: 'My Credits', buyCredits: 'Buy Credits', history: 'Credit History', purchaseHistory: 'Purchases', usageHistory: 'Usage', emptyHistory: 'No credit history yet.', loadMore: 'Load more', balanceAfter: 'Balance: {points}', pending: 'Pending', paid: 'Paid', canceled: 'Canceled', failed: 'Failed', refunded: 'Refunded', disputed: 'Disputed', autoReload: 'Auto-Reload', bonusCredits: 'Bonus Credits', buyNow: 'Buy Now', lowBalance: 'Low Balance', enabled: 'Enabled', whenBelow: 'When my balance falls below', choosePack: 'Choose a reload pack', consent: 'I authorize Voko to charge this pack automatically whenever my balance reaches the threshold. Purchases are final except where required by law.', terms: 'Terms of Use', continueSecurely: 'Continue securely', save: 'Save settings', disable: 'Turn off Auto-Reload', setupComplete: 'Payment method saved. Auto-Reload is now enabled.', setupCanceled: 'Auto-Reload setup was canceled.', actionRequired: 'The last reload failed. Set up your payment method again to resume.', howTitle: 'How Auto-Reload works', howBalance: 'We buy the selected pack when your balance reaches or falls below the threshold.', howSecure: 'Payment details are collected and stored securely by Stripe; Voko never stores your card number.', howCancel: 'You can turn Auto-Reload off at any time. Turning it off does not refund previous purchases.' }, + nav: { play: 'Play', notes: 'Clues', state: 'State', editor: 'Authoring' }, + scenario: { title: 'Scenario', available: 'Scenarios', assetManifest: 'Assets' }, + character: { title: 'Character', start: 'Start game', current: 'Current character' }, + stage: { empty: 'Choose a scenario and character', turn: 'Turn {turn}', ending: 'Ending', transcript: 'Transcript' }, + composer: { act: 'Act', say: 'Say', placeholder: 'Enter an action or line' }, + actions: { refresh: 'Refresh', start: 'Start', home: 'Home', backHome: 'Back', newRun: 'Restart', send: 'Send', save: 'Save', compile: 'Compile', publish: 'Publish', rollback: 'Rollback', upload: 'Upload', select: 'Select', endingText: 'Ending text', load: 'Load', backToGame: 'Back', cancel: 'Cancel', confirm: 'Confirm' }, + account: { + signIn: 'Sign in', + signedIn: 'Signed in', + signedOut: 'Signed out', + sessionExpired: 'Your session expired. Please sign in again.', + logoutFailed: 'Log out failed. Please try again.', + registered: 'Account created', + email: 'Email', + emailPlaceholder: 'Email address', + password: 'Password', + passwordPlaceholder: 'Enter password', + confirmPassword: 'Confirm password', + confirmPasswordPlaceholder: 'Enter password again', + verificationCode: 'Verification code', + verificationCodePlaceholder: 'Enter the 6-digit email code', + inviteToken: 'Invite code', + inviteTokenPlaceholder: 'Enter the 8-character invite code', + sendCode: 'Send code', + resendCode: 'Resend', + codeSent: 'Code sent. Check your email.', + codeRequired: 'Enter the email verification code first.', + inviteInvalid: 'Enter a valid 8-character invite code.', + emailRequired: 'Enter your email first.', + emailInvalid: 'Enter a valid email address.', + passwordRequired: 'Enter your password first.', + confirmPasswordRequired: 'Confirm your password first.', + loginFieldsRequired: 'Enter your email and password.', + inviteEmailMismatch: 'This email does not match the application email linked to the invite code.', + passwordMismatch: 'Passwords do not match.', + createAccount: 'Create account', + hasAccount: 'Already have an account?', + noAccount: 'No account yet?', + termsHint: 'By continuing, you confirm that this email belongs to you. Your account will save your progress, orders, and credits.', + login: 'Log in', + register: 'Register', + reset: 'Reset', + forgotPassword: 'Forgot password?', + resetPassword: 'Reset password', + resetTitle: 'Reset password', + resetBody: 'Verify your email code, then set a new password. Existing sessions for this account will be revoked.', + resetCodeSent: 'Reset code sent. Check your email.', + resetFieldsRequired: 'Enter your email, verification code, new password, and confirmation password.', + passwordResetDone: 'Password reset. Log in with the new password.', + logout: 'Log out', + logoutAll: 'Log out everywhere', + credits: 'Credits', + buy: 'Buy credits', + billing: 'Billing', + promptTitle: 'Sign in or register to start', + promptBody: 'Your account saves progress, save slots, endings, credits, and orders so you can continue on another device.', + signedInBody: 'Your progress, save slots, and credits are saved to your account.', + registerAndStart: 'Create an account and choose a character', + guestStart: 'Try as guest', + savedHint: 'Sign in to auto-save progress and save slots', + currentAccount: 'Current account', + saveKicker: 'Cloud save', + saveTitle: 'Save your progress and purchases', + loginTitle: 'Log in', + loginBody: 'After you log in, we will merge any guest progress from this device when possible.', + registerTitle: 'Create account', + registerBody: 'Create an account with an invite code, then verify your email. You will be signed in, receive 100 credits, and keep any guest progress from this device.', + openBilling: 'View credits and orders', + mergePending: 'Merging guest progress from this device…', + mergeSuccess: 'Guest data merged: {details}.', + mergeSeparator: ', ', + mergeRuns: '{count} runs', + mergeTurns: '{count} turns', + mergeProgress: '{count} progress records', + mergeSaveSlots: '{count} save slots', + mergeEndingRecords: '{count} endings', + mergeEvents: '{count} events', + mergeAgentCalls: '{count} AI calls', + mergeBillingLedger: '{count} ledger entries', + mergeBillingUnlocks: '{count} paid unlocks', + mergeBillingFreeQuota: '{count} free quotas', + mergePaymentOrders: '{count} orders', + mergeEmpty: 'No local guest progress to merge.', + mergeFailed: 'You are signed in, but we could not merge your guest progress. Refresh the page and try again later.', + }, + billing: { + title: 'Credits and orders', + subtitle: 'View your balance, credit purchases, transaction history, and orders.', + guestSubtitle: 'You can browse credit packs as a guest. Sign in to make a purchase or view your balance, orders, and transaction history.', + balance: 'Current balance', + products: 'Credit packs', + ledger: 'Credit ledger', + orders: 'Orders', + refresh: 'Refresh', + testModeTitle: 'Stripe test environment', + testModeBody: 'Checkout uses test payments. No real charge will be made.', + testPurchase: 'Test purchase', + buy: 'Buy {points} credits', + checkoutReady: 'Checkout link is ready.', + openCheckout: 'Open checkout', + selectedPack: 'Selected', + selectedProduct: '{title} selected. Your account balance will increase by {points} credits.', + signInToBuy: 'Sign in to buy', + readyToBuy: 'Ready to buy credits', + readyToBuyBody: 'Choose a credit pack and continue to checkout. Orders may show processing before confirmation.', + checkoutPending: 'Redirecting to checkout…', + checkoutReturnedSuccess: 'Welcome back. We are refreshing your balance and orders. Credits will be added once payment is confirmed.', + checkoutReturnedCancel: 'Checkout was canceled or not completed. No credits were added.', + signInHint: 'Sign in to view balance, orders, and ledger entries.', + emptyProducts: 'No credit packs.', + emptyLedger: 'No ledger entries.', + emptyOrders: 'No orders.', + productFallback: 'Credit pack', + points: '{points} credits', + price: '{amount} {currency}', + ledgerAmount: '{amount} credits', + orderStatus: 'Status {status}', + orderStatusPending: 'Processing', + orderStatusPaid: 'Paid', + orderStatusRefunded: 'Refunded', + orderStatusDisputed: 'Disputed', + orderStatusUnknown: 'Unknown status', + ledgerReasonSignupGift: 'Signup gift', + ledgerReasonCheckout: 'Credit purchase', + ledgerReasonRefund: 'Order refund', + ledgerReasonChargeback: 'Chargeback reversal', + ledgerReasonUnlock: 'Content unlock', + paywallTitle: 'Unlock with credits', + paywallTarget: 'Target', + paywallReason: 'Reason', + paywallGenericReason: 'Credits are required to continue this content.', + paywallFreeContinue: 'You still have a free play available. You can continue without spending credits.', + paywallInsufficient: 'You do not have enough credits. Buy more credits to continue.', + paywallPrice: 'Required credits', + paywallFreeQuota: 'Free quota left', + paywallBalance: 'Available balance', + }, + endings: { + title: 'Ending collection', + unlocked: 'Unlocked', + locked: 'Locked', + collected: 'Collected {count}/{total}', + lockedHint: 'Reach this ending to unlock it.', + }, + saves: { + title: 'Save slots', + progress: 'Current progress', + activeRun: 'Current run', + slot: 'Slot {slotNo}', + savedRun: 'Saved progress', + emptySlot: 'Empty slot', + emptyHint: 'Save your current progress here so you can return to it later.', + saveHere: 'Save', + overwrite: 'Overwrite save', + restore: 'Load', + delete: 'Delete', + saved: 'Game saved.', + restored: 'Save loaded.', + deleted: 'Save deleted.', + conflict: 'This slot already contains a save. Confirm that you want to overwrite it.', + restoreConfirm: 'Loading this save will replace your current game state.', + deleteConfirm: 'Deleting this save point cannot be undone.', + noRunHint: 'There is no active game to save. Start or continue a game first.', + }, + map: { + title: 'Map', + current: 'Current location', + route: 'Available route', + routes: 'Available destinations', + locked: 'Locked', + moveTo: 'Go to {location}', + objects: 'Inspectable objects', + inspect: 'Inspect', + inspected: 'Inspected', + inspectObject: 'Inspect {object}', + }, + videoCenter: { + eyebrow: 'Player playthroughs', + title: 'Video Center', + backHome: 'Back', + chooseScenario: 'Choose scenario', + allScenarios: 'All scenarios', + searchScenario: 'Search scenarios', + scenarioPaintedSkin: 'Painted Skin', + scenarioGuian: 'Guian Heart-Loss Case', + nowPlaying: 'Now playing', + player: 'Player', + views: 'Views', + ending: 'Ending', + duration: 'Duration', + libraryEyebrow: 'More from this scenario', + allVideos: 'All videos', + sort: 'Sort videos', + recommended: 'Recommended', + latest: 'Latest', + videoMeta: '{player} · {date}', + emptyTitle: 'No player videos yet', + emptyText: 'No public playthroughs are available for this scenario.', + viewAll: 'View all player videos', + discoverStories: 'See how other players shaped the story', + backPrevious: 'Back', aiSummary: 'AI summary:', endingCount: ' endings', publicSeriesCount: ' public series', totalPlays: ' total plays', seriesList: 'Player series', completed: 'Complete', updating: 'Episode {count} · Ongoing', seriesMetrics: 'Episodes: {episodes} · ▶ {plays} · ♥ {likes}', watchSeries: 'Watch this series', playOwnSeries: 'Create your own story', minutes: '{count} min', share: 'Share', shareCopied: 'Link copied', shareFailed: 'Could not share this video', like: 'Like', liked: 'Liked', save: 'Save', saved: 'Saved', storyHighlight: 'Story highlights', startPlay: 'Play now · Rewrite this ending', episodeList: 'Episodes', episodeTotal: 'Episodes: {count}', episodeTitle: 'Episode {count} · {title}', + }, + runHistory: { + eyebrow: 'Your play history', + title: 'My Runs', + allRuns: 'All runs', + count: 'Games: {count}', + empty: 'You have not played any games yet. Start a game and it will appear here.', + loadFailed: 'Could not load your games: {error}', + noTime: 'Unknown time', + statusActive: 'Active', + statusCompleted: 'Ended', + videoNone: 'No video', + videoQueued: 'Video queued', + videoRunning: 'Generating video', + videoReady: 'Video ready', + videoFailed: 'Video generation failed', + videoRetry: 'Generate again', + untitledEnding: 'No ending yet', + meta: '{scenario} · Turn {turn} · {date}', + openRun: 'Continue game', + openNotes: 'Notes', + watchVideo: 'Watch video', + }, + mobileProfile: { title: 'MY', settings: 'Settings', content: 'Content', benefits: 'Benefits', creator: 'Creator', support: 'Inquiries and support', contact: 'Customer support', terms: 'Terms of Use', privacy: 'Privacy Policy', social: 'Social media' }, + profileEditor: { editProfile: 'Edit profile', nameSection: 'Profile name', nameHint: 'This name is shown with your games and videos.', name: 'Name', saveName: 'Save name', nameRequired: 'Enter your name.', nameSaved: 'Your name has been updated.', passwordSection: 'Change password', passwordHint: 'Verify your account using the code sent to your email.', email: 'Email', verificationCode: 'Email verification code', sendCode: 'Send code', codeSent: 'A verification code was sent to your email.', newPassword: 'New password', confirmPassword: 'Confirm new password', savePassword: 'Update password', passwordRequirements: 'Enter the verification code and a password of at least 8 characters.', passwordMismatch: 'The passwords do not match.', passwordSaved: 'Your password has been updated.' }, + myGames: { recent: 'Recently played', popular: 'Popular stories', emptyRecent: 'Your recently played stories will appear here.', loadError: 'Could not load your games. Please try again.' }, + storyDetail: { title: 'Story Information', description: 'Story Description', episodes: '{count} Episodes', endings: '{count} Endings', by: 'By {name}', lastUpdate: 'Last Update', newPlay: 'New Play', notFound: 'This story could not be found.', loadError: 'Could not load the story. Please try again.' }, + accountCenter: { loadFailed: 'Could not load your profile', loadTimeout: 'The request timed out. Check your connection and try again.', title: 'Account', eyebrow: 'Player profile', runs: 'My games', videos: 'My videos', favorites: 'Saved videos', watchSeason: 'Watch season', emptyGames: 'You have not started any games yet.', emptyVideos: 'You have not generated any videos yet.', emptyFavorites: 'You have not saved any videos yet.', lastPlayed: 'Last played · {date}', currentTurn: 'In progress · Turn {turn}', noActiveRun: 'No game in progress', endingsUnlocked: 'Endings: {unlocked}/{total}', runsPlayed: 'Games played: {count}', continueGame: 'Continue game', startGame: 'Start game', viewHistory: 'View history', levelProgress: 'Levels played: {played}/{total}', viewSeasonDetails: 'View season details', hideSeasonDetails: 'Hide season details', levelInProgress: 'In progress', levelCompleted: 'Complete', endingLabel: 'Ending reached', cluesLabel: 'Clues found', clueCount: 'Clues: {count}', viewRunRecords: 'View game history', runRecordSummary: 'Clues: {clues} · Endings: {ending}', noEnding: 'No ending reached yet', noClues: 'No clues found yet', seasonDetailsLoadFailed: 'Could not load this season. Try again.', public: 'Public', private: 'Only me', deleteVideo: 'Delete video', confirmDelete: 'Delete video?', signInTitle: 'Sign in to continue', signInBody: 'Sign in to manage your games and videos.' }, + visual: { + eyebrow: 'TRPG Visual', + chooseScript: 'Choose script', + actTitle: 'Act {count} · {title}', + actLocked: '{title} is locked', + actSealed: 'Act Sealed', + toBeContinued: 'To be continued', + finishPreviousAct: 'Reach an ending in the previous act', + revealAfterAct: 'Revealed after completing Act {count}', + moreScripts: 'More levels', + comingSoon: 'Coming soon', + watchScriptVideo: 'Watch story video', + scenarioKicker: 'Scenario', + hiddenTitle: 'Tianyou', + revealedTitle: 'Painted Skin', + objectives: 'Current objectives', + playHistory: 'Play history', + objectiveLabel: 'Objective', + close: 'Close', + noObjectives: 'No active objectives.', + taskProgress: 'Steps {done}/{total}', + entryKicker: 'Enter game', + entryTitle: 'Enter the room tonight', + emptyScenarioEyebrow: 'Scenario unavailable', + emptyScenarioTitle: 'No playable scenario', + emptyScenarioText: 'The server did not return a published scenario. Refresh or check publishing status.', + emptyScenarioActionTitle: 'Waiting for publication', + emptyScenarioActionText: 'A game cannot start until a published scenario is available.', + continueTitle: 'Continue your last game', + entryBody: 'Choose a character before entering the old room.', + chooseCharacter: 'Choose character', + enterStory: 'Enter the story', + back: 'Back', + skip: 'Skip', + editor: 'Editor', + debugPage: 'Debug', + state: 'State', + settings: 'Settings', + language: 'Language', + languageUnavailable: 'This level does not have an available translation for that language yet.', + turnVideoMatching: 'Smart CG matching', + turnVideoMatchingHint: 'When enabled, similar story beats can be matched to this turn. When off, only exact CG bindings play.', + turnVideoMatchingOn: 'On', + turnVideoMatchingOff: 'Off', + exitGame: 'Exit game', + exitGameHint: 'Leave the current session and return to scenario selection.', + saveAndExit: 'Save and exit', + exitWithoutSaving: 'Exit without saving', + notes: 'Notes', + saves: 'Saves', + continueRun: 'Continue game', + balanceEntry: 'Balance {credits}', + endingCollection: 'Ending collection', + narrator: 'Narrator', + npcName: 'Woman in the room', + npcLine: '"{line}"', + notStarted: 'Not started', + startHint: 'Select Start to enter the old house.', + debug: 'Debug stats', + debugOnly: 'Debug only', + waiting: 'Ready.', + modeAct: 'Do', + modeSay: 'Say', + inputMethod: 'Input method', + textInputMode: 'Text', + voiceInputMode: 'Voice', + choiceGroupLabel: 'Story choices', + inputPlaceholder: 'Example: I lift the bronze mirror toward her and the portrait.', + mobileInputPlaceholder: 'Say or do something…', + voiceStart: 'Start voice input', + voiceStop: 'Stop voice input', + voiceListening: 'Listening…', + voiceRecognizing: 'Recognizing…', + voiceUnsupported: 'Voice input is not supported in this browser', + voicePermissionDenied: 'Microphone access was denied. Check your browser permissions', + voiceMicrophoneUnavailable: 'No available microphone was found', + voiceNoSpeech: 'No speech was recognized. Try again', + voiceNoAudioCaptured: 'No microphone audio was captured. Check your browser permissions', + voiceRecognitionFailed: 'Voice recognition failed. Try again', + voiceHoldToTalk: 'Hold to talk', + voicePreparing: 'Preparing microphone…', + voiceShortcutLabel: 'Shortcut', + voiceReleaseSend: 'Release to send', + voiceSlideCancel: 'Slide up to cancel', + voiceReleaseCancel: 'Release to cancel', + voiceUnderstanding: 'Understanding your command…', + ending: 'Ending', + endingLabel: 'Ending', + endingLine: 'Ending: {title}\n{description}', + endingSummary: 'Ending summary', + endingPoem: 'Ending poem', + endingFallbackPoem: '...', + endingBackgroundLoadFailed: 'The ending scene failed to load. Please refresh and try again.', + recordSeal: 'Case', + recordTitle: 'Constable\'s report', + recordKicker: 'County archive', + recordMeta: 'After the rainy night', + continue: 'Continue', + enterNextEpisode: 'Enter next episode', + viewCurrentSeasonStory: 'View this season', + played: 'Played', + nextPage: 'Next page', + statusCreating: 'Preparing your story…', + statusStarted: 'Game started. Enter dialogue or an action.', + statusHistoryRun: 'You are viewing a previous game. Continuing will follow your latest saved progress.', + statusKp: 'The Keeper is evaluating your action…', + statusDone: 'Turn complete.', + contentFiltered: 'This action cannot be described. Try acting another way.', + turnFailed: 'We couldn\'t complete this step. Please try again.', + audioFailed: 'Speech playback failed. Please try again.', + actionRejected: 'That action cannot take effect yet. Try another approach.', + statusEnded: 'Game ended.', + statusEndingText: 'Writing your ending…', + statusEndingLocked: 'Ending determined. Preparing the finale…', + statusNpcAction: 'A character is taking action. The Keeper is evaluating the outcome…', + statusNpcDone: 'NPC responded.', + clueGained: 'Clue gained', + landscapeTitle: 'Rotate your device', + landscapeBody: 'This story is designed for landscape play so the full scene stays visible.', + stats: { + suspicion: 'Suspicion', + charm: 'Charm', + fear: 'Fear', + }, + guide: { + label: 'Guide', + skip: 'Skip', + next: 'Next', + done: 'Got it', + optionsTitle: 'Choose a suggestion', + optionsText: 'After a character responds, two suggested choices appear below. Select Say or Do to continue.', + keyboardTitle: 'Switch to keyboard input', + keyboardText: 'Voice input is the default. Select the keyboard button whenever you want to type what you say or do.', + inputTitle: 'Choose Say or Do', + inputText: 'Before typing, choose whether you are speaking or acting. The Keeper will evaluate either choice.', + notesTitle: 'Check notes', + notesText: 'The notes button records discovered clues and unlocked endings.', + goalTitle: 'Survive', + goalText: 'This is more than a conversation. Listen carefully, explore the room, and investigate clues and anything unusual.', + videoTitle: 'Complete the story and turn your adventure into a video of your own', + videoExampleLabel: 'Example video', + videoExampleNotice: 'This video is only an example and does not represent the content generated from your playthrough.', + videoLoading: 'Loading an example video…', + videoUnavailable: 'This story does not have an example video yet.', + videoDone: 'Start my story', + }, + openingOptions: { + say: 'Sorry, I did not mean to break in. Something outside was chasing me.', + act: 'Keep your voice low and inspect the doors, windows, lamp, and her position.', + }, + }, + panels: { clues: 'Clues', conclusions: 'Conclusions', info: 'Info', inventory: 'Inventory', events: 'Events', stats: 'Stats', flags: 'Flags', objects: 'Objects', npcs: 'NPCs', endingRecords: 'Unlocked endings' }, + standalone: { + summary: 'Summary', + availableClues: 'Available clues', + recentTurns: 'Recent turns', + audit: { + title: 'AI audit', + subtitle: 'Costs and errors for Keeper, character, ending, and text-to-speech calls.', + totalCalls: 'Calls', + failedCalls: 'Failed', + tokens: 'Input/output tokens', + cost: 'Cost', + roles: 'Role summary', + models: 'Model summary', + recentCalls: 'Recent calls', + empty: 'No AI calls recorded.', + calls: '{count} calls', + tokenPair: 'Tokens: {tokens}', + }, + state: { + title: 'State view', + waiting: 'Waiting for game data.', + noRun: 'No game ID was provided. Open this page from a game or enter an ID manually.', + loading: 'Loading game {runId}…', + loaded: 'Game {runId} loaded', + }, + notes: { + title: 'Notes', + waiting: 'Loading notes.', + noRun: 'No current save found. Start or continue a run first.', + loading: 'Loading notes for game {runId}…', + loaded: 'Notes for game {runId} loaded', + unavailable: 'Notes for this game are temporarily unavailable.', + emptyClues: 'No clues yet', + emptyConclusions: 'No conclusions yet', + emptyEndings: 'No endings yet', + tabs: 'Note categories', + list: 'Entry list', + detail: 'Entry detail', + locked: 'Locked', + lockedHint: 'This ending has not been recorded yet.', + recorded: 'Recorded', + importance: { minor: 'Minor', major: 'Major', critical: 'Critical' }, + relatedClues: 'Related clues', + noRelatedClues: 'No supporting clue has been found yet.', + }, + }, + editor: { + title: 'Scenario authoring', + pack: 'Pack', + section: 'Section', + raw: 'JSON', + structured: 'Structured editor', + debugJson: 'Debug JSON', + item: 'Item', + noStructuredFields: 'This section has no direct structured fields yet.', + updated: 'Saved', + assetUploaded: 'Asset uploaded to draft', + compiled: 'Draft compiled', + published: 'Version published', + rolledBack: 'Rolled back to version', + versions: 'Versions', + assets: 'Asset index', + current: 'Current', + currentVersion: 'Current version', + noPublishedVersion: 'Not published', + noVersions: 'No versions yet. Compile a draft first.', + noAssets: 'No asset index yet. Compile the asset manifest first.', + noFilteredAssets: 'No assets match the current filters.', + assetSearch: 'Search', + assetSearchPlaceholder: 'Tag, file, or objectKey', + assetTypeFilter: 'Type', + assetSourceFilter: 'Source', + assetFilterAll: 'All', + assetFilterResult: '{shown}/{total}', + assetSource: { + uploaded: 'Uploaded object', + static: 'Static file', + missing: 'Missing locator', + }, + uploadFile: 'File', + uploadTag: 'Tag', + uploadTagPlaceholder: 'opening_cg', + uploadType: 'Type', + noUploadFile: 'No file selected', + }, + waitingList: { + eyebrow: 'Early access', + apply: 'Join Waitlist', + useInvite: 'Use invite code', + invitedRegister: 'Have an invite? Create account', + title: 'Join the voko.world waitlist', + body: 'Leave your email. Once you are approved for early access, we will send you a personal invitation.', + displayName: 'Name', + displayNamePlaceholder: 'Optional', + useCase: 'Use case', + useCaseOptional: 'Use case (optional)', + useCasePlaceholder: 'For example: creator preview, team test, or live event', + useCaseRequired: 'Use case is required.', + consent: 'I agree to receive emails about my invite request and voko.world product updates.', + join: 'Join the waitlist', + joined: 'We received your request. We will email you when there is an update.', + joinedTitle: 'Request submitted', + changeEmail: 'Use another email', + accountAlreadyRegistered: 'This email is already registered. Please sign in.', + emailRequired: 'Email is required.', + consentRequired: 'Consent is required before joining the list.', + inviteEyebrow: 'Invite', + inviteTitle: 'Create your voko.world account', + inviteBody: 'This invite is linked to your email address. Set a password to finish creating your account.', + inviteMissing: 'No invite code was provided.', + inviteEnterCode: 'Enter the 8-character invite code you received to create an account.', + inviteValidate: 'Verify invite code', + inviteReenter: 'Enter another code', + inviteInvalid: 'This invite is invalid or expired.', + codeSent: 'Verification code sent.', + passwordMismatch: 'Passwords do not match.', + acceptInvite: 'Create account', + }, + landing: { officialVideo: 'Watch the official video', officialVideoEyebrow: 'Meet VOKO', officialVideoTitle: 'Your kosmo is calling.', officialVideoBody: 'Create your own world, play through stories that call you back, and share every moment as video worth watching.', homeLabel: 'voko.world home', navHome: 'Home', navScripts: 'Stories', navVideos: 'Drama', featuredStories: 'Featured Games', featuredStoriesEyebrow: 'Start playing', endingCount: 'Endings: {count}', allStories: 'All Stories', dramaTitle: 'Drama', brandSub: 'Interactive stories', navLabel: 'Main navigation', navStories: 'Games', navGallery: 'Gallery', eyebrow: 'AI-powered visual role-playing', loadError: 'Could not load the home page. Please try again.', retry: 'Try again', emptyGames: 'No games are available yet.', scenarioFallback: 'Step into this world and reshape the story with every choice.', publicVideos: 'Player videos', publicGames: 'Public games', watchDrama: 'Watch story', enterPlay: 'Play now', comingSoon: 'Coming soon', carouselLabel: 'Featured games', previousGame: 'Previous game', nextGame: 'Next game', showcaseTitle: 'Stories shaped by players', showcaseBody: 'Watch the endings other players reached, then step into the game and create your own.', searchLabel: 'Search stories', searchPlaceholder: 'Search by game or episode title', sortLabel: 'Sort stories', latest: 'Latest', popular: 'Popular', videoCount: 'Videos: {count}', levelCount: 'Episodes: {count}', levelNumber: 'Episode {count}', singleLevel: 'Standalone story', publicGame: 'Public game', noSearchResults: 'No stories match your search', clearSearch: 'Clear filters', finalEyebrow: 'Your story awaits', finalTitle: 'Who will you become?', finalBody: 'Cross the threshold and make your first choice.', begin: 'Begin your story', footer: '© 2026 voko.world', earlyAccess: 'Early access', footerSocial: 'Community', footerLegal: 'Legal & contact', privacy: 'Privacy Policy', terms: 'Terms of Service', contact: 'Contact us' }, + landingIntro: { title: 'voko.world', apply: 'Request access', watchVideo: 'Watch stories', start: 'Start game', continue: 'Continue game', viewJourney: 'Play history', continueProgress: 'Season {season} · {scenario}', storyLine: 'Storyline', viewMore: 'View more' }, + landingCarousel: { introEyebrow: 'Generative drama game', introSummary: 'Watch a short drama, step into the role, and let every choice reshape the story.', dramaEyebrow: 'Featured drama {count}', controls: 'Home carousel', goToSlide: 'Go to slide {count}' }, + landingCinema: { navHome: 'Home', navDramas: 'Game', navPlayerStories: 'Dramas', navMy: 'My', featuredDramas: 'Games', playerStories: 'Dramas' }, + searchPage: { placeholder: 'Search stories, dramas, or accounts', popular: 'Popular Searches', history: 'Recent Searches', clearHistory: 'Clear', resultTypes: 'Search result types', all: 'All', accounts: 'Account', accountStats: '{titles} titles · {plays} plays' }, + storyTree: { + eyebrow: 'Season storylines', + subtitle: 'Each line represents a season. Each node represents an episode.', + root: 'Story', + branch: 'Branch', + active: 'In progress', + completed: 'Complete', + locked: 'Not reached', + empty: 'No season storylines yet.', + loadError: 'Could not load your season storylines.', + navigation: 'Season range', + range: 'Seasons {first}–{last} · Total: {total}', + newer: 'Previous', + older: 'Next', + startPlaying: 'Play now', + turnProgress: 'Turn {turn}', + continuePlaying: 'Continue playing', + playLog: 'Play history', + seasonEnding: 'Season ending', + currentSeason: 'Current season', + logLoadError: 'Could not load your play history.', + logEmpty: 'There is no play history for this season yet.', + turnNumber: 'Turn {turn}', + playerSaid: 'You said', + playerDid: 'You did', + storyOpening: 'Story opening', + turnVideos: 'Matched videos', + playActionVideo: 'Action video', + playPlayerVideo: 'Player video', + playNpcVideo: 'NPC video', + replayTurn: 'Replay', + replayTurnTitle: 'Turn {turn} replay', + replayProgress: 'Segment {current}/{total}', + actionSegment: 'Action', + playerSpeechSegment: 'Player speech', + npcSpeechSegment: 'NPC speech', + pauseReplay: 'Pause', + continueReplay: 'Continue', + restartReplay: 'Replay again', + exportReplay: 'Export full replay', + replayQueued: 'Queued', + replayExporting: 'Exporting', + playFullReplay: 'Play full replay', + retryReplayExport: 'Retry export', + downloadReplay: 'Download MP4', + downloadSegment: 'Download this segment', + downloadTurn: 'Download full turn', + turnExporting: 'Preparing turn video', + turnExportError: 'Turn video export failed.', + segmentExporting: 'Preparing segment', + segmentExportError: 'Segment export failed.', + fullReplayTitle: '{title} · Full replay', + replayExportError: 'Full replay export failed.', + videoLoadError: 'The video could not be loaded.', + tools: 'Search, filter, and sort season storylines', + search: 'Search', + searchPlaceholder: 'Search episodes, endings, or recent actions', + filter: 'Status', + filterAll: 'All', + filterInProgress: 'In progress', + filterCompleted: 'Complete', + sort: 'Sort', + sortRecent: 'Recently played', + sortSeasonDesc: 'Newest season first', + sortSeasonAsc: 'Oldest season first', + resultCount: 'Seasons: {count}', + noResults: 'No season storylines match your filters.', + canvasHint: 'Drag or scroll horizontally to explore more episodes', + canvasLabel: 'Scrollable season storyline', + storySwitch: 'Story', + }, + storyTreeCurrentSeasonHint: 'Latest progress', + storyTreeViewDetails: 'View Season {number} details', + storyTreeDelete: { action: 'Delete branch', confirm: 'Delete Season {number} and all branches created from it? This cannot be undone.', deleting: 'Deleting…', error: 'Could not delete this branch.' }, + storyTreeVideoUnavailable: 'Video unavailable', + storyTreeFork: { action: 'Play again from here', confirm: 'A new story branch will be created from turn {turn}. Your current story history will not be changed.', failed: 'Failed to create the new story branch. Please try again.', creating: 'Creating…' }, + runVideoGeneration: { title: 'Generate full video', description: 'Generate a full video from this run. Points are charged after confirmation and refunded if generation fails.', generate: 'Generate video', regenerate: 'Generate again', retry: 'Try again', queued: 'Queued', running: 'Generating', price: 'Points required', balance: 'Current balance', balanceAfter: 'Balance after charge' }, + landingShowcase: { title: 'Lives Unfold', body: 'Witness the paths chosen by other players, then enter the story and write your own ending.' }, + landingMetrics: { playCount: 'Plays', likeCount: 'Likes' }, + landingCard: { level: 'Episode', searchPlaceholder: 'Find a story, video, or player', scriptFilter: 'Drama', scriptAll: 'All drama', playerVideoCount: 'Public videos: {count}', publishedAt: 'Published', publishedAll: 'Any time', publishedToday: 'Today', publishedWeek: 'Past 7 days', publishedMonth: 'Past 30 days', expandSynopsis: 'Show more', collapseSynopsis: 'Show less' }, + playerPage: { loadError: 'Could not load this player\'s videos.', notFound: 'Player not found or has no public videos.', searchPlaceholder: 'Search dramas, levels, or season titles' }, + season: { choose: 'Choose a season', chooseBody: 'This episode will continue the story in the selected season.', number: 'Season {number}', empty: 'Complete the previous episode in a season before continuing.' }, + status: { loading: 'Loading…', empty: 'Nothing here yet', error: 'Could not load data', streaming: 'In progress' }, + storiesPage: { tags: 'Story tags', allTags: 'All', swipeForNextStory: 'Swipe up for the next story' }, + creator: { nav: 'Create', queued: 'Queued for review', attachmentTypeError: 'Upload one TXT, PDF, Word, PNG, JPG, or WEBP file.', attachmentSizeError: 'The attachment must be no larger than 10 MB.', attachmentReadError: 'The attachment could not be read.', generationFailed: 'Generation failed', reviewRejected: 'Review rejected', published: 'Published', archived: 'Archived', composerTitle: 'What kind of world will you create today?', submitIdea: 'Submit idea', examplesNote: 'Real published games shown only as layout references.', exampleStatus: 'Published example', projectsEyebrow: 'Visual Game Creator', projectsTitle: 'My games', projectsBody: 'Manage your games and follow their generation and review status.', newGame: 'Create new game', draftStatus: 'Draft', continueEditing: 'Continue editing', generatedScopes: 'generation items', emptyTitle: 'Create your first game', emptyBody: 'Start with an idea, script, or world setting. The creator system will turn it into a playable game.', eyebrow: 'Visual Game Creator', title: 'Turn your story into a playable world', subtitle: 'Share an idea, script, or world setting. The creator system will design the game, produce its media, validate every route, and send it for review.', stepIdea: 'Idea', stepGenerate: 'Generate', stepReview: 'Review', ideaTitle: 'Describe your game', ideaBody: 'Write naturally. You do not need to understand game schemas or state machines.', ideaLabel: 'Idea or script', ideaHint: 'Include the world, characters, conflict, and the experience you want.', ideaPlaceholder: 'A traveler wakes in an inn where every guest remembers a different version of last night…', ideaError: 'Please enter at least 20 characters.', languageLabel: 'Game language', languages: { zh: 'Chinese', en: 'English', ja: 'Japanese', ko: 'Korean' }, scopeTitle: 'Generate this time', scopeStory: 'Story & levels', scopeStoryBody: 'Characters, quests, states, and endings', scopeVisuals: 'Visual media', scopeVisualsBody: 'Characters, scenes, props, and videos', scopeVoice: 'Voice', scopeVoiceBody: 'Player, NPC, and ending narration', scopeError: 'Select at least one generation item.', assetsTitle: 'Reference material', assetsBody: 'Optional references help keep characters and worlds consistent.', uploadAction: 'Add reference files', uploadHint: 'One file · TXT, PDF, Word, PNG, JPG or WEBP · up to 10 MB', supportedTitle: 'Supported material', supportedBody: 'Character, scene, prop, and style images; scripts and world documents; video or audio references.', removeFile: 'Remove {name}', saved: 'Draft saved on this device.', draftNote: 'This first version saves a local draft. Generation will be connected next.', save: 'Save draft' }, + labels: { scenario: 'Scenario', asset: 'Asset', mode: 'Mode', phase: 'Phase', location: 'Location', noRun: 'Not started', none: 'None' }, +} diff --git a/packages/i18n/src/messages/es/account.js b/packages/i18n/src/messages/es/account.js new file mode 100644 index 0000000..486176d --- /dev/null +++ b/packages/i18n/src/messages/es/account.js @@ -0,0 +1,88 @@ +/** account.* —— Perfil (perfil / créditos / suscripción / historial / favoritos / ajustes). es. */ +export default { + pageTitle: 'Perfil', + backToApp: 'Volver a la app', + + header: { + memberSince: 'Miembro desde {date}', + editAvatar: 'Editar foto de perfil', + vip: 'VIP', + guestName: 'Invitado', + }, + + stats: { + credits: 'Créditos disponibles', + watched: 'Dramas vistos', + rewritten: 'Historias reescritas', + }, + + history: { + title: 'Seguir viendo', + pageTitle: 'Historial de visualización', + subtitle: 'Continúa donde lo dejaste.', + viewAll: 'Ver todo', + empty: 'Aún no tienes nada en curso. Elige un drama para empezar a ver.', + browse: 'Explorar dramas', + episodeLine: 'Ep {ep} • Quedan {left}', + resume: 'Reanudar {title}', + }, + + favorites: { + title: 'Dramas guardados', + pageTitle: 'Dramas guardados', + subtitle: 'Dramas que guardaste para más tarde.', + manage: 'Gestionar', + empty: 'Aún no tienes dramas guardados. Marca dramas para encontrarlos aquí.', + browse: 'Explorar dramas', + discoverMore: 'Descubrir más', + open: 'Abrir {title}', + }, + + back: 'Volver al perfil', + + preferences: { + title: 'Preferencias', + language: 'Idioma', + theme: 'Tema', + light: 'Claro', + dark: 'Oscuro', + }, + + subscription: { + renews: 'Se renueva el {date}', + inactive: 'Sin suscripción activa', + pastDue: 'Pago vencido', + topUp: 'Recargar créditos', + manage: 'Gestionar suscripción', + paymentMethod: 'Método de pago', + upgrade: 'Mejorar plan', + tierFree: 'Plan gratuito', + }, + + account: { + title: 'Cuenta', + notifications: 'Notificaciones', + privacy: 'Privacidad y datos', + downloadData: 'Descargar datos', + deleteData: 'Eliminar datos', + signOut: 'Cerrar sesión', + }, + + actions: { + notificationsSoon: 'Los ajustes de notificaciones llegarán pronto.', + downloadStarted: 'Estamos preparando la exportación de tus datos. Te enviaremos un enlace por correo.', + deleteTitle: '¿Eliminar tus datos?', + deleteBody: 'Esto elimina permanentemente los datos de tu cuenta y no se puede deshacer.', + deleteConfirm: 'Eliminar datos', + deleteCancel: 'Conservar mis datos', + deleteRequested: 'Solicitud de eliminación de datos recibida. La procesaremos en breve.', + signedOut: 'Has cerrado sesión.', + languageChanged: 'Idioma actualizado.', + }, + + points: { + dailyClaimTitle: 'Puntos diarios', dailyClaimAvailable: 'Reclama {points} puntos hoy.', dailyClaimed: 'Los puntos de hoy ya fueron reclamados.', claim: 'Reclamar puntos', claimed: 'Reclamado', nextAvailable: 'Disponible de nuevo: {time}', balanceTurns: '{points} puntos · {turns} turnos disponibles', + }, + loading: 'Cargando tu perfil…', + loadError: 'No pudimos cargar tu perfil. Inténtalo de nuevo.', +} diff --git a/packages/i18n/src/messages/es/auth.js b/packages/i18n/src/messages/es/auth.js new file mode 100644 index 0000000..32a1f2b --- /dev/null +++ b/packages/i18n/src/messages/es/auth.js @@ -0,0 +1,70 @@ +/** auth.* —— 登录 / 注册 / 身份合并。es 与 en 同构。 */ +export default { + login: { + title: 'Bienvenido de nuevo', + subtitle: 'Ingresa tus datos para acceder a tu cuenta.', + submit: 'Iniciar sesión', + submitting: 'Iniciando sesión…', + submitError: 'No pudimos iniciar tu sesión. Verifica tus datos e inténtalo de nuevo.', + email: 'Correo electrónico', + emailPlaceholder: "name{'@'}example.com", + emailRequired: 'El correo es obligatorio.', + invalidEmail: 'Ingresa un correo válido.', + password: 'Contraseña', + passwordRequired: 'La contraseña es obligatoria.', + forgot: '¿Olvidaste tu contraseña?', + showPassword: 'Mostrar contraseña', + hidePassword: 'Ocultar contraseña', + }, + register: { + title: 'Crea tu cuenta', + subtitle: 'Verifica tu correo para empezar.', + submit: 'Registrarse', + haveAccount: '¿Ya tienes una cuenta?', + codeSent: 'Código de verificación enviado. Revisa tu bandeja de entrada.', + }, + segment: { + signIn: 'Iniciar sesión', + createAccount: 'Crear cuenta', + }, + social: { + divider: 'O continúa con', + google: 'Continuar con Google', + googleEnglish: 'Continue with Google', + email: 'Continuar con correo electrónico', + apple: 'Continuar con Apple', + }, + forgot: { + title: 'Restablecer tu contraseña', + body: 'Ingresa el correo vinculado a tu cuenta y te enviaremos un código de restablecimiento.', + emailLabel: 'Correo electrónico', + submit: 'Enviar código', + sent: 'Código de restablecimiento enviado. Revisa tu bandeja de entrada.', + needEmail: 'Ingresa un correo válido.', + error: 'No se pudo enviar el código. Inténtalo de nuevo.', + }, + legal: { + agree: 'Al continuar aceptas nuestros', + terms: 'Términos', + and: 'y', + privacy: 'Política de privacidad', + termsTitle: 'Términos del servicio', + privacyTitle: 'Política de privacidad', + termsBody: + 'Al usar IdearRun aceptas usar el servicio únicamente para entretenimiento personal y lícito. Los dramas interactivos y las tramas que generes se te otorgan bajo licencia para verlos dentro de la app y no pueden redistribuirse. Podemos actualizar estos términos a medida que el producto evolucione; seguir usándolo implica aceptar la versión más reciente.', + privacyBody: + 'Recopilamos los datos mínimos necesarios para operar IdearRun: el correo de tu cuenta, el historial de reproducción y las decisiones de interacción que usamos para personalizar las recomendaciones. Nunca vendemos tus datos personales. La actividad anónima solo se fusiona con tu cuenta cuando decides registrarte. Puedes solicitar la exportación o eliminación de tus datos en cualquier momento.', + }, + hero: { + brand: 'IdearRun', + titleA: 'Entra en la historia.', + titleB: 'Reescribe el final.', + body: 'Únete a la nueva generación del drama interactivo. Tus decisiones moldean la narrativa en tiempo real.', + }, + convert: { + title: 'Guarda tu progreso', + body: 'Regístrate para seguir viendo {name} y desbloquear todos los episodios.', + }, + logout: 'Cerrar sesión', + guest: 'Continuar como invitado', +} diff --git a/packages/i18n/src/messages/es/common.js b/packages/i18n/src/messages/es/common.js new file mode 100644 index 0000000..22736db --- /dev/null +++ b/packages/i18n/src/messages/es/common.js @@ -0,0 +1,34 @@ +/** common.* —— 跨页面通用文案(按钮 / 状态 / 导航 / 主题 / 语言)。es 与 en 同构。 */ +export default { + appName: 'IdearRun', + ok: 'OK', + cancel: 'Cancelar', + confirm: 'Confirmar', + back: 'Atrás', + next: 'Siguiente', + retry: 'Reintentar', + loading: 'Cargando…', + save: 'Guardar', + close: 'Cerrar', + error: { + generic: 'Algo salió mal. Inténtalo de nuevo.', + network: 'Error de red. Revisa tu conexión.', + }, + nav: { + explore: 'Explorar', + library: 'Biblioteca', + pricing: 'Precios', + login: 'Iniciar sesión', + getStarted: 'Empezar', + profile: 'Perfil', + logout: 'Cerrar sesión', + }, + theme: { + toLight: 'Cambiar a tema claro', + toDark: 'Cambiar a tema oscuro', + }, + language: { + label: 'Idioma', + options: { en: 'Inglés', zh: 'Chino', ja: 'Japonés', ko: 'Coreano', es: 'Español', pt: 'Portugués' }, + }, +} diff --git a/packages/i18n/src/messages/es/drama.js b/packages/i18n/src/messages/es/drama.js new file mode 100644 index 0000000..610c1e5 --- /dev/null +++ b/packages/i18n/src/messages/es/drama.js @@ -0,0 +1,60 @@ +/** drama.* —— 剧详情页(封面 / 简介 / 剧集列表 / 演员 / 相关推荐 / 开播 CTA)。 */ +export default { + loading: 'Cargando drama…', + error: 'No se pudo cargar este drama.', + retry: 'Reintentar', + notFound: 'Este drama ya no está disponible.', + breadcrumb: { + library: 'Biblioteca', + }, + badge: { + interactive: 'Interactivo', + }, + meta: { + episodes: '{count} episodios', + perEpisode: '~{minutes} min cada uno', + rating: '{score}', + plays: '{count} reproducciones', + }, + hero: { + start: 'Empezar a ver', + resume: 'Reanudar', + favorite: 'Favorito', + favorited: 'En favoritos', + share: 'Compartir', + shareCopied: 'Enlace copiado al portapapeles', + favoriteAdded: 'Añadido a tus favoritos', + favoriteRemoved: 'Eliminado de tus favoritos', + }, + episodes: { + title: 'Episodios', + season: 'Temporada {number}', + label: 'Episodio {number}', + viewAll: 'Ver los {count} episodios', + collapse: 'Mostrar menos episodios', + empty: 'Los episodios de este drama se están preparando.', + locked: 'Desbloquear para ver', + }, + interactivity: { + title: 'Reescribe la historia', + body: 'Este drama es totalmente interactivo. ¿No te gusta la decisión de un personaje? Usa la IA para generar una escena nueva al instante.', + cta: 'Probar el modo interactivo', + generating: 'Generando una escena nueva…', + }, + cast: { + title: 'Reparto principal', + empty: 'Información del reparto próximamente.', + }, + details: { + title: 'Detalles', + director: 'Director', + release: 'Estreno', + audio: 'Audio', + format: 'Formato', + language: 'Idioma', + }, + related: { + title: 'También te puede gustar', + empty: 'Aún no hay recomendaciones.', + }, +} diff --git a/packages/i18n/src/messages/es/feed.js b/packages/i18n/src/messages/es/feed.js new file mode 100644 index 0000000..8da5a11 --- /dev/null +++ b/packages/i18n/src/messages/es/feed.js @@ -0,0 +1,30 @@ +/** feed.* —— 刷剧信息流。es 与 en 同构。 */ +export default { + cta: 'Empieza a ver gratis', + empty: 'Aún no hay dramas aquí. Vuelve pronto.', + emptyHint: 'Cada día se estrenan nuevos dramas de IA: vuelve en un rato.', + loading: 'Cargando dramas…', + loadMore: 'Cargar más', + trending: 'Tendencia ahora', + likeCount: 'sin me gusta | {count} me gusta | {count} me gusta', + + episodeOf: 'Ep {current} de {total}', + byCreator: 'por {handle}', + rewrite: 'Reescribir escena', + rewriteAria: 'Reescribe esta escena a tu manera', + share: 'Compartir', + shareAria: 'Compartir este drama', + shareCopied: 'Enlace copiado al portapapeles', + shareFailed: 'No se pudo compartir ahora. Inténtalo de nuevo.', + liked: 'Añadido a tus favoritos', + unliked: 'Quitado de tus favoritos', + likeAria: 'Dar me gusta a este drama', + prevAria: 'Drama anterior', + nextAria: 'Drama siguiente', + openDrama: 'Abrir {title}', + + upNext: 'A continuación', + viewAll: 'Ver todo', + defaultCreator: 'Estudio IdearRun', + defaultSynopsis: 'Un drama tejido por IA que puedes pausar y reescribir en cualquier momento.', +} diff --git a/packages/i18n/src/messages/es/index.js b/packages/i18n/src/messages/es/index.js new file mode 100644 index 0000000..00f12be --- /dev/null +++ b/packages/i18n/src/messages/es/index.js @@ -0,0 +1,17 @@ +/** es message 聚合 —— 分命名空间文件聚合,与 en 同构 key 树。内部聚合,不对外深导入。 */ +import common from './common.js' +import auth from './auth.js' +import feed from './feed.js' +import player from './player.js' +import pay from './pay.js' +import drama from './drama.js' +import library from './library.js' +import onboarding from './onboarding.js' +import landing from './landing.js' +import account from './account.js' +import visualGame from './visual-game.js' +import legal from './legal.js' +import visualGameOverrides from './visual-game-overrides.generated.js' +import { mergeMessages } from '../merge-messages.js' + +export default { common, auth, feed, player, pay, drama, library, onboarding, landing, account, visualGame: mergeMessages(visualGame, visualGameOverrides), legal } diff --git a/packages/i18n/src/messages/es/landing.js b/packages/i18n/src/messages/es/landing.js new file mode 100644 index 0000000..3c88a4c --- /dev/null +++ b/packages/i18n/src/messages/es/landing.js @@ -0,0 +1,63 @@ +/** landing.* —— 营销落地页。es 与 en 同构。 */ +export default { + openDrama: 'Abrir {title}', + + hero: { + badge: 'IdearRun Player v2.0 en vivo', + titleLead: 'Mira dramas.', + titleAccent: 'Reescribe la historia.', + subtitle: + 'La primera plataforma de dramas 2D interactivos con IA. Interrumpe cualquier escena, reescribe el diálogo y observa cómo la IA ramifica la narrativa en tiempo real.', + ctaPrimary: 'Empieza a ver gratis', + ctaSecondary: 'Cómo funciona', + rewriteActive: 'Reescritura con IA activa', + rewriteSample: '«No, no entregaré los documentos. Tendrás que quitármelos.»', + }, + + bento: { + heading: 'Libera la inteligencia cinematográfica', + rewriteTitle: 'Interrumpe y reescribe con IA', + rewriteDesc: + 'Pausa cualquier escena, escribe una nueva dirección y el motor esquelético regenera la actuación al instante.', + rewriteBubble: '«Creo que deberíamos reconsiderar el trato.»', + rewriteGenerating: 'Generando una ruta alternativa…', + animTitle: 'Animación de marionetas 2D cinematográfica', + animDesc: 'El rigging dinámico se une a una iluminación atmosférica.', + voicesTitle: 'Voces con IA', + branchTitle: 'Historias sin fin', + trending: 'Dramas en tendencia', + }, + + how: { + browse: { + title: '1. Explora', + desc: 'Descubre una biblioteca de historias cinematográficas 2D en curso de diversos géneros.', + }, + interrupt: { + title: '2. Interrumpe', + desc: 'Pulsa reescribir en cualquier momento. Cambia el diálogo, el tono o las acciones del personaje.', + }, + rewrite: { + title: '3. La IA reescribe', + desc: 'Observa cómo la historia evoluciona al instante mientras el motor genera nuevas ramas.', + }, + }, + + featured: { + heading: 'Dramas destacados', + browseAll: 'Ver todos', + rewrites: '{count} reescrituras', + rewritesUnknown: 'Novedad', + genrePlaceholder: 'Drama interactivo con IA', + }, + + footer: { + copyright: '© 2024 IdearRun AI. Todos los derechos reservados. Inteligencia cinematográfica.', + terms: 'Términos', + privacy: 'Privacidad', + discord: 'Discord', + twitter: 'Twitter', + docs: 'Documentación', + language: 'Idioma:', + }, +} diff --git a/packages/i18n/src/messages/es/legal.js b/packages/i18n/src/messages/es/legal.js new file mode 100644 index 0000000..29d0e3b --- /dev/null +++ b/packages/i18n/src/messages/es/legal.js @@ -0,0 +1,16 @@ +export default { + privacy: { title: 'Política de privacidad', updated: 'Última actualización: 18 de agosto de 2026', intro: 'voko.world es una experiencia de drama generativo interactivo. Esta política explica qué datos recopilamos, cómo los usamos y qué opciones tienes.', sections: { + information: { title: 'Información que recopilamos', body: 'Recopilamos datos de la cuenta, como el correo electrónico, el nombre de perfil, los datos de inicio de sesión, el progreso del juego, las decisiones de la historia, los registros de vídeos generados y datos básicos del dispositivo o de registro necesarios para mantener el servicio seguro y fiable.' }, + usage: { title: 'Cómo usamos la información', body: 'Usamos la información para ofrecer el juego, guardar tu progreso, generar y mostrar vídeos de historias, enviar notificaciones sobre el producto o el estado de los vídeos, mejorar la seguridad y la calidad, prevenir abusos y responder a solicitudes de asistencia.' }, + generated: { title: 'Contenido generado', body: 'Tus instrucciones, decisiones, registros de historias y vídeos generados pueden ser procesados por nuestros sistemas y proveedores para crear, moderar, almacenar y ofrecer la experiencia.' }, + voice: { title: 'Entrada de voz', body: 'Cuando usas la entrada de voz, la grabación del micrófono se envía al servicio de voz a texto ByteDance Doubao para producir una transcripción. voko.world no conserva la grabación una vez terminada la transcripción. Puedes editar el texto antes de enviarlo como una acción del juego.' }, + sharing: { title: 'Uso compartido', body: 'No vendemos información personal. Solo compartimos la información necesaria con proveedores de servicios e infraestructura, herramientas de análisis o seguridad, autoridades cuando la ley lo exige, o cuando decides publicar o compartir contenido.' }, + retention: { title: 'Conservación', body: 'Conservamos la información durante el tiempo necesario para prestar voko.world, cumplir obligaciones legales, resolver disputas y proteger el servicio. Puedes contactarnos para solicitar la eliminación de tu cuenta o tus datos.' }, + choices: { title: 'Tus opciones', body: 'Puedes administrar tu cuenta, decidir si publicas los vídeos generados, cancelar comunicaciones opcionales y contactarnos para solicitar acceso, corrección o eliminación.' }, + contact: { title: 'Contacto', body: "Para consultas sobre privacidad, escribe a service{'@'}voko.world." }, + } }, + terms: { title: 'Términos del servicio', updated: 'Última actualización: 13 de agosto de 2026', intro: 'Estos términos regulan tu acceso y uso de voko.world. Al utilizar el servicio, aceptas estos términos.', sections: { + usage: { title: 'Uso de voko.world', body: 'Puedes usar voko.world con fines legales de entretenimiento personal y creación. Eres responsable de tu cuenta, tus decisiones y cualquier contenido que envíes o publiques.' }, accounts: { title: 'Cuentas y acceso', body: 'Debes proporcionar información de cuenta exacta y mantener seguras tus credenciales. Podemos suspender el acceso si detectamos abuso, riesgos de seguridad o incumplimientos de estos términos.' }, generated: { title: 'Historias y vídeos generados', body: 'voko.world puede generar texto, imágenes, audio o vídeo a partir de tus interacciones. Los resultados pueden variar y ser imperfectos. No debes considerarlos asesoramiento factual, legal, médico, financiero ni profesional.' }, rules: { title: 'Reglas de contenido', body: 'No envíes, solicites, publiques ni distribuyas contenido ilegal, abusivo, infractor, engañoso, sexualmente explotador o perjudicial para otras personas.' }, property: { title: 'Propiedad intelectual', body: 'voko.world, su software, diseño, marca y contenido del servicio son propiedad nuestra o de nuestros licenciantes. Sigues siendo responsable del contenido que proporcionas y nos concedes los derechos necesarios para operar, mostrar, mejorar y proteger el servicio.' }, availability: { title: 'Disponibilidad', body: 'Podemos cambiar, pausar o retirar partes del servicio. No somos responsables de interrupciones, pérdidas de datos causadas por hechos fuera de nuestro control ni fallos de servicios de terceros.' }, contact: { title: 'Contacto', body: "Puedes enviar preguntas sobre estos términos a contact{'@'}idearrun.com." }, + } }, + contact: { title: 'Contáctanos', updated: 'Soporte y comunidad de voko.world', intro: '¿Tienes una pregunta, una propuesta de colaboración, un error que comunicar o una inquietud sobre el contenido? Nos encantaría saber de ti.', sections: { email: { title: 'Correo electrónico', body: "service{'@'}voko.world" }, community: { title: 'Comunidad', body: 'Únete a Discord: https://discord.gg/WeESGyuUSX' }, social: { title: 'Redes sociales', body: 'Sigue a voko.world en X, YouTube, Instagram, TikTok y Facebook desde los enlaces del pie de página.' }, support: { title: 'Indicaciones para soporte', body: 'Para problemas con la cuenta o los vídeos, incluye el correo de tu cuenta, la URL de la página y una breve descripción de lo ocurrido. No envíes contraseñas ni credenciales de pago.' } } }, +} diff --git a/packages/i18n/src/messages/es/library.js b/packages/i18n/src/messages/es/library.js new file mode 100644 index 0000000..ba7b6f7 --- /dev/null +++ b/packages/i18n/src/messages/es/library.js @@ -0,0 +1,36 @@ +/** library.* —— 剧本库。es 与 en 同构。 */ +export default { + title: 'Biblioteca en tendencia', + searchPlaceholder: 'Buscar dramas, creadores, géneros...', + searchAria: 'Buscar en la biblioteca', + genresAria: 'Filtrar por género', + genres: { + all: 'Todos', + sciFi: 'Ciencia ficción', + romance: 'Romance', + thriller: 'Suspenso', + comedy: 'Comedia', + drama: 'Drama', + fantasy: 'Fantasía', + }, + sortLabel: 'Ordenar: {value}', + sort: { + trending: 'Tendencia', + newest: 'Más reciente', + mostViewed: 'Más vistos', + }, + loading: 'Cargando dramas...', + error: 'No se pudo cargar la biblioteca.', + retry: 'Reintentar', + empty: 'Ningún drama coincide con tu búsqueda.', + viewsCount: 'sin vistas | {count} vista | {count} vistas', + aiBadge: 'IA', + locked: 'Drama premium', + openAria: 'Abrir {title}', + moreAria: 'Más opciones para {title}', + brand: 'IdearRun', + footerTerms: 'Términos', + footerPrivacy: 'Privacidad', + footerSupport: 'Soporte', + copyright: '© {year} IdearRun AI. Todos los derechos reservados.', +} diff --git a/packages/i18n/src/messages/es/onboarding.js b/packages/i18n/src/messages/es/onboarding.js new file mode 100644 index 0000000..bb408de --- /dev/null +++ b/packages/i18n/src/messages/es/onboarding.js @@ -0,0 +1,60 @@ +/** onboarding.* —— 选型问卷(题材多选 / 进度 / 跳过)。es 与 en 同构。 */ +export default { + brand: 'IdearRun', + stepOf: 'Paso {current} de {total}', + progressLabel: 'Progreso de incorporación', + continue: 'Continuar', + finish: 'Finalizar', + back: 'Atrás', + skip: 'Omitir por ahora', + timeHint: 'Toma menos de 30 segundos', + lineupTitle: 'Creando tu lista personalizada...', + step: { + genres: { + title: '¿Qué géneros te encantan?', + subtitle: 'Elige algunos para afinar tu motor de teatro con IA.', + }, + pacing: { + title: '¿Qué tan rápido debe avanzar?', + subtitle: 'Ajustamos el ritmo de la historia a tu gusto.', + }, + length: { + title: '¿Cuánto te gusta ver?', + subtitle: 'Adaptamos cada sesión a tu agenda.', + }, + tone: { + title: '¿Cuál es tu ambiente?', + subtitle: 'Ajustamos la narración para marcar el tono.', + }, + }, + option: { + genres: { + romance: 'Romance', + thriller: 'Suspenso', + scifi: 'Ciencia ficción', + comedy: 'Comedia', + mystery: 'Misterio', + drama: 'Drama', + fantasy: 'Fantasía', + horror: 'Terror', + }, + pacing: { + slowburn: 'Lento', + steady: 'Constante', + fast: 'Ágil', + twisty: 'Lleno de giros', + }, + length: { + short: 'Cortos', + medium: 'Medio', + long: 'Inmersivos', + binge: 'Para maratón', + }, + tone: { + witty: 'Ingenioso', + intense: 'Intenso', + cozy: 'Acogedor', + epic: 'Épico', + }, + }, +} diff --git a/packages/i18n/src/messages/es/pay.js b/packages/i18n/src/messages/es/pay.js new file mode 100644 index 0000000..1f04ae5 --- /dev/null +++ b/packages/i18n/src/messages/es/pay.js @@ -0,0 +1,106 @@ +/** pay.* —— 解锁 / 订阅 / 支付。es 与 en 同构。 */ +export default { + unlock: 'Desbloquear este episodio', + subscribe: 'Suscribirse', + price: '{amount} / mes', + coins: 'sin monedas | {count} moneda | {count} monedas', + restore: 'Restaurar compras', + success: 'Pago realizado', + failed: 'Pago fallido. No se te cobró.', + + checkout: { + back: 'Volver a los planes', + brand: 'IdearRun', + subscribeTo: 'Suscribirse a', + perYear: '/año', + perMonth: '/mes', + lineItem: '{plan} — {amount}', + subtotal: 'Subtotal', + tax: 'Impuestos', + total: 'Total', + renewNotice: 'Se renueva a {amount} el {date}', + secure: 'Pago seguro', + + paymentDetails: 'Datos de pago', + expressApple: 'Apple Pay', + expressGoogle: 'Google Pay', + expressLink: 'Link', + orPayWithCard: 'o paga con tarjeta', + + emailLabel: 'Correo electrónico', + emailPlaceholder: 'tu{at}ejemplo.com', + emailInvalid: 'Introduce una dirección de correo válida.', + + cardLabel: 'Información de la tarjeta', + cardSecuredHeading: 'La entrada de la tarjeta la gestiona Stripe de forma segura', + cardSecuredBody: 'Nunca vemos ni almacenamos el número de tu tarjeta. Completarás el pago en el formulario seguro de Stripe.', + cardElementLoading: 'Cargando campo seguro de tarjeta…', + + nameLabel: 'Nombre del titular', + namePlaceholder: 'Nombre completo en la tarjeta', + + billingLabel: 'Dirección de facturación', + countryUS: 'Estados Unidos', + countryCA: 'Canadá', + countryGB: 'Reino Unido', + countryAU: 'Australia', + zipPlaceholder: 'Código postal', + + pay: 'Pagar {amount}', + paying: 'Redirigiendo al pago seguro…', + poweredBy: 'Con tecnología de {provider}', + + cancelled: 'Pago cancelado. No se te cobró.', + error: 'No se pudo iniciar el pago. Inténtalo de nuevo.', + }, + + paywall: { + title: 'Desbloquea esta historia', + body: 'Este camino requiere créditos. Desbloquéalo para revelar una nueva rama narrativa e interactuar con la escena mediante IA.', + unlock: 'Desbloquear con {count} créditos', + goVip: 'Hazte VIP para desbloqueos ilimitados', + balancePre: 'Tienes', + balancePost: 'créditos', + insufficient: 'Créditos insuficientes: recarga o hazte VIP.', + later: 'Quizás más tarde', + }, + + plans: { + title: 'Recarga o hazte premium', + subtitle: 'Elige un paquete de créditos para desbloqueos ocasionales o suscríbete a VIP para acceder a todas las historias ramificadas y funciones de generación con IA.', + confirm: 'Confirmar selección', + confirmHint: 'Continuar al pago seguro.', + selectHint: 'Selecciona un paquete de créditos o plan VIP para continuar.', + }, + + packs: { + credits: '{count} créditos', + standard: 'Paquete estándar', + popular: 'Popular', + bonus: 'Incluye {count} créditos de bonificación', + bestValue: 'Mejor valor - Ahorra {pct}%', + }, + + points: { + paid: 'Puntos pagados: {points}', bonus: 'Puntos extra: {points}', total: 'Puntos totales: {points}', buy: 'Comprar puntos', signInToBuy: 'Inicia sesión para comprar', insufficient: 'No tienes puntos suficientes.', shortfall: 'Te faltan {points} puntos.', orderCanceled: 'Cancelado', orderFailed: 'Pago fallido', + }, + vip: { + title: 'IdearRun VIP', + subtitle: 'Vive todo el potencial del teatro con IA.', + billingCycle: 'Ciclo de facturación', + monthly: 'Mensual', + yearly: 'Anual', + save: 'Ahorra {pct}%', + perMonth: '/ mes', + billedYearly: 'Facturado anualmente a {amount}', + billedMonthly: 'Facturado mensualmente, cancela cuando quieras', + cta: 'Empezar VIP', + selected: 'VIP seleccionado', + perks: { + unlimited: 'Desbloqueos de historias ilimitados', + endings: 'Acceso a finales exclusivos', + early: 'Acceso anticipado a nuevos dramas', + adfree: 'Experiencia de navegación sin anuncios', + }, + }, +} diff --git a/packages/i18n/src/messages/es/player.js b/packages/i18n/src/messages/es/player.js new file mode 100644 index 0000000..9e9ae04 --- /dev/null +++ b/packages/i18n/src/messages/es/player.js @@ -0,0 +1,51 @@ +/** player.* —— Reproducción y reescritura interactiva. es 与 en 同构。 */ +export default { + play: 'Reproducir', + pause: 'Pausar', + captions: 'Subtítulos', + volume: 'Volumen', + mute: 'Silenciar', + unmute: 'Activar sonido', + fullscreen: 'Pantalla completa', + exitFullscreen: 'Salir de pantalla completa', + settings: { + title: 'Ajustes de reproducción', + captionLanguage: 'Idioma de subtítulos', + }, + episode: 'Episodio {n}', + nextEpisode: 'Siguiente episodio', + replay: 'Repetir', + progress: 'Progreso de reproducción', + voiceLoading: 'Generando voz', + generating: 'Escribiendo la siguiente escena…', + ended: 'Llegaste al final. ¿Quieres más?', + subtitleEmpty: 'La escena se está desarrollando…', + like: 'Me gusta', + tip: 'Propina', + share: 'Compartir', + report: 'Reportar', + shareCopied: 'Enlace copiado al portapapeles', + reported: 'Gracias, recibimos tu reporte', + rewrite: { + title: 'Reescribir esta escena', + engineActive: 'Motor de IA activo', + hint: 'Interrumpe el flujo. Cambia el diálogo, el tono o las acciones.', + placeholder: "Dile a la IA cómo cambiar esta escena… p. ej. 'Haz que Mira suene más desesperada y saque un arma.'", + generate: 'Generar escena alternativa', + renderingHint: 'Renderizando el esqueleto {\'@\'} la voz de IA', + done: 'Nueva escena generada', + failed: 'No se pudo generar la escena. Inténtalo de nuevo.', + }, + cast: { + title: 'Reparto actual', + add: 'Añadir', + addPrompt: 'Añade un nuevo personaje a esta escena: ', + viewAll: 'Ver todo', + viewLess: 'Ver menos', + }, + upNext: { + title: "A continuación en '{drama}'", + empty: 'Aún no hay más episodios: interrumpe para seguir la historia.', + untitled: 'Episodio sin título', + }, +} diff --git a/packages/i18n/src/messages/es/visual-game-overrides.generated.js b/packages/i18n/src/messages/es/visual-game-overrides.generated.js new file mode 100644 index 0000000..8c425ad --- /dev/null +++ b/packages/i18n/src/messages/es/visual-game-overrides.generated.js @@ -0,0 +1,26 @@ +/** Generated reviewed Visual Game translations. */ +export default { + "account": { + "email": "Correo electrónico", + "mergeSeparator": ", " + }, + "videoCenter": { + "minutes": "{count} min" + }, + "visual": { + "hiddenTitle": "Tianyou", + "editor": "Editor", + "npcLine": "\"{line}\"", + "endingFallbackPoem": "..." + }, + "landing": { + "dramaTitle": "Drama" + }, + "landingCinema": { + "navPlayerStories": "Dramas", + "playerStories": "Dramas" + }, + "landingCard": { + "scriptFilter": "Drama" + } +} diff --git a/packages/i18n/src/messages/es/visual-game.js b/packages/i18n/src/messages/es/visual-game.js new file mode 100644 index 0000000..ab0ed50 --- /dev/null +++ b/packages/i18n/src/messages/es/visual-game.js @@ -0,0 +1,595 @@ +export default { + appName: 'voko.world', + productBrandSub: 'Tu kosmo te llama.', + mobileCredit: { title: 'Mis créditos', buyCredits: 'Comprar créditos', history: 'Historial de créditos', purchaseHistory: 'Compras', usageHistory: 'Uso', emptyHistory: 'Aún no hay movimientos.', loadMore: 'Cargar más', balanceAfter: 'Saldo: {points}', pending: 'Pendiente', paid: 'Pagado', canceled: 'Cancelado', failed: 'Fallido', refunded: 'Reembolsado', disputed: 'En disputa', autoReload: 'Recarga automática', bonusCredits: 'Créditos extra', buyNow: 'Comprar ahora', lowBalance: 'Saldo bajo', enabled: 'Activada', whenBelow: 'Cuando mi saldo sea inferior a', choosePack: 'Elige un paquete', consent: 'Autorizo a Voko a comprar automáticamente este paquete cuando mi saldo alcance el límite.', terms: 'Términos de uso', continueSecurely: 'Continuar de forma segura', save: 'Guardar ajustes', disable: 'Desactivar recarga', setupComplete: 'Método de pago guardado y recarga activada.', setupCanceled: 'Configuración cancelada.', actionRequired: 'La última recarga falló. Configura de nuevo el método de pago.', howTitle: 'Cómo funciona', howBalance: 'Compramos el paquete elegido cuando el saldo llega al límite.', howSecure: 'Stripe guarda los datos de pago; Voko no almacena el número de tarjeta.', howCancel: 'Puedes desactivarla cuando quieras. Las compras anteriores no se reembolsan.' }, + nav: { play: 'Partida', notes: 'Pistas', state: 'Estado', editor: 'Autoría' }, + scenario: { title: 'Escenario', available: 'Escenarios', assetManifest: 'Recursos' }, + character: { title: 'Personaje', start: 'Iniciar', current: 'Personaje actual' }, + stage: { empty: 'Elige escenario y personaje', turn: 'Turno {turn}', ending: 'Final', transcript: 'Registro' }, + composer: { act: 'Actuar', say: 'Decir', placeholder: 'Escribe una acción o frase' }, + actions: { refresh: 'Actualizar', start: 'Iniciar', home: 'Inicio', backHome: 'Volver', newRun: 'Reiniciar', send: 'Enviar', save: 'Guardar', compile: 'Compilar', publish: 'Publicar', rollback: 'Revertir', upload: 'Subir', select: 'Elegir', endingText: 'Texto final', load: 'Cargar', backToGame: 'Volver', cancel: 'Cancelar', confirm: 'Confirmar' }, + account: { + signIn: 'Iniciar sesión', + signedIn: 'Sesión iniciada', + signedOut: 'Sesión cerrada', + sessionExpired: 'Tu sesión expiró. Vuelve a iniciar sesión.', + logoutFailed: 'No se pudo cerrar sesión. Inténtalo de nuevo.', + registered: 'Registrado', + email: 'Email', + emailPlaceholder: 'Dirección de email', + password: 'Contraseña', + passwordPlaceholder: 'Introduce la contraseña', + confirmPassword: 'Confirmar contraseña', + confirmPasswordPlaceholder: 'Repite la contraseña', + verificationCode: 'Código de verificación', + verificationCodePlaceholder: 'Código de 6 dígitos', + inviteToken: 'Código de invitación', + inviteTokenPlaceholder: 'Introduce el código de invitación de 8 caracteres', + sendCode: 'Enviar código', + resendCode: 'Reenviar', + codeSent: 'Código enviado. Revisa tu email.', + codeRequired: 'Introduce primero el código de verificación.', + inviteInvalid: 'Introduce un código de invitación válido de 8 caracteres.', + emailRequired: 'Introduce primero tu email.', + emailInvalid: 'Introduce un email válido.', + passwordRequired: 'Introduce primero tu contraseña.', + confirmPasswordRequired: 'Confirma primero tu contraseña.', + loginFieldsRequired: 'Introduce tu email y contraseña.', + inviteEmailMismatch: 'Este email no coincide con el email de solicitud vinculado al código de invitación.', + passwordMismatch: 'Las contraseñas no coinciden.', + createAccount: 'Crear cuenta', + hasAccount: '¿Ya tienes cuenta?', + noAccount: '¿No tienes cuenta?', + termsHint: 'Al continuar confirmas que este email te pertenece y aceptas guardar progreso, pedidos y créditos.', + login: 'Entrar', + register: 'Registrarse', + reset: 'Restablecer', + forgotPassword: '¿Olvidaste la contraseña?', + resetPassword: 'Restablecer contraseña', + resetTitle: 'Restablecer contraseña', + resetBody: 'Verifica el código de email y define una nueva contraseña. Las sesiones existentes se revocarán.', + resetCodeSent: 'Código de restablecimiento enviado. Revisa tu email.', + resetFieldsRequired: 'Introduce el correo, el código, la nueva contraseña y su confirmación.', + passwordResetDone: 'Contraseña restablecida. Entra con la nueva contraseña.', + logout: 'Salir', + logoutAll: 'Salir en todos los dispositivos', + credits: 'Créditos', + buy: 'Comprar créditos', + billing: 'Facturación', + promptTitle: 'Inicia sesión o regístrate para empezar', + promptBody: 'Tu cuenta guarda progreso, partidas, finales, créditos y pedidos para continuar en otro dispositivo.', + signedInBody: 'Tu progreso, partidas guardadas y créditos se escriben en la base de datos.', + registerAndStart: 'Registrarse y elegir rol', + guestStart: 'Probar como invitado', + savedHint: 'Inicia sesión para guardar progreso y partidas', + currentAccount: 'Cuenta actual', + saveKicker: 'Guardado de cuenta', + saveTitle: 'Guardar progreso y pedidos', + loginTitle: 'Entrar', + loginBody: 'Después de entrar, se fusionará el progreso invitado de este dispositivo cuando sea posible.', + registerTitle: 'Crear cuenta', + registerBody: 'Regístrate con un código de invitación y verifica tu email. El registro inicia sesión, concede 100 créditos y fusiona el progreso del dispositivo.', + openBilling: 'Ver créditos y pedidos', + mergePending: 'Fusionando progreso invitado de este dispositivo...', + mergeSuccess: 'Datos locales fusionados: {details}.', + mergeSeparator: ', ', + mergeRuns: '{count} partidas', + mergeTurns: '{count} turnos', + mergeProgress: '{count} progresos', + mergeSaveSlots: '{count} guardados', + mergeEndingRecords: '{count} finales', + mergeEvents: '{count} eventos', + mergeAgentCalls: '{count} llamadas de IA', + mergeBillingLedger: '{count} movimientos de saldo', + mergeBillingUnlocks: '{count} desbloqueos pagos', + mergeBillingFreeQuota: '{count} cuotas gratuitas', + mergePaymentOrders: '{count} pedidos', + mergeEmpty: 'No hay progreso local para fusionar.', + mergeFailed: 'Sesión iniciada, pero el progreso local no se fusionó. Actualiza e inténtalo más tarde.', + }, + billing: { + title: 'Créditos y pedidos', + subtitle: 'Saldo, recargas, movimientos y pedidos vienen de la base de datos.', + guestSubtitle: 'Los invitados pueden ver paquetes. Comprar, saldo, pedidos y movimientos requieren sesión.', + balance: 'Saldo actual', + products: 'Paquetes de créditos', + ledger: 'Movimientos', + orders: 'Pedidos', + refresh: 'Actualizar facturación', + testModeTitle: 'Entorno de prueba de Stripe', + testModeBody: 'El pago usa el modo de prueba y no genera cargos reales.', + testPurchase: 'Compra de prueba', + buy: 'Comprar {points} créditos', + checkoutReady: 'El enlace de pago está listo.', + openCheckout: 'Abrir pago', + selectedPack: 'Seleccionado', + selectedProduct: '{title} seleccionado. Tu saldo aumentará en {points} créditos.', + signInToBuy: 'Entrar para comprar', + readyToBuy: 'Listo para comprar créditos', + readyToBuyBody: 'Elige un paquete y continúa al pago. Los pedidos pueden aparecer en proceso antes de confirmarse.', + checkoutPending: 'Redirigiendo al pago...', + checkoutReturnedSuccess: 'Has vuelto a la app. Actualizando saldo y pedidos; el abono depende del estado del pedido.', + checkoutReturnedCancel: 'El pago fue cancelado o no completado. No hay abono confirmado.', + signInHint: 'Inicia sesión para ver saldo, pedidos y movimientos.', + emptyProducts: 'No hay paquetes de créditos.', + emptyLedger: 'No hay movimientos.', + emptyOrders: 'No hay pedidos.', + productFallback: 'Paquete de créditos', + points: '{points} créditos', + price: '{amount} {currency}', + ledgerAmount: '{amount} créditos', + orderStatus: 'Estado {status}', + orderStatusPending: 'En proceso', + orderStatusPaid: 'Pagado', + orderStatusRefunded: 'Reembolsado', + orderStatusDisputed: 'En disputa', + orderStatusUnknown: 'Estado desconocido', + ledgerReasonSignupGift: 'Regalo de registro', + ledgerReasonCheckout: 'Compra de créditos', + ledgerReasonRefund: 'Reembolso de pedido', + ledgerReasonChargeback: 'Reversión por disputa', + ledgerReasonUnlock: 'Desbloqueo de contenido', + paywallTitle: 'Desbloqueo de pago', + paywallTarget: 'Objetivo', + paywallReason: 'Motivo del cargo', + paywallGenericReason: 'Se requieren créditos para continuar este contenido.', + paywallFreeContinue: 'Todavía tienes cupo gratis. Puedes continuar.', + paywallInsufficient: 'No tienes créditos suficientes. Compra créditos primero.', + paywallPrice: 'Créditos requeridos', + paywallFreeQuota: 'Cupo gratis restante', + paywallBalance: 'Saldo disponible', + }, + endings: { + title: 'Colección de finales', + unlocked: 'Desbloqueado', + locked: 'Bloqueado', + collected: 'Coleccionados {count}/{total}', + lockedHint: 'Alcanza este final para desbloquearlo.', + }, + saves: { + title: 'Guardados', + progress: 'Progreso actual', + activeRun: 'Partida actual', + slot: 'Espacio {slotNo}', + savedRun: 'Progreso guardado', + emptySlot: 'Espacio vacío', + emptyHint: 'Guarda la run actual para restaurarla tras recargar.', + saveHere: 'Guardar', + overwrite: 'Sobrescribir', + restore: 'Cargar', + delete: 'Eliminar', + saved: 'Guardado escrito.', + restored: 'Guardado cargado.', + deleted: 'Guardado eliminado.', + conflict: 'Ya existe un guardado. Confirma antes de sobrescribir este punto.', + restoreConfirm: 'Cargar cambiará la partida actual a este punto guardado.', + deleteConfirm: 'Eliminar este punto guardado no se puede deshacer.', + noRunHint: 'No hay una run actual para guardar. Inicia o continúa una primero.', + }, + map: { + title: 'Mapa', + current: 'Ubicación actual', + route: 'Ruta disponible', + routes: 'Destinos disponibles', + locked: 'Bloqueado', + moveTo: 'Ir a {location}', + objects: 'Objetos inspeccionables', + inspect: 'Inspeccionar', + inspected: 'Inspeccionado', + inspectObject: 'Inspeccionar {object}', + }, + videoCenter: { + eyebrow: 'Partidas de jugadores', + title: 'Centro de vídeos', + backHome: 'Volver', + chooseScenario: 'Elegir guion', + allScenarios: 'Todos los guiones', + searchScenario: 'Buscar guiones', + scenarioPaintedSkin: 'Piel pintada', + scenarioGuian: 'El caso del corazón perdido de Guian', + nowPlaying: 'Reproduciendo', + player: 'Jugador', + views: 'Visualizaciones', + ending: 'Final', + duration: 'Duración', + libraryEyebrow: 'Más de este guion', + allVideos: 'Todos los vídeos', + sort: 'Orden de vídeos', + recommended: 'Recomendados', + latest: 'Más recientes', + videoMeta: '{player} · {date}', + emptyTitle: 'Aún no hay vídeos', + emptyText: 'No hay partidas públicas para este guion.', + viewAll: 'Ver todos los vídeos de jugadores', + discoverStories: 'Descubre las decisiones de otros', + backPrevious: 'Volver', aiSummary: 'Resumen IA:', endingCount: ' finales', publicSeriesCount: ' series públicas', totalPlays: ' reproducciones', seriesList: 'Series de jugadores', completed: 'Completada', updating: 'Episodio {count} · En curso', seriesMetrics: '{episodes} episodios · ▶ {plays} · ♥ {likes}', watchSeries: 'Ver esta serie', playOwnSeries: 'Crea tu propia serie', minutes: '{count} min', share: 'Compartir', shareCopied: 'Enlace copiado', shareFailed: 'No se pudo compartir el video', like: 'Me gusta', liked: 'Te gusta', save: 'Guardar', saved: 'Guardado', storyHighlight: 'Lo mejor de la historia', startPlay: 'Jugar · Reescribir el final', episodeList: 'Episodios', episodeTotal: '{count} episodios', episodeTitle: 'Episodio {count} · {title}', + }, + runHistory: { + eyebrow: 'Registros personales', + title: 'Mis partidas', + allRuns: 'Todas las partidas', + count: '{count} registros', + empty: 'Aún no hay registros. Inicia una partida y aparecerá aquí.', + loadFailed: 'Error al cargar: {error}', + noTime: 'Hora desconocida', + statusActive: 'En curso', + statusCompleted: 'Finalizada', + videoNone: 'Sin vídeo', + videoQueued: 'Vídeo en cola', + videoRunning: 'Generando vídeo', + videoReady: 'Vídeo listo', + videoFailed: 'Vídeo fallido', + videoRetry: 'Generar de nuevo', + untitledEnding: 'Sin final todavía', + meta: '{scenario} · Turno {turn} · {date}', + openRun: 'Abrir partida', + openNotes: 'Notas', + watchVideo: 'Ver vídeo', + }, + mobileProfile: { title: 'MI PERFIL', settings: 'Ajustes', content: 'Contenido', benefits: 'Beneficios', creator: 'Creador', support: 'Consultas y soporte', contact: 'Atención al cliente', terms: 'Términos de uso', privacy: 'Política de privacidad', social: 'Redes sociales' }, + profileEditor: { editProfile: 'Editar perfil', nameSection: 'Nombre del perfil', nameHint: 'Este nombre aparece junto a tus juegos y vídeos.', name: 'Nombre', saveName: 'Guardar nombre', nameRequired: 'Introduce tu nombre.', nameSaved: 'Tu nombre se ha actualizado.', passwordSection: 'Cambiar contraseña', passwordHint: 'Confirma tu identidad con el código enviado a tu correo.', email: 'Correo electrónico', verificationCode: 'Código de verificación', sendCode: 'Enviar código', codeSent: 'Se envió un código a tu correo.', newPassword: 'Nueva contraseña', confirmPassword: 'Confirmar contraseña', savePassword: 'Actualizar contraseña', passwordRequirements: 'Introduce el código y una contraseña de al menos 8 caracteres.', passwordMismatch: 'Las contraseñas no coinciden.', passwordSaved: 'Tu contraseña se ha actualizado.' }, + myGames: { recent: 'Jugados recientemente', popular: 'Historias populares', emptyRecent: 'Tus historias jugadas recientemente aparecerán aquí.', loadError: 'No se pudieron cargar tus juegos. Inténtalo de nuevo.' }, + storyDetail: { title: 'Información de la historia', description: 'Descripción', episodes: '{count} episodios', endings: '{count} finales', by: 'Por {name}', lastUpdate: 'Última actualización', newPlay: 'Nueva partida', notFound: 'No se encontró esta historia.', loadError: 'No se pudo cargar la historia.' }, + accountCenter: { loadFailed: 'No se pudo cargar tu perfil', loadTimeout: 'La solicitud agotó el tiempo. Comprueba tu conexión e inténtalo de nuevo.', title: 'Centro personal', eyebrow: 'Perfil del jugador', runs: 'Mis juegos', videos: 'Colección de vídeos', favorites: 'Favoritos', watchSeason: 'Ver temporada', emptyGames: 'Todavía no has iniciado ningún juego.', emptyVideos: 'Aún no hay vídeos generados.', emptyFavorites: 'Aún no hay vídeos guardados.', lastPlayed: 'Última partida · {date}', currentTurn: 'En curso · Turno {turn}', noActiveRun: 'No hay partida activa', endingsUnlocked: 'Finales {unlocked}/{total}', runsPlayed: '{count} partidas', continueGame: 'Continuar juego', startGame: 'Iniciar juego', viewHistory: 'Ver historial', levelProgress: '{played}/{total} niveles jugados', viewSeasonDetails: 'Ver detalles de la temporada', hideSeasonDetails: 'Ocultar detalles', levelInProgress: 'En curso', levelCompleted: 'Completado', endingLabel: 'Final alcanzado', cluesLabel: 'Pistas obtenidas', clueCount: '{count} pistas', viewRunRecords: 'Ver registros del juego', runRecordSummary: '{clues} pistas · {ending} final', noEnding: 'Aún no se alcanzó ningún final', noClues: 'Aún no se encontraron pistas', seasonDetailsLoadFailed: 'No se pudo cargar la temporada. Inténtalo de nuevo.', public: 'Público', private: 'Solo yo', deleteVideo: 'Eliminar vídeo', confirmDelete: 'Confirmar eliminación', signInTitle: 'Inicia sesión', signInBody: 'Inicia sesión para gestionar tus juegos y vídeos.' }, + visual: { + eyebrow: 'TRPG Visual', + chooseScript: 'Elegir guion', + actTitle: 'Acto {count} · {title}', + actLocked: '{title} está bloqueado', + actSealed: 'Acto sellado', + toBeContinued: 'Continuará', + finishPreviousAct: 'Alcanza un final en el acto anterior', + revealAfterAct: 'Se revela al completar el acto {count}', + moreScripts: 'Más niveles', + comingSoon: 'Próximamente', + watchScriptVideo: 'Ver vídeo de la historia', + scenarioKicker: 'Guion', + hiddenTitle: 'Tianyou', + revealedTitle: 'Piel pintada', + objectives: 'Objetivos actuales', + playHistory: 'Registro de juego', + objectiveLabel: 'Objetivo', + close: 'Cerrar', + noObjectives: 'No hay objetivos activos.', + taskProgress: 'Pasos {done}/{total}', + entryKicker: 'Entrar al juego', + entryTitle: 'Entrar al cuarto esta noche', + emptyScenarioEyebrow: 'Guion no disponible', + emptyScenarioTitle: 'No hay guion jugable', + emptyScenarioText: 'El servidor no devolvió un guion publicado. Actualiza o revisa el estado de publicación.', + emptyScenarioActionTitle: 'Esperando publicación', + emptyScenarioActionText: 'La app no iniciará un guion local falso sin datos reales.', + continueTitle: 'Continuar partida', + entryBody: 'Elige un personaje antes de entrar en la vieja habitación.', + chooseCharacter: 'Elegir personaje', + enterStory: 'Entrar en la historia', + back: 'Volver', + skip: 'Saltar', + editor: 'Editor', + debugPage: 'Depurar', + state: 'Estado', + settings: 'Ajustes', + language: 'Idioma', + languageUnavailable: 'Este nivel aún no tiene una traducción disponible para ese idioma.', + turnVideoMatching: 'Emparejamiento inteligente de CG', + turnVideoMatchingHint: 'Al activarlo, se buscan CG de escenas similares para este turno. Desactivado, solo se reproducen CG vinculados exactamente.', + turnVideoMatchingOn: 'Activado', + turnVideoMatchingOff: 'Desactivado', + exitGame: 'Salir de la partida', + exitGameHint: 'Sal de la sesión actual y vuelve a la selección de escenarios.', + saveAndExit: 'Guardar y salir', + exitWithoutSaving: 'Salir sin guardar', + notes: 'Notas', + saves: 'Guardados', + continueRun: 'Continuar partida', + balanceEntry: 'Saldo {credits}', + endingCollection: 'Colección de finales', + narrator: 'Narrador', + npcName: 'Mujer en la habitación', + npcLine: '"{line}"', + notStarted: 'Sin iniciar', + startHint: 'Pulsa iniciar para entrar en la casa vieja.', + debug: 'Depurar atributos', + debugOnly: 'Solo depuración', + waiting: 'Esperando.', + modeAct: 'Hacer', + modeSay: 'Decir', + inputMethod: 'Método de entrada', + textInputMode: 'Texto', + voiceInputMode: 'Voz', + choiceGroupLabel: 'Opciones de historia', + inputPlaceholder: 'Ejemplo: levanto el espejo de bronce hacia ella y el retrato.', + mobileInputPlaceholder: 'Habla o actúa…', + voiceStart: 'Iniciar entrada de voz', + voiceStop: 'Detener entrada de voz', + voiceListening: 'Escuchando…', + voiceRecognizing: 'Reconociendo…', + voiceUnsupported: 'Este navegador no admite entrada de voz', + voicePermissionDenied: 'No se permitió el acceso al micrófono. Revisa los permisos del navegador', + voiceMicrophoneUnavailable: 'No se encontró un micrófono disponible', + voiceNoSpeech: 'No se reconoció ninguna voz. Inténtalo de nuevo', + voiceNoAudioCaptured: 'No se capturó audio del micrófono. Revisa los permisos del navegador', + voiceRecognitionFailed: 'Falló el reconocimiento de voz. Inténtalo de nuevo', + voiceHoldToTalk: 'Mantén pulsado para hablar', + voicePreparing: 'Preparando el micrófono…', + voiceShortcutLabel: 'Atajo', + voiceReleaseSend: 'Suelta para enviar', + voiceSlideCancel: 'Desliza hacia arriba para cancelar', + voiceReleaseCancel: 'Suelta para cancelar', + voiceUnderstanding: 'Interpretando tu instrucción…', + ending: 'Final', + endingLabel: 'Final', + endingLine: 'Final: {title}\n{description}', + endingSummary: 'Resumen del final', + endingPoem: 'Poema final', + endingFallbackPoem: '...', + endingBackgroundLoadFailed: 'No se pudo cargar la escena final. Actualiza la página e inténtalo de nuevo.', + recordSeal: 'Caso', + recordTitle: 'Registro del alguacil', + recordKicker: 'Archivo del condado', + recordMeta: 'Tras la noche lluviosa', + continue: 'Continuar', + enterNextEpisode: 'Entrar al siguiente episodio', + viewCurrentSeasonStory: 'Ver esta temporada', + played: 'Reproducido', + nextPage: 'Página siguiente', + statusCreating: 'Preparando tu historia...', + statusStarted: 'Iniciado. Escribe una frase o acción.', + statusHistoryRun: 'Viendo una partida histórica. Continuar usa el progreso del servidor.', + statusKp: 'El KP está juzgando...', + statusDone: 'Turno completado.', + contentFiltered: 'Esta acción no se puede describir. Intenta actuar de otra manera.', + turnFailed: 'No pudimos completar este paso. Inténtalo de nuevo.', + audioFailed: 'No se pudo reproducir la voz. Inténtalo de nuevo.', + actionRejected: 'Esa acción aún no puede surtir efecto. Prueba otro enfoque.', + statusEnded: 'Partida terminada.', + statusEndingText: 'Generando texto final...', + statusEndingLocked: 'Final determinado. Entrando al final...', + statusNpcAction: 'El NPC intenta actuar. Esperando juicio del KP...', + statusNpcDone: 'El NPC respondió.', + clueGained: 'Pista obtenida', + landscapeTitle: 'Gira tu dispositivo', + landscapeBody: 'Juega en horizontal para ver la escena y las imágenes CG completas.', + stats: { + suspicion: 'Sospecha', + charm: 'Encanto', + fear: 'Miedo', + }, + guide: { + label: 'Guía', + skip: 'Saltar', + next: 'Siguiente', + done: 'Entendido', + optionsTitle: 'Elegir directamente', + optionsText: 'Tras la respuesta del NPC aparecerán dos opciones sugeridas: decir o hacer.', + keyboardTitle: 'Cambiar a entrada por teclado', + keyboardText: 'La entrada de voz es la opción predeterminada. Pulsa el botón del teclado para escribir lo que dices o haces.', + inputTitle: 'Elegir Decir o Hacer', + inputText: 'Antes de escribir, elige si vas a hablar o actuar. El KP evaluará cualquiera de las dos opciones.', + notesTitle: 'Ver notas', + notesText: 'El botón de notas registra pistas descubiertas y finales desbloqueados.', + goalTitle: 'Sobrevive', + goalText: 'No es solo conversar. Escucha, pero observa la habitación, las pistas y lo extraño.', + videoTitle: 'Completa la historia y convierte tu aventura en un video único', + videoExampleLabel: 'Video de ejemplo', + videoExampleNotice: 'Este video es solo un ejemplo y no representa el contenido generado a partir de tu partida.', + videoLoading: 'Cargando un video de ejemplo…', + videoUnavailable: 'Esta historia todavía no tiene un video de ejemplo.', + videoDone: 'Comenzar mi historia', + }, + openingOptions: { + say: 'Perdón, no quise entrar. Algo afuera me estaba persiguiendo.', + act: 'Bajar la voz y revisar puertas, ventanas, la lámpara y la posición de ella.', + }, + }, + panels: { clues: 'Pistas', conclusions: 'Conclusiones', info: 'Información', inventory: 'Inventario', events: 'Eventos', stats: 'Atributos', flags: 'Marcas', objects: 'Objetos', npcs: 'NPC', endingRecords: 'Finales desbloqueados' }, + standalone: { + summary: 'Resumen', + availableClues: 'Pistas disponibles', + recentTurns: 'Turnos recientes', + audit: { + title: 'Auditoría de IA', + subtitle: 'Costos y fallos de llamadas KP, NPC, finales y TTS.', + totalCalls: 'Llamadas', + failedCalls: 'Fallidas', + tokens: 'Tokens entrada/salida', + cost: 'Costo', + roles: 'Resumen por rol', + models: 'Resumen por modelo', + recentCalls: 'Llamadas recientes', + empty: 'No hay llamadas de IA registradas.', + calls: '{count} llamadas', + tokenPair: 'tokens {tokens}', + }, + state: { + title: 'Vista de estado', + waiting: 'Esperando RunState.', + noRun: 'No hay runId. Abre esta página desde el juego o escribe un runId.', + loading: 'Cargando {runId}...', + loaded: '{runId} cargado', + }, + notes: { + title: 'Notas', + waiting: 'Cargando notas.', + noRun: 'No se encontró guardado actual. Inicia o continúa una partida primero.', + loading: 'Cargando {runId}...', + loaded: '{runId} cargado', + unavailable: 'Las notas de esta partida no están disponibles temporalmente.', + emptyClues: 'Aún no hay pistas', + emptyConclusions: 'Aún no hay conclusiones', + emptyEndings: 'Aún no hay finales', + tabs: 'Categorías de notas', + list: 'Lista de entradas', + detail: 'Detalle de entrada', + locked: 'Bloqueado', + lockedHint: 'Este final aún no se ha registrado.', + recorded: 'Registrado', + importance: { minor: 'Secundaria', major: 'Importante', critical: 'Crítica' }, + relatedClues: 'Pistas relacionadas', + noRelatedClues: 'Aún no se ha encontrado ninguna pista que apoye esta conclusión.', + }, + }, + editor: { + title: 'Autoría de escenario', + pack: 'Paquete', + section: 'Sección', + raw: 'JSON', + structured: 'Editor estructurado', + debugJson: 'JSON de depuración', + item: 'Elemento', + noStructuredFields: 'Esta sección aún no tiene campos estructurados editables.', + updated: 'Guardado', + assetUploaded: 'Recurso subido al borrador', + compiled: 'Borrador compilado', + published: 'Versión publicada', + rolledBack: 'Versión revertida', + versions: 'Versiones', + assets: 'Índice de recursos', + current: 'Actual', + currentVersion: 'Versión actual', + noPublishedVersion: 'Sin publicar', + noVersions: 'Aún no hay versiones. Compila un borrador primero.', + noAssets: 'Aún no hay índice de recursos. Compila el manifiesto primero.', + noFilteredAssets: 'Ningún recurso coincide con los filtros.', + assetSearch: 'Buscar', + assetSearchPlaceholder: 'Etiqueta, archivo u objectKey', + assetTypeFilter: 'Tipo', + assetSourceFilter: 'Origen', + assetFilterAll: 'Todos', + assetFilterResult: '{shown}/{total}', + assetSource: { + uploaded: 'Objeto subido', + static: 'Archivo estático', + missing: 'Sin localizador', + }, + uploadFile: 'Archivo', + uploadTag: 'Etiqueta', + uploadTagPlaceholder: 'opening_cg', + uploadType: 'Tipo', + noUploadFile: 'Sin archivo', + }, + waitingList: { + eyebrow: 'Acceso anticipado', + apply: 'Solicitar acceso', + useInvite: 'Usar código de invitación', + invitedRegister: '¿Tienes invitación? Crear cuenta', + title: 'Únete a la waiting list del servidor de juego', + body: 'Deja un email y un caso de uso. Operaciones emitirá invitaciones desde admin.', + displayName: 'Nombre', + displayNamePlaceholder: 'Opcional', + useCase: 'Caso de uso', + useCaseOptional: 'Caso de uso (opcional)', + useCasePlaceholder: 'Prueba de equipo, creador, evento', + useCaseRequired: 'El caso de uso es obligatorio.', + consent: 'Acepto recibir emails de invitación y producto para esta waiting list.', + join: 'Unirme', + joined: 'Solicitud recibida. Espera la notificación por email.', + joinedTitle: 'Solicitud enviada', + changeEmail: 'Usar otro email', + accountAlreadyRegistered: 'Este email ya está registrado. Inicia sesión.', + emailRequired: 'El email es obligatorio.', + consentRequired: 'Debes aceptar los emails antes de unirte.', + inviteEyebrow: 'Invitación', + inviteTitle: 'Crea tu cuenta de voko.world', + inviteBody: 'Esta invitación está vinculada a tu email. Define una contraseña para completar el registro.', + inviteMissing: 'Falta el token de invitación.', + inviteEnterCode: 'Introduce el código de invitación de 8 caracteres que recibiste para crear una cuenta.', + inviteValidate: 'Verificar código', + inviteReenter: 'Introducir otro código', + inviteInvalid: 'La invitación no es válida o expiró.', + codeSent: 'Código de verificación enviado.', + passwordMismatch: 'Las contraseñas no coinciden.', + acceptInvite: 'Crear cuenta', + }, + landing: { officialVideo: 'Ver el vídeo oficial', officialVideoEyebrow: 'Conoce VOKO', officialVideoTitle: 'Tu mundo te está llamando.', officialVideoBody: 'Crea tu propio mundo, vive historias que vuelven a llamarte y comparte cada momento como un vídeo que merece la pena ver.', homeLabel: 'Inicio de voko.world', navHome: 'Home', navScripts: 'Historias', navVideos: 'Vídeos', featuredStories: 'Historias destacadas', featuredStoriesEyebrow: 'Empieza a jugar', endingCount: '{count} finales', allStories: 'Todas las historias', dramaTitle: 'Drama', brandSub: 'Historias interactivas', navLabel: 'Navegación principal', navStories: 'Juegos', navGallery: 'Galería', eyebrow: 'Rol visual con IA', loadError: 'No se pudo cargar el inicio.', retry: 'Reintentar', emptyGames: 'Aún no hay juegos publicados.', scenarioFallback: 'Cambia la historia con tus decisiones.', publicVideos: 'vídeos de jugadores', publicGames: 'juegos públicos', watchDrama: 'Ver drama', enterPlay: 'Jugar', comingSoon: 'Próximamente', carouselLabel: 'Carrusel de juegos', previousGame: 'Juego anterior', nextGame: 'Juego siguiente', showcaseTitle: 'Historias creadas por jugadores', showcaseBody: 'Mira otros finales y crea tu propia versión.', searchLabel: 'Buscar historias', searchPlaceholder: 'Buscar por título', sortLabel: 'Orden', latest: 'Recientes', popular: 'Populares', videoCount: '{count} vídeos', levelCount: '{count} niveles', levelNumber: 'Nivel {count}', singleLevel: 'Nivel independiente', publicGame: 'Juego público', noSearchResults: 'No se encontraron historias', clearSearch: 'Borrar búsqueda', finalEyebrow: 'Tu historia te espera', finalTitle: '¿Quién serás?', finalBody: 'Cruza el umbral y pronuncia tus primeras palabras.', begin: 'Comenzar el viaje', footer: '© 2026 voko.world', earlyAccess: 'Acceso anticipado', footerSocial: 'Comunidad', footerLegal: 'Legal y contacto', privacy: 'Política de privacidad', terms: 'Términos del servicio', contact: 'Email de contacto' }, + landingCarousel: { introEyebrow: 'Juego dramático generativo', introSummary: 'Mira un drama breve, entra en el papel y cambia la historia con cada decisión.', dramaEyebrow: 'Drama destacado {count}', controls: 'Carrusel de inicio', goToSlide: 'Ir a la diapositiva {count}' }, + landingCinema: { navHome: 'Inicio', navDramas: 'Juego', navPlayerStories: 'Dramas', navMy: 'Yo', featuredDramas: 'Juegos', playerStories: 'Dramas' }, + searchPage: { placeholder: 'Buscar historias, dramas o cuentas', popular: 'Búsquedas populares', history: 'Búsquedas recientes', clearHistory: 'Borrar', resultTypes: 'Tipos de resultados', all: 'Todo', accounts: 'Cuenta', accountStats: '{titles} títulos · {plays} reproducciones' }, + landingIntro: { title: 'voko.world', apply: 'Solicitar acceso', watchVideo: 'Ver vídeos', start: 'Iniciar partida', continue: 'Continuar partida', viewJourney: 'Historial de juego', continueProgress: 'Temporada {season} {scenario}', storyLine: 'Línea narrativa', viewMore: 'Ver más' }, + storyTree: { + eyebrow: 'Líneas de temporada', + subtitle: 'Cada línea es una temporada y cada nodo es un nivel.', + root: 'Drama', + branch: 'Rama', + active: 'En curso', + completed: 'Completado', + locked: 'No alcanzado', + empty: 'Aún no hay líneas de temporada.', + loadError: 'No se pudieron cargar las líneas de temporada.', + navigation: 'Rango de temporadas', + range: 'Temporadas {first}–{last} · {total} en total', + newer: 'Anterior', + older: 'Siguiente', + startPlaying: 'Jugar ahora', + turnProgress: 'Turno {turn}', + continuePlaying: 'Continuar jugando', + playLog: 'Registro de juego', + seasonEnding: 'Final de temporada', + currentSeason: 'Temporada actual', + logLoadError: 'No se pudo cargar el registro.', + logEmpty: 'Esta temporada aún no tiene historial.', + turnNumber: 'Turno {turn}', + playerSaid: 'Dijiste', + playerDid: 'Hiciste', + storyOpening: 'Inicio de la historia', + turnVideos: 'Vídeos coincidentes', + playActionVideo: 'Vídeo de acción', + playPlayerVideo: 'Vídeo del jugador', + playNpcVideo: 'Vídeo del NPC', + replayTurn: 'Repetición', + replayTurnTitle: 'Repetición del turno {turn}', + replayProgress: 'Segmento {current}/{total}', + actionSegment: 'Acción', + playerSpeechSegment: 'Habla del jugador', + npcSpeechSegment: 'Habla del NPC', + pauseReplay: 'Pausar', + continueReplay: 'Continuar', + restartReplay: 'Repetir', + exportReplay: 'Exportar repetición', + replayQueued: 'En cola', + replayExporting: 'Exportando', + playFullReplay: 'Reproducir todo', + retryReplayExport: 'Reintentar exportación', + downloadReplay: 'Descargar MP4', + downloadSegment: 'Descargar este segmento', + downloadTurn: 'Descargar turno completo', + turnExporting: 'Preparando vídeo del turno', + turnExportError: 'Falló la exportación del turno.', + segmentExporting: 'Preparando segmento', + segmentExportError: 'Falló la exportación del segmento.', + fullReplayTitle: '{title} · Repetición completa', + replayExportError: 'Falló la exportación de la repetición.', + videoLoadError: 'No se pudo cargar el vídeo.', + tools: 'Buscar, filtrar y ordenar historias', + search: 'Buscar', + searchPlaceholder: 'Buscar nivel, final o acción reciente', + filter: 'Estado', + filterAll: 'Todo', + filterInProgress: 'En curso', + filterCompleted: 'Completado', + sort: 'Ordenar', + sortRecent: 'Jugado recientemente', + sortSeasonDesc: 'Temporada descendente', + sortSeasonAsc: 'Temporada ascendente', + resultCount: '{count} temporadas', + noResults: 'Ninguna historia coincide con los criterios.', + canvasHint: 'Arrastra el lienzo o desplázate para ver los niveles de la derecha', + canvasLabel: 'Lienzo del árbol de historia desplazable horizontalmente', + storySwitch: 'Historia', + }, + storyTreeCurrentSeasonHint: 'Progreso más reciente', + storyTreeViewDetails: 'Ver detalles de la temporada {number}', + storyTreeDelete: { action: 'Eliminar rama', confirm: '¿Eliminar la temporada {number} y todas sus ramas derivadas? Esta acción no se puede deshacer.', deleting: 'Eliminando…', error: 'No se pudo eliminar la rama.' }, + storyTreeVideoUnavailable: 'Vídeo no disponible', + storyTreeFork: { action: 'Volver a jugar desde aquí', confirm: 'Se creará una nueva rama desde el turno {turn}. El historial actual no cambiará.', failed: 'No se pudo crear la nueva rama. Inténtalo de nuevo.', creating: 'Creando…' }, + runVideoGeneration: { title: 'Generar vídeo completo', description: 'Genera un vídeo completo de esta partida. Los puntos se cobran al confirmar y se devuelven si falla la generación.', generate: 'Generar vídeo', regenerate: 'Generar de nuevo', retry: 'Intentar de nuevo', queued: 'En cola', running: 'Generando', price: 'Puntos necesarios', balance: 'Saldo actual', balanceAfter: 'Saldo después del cobro' }, + landingShowcase: { title: 'Vidas en escena', body: 'Descubre los destinos de otros jugadores, entra en la historia y escribe tu propio final.' }, + landingMetrics: { playCount: 'Reproducciones', likeCount: 'Me gusta' }, + landingCard: { level: 'Episodio', searchPlaceholder: 'Encuentra historias, vídeos o jugadores', scriptFilter: 'Drama', scriptAll: 'Todos los dramas', playerVideoCount: '{count} vídeos públicos', publishedAt: 'Publicado', publishedAll: 'Todo', publishedToday: 'Hoy', publishedWeek: 'Últimos 7 días', publishedMonth: 'Últimos 30 días', expandSynopsis: 'Ver más', collapseSynopsis: 'Ver menos' }, + playerPage: { loadError: 'No se pudieron cargar los vídeos del jugador.', notFound: 'No se encontró al jugador o no tiene vídeos públicos.', searchPlaceholder: 'Buscar dramas, niveles o temporadas' }, + season: { choose: 'Elige una temporada', chooseBody: 'Este episodio continuará la historia de la temporada elegida.', number: 'Temporada {number}', empty: 'Completa el episodio anterior de una temporada antes de continuar.' }, + status: { loading: 'Cargando', empty: 'Sin datos', error: 'Error al cargar', streaming: 'Procesando' }, + storiesPage: { tags: 'Etiquetas de historias', allTags: 'Todas', swipeForNextStory: 'Desliza hacia arriba para ver la siguiente historia' }, + creator: { nav: 'Crear', queued: 'En cola', attachmentTypeError: 'Sube un archivo TXT, PDF, Word, PNG, JPG o WEBP.', attachmentSizeError: 'El archivo adjunto no puede superar los 10 MB.', attachmentReadError: 'No se pudo leer el archivo adjunto.', generationFailed: 'Generación fallida', reviewRejected: 'Revisión rechazada', published: 'Publicado', archived: 'Archivado', composerTitle: '¿Qué mundo quieres crear hoy?', submitIdea: 'Enviar idea', examplesNote: 'Juegos publicados reales mostrados como referencia visual.', exampleStatus: 'Ejemplo publicado', projectsEyebrow: 'Visual Game Creator', projectsTitle: 'Mis juegos', projectsBody: 'Gestiona tus juegos y consulta su generación y revisión.', newGame: 'Crear juego', draftStatus: 'Borrador', continueEditing: 'Seguir editando', generatedScopes: 'elementos de generación', emptyTitle: 'Crea tu primer juego', emptyBody: 'Empieza con una idea, un guion o un mundo y conviértelo en un juego.', eyebrow: 'Visual Game Creator', title: 'Convierte tu historia en un mundo jugable', subtitle: 'Comparte una idea, un guion o un mundo. El sistema diseñará, producirá y validará el juego.', stepIdea: 'Idea', stepGenerate: 'Generar', stepReview: 'Revisión', ideaTitle: 'Describe tu juego', ideaBody: 'Escribe con naturalidad. No necesitas conocer JSON.', ideaLabel: 'Idea o guion', ideaHint: 'Incluye mundo, personajes, conflicto y experiencia.', ideaPlaceholder: 'Un viajero despierta en una posada donde todos recuerdan una noche diferente…', ideaError: 'Escribe al menos 20 caracteres.', languageLabel: 'Idioma', languages: { zh: 'Chino', en: 'Inglés', ja: 'Japonés', ko: 'Coreano' }, scopeTitle: 'Generar ahora', scopeStory: 'Historia y niveles', scopeStoryBody: 'Personajes, misiones, estados y finales', scopeVisuals: 'Medios visuales', scopeVisualsBody: 'Personajes, escenas, objetos y vídeos', scopeVoice: 'Voces', scopeVoiceBody: 'Jugador, NPC y narración', scopeError: 'Selecciona al menos un elemento.', assetsTitle: 'Material de referencia', assetsBody: 'Opcional. Ayuda a mantener la coherencia.', uploadAction: 'Añadir archivos', uploadHint: 'Un archivo · TXT, PDF, Word, PNG, JPG o WEBP · máximo 10 MB', supportedTitle: 'Material compatible', supportedBody: 'Imágenes, guiones, documentos, vídeo y audio.', removeFile: 'Eliminar {name}', saved: 'Borrador guardado.', draftNote: 'Esta primera versión guarda un borrador local.', save: 'Guardar borrador' }, + labels: { scenario: 'Escenario', asset: 'Recurso', mode: 'Modo', phase: 'Fase', location: 'Ubicación', noRun: 'Sin iniciar', none: 'Nada' }, +} diff --git a/packages/i18n/src/messages/index.js b/packages/i18n/src/messages/index.js new file mode 100644 index 0000000..3e459ab --- /dev/null +++ b/packages/i18n/src/messages/index.js @@ -0,0 +1,13 @@ +/** + * 各语言 message 汇总 —— 内部聚合,不对外深导入。 + * app 只经 `createI18n` 拿运行时,不直接 import 单语言资源。 + */ +import en from './en/index.js' +import zh from './zh/index.js' +import ja from './ja/index.js' +import ko from './ko/index.js' +import es from './es/index.js' +import pt from './pt/index.js' + +/** @type {Record>} */ +export const messages = { en, zh, ja, ko, es, pt } diff --git a/packages/i18n/src/messages/ja/account.js b/packages/i18n/src/messages/ja/account.js new file mode 100644 index 0000000..c7f0510 --- /dev/null +++ b/packages/i18n/src/messages/ja/account.js @@ -0,0 +1,88 @@ +/** account.* —— プロフィール(プロフィール / クレジット / サブスク / 履歴 / お気に入り / 設定)。ja。 */ +export default { + pageTitle: 'プロフィール', + backToApp: 'アプリに戻る', + + header: { + memberSince: '{date} に登録', + editAvatar: 'プロフィール写真を編集', + vip: 'VIP', + guestName: 'ゲスト', + }, + + stats: { + credits: '利用可能なクレジット', + watched: '視聴したドラマ', + rewritten: '書き換えたストーリー', + }, + + history: { + title: '続きを見る', + pageTitle: '視聴履歴', + subtitle: '前回の続きから再生できます。', + viewAll: 'すべて表示', + empty: '視聴中のドラマはありません。作品を選んで見始めましょう。', + browse: 'ドラマを探す', + episodeLine: '第{ep}話 • 残り{left}', + resume: '「{title}」を続きから再生', + }, + + favorites: { + title: '保存したドラマ', + pageTitle: '保存したドラマ', + subtitle: '後で見るためにブックマークした作品。', + manage: '管理', + empty: '保存したドラマはまだありません。ブックマークするとここに表示されます。', + browse: 'ドラマを探す', + discoverMore: 'もっと見つける', + open: '「{title}」を開く', + }, + + back: 'プロフィールに戻る', + + preferences: { + title: '設定', + language: '言語', + theme: 'テーマ', + light: 'ライト', + dark: 'ダーク', + }, + + subscription: { + renews: '{date} に更新', + inactive: '有効なサブスクリプションはありません', + pastDue: 'お支払いが期限切れです', + topUp: 'クレジットをチャージ', + manage: 'サブスクリプションを管理', + paymentMethod: 'お支払い方法', + upgrade: 'プランをアップグレード', + tierFree: '無料プラン', + }, + + account: { + title: 'アカウント', + notifications: '通知', + privacy: 'プライバシーとデータ', + downloadData: 'データをダウンロード', + deleteData: 'データを削除', + signOut: 'ログアウト', + }, + + actions: { + notificationsSoon: '通知設定は近日公開予定です。', + downloadStarted: 'データのエクスポートを準備しています。完了したらメールでリンクをお送りします。', + deleteTitle: 'データを削除しますか?', + deleteBody: 'この操作はアカウントデータを完全に削除し、元に戻すことはできません。', + deleteConfirm: 'データを削除', + deleteCancel: 'データを残す', + deleteRequested: 'データ削除のリクエストを受け付けました。まもなく処理されます。', + signedOut: 'ログアウトしました。', + languageChanged: '言語を更新しました。', + }, + + points: { + dailyClaimTitle: 'デイリーポイント', dailyClaimAvailable: '今日は {points} ポイントを受け取れます。', dailyClaimed: '本日のポイントは受取済みです。', claim: 'ポイントを受け取る', claimed: '受取済み', nextAvailable: '次回受取可能:{time}', balanceTurns: '{points} ポイント・{turns} ターンプレイ可能', + }, + loading: 'プロフィールを読み込んでいます…', + loadError: 'プロフィールを読み込めませんでした。もう一度お試しください。', +} diff --git a/packages/i18n/src/messages/ja/auth.js b/packages/i18n/src/messages/ja/auth.js new file mode 100644 index 0000000..b97c28b --- /dev/null +++ b/packages/i18n/src/messages/ja/auth.js @@ -0,0 +1,70 @@ +/** auth.* —— 登录 / 注册 / 身份合并。ja 与 en 同构。 */ +export default { + login: { + title: 'おかえりなさい', + subtitle: 'アカウントにアクセスするには情報を入力してください。', + submit: 'ログイン', + submitting: 'ログイン中…', + submitError: 'ログインできませんでした。情報を確認してもう一度お試しください。', + email: 'メールアドレス', + emailPlaceholder: "name{'@'}example.com", + emailRequired: 'メールアドレスを入力してください。', + invalidEmail: '有効なメールアドレスを入力してください。', + password: 'パスワード', + passwordRequired: 'パスワードを入力してください。', + forgot: 'パスワードをお忘れですか?', + showPassword: 'パスワードを表示', + hidePassword: 'パスワードを非表示', + }, + register: { + title: 'アカウントを作成', + subtitle: 'メールアドレスを認証して始めましょう。', + submit: '登録', + haveAccount: 'すでにアカウントをお持ちですか?', + codeSent: '認証コードを送信しました。受信トレイをご確認ください。', + }, + segment: { + signIn: 'ログイン', + createAccount: 'アカウント作成', + }, + social: { + divider: 'または次で続行', + google: 'Google で続行', + googleEnglish: 'Continue with Google', + email: 'メールで続行', + apple: 'Apple で続行', + }, + forgot: { + title: 'パスワードをリセット', + body: 'アカウントに登録したメールアドレスを入力すると、リセットコードをお送りします。', + emailLabel: 'メールアドレス', + submit: 'リセットコードを送信', + sent: 'リセットコードを送信しました。受信トレイをご確認ください。', + needEmail: '有効なメールアドレスを入力してください。', + error: 'リセットコードを送信できませんでした。もう一度お試しください。', + }, + legal: { + agree: '続行すると、以下に同意したことになります', + terms: '利用規約', + and: 'および', + privacy: 'プライバシーポリシー', + termsTitle: '利用規約', + privacyTitle: 'プライバシーポリシー', + termsBody: + 'IdearRun を利用することで、本サービスを合法的かつ個人的な娯楽目的にのみ使用することに同意したものとみなされます。インタラクティブドラマおよびあなたが生成したストーリーは、アプリ内での視聴に限り許諾され、再配布はできません。製品の進化に伴い本規約を更新する場合があります。継続して利用する場合、最新版に同意したものとみなされます。', + privacyBody: + 'IdearRun の運営に必要な最小限のデータのみを収集します。アカウントのメールアドレス、視聴履歴、レコメンドの個人化に使う操作の選択などです。あなたの個人データを販売することは一切ありません。匿名での視聴データは、あなたが登録を選択した場合にのみアカウントへ統合されます。データのエクスポートや削除はいつでも申請できます。', + }, + hero: { + brand: 'IdearRun', + titleA: '物語に踏み込む。', + titleB: '結末を書き換える。', + body: '次世代のインタラクティブドラマへ。あなたの選択がリアルタイムで物語を形作ります。', + }, + convert: { + title: '進捗を保存', + body: '登録すると「{name}」を続けて視聴し、全話をアンロックできます。', + }, + logout: 'ログアウト', + guest: 'ゲストとして続ける', +} diff --git a/packages/i18n/src/messages/ja/common.js b/packages/i18n/src/messages/ja/common.js new file mode 100644 index 0000000..dfc206d --- /dev/null +++ b/packages/i18n/src/messages/ja/common.js @@ -0,0 +1,34 @@ +/** common.* —— 跨页面通用文案(按钮 / 状态 / 导航 / 主题 / 语言)。ja 与 en 同构。 */ +export default { + appName: 'IdearRun', + ok: 'OK', + cancel: 'キャンセル', + confirm: '確認', + back: '戻る', + next: '次へ', + retry: '再試行', + loading: '読み込み中…', + save: '保存', + close: '閉じる', + error: { + generic: '問題が発生しました。もう一度お試しください。', + network: 'ネットワークエラー。接続を確認してください。', + }, + nav: { + explore: '見つける', + library: 'ライブラリ', + pricing: '料金', + login: 'ログイン', + getStarted: '始める', + profile: 'プロフィール', + logout: 'ログアウト', + }, + theme: { + toLight: 'ライトテーマに切り替え', + toDark: 'ダークテーマに切り替え', + }, + language: { + label: '言語', + options: { en: '英語', zh: '中国語', ja: '日本語', ko: '韓国語', es: 'スペイン語', pt: 'ポルトガル語' }, + }, +} diff --git a/packages/i18n/src/messages/ja/drama.js b/packages/i18n/src/messages/ja/drama.js new file mode 100644 index 0000000..aea81e0 --- /dev/null +++ b/packages/i18n/src/messages/ja/drama.js @@ -0,0 +1,60 @@ +/** drama.* —— 剧详情页(封面 / 简介 / 剧集列表 / 演员 / 相关推荐 / 开播 CTA)。 */ +export default { + loading: 'ドラマを読み込み中…', + error: 'このドラマを読み込めませんでした。', + retry: '再試行', + notFound: 'このドラマは現在ご利用いただけません。', + breadcrumb: { + library: 'ライブラリ', + }, + badge: { + interactive: 'インタラクティブ', + }, + meta: { + episodes: '全{count}話', + perEpisode: '各話 約{minutes}分', + rating: '{score}', + plays: '{count} 回再生', + }, + hero: { + start: '視聴を開始', + resume: '続きから', + favorite: 'お気に入り', + favorited: 'お気に入り済み', + share: '共有', + shareCopied: 'リンクをクリップボードにコピーしました', + favoriteAdded: 'お気に入りに追加しました', + favoriteRemoved: 'お気に入りから削除しました', + }, + episodes: { + title: 'エピソード', + season: 'シーズン{number}', + label: '第{number}話', + viewAll: '全{count}話を表示', + collapse: 'エピソードを折りたたむ', + empty: 'このドラマのエピソードは準備中です。', + locked: 'アンロックして視聴', + }, + interactivity: { + title: 'ストーリーを書き換える', + body: 'このドラマは完全インタラクティブ。キャラクターの選択が気に入らない?AI で新しいシーンを即座に生成しましょう。', + cta: 'インタラクティブモードを試す', + generating: '新しいシーンを生成中…', + }, + cast: { + title: '主なキャスト', + empty: 'キャスト情報は近日公開。', + }, + details: { + title: '詳細', + director: '監督', + release: '配信日', + audio: '音声', + format: 'フォーマット', + language: '言語', + }, + related: { + title: 'こちらもおすすめ', + empty: 'おすすめはまだありません。', + }, +} diff --git a/packages/i18n/src/messages/ja/feed.js b/packages/i18n/src/messages/ja/feed.js new file mode 100644 index 0000000..57617ff --- /dev/null +++ b/packages/i18n/src/messages/ja/feed.js @@ -0,0 +1,30 @@ +/** feed.* —— 刷剧信息流。ja 与 en 同构。 */ +export default { + cta: '無料で見る', + empty: 'まだドラマがありません。また後で見てください。', + emptyHint: '新しい AI ドラマが毎日登場します。少し経ってからまた来てください。', + loading: 'ドラマを読み込み中…', + loadMore: 'もっと見る', + trending: '人気急上昇', + likeCount: 'いいねなし | {count} いいね | {count} いいね', + + episodeOf: '第 {current} 話 / 全 {total} 話', + byCreator: '{handle} 制作', + rewrite: 'シーンを書き換える', + rewriteAria: 'このシーンを自分の発想で書き換える', + share: 'シェア', + shareAria: 'このドラマをシェア', + shareCopied: 'リンクをクリップボードにコピーしました', + shareFailed: '今はシェアできません。もう一度お試しください。', + liked: 'お気に入りに追加しました', + unliked: 'お気に入りから削除しました', + likeAria: 'このドラマにいいね', + prevAria: '前のドラマ', + nextAria: '次のドラマ', + openDrama: '「{title}」を開く', + + upNext: '次に見る', + viewAll: 'すべて見る', + defaultCreator: 'IdearRun スタジオ', + defaultSynopsis: 'いつでも一時停止して書き換えられる、AI が紡ぐドラマ。', +} diff --git a/packages/i18n/src/messages/ja/index.js b/packages/i18n/src/messages/ja/index.js new file mode 100644 index 0000000..48d018d --- /dev/null +++ b/packages/i18n/src/messages/ja/index.js @@ -0,0 +1,15 @@ +/** ja message 聚合 —— 分命名空间文件聚合,与 en 同构 key 树。内部聚合,不对外深导入。 */ +import common from './common.js' +import auth from './auth.js' +import feed from './feed.js' +import player from './player.js' +import pay from './pay.js' +import drama from './drama.js' +import library from './library.js' +import onboarding from './onboarding.js' +import landing from './landing.js' +import account from './account.js' +import visualGame from './visual-game.js' +import legal from './legal.js' + +export default { common, auth, feed, player, pay, drama, library, onboarding, landing, account, visualGame, legal } diff --git a/packages/i18n/src/messages/ja/landing.js b/packages/i18n/src/messages/ja/landing.js new file mode 100644 index 0000000..1769356 --- /dev/null +++ b/packages/i18n/src/messages/ja/landing.js @@ -0,0 +1,63 @@ +/** landing.* —— 営销落地页。ja 与 en 同构。 */ +export default { + openDrama: '{title} を開く', + + hero: { + badge: 'IdearRun Player v2.0 公開中', + titleLead: 'ドラマを観る。', + titleAccent: '物語を書き換える。', + subtitle: + '初のAIインタラクティブ2Dドラマプラットフォーム。どのシーンでも中断してセリフを書き換え、AIが物語をリアルタイムで分岐させる様子を見届けましょう。', + ctaPrimary: '無料で視聴を始める', + ctaSecondary: '仕組みを見る', + rewriteActive: 'AI 書き換え中', + rewriteSample: '「いいえ、書類は渡しません。奪うつもりならどうぞ。」', + }, + + bento: { + heading: 'シネマティックな知性を解き放つ', + rewriteTitle: 'AIで中断して書き換える', + rewriteDesc: + 'どのシーンも一時停止し、新しい方向性を入力すれば、スケルタルエンジンが瞬時に演技を再生成します。', + rewriteBubble: '「この取引は考え直すべきだと思う。」', + rewriteGenerating: '別の展開を生成中…', + animTitle: 'シネマティックな2Dパペットアニメーション', + animDesc: 'ダイナミックなリギングと情感豊かなライティング。', + voicesTitle: 'AIボイス', + branchTitle: '無限のストーリーライン', + trending: 'トレンドのドラマ', + }, + + how: { + browse: { + title: '1. 探す', + desc: 'さまざまなジャンルにわたる連載中の2Dシネマティックストーリーのライブラリを発見。', + }, + interrupt: { + title: '2. 中断する', + desc: 'いつでも書き換えをタップ。セリフ、雰囲気、キャラクターの行動を変更できます。', + }, + rewrite: { + title: '3. AIが書き換える', + desc: 'エンジンが新たな分岐を生成し、物語が瞬時に進化していく様子を見届けましょう。', + }, + }, + + featured: { + heading: '注目のドラマ', + browseAll: 'すべて見る', + rewrites: '{count} 回の書き換え', + rewritesUnknown: '新着', + genrePlaceholder: 'AIインタラクティブドラマ', + }, + + footer: { + copyright: '© 2024 IdearRun AI. All rights reserved. Cinematic Intelligence.', + terms: '利用規約', + privacy: 'プライバシー', + discord: 'Discord', + twitter: 'Twitter', + docs: 'ドキュメント', + language: '言語:', + }, +} diff --git a/packages/i18n/src/messages/ja/legal.js b/packages/i18n/src/messages/ja/legal.js new file mode 100644 index 0000000..46883b8 --- /dev/null +++ b/packages/i18n/src/messages/ja/legal.js @@ -0,0 +1,16 @@ +export default { + privacy: { title: 'プライバシーポリシー', updated: '最終更新日:2026年8月18日', intro: 'voko.world は生成 AI を活用したインタラクティブドラマ体験です。本ポリシーでは、収集する情報、その利用方法、およびお客様の選択肢について説明します。', sections: { + information: { title: '収集する情報', body: 'メールアドレス、プロフィール名、ログインデータ、ゲームの進行状況、ストーリー上の選択、生成動画の記録、およびサービスの安全性と信頼性の維持に必要な基本的な端末・ログ情報を収集します。' }, + usage: { title: '情報の利用目的', body: 'ゲームの提供、進行状況の保存、ストーリー動画の生成・表示、製品または動画状態の通知、安全性と品質の向上、不正利用の防止、サポート対応のために情報を利用します。' }, + generated: { title: '生成コンテンツ', body: '入力、選択、ストーリーログ、生成動画は、体験の作成、審査、保存、配信のため、当社のシステムおよびサービス提供者によって処理される場合があります。' }, + voice: { title: '音声入力', body: '音声入力を使用すると、マイクの録音は文字起こしのため ByteDance Doubao 音声認識サービスに送信されます。voko.world は文字起こし完了後に録音を保存しません。文字起こし結果はゲーム操作として送信する前に編集できます。' }, + sharing: { title: '情報の共有', body: '個人情報を販売することはありません。サービス提供者、インフラ事業者、分析・安全ツール、法令上必要な当局との共有、またはお客様自身がコンテンツの公開・共有を選択した場合に限り、必要な情報を共有します。' }, + retention: { title: '保存期間', body: 'voko.world の提供、法的義務の遵守、紛争の解決、サービスの保護に必要な期間、情報を保存します。アカウントまたはデータの削除は、当社にご連絡ください。' }, + choices: { title: 'お客様の選択', body: 'アカウントの管理、生成動画の公開・非公開の選択、任意の通知の配信停止ができます。情報の開示、訂正、削除については当社にご連絡ください。' }, + contact: { title: 'お問い合わせ', body: "プライバシーに関するご質問は service{'@'}voko.world までお送りください。" }, + } }, + terms: { title: '利用規約', updated: '最終更新日:2026年8月13日', intro: '本規約は voko.world へのアクセスおよび利用に適用されます。本サービスを利用することで、本規約に同意したものとみなされます。', sections: { + usage: { title: 'voko.world の利用', body: 'voko.world は、合法な個人の娯楽および創作目的で利用できます。お客様は、自身のアカウント、選択、送信または公開するコンテンツに責任を負います。' }, accounts: { title: 'アカウントとアクセス', body: '正確なアカウント情報を提供し、ログイン情報を安全に管理してください。不正利用、セキュリティリスク、または本規約への違反を検出した場合、アクセスを停止することがあります。' }, generated: { title: '生成ストーリーと動画', body: 'voko.world は操作内容に基づいてテキスト、画像、音声、動画を生成する場合があります。生成結果は一定ではなく、不完全な場合があります。事実、法律、医療、金融、その他の専門的助言として依拠しないでください。' }, rules: { title: 'コンテンツルール', body: '違法、侵害、権利侵害、詐欺、性的搾取、または他者に害を与えるコンテンツを送信、要求、公開、配布しないでください。' }, property: { title: '知的財産権', body: 'voko.world のソフトウェア、デザイン、ブランド、サービスコンテンツは、当社または許諾者に帰属します。お客様は自身が提供するコンテンツに責任を持ち、サービスの運営、表示、改善、保護に必要な権利を当社に許諾します。' }, availability: { title: 'サービスの提供', body: '当社はサービスの一部を変更、一時停止、終了する場合があります。当社の管理を超える事象による中断やデータ損失、第三者サービスの障害について、当社は責任を負いません。' }, contact: { title: 'お問い合わせ', body: "本規約に関するご質問は contact{'@'}idearrun.com までお送りください。" }, + } }, + contact: { title: 'お問い合わせ', updated: 'voko.world サポートとコミュニティ', intro: 'ご質問、パートナーシップのご提案、不具合のご報告、コンテンツに関するご相談があればご連絡ください。', sections: { email: { title: 'メール', body: "service{'@'}voko.world" }, community: { title: 'コミュニティ', body: 'Discord に参加:https://discord.gg/WeESGyuUSX' }, social: { title: 'ソーシャルメディア', body: 'フッターのリンクから X、YouTube、Instagram、TikTok、Facebook の voko.world をフォローできます。' }, support: { title: 'サポートへのご連絡', body: 'アカウントまたは動画の問題については、アカウントのメールアドレス、ページ URL、発生した問題の簡単な説明をお送りください。パスワードや決済情報は送信しないでください。' } } }, +} diff --git a/packages/i18n/src/messages/ja/library.js b/packages/i18n/src/messages/ja/library.js new file mode 100644 index 0000000..6dd74f9 --- /dev/null +++ b/packages/i18n/src/messages/ja/library.js @@ -0,0 +1,36 @@ +/** library.* —— 剧本库。ja 与 en 同构。 */ +export default { + title: '人気のライブラリ', + searchPlaceholder: 'ドラマ・クリエイター・ジャンルを検索...', + searchAria: 'ライブラリを検索', + genresAria: 'ジャンルで絞り込む', + genres: { + all: 'すべて', + sciFi: 'SF', + romance: 'ロマンス', + thriller: 'スリラー', + comedy: 'コメディ', + drama: 'ドラマ', + fantasy: 'ファンタジー', + }, + sortLabel: '並び替え:{value}', + sort: { + trending: '急上昇', + newest: '新着', + mostViewed: '再生回数順', + }, + loading: 'ドラマを読み込み中...', + error: 'ライブラリを読み込めませんでした。', + retry: '再試行', + empty: '検索条件に一致するドラマがありません。', + viewsCount: '再生なし | {count} 回再生 | {count} 回再生', + aiBadge: 'AI', + locked: 'プレミアムドラマ', + openAria: '{title} を開く', + moreAria: '{title} のその他の操作', + brand: 'IdearRun', + footerTerms: '利用規約', + footerPrivacy: 'プライバシー', + footerSupport: 'サポート', + copyright: '© {year} IdearRun AI All rights reserved.', +} diff --git a/packages/i18n/src/messages/ja/onboarding.js b/packages/i18n/src/messages/ja/onboarding.js new file mode 100644 index 0000000..351541f --- /dev/null +++ b/packages/i18n/src/messages/ja/onboarding.js @@ -0,0 +1,60 @@ +/** onboarding.* —— 选型问卷(题材多选 / 进度 / 跳过)。ja 与 en 同构。 */ +export default { + brand: 'IdearRun', + stepOf: 'ステップ {current} / {total}', + progressLabel: 'オンボーディングの進捗', + continue: '次へ', + finish: '完了', + back: '戻る', + skip: '今はスキップ', + timeHint: '30秒もかかりません', + lineupTitle: 'あなた専用のラインナップを準備中…', + step: { + genres: { + title: 'どんなジャンルが好きですか?', + subtitle: 'いくつか選んで、AIシアターエンジンを調整しましょう。', + }, + pacing: { + title: '展開のテンポは?', + subtitle: 'お好みに合わせて物語のテンポを調整します。', + }, + length: { + title: 'どのくらいの長さが好み?', + subtitle: 'あなたの時間に合わせて各話の長さを調整します。', + }, + tone: { + title: 'どんな雰囲気が好き?', + subtitle: 'ナレーションを調整してムードを演出します。', + }, + }, + option: { + genres: { + romance: 'ロマンス', + thriller: 'スリラー', + scifi: 'SF', + comedy: 'コメディ', + mystery: 'ミステリー', + drama: 'ドラマ', + fantasy: 'ファンタジー', + horror: 'ホラー', + }, + pacing: { + slowburn: 'じっくり', + steady: '安定', + fast: 'スピーディ', + twisty: 'どんでん返し', + }, + length: { + short: '短め', + medium: 'ふつう', + long: 'じっくり長編', + binge: '一気見向き', + }, + tone: { + witty: '軽妙', + intense: '緊迫', + cozy: 'ほっこり', + epic: '壮大', + }, + }, +} diff --git a/packages/i18n/src/messages/ja/pay.js b/packages/i18n/src/messages/ja/pay.js new file mode 100644 index 0000000..ec970b2 --- /dev/null +++ b/packages/i18n/src/messages/ja/pay.js @@ -0,0 +1,106 @@ +/** pay.* —— 解锁 / 订阅 / 支付。ja 与 en 同构。 */ +export default { + unlock: 'この話をアンロック', + subscribe: '購読', + price: '{amount} / 月', + coins: 'コインなし | {count} コイン | {count} コイン', + restore: '購入を復元', + success: '支払い完了', + failed: '支払いに失敗しました。請求はされていません。', + + checkout: { + back: 'プランに戻る', + brand: 'IdearRun', + subscribeTo: '購読プラン', + perYear: '/年', + perMonth: '/月', + lineItem: '{plan} — {amount}', + subtotal: '小計', + tax: '税', + total: '合計', + renewNotice: '{date} に {amount} で更新されます', + secure: '安全な決済', + + paymentDetails: 'お支払い情報', + expressApple: 'Apple Pay', + expressGoogle: 'Google Pay', + expressLink: 'Link', + orPayWithCard: 'またはカードで支払う', + + emailLabel: 'メールアドレス', + emailPlaceholder: 'you{at}example.com', + emailInvalid: '有効なメールアドレスを入力してください。', + + cardLabel: 'カード情報', + cardSecuredHeading: 'カード入力は Stripe が安全に処理します', + cardSecuredBody: 'カード番号を当社が閲覧・保存することはありません。お支払いは Stripe の安全なフォームで完了します。', + cardElementLoading: '安全なカード入力欄を読み込み中…', + + nameLabel: 'カード名義', + namePlaceholder: 'カード上の氏名', + + billingLabel: '請求先住所', + countryUS: 'アメリカ合衆国', + countryCA: 'カナダ', + countryGB: 'イギリス', + countryAU: 'オーストラリア', + zipPlaceholder: '郵便番号', + + pay: '{amount} を支払う', + paying: '安全な決済へリダイレクト中…', + poweredBy: 'Powered by {provider}', + + cancelled: '決済をキャンセルしました。請求はされていません。', + error: '決済を開始できませんでした。もう一度お試しください。', + }, + + paywall: { + title: 'このストーリーをアンロック', + body: 'この分岐にはクレジットが必要です。アンロックして新しい物語の分岐を開き、AI でシーンと対話しましょう。', + unlock: '{count} クレジットでアンロック', + goVip: 'VIP で無制限アンロック', + balancePre: '保有', + balancePost: 'クレジット', + insufficient: 'クレジット不足です。チャージするか VIP にご加入ください。', + later: 'あとで', + }, + + plans: { + title: 'チャージまたはプレミアムへ', + subtitle: '都度アンロック用のクレジットパックを選ぶか、VIP に登録して全ての分岐ストーリーと AI 生成機能をフル活用しましょう。', + confirm: '選択を確定', + confirmHint: '安全な決済へ進みます。', + selectHint: 'クレジットパックまたは VIP プランを選択してください。', + }, + + packs: { + credits: '{count} クレジット', + standard: '標準パック', + popular: '人気', + bonus: 'ボーナス {count} クレジット込み', + bestValue: 'お得 - {pct}% オフ', + }, + + points: { + paid: '購入ポイント:{points}', bonus: 'ボーナスポイント:{points}', total: '合計ポイント:{points}', buy: 'ポイントを購入', signInToBuy: 'ログインして購入', insufficient: 'ポイント残高が不足しています。', shortfall: 'あと {points} ポイント必要です。', orderCanceled: 'キャンセル済み', orderFailed: '支払い失敗', + }, + vip: { + title: 'IdearRun VIP', + subtitle: 'AI シアターの真価を体験。', + billingCycle: '請求サイクル', + monthly: '月額', + yearly: '年額', + save: '{pct}% オフ', + perMonth: '/ 月', + billedYearly: '年額 {amount} で請求', + billedMonthly: '月額請求、いつでも解約可', + cta: 'VIP を始める', + selected: 'VIP 選択中', + perks: { + unlimited: 'ストーリーの無制限アンロック', + endings: '限定エンディングへのアクセス', + early: '新作ドラマへの先行アクセス', + adfree: '広告なしの閲覧体験', + }, + }, +} diff --git a/packages/i18n/src/messages/ja/player.js b/packages/i18n/src/messages/ja/player.js new file mode 100644 index 0000000..5c2b1c9 --- /dev/null +++ b/packages/i18n/src/messages/ja/player.js @@ -0,0 +1,51 @@ +/** player.* —— 再生とインタラクティブ続き書き。ja 与 en 同构。 */ +export default { + play: '再生', + pause: '一時停止', + captions: '字幕', + volume: '音量', + mute: 'ミュート', + unmute: 'ミュート解除', + fullscreen: '全画面表示', + exitFullscreen: '全画面を終了', + settings: { + title: '再生設定', + captionLanguage: '字幕の言語', + }, + episode: '第 {n} 話', + nextEpisode: '次の話', + replay: '最初から再生', + progress: '再生進捗', + voiceLoading: '音声生成中', + generating: '次のシーンを書いています…', + ended: '最後まで来ました。もっと見ますか?', + subtitleEmpty: 'シーンが展開中…', + like: 'いいね', + tip: 'チップ', + share: 'シェア', + report: '報告', + shareCopied: 'リンクをクリップボードにコピーしました', + reported: 'ご報告ありがとうございます。受け付けました', + rewrite: { + title: 'このシーンを書き換える', + engineActive: 'AI エンジン稼働中', + hint: '流れを中断して、セリフ・雰囲気・動きを変えましょう。', + placeholder: 'AI にこのシーンの変え方を伝えてください…例:「Mira をもっと必死に、武器を取り出させて」。', + generate: '別の展開を生成', + renderingHint: 'スケルトンリグ {\'@\'} AI ボイスを生成中', + done: '新しいシーンを生成しました', + failed: 'シーンを生成できませんでした。もう一度お試しください。', + }, + cast: { + title: '現在のキャスト', + add: 'キャラ追加', + addPrompt: 'このシーンに新しいキャラクターを追加:', + viewAll: 'すべて表示', + viewLess: '折りたたむ', + }, + upNext: { + title: "『{drama}』の次のエピソード", + empty: 'まだ続きはありません——中断して物語を進めましょう。', + untitled: '無題のエピソード', + }, +} diff --git a/packages/i18n/src/messages/ja/visual-game.js b/packages/i18n/src/messages/ja/visual-game.js new file mode 100644 index 0000000..fd116c5 --- /dev/null +++ b/packages/i18n/src/messages/ja/visual-game.js @@ -0,0 +1,595 @@ +export default { + appName: 'voko.world', + productBrandSub: 'あなたのkosmoが呼んでいる。', + mobileCredit: { title: 'マイクレジット', buyCredits: 'クレジット購入', history: 'クレジット履歴', purchaseHistory: '購入履歴', usageHistory: '利用履歴', emptyHistory: '履歴はありません。', loadMore: 'さらに読み込む', balanceAfter: '残高:{points}', pending: '処理中', paid: '支払い済み', canceled: 'キャンセル済み', failed: '失敗', refunded: '返金済み', disputed: '異議申立中', autoReload: '自動リロード', bonusCredits: 'ボーナスクレジット', buyNow: '今すぐ購入', lowBalance: '残高不足', enabled: '有効', whenBelow: '残高が次を下回ったら', choosePack: 'リロードパックを選択', consent: '残高がしきい値に達した際、選択したパックを自動購入することを許可します。', terms: '利用規約', continueSecurely: '安全に続行', save: '設定を保存', disable: '自動リロードをオフ', setupComplete: '支払い方法を保存し、自動リロードを有効にしました。', setupCanceled: '設定をキャンセルしました。', actionRequired: '前回のリロードに失敗しました。支払い方法を再設定してください。', howTitle: '自動リロードの仕組み', howBalance: '残高がしきい値以下になると、選択したパックを購入します。', howSecure: '支払い情報は Stripe が安全に保存し、Voko はカード番号を保存しません。', howCancel: 'いつでも停止できます。過去の購入は返金されません。' }, + nav: { play: 'プレイ', notes: '手掛かり', state: '状態', editor: '編集' }, + scenario: { title: 'シナリオ', available: 'シナリオ', assetManifest: '素材一覧' }, + character: { title: 'キャラクター', start: '開始', current: '現在のキャラクター' }, + stage: { empty: 'シナリオとキャラクターを選択', turn: 'ターン {turn}', ending: 'エンディング', transcript: '記録' }, + composer: { act: '行動', say: '発言', placeholder: '行動または発言を入力' }, + actions: { refresh: '更新', start: '開始', home: 'ホーム', backHome: '戻る', newRun: '再開', send: '送信', save: '保存', compile: 'コンパイル', publish: '公開', rollback: 'ロールバック', upload: 'アップロード', select: '選択', endingText: 'エンディング文', load: '読み込む', backToGame: '戻る', cancel: 'キャンセル', confirm: '確認' }, + account: { + signIn: 'ログイン', + signedIn: 'ログイン中', + signedOut: 'ログアウトしました', + sessionExpired: 'セッションの有効期限が切れました。もう一度ログインしてください。', + logoutFailed: 'ログアウトに失敗しました。もう一度お試しください。', + registered: '登録完了', + email: 'メール', + emailPlaceholder: 'メールアドレス', + password: 'パスワード', + passwordPlaceholder: 'パスワードを入力', + confirmPassword: 'パスワード確認', + confirmPasswordPlaceholder: 'もう一度入力', + verificationCode: '確認コード', + verificationCodePlaceholder: '6 桁のメール確認コード', + inviteToken: '招待コード', + inviteTokenPlaceholder: '8 文字の招待コードを入力', + sendCode: 'コード送信', + resendCode: '再送信', + codeSent: '確認コードを送信しました。メールを確認してください。', + codeRequired: 'メール確認コードを入力してください。', + inviteInvalid: '有効な 8 文字の招待コードを入力してください。', + emailRequired: '先にメールを入力してください。', + emailInvalid: '有効なメールアドレスを入力してください。', + passwordRequired: '先にパスワードを入力してください。', + confirmPasswordRequired: '先に確認用パスワードを入力してください。', + loginFieldsRequired: 'メールアドレスとパスワードを入力してください。', + inviteEmailMismatch: 'このメールアドレスは招待コードに紐づく申請メールと一致しません。', + passwordMismatch: 'パスワードが一致しません。', + createAccount: 'アカウント作成', + hasAccount: 'アカウントをお持ちですか?', + noAccount: 'アカウントはまだありませんか?', + termsHint: '続行すると、このメールが本人所有であることを確認し、進行、注文、ポイント保存に同意します。', + login: 'ログイン', + register: '登録', + reset: 'リセット', + forgotPassword: 'パスワードをお忘れですか?', + resetPassword: 'パスワードをリセット', + resetTitle: 'パスワードをリセット', + resetBody: 'メール確認コードを入力して新しいパスワードを設定します。既存セッションはすべて無効になります。', + resetCodeSent: 'リセット用コードを送信しました。メールを確認してください。', + resetFieldsRequired: 'メールアドレス、確認コード、新しいパスワード、確認用パスワードを入力してください。', + passwordResetDone: 'パスワードをリセットしました。新しいパスワードでログインしてください。', + logout: 'ログアウト', + logoutAll: 'すべての端末からログアウト', + credits: 'ポイント', + buy: 'ポイント購入', + billing: '請求', + promptTitle: 'ログインまたは登録して開始', + promptBody: '進行、セーブ、エンディング、ポイント、注文をアカウントに保存し、別の端末でも続行できます。', + signedInBody: '進行、セーブ、ポイントはデータベースに保存されます。', + registerAndStart: '登録して役を選ぶ', + guestStart: 'ゲストで試す', + savedHint: 'ログインすると進行とセーブを自動保存', + currentAccount: '現在のアカウント', + saveKicker: 'アカウント保存', + saveTitle: '進行と注文を保存', + loginTitle: 'ログイン', + loginBody: 'ログイン後、この端末のゲスト進行を可能な範囲で統合します。', + registerTitle: 'アカウント作成', + registerBody: '招待コードとメールの所有を確認してからアカウントを作成します。登録後は自動ログインし、100 ポイントを付与して端末進行を統合します。', + openBilling: 'ポイントと注文を見る', + mergePending: 'この端末のゲスト進行を統合中…', + mergeSuccess: '端末データを統合しました:{details}。', + mergeSeparator: '、', + mergeRuns: '{count} 件の対局', + mergeTurns: '{count} 件のターン', + mergeProgress: '{count} 件の進行', + mergeSaveSlots: '{count} 件のセーブ', + mergeEndingRecords: '{count} 件のエンディング', + mergeEvents: '{count} 件のイベント', + mergeAgentCalls: '{count} 件の AI 呼び出し', + mergeBillingLedger: '{count} 件のポイント履歴', + mergeBillingUnlocks: '{count} 件の有料解放', + mergeBillingFreeQuota: '{count} 件の無料枠', + mergePaymentOrders: '{count} 件の注文', + mergeEmpty: '統合できる端末進行はありません。', + mergeFailed: 'ログイン済みですが、端末進行はまだ統合されていません。後で更新してください。', + }, + billing: { + title: 'ポイントと注文', + subtitle: '残高、購入、履歴、注文はデータベースから読み込みます。', + guestSubtitle: 'ゲストはポイントパックを確認できます。購入、残高、注文、履歴にはログインが必要です。', + balance: '現在の残高', + products: 'ポイントパック', + ledger: 'ポイント履歴', + orders: '注文', + refresh: '請求情報を更新', + testModeTitle: 'Stripe テスト環境', + testModeBody: 'テスト決済を使用するため、実際の請求は発生しません。', + testPurchase: 'テスト購入', + buy: '{points} ポイントを購入', + checkoutReady: '決済リンクを作成しました。', + openCheckout: '決済ページを開く', + selectedPack: '選択中', + selectedProduct: '{title} を選択しました。残高に {points} ポイントが追加されます。', + signInToBuy: 'ログインして購入', + readyToBuy: 'ポイントを購入できます', + readyToBuyBody: 'ポイントパックを選び、決済へ進みます。確認前の注文は処理中になります。', + checkoutPending: '決済ページへ移動中…', + checkoutReturnedSuccess: 'アプリに戻りました。残高と注文を更新しています。反映は注文状態に従います。', + checkoutReturnedCancel: '決済はキャンセルまたは未完了です。反映は確認されていません。', + signInHint: 'ログインすると残高、注文、履歴を確認できます。', + emptyProducts: 'ポイントパックはありません。', + emptyLedger: '履歴はありません。', + emptyOrders: '注文はありません。', + productFallback: 'ポイントパック', + points: '{points} ポイント', + price: '{amount} {currency}', + ledgerAmount: '{amount} ポイント', + orderStatus: '状態 {status}', + orderStatusPending: '処理中', + orderStatusPaid: '支払い済み', + orderStatusRefunded: '返金済み', + orderStatusDisputed: '異議申し立て中', + orderStatusUnknown: '状態不明', + ledgerReasonSignupGift: '登録特典', + ledgerReasonCheckout: 'ポイント購入', + ledgerReasonRefund: '注文返金', + ledgerReasonChargeback: 'チャージバック取り消し', + ledgerReasonUnlock: 'コンテンツ解放', + paywallTitle: '有料解放', + paywallTarget: '対象', + paywallReason: '課金理由', + paywallGenericReason: 'この内容を続けるにはポイントが必要です。', + paywallFreeContinue: '無料枠が残っています。続行できます。', + paywallInsufficient: 'ポイントが不足しています。先に購入してください。', + paywallPrice: '必要ポイント', + paywallFreeQuota: '残り無料枠', + paywallBalance: '利用可能残高', + }, + endings: { + title: 'エンディング図鑑', + unlocked: '解放済み', + locked: '未解放', + collected: '{count}/{total} 収集済み', + lockedHint: '対応するエンディングに到達すると解放されます。', + }, + saves: { + title: 'セーブ', + progress: '現在の進行', + activeRun: '現在のゲーム', + slot: 'スロット {slotNo}', + savedRun: '保存済み進行', + emptySlot: '空きスロット', + emptyHint: '現在の run を保存すると再読み込み後も復帰できます。', + saveHere: '保存', + overwrite: '上書き保存', + restore: '読み込み', + delete: '削除', + saved: 'セーブしました。', + restored: 'セーブを読み込みました。', + deleted: 'セーブを削除しました。', + conflict: '既存のセーブがあります。この復元地点を上書きする前に確認してください。', + restoreConfirm: '読み込むと現在のゲームはこのセーブ地点に切り替わります。', + deleteConfirm: '削除したセーブ地点は復元できません。', + noRunHint: '保存できる現在の run がありません。先に開始または再開してください。', + }, + map: { + title: 'マップ', + current: '現在地', + route: '移動可能', + routes: '移動可能な場所', + locked: '未解放', + moveTo: '{location}へ移動', + objects: '調査できる対象', + inspect: '調査', + inspected: '調査済み', + inspectObject: '{object}を調べる', + }, + videoCenter: { + eyebrow: 'プレイヤー記録', + title: '映像センター', + backHome: '戻る', + chooseScenario: 'シナリオ選択', + allScenarios: 'すべてのシナリオ', + searchScenario: 'シナリオを検索', + scenarioPaintedSkin: '画皮', + scenarioGuian: '帰安県失心事件', + nowPlaying: '再生中', + player: 'プレイヤー', + views: '視聴回数', + ending: '結末', + duration: '時間', + libraryEyebrow: '同じシナリオの映像', + allVideos: 'すべての映像', + sort: '映像の並び順', + recommended: 'おすすめ', + latest: '最新', + videoMeta: '{player} · {date}', + emptyTitle: 'プレイヤー映像はまだありません', + emptyText: 'このシナリオには公開されたプレイ映像がありません。', + viewAll: 'すべてのプレイヤー映像を見る', + discoverStories: '他の人の選択を見る', + backPrevious: '前のページへ', aiSummary: 'AI概要:', endingCount: '種類の結末', publicSeriesCount: '公開シリーズ', totalPlays: '総再生', seriesList: 'プレイヤーシリーズ', completed: '完結', updating: '第{count}話まで · 連載中', seriesMetrics: '全{episodes}話 · ▶ {plays} · ♥ {likes}', watchSeries: 'このシリーズを見る', playOwnSeries: '自分のシリーズを作る', minutes: '{count}分', share: '共有', shareCopied: 'リンクをコピーしました', shareFailed: '共有できませんでした', like: 'いいね', liked: 'いいね済み', save: '保存', saved: '保存済み', storyHighlight: '物語の見どころ', startPlay: 'プレイして結末を書き換える', episodeList: 'エピソード', episodeTotal: '全{count}話', episodeTitle: '第{count}話 · {title}', + }, + runHistory: { + eyebrow: '個人プレイ記録', + title: '自分の対局', + allRuns: 'すべての対局', + count: '{count} 件', + empty: 'プレイ記録はまだありません。対局を始めるとここに表示されます。', + loadFailed: '読み込み失敗:{error}', + noTime: '時刻不明', + statusActive: '進行中', + statusCompleted: '終了', + videoNone: '動画なし', + videoQueued: '動画待機中', + videoRunning: '動画生成中', + videoReady: '動画準備完了', + videoFailed: '動画失敗', + videoRetry: '再生成', + untitledEnding: 'エンディング未到達', + meta: '{scenario} · ターン {turn} · {date}', + openRun: '対局を開く', + openNotes: 'ノート', + watchVideo: '動画を見る', + }, + mobileProfile: { title: 'マイ', settings: '設定', content: 'コンテンツ', benefits: '特典', creator: 'クリエイター', support: 'お問い合わせ・サポート', contact: 'カスタマーサポート', terms: '利用規約', privacy: 'プライバシーポリシー', social: 'ソーシャルメディア' }, + profileEditor: { editProfile: 'プロフィールを編集', nameSection: 'プロフィール名', nameHint: 'ゲームや動画に表示される名前です。', name: '名前', saveName: '名前を保存', nameRequired: '名前を入力してください。', nameSaved: '名前を更新しました。', passwordSection: 'パスワードを変更', passwordHint: 'メールに届く認証コードで本人確認を行います。', email: 'メール', verificationCode: 'メール認証コード', sendCode: 'コードを送信', codeSent: '認証コードをメールに送信しました。', newPassword: '新しいパスワード', confirmPassword: '新しいパスワードを確認', savePassword: 'パスワードを更新', passwordRequirements: '認証コードと8文字以上の新しいパスワードを入力してください。', passwordMismatch: 'パスワードが一致しません。', passwordSaved: 'パスワードを更新しました。' }, + myGames: { recent: '最近プレイした作品', popular: '人気のストーリー', emptyRecent: '最近プレイしたストーリーがここに表示されます。', loadError: 'ゲームを読み込めませんでした。もう一度お試しください。' }, + storyDetail: { title: 'ストーリー情報', description: 'ストーリー紹介', episodes: '{count} エピソード', endings: '{count} エンディング', by: '作者:{name}', lastUpdate: '最終更新', newPlay: '新しくプレイ', notFound: 'ストーリーが見つかりません。', loadError: 'ストーリーを読み込めませんでした。' }, + accountCenter: { loadFailed: '個人センターを読み込めませんでした', loadTimeout: 'リクエストがタイムアウトしました。接続を確認して再試行してください。', title: 'マイページ', eyebrow: 'プレイヤープロフィール', runs: 'マイゲーム', videos: '動画コレクション', favorites: 'お気に入り', watchSeason: 'このシーズンを見る', emptyGames: 'まだゲームを開始していません。', emptyVideos: '生成された動画はまだありません。', emptyFavorites: 'お気に入りの動画はありません。', lastPlayed: '最終プレイ · {date}', currentTurn: '進行中 · ターン {turn}', noActiveRun: '進行中のプレイはありません', endingsUnlocked: 'エンディング {unlocked}/{total}', runsPlayed: '累計 {count} 回', continueGame: 'ゲームを続ける', startGame: 'ゲームを始める', viewHistory: '履歴を見る', levelProgress: '{played}/{total} ステージをプレイ', viewSeasonDetails: 'シーズン詳細を見る', hideSeasonDetails: 'シーズン詳細を閉じる', levelInProgress: '進行中', levelCompleted: '完了', endingLabel: '到達したエンディング', cluesLabel: '獲得した手がかり', clueCount: '手がかり {count} 件', viewRunRecords: 'ゲーム記録を見る', runRecordSummary: '手がかり {clues} 件 · エンディング {ending} 件', noEnding: 'まだエンディングに到達していません', noClues: '手がかりはまだありません', seasonDetailsLoadFailed: 'シーズン詳細を読み込めませんでした。再試行してください。', public: '公開', private: '自分のみ', deleteVideo: '動画を削除', confirmDelete: '削除を確認', signInTitle: 'ログインしてください', signInBody: 'ログインするとゲーム進行と動画を管理できます。' }, + visual: { + eyebrow: 'TRPG Visual', + chooseScript: 'シナリオ選択', + actTitle: '第{count}幕 · {title}', + actLocked: '{title}は未解放です', + actSealed: '前幕未完', + toBeContinued: '続く', + finishPreviousAct: '前幕の結末に到達すると解放', + revealAfterAct: '第{count}幕を完了すると明らかになります', + moreScripts: 'その他のステージ', + comingSoon: '近日公開', + watchScriptVideo: 'シナリオ映像を見る', + scenarioKicker: 'シナリオ', + hiddenTitle: '田又', + revealedTitle: '画皮', + objectives: '現在の目標', + playHistory: 'プレイ記録', + objectiveLabel: 'ミッション目標', + close: '閉じる', + noObjectives: '進行中のタスクはありません。', + taskProgress: '手順 {done}/{total}', + entryKicker: 'ゲーム開始', + entryTitle: '今夜、部屋に入る', + emptyScenarioEyebrow: 'シナリオ未公開', + emptyScenarioTitle: 'プレイ可能なシナリオがありません', + emptyScenarioText: 'サーバーから公開済みシナリオが返りません。更新するか公開状態を確認してください。', + emptyScenarioActionTitle: '公開待ち', + emptyScenarioActionText: '実データなしでローカルの仮シナリオは開始しません。', + continueTitle: '前回の続き', + entryBody: '先に役を選んで旧屋へ入ります。', + chooseCharacter: 'キャラクター選択', + enterStory: '物語へ進む', + back: '戻る', + skip: 'スキップ', + editor: 'エディター', + debugPage: 'デバッグ', + state: '状態', + settings: '設定', + language: '言語', + languageUnavailable: 'このレベルには、選択した言語の翻訳がまだありません。', + turnVideoMatching: 'スマートCGマッチング', + turnVideoMatchingHint: 'オンにすると類似する場面からこのターンのCGを探します。オフでは完全一致のCGのみ再生します。', + turnVideoMatchingOn: 'オン', + turnVideoMatchingOff: 'オフ', + exitGame: 'ゲームを終了', + exitGameHint: '現在のプレイを離れ、シナリオ選択へ戻ります。', + saveAndExit: '保存して終了', + exitWithoutSaving: '保存せず終了', + notes: 'ノート', + saves: 'セーブ', + continueRun: '続きから', + balanceEntry: '残高 {credits}', + endingCollection: 'エンディング図鑑', + narrator: '語り手', + npcName: '屋内の女', + npcLine: '「{line}」', + notStarted: '未開始', + startHint: '開始を押して古屋に入る。', + debug: '数値デバッグ', + debugOnly: 'デバッグ用', + waiting: '待機中。', + modeAct: '行動', + modeSay: '発言', + inputMethod: '入力方法', + textInputMode: 'テキスト', + voiceInputMode: '音声', + choiceGroupLabel: '選択肢', + inputPlaceholder: '例:銅鏡を持ち、彼女と壁の絵に向ける。', + mobileInputPlaceholder: 'セリフや行動を入力…', + voiceStart: '音声入力を開始', + voiceStop: '音声入力を停止', + voiceListening: '聞き取り中…', + voiceRecognizing: '認識中…', + voiceUnsupported: 'このブラウザは音声入力に対応していません', + voicePermissionDenied: 'マイクを使用できません。ブラウザの権限を確認してください', + voiceMicrophoneUnavailable: '使用可能なマイクが見つかりません', + voiceNoSpeech: '音声を認識できませんでした。もう一度お試しください', + voiceNoAudioCaptured: 'マイクの音声を取得できませんでした。ブラウザの権限を確認してください', + voiceRecognitionFailed: '音声認識に失敗しました。もう一度お試しください', + voiceHoldToTalk: '押して話す', + voicePreparing: 'マイクを準備中…', + voiceShortcutLabel: 'ショートカット', + voiceReleaseSend: '離して送信', + voiceSlideCancel: '上にスライドしてキャンセル', + voiceReleaseCancel: '離してキャンセル', + voiceUnderstanding: '指示を理解しています…', + ending: 'エンディング', + endingLabel: 'エンディング', + endingLine: 'エンディング:{title}\n{description}', + endingSummary: '結末まとめ', + endingPoem: '定場詩', + endingFallbackPoem: '……', + endingBackgroundLoadFailed: 'エンディング画面を読み込めませんでした。再読み込みしてください。', + recordSeal: '案', + recordTitle: '捕吏記録', + recordKicker: '県衙文書', + recordMeta: '雨夜の後', + continue: '続ける', + enterNextEpisode: '次の章へ', + viewCurrentSeasonStory: '今季のストーリーを見る', + played: '再生済み', + nextPage: '次ページ', + statusCreating: '物語を準備しています...', + statusStarted: '開始しました。発言か行動を入力してください。', + statusHistoryRun: '過去の run を表示中です。続きはサーバー進行に従います。', + statusKp: 'KP が判定中...', + statusDone: 'ターン完了。', + contentFiltered: 'この行動は描写できません。別の方法を試してください。', + turnFailed: 'このステップを完了できませんでした。もう一度お試しください。', + audioFailed: '音声の再生に失敗しました。もう一度お試しください。', + actionRejected: 'その行動はまだ成立しません。別の方法を試してください。', + statusEnded: 'ゲーム終了。', + statusEndingText: '結末文を生成中...', + statusEndingLocked: 'エンディングが確定しました。移行中...', + statusNpcAction: 'NPC が行動を試みています。KP 判定待ち...', + statusNpcDone: 'NPC が応答しました。', + clueGained: '手掛かり入手', + landscapeTitle: '端末を横向きにしてください', + landscapeBody: 'シーンとCGをすべて表示するため、横向きでプレイしてください。', + stats: { + suspicion: '疑念', + charm: '魅惑', + fear: '恐怖', + }, + guide: { + label: 'ガイド', + skip: 'スキップ', + next: '次へ', + done: '了解', + optionsTitle: '直接選択', + optionsText: 'NPC の反応後、下に二つの候補が出ます。「発言」か「行動」を選べます。', + keyboardTitle: 'キーボード入力に切り替える', + keyboardText: '初期設定は音声入力です。キーボードボタンを押すと、発言や行動を直接入力できます。', + inputTitle: '「発言 / 行動」を選ぶ', + inputText: '入力前に「発言」か「行動」を選びます。どちらも KP が判定します。', + notesTitle: 'ノート確認', + notesText: '右側のノートには発見した手掛かりと解放済みエンディングが記録されます。', + goalTitle: '生き残る', + goalText: 'これは会話だけではありません。彼女の言葉を聞き、部屋と異常も観察してください。', + videoTitle: 'ストーリーを完成させると、この冒険があなただけの映像になります', + videoExampleLabel: 'サンプル動画', + videoExampleNotice: 'この動画はサンプルであり、あなたのプレイから生成される内容ではありません。', + videoLoading: 'サンプル動画を読み込み中…', + videoUnavailable: 'このストーリーにはまだサンプル動画がありません。', + videoDone: '私のストーリーを始める', + }, + openingOptions: { + say: 'すみません、勝手に入るつもりはありませんでした。外に何かがいて追われていました。', + act: '声を低くして、扉、窓、灯、彼女の位置を確認する。', + }, + }, + panels: { clues: '手掛かり', conclusions: '結論', info: '情報', inventory: '所持品', events: 'イベント', stats: '能力値', flags: 'フラグ', objects: 'オブジェクト', npcs: 'NPC', endingRecords: '解放済みエンディング' }, + standalone: { + summary: '概要', + availableClues: '入手可能な手掛かり', + recentTurns: '最近のターン', + audit: { + title: 'AI 監査', + subtitle: 'KP、NPC、エンディング、TTS 呼び出しのコストと失敗記録。', + totalCalls: '呼び出し', + failedCalls: '失敗', + tokens: '入力/出力 token', + cost: 'コスト', + roles: 'ロール別集計', + models: 'モデル別集計', + recentCalls: '最近の呼び出し', + empty: 'AI 呼び出し記録はありません。', + calls: '{count} 回', + tokenPair: 'token {tokens}', + }, + state: { + title: '状態可視化', + waiting: 'RunState の読み込み待ち。', + noRun: 'runId がありません。ゲーム画面から開くか、runId を入力してください。', + loading: '{runId} を読み込み中...', + loaded: '{runId} を読み込みました', + }, + notes: { + title: 'ノート', + waiting: 'ノートを読み込み中。', + noRun: '現在のセーブがありません。先にゲームを開始または再開してください。', + loading: '{runId} を読み込み中...', + loaded: '{runId} を読み込みました', + unavailable: 'このゲームのノートは現在読み込めません。', + emptyClues: '手掛かりはまだありません', + emptyConclusions: '結論はまだありません', + emptyEndings: 'エンディングはまだありません', + tabs: 'ノート分類', + list: '項目リスト', + detail: '項目詳細', + locked: '未解放', + lockedHint: 'このエンディングはまだ記録されていません。', + recorded: '記録済み', + importance: { minor: '副次', major: '重要', critical: '核心' }, + relatedClues: '関連する手掛かり', + noRelatedClues: 'この結論を支える手掛かりはまだ見つかっていません。', + }, + }, + editor: { + title: 'シナリオ編集', + pack: 'パック', + section: 'セクション', + raw: 'JSON', + structured: '構造化編集', + debugJson: 'デバッグ JSON', + item: '項目', + noStructuredFields: 'このセクションには直接編集できる構造化フィールドがまだありません。', + updated: '保存済み', + assetUploaded: 'アセットをドラフトに追加済み', + compiled: 'ドラフトをコンパイル済み', + published: 'バージョンを公開済み', + rolledBack: 'バージョンをロールバック済み', + versions: 'バージョン', + assets: 'アセット索引', + current: '現在', + currentVersion: '現在のバージョン', + noPublishedVersion: '未公開', + noVersions: 'まだバージョンがありません。先にドラフトをコンパイルしてください。', + noAssets: 'まだアセット索引がありません。先にマニフェストをコンパイルしてください。', + noFilteredAssets: '条件に一致するアセットはありません。', + assetSearch: '検索', + assetSearchPlaceholder: 'タグ、ファイル、objectKey', + assetTypeFilter: '種類', + assetSourceFilter: 'ソース', + assetFilterAll: 'すべて', + assetFilterResult: '{shown}/{total}', + assetSource: { + uploaded: 'アップロード済み', + static: '静的ファイル', + missing: '参照なし', + }, + uploadFile: 'ファイル', + uploadTag: 'タグ', + uploadTagPlaceholder: 'opening_cg', + uploadType: '種類', + noUploadFile: 'ファイル未選択', + }, + waitingList: { + eyebrow: '先行アクセス', + apply: '体験を申し込む', + useInvite: '招待コードを使う', + invitedRegister: '招待済みですか?アカウント作成', + title: 'ゲームサーバーの waiting list に参加', + body: 'メールと用途を残すと、運営が管理画面から招待リンクを発行します。', + displayName: '名前', + displayNamePlaceholder: '任意', + useCase: '用途', + useCaseOptional: '用途(任意)', + useCasePlaceholder: 'チームテスト、作者トライアル、イベント', + useCaseRequired: '用途を入力してください。', + consent: 'この waiting list の招待と製品メールを受け取ることに同意します。', + join: '参加する', + joined: '申請を受け付けました。メール通知をお待ちください。', + joinedTitle: '申請済み', + changeEmail: '別のメールを使う', + accountAlreadyRegistered: 'このメールアドレスは登録済みです。ログインしてください。', + emailRequired: 'メールアドレスが必要です。', + consentRequired: '参加前にメール送信への同意が必要です。', + inviteEyebrow: '招待', + inviteTitle: 'voko.world アカウントを作成', + inviteBody: 'この招待はメールアドレスに紐づいています。パスワードを設定して登録を完了してください。', + inviteMissing: '招待 token がありません。', + inviteEnterCode: '受け取った 8 文字の招待コードを入力してアカウントを作成します。', + inviteValidate: '招待コードを確認', + inviteReenter: '招待コードを再入力', + inviteInvalid: '招待が無効または期限切れです。', + codeSent: '確認コードを送信しました。', + passwordMismatch: 'パスワードが一致しません。', + acceptInvite: 'アカウント登録', + }, + landing: { officialVideo: '公式動画を見る', officialVideoEyebrow: 'VOKO について', officialVideoTitle: 'あなたの世界が呼んでいます。', officialVideoBody: '自分の世界を作り、再びあなたを呼ぶ物語をプレイし、すべての瞬間を見たくなる映像として共有しましょう。', homeLabel: 'voko.world ホーム', navHome: 'Home', navScripts: '物語', navVideos: '動画', featuredStories: '注目の物語', featuredStoriesEyebrow: 'プレイ開始', endingCount: 'エンディング {count} 個', allStories: 'すべての物語', dramaTitle: 'ドラマ', brandSub: 'インタラクティブストーリー', navLabel: 'メインナビ', navStories: 'ゲーム', navGallery: 'ギャラリー', eyebrow: 'AI ビジュアルロールプレイ', loadError: '読み込みに失敗しました。', retry: '再読み込み', emptyGames: '公開中のゲームはありません。', scenarioFallback: '選択でこの物語を変えましょう。', publicVideos: 'プレイヤー動画', publicGames: '公開ゲーム', watchDrama: 'ドラマを見る', enterPlay: 'プレイする', comingSoon: '近日公開', carouselLabel: 'ゲームカルーセル', previousGame: '前のゲーム', nextGame: '次のゲーム', showcaseTitle: 'プレイヤーが作った物語', showcaseBody: '他のプレイヤーの結末を見て、自分の物語を作りましょう。', searchLabel: '物語を検索', searchPlaceholder: 'ゲーム名で検索', sortLabel: '並び順', latest: '最新', popular: '人気', videoCount: '{count} 本の動画', levelCount: '全{count}関', levelNumber: '第{count}関', singleLevel: '単独シナリオ', publicGame: '公開ゲーム', noSearchResults: '一致する物語がありません', clearSearch: '検索をクリア', finalEyebrow: 'あなたの物語が待っています', finalTitle: '誰になりますか?', finalBody: '世界へ足を踏み入れ、最初の一言を。', begin: '旅を始める', footer: '© 2026 voko.world', earlyAccess: '先行アクセス', footerSocial: 'コミュニティ', footerLegal: '規約と連絡先', privacy: 'プライバシーポリシー', terms: '利用規約', contact: '連絡先メール' }, + landingCarousel: { introEyebrow: '生成ドラマゲーム', introSummary: '短いドラマを見て役に入り、選択で物語を変えましょう。', dramaEyebrow: '注目ドラマ {count}', controls: 'ホームカルーセル', goToSlide: 'スライド {count} へ' }, + landingCinema: { navHome: 'ホーム', navDramas: 'ゲーム', navPlayerStories: 'ドラマ', navMy: 'マイ', featuredDramas: 'ゲーム', playerStories: 'ドラマ' }, + searchPage: { placeholder: 'ストーリー、ドラマ、アカウントを検索', popular: '人気の検索', history: '最近の検索', clearHistory: 'クリア', resultTypes: '検索結果の種類', all: 'すべて', accounts: 'アカウント', accountStats: '作品 {titles} 件 · 再生 {plays} 回' }, + landingIntro: { title: 'voko.world', apply: '体験を申し込む', watchVideo: '動画を見る', start: 'ゲームを始める', continue: 'ゲームを続ける', viewJourney: 'プレイ履歴', continueProgress: 'シーズン {season} {scenario}', storyLine: 'ストーリーライン', viewMore: 'さらに見る' }, + storyTree: { + eyebrow: 'シーズンライン', + subtitle: '1本のラインが1シーズン、各ノードが1ステージを表します。', + root: 'ドラマ', + branch: '分岐', + active: '進行中', + completed: '完了', + locked: '未到達', + empty: '表示できるシーズンラインはまだありません。', + loadError: 'シーズンラインを読み込めませんでした。', + navigation: 'シーズン範囲', + range: 'シーズン {first}–{last} · 全 {total}', + newer: '前のグループ', + older: '次のグループ', + startPlaying: 'プレイ開始', + turnProgress: '第 {turn} ターン', + continuePlaying: 'プレイを続ける', + playLog: 'プレイ記録', + seasonEnding: 'シーズンの結末', + currentSeason: '現在のシーズン', + logLoadError: 'プレイ記録を読み込めません。', + logEmpty: 'このシーズンにはまだプレイ履歴がありません。', + turnNumber: '第 {turn} ターン', + playerSaid: 'あなたの発言', + playerDid: 'あなたの行動', + storyOpening: '物語の始まり', + turnVideos: '一致した動画', + playActionVideo: 'アクション動画', + playPlayerVideo: 'プレイヤー動画', + playNpcVideo: 'NPC 動画', + replayTurn: 'リプレイ', + replayTurnTitle: '第 {turn} ターンの再生', + replayProgress: '{current}/{total} クリップ', + actionSegment: 'アクション', + playerSpeechSegment: 'プレイヤー発言', + npcSpeechSegment: 'NPC 発言', + pauseReplay: '一時停止', + continueReplay: '続ける', + restartReplay: '最初から再生', + exportReplay: '全編を書き出す', + replayQueued: '待機中', + replayExporting: '書き出し中', + playFullReplay: '全編を再生', + retryReplayExport: '再書き出し', + downloadReplay: 'MP4 をダウンロード', + downloadSegment: 'このクリップをダウンロード', + downloadTurn: 'このターン全体をダウンロード', + turnExporting: 'ターン動画を書き出し中', + turnExportError: 'ターン動画の書き出しに失敗しました。', + segmentExporting: 'クリップを書き出し中', + segmentExportError: 'クリップの書き出しに失敗しました。', + fullReplayTitle: '{title} · 全編再生', + replayExportError: '全編の書き出しに失敗しました。', + videoLoadError: '動画を読み込めませんでした。', + tools: 'ストーリーラインの検索・絞り込み・並べ替え', + search: '検索', + searchPlaceholder: 'ステージ、結末、最近の行動を検索', + filter: '状態', + filterAll: 'すべて', + filterInProgress: '進行中', + filterCompleted: '完了', + sort: '並べ替え', + sortRecent: '最近プレイした順', + sortSeasonDesc: 'シーズン降順', + sortSeasonAsc: 'シーズン昇順', + resultCount: '全 {count} シーズン', + noResults: '条件に一致するストーリーラインはありません。', + canvasHint: 'キャンバスをドラッグまたはスクロールして右側のステージを表示', + canvasLabel: '横方向に移動できるストーリーツリーキャンバス', + storySwitch: '作品', + }, + storyTreeCurrentSeasonHint: '最新の進行', + storyTreeViewDetails: 'シーズン {number} の詳細を見る', + storyTreeDelete: { action: '分岐を削除', confirm: 'シーズン {number} とそこから派生した分岐を削除しますか?この操作は取り消せません。', deleting: '削除中…', error: '分岐を削除できませんでした。' }, + storyTreeVideoUnavailable: '映像はありません', + storyTreeFork: { action: 'ここからやり直す', confirm: '第 {turn} ターンから新しいストーリー分岐を作成します。現在のプレイ記録は変更されません。', failed: '新しいストーリー分岐を作成できませんでした。', creating: '作成中…' }, + runVideoGeneration: { title: 'フル動画を生成', description: 'このプレイからフル動画を生成します。確定後にポイントを消費し、生成失敗時は自動返還されます。', generate: '動画を生成', regenerate: '再生成', retry: 'もう一度生成', queued: '生成待ち', running: '生成中', price: '必要ポイント', balance: '現在の残高', balanceAfter: '消費後の残高' }, + landingShowcase: { title: 'それぞれの物語', body: 'ほかのプレイヤーが辿った運命を見届け、物語に入り、あなただけの結末を描きましょう。' }, + landingMetrics: { playCount: '再生数', likeCount: 'いいね数' }, + landingCard: { level: 'エピソード', searchPlaceholder: '物語・動画・プレイヤーを探す', scriptFilter: 'ドラマ', scriptAll: 'すべてのドラマ', playerVideoCount: '公開動画 {count} 本', publishedAt: '公開日', publishedAll: 'すべて', publishedToday: '今日', publishedWeek: '過去 7 日', publishedMonth: '過去 30 日', expandSynopsis: '展開', collapseSynopsis: '閉じる' }, + playerPage: { loadError: 'プレイヤーの動画を読み込めませんでした。', notFound: 'プレイヤーが見つからないか、公開動画がありません。', searchPlaceholder: '作品・ステージ・シーズン名を検索' }, + season: { choose: 'シーズンを選択', chooseBody: 'このエピソードは選択したシーズンの物語に続きます。', number: 'シーズン {number}', empty: '続けるには、いずれかのシーズンで前のエピソードを完了してください。' }, + status: { loading: '読み込み中', empty: 'データなし', error: '読み込み失敗', streaming: '進行中' }, + storiesPage: { tags: 'ストーリータグ', allTags: 'すべて', swipeForNextStory: '上にスワイプして次の物語へ' }, + creator: { nav: '制作', queued: '処理待ち', attachmentTypeError: 'TXT、PDF、Word、PNG、JPG、WEBP のいずれか1ファイルを選択してください。', attachmentSizeError: '添付ファイルは10 MB以下にしてください。', attachmentReadError: '添付ファイルを読み取れませんでした。', generationFailed: '生成失敗', reviewRejected: '審査否認', published: '公開済み', archived: 'アーカイブ', composerTitle: '今日はどんな世界をつくりますか?', submitIdea: 'アイデアを送信', examplesNote: '公開済みゲームをレイアウト例として表示しています。', exampleStatus: '公開例', projectsEyebrow: 'Visual Game Creator', projectsTitle: 'マイゲーム', projectsBody: '制作したゲームと生成・審査状況を管理します。', newGame: '新しいゲームを作成', draftStatus: '下書き', continueEditing: '編集を続ける', generatedScopes: '件の生成内容', emptyTitle: '最初のゲームを作成', emptyBody: 'アイデアや脚本、世界観から遊べるゲームを作りましょう。', eyebrow: 'Visual Game Creator', title: '物語を、遊べる世界へ', subtitle: 'アイデアや脚本、世界観を共有してください。ゲーム設計、素材制作、検証、審査申請まで自動で進みます。', stepIdea: 'アイデア', stepGenerate: '生成', stepReview: '審査', ideaTitle: 'ゲームを説明する', ideaBody: '自然な言葉で入力できます。JSONの知識は不要です。', ideaLabel: 'アイデアまたは脚本', ideaHint: '世界、人物、対立、望む体験を含めてください。', ideaPlaceholder: '旅人が嵐の夜に宿で目覚めると、全員が昨夜を違って記憶していた……', ideaError: '20文字以上入力してください。', languageLabel: 'ゲーム言語', languages: { zh: '中国語', en: '英語', ja: '日本語', ko: '韓国語' }, scopeTitle: '生成する内容', scopeStory: '物語とレベル', scopeStoryBody: '人物、任務、状態、結末', scopeVisuals: 'ビジュアル', scopeVisualsBody: '人物、背景、小道具、動画', scopeVoice: '音声', scopeVoiceBody: 'プレイヤー、NPC、結末', scopeError: '1つ以上選択してください。', assetsTitle: '参考素材', assetsBody: '任意。人物や世界の一貫性を高めます。', uploadAction: '参考ファイルを追加', uploadHint: '1ファイル · TXT、PDF、Word、PNG、JPG、WEBP · 最大10 MB', supportedTitle: '対応素材', supportedBody: '人物・背景・小道具・画風画像、脚本、文書、動画、音声。', removeFile: '{name} を削除', saved: '下書きを保存しました。', draftNote: '初版はローカル下書き保存に対応します。', save: '下書きを保存' }, + labels: { scenario: 'シナリオ', asset: '素材', mode: 'モード', phase: 'フェーズ', location: '場所', noRun: '未開始', none: 'なし' }, +} diff --git a/packages/i18n/src/messages/ko/account.js b/packages/i18n/src/messages/ko/account.js new file mode 100644 index 0000000..b3a2b32 --- /dev/null +++ b/packages/i18n/src/messages/ko/account.js @@ -0,0 +1,5 @@ +export default { + points: { + dailyClaimTitle: '매일 포인트', dailyClaimAvailable: '오늘 {points} 포인트를 받을 수 있습니다.', dailyClaimed: '오늘의 포인트를 이미 받았습니다.', claim: '포인트 받기', claimed: '받음', nextAvailable: '다음 수령 가능 시간: {time}', balanceTurns: '{points} 포인트 · {turns}턴 플레이 가능', + }, +} diff --git a/packages/i18n/src/messages/ko/auth.js b/packages/i18n/src/messages/ko/auth.js new file mode 100644 index 0000000..a6de35f --- /dev/null +++ b/packages/i18n/src/messages/ko/auth.js @@ -0,0 +1,50 @@ +export default { + login: { + title: '다시 오신 것을 환영합니다', + subtitle: '계정에 로그인하려면 정보를 입력하세요.', + submit: '로그인', + submitting: '로그인하는 중…', + submitError: '로그인할 수 없습니다. 정보를 확인하고 다시 시도하세요.', + email: '이메일 주소', + emailPlaceholder: "name{'@'}example.com", + emailRequired: '이메일을 입력하세요.', + invalidEmail: '올바른 이메일 주소를 입력하세요.', + password: '비밀번호', + passwordRequired: '비밀번호를 입력하세요.', + forgot: '비밀번호를 잊으셨나요?', + showPassword: '비밀번호 표시', + hidePassword: '비밀번호 숨기기', + }, + register: { + title: '계정 만들기', + subtitle: '이메일을 인증하고 시작하세요.', + submit: '가입하기', + haveAccount: '이미 계정이 있으신가요?', + codeSent: '인증 코드를 보냈습니다. 받은편지함을 확인하세요.', + }, + segment: { signIn: '로그인', createAccount: '계정 만들기' }, + social: { divider: '또는 다음으로 계속', google: 'Google로 계속', googleEnglish: 'Continue with Google', email: '이메일로 계속', apple: 'Apple로 계속' }, + forgot: { + title: '비밀번호 재설정', + body: '계정에 연결된 이메일을 입력하면 재설정 코드를 보내드립니다.', + emailLabel: '이메일 주소', + submit: '재설정 코드 보내기', + sent: '재설정 코드를 보냈습니다. 받은편지함을 확인하세요.', + needEmail: '올바른 이메일을 입력하세요.', + error: '재설정 코드를 보낼 수 없습니다. 다시 시도하세요.', + }, + legal: { + agree: '계속하면 다음에 동의하게 됩니다:', + terms: '이용약관', + and: '및', + privacy: '개인정보 처리방침', + termsTitle: '이용약관', + privacyTitle: '개인정보 처리방침', + termsBody: '서비스는 합법적인 개인 오락 목적으로만 이용할 수 있습니다.', + privacyBody: '서비스 운영에 필요한 최소한의 계정 및 이용 데이터를 수집합니다.', + }, + hero: { brand: 'IdearRun', titleA: '이야기 속으로.', titleB: '결말을 다시 쓰세요.', body: '선택이 실시간으로 이야기를 만듭니다.' }, + convert: { title: '진행 상황 저장', body: '가입하면 {name}을 계속 보고 모든 에피소드를 잠금 해제할 수 있습니다.' }, + logout: '로그아웃', + guest: '게스트로 계속', +} diff --git a/packages/i18n/src/messages/ko/index.js b/packages/i18n/src/messages/ko/index.js new file mode 100644 index 0000000..22ac8f2 --- /dev/null +++ b/packages/i18n/src/messages/ko/index.js @@ -0,0 +1,426 @@ +import en from '../en/index.js' +import auth from './auth.js' +import accountPoints from './account.js' +import payPoints from './pay.js' +import generatedVisualGame from './visual-game.generated.js' +import legal from './legal.js' + +const common = { + ...en.common, + cancel: '취소', + confirm: '확인', + back: '뒤로', + next: '다음', + retry: '다시 시도', + loading: '불러오는 중…', + save: '저장', + close: '닫기', + language: { + label: '언어', + options: { en: '영어', zh: '중국어', ja: '일본어', ko: '한국어', es: '스페인어', pt: '포르투갈어' }, + }, +} + +const account = { + ...en.account, + ...accountPoints, + account: { + ...en.account.account, + notifications: '알림', + }, +} + +const visualGame = mergeMessages({ + mobileCredit: { + title: '내 크레딧', + buyCredits: '크레딧 구매', + history: '크레딧 내역', + purchaseHistory: '구매 내역', + usageHistory: '사용 내역', + emptyHistory: '크레딧 내역이 없습니다.', + loadMore: '더 보기', + balanceAfter: '잔액: {points}', + pending: '처리 중', + paid: '결제 완료', + canceled: '취소됨', + failed: '실패', + refunded: '환불됨', + disputed: '분쟁 중', + autoReload: '자동 충전', + bonusCredits: '보너스 크레딧', + buyNow: '지금 구매', + lowBalance: '잔액 부족', + enabled: '사용 중', + whenBelow: '잔액이 다음보다 낮을 때', + choosePack: '충전 팩 선택', + consent: '잔액이 기준에 도달할 때 선택한 팩을 자동 구매하도록 Voko에 승인합니다.', + terms: '이용 약관', + continueSecurely: '안전하게 계속', + save: '설정 저장', + disable: '자동 충전 끄기', + setupComplete: '결제 수단이 저장되고 자동 충전이 켜졌습니다.', + setupCanceled: '자동 충전 설정이 취소되었습니다.', + actionRequired: '마지막 충전에 실패했습니다. 결제 수단을 다시 설정해 주세요.', + howTitle: '자동 충전 작동 방식', + howBalance: '잔액이 기준 이하가 되면 선택한 팩을 구매합니다.', + howSecure: '결제 정보는 Stripe가 안전하게 보관하며 Voko는 카드 번호를 저장하지 않습니다.', + howCancel: '언제든 끌 수 있으며 이전 구매는 환불되지 않습니다.', + }, + ...generatedVisualGame, + productBrandSub: '당신의 kosmo가 부르고 있어요.', + actions: { + ...en.visualGame.actions, + cancel: '취소', + confirm: '확인', + }, + mobileProfile: { + title: '마이', + settings: '설정', + content: '콘텐츠', + benefits: '혜택', + creator: '크리에이터', + support: '문의 및 지원', + contact: '고객 지원', + terms: '이용 약관', + privacy: '개인정보 처리방침', + social: '소셜 미디어', + }, + myGames: { + recent: '최근 플레이', + popular: '인기 스토리', + emptyRecent: '최근 플레이한 스토리가 여기에 표시됩니다.', + loadError: '게임을 불러오지 못했습니다. 다시 시도해 주세요.', + }, + storyDetail: { + title: '스토리 정보', + description: '스토리 설명', + episodes: '{count}개 에피소드', + endings: '{count}개 엔딩', + by: '작가: {name}', + lastUpdate: '최근 업데이트', + newPlay: '새로 플레이', + notFound: '스토리를 찾을 수 없습니다.', + loadError: '스토리를 불러오지 못했습니다.', + }, + creator: { + nav: '만들기', + queued: '대기 중', + attachmentTypeError: 'TXT, PDF, Word, PNG, JPG 또는 WEBP 파일 하나만 업로드할 수 있습니다.', + attachmentSizeError: '첨부 파일은 10MB 이하여야 합니다.', + attachmentReadError: '첨부 파일을 읽을 수 없습니다.', + generationFailed: '생성 실패', + reviewRejected: '심사 거절', + published: '공개됨', + archived: '보관됨', + composerTitle: '오늘은 어떤 세계를 만들어 볼까요?', + submitIdea: '아이디어 제출', + examplesNote: '실제 공개 게임을 레이아웃 참고용으로 표시합니다.', + exampleStatus: '공개 예시', + projectsEyebrow: 'Visual Game Creator', + projectsTitle: '내 게임', + projectsBody: '내가 만든 게임과 생성 및 심사 상태를 관리합니다.', + newGame: '새 게임 만들기', + draftStatus: '초안', + continueEditing: '계속 편집', + generatedScopes: '개 생성 항목', + emptyTitle: '첫 게임 만들기', + emptyBody: '아이디어, 대본 또는 세계관에서 시작해 플레이할 수 있는 게임을 만드세요.', + eyebrow: 'Visual Game Creator', + title: '당신의 이야기를 플레이할 수 있는 세계로', + subtitle: '아이디어, 대본 또는 세계관을 공유하세요. 제작 시스템이 게임 설계, 미디어 제작, 전체 경로 검증과 심사 제출을 진행합니다.', + stepIdea: '아이디어', + stepGenerate: '생성', + stepReview: '심사', + ideaTitle: '만들고 싶은 게임을 설명하세요', + ideaBody: '자연어로 작성하세요. JSON이나 상태 머신을 알 필요가 없습니다.', + ideaLabel: '게임 아이디어 또는 대본', + ideaHint: '세계, 인물, 갈등과 플레이어에게 주고 싶은 경험을 포함하세요.', + ideaPlaceholder: '예: 폭우가 내리는 밤, 한 여행자가 여관에서 깨어나지만 모두가 지난밤을 다르게 기억한다…', + ideaError: '20자 이상 입력해 주세요.', + languageLabel: '게임 언어', + languages: { + zh: '중국어', + en: '영어', + ja: '일본어', + ko: '한국어', + }, + scopeTitle: '이번 생성 범위', + scopeStory: '스토리와 레벨', + scopeStoryBody: '인물, 임무, 상태와 엔딩', + scopeVisuals: '비주얼 미디어', + scopeVisualsBody: '캐릭터, 장면, 소품과 영상', + scopeVoice: '음성', + scopeVoiceBody: '플레이어, NPC와 엔딩 내레이션', + scopeError: '생성 항목을 하나 이상 선택하세요.', + assetsTitle: '참고 자료', + assetsBody: '선택 사항입니다. 인물과 세계의 일관성을 높입니다.', + uploadAction: '참고 파일 추가', + uploadHint: '파일 1개 · TXT, PDF, Word, PNG, JPG 또는 WEBP · 최대 10MB', + supportedTitle: '지원 자료', + supportedBody: '인물, 장면, 소품, 화풍 이미지, 대본과 세계관 문서, 영상 또는 오디오 참고.', + removeFile: '{name} 삭제', + saved: '이 기기에 초안을 저장했습니다.', + draftNote: '첫 버전은 로컬 초안 저장을 제공합니다. 생성 연결은 다음 단계에서 진행합니다.', + save: '초안 저장', + }, + billing: { + ...en.visualGame.billing, + testModeTitle: 'Stripe 테스트 환경', + testModeBody: '테스트 결제를 사용하므로 실제 요금은 청구되지 않습니다.', + testPurchase: '테스트 구매', + }, + storyTree: { + ...generatedVisualGame.storyTree, + turnVideos: '일치한 동영상', + playActionVideo: '동작 동영상', + playPlayerVideo: '플레이어 동영상', + playNpcVideo: 'NPC 동영상', + replayTurn: '다시 보기', + replayTurnTitle: '{turn}턴 다시 보기', + replayProgress: '{current}/{total} 클립', + actionSegment: '동작', + playerSpeechSegment: '플레이어 대사', + npcSpeechSegment: 'NPC 대사', + pauseReplay: '일시 정지', + continueReplay: '계속 재생', + restartReplay: '처음부터 재생', + exportReplay: '전체 다시 보기 내보내기', + replayQueued: '대기 중', + replayExporting: '내보내는 중', + playFullReplay: '전체 다시 보기 재생', + retryReplayExport: '다시 내보내기', + downloadReplay: 'MP4 다운로드', + downloadSegment: '이 클립 다운로드', + downloadTurn: '이 턴 전체 다운로드', + turnExporting: '턴 동영상 내보내는 중', + turnExportError: '턴 동영상을 내보내지 못했습니다.', + segmentExporting: '클립 내보내는 중', + segmentExportError: '클립을 내보내지 못했습니다.', + fullReplayTitle: '{title} · 전체 다시 보기', + replayExportError: '전체 다시 보기를 내보내지 못했습니다.', + videoLoadError: '동영상을 불러올 수 없습니다.', + }, + runVideoGeneration: { + title: '전체 동영상 생성', + description: '이 플레이의 전체 동영상을 생성합니다. 확인 후 포인트가 차감되며 생성 실패 시 자동으로 환불됩니다.', + generate: '동영상 생성', + regenerate: '다시 생성', + retry: '다시 시도', + queued: '대기 중', + running: '생성 중', + price: '필요 포인트', + balance: '현재 잔액', + balanceAfter: '차감 후 잔액', + }, + account: { + ...en.visualGame.account, + signIn: '로그인', + signedIn: '로그인됨', + email: '이메일', + emailPlaceholder: '이메일 주소', + password: '비밀번호', + passwordPlaceholder: '비밀번호 입력', + confirmPassword: '비밀번호 확인', + confirmPasswordPlaceholder: '비밀번호 다시 입력', + verificationCode: '인증 코드', + verificationCodePlaceholder: '6자리 이메일 코드', + inviteToken: '초대 코드', + inviteTokenPlaceholder: '8자리 초대 코드', + sendCode: '코드 보내기', + codeSent: '코드를 보냈습니다. 이메일을 확인하세요.', + codeRequired: '먼저 인증 코드를 입력하세요.', + emailRequired: '먼저 이메일을 입력하세요.', + emailInvalid: '올바른 이메일 주소를 입력하세요.', + passwordRequired: '비밀번호를 입력하세요.', + passwordMismatch: '비밀번호가 일치하지 않습니다.', + loginFieldsRequired: '이메일과 비밀번호를 입력하세요.', + createAccount: '계정 만들기', + noAccount: '계정이 없으신가요?', + login: '로그인', + register: '가입하기', + reset: '재설정', + forgotPassword: '비밀번호를 잊으셨나요?', + resetPassword: '비밀번호 재설정', + resetTitle: '비밀번호 재설정', + resetCodeSent: '재설정 코드를 보냈습니다. 이메일을 확인하세요.', + passwordResetDone: '비밀번호가 재설정되었습니다. 새 비밀번호로 로그인하세요.', + logout: '로그아웃', + logoutAll: '모든 기기에서 로그아웃', + loginTitle: '로그인', + registerTitle: '계정 만들기', + registerBody: '초대 코드로 계정을 만들고 이메일을 인증하세요.', + guestStart: '게스트로 체험하기', + savedHint: '로그인하면 진행 상황이 자동으로 저장됩니다.', + currentAccount: '현재 계정', + saveKicker: '클라우드 저장', + }, + waitingList: { + ...en.visualGame.waitingList, + apply: '대기자 명단 신청', + useInvite: '초대 코드 사용', + invitedRegister: '초대장이 있나요? 계정 만들기', + join: '대기자 명단 가입', + }, + landing: { + ...en.visualGame.landing, + homeLabel: 'voko.world 홈', + navHome: '홈', + navScripts: '스토리', + navVideos: '드라마', + featuredStories: '추천 스토리', + featuredStoriesEyebrow: '게임 시작', + allStories: '모든 스토리', + dramaTitle: '드라마', + brandSub: '인터랙티브 스토리', + navLabel: '주요 탐색', + loadError: '홈페이지를 불러오지 못했습니다. 다시 시도하세요.', + retry: '다시 시도', + emptyGames: '아직 이용할 수 있는 게임이 없습니다.', + scenarioFallback: '이 세계에 들어가 선택으로 스토리를 바꿔 보세요.', + watchDrama: '스토리 보기', + enterPlay: '지금 플레이', + comingSoon: '공개 예정', + previousGame: '이전 게임', + nextGame: '다음 게임', + searchLabel: '스토리 검색', + searchPlaceholder: '게임 또는 에피소드 제목 검색', + sortLabel: '스토리 정렬', + latest: '최신순', + popular: '인기순', + noSearchResults: '검색 조건에 맞는 스토리가 없습니다.', + clearSearch: '필터 초기화', + footerSocial: '커뮤니티', + footerLegal: '법적 고지 및 문의', + privacy: '개인정보 처리방침', + terms: '이용약관', + contact: '문의하기', + }, + landingCinema: { + ...en.visualGame.landingCinema, + navHome: '홈', + navDramas: '게임', + navPlayerStories: '플레이어 스토리', + navMy: '마이', + featuredDramas: '게임', + playerStories: '플레이어 스토리', + }, + searchPage: { placeholder: '스토리, 드라마 또는 계정 검색', popular: '인기 검색어', history: '최근 검색', clearHistory: '지우기', resultTypes: '검색 결과 유형', all: '전체', accounts: '계정', accountStats: '작품 {titles}개 · 재생 {plays}회' }, + storiesPage: { + swipeForNextStory: '위로 스와이프하여 다음 스토리 보기', + tags: '태그', + allTags: '모든 태그', + }, + runHistory: { + ...en.visualGame.runHistory, + videoReady: '비디오 준비 완료', + videoFailed: '비디오 생성 실패', + }, + accountCenter: { + ...en.visualGame.accountCenter, + title: '계정', + videos: '내 비디오', + emptyVideos: '아직 생성한 비디오가 없습니다.', + }, + profileEditor: { + ...en.visualGame.profileEditor, + editProfile: '프로필 편집', + nameSection: '프로필 이름', + nameHint: '게임과 비디오에 표시되는 이름입니다.', + name: '이름', + saveName: '이름 저장', + nameRequired: '이름을 입력해 주세요.', + nameSaved: '이름이 업데이트되었습니다.', + passwordSection: '비밀번호 변경', + passwordHint: '이메일로 전송된 인증 코드로 본인 확인을 진행합니다.', + email: '이메일', + verificationCode: '이메일 인증 코드', + sendCode: '코드 보내기', + codeSent: '인증 코드를 이메일로 보냈습니다.', + newPassword: '새 비밀번호', + confirmPassword: '새 비밀번호 확인', + savePassword: '비밀번호 변경', + passwordRequirements: '인증 코드와 8자 이상의 새 비밀번호를 입력해 주세요.', + passwordMismatch: '비밀번호가 일치하지 않습니다.', + passwordSaved: '비밀번호가 변경되었습니다.', + }, + visual: { + ...en.visualGame.visual, + audioFailed: '음성 재생에 실패했습니다. 다시 시도해 주세요.', + guide: { + ...en.visualGame.visual.guide, + keyboardTitle: '키보드 입력으로 전환', + keyboardText: '기본은 음성 입력입니다. 키보드 버튼을 누르면 말하거나 행동할 내용을 직접 입력할 수 있습니다.', + inputTitle: '말하기 / 행동하기 선택', + inputText: '입력하기 전에 말할지 행동할지 선택하세요. 두 선택 모두 KP가 판정합니다.', + videoTitle: '스토리를 완성하면 이번 모험이 나만의 영상이 됩니다', + videoExampleLabel: '예시 영상', + videoExampleNotice: '이 영상은 예시일 뿐이며, 실제 플레이에서 생성될 내용을 나타내지 않습니다.', + videoLoading: '예시 영상을 불러오는 중…', + videoUnavailable: '이 스토리에는 아직 예시 영상이 없습니다.', + videoDone: '나의 스토리 시작하기', + }, + close: '닫기', + inputMethod: '입력 방식', + turnVideoMatching: '스마트 CG 매칭', + turnVideoMatchingHint: '켜면 비슷한 장면의 CG를 이번 턴에 매칭합니다. 끄면 정확히 연결된 CG만 재생합니다.', + turnVideoMatchingOn: '켜짐', + turnVideoMatchingOff: '꺼짐', + textInputMode: '텍스트', + voiceInputMode: '음성', + voiceShortcutLabel: '단축키', + }, + videoCenter: { + ...en.visualGame.videoCenter, + share: '공유', + shareCopied: '링크를 복사했습니다', + shareFailed: '영상을 공유할 수 없습니다', + }, + landingIntro: { + ...en.visualGame.landingIntro, + title: 'voko.world', + apply: '액세스 신청', + watchVideo: '스토리 보기', + start: '게임 시작', + continue: '게임 계속', + viewJourney: '플레이 기록', + continueProgress: '시즌 {season} · {scenario}', + storyLine: '스토리라인', + viewMore: '더 보기', + }, + landingCarousel: { + ...en.visualGame.landingCarousel, + introEyebrow: '생성형 드라마 게임', + introSummary: '숏 드라마를 보고 역할 속으로 들어가, 모든 선택으로 스토리를 바꿔 보세요.', + dramaEyebrow: '추천 드라마 {count}', + controls: '홈 캐러셀', + goToSlide: '{count}번 슬라이드로 이동', + }, + landingMetrics: { playCount: '플레이', likeCount: '좋아요' }, + landingCard: { + ...en.visualGame.landingCard, + level: '에피소드', + searchPlaceholder: '스토리, 비디오 또는 플레이어 찾기', + scriptFilter: '드라마', + scriptAll: '모든 드라마', + playerVideoCount: '공개 비디오: {count}', + publishedAt: '공개일', + publishedAll: '전체 기간', + publishedToday: '오늘', + publishedWeek: '지난 7일', + publishedMonth: '지난 30일', + expandSynopsis: '더 보기', + collapseSynopsis: '접기', + }, +}, generatedVisualGame) + +function mergeMessages(base, overrides) { + const output = { ...base } + for (const [key, value] of Object.entries(overrides)) { + output[key] = value && typeof value === 'object' && !Array.isArray(value) + ? mergeMessages(output[key], value) + : value + } + return output +} + +export default { ...en, common, auth, account, pay: { ...en.pay, ...payPoints }, visualGame, legal } diff --git a/packages/i18n/src/messages/ko/legal.js b/packages/i18n/src/messages/ko/legal.js new file mode 100644 index 0000000..b116aa4 --- /dev/null +++ b/packages/i18n/src/messages/ko/legal.js @@ -0,0 +1,16 @@ +export default { + privacy: { title: '개인정보 처리방침', updated: '최종 업데이트: 2026년 8월 18일', intro: 'voko.world는 생성형 인터랙티브 드라마 경험입니다. 본 방침은 수집하는 정보, 사용 방법, 그리고 이용자의 선택권을 설명합니다.', sections: { + information: { title: '수집하는 정보', body: '이메일 주소, 프로필 이름, 로그인 데이터, 게임 진행 정보, 스토리 선택, 생성된 비디오 기록 및 서비스의 안전성과 신뢰성을 유지하는 데 필요한 기본 기기·로그 정보를 수집합니다.' }, + usage: { title: '정보 사용 방법', body: '게임 제공, 진행 정보 저장, 스토리 비디오 생성·표시, 제품 또는 비디오 상태 알림, 안전성과 품질 개선, 남용 방지 및 고객 지원을 위해 정보를 사용합니다.' }, + generated: { title: '생성 콘텐츠', body: '입력, 선택, 스토리 로그 및 생성된 비디오는 경험을 생성·검수·저장·제공하기 위해 당사 시스템과 서비스 제공업체에서 처리될 수 있습니다.' }, + voice: { title: '음성 입력', body: '음성 입력을 사용하면 마이크 녹음이 변환을 위해 ByteDance Doubao 음성-텍스트 서비스로 전송됩니다. voko.world는 변환이 완료된 후 녹음을 저장하지 않습니다. 변환된 텍스트는 게임 행동으로 전송하기 전에 수정할 수 있습니다.' }, + sharing: { title: '정보 공유', body: '개인정보를 판매하지 않습니다. 서비스 제공업체, 인프라 업체, 분석·안전 도구, 법적으로 요구된 기관, 또는 이용자가 콘텐츠 공개·공유를 선택한 경우에만 필요한 정보를 공유합니다.' }, + retention: { title: '보유 기간', body: 'voko.world 제공, 법적 의무 준수, 분쟁 해결 및 서비스 보호에 필요한 기간 동안 정보를 보유합니다. 계정 또는 데이터 삭제를 요청하려면 문의해 주세요.' }, + choices: { title: '이용자의 선택', body: '계정을 관리하고, 생성된 비디오의 공개 여부를 선택하며, 선택적 알림을 해지할 수 있습니다. 정보 열람, 정정 또는 삭제 요청은 당사에 문의해 주세요.' }, + contact: { title: '문의', body: "개인정보 관련 문의는 service{'@'}voko.world로 보내 주세요." }, + } }, + terms: { title: '이용약관', updated: '최종 업데이트: 2026년 8월 13일', intro: '본 약관은 voko.world에 대한 접근 및 이용에 적용됩니다. 서비스를 사용하면 본 약관에 동의한 것으로 간주됩니다.', sections: { + usage: { title: 'voko.world 이용', body: 'voko.world를 합법적인 개인 오락 및 창작 목적으로 이용할 수 있습니다. 이용자는 자신의 계정, 선택, 제출 또는 공개하는 콘텐츠에 대해 책임을 집니다.' }, accounts: { title: '계정과 접근', body: '정확한 계정 정보를 제공하고 로그인 정보를 안전하게 보관해야 합니다. 남용, 보안 위험 또는 약관 위반이 확인되면 접근을 정지할 수 있습니다.' }, generated: { title: '생성된 스토리와 비디오', body: 'voko.world는 상호작용을 기반으로 텍스트, 이미지, 오디오 또는 비디오를 생성할 수 있습니다. 생성 결과는 달라질 수 있고 불완전할 수 있으며, 사실·법률·의료·금융 또는 전문 조언으로 의존해서는 안 됩니다.' }, rules: { title: '콘텐츠 규칙', body: '불법, 학대, 권리 침해, 기망, 성적 착취 또는 타인에게 해를 끼치는 콘텐츠를 제출·요청·공개·배포하지 마세요.' }, property: { title: '지식재산권', body: 'voko.world의 소프트웨어, 디자인, 브랜드 및 서비스 콘텐츠는 당사 또는 라이선스 제공자에게 귀속됩니다. 이용자는 자신이 제공하는 콘텐츠에 책임을 지며, 서비스 운영·표시·개선·보호에 필요한 권리를 당사에 부여합니다.' }, availability: { title: '서비스 제공', body: '서비스의 일부를 변경·일시 중지·종료할 수 있습니다. 당사가 통제할 수 없는 사건으로 인한 중단·데이터 손실 또는 제3자 서비스 장애에 대해 책임을 지지 않습니다.' }, contact: { title: '문의', body: "본 약관에 대한 문의는 contact{'@'}idearrun.com으로 보내 주세요." }, + } }, + contact: { title: '문의하기', updated: 'voko.world 지원 및 커뮤니티', intro: '질문, 파트너십 제안, 버그 제보 또는 콘텐츠 관련 문의가 있다면 연락해 주세요.', sections: { email: { title: '이메일', body: "service{'@'}voko.world" }, community: { title: '커뮤니티', body: 'Discord 참여: https://discord.gg/WeESGyuUSX' }, social: { title: '소셜 미디어', body: '푸터 링크에서 X, YouTube, Instagram, TikTok, Facebook의 voko.world를 팔로우하세요.' }, support: { title: '지원 문의 안내', body: '계정 또는 비디오 문제를 문의할 때는 계정 이메일, 페이지 URL, 문제에 대한 짧은 설명을 함께 보내 주세요. 비밀번호나 결제 정보는 보내지 마세요.' } } }, +} diff --git a/packages/i18n/src/messages/ko/pay.js b/packages/i18n/src/messages/ko/pay.js new file mode 100644 index 0000000..4080b3e --- /dev/null +++ b/packages/i18n/src/messages/ko/pay.js @@ -0,0 +1,5 @@ +export default { + points: { + paid: '구매 포인트: {points}', bonus: '보너스 포인트: {points}', total: '총 포인트: {points}', buy: '포인트 구매', signInToBuy: '로그인 후 구매', insufficient: '포인트 잔액이 부족합니다.', shortfall: '{points} 포인트가 더 필요합니다.', orderCanceled: '취소됨', orderFailed: '결제 실패', + }, +} diff --git a/packages/i18n/src/messages/ko/visual-game.generated.js b/packages/i18n/src/messages/ko/visual-game.generated.js new file mode 100644 index 0000000..23f917d --- /dev/null +++ b/packages/i18n/src/messages/ko/visual-game.generated.js @@ -0,0 +1,788 @@ +/** Generated Korean Visual Game messages. Regenerate with tools/scripts/translate-visual-game-static-ko.js. */ +export default { + "appName": "voko.world", + "productBrandSub": "당신의 kosmo가 부르고 있어요.", + "nav": { + "play": "플레이", + "notes": "단서", + "state": "상태", + "editor": "제작" + }, + "scenario": { + "title": "시나리오", + "available": "시나리오 목록", + "assetManifest": "에셋 목록" + }, + "character": { + "title": "캐릭터", + "start": "게임 시작", + "current": "현재 캐릭터" + }, + "stage": { + "empty": "시나리오와 캐릭터를 선택하세요", + "turn": "{turn}턴", + "ending": "엔딩", + "transcript": "대화 기록" + }, + "composer": { + "act": "행동", + "say": "대사", + "placeholder": "행동이나 대사를 입력하세요" + }, + "actions": { + "refresh": "새로고침", + "start": "시작", + "home": "홈", + "backHome": "뒤로", + "newRun": "다시 시작", + "send": "전송", + "save": "저장", + "compile": "컴파일", + "publish": "게시", + "rollback": "롤백", + "upload": "업로드", + "select": "선택", + "endingText": "엔딩 텍스트", + "load": "불러오기", + "backToGame": "뒤로", + "cancel": "취소", + "confirm": "확인" + }, + "account": { + "signIn": "로그인", + "signedIn": "로그인됨", + "signedOut": "로그아웃되었습니다", + "sessionExpired": "세션이 만료되었습니다. 다시 로그인해 주세요.", + "logoutFailed": "로그아웃에 실패했습니다. 다시 시도해 주세요.", + "registered": "계정이 생성되었습니다", + "email": "이메일", + "emailPlaceholder": "이메일 주소", + "password": "비밀번호", + "passwordPlaceholder": "비밀번호 입력", + "confirmPassword": "비밀번호 확인", + "confirmPasswordPlaceholder": "비밀번호 다시 입력", + "verificationCode": "인증 코드", + "verificationCodePlaceholder": "6자리 이메일 코드", + "inviteToken": "초대 코드", + "inviteTokenPlaceholder": "8자리 초대 코드", + "sendCode": "코드 보내기", + "resendCode": "재전송", + "codeSent": "코드를 보냈습니다. 이메일을 확인하세요.", + "codeRequired": "먼저 인증 코드를 입력하세요.", + "inviteInvalid": "유효한 8자리 초대 코드를 입력하세요.", + "emailRequired": "먼저 이메일을 입력하세요.", + "emailInvalid": "올바른 이메일 주소를 입력하세요.", + "passwordRequired": "비밀번호를 입력하세요.", + "confirmPasswordRequired": "먼저 비밀번호를 확인해 주세요.", + "loginFieldsRequired": "이메일과 비밀번호를 입력하세요.", + "inviteEmailMismatch": "해당 이메일이 초대 코드에 연결된 신청 이메일과 일치하지 않습니다.", + "passwordMismatch": "비밀번호가 일치하지 않습니다.", + "createAccount": "계정 만들기", + "hasAccount": "이미 계정이 있으신가요?", + "noAccount": "계정이 없으신가요?", + "termsHint": "계속하면 해당 이메일이 본인 소유임을 확인하는 것입니다. 계정은 진행 상황, 주문 및 크레딧을 저장합니다.", + "login": "로그인", + "register": "가입하기", + "reset": "재설정", + "forgotPassword": "비밀번호를 잊으셨나요?", + "resetPassword": "비밀번호 재설정", + "resetTitle": "비밀번호 재설정", + "resetBody": "이메일 코드를 인증한 후 새 비밀번호를 설정하세요. 해당 계정의 기존 세션이 모두 해제됩니다.", + "resetCodeSent": "재설정 코드를 보냈습니다. 이메일을 확인하세요.", + "resetFieldsRequired": "이메일, 인증 코드, 새 비밀번호 및 확인 비밀번호를 입력하세요.", + "passwordResetDone": "비밀번호가 재설정되었습니다. 새 비밀번호로 로그인하세요.", + "logout": "로그아웃", + "logoutAll": "모든 기기에서 로그아웃", + "credits": "크레딧", + "buy": "크레딧 구매", + "billing": "결제", + "promptTitle": "로그인 또는 회원가입 후 시작", + "promptBody": "계정에 진행 상황, 세이브 슬롯, 엔딩, 크레딧, 주문 내역이 저장되어 다른 기기에서도 계속할 수 있습니다.", + "signedInBody": "진행 상황, 세이브 슬롯, 크레딧이 계정에 저장되었습니다.", + "registerAndStart": "계정을 만들고 캐릭터를 선택하세요", + "guestStart": "게스트로 체험하기", + "savedHint": "로그인하면 진행 상황이 자동으로 저장됩니다.", + "currentAccount": "현재 계정", + "saveKicker": "클라우드 저장", + "saveTitle": "진행 상황과 구매 내역을 저장하세요", + "loginTitle": "로그인", + "loginBody": "로그인 후 현재 기기의 게스트 진행 상황을 병합합니다. 병합할 내용이 없으면 계정 상태로 바로 진입합니다.", + "registerTitle": "계정 만들기", + "registerBody": "초대 코드로 계정을 만들고 이메일을 인증하세요.", + "openBilling": "크레딧과 주문 내역 보기", + "mergePending": "현재 기기의 게스트 진행 상황을 병합하는 중…", + "mergeSuccess": "게스트 데이터 병합 완료: {details}.", + "mergeSeparator": "· ", + "mergeRuns": "{count}회 플레이", + "mergeTurns": "{count}턴", + "mergeProgress": "진행 기록 {count}개", + "mergeSaveSlots": "세이브 슬롯 {count}개", + "mergeEndingRecords": "엔딩 {count}개", + "mergeEvents": "이벤트 {count}개", + "mergeAgentCalls": "AI 호출 {count}회", + "mergeBillingLedger": "크레딧 내역 {count}건", + "mergeBillingUnlocks": "유료 해제 {count}건", + "mergeBillingFreeQuota": "무료 할당량 {count}건", + "mergePaymentOrders": "주문 {count}건", + "mergeEmpty": "병합할 로컬 게스트 진행 상황이 없습니다.", + "mergeFailed": "로그인되었으나 게스트 진행 상황을 병합하지 못했습니다. 페이지를 새로고침하고 다시 시도해 주세요." + }, + "billing": { + "title": "크레딧과 주문", + "subtitle": "잔액, 크레딧 구매, 거래 내역, 주문 내역을 확인하세요.", + "guestSubtitle": "게스트로도 크레딧 패키지를 둘러볼 수 있습니다. 구매, 잔액 확인, 주문 및 거래 내역 조회는 로그인 후 이용하세요.", + "balance": "현재 잔액", + "products": "크레딧 패키지", + "ledger": "크레딧 내역", + "orders": "주문", + "refresh": "새로고침", + "buy": "{points} 크레딧 구매", + "checkoutReady": "결제 링크가 준비되었습니다.", + "openCheckout": "결제 페이지 열기", + "selectedPack": "선택됨", + "selectedProduct": "{title} 선택됨. 계정 잔액이 {points} 크레딧 증가합니다.", + "signInToBuy": "구매하려면 로그인하세요", + "readyToBuy": "크레딧 구매 준비 완료", + "readyToBuyBody": "크레딧 패키지를 선택하고 결제를 진행하세요. 주문은 확인 전까지 처리 중으로 표시될 수 있습니다.", + "checkoutPending": "결제 페이지로 이동 중…", + "checkoutReturnedSuccess": "돌아오신 것을 환영합니다. 잔액과 주문을 새로고침 중입니다. 결제 확인 후 크레딧이 추가됩니다.", + "checkoutReturnedCancel": "결제가 취소되었거나 완료되지 않았습니다. 크레딧이 추가되지 않았습니다.", + "signInHint": "잔액, 주문 및 내역을 확인하려면 로그인하세요.", + "emptyProducts": "크레딧 패키지가 없습니다.", + "emptyLedger": "내역이 없습니다.", + "emptyOrders": "주문이 없습니다.", + "productFallback": "크레딧 패키지", + "points": "{points} 크레딧", + "price": "{amount} {currency}", + "ledgerAmount": "{amount} 크레딧", + "orderStatus": "상태 {status}", + "orderStatusPending": "처리 중", + "orderStatusPaid": "결제 완료", + "orderStatusRefunded": "환불 완료", + "orderStatusDisputed": "분쟁 처리 중", + "orderStatusUnknown": "알 수 없는 상태", + "ledgerReasonSignupGift": "가입 선물", + "ledgerReasonCheckout": "크레딧 구매", + "ledgerReasonRefund": "주문 환불", + "ledgerReasonChargeback": "충전백 취소", + "ledgerReasonUnlock": "콘텐츠 잠금 해제", + "paywallTitle": "크레딧으로 잠금 해제", + "paywallTarget": "대상", + "paywallReason": "사유", + "paywallGenericReason": "이 콘텐츠를 계속하려면 크레딧이 필요합니다.", + "paywallFreeContinue": "아직 무료 플레이 기회가 남아 있습니다. 크레딧을 소모하지 않고 계속할 수 있습니다.", + "paywallInsufficient": "크레딧이 부족합니다. 더 많은 크레딧을 구매해 주세요.", + "paywallPrice": "필요한 크레딧", + "paywallFreeQuota": "남은 무료 횟수", + "paywallBalance": "사용 가능한 잔액" + }, + "endings": { + "title": "엔딩 도감", + "unlocked": "해제됨", + "locked": "잠김", + "collected": "수집 {count}/{total}", + "lockedHint": "해당 엔딩을 달성하면 잠금이 해제됩니다." + }, + "saves": { + "title": "저장 슬롯", + "progress": "현재 진행", + "activeRun": "현재 게임", + "slot": "슬롯 {slotNo}", + "savedRun": "저장된 진행", + "emptySlot": "빈 슬롯", + "emptyHint": "현재 진행 상황을 저장하면 나중에 다시 돌아올 수 있습니다.", + "saveHere": "저장", + "overwrite": "저장 덮어쓰기", + "restore": "불러오기", + "delete": "삭제", + "saved": "게임이 저장되었습니다.", + "restored": "저장 파일을 불러왔습니다.", + "deleted": "저장 파일이 삭제되었습니다.", + "conflict": "이 슬롯에 이미 저장 파일이 있습니다. 덮어쓰기를 확인하세요.", + "restoreConfirm": "이 저장 파일을 불러오면 현재 게임 상태가 대체됩니다.", + "deleteConfirm": "이 저장 지점을 삭제하면 되돌릴 수 없습니다.", + "noRunHint": "저장할 수 있는 진행 중인 게임이 없습니다. 먼저 게임을 시작하거나 계속하세요." + }, + "map": { + "title": "맵", + "current": "현재 위치", + "route": "이동 가능 경로", + "routes": "이동 가능 목적지", + "locked": "잠김", + "moveTo": "{location}로 이동", + "objects": "조사 가능한 오브젝트", + "inspect": "조사", + "inspected": "조사 완료", + "inspectObject": "{object} 조사" + }, + "videoCenter": { + "eyebrow": "플레이어 플레이 기록", + "title": "영상관", + "backHome": "뒤로", + "chooseScenario": "시나리오 선택", + "allScenarios": "전체 시나리오", + "searchScenario": "시나리오 검색", + "scenarioPaintedSkin": "화피", + "scenarioGuian": "귀안현 실심사건", + "nowPlaying": "재생 중", + "player": "플레이어", + "views": "조회", + "ending": "엔딩", + "duration": "재생 시간", + "libraryEyebrow": "같은 시나리오의 플레이어 영상", + "allVideos": "전체 영상", + "sort": "영상 정렬", + "recommended": "추천", + "latest": "최신", + "videoMeta": "{player} · {date}", + "emptyTitle": "아직 플레이어 영상 없음", + "emptyText": "이 시나리오의 공개 플레이 영상이 없습니다.", + "viewAll": "모든 플레이어 영상 보기", + "discoverStories": "다른 플레이어들의 선택 확인하기", + "backPrevious": "뒤로", + "aiSummary": "AI 요약:", + "endingCount": "개의 엔딩", + "publicSeriesCount": "개의 공개 시리즈", + "totalPlays": "총 재생", + "seriesList": "플레이어 시리즈", + "completed": "완결", + "updating": "에피소드 {count} · 연재 중", + "seriesMetrics": "에피소드: {episodes} · ▶ {plays} · ♥ {likes}", + "watchSeries": "이 시리즈 시청하기", + "playOwnSeries": "나만의 이야기 만들기", + "minutes": "{count}분", + "share": "공유", + "shareCopied": "링크를 복사했습니다", + "shareFailed": "영상을 공유할 수 없습니다", + "like": "좋아요", + "liked": "좋아요 누름", + "save": "저장", + "saved": "저장됨", + "storyHighlight": "스토리 하이라이트", + "startPlay": "지금 플레이 · 이 엔딩을 바꿔보세요", + "episodeList": "에피소드", + "episodeTotal": "에피소드: {count}", + "episodeTitle": "에피소드 {count} · {title}" + }, + "runHistory": { + "eyebrow": "플레이 기록", + "title": "내 게임", + "allRuns": "전체 기록", + "count": "게임: {count}", + "empty": "아직 플레이한 게임이 없습니다. 게임을 시작하면 여기에 표시됩니다.", + "loadFailed": "게임을 불러올 수 없습니다: {error}", + "noTime": "알 수 없는 시간", + "statusActive": "진행 중", + "statusCompleted": "종료됨", + "videoNone": "영상 없음", + "videoQueued": "영상 대기 중", + "videoRunning": "영상 생성 중", + "videoReady": "비디오 준비 완료", + "videoFailed": "비디오 생성 실패", + "videoRetry": "다시 생성", + "untitledEnding": "결과 없음", + "meta": "{scenario} · 턴 {turn} · {date}", + "openRun": "게임 계속하기", + "openNotes": "노트", + "watchVideo": "영상 보기" + }, + "accountCenter": { + "loadFailed": "프로필을 불러올 수 없습니다", + "loadTimeout": "요청 시간이 초과되었습니다. 연결 상태를 확인하고 다시 시도하세요.", + "title": "계정", + "eyebrow": "플레이어 프로필", + "runs": "내 게임", + "videos": "내 비디오", + "favorites": "저장한 영상", + "watchSeason": "시즌 보기", + "emptyGames": "아직 시작한 게임이 없습니다.", + "emptyVideos": "아직 생성한 비디오가 없습니다.", + "emptyFavorites": "아직 저장한 영상이 없습니다.", + "lastPlayed": "최근 플레이 · {date}", + "currentTurn": "진행 중 · {turn}턴", + "noActiveRun": "진행 중인 게임 없음", + "endingsUnlocked": "엔딩: {unlocked}/{total}", + "runsPlayed": "플레이 횟수: {count}회", + "continueGame": "게임 계속하기", + "startGame": "게임 시작하기", + "viewHistory": "기록 보기", + "levelProgress": "클리어: {played}/{total} 스테이지", + "viewSeasonDetails": "시즌 상세 정보 보기", + "hideSeasonDetails": "시즌 상세 정보 숨기기", + "levelInProgress": "진행 중", + "levelCompleted": "완료", + "endingLabel": "엔딩 도달", + "cluesLabel": "단서 획득", + "clueCount": "단서: {count}개", + "viewRunRecords": "게임 기록 보기", + "runRecordSummary": "단서: {clues}개 · 엔딩: {ending}개", + "noEnding": "아직 도달한 엔딩 없음", + "noClues": "아직 획득한 단서 없음", + "seasonDetailsLoadFailed": "시즌 정보를 불러올 수 없습니다. 다시 시도해 주세요.", + "public": "공개", + "private": "나만 보기", + "deleteVideo": "영상 삭제", + "confirmDelete": "영상을 삭제하시겠습니까?", + "signInTitle": "계속하려면 로그인하세요", + "signInBody": "게임 진행 상황과 영상을 관리하려면 로그인하세요." + }, + "visual": { + "eyebrow": "TRPG Visual", + "chooseScript": "시나리오 선택", + "actTitle": "제{count}막 · {title}", + "actLocked": "{title} 잠김", + "actSealed": "앞 막이 끝나지 않았습니다", + "toBeContinued": "계속", + "finishPreviousAct": "이전 막의 엔딩을 달성하세요", + "revealAfterAct": "Act {count} 완료 후 공개", + "moreScripts": "더 많은 레벨", + "comingSoon": "곧 공개", + "watchScriptVideo": "스토리 영상 보기", + "scenarioKicker": "시나리오", + "hiddenTitle": "톈우", + "revealedTitle": "화피", + "objectives": "현재 목표", + "playHistory": "플레이 기록", + "objectiveLabel": "목표", + "close": "닫기", + "noObjectives": "진행 중인 목표가 없습니다.", + "taskProgress": "단계 {done}/{total}", + "entryKicker": "게임 시작", + "entryTitle": "오늘 밤, 그 방에 들어가다", + "emptyScenarioEyebrow": "시나리오 이용 불가", + "emptyScenarioTitle": "플레이 가능한 시나리오 없음", + "emptyScenarioText": "서버에서 게시된 시나리오를 반환하지 않았습니다. 새로고침하거나 게시 상태를 확인하세요.", + "emptyScenarioActionTitle": "게시 대기 중", + "emptyScenarioActionText": "게시된 시나리오가 있어야 게임을 시작할 수 있습니다.", + "continueTitle": "마지막 게임 계속하기", + "entryBody": "오래된 방에 들어가기 전에 캐릭터를 선택하세요.", + "chooseCharacter": "캐릭터 선택", + "enterStory": "스토리 시작", + "back": "뒤로", + "skip": "건너뛰기", + "editor": "에디터", + "debugPage": "디버그", + "state": "상태", + "settings": "설정", + "language": "언어", + "languageUnavailable": "해당 레벨은 아직 선택한 언어의 번역을 제공하지 않습니다.", + "exitGame": "게임 종료", + "exitGameHint": "현재 세션을 종료하고 시나리오 선택 화면으로 돌아갑니다.", + "saveAndExit": "저장하고 종료", + "exitWithoutSaving": "저장하지 않고 종료", + "notes": "노트", + "saves": "저장", + "continueRun": "게임 계속하기", + "balanceEntry": "잔액 {credits}", + "endingCollection": "엔딩 도감", + "narrator": "내레이터", + "npcName": "방 안의 여인", + "npcLine": "“{line}”", + "notStarted": "시작 전", + "startHint": "시작을 눌러 오래된 집에 들어가세요.", + "debug": "디버그 수치", + "debugOnly": "디버그 전용", + "waiting": "준비 완료.", + "modeAct": "행동", + "modeSay": "대화", + "inputMethod": "입력 방식", + "textInputMode": "텍스트", + "voiceInputMode": "음성", + "choiceGroupLabel": "스토리 분기 선택", + "inputPlaceholder": "예: 나는 동경을 들어 그녀와 초상화를 비추었다.", + "mobileInputPlaceholder": "대화나 행동을 입력하세요…", + "voiceStart": "음성 입력 시작", + "voiceStop": "음성 입력 중지", + "voiceListening": "듣는 중…", + "voiceRecognizing": "인식 중…", + "voiceUnsupported": "이 브라우저는 음성 입력을 지원하지 않습니다", + "voicePermissionDenied": "마이크 접근이 거부되었습니다. 브라우저 권한을 확인해 주세요", + "voiceMicrophoneUnavailable": "사용 가능한 마이크를 찾을 수 없습니다", + "voiceNoSpeech": "음성을 인식하지 못했습니다. 다시 시도해 주세요", + "voiceNoAudioCaptured": "마이크 오디오를 캡처하지 못했습니다. 브라우저 권한을 확인해 주세요", + "voiceRecognitionFailed": "음성 인식에 실패했습니다. 다시 시도해 주세요", + "voiceHoldToTalk": "누르고 말하기", + "voicePreparing": "마이크 준비 중…", + "voiceShortcutLabel": "단축키", + "voiceReleaseSend": "떼면 전송", + "voiceSlideCancel": "위로 올려 취소", + "voiceReleaseCancel": "떼면 취소", + "voiceUnderstanding": "명령을 이해하는 중…", + "ending": "결말", + "endingLabel": "엔딩", + "endingLine": "결말: {title}\n{description}", + "endingSummary": "결말 요약", + "endingPoem": "정장시", + "endingFallbackPoem": "……", + "endingBackgroundLoadFailed": "결말 장면을 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요.", + "recordSeal": "사건", + "recordTitle": "포쾌의 기록", + "recordKicker": "현 아카이브", + "recordMeta": "비가 그친 밤 · 낡은 집 수사기", + "continue": "계속하기", + "enterNextEpisode": "다음 에피소드로", + "viewCurrentSeasonStory": "이 시즌 스토리 보기", + "played": "재생 완료", + "nextPage": "다음 페이지", + "statusCreating": "스토리를 준비하는 중…", + "statusStarted": "게임이 시작되었습니다. 대화나 행동을 입력하세요.", + "statusHistoryRun": "이전 게임 기록을 보고 있습니다. 계속하면 최근 저장된 진행 상황을 따릅니다.", + "statusKp": "키퍼가 행동을 판정하는 중…", + "statusDone": "턴 완료.", + "contentFiltered": "이 행동은 묘사할 수 없습니다. 다른 방식으로 행동해 보세요.", + "turnFailed": "이 단계를 완료하지 못했습니다. 다시 시도해 주세요.", + "actionRejected": "그 행동은 아직 성립할 수 없습니다. 다른 방법을 시도하세요.", + "statusEnded": "게임이 종료되었습니다.", + "statusEndingText": "엔딩을 작성하는 중…", + "statusEndingLocked": "엔딩이 결정되었습니다. 피날레를 준비하는 중…", + "statusNpcAction": "캐릭터가 행동을 취하는 중입니다. 키퍼가 결과를 판정하는 중…", + "statusNpcDone": "NPC가 응답했습니다.", + "clueGained": "단서 획득", + "landscapeTitle": "기기를 가로로 회전하세요", + "landscapeBody": "이 스토리는 가로 화면에 최적화되어 있어 전체 장면이 보입니다.", + "stats": { + "suspicion": "의심", + "charm": "매력", + "fear": "공포" + }, + "guide": { + "label": "가이드", + "skip": "건너뛰기", + "next": "다음", + "done": "알겠습니다", + "optionsTitle": "제안 선택", + "optionsText": "캐릭터가 응답하면 아래에 두 가지 제안 선택지가 나타납니다. '말하기' 또는 '행동하기'를 선택해 계속하세요.", + "inputTitle": "자유 행동 입력", + "inputText": "'말하기 / 행동하기'를 전환해 직접 입력할 수도 있습니다. 무엇을 말하든, 무엇을 하든 KP가 판정합니다.", + "notesTitle": "노트 확인", + "notesText": "오른쪽 노트 버튼은 발견한 단서와 해금한 엔딩을 기록합니다. 정보를 정리할 때 언제든 열어보세요.", + "goalTitle": "생존", + "goalText": "이건 단순한 대화가 아닙니다. 살아남는 것이 목표입니다: 그녀의 말을 듣고, 방을 살피며, 단서와 이상한 것들을 조사하세요." + }, + "openingOptions": { + "say": "죄송합니다, 무단 침입할 생각은 없었어요. 밖에 뭔가가 저를 쫓고 있었어요.", + "act": "그녀의 말에 따라 목소리를 낮추고, 문과 창문, 램프, 그리고 그녀의 위치를 살핍니다." + } + }, + "panels": { + "clues": "단서", + "conclusions": "결론", + "info": "정보", + "inventory": "소지품", + "events": "사건", + "stats": "능력치", + "flags": "플래그", + "objects": "오브젝트", + "npcs": "NPC", + "endingRecords": "해금한 엔딩" + }, + "standalone": { + "summary": "요약", + "availableClues": "확인 가능한 단서", + "recentTurns": "최근 턴", + "audit": { + "title": "AI 감사", + "subtitle": "KP, NPC, 엔딩 및 TTS 호출의 비용과 오류 기록.", + "totalCalls": "호출", + "failedCalls": "실패", + "tokens": "입력/출력 토큰", + "cost": "비용", + "roles": "역할 요약", + "models": "모델 요약", + "recentCalls": "최근 호출", + "empty": "AI 호출 기록이 없습니다.", + "calls": "{count}회 호출", + "tokenPair": "토큰: {tokens}" + }, + "state": { + "title": "상태 보기", + "waiting": "게임 데이터를 기다리는 중입니다.", + "noRun": "게임 ID가 제공되지 않았습니다. 게임에서 이 페이지를 열거나 ID를 수동으로 입력하세요.", + "loading": "게임 {runId} 로드 중…", + "loaded": "게임 {runId} 로드 완료" + }, + "notes": { + "title": "노트", + "waiting": "노트를 로드하는 중입니다.", + "noRun": "현재 저장된 게임을 찾을 수 없습니다. 먼저 게임을 시작하거나 계속하세요.", + "loading": "게임 {runId}의 노트 로드 중…", + "loaded": "게임 {runId}의 노트 로드 완료", + "unavailable": "이 게임의 노트를 일시적으로 사용할 수 없습니다.", + "emptyClues": "아직 단서가 없습니다", + "emptyConclusions": "아직 결론이 없습니다", + "emptyEndings": "아직 엔딩이 없습니다", + "tabs": "노트 카테고리", + "list": "항목 목록", + "detail": "항목 상세", + "locked": "잠김", + "lockedHint": "이 엔딩은 아직 기록되지 않았습니다.", + "recorded": "기록됨", + "importance": { + "minor": "부차적", + "major": "중요", + "critical": "핵심" + }, + "relatedClues": "관련 단서", + "noRelatedClues": "아직 이 결론을 뒷받침하는 단서를 찾지 못했습니다." + } + }, + "editor": { + "title": "시나리오 제작", + "pack": "시나리오 팩", + "section": "섹션", + "raw": "JSON", + "structured": "구조화 편집", + "debugJson": "디버그 JSON", + "item": "항목", + "noStructuredFields": "이 섹션에는 아직 직접 편집 가능한 구조화 필드가 없습니다.", + "updated": "저장됨", + "assetUploaded": "에셋이 드래프트에 업로드됨", + "compiled": "드래프트 컴파일 완료", + "published": "버전 게시 완료", + "rolledBack": "버전으로 롤백됨", + "versions": "버전", + "assets": "에셋 인덱스", + "current": "현재", + "currentVersion": "현재 버전", + "noPublishedVersion": "게시되지 않음", + "noVersions": "아직 버전이 없습니다. 먼저 초안을 컴파일하세요.", + "noAssets": "아직 에셋 인덱스가 없습니다. 먼저 에셋 매니페스트를 컴파일하세요.", + "noFilteredAssets": "현재 필터와 일치하는 에셋이 없습니다.", + "assetSearch": "검색", + "assetSearchPlaceholder": "태그, 파일 또는 objectKey", + "assetTypeFilter": "유형", + "assetSourceFilter": "출처", + "assetFilterAll": "전체", + "assetFilterResult": "{shown}/{total}", + "assetSource": { + "uploaded": "업로드된 객체", + "static": "정적 파일", + "missing": "누락된 로케이터" + }, + "uploadFile": "파일", + "uploadTag": "태그", + "uploadTagPlaceholder": "opening_cg", + "uploadType": "유형", + "noUploadFile": "선택된 파일 없음" + }, + "waitingList": { + "eyebrow": "얼리 액세스", + "apply": "대기자 명단 신청", + "useInvite": "초대 코드 사용", + "invitedRegister": "초대장이 있나요? 계정 만들기", + "title": "voko.world 대기자 명단에 참여하기", + "body": "이메일을 남겨주세요. 얼리 액세스가 승인되면 개인 초대장을 보내드립니다.", + "displayName": "이름", + "displayNamePlaceholder": "선택사항", + "useCase": "사용 사례", + "useCaseOptional": "사용 사례(선택사항)", + "useCasePlaceholder": "예: 크리에이터 프리뷰, 팀 테스트, 라이브 이벤트", + "useCaseRequired": "사용 사례를 입력해 주세요. 예: 크리에이터 프리뷰, 팀 테스트, 라이브 이벤트", + "consent": "voko.world 제품 업데이트 및 초대 요청 관련 이메일 수신에 동의합니다.", + "join": "대기자 명단 가입", + "joined": "요청이 접수되었습니다. 업데이트가 있으면 이메일로 알려드리겠습니다.", + "joinedTitle": "요청이 제출되었습니다", + "changeEmail": "다른 이메일 사용", + "accountAlreadyRegistered": "이 이메일은 이미 등록되었습니다. 로그인해 주세요.", + "emailRequired": "이메일을 입력해 주세요.", + "consentRequired": "대기 명단에 등록하려면 동의가 필요합니다.", + "inviteEyebrow": "초대", + "inviteTitle": "voko.world 계정 만들기", + "inviteBody": "이 초대는 이메일 주소와 연결되어 있습니다. 비밀번호를 설정하여 계정 생성을 완료하세요.", + "inviteMissing": "초대 코드가 제공되지 않았습니다.", + "inviteEnterCode": "받은 8자리 초대 코드를 입력하여 계정을 생성하세요.", + "inviteValidate": "초대 코드 확인", + "inviteReenter": "다른 코드 입력", + "inviteInvalid": "이 초대는 유효하지 않거나 만료되었습니다.", + "codeSent": "인증 코드가 전송되었습니다.", + "passwordMismatch": "비밀번호가 일치하지 않습니다.", + "acceptInvite": "계정 만들기" + }, + "landing": { + "homeLabel": "voko.world 홈", + "navHome": "홈", + "navScripts": "스토리", + "navVideos": "드라마", + "featuredStories": "추천 스토리", + "featuredStoriesEyebrow": "게임 시작", + "endingCount": "엔딩: {count}개", + "allStories": "모든 스토리", + "dramaTitle": "드라마", + "brandSub": "인터랙티브 스토리", + "navLabel": "주요 탐색", + "navStories": "게임", + "navGallery": "갤러리", + "eyebrow": "AI 실시간 인터랙티브 영화 게임", + "loadError": "홈페이지를 불러오지 못했습니다. 다시 시도하세요.", + "retry": "다시 시도", + "emptyGames": "아직 이용할 수 있는 게임이 없습니다.", + "scenarioFallback": "이 세계에 들어가 선택으로 스토리를 바꿔 보세요.", + "publicVideos": "플레이어 영상", + "publicGames": "공개 게임", + "watchDrama": "스토리 보기", + "enterPlay": "지금 플레이", + "comingSoon": "공개 예정", + "carouselLabel": "추천 게임", + "previousGame": "이전 게임", + "nextGame": "다음 게임", + "showcaseTitle": "플레이어가 만든 이야기", + "showcaseBody": "다른 플레이어가 도달한 결말을 감상하고, 게임에 들어가 당신만의 이야기를 만들어 보세요.", + "searchLabel": "스토리 검색", + "searchPlaceholder": "게임 또는 에피소드 제목 검색", + "sortLabel": "스토리 정렬", + "latest": "최신순", + "popular": "인기순", + "videoCount": "영상: {count}개", + "levelCount": "에피소드: {count}개", + "levelNumber": "에피소드 {count}", + "singleLevel": "단독 스토리", + "publicGame": "공개 게임", + "noSearchResults": "검색 조건에 맞는 스토리가 없습니다.", + "clearSearch": "필터 초기화", + "finalEyebrow": "당신의 이야기가 기다리고 있습니다", + "finalTitle": "누가 되시겠습니까?", + "finalBody": "문턱을 넘어 첫 선택을 내려보세요.", + "begin": "이야기 시작하기", + "footer": "© 2026 voko.world", + "earlyAccess": "얼리 액세스", + "footerSocial": "커뮤니티", + "footerLegal": "법적 고지 및 문의", + "privacy": "개인정보 처리방침", + "terms": "이용약관", + "contact": "문의하기" + }, + "landingIntro": { + "title": "voko.world", + "apply": "액세스 신청", + "watchVideo": "스토리 보기", + "start": "게임 시작", + "continue": "게임 계속", + "viewJourney": "플레이 기록", + "continueProgress": "시즌 {season} · {scenario}", + "storyLine": "스토리라인", + "viewMore": "더 보기" + }, + "landingCarousel": { + "introEyebrow": "생성형 드라마 게임", + "introSummary": "숏 드라마를 보고 역할 속으로 들어가, 모든 선택으로 스토리를 바꿔 보세요.", + "dramaEyebrow": "추천 드라마 {count}", + "controls": "홈 캐러셀", + "goToSlide": "{count}번 슬라이드로 이동" + }, + "landingCinema": { + "navHome": "홈", + "navDramas": "인터랙티브 드라마", + "navPlayerStories": "플레이어 스토리", + "featuredDramas": "인터랙티브 드라마", + "playerStories": "플레이어 스토리" + }, + "storyTree": { + "eyebrow": "시즌 스토리라인", + "subtitle": "각 선은 시즌을, 각 노드는 에피소드를 나타냅니다.", + "root": "스토리", + "branch": "분기", + "active": "진행 중", + "completed": "완료", + "locked": "미도달", + "empty": "아직 시즌 스토리라인이 없습니다.", + "loadError": "시즌 스토리라인을 불러올 수 없습니다.", + "navigation": "시즌 범위", + "range": "시즌 {first}–{last} · 총 {total}시즌", + "newer": "이전", + "older": "다음", + "startPlaying": "지금 플레이", + "turnProgress": "{turn}턴", + "continuePlaying": "계속 플레이", + "playLog": "플레이 기록", + "seasonEnding": "이번 시즌의 결말", + "currentSeason": "현재 시즌", + "logLoadError": "플레이 기록을 불러올 수 없습니다.", + "logEmpty": "이번 시즌에는 아직 플레이 기록이 없습니다.", + "turnNumber": "{turn}턴", + "playerSaid": "당신의 대사", + "playerDid": "당신의 행동", + "storyOpening": "스토리 오프닝", + "tools": "시즌 스토리라인 검색, 필터 및 정렬", + "search": "검색", + "searchPlaceholder": "에피소드, 엔딩 또는 최근 행동 검색", + "filter": "상태", + "filterAll": "전체", + "filterInProgress": "진행 중", + "filterCompleted": "완료", + "sort": "정렬", + "sortRecent": "최근 플레이", + "sortSeasonDesc": "최신 시즌 순", + "sortSeasonAsc": "오래된 시즌 순", + "resultCount": "시즌: {count}", + "noResults": "필터 조건에 맞는 시즌 스토리가 없습니다.", + "canvasHint": "드래그하거나 가로로 스크롤하여 더 많은 에피소드를 탐색하세요", + "canvasLabel": "가로 스크롤 가능한 시즌 스토리라인", + "storySwitch": "스토리" + }, + "storyTreeCurrentSeasonHint": "최신 진행 상황", + "storyTreeViewDetails": "시즌 {number} 상세 정보 보기", + "storyTreeDelete": { + "action": "분기 삭제", + "confirm": "시즌 {number} 및 이로부터 파생된 모든 분기를 삭제하시겠습니까? 삭제 후에는 복구할 수 없습니다.", + "deleting": "삭제 중…", + "error": "해당 분기를 삭제할 수 없습니다." + }, + "storyTreeVideoUnavailable": "영상 없음", + "storyTreeFork": { + "action": "여기서 다시 플레이", + "confirm": "{turn} 턴에서 새로운 스토리 분기가 생성됩니다. 현재 스토리 기록은 변경되지 않습니다.", + "failed": "새로운 스토리 분기 생성에 실패했습니다. 다시 시도해 주세요.", + "creating": "생성 중…" + }, + "landingShowcase": { + "title": "펼쳐지는 삶들", + "body": "다른 플레이어들이 선택한 길을 지켜보고, 스토리에 입장해 당신만의 결말을 써보세요." + }, + "landingMetrics": { + "playCount": "플레이", + "likeCount": "좋아요" + }, + "landingCard": { + "level": "에피소드", + "searchPlaceholder": "드라마, 에피소드 또는 플레이어 검색", + "scriptFilter": "드라마", + "scriptAll": "모든 드라마", + "playerVideoCount": "공개 비디오: {count}", + "publishedAt": "공개일", + "publishedAll": "전체 기간", + "publishedToday": "오늘", + "publishedWeek": "지난 7일", + "publishedMonth": "지난 30일", + "expandSynopsis": "더 보기", + "collapseSynopsis": "접기" + }, + "playerPage": { + "loadError": "해당 플레이어의 영상을 불러올 수 없습니다.", + "notFound": "플레이어를 찾을 수 없거나 공개된 영상이 없습니다.", + "searchPlaceholder": "드라마, 레벨 또는 시즌 제목 검색" + }, + "season": { + "choose": "시즌 선택", + "chooseBody": "현재 에피소드는 선택한 시즌의 스토리를 이어갑니다.", + "number": "시즌 {number}", + "empty": "시즌에서 이전 에피소드를 완료한 후에 계속하세요." + }, + "status": { + "loading": "로딩 중…", + "empty": "아직 내용이 없습니다", + "error": "데이터를 불러올 수 없습니다", + "streaming": "진행 중" + }, + "storiesPage": { + "swipeForNextStory": "위로 스와이프하여 다음 스토리 보기" + }, + "labels": { + "scenario": "시나리오", + "asset": "에셋", + "mode": "모드", + "phase": "페이즈", + "location": "장소", + "noRun": "시작 전", + "none": "없음" + } +} diff --git a/packages/i18n/src/messages/merge-messages.js b/packages/i18n/src/messages/merge-messages.js new file mode 100644 index 0000000..1220232 --- /dev/null +++ b/packages/i18n/src/messages/merge-messages.js @@ -0,0 +1,9 @@ +export function mergeMessages(base, overrides) { + const output = { ...base } + for (const [key, value] of Object.entries(overrides)) { + output[key] = value && typeof value === 'object' && !Array.isArray(value) + ? mergeMessages(output[key], value) + : value + } + return output +} diff --git a/packages/i18n/src/messages/pt/account.js b/packages/i18n/src/messages/pt/account.js new file mode 100644 index 0000000..e2059c2 --- /dev/null +++ b/packages/i18n/src/messages/pt/account.js @@ -0,0 +1,88 @@ +/** account.* —— Perfil (perfil / créditos / assinatura / histórico / favoritos / ajustes). pt. */ +export default { + pageTitle: 'Perfil', + backToApp: 'Voltar ao app', + + header: { + memberSince: 'Membro desde {date}', + editAvatar: 'Editar foto do perfil', + vip: 'VIP', + guestName: 'Convidado', + }, + + stats: { + credits: 'Créditos disponíveis', + watched: 'Dramas assistidos', + rewritten: 'Histórias reescritas', + }, + + history: { + title: 'Continuar assistindo', + pageTitle: 'Histórico de visualização', + subtitle: 'Continue de onde você parou.', + viewAll: 'Ver tudo', + empty: 'Nada em andamento ainda. Escolha um drama para começar a assistir.', + browse: 'Explorar dramas', + episodeLine: 'Ep {ep} • Faltam {left}', + resume: 'Retomar {title}', + }, + + favorites: { + title: 'Dramas salvos', + pageTitle: 'Dramas salvos', + subtitle: 'Dramas que você marcou para depois.', + manage: 'Gerenciar', + empty: 'Nenhum drama salvo ainda. Marque dramas para encontrá-los aqui.', + browse: 'Explorar dramas', + discoverMore: 'Descobrir mais', + open: 'Abrir {title}', + }, + + back: 'Voltar ao perfil', + + preferences: { + title: 'Preferências', + language: 'Idioma', + theme: 'Tema', + light: 'Claro', + dark: 'Escuro', + }, + + subscription: { + renews: 'Renova em {date}', + inactive: 'Nenhuma assinatura ativa', + pastDue: 'Pagamento em atraso', + topUp: 'Recarregar créditos', + manage: 'Gerenciar assinatura', + paymentMethod: 'Forma de pagamento', + upgrade: 'Atualizar plano', + tierFree: 'Plano gratuito', + }, + + account: { + title: 'Conta', + notifications: 'Notificações', + privacy: 'Privacidade e dados', + downloadData: 'Baixar dados', + deleteData: 'Excluir dados', + signOut: 'Sair', + }, + + actions: { + notificationsSoon: 'As configurações de notificação chegarão em breve.', + downloadStarted: 'Preparando a exportação dos seus dados. Enviaremos um link por e-mail.', + deleteTitle: 'Excluir seus dados?', + deleteBody: 'Isso remove permanentemente os dados da sua conta e não pode ser desfeito.', + deleteConfirm: 'Excluir dados', + deleteCancel: 'Manter meus dados', + deleteRequested: 'Solicitação de exclusão de dados recebida. Vamos processá-la em breve.', + signedOut: 'Você saiu da conta.', + languageChanged: 'Idioma atualizado.', + }, + + points: { + dailyClaimTitle: 'Pontos diários', dailyClaimAvailable: 'Resgate {points} pontos hoje.', dailyClaimed: 'Os pontos de hoje já foram resgatados.', claim: 'Resgatar pontos', claimed: 'Resgatado', nextAvailable: 'Disponível novamente: {time}', balanceTurns: '{points} pontos · {turns} turnos jogáveis', + }, + loading: 'Carregando seu perfil…', + loadError: 'Não foi possível carregar seu perfil. Tente novamente.', +} diff --git a/packages/i18n/src/messages/pt/auth.js b/packages/i18n/src/messages/pt/auth.js new file mode 100644 index 0000000..e9b332b --- /dev/null +++ b/packages/i18n/src/messages/pt/auth.js @@ -0,0 +1,70 @@ +/** auth.* —— 登录 / 注册 / 身份合并。pt 与 en 同构。 */ +export default { + login: { + title: 'Bem-vindo de volta', + subtitle: 'Insira seus dados para acessar sua conta.', + submit: 'Entrar', + submitting: 'Entrando…', + submitError: 'Não foi possível entrar. Verifique seus dados e tente novamente.', + email: 'Endereço de e-mail', + emailPlaceholder: "name{'@'}example.com", + emailRequired: 'O e-mail é obrigatório.', + invalidEmail: 'Insira um e-mail válido.', + password: 'Senha', + passwordRequired: 'A senha é obrigatória.', + forgot: 'Esqueceu a senha?', + showPassword: 'Mostrar senha', + hidePassword: 'Ocultar senha', + }, + register: { + title: 'Crie sua conta', + subtitle: 'Verifique seu e-mail para começar.', + submit: 'Cadastrar', + haveAccount: 'Já tem uma conta?', + codeSent: 'Código de verificação enviado. Verifique sua caixa de entrada.', + }, + segment: { + signIn: 'Entrar', + createAccount: 'Criar conta', + }, + social: { + divider: 'Ou continue com', + google: 'Continuar com Google', + googleEnglish: 'Continue with Google', + email: 'Continuar com e-mail', + apple: 'Continuar com Apple', + }, + forgot: { + title: 'Redefinir sua senha', + body: 'Insira o e-mail vinculado à sua conta e enviaremos um código de redefinição.', + emailLabel: 'Endereço de e-mail', + submit: 'Enviar código', + sent: 'Código de redefinição enviado. Verifique sua caixa de entrada.', + needEmail: 'Insira um e-mail válido.', + error: 'Não foi possível enviar o código. Tente novamente.', + }, + legal: { + agree: 'Ao continuar você concorda com nossos', + terms: 'Termos', + and: 'e', + privacy: 'Política de Privacidade', + termsTitle: 'Termos de Serviço', + privacyTitle: 'Política de Privacidade', + termsBody: + 'Ao usar o IdearRun você concorda em usar o serviço apenas para entretenimento pessoal e lícito. Os dramas interativos e as histórias que você gera são licenciados para você assistir dentro do app e não podem ser redistribuídos. Podemos atualizar estes termos conforme o produto evolui; o uso contínuo significa que você aceita a versão mais recente.', + privacyBody: + 'Coletamos os dados mínimos necessários para operar o IdearRun: o e-mail da sua conta, o histórico de exibição e as escolhas de interação usadas para personalizar recomendações. Nunca vendemos seus dados pessoais. A atividade anônima só é mesclada à sua conta quando você opta por se cadastrar. Você pode solicitar a exportação ou exclusão dos seus dados a qualquer momento.', + }, + hero: { + brand: 'IdearRun', + titleA: 'Entre na história.', + titleB: 'Reescreva o final.', + body: 'Junte-se à nova geração do drama interativo. Suas escolhas moldam a narrativa em tempo real.', + }, + convert: { + title: 'Salve seu progresso', + body: 'Cadastre-se para continuar assistindo {name} e desbloquear todos os episódios.', + }, + logout: 'Sair', + guest: 'Continuar como convidado', +} diff --git a/packages/i18n/src/messages/pt/common.js b/packages/i18n/src/messages/pt/common.js new file mode 100644 index 0000000..1561ba0 --- /dev/null +++ b/packages/i18n/src/messages/pt/common.js @@ -0,0 +1,34 @@ +/** common.* —— 跨页面通用文案(按钮 / 状态 / 导航 / 主题 / 语言)。pt 与 en 同构。 */ +export default { + appName: 'IdearRun', + ok: 'OK', + cancel: 'Cancelar', + confirm: 'Confirmar', + back: 'Voltar', + next: 'Próximo', + retry: 'Tentar de novo', + loading: 'Carregando…', + save: 'Salvar', + close: 'Fechar', + error: { + generic: 'Algo deu errado. Tente novamente.', + network: 'Erro de rede. Verifique sua conexão.', + }, + nav: { + explore: 'Explorar', + library: 'Biblioteca', + pricing: 'Preços', + login: 'Entrar', + getStarted: 'Começar', + profile: 'Perfil', + logout: 'Sair', + }, + theme: { + toLight: 'Mudar para tema claro', + toDark: 'Mudar para tema escuro', + }, + language: { + label: 'Idioma', + options: { en: 'Inglês', zh: 'Chinês', ja: 'Japonês', ko: 'Coreano', es: 'Espanhol', pt: 'Português' }, + }, +} diff --git a/packages/i18n/src/messages/pt/drama.js b/packages/i18n/src/messages/pt/drama.js new file mode 100644 index 0000000..f7a5707 --- /dev/null +++ b/packages/i18n/src/messages/pt/drama.js @@ -0,0 +1,60 @@ +/** drama.* —— 剧详情页(封面 / 简介 / 剧集列表 / 演员 / 相关推荐 / 开播 CTA)。 */ +export default { + loading: 'Carregando drama…', + error: 'Não foi possível carregar este drama.', + retry: 'Tentar novamente', + notFound: 'Este drama não está mais disponível.', + breadcrumb: { + library: 'Biblioteca', + }, + badge: { + interactive: 'Interativo', + }, + meta: { + episodes: '{count} episódios', + perEpisode: '~{minutes} min cada', + rating: '{score}', + plays: '{count} reproduções', + }, + hero: { + start: 'Começar a assistir', + resume: 'Retomar', + favorite: 'Favoritar', + favorited: 'Nos favoritos', + share: 'Compartilhar', + shareCopied: 'Link copiado para a área de transferência', + favoriteAdded: 'Adicionado aos seus favoritos', + favoriteRemoved: 'Removido dos seus favoritos', + }, + episodes: { + title: 'Episódios', + season: 'Temporada {number}', + label: 'Episódio {number}', + viewAll: 'Ver todos os {count} episódios', + collapse: 'Mostrar menos episódios', + empty: 'Os episódios deste drama estão sendo preparados.', + locked: 'Desbloquear para assistir', + }, + interactivity: { + title: 'Reescreva a história', + body: 'Este drama é totalmente interativo. Não gostou da decisão de um personagem? Use a IA para gerar uma nova cena na hora.', + cta: 'Experimentar o modo interativo', + generating: 'Gerando uma nova cena…', + }, + cast: { + title: 'Elenco principal', + empty: 'Detalhes do elenco em breve.', + }, + details: { + title: 'Detalhes', + director: 'Diretor', + release: 'Lançamento', + audio: 'Áudio', + format: 'Formato', + language: 'Idioma', + }, + related: { + title: 'Você também pode gostar', + empty: 'Ainda não há recomendações.', + }, +} diff --git a/packages/i18n/src/messages/pt/feed.js b/packages/i18n/src/messages/pt/feed.js new file mode 100644 index 0000000..e50e4ac --- /dev/null +++ b/packages/i18n/src/messages/pt/feed.js @@ -0,0 +1,30 @@ +/** feed.* —— 刷剧信息流。pt 与 en 同构。 */ +export default { + cta: 'Comece a assistir grátis', + empty: 'Ainda não há dramas aqui. Volte em breve.', + emptyHint: 'Novos dramas de IA estreiam todos os dias — volte daqui a pouco.', + loading: 'Carregando dramas…', + loadMore: 'Carregar mais', + trending: 'Em alta agora', + likeCount: 'sem curtidas | {count} curtida | {count} curtidas', + + episodeOf: 'Ep {current} de {total}', + byCreator: 'por {handle}', + rewrite: 'Reescrever cena', + rewriteAria: 'Reescreva esta cena do seu jeito', + share: 'Compartilhar', + shareAria: 'Compartilhar este drama', + shareCopied: 'Link copiado para a área de transferência', + shareFailed: 'Não foi possível compartilhar agora. Tente de novo.', + liked: 'Adicionado aos favoritos', + unliked: 'Removido dos favoritos', + likeAria: 'Curtir este drama', + prevAria: 'Drama anterior', + nextAria: 'Próximo drama', + openDrama: 'Abrir {title}', + + upNext: 'A seguir', + viewAll: 'Ver tudo', + defaultCreator: 'Estúdio IdearRun', + defaultSynopsis: 'Um drama tecido por IA que você pode pausar e reescrever a qualquer momento.', +} diff --git a/packages/i18n/src/messages/pt/index.js b/packages/i18n/src/messages/pt/index.js new file mode 100644 index 0000000..fa8c3a6 --- /dev/null +++ b/packages/i18n/src/messages/pt/index.js @@ -0,0 +1,17 @@ +/** pt message 聚合 —— 分命名空间文件聚合,与 en 同构 key 树。内部聚合,不对外深导入。 */ +import common from './common.js' +import auth from './auth.js' +import feed from './feed.js' +import player from './player.js' +import pay from './pay.js' +import drama from './drama.js' +import library from './library.js' +import onboarding from './onboarding.js' +import landing from './landing.js' +import account from './account.js' +import visualGame from './visual-game.js' +import legal from './legal.js' +import visualGameOverrides from './visual-game-overrides.generated.js' +import { mergeMessages } from '../merge-messages.js' + +export default { common, auth, feed, player, pay, drama, library, onboarding, landing, account, visualGame: mergeMessages(visualGame, visualGameOverrides), legal } diff --git a/packages/i18n/src/messages/pt/landing.js b/packages/i18n/src/messages/pt/landing.js new file mode 100644 index 0000000..5cea031 --- /dev/null +++ b/packages/i18n/src/messages/pt/landing.js @@ -0,0 +1,63 @@ +/** landing.* —— 营销落地页。pt 与 en 同构。 */ +export default { + openDrama: 'Abrir {title}', + + hero: { + badge: 'IdearRun Player v2.0 no ar', + titleLead: 'Assista a dramas.', + titleAccent: 'Reescreva a história.', + subtitle: + 'A primeira plataforma de dramas 2D interativos com IA. Interrompa qualquer cena, reescreva o diálogo e veja a IA ramificar a narrativa em tempo real.', + ctaPrimary: 'Comece a assistir grátis', + ctaSecondary: 'Como funciona', + rewriteActive: 'Reescrita por IA ativa', + rewriteSample: '"Não, não vou entregar os documentos. Você terá que tomá-los."', + }, + + bento: { + heading: 'Liberte a inteligência cinematográfica', + rewriteTitle: 'Interrompa e reescreva com IA', + rewriteDesc: + 'Pause qualquer cena, digite uma nova direção e o motor esquelético regenera a atuação instantaneamente.', + rewriteBubble: '"Acho que devíamos reconsiderar o acordo."', + rewriteGenerating: 'Gerando um caminho alternativo…', + animTitle: 'Animação de marionetes 2D cinematográfica', + animDesc: 'Rigging dinâmico encontra iluminação atmosférica.', + voicesTitle: 'Vozes com IA', + branchTitle: 'Enredos infinitos', + trending: 'Dramas em alta', + }, + + how: { + browse: { + title: '1. Explore', + desc: 'Descubra uma biblioteca de histórias cinematográficas 2D em andamento de vários gêneros.', + }, + interrupt: { + title: '2. Interrompa', + desc: 'Toque em reescrever a qualquer momento. Mude o diálogo, o clima ou as ações dos personagens.', + }, + rewrite: { + title: '3. A IA reescreve', + desc: 'Veja a história evoluir instantaneamente enquanto o motor gera novos caminhos ramificados.', + }, + }, + + featured: { + heading: 'Dramas em destaque', + browseAll: 'Ver todos', + rewrites: '{count} reescritas', + rewritesUnknown: 'Lançamento', + genrePlaceholder: 'Drama interativo com IA', + }, + + footer: { + copyright: '© 2024 IdearRun AI. Todos os direitos reservados. Inteligência cinematográfica.', + terms: 'Termos', + privacy: 'Privacidade', + discord: 'Discord', + twitter: 'Twitter', + docs: 'Documentação', + language: 'Idioma:', + }, +} diff --git a/packages/i18n/src/messages/pt/legal.js b/packages/i18n/src/messages/pt/legal.js new file mode 100644 index 0000000..1310412 --- /dev/null +++ b/packages/i18n/src/messages/pt/legal.js @@ -0,0 +1,16 @@ +export default { + privacy: { title: 'Política de Privacidade', updated: 'Última atualização: 18 de agosto de 2026', intro: 'voko.world é uma experiência de drama generativo interativo. Esta política explica quais dados coletamos, como os usamos e quais escolhas você pode fazer.', sections: { + information: { title: 'Informações que coletamos', body: 'Coletamos informações da conta, como endereço de e-mail, nome de perfil, dados de login, progresso no jogo, escolhas da história, registros de vídeos gerados e dados básicos do dispositivo ou de logs necessários para manter o serviço seguro e confiável.' }, + usage: { title: 'Como usamos as informações', body: 'Usamos as informações para oferecer o jogo, salvar seu progresso, gerar e exibir vídeos de histórias, enviar notificações sobre o produto ou o status dos vídeos, melhorar a segurança e a qualidade, prevenir abusos e responder a solicitações de suporte.' }, + generated: { title: 'Conteúdo gerado', body: 'Seus comandos, escolhas, registros de histórias e vídeos gerados podem ser processados por nossos sistemas e prestadores de serviço para criar, moderar, armazenar e entregar a experiência.' }, + voice: { title: 'Entrada de voz', body: 'Ao usar a entrada de voz, a gravação do microfone é enviada ao serviço de conversão de voz em texto ByteDance Doubao para produzir uma transcrição. voko.world não armazena a gravação após a conclusão. Você pode editar a transcrição antes de enviá-la como uma ação do jogo.' }, + sharing: { title: 'Compartilhamento', body: 'Não vendemos informações pessoais. Compartilhamos apenas as informações necessárias com prestadores de serviço e infraestrutura, ferramentas de análise ou segurança, autoridades quando exigido por lei, ou quando você decide publicar ou compartilhar conteúdo.' }, + retention: { title: 'Retenção', body: 'Mantemos as informações pelo tempo necessário para fornecer o voko.world, cumprir obrigações legais, resolver disputas e proteger o serviço. Você pode entrar em contato para solicitar a exclusão da conta ou dos dados.' }, + choices: { title: 'Suas escolhas', body: 'Você pode gerenciar sua conta, escolher se deseja publicar vídeos gerados, cancelar comunicações opcionais e entrar em contato para solicitar acesso, correção ou exclusão.' }, + contact: { title: 'Contato', body: "Para dúvidas sobre privacidade, envie um e-mail para service{'@'}voko.world." }, + } }, + terms: { title: 'Termos de Serviço', updated: 'Última atualização: 13 de agosto de 2026', intro: 'Estes termos regem seu acesso e uso do voko.world. Ao usar o serviço, você concorda com estes termos.', sections: { + usage: { title: 'Uso do voko.world', body: 'Você pode usar o voko.world para fins legais de entretenimento pessoal e criação. Você é responsável por sua conta, suas escolhas e qualquer conteúdo que enviar ou publicar.' }, accounts: { title: 'Contas e acesso', body: 'Você deve fornecer informações corretas da conta e manter suas credenciais seguras. Podemos suspender o acesso se detectarmos abuso, riscos de segurança ou violações destes termos.' }, generated: { title: 'Histórias e vídeos gerados', body: 'voko.world pode gerar texto, imagens, áudio ou vídeo com base em suas interações. Os resultados podem variar e ser imperfeitos. Não confie no conteúdo gerado como orientação factual, jurídica, médica, financeira ou profissional.' }, rules: { title: 'Regras de conteúdo', body: 'Não envie, solicite, publique ou distribua conteúdo ilegal, abusivo, infrator, enganoso, sexualmente explorador ou prejudicial a outras pessoas.' }, property: { title: 'Propriedade intelectual', body: 'voko.world, seu software, design, marca e conteúdo do serviço pertencem a nós ou aos nossos licenciadores. Você continua responsável pelo conteúdo que fornece e nos concede os direitos necessários para operar, exibir, melhorar e proteger o serviço.' }, availability: { title: 'Disponibilidade', body: 'Podemos alterar, pausar ou encerrar partes do serviço. Não somos responsáveis por interrupções, perda de dados causada por eventos fora do nosso controle ou falhas de serviços de terceiros.' }, contact: { title: 'Contato', body: "Dúvidas sobre estes termos podem ser enviadas para contact{'@'}idearrun.com." }, + } }, + contact: { title: 'Fale conosco', updated: 'Suporte e comunidade voko.world', intro: 'Tem uma pergunta, ideia de parceria, relato de erro ou preocupação com conteúdo? Gostaríamos de ouvir você.', sections: { email: { title: 'E-mail', body: "service{'@'}voko.world" }, community: { title: 'Comunidade', body: 'Entre no Discord: https://discord.gg/WeESGyuUSX' }, social: { title: 'Redes sociais', body: 'Siga o voko.world no X, YouTube, Instagram, TikTok e Facebook pelos links do rodapé.' }, support: { title: 'Orientações para suporte', body: 'Para problemas de conta ou vídeo, inclua o e-mail da conta, a URL da página e uma breve descrição do ocorrido. Não envie senhas nem credenciais de pagamento.' } } }, +} diff --git a/packages/i18n/src/messages/pt/library.js b/packages/i18n/src/messages/pt/library.js new file mode 100644 index 0000000..bc54b7a --- /dev/null +++ b/packages/i18n/src/messages/pt/library.js @@ -0,0 +1,36 @@ +/** library.* —— 剧本库。pt 与 en 同构。 */ +export default { + title: 'Biblioteca em alta', + searchPlaceholder: 'Buscar dramas, criadores, gêneros...', + searchAria: 'Buscar na biblioteca', + genresAria: 'Filtrar por gênero', + genres: { + all: 'Todos', + sciFi: 'Ficção científica', + romance: 'Romance', + thriller: 'Suspense', + comedy: 'Comédia', + drama: 'Drama', + fantasy: 'Fantasia', + }, + sortLabel: 'Ordenar: {value}', + sort: { + trending: 'Em alta', + newest: 'Mais recentes', + mostViewed: 'Mais vistos', + }, + loading: 'Carregando dramas...', + error: 'Não foi possível carregar a biblioteca.', + retry: 'Tentar novamente', + empty: 'Nenhum drama corresponde à sua busca.', + viewsCount: 'sem visualizações | {count} visualização | {count} visualizações', + aiBadge: 'IA', + locked: 'Drama premium', + openAria: 'Abrir {title}', + moreAria: 'Mais opções para {title}', + brand: 'IdearRun', + footerTerms: 'Termos', + footerPrivacy: 'Privacidade', + footerSupport: 'Suporte', + copyright: '© {year} IdearRun AI. Todos os direitos reservados.', +} diff --git a/packages/i18n/src/messages/pt/onboarding.js b/packages/i18n/src/messages/pt/onboarding.js new file mode 100644 index 0000000..ebd7503 --- /dev/null +++ b/packages/i18n/src/messages/pt/onboarding.js @@ -0,0 +1,60 @@ +/** onboarding.* —— 选型问卷(题材多选 / 进度 / 跳过)。pt 与 en 同构。 */ +export default { + brand: 'IdearRun', + stepOf: 'Etapa {current} de {total}', + progressLabel: 'Progresso da introdução', + continue: 'Continuar', + finish: 'Concluir', + back: 'Voltar', + skip: 'Pular por enquanto', + timeHint: 'Leva menos de 30 segundos', + lineupTitle: 'Montando sua seleção personalizada...', + step: { + genres: { + title: 'Quais gêneros você adora?', + subtitle: 'Escolha alguns para ajustar seu motor de teatro com IA.', + }, + pacing: { + title: 'Qual deve ser o ritmo?', + subtitle: 'Ajustamos o ritmo da história ao seu gosto.', + }, + length: { + title: 'Quanto tempo você gosta de assistir?', + subtitle: 'Adaptamos cada sessão à sua agenda.', + }, + tone: { + title: 'Qual é o seu clima?', + subtitle: 'Ajustamos a narração para definir o tom.', + }, + }, + option: { + genres: { + romance: 'Romance', + thriller: 'Suspense', + scifi: 'Ficção científica', + comedy: 'Comédia', + mystery: 'Mistério', + drama: 'Drama', + fantasy: 'Fantasia', + horror: 'Terror', + }, + pacing: { + slowburn: 'Lento', + steady: 'Constante', + fast: 'Acelerado', + twisty: 'Cheio de reviravoltas', + }, + length: { + short: 'Curtos', + medium: 'Médio', + long: 'Imersivos', + binge: 'Para maratonar', + }, + tone: { + witty: 'Espirituoso', + intense: 'Intenso', + cozy: 'Aconchegante', + epic: 'Épico', + }, + }, +} diff --git a/packages/i18n/src/messages/pt/pay.js b/packages/i18n/src/messages/pt/pay.js new file mode 100644 index 0000000..a1da0b6 --- /dev/null +++ b/packages/i18n/src/messages/pt/pay.js @@ -0,0 +1,106 @@ +/** pay.* —— 解锁 / 订阅 / 支付。pt 与 en 同构。 */ +export default { + unlock: 'Desbloquear este episódio', + subscribe: 'Assinar', + price: '{amount} / mês', + coins: 'sem moedas | {count} moeda | {count} moedas', + restore: 'Restaurar compras', + success: 'Pagamento concluído', + failed: 'Pagamento falhou. Você não foi cobrado.', + + checkout: { + back: 'Voltar aos planos', + brand: 'IdearRun', + subscribeTo: 'Assinar', + perYear: '/ano', + perMonth: '/mês', + lineItem: '{plan} — {amount}', + subtotal: 'Subtotal', + tax: 'Impostos', + total: 'Total', + renewNotice: 'Renova por {amount} em {date}', + secure: 'Pagamento seguro', + + paymentDetails: 'Detalhes do pagamento', + expressApple: 'Apple Pay', + expressGoogle: 'Google Pay', + expressLink: 'Link', + orPayWithCard: 'ou pague com cartão', + + emailLabel: 'E-mail', + emailPlaceholder: 'voce{at}exemplo.com', + emailInvalid: 'Insira um endereço de e-mail válido.', + + cardLabel: 'Informações do cartão', + cardSecuredHeading: 'A entrada do cartão é tratada com segurança pela Stripe', + cardSecuredBody: 'Nunca vemos nem armazenamos o número do seu cartão. Você concluirá o pagamento no formulário seguro da Stripe.', + cardElementLoading: 'Carregando campo de cartão seguro…', + + nameLabel: 'Nome do titular', + namePlaceholder: 'Nome completo no cartão', + + billingLabel: 'Endereço de cobrança', + countryUS: 'Estados Unidos', + countryCA: 'Canadá', + countryGB: 'Reino Unido', + countryAU: 'Austrália', + zipPlaceholder: 'CEP / Código postal', + + pay: 'Pagar {amount}', + paying: 'Redirecionando para o pagamento seguro…', + poweredBy: 'Desenvolvido por {provider}', + + cancelled: 'Pagamento cancelado. Você não foi cobrado.', + error: 'Não foi possível iniciar o pagamento. Tente novamente.', + }, + + paywall: { + title: 'Desbloqueie esta história', + body: 'Este caminho exige créditos. Desbloqueie para revelar um novo ramo narrativo e interagir com a cena por meio de IA.', + unlock: 'Desbloquear com {count} créditos', + goVip: 'Seja VIP para desbloqueios ilimitados', + balancePre: 'Você tem', + balancePost: 'créditos', + insufficient: 'Créditos insuficientes — recarregue ou seja VIP.', + later: 'Talvez depois', + }, + + plans: { + title: 'Recarregue ou seja premium', + subtitle: 'Escolha um pacote de créditos para desbloqueios ocasionais ou assine o VIP para acesso total a todas as histórias ramificadas e recursos de geração com IA.', + confirm: 'Confirmar seleção', + confirmHint: 'Continuar para o checkout seguro.', + selectHint: 'Selecione um pacote de créditos ou plano VIP para continuar.', + }, + + packs: { + credits: '{count} créditos', + standard: 'Pacote padrão', + popular: 'Popular', + bonus: 'Inclui {count} créditos bônus', + bestValue: 'Melhor valor - Economize {pct}%', + }, + + points: { + paid: 'Pontos pagos: {points}', bonus: 'Pontos bônus: {points}', total: 'Total de pontos: {points}', buy: 'Comprar pontos', signInToBuy: 'Entre para comprar', insufficient: 'Seu saldo de pontos é insuficiente.', shortfall: 'Faltam {points} pontos.', orderCanceled: 'Cancelado', orderFailed: 'Falha no pagamento', + }, + vip: { + title: 'IdearRun VIP', + subtitle: 'Viva todo o potencial do teatro com IA.', + billingCycle: 'Ciclo de cobrança', + monthly: 'Mensal', + yearly: 'Anual', + save: 'Economize {pct}%', + perMonth: '/ mês', + billedYearly: 'Cobrado anualmente em {amount}', + billedMonthly: 'Cobrado mensalmente, cancele quando quiser', + cta: 'Iniciar VIP', + selected: 'VIP selecionado', + perks: { + unlimited: 'Desbloqueios de histórias ilimitados', + endings: 'Acesso a finais exclusivos', + early: 'Acesso antecipado a novos dramas', + adfree: 'Experiência de navegação sem anúncios', + }, + }, +} diff --git a/packages/i18n/src/messages/pt/player.js b/packages/i18n/src/messages/pt/player.js new file mode 100644 index 0000000..00a6102 --- /dev/null +++ b/packages/i18n/src/messages/pt/player.js @@ -0,0 +1,51 @@ +/** player.* —— Reprodução e reescrita interativa. pt 与 en 同构。 */ +export default { + play: 'Reproduzir', + pause: 'Pausar', + captions: 'Legendas', + volume: 'Volume', + mute: 'Silenciar', + unmute: 'Ativar som', + fullscreen: 'Tela cheia', + exitFullscreen: 'Sair da tela cheia', + settings: { + title: 'Configurações de reprodução', + captionLanguage: 'Idioma das legendas', + }, + episode: 'Episódio {n}', + nextEpisode: 'Próximo episódio', + replay: 'Reproduzir de novo', + progress: 'Progresso de reprodução', + voiceLoading: 'Gerando voz', + generating: 'Escrevendo a próxima cena…', + ended: 'Você chegou ao fim. Quer mais?', + subtitleEmpty: 'A cena está se desenrolando…', + like: 'Curtir', + tip: 'Gorjeta', + share: 'Compartilhar', + report: 'Denunciar', + shareCopied: 'Link copiado para a área de transferência', + reported: 'Obrigado — recebemos sua denúncia', + rewrite: { + title: 'Reescrever esta cena', + engineActive: 'Motor de IA ativo', + hint: 'Interrompa o fluxo. Mude o diálogo, o clima ou as ações.', + placeholder: "Diga à IA como mudar esta cena… ex. 'Faça a Mira soar mais desesperada e sacar uma arma.'", + generate: 'Gerar cena alternativa', + renderingHint: 'Renderizando o esqueleto {\'@\'} a voz de IA', + done: 'Nova cena gerada', + failed: 'Não foi possível gerar a cena. Tente novamente.', + }, + cast: { + title: 'Elenco atual', + add: 'Adicionar', + addPrompt: 'Adicione um novo personagem a esta cena: ', + viewAll: 'Ver tudo', + viewLess: 'Ver menos', + }, + upNext: { + title: "A seguir em '{drama}'", + empty: 'Ainda não há mais episódios — interrompa para continuar a história.', + untitled: 'Episódio sem título', + }, +} diff --git a/packages/i18n/src/messages/pt/visual-game-overrides.generated.js b/packages/i18n/src/messages/pt/visual-game-overrides.generated.js new file mode 100644 index 0000000..83cfbe0 --- /dev/null +++ b/packages/i18n/src/messages/pt/visual-game-overrides.generated.js @@ -0,0 +1,43 @@ +/** Generated reviewed Visual Game translations. */ +export default { + "account": { + "email": "Email", + "mergeSeparator": ", ", + "mergeRuns": "{count} partidas" + }, + "billing": { + "orderStatus": "Estado {status}" + }, + "saves": { + "slot": "Espaço {slotNo}" + }, + "videoCenter": { + "minutes": "{count} min" + }, + "visual": { + "hiddenTitle": "Tianyou", + "editor": "Editor", + "npcLine": "\"{line}\"", + "endingFallbackPoem": "..." + }, + "panels": { + "npcs": "NPCs" + }, + "editor": { + "item": "Item", + "uploadTag": "Etiqueta" + }, + "landing": { + "dramaTitle": "Drama" + }, + "landingCinema": { + "navPlayerStories": "Dramas", + "playerStories": "Dramas" + }, + "storyTree": { + "filter": "Estado" + }, + "landingCard": { + "scriptFilter": "Drama" + } +} diff --git a/packages/i18n/src/messages/pt/visual-game.js b/packages/i18n/src/messages/pt/visual-game.js new file mode 100644 index 0000000..5501833 --- /dev/null +++ b/packages/i18n/src/messages/pt/visual-game.js @@ -0,0 +1,595 @@ +export default { + appName: 'voko.world', + productBrandSub: 'O seu kosmo está chamando.', + mobileCredit: { title: 'Meus créditos', buyCredits: 'Comprar créditos', history: 'Histórico de créditos', purchaseHistory: 'Compras', usageHistory: 'Uso', emptyHistory: 'Ainda não há histórico.', loadMore: 'Carregar mais', balanceAfter: 'Saldo: {points}', pending: 'Pendente', paid: 'Pago', canceled: 'Cancelado', failed: 'Falhou', refunded: 'Reembolsado', disputed: 'Em disputa', autoReload: 'Recarga automática', bonusCredits: 'Créditos bônus', buyNow: 'Comprar agora', lowBalance: 'Saldo baixo', enabled: 'Ativada', whenBelow: 'Quando meu saldo ficar abaixo de', choosePack: 'Escolha um pacote', consent: 'Autorizo a Voko a comprar automaticamente este pacote quando meu saldo atingir o limite.', terms: 'Termos de Uso', continueSecurely: 'Continuar com segurança', save: 'Salvar configurações', disable: 'Desativar recarga', setupComplete: 'Forma de pagamento salva e recarga ativada.', setupCanceled: 'Configuração cancelada.', actionRequired: 'A última recarga falhou. Configure novamente a forma de pagamento.', howTitle: 'Como funciona', howBalance: 'Compramos o pacote escolhido quando o saldo atinge o limite.', howSecure: 'A Stripe armazena os dados de pagamento; a Voko não guarda o número do cartão.', howCancel: 'Você pode desativar a qualquer momento. Compras anteriores não são reembolsadas.' }, + nav: { play: 'Jogar', notes: 'Pistas', state: 'Estado', editor: 'Autoria' }, + scenario: { title: 'Cenário', available: 'Cenários', assetManifest: 'Recursos' }, + character: { title: 'Personagem', start: 'Iniciar', current: 'Personagem atual' }, + stage: { empty: 'Escolha cenário e personagem', turn: 'Turno {turn}', ending: 'Final', transcript: 'Registro' }, + composer: { act: 'Agir', say: 'Falar', placeholder: 'Digite uma ação ou fala' }, + actions: { refresh: 'Atualizar', start: 'Iniciar', home: 'Início', backHome: 'Voltar', newRun: 'Reiniciar', send: 'Enviar', save: 'Salvar', compile: 'Compilar', publish: 'Publicar', rollback: 'Reverter', upload: 'Enviar', select: 'Selecionar', endingText: 'Texto final', load: 'Carregar', backToGame: 'Voltar', cancel: 'Cancelar', confirm: 'Confirmar' }, + account: { + signIn: 'Entrar', + signedIn: 'Conectado', + signedOut: 'Desconectado', + sessionExpired: 'Sua sessão expirou. Entre novamente.', + logoutFailed: 'Falha ao sair. Tente novamente.', + registered: 'Registrado', + email: 'Email', + emailPlaceholder: 'Endereço de email', + password: 'Senha', + passwordPlaceholder: 'Digite a senha', + confirmPassword: 'Confirmar senha', + confirmPasswordPlaceholder: 'Digite a senha novamente', + verificationCode: 'Código de verificação', + verificationCodePlaceholder: 'Código de 6 dígitos', + inviteToken: 'Código de convite', + inviteTokenPlaceholder: 'Digite o código de convite de 8 caracteres', + sendCode: 'Enviar código', + resendCode: 'Reenviar', + codeSent: 'Código enviado. Verifique seu email.', + codeRequired: 'Digite primeiro o código de verificação.', + inviteInvalid: 'Digite um código de convite válido de 8 caracteres.', + emailRequired: 'Digite primeiro seu email.', + emailInvalid: 'Digite um email válido.', + passwordRequired: 'Digite primeiro sua senha.', + confirmPasswordRequired: 'Confirme sua senha primeiro.', + loginFieldsRequired: 'Digite seu email e senha.', + inviteEmailMismatch: 'Este email não corresponde ao email da solicitação vinculado ao código de convite.', + passwordMismatch: 'As senhas não coincidem.', + createAccount: 'Criar conta', + hasAccount: 'Já tem conta?', + noAccount: 'Ainda não tem conta?', + termsHint: 'Ao continuar, você confirma que este email é seu e aceita salvar progresso, pedidos e créditos.', + login: 'Entrar', + register: 'Registrar', + reset: 'Redefinir', + forgotPassword: 'Esqueceu a senha?', + resetPassword: 'Redefinir senha', + resetTitle: 'Redefinir senha', + resetBody: 'Verifique o código enviado por email e defina uma nova senha. As sessões antigas serão revogadas.', + resetCodeSent: 'Código de redefinição enviado. Verifique seu email.', + resetFieldsRequired: 'Informe o email, o código, a nova senha e a confirmação da senha.', + passwordResetDone: 'Senha redefinida. Entre com a nova senha.', + logout: 'Sair', + logoutAll: 'Sair de todos os dispositivos', + credits: 'Créditos', + buy: 'Comprar créditos', + billing: 'Cobrança', + promptTitle: 'Entre ou registre-se para começar', + promptBody: 'Sua conta salva progresso, saves, finais, créditos e pedidos para continuar em outro dispositivo.', + signedInBody: 'Seu progresso, saves e créditos são gravados no banco de dados.', + registerAndStart: 'Registrar e escolher papel', + guestStart: 'Testar como convidado', + savedHint: 'Entre para salvar progresso e saves automaticamente', + currentAccount: 'Conta atual', + saveKicker: 'Salvar conta', + saveTitle: 'Salvar progresso e pedidos', + loginTitle: 'Entrar', + loginBody: 'Após entrar, o progresso convidado deste dispositivo será mesclado quando possível.', + registerTitle: 'Criar conta', + registerBody: 'Cadastre-se com um código de convite e confirme seu email. O registro entra automaticamente, concede 100 créditos e mescla o progresso do dispositivo.', + openBilling: 'Ver créditos e pedidos', + mergePending: 'Mesclando progresso convidado deste dispositivo...', + mergeSuccess: 'Dados locais mesclados: {details}.', + mergeSeparator: ', ', + mergeRuns: '{count} runs', + mergeTurns: '{count} turnos', + mergeProgress: '{count} registros de progresso', + mergeSaveSlots: '{count} saves', + mergeEndingRecords: '{count} finais', + mergeEvents: '{count} eventos', + mergeAgentCalls: '{count} chamadas de IA', + mergeBillingLedger: '{count} lançamentos de saldo', + mergeBillingUnlocks: '{count} desbloqueios pagos', + mergeBillingFreeQuota: '{count} cotas grátis', + mergePaymentOrders: '{count} pedidos', + mergeEmpty: 'Não há progresso local para mesclar.', + mergeFailed: 'Você entrou, mas o progresso local não foi mesclado. Atualize e tente mais tarde.', + }, + billing: { + title: 'Créditos e pedidos', + subtitle: 'Saldo, recargas, lançamentos e pedidos vêm do banco de dados.', + guestSubtitle: 'Convidados podem ver pacotes. Compra, saldo, pedidos e lançamentos exigem login.', + balance: 'Saldo atual', + products: 'Pacotes de créditos', + ledger: 'Lançamentos', + orders: 'Pedidos', + refresh: 'Atualizar cobrança', + testModeTitle: 'Ambiente de teste do Stripe', + testModeBody: 'O checkout usa pagamentos de teste e não gera cobranças reais.', + testPurchase: 'Compra de teste', + buy: 'Comprar {points} créditos', + checkoutReady: 'Link de checkout pronto.', + openCheckout: 'Abrir checkout', + selectedPack: 'Selecionado', + selectedProduct: '{title} selecionado. Seu saldo aumentará em {points} créditos.', + signInToBuy: 'Entrar para comprar', + readyToBuy: 'Pronto para comprar créditos', + readyToBuyBody: 'Escolha um pacote e siga para o checkout. Pedidos podem aparecer em processamento antes da confirmação.', + checkoutPending: 'Redirecionando para checkout...', + checkoutReturnedSuccess: 'Você voltou ao app. Atualizando saldo e pedidos; o crédito depende do status do pedido.', + checkoutReturnedCancel: 'Checkout cancelado ou não concluído. Nenhum crédito confirmado.', + signInHint: 'Entre para ver saldo, pedidos e lançamentos.', + emptyProducts: 'Nenhum pacote de créditos.', + emptyLedger: 'Nenhum lançamento.', + emptyOrders: 'Nenhum pedido.', + productFallback: 'Pacote de créditos', + points: '{points} créditos', + price: '{amount} {currency}', + ledgerAmount: '{amount} créditos', + orderStatus: 'Status {status}', + orderStatusPending: 'Processando', + orderStatusPaid: 'Pago', + orderStatusRefunded: 'Reembolsado', + orderStatusDisputed: 'Em disputa', + orderStatusUnknown: 'Status desconhecido', + ledgerReasonSignupGift: 'Bônus de registro', + ledgerReasonCheckout: 'Compra de créditos', + ledgerReasonRefund: 'Reembolso de pedido', + ledgerReasonChargeback: 'Reversão de disputa', + ledgerReasonUnlock: 'Desbloqueio de conteúdo', + paywallTitle: 'Desbloqueio pago', + paywallTarget: 'Alvo', + paywallReason: 'Motivo da cobrança', + paywallGenericReason: 'Créditos são necessários para continuar este conteúdo.', + paywallFreeContinue: 'Ainda há cota grátis. Você pode continuar.', + paywallInsufficient: 'Créditos insuficientes. Compre créditos primeiro.', + paywallPrice: 'Créditos necessários', + paywallFreeQuota: 'Cota grátis restante', + paywallBalance: 'Saldo disponível', + }, + endings: { + title: 'Coleção de finais', + unlocked: 'Desbloqueado', + locked: 'Bloqueado', + collected: 'Coletados {count}/{total}', + lockedHint: 'Alcance este final para desbloqueá-lo.', + }, + saves: { + title: 'Salvamentos', + progress: 'Progresso atual', + activeRun: 'Jogo atual', + slot: 'Slot {slotNo}', + savedRun: 'Progresso salvo', + emptySlot: 'Slot vazio', + emptyHint: 'Salve a run atual para restaurar após recarregar.', + saveHere: 'Salvar', + overwrite: 'Sobrescrever', + restore: 'Carregar', + delete: 'Excluir', + saved: 'Salvamento gravado.', + restored: 'Salvamento carregado.', + deleted: 'Salvamento excluído.', + conflict: 'Já existe um salvamento. Confirme antes de sobrescrever este ponto.', + restoreConfirm: 'Carregar mudará o jogo atual para este ponto salvo.', + deleteConfirm: 'Excluir este ponto salvo não pode ser desfeito.', + noRunHint: 'Não há run atual para salvar. Inicie ou continue uma run primeiro.', + }, + map: { + title: 'Mapa', + current: 'Local atual', + route: 'Rota disponível', + routes: 'Destinos disponíveis', + locked: 'Bloqueado', + moveTo: 'Ir para {location}', + objects: 'Objetos investigáveis', + inspect: 'Investigar', + inspected: 'Investigado', + inspectObject: 'Investigar {object}', + }, + videoCenter: { + eyebrow: 'Partidas dos jogadores', + title: 'Central de vídeos', + backHome: 'Voltar', + chooseScenario: 'Escolher cenário', + allScenarios: 'Todos os cenários', + searchScenario: 'Buscar cenários', + scenarioPaintedSkin: 'Pele pintada', + scenarioGuian: 'O caso do coração perdido de Guian', + nowPlaying: 'Reproduzindo', + player: 'Jogador', + views: 'Visualizações', + ending: 'Final', + duration: 'Duração', + libraryEyebrow: 'Mais deste cenário', + allVideos: 'Todos os vídeos', + sort: 'Ordem dos vídeos', + recommended: 'Recomendados', + latest: 'Mais recentes', + videoMeta: '{player} · {date}', + emptyTitle: 'Ainda não há vídeos', + emptyText: 'Não há partidas públicas para este cenário.', + viewAll: 'Ver todos os vídeos de jogadores', + discoverStories: 'Veja as escolhas de outros', + backPrevious: 'Voltar', aiSummary: 'Resumo IA:', endingCount: ' finais', publicSeriesCount: ' séries públicas', totalPlays: ' reproduções', seriesList: 'Séries de jogadores', completed: 'Concluída', updating: 'Episódio {count} · Em andamento', seriesMetrics: '{episodes} episódios · ▶ {plays} · ♥ {likes}', watchSeries: 'Assistir esta série', playOwnSeries: 'Crie sua própria série', minutes: '{count} min', share: 'Compartilhar', shareCopied: 'Link copiado', shareFailed: 'Não foi possível compartilhar o vídeo', like: 'Curtir', liked: 'Curtido', save: 'Salvar', saved: 'Salvo', storyHighlight: 'Destaques da história', startPlay: 'Começar a jogar · Reescrever o final', episodeList: 'Episódios', episodeTotal: '{count} episódios', episodeTitle: 'Episódio {count} · {title}', + }, + runHistory: { + eyebrow: 'Registros pessoais', + title: 'Minhas partidas', + allRuns: 'Todas as partidas', + count: '{count} registros', + empty: 'Ainda não há registros. Comece uma partida e ela aparecerá aqui.', + loadFailed: 'Falha ao carregar: {error}', + noTime: 'Hora desconhecida', + statusActive: 'Em andamento', + statusCompleted: 'Encerrada', + videoNone: 'Sem vídeo', + videoQueued: 'Vídeo na fila', + videoRunning: 'Gerando vídeo', + videoReady: 'Vídeo pronto', + videoFailed: 'Vídeo falhou', + videoRetry: 'Gerar novamente', + untitledEnding: 'Sem final ainda', + meta: '{scenario} · Turno {turn} · {date}', + openRun: 'Abrir partida', + openNotes: 'Notas', + watchVideo: 'Assistir vídeo', + }, + mobileProfile: { title: 'MEU PERFIL', settings: 'Configurações', content: 'Conteúdo', benefits: 'Benefícios', creator: 'Criador', support: 'Dúvidas e suporte', contact: 'Suporte ao cliente', terms: 'Termos de Uso', privacy: 'Política de Privacidade', social: 'Redes sociais' }, + profileEditor: { editProfile: 'Editar perfil', nameSection: 'Nome do perfil', nameHint: 'Este nome aparece ao lado dos seus jogos e vídeos.', name: 'Nome', saveName: 'Salvar nome', nameRequired: 'Digite seu nome.', nameSaved: 'Seu nome foi atualizado.', passwordSection: 'Alterar senha', passwordHint: 'Confirme sua identidade com o código enviado ao seu e-mail.', email: 'E-mail', verificationCode: 'Código de verificação', sendCode: 'Enviar código', codeSent: 'Um código foi enviado ao seu e-mail.', newPassword: 'Nova senha', confirmPassword: 'Confirmar nova senha', savePassword: 'Atualizar senha', passwordRequirements: 'Digite o código e uma senha com pelo menos 8 caracteres.', passwordMismatch: 'As senhas não coincidem.', passwordSaved: 'Sua senha foi atualizada.' }, + myGames: { recent: 'Jogados recentemente', popular: 'Histórias populares', emptyRecent: 'As histórias jogadas recentemente aparecerão aqui.', loadError: 'Não foi possível carregar seus jogos. Tente novamente.' }, + storyDetail: { title: 'Informações da história', description: 'Descrição', episodes: '{count} episódios', endings: '{count} finais', by: 'Por {name}', lastUpdate: 'Última atualização', newPlay: 'Novo jogo', notFound: 'Esta história não foi encontrada.', loadError: 'Não foi possível carregar a história.' }, + accountCenter: { loadFailed: 'Não foi possível carregar o perfil', loadTimeout: 'A solicitação expirou. Verifique a conexão e tente novamente.', title: 'Central pessoal', eyebrow: 'Perfil do jogador', runs: 'Meus jogos', videos: 'Coleção de vídeos', favorites: 'Favoritos', watchSeason: 'Ver temporada', emptyGames: 'Você ainda não iniciou nenhum jogo.', emptyVideos: 'Ainda não há vídeos gerados.', emptyFavorites: 'Ainda não há vídeos favoritos.', lastPlayed: 'Última partida · {date}', currentTurn: 'Em andamento · Turno {turn}', noActiveRun: 'Nenhuma partida ativa', endingsUnlocked: 'Finais {unlocked}/{total}', runsPlayed: '{count} partidas', continueGame: 'Continuar jogo', startGame: 'Iniciar jogo', viewHistory: 'Ver histórico', levelProgress: '{played}/{total} fases jogadas', viewSeasonDetails: 'Ver detalhes da temporada', hideSeasonDetails: 'Ocultar detalhes', levelInProgress: 'Em andamento', levelCompleted: 'Concluído', endingLabel: 'Final alcançado', cluesLabel: 'Pistas obtidas', clueCount: '{count} pistas', viewRunRecords: 'Ver registros do jogo', runRecordSummary: '{clues} pistas · {ending} final', noEnding: 'Nenhum final alcançado ainda', noClues: 'Nenhuma pista encontrada ainda', seasonDetailsLoadFailed: 'Não foi possível carregar a temporada. Tente novamente.', public: 'Público', private: 'Só eu', deleteVideo: 'Excluir vídeo', confirmDelete: 'Confirmar exclusão', signInTitle: 'Entre primeiro', signInBody: 'Entre para gerenciar seus jogos e vídeos.' }, + visual: { + eyebrow: 'TRPG Visual', + chooseScript: 'Escolher roteiro', + actTitle: 'Ato {count} · {title}', + actLocked: '{title} está bloqueado', + actSealed: 'Ato selado', + toBeContinued: 'Continua', + finishPreviousAct: 'Alcance um final no ato anterior', + revealAfterAct: 'Revelado após concluir o Ato {count}', + moreScripts: 'Mais níveis', + comingSoon: 'Em breve', + watchScriptVideo: 'Assistir ao vídeo da história', + scenarioKicker: 'Roteiro', + hiddenTitle: 'Tianyou', + revealedTitle: 'Pele pintada', + objectives: 'Objetivos atuais', + playHistory: 'Registro de jogo', + objectiveLabel: 'Objetivo', + close: 'Fechar', + noObjectives: 'Nenhum objetivo ativo.', + taskProgress: 'Etapas {done}/{total}', + entryKicker: 'Entrar no jogo', + entryTitle: 'Entrar no quarto hoje', + emptyScenarioEyebrow: 'Roteiro indisponível', + emptyScenarioTitle: 'Nenhum roteiro jogável', + emptyScenarioText: 'O servidor não retornou um roteiro publicado. Atualize ou verifique o estado de publicação.', + emptyScenarioActionTitle: 'Aguardando publicação', + emptyScenarioActionText: 'O app não inicia um roteiro local falso sem dados reais.', + continueTitle: 'Continuar última run', + entryBody: 'Escolha um personagem antes de entrar na casa antiga.', + chooseCharacter: 'Escolher personagem', + enterStory: 'Entrar na história', + back: 'Voltar', + skip: 'Pular', + editor: 'Editor', + debugPage: 'Depurar', + state: 'Estado', + settings: 'Configurações', + language: 'Idioma', + languageUnavailable: 'Este nível ainda não tem uma tradução disponível para esse idioma.', + turnVideoMatching: 'Correspondência inteligente de CG', + turnVideoMatchingHint: 'Quando ativada, procura CGs de cenas semelhantes para este turno. Desativada, só toca CGs vinculados exatamente.', + turnVideoMatchingOn: 'Ativada', + turnVideoMatchingOff: 'Desativada', + exitGame: 'Sair do jogo', + exitGameHint: 'Saia da sessão atual e volte à seleção de cenários.', + saveAndExit: 'Salvar e sair', + exitWithoutSaving: 'Sair sem salvar', + notes: 'Notas', + saves: 'Salvamentos', + continueRun: 'Continuar run', + balanceEntry: 'Saldo {credits}', + endingCollection: 'Coleção de finais', + narrator: 'Narrador', + npcName: 'Mulher no quarto', + npcLine: '"{line}"', + notStarted: 'Não iniciado', + startHint: 'Pressione iniciar para entrar na casa velha.', + debug: 'Depurar atributos', + debugOnly: 'Somente depuração', + waiting: 'Aguardando.', + modeAct: 'Agir', + modeSay: 'Falar', + inputMethod: 'Método de entrada', + textInputMode: 'Texto', + voiceInputMode: 'Voz', + choiceGroupLabel: 'Escolhas da história', + inputPlaceholder: 'Exemplo: ergo o espelho de bronze para ela e para o retrato.', + mobileInputPlaceholder: 'Fale ou aja…', + voiceStart: 'Iniciar entrada de voz', + voiceStop: 'Parar entrada de voz', + voiceListening: 'Ouvindo…', + voiceRecognizing: 'Reconhecendo…', + voiceUnsupported: 'Este navegador não oferece suporte à entrada de voz', + voicePermissionDenied: 'O acesso ao microfone foi negado. Verifique as permissões do navegador', + voiceMicrophoneUnavailable: 'Nenhum microfone disponível foi encontrado', + voiceNoSpeech: 'Nenhuma fala foi reconhecida. Tente novamente', + voiceNoAudioCaptured: 'Áudio do microfone não capturado. Verifique as permissões do navegador', + voiceRecognitionFailed: 'Falha no reconhecimento de voz. Tente novamente', + voiceHoldToTalk: 'Segure para falar', + voicePreparing: 'A preparar o microfone…', + voiceShortcutLabel: 'Atalho', + voiceReleaseSend: 'Solte para enviar', + voiceSlideCancel: 'Deslize para cima para cancelar', + voiceReleaseCancel: 'Solte para cancelar', + voiceUnderstanding: 'Entendendo seu comando…', + ending: 'Final', + endingLabel: 'Final', + endingLine: 'Final: {title}\n{description}', + endingSummary: 'Resumo do final', + endingPoem: 'Poema final', + endingFallbackPoem: '...', + endingBackgroundLoadFailed: 'Não foi possível carregar a cena final. Atualize a página e tente novamente.', + recordSeal: 'Caso', + recordTitle: 'Registro do guarda', + recordKicker: 'Arquivo do condado', + recordMeta: 'Depois da noite chuvosa', + continue: 'Continuar', + enterNextEpisode: 'Entrar no próximo episódio', + viewCurrentSeasonStory: 'Ver esta temporada', + played: 'Reproduzido', + nextPage: 'Próxima página', + statusCreating: 'Preparando sua história...', + statusStarted: 'Iniciado. Digite uma fala ou ação.', + statusHistoryRun: 'Visualizando uma partida antiga. Continuar usa o progresso do servidor.', + statusKp: 'KP está julgando...', + statusDone: 'Turno concluído.', + contentFiltered: 'Esta ação não pode ser descrita. Tente agir de outra forma.', + turnFailed: 'Não foi possível concluir esta etapa. Tente novamente.', + audioFailed: 'Não foi possível reproduzir a voz. Tente novamente.', + actionRejected: 'Essa ação ainda não pode funcionar. Tente outra abordagem.', + statusEnded: 'Jogo encerrado.', + statusEndingText: 'Gerando texto final...', + statusEndingLocked: 'Final determinado. Entrando no final...', + statusNpcAction: 'NPC está tentando agir. Aguardando julgamento do KP...', + statusNpcDone: 'NPC respondeu.', + clueGained: 'Pista obtida', + landscapeTitle: 'Gire o dispositivo', + landscapeBody: 'Jogue na horizontal para ver toda a cena e as imagens CG.', + stats: { + suspicion: 'Suspeita', + charm: 'Encanto', + fear: 'Medo', + }, + guide: { + label: 'Guia', + skip: 'Pular', + next: 'Próximo', + done: 'Entendi', + optionsTitle: 'Escolher diretamente', + optionsText: 'Após a resposta do NPC, duas sugestões aparecem: falar ou agir.', + keyboardTitle: 'Mudar para entrada pelo teclado', + keyboardText: 'A entrada por voz é o padrão. Toque no botão do teclado para escrever o que você diz ou faz.', + inputTitle: 'Escolher Falar ou Agir', + inputText: 'Antes de escrever, escolha se você vai falar ou agir. O KP avaliará qualquer uma das opções.', + notesTitle: 'Ver notas', + notesText: 'O botão de notas registra pistas descobertas e finais desbloqueados.', + goalTitle: 'Sobreviver', + goalText: 'Não é só conversa. Escute, mas observe o quarto, as pistas e as anomalias.', + videoTitle: 'Complete a história e transforme sua aventura em um vídeo só seu', + videoExampleLabel: 'Vídeo de exemplo', + videoExampleNotice: 'Este vídeo é apenas um exemplo e não representa o conteúdo gerado a partir da sua partida.', + videoLoading: 'Carregando um vídeo de exemplo…', + videoUnavailable: 'Esta história ainda não tem um vídeo de exemplo.', + videoDone: 'Começar minha história', + }, + openingOptions: { + say: 'Desculpe, eu não queria invadir. Algo lá fora estava me perseguindo.', + act: 'Falar baixo e observar portas, janelas, a lamparina e a posição dela.', + }, + }, + panels: { clues: 'Pistas', conclusions: 'Conclusões', info: 'Informações', inventory: 'Inventário', events: 'Eventos', stats: 'Atributos', flags: 'Marcadores', objects: 'Objetos', npcs: 'NPCs', endingRecords: 'Finais desbloqueados' }, + standalone: { + summary: 'Resumo', + availableClues: 'Pistas disponíveis', + recentTurns: 'Turnos recentes', + audit: { + title: 'Auditoria de IA', + subtitle: 'Custos e falhas de chamadas KP, NPC, finais e TTS.', + totalCalls: 'Chamadas', + failedCalls: 'Falhas', + tokens: 'Tokens entrada/saída', + cost: 'Custo', + roles: 'Resumo por papel', + models: 'Resumo por modelo', + recentCalls: 'Chamadas recentes', + empty: 'Nenhuma chamada de IA registrada.', + calls: '{count} chamadas', + tokenPair: 'tokens {tokens}', + }, + state: { + title: 'Visualização de estado', + waiting: 'Aguardando RunState.', + noRun: 'Sem runId. Abra pelo jogo ou digite um runId.', + loading: 'Carregando {runId}...', + loaded: '{runId} carregado', + }, + notes: { + title: 'Notas', + waiting: 'Carregando notas.', + noRun: 'Nenhum save atual encontrado. Inicie ou continue uma run primeiro.', + loading: 'Carregando {runId}...', + loaded: '{runId} carregado', + unavailable: 'As notas desta partida estão temporariamente indisponíveis.', + emptyClues: 'Ainda não há pistas', + emptyConclusions: 'Ainda não há conclusões', + emptyEndings: 'Ainda não há finais', + tabs: 'Categorias de notas', + list: 'Lista de entradas', + detail: 'Detalhe da entrada', + locked: 'Bloqueado', + lockedHint: 'Este final ainda não foi registrado.', + recorded: 'Registrado', + importance: { minor: 'Secundária', major: 'Importante', critical: 'Crítica' }, + relatedClues: 'Pistas relacionadas', + noRelatedClues: 'Nenhuma pista de apoio foi encontrada ainda.', + }, + }, + editor: { + title: 'Autoria de cenário', + pack: 'Pacote', + section: 'Seção', + raw: 'JSON', + structured: 'Editor estruturado', + debugJson: 'JSON de depuração', + item: 'Item', + noStructuredFields: 'Esta seção ainda não tem campos estruturados editáveis diretamente.', + updated: 'Salvo', + assetUploaded: 'Asset enviado ao rascunho', + compiled: 'Rascunho compilado', + published: 'Versão publicada', + rolledBack: 'Versão revertida', + versions: 'Versões', + assets: 'Índice de assets', + current: 'Atual', + currentVersion: 'Versão atual', + noPublishedVersion: 'Não publicado', + noVersions: 'Ainda não há versões. Compile um rascunho primeiro.', + noAssets: 'Ainda não há índice de assets. Compile o manifesto primeiro.', + noFilteredAssets: 'Nenhum asset corresponde aos filtros.', + assetSearch: 'Buscar', + assetSearchPlaceholder: 'Tag, arquivo ou objectKey', + assetTypeFilter: 'Tipo', + assetSourceFilter: 'Origem', + assetFilterAll: 'Todos', + assetFilterResult: '{shown}/{total}', + assetSource: { + uploaded: 'Objeto enviado', + static: 'Arquivo estático', + missing: 'Localizador ausente', + }, + uploadFile: 'Arquivo', + uploadTag: 'Tag', + uploadTagPlaceholder: 'opening_cg', + uploadType: 'Tipo', + noUploadFile: 'Nenhum arquivo', + }, + waitingList: { + eyebrow: 'Acesso antecipado', + apply: 'Solicitar acesso', + useInvite: 'Usar código de convite', + invitedRegister: 'Tem um convite? Criar conta', + title: 'Entre na waiting list do servidor de jogo', + body: 'Informe email e caso de uso. Operadores enviarão convites pelo admin.', + displayName: 'Nome', + displayNamePlaceholder: 'Opcional', + useCase: 'Caso de uso', + useCaseOptional: 'Caso de uso (opcional)', + useCasePlaceholder: 'Teste de equipe, criador, evento', + useCaseRequired: 'O caso de uso é obrigatório.', + consent: 'Aceito receber emails de convite e produto desta waiting list.', + join: 'Entrar', + joined: 'Solicitação recebida. Aguarde a notificação por email.', + joinedTitle: 'Solicitação enviada', + changeEmail: 'Usar outro email', + accountAlreadyRegistered: 'Este email já está cadastrado. Entre na sua conta.', + emailRequired: 'Email é obrigatório.', + consentRequired: 'Consentimento de email é necessário antes de entrar.', + inviteEyebrow: 'Convite', + inviteTitle: 'Crie sua conta voko.world', + inviteBody: 'Este convite está vinculado ao seu email. Defina uma senha para concluir o cadastro.', + inviteMissing: 'Token de convite ausente.', + inviteEnterCode: 'Digite o código de convite de 8 caracteres que você recebeu para criar uma conta.', + inviteValidate: 'Verificar código', + inviteReenter: 'Inserir outro código', + inviteInvalid: 'Convite inválido ou expirado.', + codeSent: 'Código de verificação enviado.', + passwordMismatch: 'As senhas não coincidem.', + acceptInvite: 'Criar conta', + }, + landing: { officialVideo: 'Ver o vídeo oficial', officialVideoEyebrow: 'Conheça VOKO', officialVideoTitle: 'O seu mundo está chamando.', officialVideoBody: 'Crie seu próprio mundo, viva histórias que chamam você de volta e compartilhe cada momento como um vídeo que vale assistir.', homeLabel: 'Início do voko.world', navHome: 'Home', navScripts: 'Histórias', navVideos: 'Vídeos', featuredStories: 'Histórias em destaque', featuredStoriesEyebrow: 'Comece a jogar', endingCount: '{count} finais', allStories: 'Todas as histórias', dramaTitle: 'Drama', brandSub: 'Histórias interativas', navLabel: 'Navegação principal', navStories: 'Jogos', navGallery: 'Galeria', eyebrow: 'RPG visual com IA', loadError: 'Não foi possível carregar a página.', retry: 'Tentar novamente', emptyGames: 'Ainda não há jogos publicados.', scenarioFallback: 'Mude a história com suas escolhas.', publicVideos: 'vídeos de jogadores', publicGames: 'jogos públicos', watchDrama: 'Assistir drama', enterPlay: 'Começar a jogar', comingSoon: 'Em breve', carouselLabel: 'Carrossel de jogos', previousGame: 'Jogo anterior', nextGame: 'Próximo jogo', showcaseTitle: 'Histórias criadas por jogadores', showcaseBody: 'Veja outros finais e crie sua própria versão.', searchLabel: 'Buscar histórias', searchPlaceholder: 'Buscar pelo título', sortLabel: 'Ordenação', latest: 'Recentes', popular: 'Populares', videoCount: '{count} vídeos', levelCount: '{count} níveis', levelNumber: 'Nível {count}', singleLevel: 'Nível independente', publicGame: 'Jogo público', noSearchResults: 'Nenhuma história encontrada', clearSearch: 'Limpar busca', finalEyebrow: 'Sua história está esperando', finalTitle: 'Quem você será?', finalBody: 'Atravesse o limiar e diga suas primeiras palavras.', begin: 'Começar a jornada', footer: '© 2026 voko.world', earlyAccess: 'Acesso antecipado', footerSocial: 'Comunidade', footerLegal: 'Legal e contato', privacy: 'Política de Privacidade', terms: 'Termos de Serviço', contact: 'Email de contato' }, + landingCarousel: { introEyebrow: 'Jogo dramático generativo', introSummary: 'Assista a um drama curto, entre no papel e mude a história com suas escolhas.', dramaEyebrow: 'Drama em destaque {count}', controls: 'Carrossel inicial', goToSlide: 'Ir para o slide {count}' }, + landingCinema: { navHome: 'Início', navDramas: 'Jogo', navPlayerStories: 'Dramas', navMy: 'Eu', featuredDramas: 'Jogos', playerStories: 'Dramas' }, + searchPage: { placeholder: 'Buscar histórias, dramas ou contas', popular: 'Pesquisas populares', history: 'Pesquisas recentes', clearHistory: 'Limpar', resultTypes: 'Tipos de resultado', all: 'Tudo', accounts: 'Conta', accountStats: '{titles} títulos · {plays} reproduções' }, + landingIntro: { title: 'voko.world', apply: 'Solicitar acesso', watchVideo: 'Ver vídeos', start: 'Iniciar jogo', continue: 'Continuar jogo', viewJourney: 'Histórico de jogo', continueProgress: 'Temporada {season} {scenario}', storyLine: 'Linha narrativa', viewMore: 'Ver mais' }, + storyTree: { + eyebrow: 'Linhas de temporada', + subtitle: 'Cada linha é uma temporada e cada nó é uma fase.', + root: 'Drama', + branch: 'Ramo', + active: 'Em andamento', + completed: 'Concluído', + locked: 'Não alcançado', + empty: 'Ainda não há linhas de temporada.', + loadError: 'Falha ao carregar as linhas de temporada.', + navigation: 'Intervalo de temporadas', + range: 'Temporadas {first}–{last} · {total} no total', + newer: 'Anterior', + older: 'Próximo', + startPlaying: 'Jogar agora', + turnProgress: 'Turno {turn}', + continuePlaying: 'Continuar jogando', + playLog: 'Registro de jogo', + seasonEnding: 'Final da temporada', + currentSeason: 'Temporada atual', + logLoadError: 'Falha ao carregar o registro.', + logEmpty: 'Ainda não há histórico nesta temporada.', + turnNumber: 'Turno {turn}', + playerSaid: 'Você disse', + playerDid: 'Você fez', + storyOpening: 'Início da história', + turnVideos: 'Vídeos correspondentes', + playActionVideo: 'Vídeo de ação', + playPlayerVideo: 'Vídeo do jogador', + playNpcVideo: 'Vídeo do NPC', + replayTurn: 'Rever', + replayTurnTitle: 'Repetição do turno {turn}', + replayProgress: 'Trecho {current}/{total}', + actionSegment: 'Ação', + playerSpeechSegment: 'Fala do jogador', + npcSpeechSegment: 'Fala do NPC', + pauseReplay: 'Pausar', + continueReplay: 'Continuar', + restartReplay: 'Repetir', + exportReplay: 'Exportar replay', + replayQueued: 'Na fila', + replayExporting: 'Exportando', + playFullReplay: 'Reproduzir tudo', + retryReplayExport: 'Tentar novamente', + downloadReplay: 'Baixar MP4', + downloadSegment: 'Baixar este trecho', + downloadTurn: 'Baixar turno completo', + turnExporting: 'Preparando vídeo do turno', + turnExportError: 'Falha ao exportar o turno.', + segmentExporting: 'Preparando trecho', + segmentExportError: 'Falha ao exportar o trecho.', + fullReplayTitle: '{title} · Replay completo', + replayExportError: 'Falha ao exportar o replay.', + videoLoadError: 'Não foi possível carregar o vídeo.', + tools: 'Pesquisar, filtrar e ordenar histórias', + search: 'Pesquisar', + searchPlaceholder: 'Pesquisar fase, final ou ação recente', + filter: 'Status', + filterAll: 'Todas', + filterInProgress: 'Em andamento', + filterCompleted: 'Concluídas', + sort: 'Ordenar', + sortRecent: 'Jogado recentemente', + sortSeasonDesc: 'Temporada decrescente', + sortSeasonAsc: 'Temporada crescente', + resultCount: '{count} temporadas', + noResults: 'Nenhuma história corresponde aos critérios.', + canvasHint: 'Arraste a tela ou role para ver as fases à direita', + canvasLabel: 'Tela da árvore da história com movimento horizontal', + storySwitch: 'História', + }, + storyTreeCurrentSeasonHint: 'Progresso mais recente', + storyTreeViewDetails: 'Ver detalhes da temporada {number}', + storyTreeDelete: { action: 'Excluir ramo', confirm: 'Excluir a temporada {number} e todos os ramos derivados? Esta ação não pode ser desfeita.', deleting: 'Excluindo…', error: 'Não foi possível excluir o ramo.' }, + storyTreeVideoUnavailable: 'Vídeo indisponível', + storyTreeFork: { action: 'Jogar novamente daqui', confirm: 'Uma nova ramificação será criada a partir do turno {turn}. O histórico atual não será alterado.', failed: 'Não foi possível criar a nova ramificação. Tente novamente.', creating: 'Criando…' }, + runVideoGeneration: { title: 'Gerar vídeo completo', description: 'Gere um vídeo completo desta partida. Os pontos são cobrados após a confirmação e devolvidos se a geração falhar.', generate: 'Gerar vídeo', regenerate: 'Gerar novamente', retry: 'Tentar novamente', queued: 'Na fila', running: 'Gerando', price: 'Pontos necessários', balance: 'Saldo atual', balanceAfter: 'Saldo após a cobrança' }, + landingShowcase: { title: 'Vidas em cena', body: 'Veja os destinos traçados por outros jogadores, entre na história e escreva seu próprio final.' }, + landingMetrics: { playCount: 'Reproduções', likeCount: 'Curtidas' }, + landingCard: { level: 'Episódio', searchPlaceholder: 'Encontre histórias, vídeos ou jogadores', scriptFilter: 'Drama', scriptAll: 'Todos os dramas', playerVideoCount: '{count} vídeos públicos', publishedAt: 'Publicado', publishedAll: 'Tudo', publishedToday: 'Hoje', publishedWeek: 'Últimos 7 dias', publishedMonth: 'Últimos 30 dias', expandSynopsis: 'Ver mais', collapseSynopsis: 'Ver menos' }, + playerPage: { loadError: 'Não foi possível carregar os vídeos do jogador.', notFound: 'Jogador não encontrado ou sem vídeos públicos.', searchPlaceholder: 'Buscar dramas, fases ou temporadas' }, + season: { choose: 'Escolha uma temporada', chooseBody: 'Este episódio continuará a história da temporada escolhida.', number: 'Temporada {number}', empty: 'Conclua o episódio anterior de uma temporada antes de continuar.' }, + status: { loading: 'Carregando', empty: 'Sem dados', error: 'Falha ao carregar', streaming: 'Processando' }, + storiesPage: { tags: 'Tags de histórias', allTags: 'Todas', swipeForNextStory: 'Deslize para cima para ver a próxima história' }, + creator: { nav: 'Criar', queued: 'Na fila', attachmentTypeError: 'Envie um arquivo TXT, PDF, Word, PNG, JPG ou WEBP.', attachmentSizeError: 'O anexo deve ter no máximo 10 MB.', attachmentReadError: 'Não foi possível ler o anexo.', generationFailed: 'Falha na geração', reviewRejected: 'Revisão rejeitada', published: 'Publicado', archived: 'Arquivado', composerTitle: 'Que mundo você quer criar hoje?', submitIdea: 'Enviar ideia', examplesNote: 'Jogos publicados reais exibidos como referência visual.', exampleStatus: 'Exemplo publicado', projectsEyebrow: 'Visual Game Creator', projectsTitle: 'Meus jogos', projectsBody: 'Gerencie seus jogos e acompanhe a geração e revisão.', newGame: 'Criar novo jogo', draftStatus: 'Rascunho', continueEditing: 'Continuar editando', generatedScopes: 'itens de geração', emptyTitle: 'Crie seu primeiro jogo', emptyBody: 'Comece com uma ideia, roteiro ou universo e transforme-o em um jogo.', eyebrow: 'Visual Game Creator', title: 'Transforme sua história em um mundo jogável', subtitle: 'Compartilhe uma ideia, roteiro ou universo. O sistema criará, produzirá e validará o jogo.', stepIdea: 'Ideia', stepGenerate: 'Gerar', stepReview: 'Revisão', ideaTitle: 'Descreva seu jogo', ideaBody: 'Escreva naturalmente. Você não precisa conhecer JSON.', ideaLabel: 'Ideia ou roteiro', ideaHint: 'Inclua mundo, personagens, conflito e experiência.', ideaPlaceholder: 'Um viajante acorda numa hospedaria onde todos lembram uma noite diferente…', ideaError: 'Digite pelo menos 20 caracteres.', languageLabel: 'Idioma', languages: { zh: 'Chinês', en: 'Inglês', ja: 'Japonês', ko: 'Coreano' }, scopeTitle: 'Gerar agora', scopeStory: 'História e fases', scopeStoryBody: 'Personagens, missões, estados e finais', scopeVisuals: 'Mídia visual', scopeVisualsBody: 'Personagens, cenas, objetos e vídeos', scopeVoice: 'Vozes', scopeVoiceBody: 'Jogador, NPC e narração', scopeError: 'Selecione ao menos um item.', assetsTitle: 'Material de referência', assetsBody: 'Opcional. Ajuda a manter a consistência.', uploadAction: 'Adicionar arquivos', uploadHint: 'Um arquivo · TXT, PDF, Word, PNG, JPG ou WEBP · até 10 MB', supportedTitle: 'Material compatível', supportedBody: 'Imagens, roteiros, documentos, vídeo e áudio.', removeFile: 'Remover {name}', saved: 'Rascunho salvo.', draftNote: 'Esta primeira versão salva um rascunho local.', save: 'Salvar rascunho' }, + labels: { scenario: 'Cenário', asset: 'Recurso', mode: 'Modo', phase: 'Fase', location: 'Local', noRun: 'Não iniciado', none: 'Nenhum' }, +} diff --git a/packages/i18n/src/messages/zh/account.js b/packages/i18n/src/messages/zh/account.js new file mode 100644 index 0000000..b421f78 --- /dev/null +++ b/packages/i18n/src/messages/zh/account.js @@ -0,0 +1,88 @@ +/** account.* —— 个人中心(资料 / 积分 / 订阅 / 历史 / 收藏 / 设置)。zh。 */ +export default { + pageTitle: '个人中心', + backToApp: '返回应用', + + header: { + memberSince: '{date} 加入', + editAvatar: '编辑头像', + vip: 'VIP', + guestName: '访客', + }, + + stats: { + credits: '可用积分', + watched: '已观看剧集', + rewritten: '改写故事数', + }, + + history: { + title: '继续观看', + pageTitle: '观看历史', + subtitle: '从上次中断处继续。', + viewAll: '查看全部', + empty: '还没有进行中的内容,挑一部剧开始观看吧。', + browse: '去看剧', + episodeLine: '第 {ep} 集 • 还剩 {left}', + resume: '继续观看《{title}》', + }, + + favorites: { + title: '已收藏的剧', + pageTitle: '已收藏的剧', + subtitle: '你稍后想看而收藏的剧。', + manage: '管理', + empty: '还没有收藏,收藏喜欢的剧后会出现在这里。', + browse: '去看剧', + discoverMore: '发现更多', + open: '打开《{title}》', + }, + + back: '返回个人中心', + + preferences: { + title: '偏好设置', + language: '语言', + theme: '主题', + light: '浅色', + dark: '深色', + }, + + subscription: { + renews: '将于 {date} 续订', + inactive: '当前没有有效订阅', + pastDue: '付款已逾期', + topUp: '充值积分', + manage: '管理订阅', + paymentMethod: '支付方式', + upgrade: '升级套餐', + tierFree: '免费套餐', + }, + + account: { + title: '账户', + notifications: '通知', + privacy: '隐私与数据', + downloadData: '下载我的数据', + deleteData: '删除我的数据', + signOut: '退出登录', + }, + + actions: { + notificationsSoon: '通知设置即将上线。', + downloadStarted: '正在准备你的数据导出,完成后会通过邮件发送链接。', + deleteTitle: '删除你的数据?', + deleteBody: '此操作将永久删除你的账户数据,且无法撤销。', + deleteConfirm: '删除数据', + deleteCancel: '保留我的数据', + deleteRequested: '已提交数据删除请求,我们将尽快处理。', + signedOut: '你已退出登录。', + languageChanged: '语言已更新。', + }, + + points: { + dailyClaimTitle: '每日积分', dailyClaimAvailable: '今日可领取 {points} 积分。', dailyClaimed: '今日积分已领取。', claim: '领取积分', claimed: '已领取', nextAvailable: '下次可领取时间:{time}', balanceTurns: '{points} 积分 · 可玩 {turns} 回合', + }, + loading: '正在加载你的资料…', + loadError: '无法加载你的资料,请重试。', +} diff --git a/packages/i18n/src/messages/zh/auth.js b/packages/i18n/src/messages/zh/auth.js new file mode 100644 index 0000000..1f1454f --- /dev/null +++ b/packages/i18n/src/messages/zh/auth.js @@ -0,0 +1,70 @@ +/** auth.* —— 登录 / 注册 / 身份合并。zh 与 en 同构。 */ +export default { + login: { + title: '欢迎回来', + subtitle: '输入你的账号信息以登录。', + submit: '登录', + submitting: '正在登录…', + submitError: '登录失败,请检查信息后重试。', + email: '邮箱地址', + emailPlaceholder: "name{'@'}example.com", + emailRequired: '请输入邮箱。', + invalidEmail: '请输入有效的邮箱。', + password: '密码', + passwordRequired: '请输入密码。', + forgot: '忘记密码?', + showPassword: '显示密码', + hidePassword: '隐藏密码', + }, + register: { + title: '创建账号', + subtitle: '验证邮箱后即可开始。', + submit: '注册', + haveAccount: '已有账号?', + codeSent: '验证码已发送,请查收邮箱。', + }, + segment: { + signIn: '登录', + createAccount: '创建账号', + }, + social: { + divider: '或使用以下方式继续', + google: '使用 Google 继续', + googleEnglish: 'Continue with Google', + email: '使用邮箱继续', + apple: '使用 Apple 继续', + }, + forgot: { + title: '重置密码', + body: '输入账号绑定的邮箱,我们会向你发送重置验证码。', + emailLabel: '邮箱地址', + submit: '发送重置验证码', + sent: '重置验证码已发送,请查收邮箱。', + needEmail: '请输入有效的邮箱。', + error: '验证码发送失败,请重试。', + }, + legal: { + agree: '继续即表示你同意我们的', + terms: '服务条款', + and: '和', + privacy: '隐私政策', + termsTitle: '服务条款', + privacyTitle: '隐私政策', + termsBody: + '使用 IdearRun 即表示你同意仅将本服务用于合法的个人娱乐用途。互动短剧及你生成的剧情仅授权你在应用内观看,不得二次分发。我们可能随产品迭代更新本条款;继续使用即代表你接受最新版本。', + privacyBody: + '我们仅收集运行 IdearRun 所必需的最少数据:你的账号邮箱、观看历史,以及用于个性化推荐的互动选择。我们绝不出售你的个人数据。匿名观看数据仅在你选择注册时才会合并进账号。你可随时申请导出或删除你的数据。', + }, + hero: { + brand: 'IdearRun', + titleA: '走进故事。', + titleB: '改写结局。', + body: '加入新一代互动短剧。你的选择实时塑造剧情走向。', + }, + convert: { + title: '保存你的进度', + body: '注册后即可继续观看《{name}》并解锁全部剧集。', + }, + logout: '退出登录', + guest: '以访客身份继续', +} diff --git a/packages/i18n/src/messages/zh/common.js b/packages/i18n/src/messages/zh/common.js new file mode 100644 index 0000000..b23bfed --- /dev/null +++ b/packages/i18n/src/messages/zh/common.js @@ -0,0 +1,34 @@ +/** common.* —— 跨页面通用文案(按钮 / 状态 / 导航 / 主题 / 语言)。zh 与 en 同构。 */ +export default { + appName: 'IdearRun', + ok: '好', + cancel: '取消', + confirm: '确认', + back: '返回', + next: '下一步', + retry: '重试', + loading: '加载中…', + save: '保存', + close: '关闭', + error: { + generic: '出了点问题,请重试。', + network: '网络错误,请检查连接。', + }, + nav: { + explore: '发现', + library: '剧本库', + pricing: '会员', + login: '登录', + getStarted: '开始体验', + profile: '个人中心', + logout: '退出登录', + }, + theme: { + toLight: '切换到亮色主题', + toDark: '切换到暗色主题', + }, + language: { + label: '语言', + options: { en: '英语', zh: '中文', ja: '日语', ko: '韩语', es: '西班牙语', pt: '葡萄牙语' }, + }, +} diff --git a/packages/i18n/src/messages/zh/drama.js b/packages/i18n/src/messages/zh/drama.js new file mode 100644 index 0000000..b2bb2cf --- /dev/null +++ b/packages/i18n/src/messages/zh/drama.js @@ -0,0 +1,60 @@ +/** drama.* —— 剧详情页(封面 / 简介 / 剧集列表 / 演员 / 相关推荐 / 开播 CTA)。 */ +export default { + loading: '正在加载剧目…', + error: '无法加载该剧目。', + retry: '重试', + notFound: '该剧目已不可用。', + breadcrumb: { + library: '剧本库', + }, + badge: { + interactive: '可互动', + }, + meta: { + episodes: '{count} 集', + perEpisode: '每集约 {minutes} 分钟', + rating: '{score}', + plays: '{count} 次播放', + }, + hero: { + start: '开始观看', + resume: '继续观看', + favorite: '收藏', + favorited: '已收藏', + share: '分享', + shareCopied: '链接已复制到剪贴板', + favoriteAdded: '已加入收藏', + favoriteRemoved: '已从收藏中移除', + }, + episodes: { + title: '剧集', + season: '第 {number} 季', + label: '第 {number} 集', + viewAll: '查看全部 {count} 集', + collapse: '收起剧集', + empty: '该剧目的剧集正在准备中。', + locked: '解锁后观看', + }, + interactivity: { + title: '改写剧情', + body: '本剧支持全程互动。不喜欢某个角色的选择?用 AI 即时生成新的场景。', + cta: '体验互动模式', + generating: '正在生成新场景…', + }, + cast: { + title: '主要角色', + empty: '角色信息即将上线。', + }, + details: { + title: '详情', + director: '导演', + release: '上线时间', + audio: '音频', + format: '格式', + language: '语言', + }, + related: { + title: '你可能也喜欢', + empty: '暂无推荐。', + }, +} diff --git a/packages/i18n/src/messages/zh/feed.js b/packages/i18n/src/messages/zh/feed.js new file mode 100644 index 0000000..c569204 --- /dev/null +++ b/packages/i18n/src/messages/zh/feed.js @@ -0,0 +1,30 @@ +/** feed.* —— 刷剧信息流。zh 与 en 同构。 */ +export default { + cta: '免费开刷', + empty: '这里还没有短剧,过会儿再来看看。', + emptyHint: '每天都有新的 AI 短剧上线,稍后再来看看吧。', + loading: '正在加载短剧…', + loadMore: '加载更多', + trending: '正在热播', + likeCount: '暂无点赞 | {count} 个赞 | {count} 个赞', + + episodeOf: '第 {current} 集 / 共 {total} 集', + byCreator: '由 {handle} 创作', + rewrite: '改写剧情', + rewriteAria: '用你的脑洞改写这一幕', + share: '分享', + shareAria: '分享这部短剧', + shareCopied: '链接已复制到剪贴板', + shareFailed: '暂时无法分享,请稍后再试。', + liked: '已加入收藏', + unliked: '已取消收藏', + likeAria: '点赞这部短剧', + prevAria: '上一部', + nextAria: '下一部', + openDrama: '打开《{title}》', + + upNext: '即将播放', + viewAll: '查看全部', + defaultCreator: 'IdearRun 工作室', + defaultSynopsis: '一部由 AI 编织的短剧,随时可以暂停并改写任意一幕。', +} diff --git a/packages/i18n/src/messages/zh/index.js b/packages/i18n/src/messages/zh/index.js new file mode 100644 index 0000000..b276ba1 --- /dev/null +++ b/packages/i18n/src/messages/zh/index.js @@ -0,0 +1,15 @@ +/** zh message 聚合 —— 分命名空间文件聚合,与 en 同构 key 树。内部聚合,不对外深导入。 */ +import common from './common.js' +import auth from './auth.js' +import feed from './feed.js' +import player from './player.js' +import pay from './pay.js' +import drama from './drama.js' +import library from './library.js' +import onboarding from './onboarding.js' +import landing from './landing.js' +import account from './account.js' +import visualGame from './visual-game.js' +import legal from './legal.js' + +export default { common, auth, feed, player, pay, drama, library, onboarding, landing, account, visualGame, legal } diff --git a/packages/i18n/src/messages/zh/landing.js b/packages/i18n/src/messages/zh/landing.js new file mode 100644 index 0000000..d214435 --- /dev/null +++ b/packages/i18n/src/messages/zh/landing.js @@ -0,0 +1,62 @@ +/** landing.* —— 营销落地页。zh 与 en 同构。 */ +export default { + openDrama: '打开《{title}》', + + hero: { + badge: 'IdearRun 播放器 v2.0 已上线', + titleLead: '看剧。', + titleAccent: '改写剧情。', + subtitle: + '首个 AI 互动 2D 短剧平台。随时打断任意一幕,改写对白,看 AI 实时分叉出新的剧情走向。', + ctaPrimary: '免费开始看剧', + ctaSecondary: '它如何运作', + rewriteActive: 'AI 改写中', + rewriteSample: '"不,我不会交出这些文件。你只能自己来拿。"', + }, + + bento: { + heading: '释放电影级智能', + rewriteTitle: '用 AI 打断并改写', + rewriteDesc: '暂停任意一幕,输入新的走向,骨骼引擎即刻重新生成这段表演。', + rewriteBubble: '"我觉得我们该重新考虑这笔交易。"', + rewriteGenerating: '正在生成另一条路径…', + animTitle: '电影感 2D 纸偶动画', + animDesc: '动态骨骼绑定,遇上极具情绪的光影。', + voicesTitle: 'AI 配音', + branchTitle: '无尽剧情线', + trending: '热门短剧', + }, + + how: { + browse: { + title: '1. 浏览', + desc: '在涵盖各类题材的 2D 电影化连载故事库中自由探索。', + }, + interrupt: { + title: '2. 打断', + desc: '随时点击改写,修改对白、情绪或角色的动作。', + }, + rewrite: { + title: '3. AI 改写', + desc: '看着引擎生成全新的分叉路径,故事瞬间随之演变。', + }, + }, + + featured: { + heading: '精选短剧', + browseAll: '浏览全部', + rewrites: '{count} 次改写', + rewritesUnknown: '新上线', + genrePlaceholder: 'AI 互动短剧', + }, + + footer: { + copyright: '© 2024 IdearRun AI 版权所有。电影级智能。', + terms: '服务条款', + privacy: '隐私政策', + discord: 'Discord', + twitter: 'Twitter', + docs: '文档', + language: '语言:', + }, +} diff --git a/packages/i18n/src/messages/zh/legal.js b/packages/i18n/src/messages/zh/legal.js new file mode 100644 index 0000000..5facccb --- /dev/null +++ b/packages/i18n/src/messages/zh/legal.js @@ -0,0 +1,30 @@ +export default { + privacy: { + title: '隐私协议', updated: '最后更新:2026 年 8 月 18 日', intro: 'voko.world 是一个生成式互动短剧体验。本协议说明我们会收集哪些信息、如何使用这些信息,以及你可以如何管理自己的选择。', + sections: { + information: { title: '我们收集的信息', body: '我们会收集账号信息,例如邮箱、昵称、登录数据、游玩进度、故事选择、生成视频记录,以及保障服务安全稳定所需的基础设备与日志信息。' }, + usage: { title: '信息用途', body: '我们使用这些信息来提供游戏、保存进度、生成和展示故事视频、发送产品或视频状态通知、提升安全与质量、防止滥用,并处理客服请求。' }, + generated: { title: '生成内容', body: '你的输入、选择、故事记录和生成视频可能会由我们的系统及服务提供商处理,用于创建、审核、存储和交付体验。' }, + voice: { title: '语音输入', body: '当你使用语音输入时,麦克风录音会发送给字节跳动豆包语音转文字服务生成转录。转录完成后,voko.world 不会保存录音;转录文字会先留在输入框中,由你确认编辑后再发送。' }, + sharing: { title: '信息共享', body: '我们不会出售个人信息。我们只会在服务提供商、基础设施厂商、分析或安全工具、法律要求,或你主动公开或分享内容时共享必要信息。' }, + retention: { title: '保存期限', body: '我们会在提供 voko.world、履行法律义务、解决争议和保护服务所需的期限内保存信息。你可以联系我们请求删除账号或数据。' }, + choices: { title: '你的选择', body: '你可以管理账号、选择是否公开生成视频、退订可选通知,并联系我们处理访问、更正或删除请求。' }, + contact: { title: '联系我们', body: "隐私相关问题请发送邮件至 service{'@'}voko.world。" }, + }, + }, + terms: { + title: '服务协议', updated: '最后更新:2026 年 8 月 13 日', intro: '本协议适用于你访问和使用 voko.world。使用本服务即表示你同意本协议。', + sections: { + usage: { title: '使用 voko.world', body: '你可以将 voko.world 用于合法的个人娱乐和创作目的。你需要对自己的账号、选择以及提交或公开的内容负责。' }, + accounts: { title: '账号与访问', body: '你应提供准确的账号信息并妥善保管登录凭证。如果我们发现滥用、安全风险或违反协议的行为,可能会暂停访问。' }, + generated: { title: '生成故事与视频', body: 'voko.world 可能基于你的互动生成文本、图片、音频或视频。生成结果可能变化且不完美,不应作为事实、法律、医疗、金融或专业建议使用。' }, + rules: { title: '内容规则', body: '不得提交、请求、发布或传播违法、辱骂、侵权、欺骗、性剥削或伤害他人的内容。' }, + property: { title: '知识产权', body: 'voko.world 的软件、设计、品牌和服务内容归我们或许可方所有。你需对自己提供的内容负责,并授予我们运营、展示、改进和保护服务所需的权利。' }, + availability: { title: '服务可用性', body: '我们可能调整、暂停或终止部分服务。对于服务中断、不可控事件导致的数据损失或第三方服务故障,我们不承担超出法律要求的责任。' }, + contact: { title: '联系我们', body: "服务协议相关问题请发送邮件至 contact{'@'}idearrun.com。" }, + }, + }, + contact: { title: '联系我们', updated: 'voko.world 支持与社区', intro: '如果你有问题、合作想法、Bug 反馈或内容申诉,欢迎联系我们。', sections: { + email: { title: '邮箱', body: "service{'@'}voko.world" }, community: { title: '社区', body: '加入 Discord:https://discord.gg/WeESGyuUSX' }, social: { title: '社交媒体', body: '你可以通过页脚链接关注 voko.world 的 X、YouTube、Instagram、TikTok 和 Facebook。' }, support: { title: '支持说明', body: '反馈账号或视频问题时,请附上账号邮箱、页面 URL 和问题描述。请不要发送密码或支付凭证。' }, + } }, +} diff --git a/packages/i18n/src/messages/zh/library.js b/packages/i18n/src/messages/zh/library.js new file mode 100644 index 0000000..f34c8b9 --- /dev/null +++ b/packages/i18n/src/messages/zh/library.js @@ -0,0 +1,36 @@ +/** library.* —— 剧本库。zh 与 en 同构。 */ +export default { + title: '热门剧本库', + searchPlaceholder: '搜索短剧、创作者、题材……', + searchAria: '搜索剧本库', + genresAria: '按题材筛选', + genres: { + all: '全部', + sciFi: '科幻', + romance: '言情', + thriller: '惊悚', + comedy: '喜剧', + drama: '剧情', + fantasy: '奇幻', + }, + sortLabel: '排序:{value}', + sort: { + trending: '热度', + newest: '最新', + mostViewed: '最多观看', + }, + loading: '正在加载短剧……', + error: '剧本库加载失败。', + retry: '重试', + empty: '没有符合搜索条件的短剧。', + viewsCount: '暂无观看 | {count} 次观看 | {count} 次观看', + aiBadge: 'AI', + locked: '付费短剧', + openAria: '打开 {title}', + moreAria: '{title} 的更多操作', + brand: 'IdearRun', + footerTerms: '条款', + footerPrivacy: '隐私', + footerSupport: '支持', + copyright: '© {year} IdearRun AI 版权所有。', +} diff --git a/packages/i18n/src/messages/zh/onboarding.js b/packages/i18n/src/messages/zh/onboarding.js new file mode 100644 index 0000000..8851b73 --- /dev/null +++ b/packages/i18n/src/messages/zh/onboarding.js @@ -0,0 +1,60 @@ +/** onboarding.* —— 选型问卷(题材多选 / 进度 / 跳过)。zh 与 en 同构。 */ +export default { + brand: 'IdearRun', + stepOf: '第 {current} / {total} 步', + progressLabel: '引导进度', + continue: '继续', + finish: '完成', + back: '上一步', + skip: '暂时跳过', + timeHint: '不到 30 秒就能搞定', + lineupTitle: '正在为你生成专属片单……', + step: { + genres: { + title: '你喜欢哪些题材?', + subtitle: '选几个,帮我们调校你的 AI 剧场引擎。', + }, + pacing: { + title: '你偏好怎样的节奏?', + subtitle: '我们会按你的口味把控剧情快慢。', + }, + length: { + title: '你喜欢看多长?', + subtitle: '我们会按你的时间安排每集时长。', + }, + tone: { + title: '你想要怎样的氛围?', + subtitle: '我们会据此调校旁白的情绪基调。', + }, + }, + option: { + genres: { + romance: '言情', + thriller: '惊悚', + scifi: '科幻', + comedy: '喜剧', + mystery: '悬疑', + drama: '剧情', + fantasy: '奇幻', + horror: '恐怖', + }, + pacing: { + slowburn: '慢热', + steady: '平稳', + fast: '快节奏', + twisty: '多反转', + }, + length: { + short: '碎片化', + medium: '适中', + long: '深度长篇', + binge: '适合刷剧', + }, + tone: { + witty: '机智', + intense: '紧张', + cozy: '温馨', + epic: '史诗', + }, + }, +} diff --git a/packages/i18n/src/messages/zh/pay.js b/packages/i18n/src/messages/zh/pay.js new file mode 100644 index 0000000..4d8b223 --- /dev/null +++ b/packages/i18n/src/messages/zh/pay.js @@ -0,0 +1,106 @@ +/** pay.* —— 解锁 / 订阅 / 支付。zh 与 en 同构。 */ +export default { + unlock: '解锁本集', + subscribe: '订阅', + price: '{amount} / 月', + coins: '暂无金币 | {count} 金币 | {count} 金币', + restore: '恢复购买', + success: '支付成功', + failed: '支付失败,未扣款。', + + checkout: { + back: '返回套餐', + brand: 'IdearRun', + subscribeTo: '订阅', + perYear: '/年', + perMonth: '/月', + lineItem: '{plan} — {amount}', + subtotal: '小计', + tax: '税费', + total: '合计', + renewNotice: '将于 {date} 按 {amount} 续费', + secure: '安全结账', + + paymentDetails: '支付信息', + expressApple: 'Apple Pay', + expressGoogle: 'Google Pay', + expressLink: 'Link', + orPayWithCard: '或使用银行卡支付', + + emailLabel: '邮箱', + emailPlaceholder: 'you{at}example.com', + emailInvalid: '请输入有效的邮箱地址。', + + cardLabel: '卡信息', + cardSecuredHeading: '卡信息由 Stripe 安全收集', + cardSecuredBody: '我们绝不查看或存储你的卡号,付款将在 Stripe 的安全表单中完成。', + cardElementLoading: '正在加载安全卡片输入框…', + + nameLabel: '持卡人姓名', + namePlaceholder: '卡片上的全名', + + billingLabel: '账单地址', + countryUS: '美国', + countryCA: '加拿大', + countryGB: '英国', + countryAU: '澳大利亚', + zipPlaceholder: '邮政编码', + + pay: '支付 {amount}', + paying: '正在跳转至安全支付…', + poweredBy: '由 {provider} 提供支持', + + cancelled: '已取消结账,未扣款。', + error: '无法发起结账,请重试。', + }, + + paywall: { + title: '解锁这条剧情线', + body: '这条分支需要消耗金币。解锁即可展开全新的叙事支线,并通过 AI 与场景互动。', + unlock: '用 {count} 金币解锁', + goVip: '开通 VIP 无限解锁', + balancePre: '你当前拥有', + balancePost: '金币', + insufficient: '金币不足 —— 去充值或开通 VIP。', + later: '以后再说', + }, + + plans: { + title: '充值金币或开通会员', + subtitle: '选择金币包按需解锁,或订阅 VIP 畅享全部分支剧情与 AI 生成能力。', + confirm: '确认', + confirmHint: '继续前往安全结账。', + selectHint: '选择一个积分包或 VIP 套餐以继续。', + }, + + packs: { + credits: '{count} 金币', + standard: '标准包', + popular: '热门', + bonus: '额外赠送 {count} 金币', + bestValue: '超值优惠 - 省 {pct}%', + }, + + points: { + paid: '实付积分:{points}', bonus: '赠送积分:{points}', total: '总积分:{points}', buy: '购买积分', signInToBuy: '登录后购买', insufficient: '积分余额不足。', shortfall: '还差 {points} 积分。', orderCanceled: '已取消', orderFailed: '支付失败', + }, + vip: { + title: 'IdearRun VIP', + subtitle: '释放 AI 剧场的全部潜能。', + billingCycle: '计费周期', + monthly: '按月', + yearly: '按年', + save: '省 {pct}%', + perMonth: '/ 月', + billedYearly: '按年计费 {amount}', + billedMonthly: '按月计费,随时取消', + cta: '开通 VIP', + selected: '已选 VIP', + perks: { + unlimited: '无限解锁剧情线', + endings: '解锁专属结局', + early: '抢先体验新剧', + adfree: '无广告浏览体验', + }, + }, +} diff --git a/packages/i18n/src/messages/zh/player.js b/packages/i18n/src/messages/zh/player.js new file mode 100644 index 0000000..900e8d8 --- /dev/null +++ b/packages/i18n/src/messages/zh/player.js @@ -0,0 +1,51 @@ +/** player.* —— 播放与互动续写。zh 与 en 同构。 */ +export default { + play: '播放', + pause: '暂停', + captions: '字幕', + volume: '音量', + mute: '静音', + unmute: '取消静音', + fullscreen: '进入全屏', + exitFullscreen: '退出全屏', + settings: { + title: '播放设置', + captionLanguage: '字幕语言', + }, + episode: '第 {n} 集', + nextEpisode: '下一集', + replay: '重新播放', + progress: '播放进度', + voiceLoading: '配音生成中', + generating: '正在续写下一幕…', + ended: '已经到结尾了,还想看更多吗?', + subtitleEmpty: '剧情正在展开…', + like: '喜欢', + tip: '打赏', + share: '分享', + report: '举报', + shareCopied: '链接已复制到剪贴板', + reported: '感谢反馈,我们已收到你的举报', + rewrite: { + title: '改写这一幕', + engineActive: 'AI 引擎已就绪', + hint: '打断剧情,改写对白、情绪或动作。', + placeholder: '告诉 AI 怎么改这一幕…例如「让 Mira 听起来更绝望,并掏出武器」。', + generate: '生成另一种走向', + renderingHint: '正在渲染骨骼动画 {\'@\'} AI 配音', + done: '新场景已生成', + failed: '场景生成失败,请重试。', + }, + cast: { + title: '当前角色', + add: '加角色', + addPrompt: '为这一幕加入一个新角色:', + viewAll: '查看全部', + viewLess: '收起', + }, + upNext: { + title: "《{drama}》接下来", + empty: '暂时没有更多剧集——打断剧情让故事继续。', + untitled: '未命名剧集', + }, +} diff --git a/packages/i18n/src/messages/zh/visual-game.js b/packages/i18n/src/messages/zh/visual-game.js new file mode 100644 index 0000000..d8fcc2b --- /dev/null +++ b/packages/i18n/src/messages/zh/visual-game.js @@ -0,0 +1,595 @@ +export default { + appName: 'voko.world', + productBrandSub: '你的 kosmo 正在召唤。', + mobileCredit: { title: '我的积分', buyCredits: '购买积分', history: '积分记录', purchaseHistory: '购买记录', usageHistory: '使用记录', emptyHistory: '暂无积分记录', loadMore: '加载更多', balanceAfter: '余额:{points}', pending: '处理中', paid: '已支付', canceled: '已取消', failed: '失败', refunded: '已退款', disputed: '争议中', autoReload: '自动充值', bonusCredits: '赠送积分', buyNow: '立即购买', lowBalance: '余额不足时', enabled: '已开启', whenBelow: '当积分余额低于', choosePack: '选择自动充值套餐', consent: '我授权 Voko 在余额达到阈值时自动购买所选套餐。除法律另有规定外,购买后不可退款。', terms: '使用条款', continueSecurely: '安全设置支付方式', save: '保存设置', disable: '关闭自动充值', setupComplete: '支付方式已保存,自动充值已开启。', setupCanceled: '已取消自动充值设置。', actionRequired: '上次自动充值失败,请重新设置支付方式后恢复。', howTitle: '自动充值如何运作', howBalance: '积分余额达到或低于阈值时,系统会自动购买所选套餐。', howSecure: '支付信息由 Stripe 安全收集和保存,Voko 不会保存你的银行卡号。', howCancel: '你可以随时关闭自动充值;关闭不会退还此前的购买。' }, + nav: { play: '对局', notes: '线索', state: '状态', editor: '编排' }, + scenario: { title: '关卡', available: '可用关卡', assetManifest: '素材清单' }, + character: { title: '角色', start: '开始对局', current: '当前角色' }, + stage: { empty: '选择关卡和角色', turn: '回合 {turn}', ending: '结局', transcript: '记录' }, + composer: { act: '行动', say: '对白', placeholder: '输入行动或对白' }, + actions: { refresh: '刷新', start: '开始', home: '首页', backHome: '返回', newRun: '重开', send: '发送', save: '保存', compile: '编译', publish: '发布', rollback: '回滚', upload: '上传', select: '选择', endingText: '结局文本', load: '载入', backToGame: '返回', cancel: '取消', confirm: '确认' }, + account: { + signIn: '登录', + signedIn: '已登录', + signedOut: '已退出登录', + sessionExpired: '登录已过期,请重新登录。', + logoutFailed: '退出登录失败,请稍后重试。', + registered: '注册成功', + email: '邮箱', + emailPlaceholder: '邮箱地址', + password: '密码', + passwordPlaceholder: '输入密码', + confirmPassword: '确认密码', + confirmPasswordPlaceholder: '再次输入密码', + verificationCode: '验证码', + verificationCodePlaceholder: '输入 6 位邮箱验证码', + inviteToken: '邀请码', + inviteTokenPlaceholder: '输入 8 位邀请码', + sendCode: '发送验证码', + resendCode: '重新发送', + codeSent: '验证码已发送,请查看邮箱。', + codeRequired: '请先输入邮箱验证码。', + inviteInvalid: '请输入有效的 8 位邀请码。', + emailRequired: '请先输入邮箱。', + emailInvalid: '请输入有效的邮箱地址。', + passwordRequired: '请先输入密码。', + confirmPasswordRequired: '请先确认密码。', + loginFieldsRequired: '请填写邮箱和密码。', + inviteEmailMismatch: '该邮箱与邀请码绑定的申请邮箱不一致。', + passwordMismatch: '两次输入的密码不一致。', + createAccount: '创建账号', + hasAccount: '已有账号?', + noAccount: '还没有账号?', + termsHint: '继续即表示你确认该邮箱归你所有,并同意账号用于保存进度、订单和积分。', + login: '登录', + register: '注册', + reset: '重置密码', + forgotPassword: '忘记密码?', + resetPassword: '重置密码', + resetTitle: '重置密码', + resetBody: '输入邮箱并验证验证码后设置新密码。重置成功后,该账号的旧会话会全部失效。', + resetCodeSent: '重置验证码已发送,请查看邮箱。', + resetFieldsRequired: '请填写邮箱、验证码、新密码和确认密码。', + passwordResetDone: '密码已重置,请使用新密码登录。', + logout: '退出登录', + logoutAll: '退出全部设备', + credits: '积分', + buy: '购买积分', + billing: '账务', + promptTitle: '登录或注册后开始', + promptBody: '账号会保存进度、存档、结局图鉴、积分和订单;换设备也能继续。', + signedInBody: '你的进度、存档和积分会写入数据库。', + registerAndStart: '注册并选择角色', + guestStart: '游客试玩', + savedHint: '登录后自动保存进度和存档', + currentAccount: '当前账号', + saveKicker: '账号保存', + saveTitle: '保存进度和订单', + loginTitle: '登录', + loginBody: '登录后会尝试合并当前设备游客进度;如果没有可合并内容,会直接进入账号状态。', + registerTitle: '创建账号', + registerBody: '邀请码注册:输入邀请码并验证邮箱归属后创建账号。注册成功后会自动登录、赠送 100 积分,并合并当前设备进度。', + openBilling: '查看积分与订单', + mergePending: '正在合并本机游客进度…', + mergeSuccess: '已合并本机数据:{details}。', + mergeSeparator: '、', + mergeRuns: '{count} 个对局', + mergeTurns: '{count} 个回合', + mergeProgress: '{count} 条进度', + mergeSaveSlots: '{count} 个存档', + mergeEndingRecords: '{count} 个结局', + mergeEvents: '{count} 条事件', + mergeAgentCalls: '{count} 条 AI 调用', + mergeBillingLedger: '{count} 条积分流水', + mergeBillingUnlocks: '{count} 条付费解锁', + mergeBillingFreeQuota: '{count} 条免费额度', + mergePaymentOrders: '{count} 个订单', + mergeEmpty: '没有可合并的本机进度。', + mergeFailed: '账号已登录,本机进度暂未合并。请稍后刷新重试。', + }, + billing: { + title: '积分与订单', + subtitle: '管理积分余额、充值记录和消费明细。', + guestSubtitle: '游客可以查看积分包;购买、余额、订单和流水需要登录后继续。', + balance: '当前余额', + products: '积分包', + ledger: '积分流水', + orders: '订单', + refresh: '刷新', + testModeTitle: 'Stripe 测试环境', + testModeBody: '当前结账使用测试支付,不会产生真实扣款。', + testPurchase: '测试购买', + buy: '购买 {points} 积分', + checkoutReady: '结账链接已生成。', + openCheckout: '打开结账页', + selectedPack: '已选择', + selectedProduct: '已选择 {title},账户余额将增加 {points} 积分。', + signInToBuy: '登录后购买', + readyToBuy: '充值积分', + readyToBuyBody: '选择适合的积分包,完成结账后积分将自动到账。', + checkoutPending: '正在跳转至结账页…', + checkoutReturnedSuccess: '已返回应用,正在刷新余额和订单;入账结果以订单状态为准。', + checkoutReturnedCancel: '结账已取消或未完成,未确认入账。', + signInHint: '登录后可查看余额、订单和流水。', + emptyProducts: '暂无积分包。', + emptyLedger: '暂无流水。', + emptyOrders: '暂无订单。', + productFallback: '积分包', + points: '{points} 积分', + price: '{amount} {currency}', + ledgerAmount: '{amount} 积分', + orderStatus: '状态 {status}', + orderStatusPending: '处理中', + orderStatusPaid: '已支付', + orderStatusRefunded: '已退款', + orderStatusDisputed: '拒付处理中', + orderStatusUnknown: '状态未知', + ledgerReasonSignupGift: '注册赠送', + ledgerReasonCheckout: '积分购买', + ledgerReasonRefund: '订单退款', + ledgerReasonChargeback: '拒付扣回', + ledgerReasonUnlock: '内容解锁', + paywallTitle: '付费解锁', + paywallTarget: '目标', + paywallReason: '扣费原因', + paywallGenericReason: '继续该内容需要积分。', + paywallFreeContinue: '当前还有免费额度,可以继续推进。', + paywallInsufficient: '当前积分不足,请先购买积分。', + paywallPrice: '需要积分', + paywallFreeQuota: '剩余免费额度', + paywallBalance: '可用余额', + }, + endings: { + title: '结局图鉴', + unlocked: '已解锁', + locked: '未解锁', + collected: '已收集 {count}/{total}', + lockedHint: '达成对应结局后即可解锁。', + }, + saves: { + title: '存档', + progress: '当前进度', + activeRun: '当前游戏', + slot: '存档 {slotNo}', + savedRun: '已保存进度', + emptySlot: '空存档位', + emptyHint: '保存当前 run 后可跨刷新恢复。', + saveHere: '保存', + overwrite: '覆盖存档', + restore: '读取', + delete: '删除', + saved: '已保存存档。', + restored: '已读取存档。', + deleted: '已删除存档。', + conflict: '已有存档。确认后才会覆盖这个回滚点。', + restoreConfirm: '读取后会把当前游戏切换到这个存档点。', + deleteConfirm: '删除后这个存档点不能恢复。', + noRunHint: '当前没有可保存的 run。请先开始或继续一局。', + }, + map: { + title: '地图', + current: '当前位置', + route: '可前往', + routes: '可去地点', + locked: '未解锁', + moveTo: '前往{location}', + objects: '可调查对象', + inspect: '调查', + inspected: '已调查', + inspectObject: '查看{object}', + }, + videoCenter: { + eyebrow: '玩家游玩记录', + title: '影像馆', + backHome: '返回', + chooseScenario: '选择剧本', + allScenarios: '全部剧本', + searchScenario: '搜索剧本', + scenarioPaintedSkin: '画皮', + scenarioGuian: '归安县失心案', + nowPlaying: '正在播放', + player: '玩家', + views: '观看', + ending: '结局', + duration: '时长', + libraryEyebrow: '同剧本玩家影像', + allVideos: '全部影像', + sort: '视频排序', + recommended: '推荐', + latest: '最新', + videoMeta: '{player} · {date}', + emptyTitle: '暂无玩家影像', + emptyText: '这个剧本还没有公开的游玩视频。', + viewAll: '查看全部玩家影像', + discoverStories: '看看其他人的选择', + backPrevious: '返回上一页', aiSummary: 'AI 摘要:', endingCount: '种结局', publicSeriesCount: '个公开系列', totalPlays: '总播放', seriesList: '玩家系列', completed: '已完结', updating: '更新至第 {count} 集 · 追更中', seriesMetrics: '共 {episodes} 集 · ▶ {plays} · ♥ {likes}', watchSeries: '观看这个系列', playOwnSeries: '玩出自己的系列', minutes: '{count} 分钟', share: '分享', shareCopied: '链接已复制', shareFailed: '分享失败,请重试', like: '点赞', liked: '已点赞', save: '收藏', saved: '已收藏', storyHighlight: '故事看点', startPlay: '开始游玩 · 改写这个结局', episodeList: '剧集列表', episodeTotal: '全 {count} 集', episodeTitle: '第 {count} 集 · {title}', + }, + runHistory: { + eyebrow: '个人游玩记录', + title: '我的对局', + allRuns: '全部对局', + count: '{count} 条记录', + empty: '暂无游玩记录。开始一局后会出现在这里。', + loadFailed: '加载失败:{error}', + noTime: '时间未知', + statusActive: '进行中', + statusCompleted: '已结束', + videoNone: '暂无视频', + videoQueued: '视频排队中', + videoRunning: '视频生成中', + videoReady: '视频已生成', + videoFailed: '视频失败', + videoRetry: '重新生成', + untitledEnding: '未达成结局', + meta: '{scenario} · 回合 {turn} · {date}', + openRun: '打开对局', + openNotes: '查看笔记', + watchVideo: '播放视频', + }, + mobileProfile: { title: '我的', settings: '设置', content: '内容管理', benefits: '账户权益', creator: '创作中心', support: '咨询与支持', contact: '联系客服', terms: '使用条款', privacy: '隐私政策', social: '社交媒体' }, + profileEditor: { editProfile: '编辑个人信息', nameSection: '个人名称', nameHint: '这个名字会展示在你的游戏和视频旁。', name: '名字', saveName: '保存名字', nameRequired: '请输入名字', nameSaved: '名字已更新', passwordSection: '修改密码', passwordHint: '通过发送到绑定邮箱的验证码确认身份。', email: '邮箱', verificationCode: '邮箱验证码', sendCode: '发送验证码', codeSent: '验证码已发送到你的邮箱', newPassword: '新密码', confirmPassword: '确认新密码', savePassword: '修改密码', passwordRequirements: '请输入邮箱验证码,并设置至少 8 位的新密码', passwordMismatch: '两次输入的密码不一致', passwordSaved: '密码已修改' }, + myGames: { recent: '最近游玩', popular: '热门剧本', emptyRecent: '最近游玩的剧本会显示在这里', loadError: '游戏加载失败,请稍后重试' }, + storyDetail: { title: '剧本详情', description: '剧本简介', episodes: '{count} 个章节', endings: '{count} 个结局', by: '作者:{name}', lastUpdate: '最近更新', newPlay: '开始游玩', notFound: '没有找到这个剧本。', loadError: '剧本加载失败,请稍后重试。' }, + accountCenter: { loadFailed: '个人中心加载失败', loadTimeout: '请求超时,请检查网络后重试', title: '个人中心', eyebrow: '玩家档案', runs: '我的游戏', videos: '视频合集', favorites: '我的收藏', watchSeason: '观看本季', emptyGames: '还没有开始任何游戏。', emptyVideos: '还没有生成的视频。', emptyFavorites: '还没有收藏的视频。', lastPlayed: '最近游玩 · {date}', currentTurn: '进行中 · 第 {turn} 回合', noActiveRun: '暂无进行中的对局', endingsUnlocked: '结局 {unlocked}/{total}', runsPlayed: '累计 {count} 局', continueGame: '继续游戏', startGame: '开始游戏', viewHistory: '查看历史', levelProgress: '已游玩 {played}/{total} 关', viewSeasonDetails: '查看本季详情', hideSeasonDetails: '收起本季详情', levelInProgress: '进行中', levelCompleted: '已完成', endingLabel: '达成结局', cluesLabel: '获得线索', clueCount: '{count} 条线索', viewRunRecords: '查看游戏记录', runRecordSummary: '{clues} 条线索 · {ending} 个结局', noEnding: '尚未达成结局', noClues: '尚未获得线索', seasonDetailsLoadFailed: '本季详情加载失败,请重试', public: '公开展示', private: '仅自己可见', deleteVideo: '删除视频', confirmDelete: '确认删除', signInTitle: '请先登录', signInBody: '登录后即可管理游戏进度和视频。' }, + visual: { + eyebrow: 'TRPG Visual', + chooseScript: '选择剧本', + actTitle: '第{count}幕 · {title}', + actLocked: '{title}尚未解锁', + actSealed: '前幕未终', + toBeContinued: '待续', + finishPreviousAct: '触发前一幕结局后开启', + revealAfterAct: '完成第{count}幕后揭晓', + moreScripts: '更多关卡', + comingSoon: '敬请期待', + watchScriptVideo: '观看剧本视频', + scenarioKicker: '剧本', + hiddenTitle: '田又', + revealedTitle: '画皮', + objectives: '当前目标', + playHistory: '游玩记录', + objectiveLabel: '任务目标', + close: '关闭', + noObjectives: '暂无进行中的任务。', + taskProgress: '步骤 {done}/{total}', + entryKicker: '进入游戏', + entryTitle: '今晚入屋', + emptyScenarioEyebrow: '剧本未就绪', + emptyScenarioTitle: '暂无可进入剧本', + emptyScenarioText: '服务器没有返回已发布剧本。请刷新或联系运营检查发布状态。', + emptyScenarioActionTitle: '等待发布', + emptyScenarioActionText: '没有真实剧本数据时不会启动本地伪剧本。', + continueTitle: '继续上次对局', + entryBody: '先选角色进入旧屋。', + chooseCharacter: '选择角色', + enterStory: '进入故事', + back: '返回', + skip: '跳过', + editor: '编辑器', + debugPage: '调试页', + state: '状态', + settings: '设置', + language: '语言', + languageUnavailable: '该关卡当前没有可用的对应语言译文。', + turnVideoMatching: '智能 CG 匹配', + turnVideoMatchingHint: '开启后会用相似剧情片段匹配本回合 CG;关闭时只播放精准绑定的 CG。', + turnVideoMatchingOn: '已开启', + turnVideoMatchingOff: '已关闭', + exitGame: '退出本局', + exitGameHint: '退出当前游戏,返回首页。', + saveAndExit: '保存并退出', + exitWithoutSaving: '直接退出', + notes: '笔记', + saves: '存档', + continueRun: '继续对局', + balanceEntry: '余额 {credits}', + endingCollection: '结局图鉴', + narrator: '旁白', + npcName: '屋中女子', + npcLine: '“{line}”', + notStarted: '未开始', + startHint: '点击开始进入旧屋。', + debug: '调试数值', + debugOnly: '仅调试用', + waiting: '等待开始。', + modeAct: '行', + modeSay: '言', + inputMethod: '输入方式', + textInputMode: '文字输入', + voiceInputMode: '语音输入', + choiceGroupLabel: '剧情分支选择', + inputPlaceholder: '例如:我拿起铜镜,照向她和墙上的美人图。', + mobileInputPlaceholder: '输入对话或行动…', + voiceStart: '开始语音输入', + voiceStop: '停止语音输入', + voiceListening: '正在聆听…', + voiceRecognizing: '正在识别…', + voiceUnsupported: '当前浏览器不支持语音输入', + voicePermissionDenied: '无法使用麦克风,请检查浏览器权限', + voiceMicrophoneUnavailable: '未检测到可用的麦克风', + voiceNoSpeech: '未识别到语音,请重试', + voiceNoAudioCaptured: '未采集到麦克风声音,请检查浏览器权限', + voiceRecognitionFailed: '语音识别失败,请重试', + voiceHoldToTalk: '按住说话', + voicePreparing: '正在准备麦克风…', + voiceShortcutLabel: '快捷键', + voiceReleaseSend: '松开发送', + voiceSlideCancel: '上滑取消', + voiceReleaseCancel: '松开取消', + voiceUnderstanding: '正在理解你的指令…', + ending: '结局', + endingLabel: '结局', + endingLine: '结局:{title}\n{description}', + endingSummary: '结局总结', + endingPoem: '定场诗', + endingFallbackPoem: '……', + endingBackgroundLoadFailed: '结局画面加载失败,请刷新后重试。', + recordSeal: '案', + recordTitle: '捕快笔录', + recordKicker: '县衙案牍', + recordMeta: '夜雨后 · 破屋验记', + continue: '继续', + enterNextEpisode: '进入下一关', + viewCurrentSeasonStory: '查看本季故事线', + played: '已播放', + nextPage: '下一页', + statusCreating: '正在准备故事……', + statusStarted: '已开始。输入一句话或一个动作。', + statusHistoryRun: '正在查看历史对局,继续游戏仍以服务器进度为准。', + statusKp: 'KP 正在判定玩家行动...', + statusDone: '回合完成。', + contentFiltered: '这个行动无法继续描述,请换一种方式行动。', + turnFailed: '这一步没能完成,请重试。', + audioFailed: '语音播放失败,请重试。', + actionRejected: '这个行动暂时无法成立,请换一种方式试试。', + statusEnded: '游戏已结束。', + statusEndingText: '正在生成结局文字...', + statusEndingLocked: '结局已判定,正在进入结局...', + statusNpcAction: 'NPC 正在尝试行动,等待 KP 判定...', + statusNpcDone: 'NPC 已回应。', + clueGained: '获得线索', + landscapeTitle: '请横屏游玩', + landscapeBody: '横屏可以完整显示剧情画面和 CG,并获得更好的操作体验。', + stats: { + suspicion: '怀疑', + charm: '魅惑', + fear: '恐惧', + }, + guide: { + label: '新手引导', + skip: '跳过', + next: '下一步', + done: '知道了', + optionsTitle: '直接选择', + optionsText: 'NPC 回应后,下方会出现两个建议选项。你可以直接点“言”或“行”,快速推进这一回合。', + keyboardTitle: '切换到键盘输入', + keyboardText: '默认使用语音输入。点击键盘按钮,也可以自己输入想说的话或想做的事。', + inputTitle: '切换“言 / 行”', + inputText: '输入前可以选择“言”或“行”。说什么、做什么都会交给 KP 判定。', + notesTitle: '查看笔记', + notesText: '右侧的笔记按钮会记录已经发现的线索和已经解锁的结局。想整理信息时,随时可以打开。', + goalTitle: '活下来', + goalText: '这不是单纯聊天。你的目标是活下来:听她说话,也要观察屋子、线索和异常。', + videoTitle: '完成故事后,你的这次冒险将变成一段专属影片', + videoExampleLabel: '示例视频', + videoExampleNotice: '以下仅为效果示例,不代表你本次游玩生成的内容。', + videoLoading: '正在加载示例影片……', + videoUnavailable: '该剧本暂无可播放的示例影片。', + videoDone: '开始我的故事', + }, + openingOptions: { + say: '对不起,我不是有意闯进来的,外面有东西在追我。', + act: '照她的话压低声音,先观察门窗、油灯和她的位置。', + }, + }, + panels: { clues: '线索', conclusions: '结论', info: '信息', inventory: '物品', events: '事件', stats: '数值', flags: '标记', objects: '物件', npcs: 'NPC', endingRecords: '已解锁结局' }, + standalone: { + summary: '概览', + availableClues: '可得知线索', + recentTurns: '最近回合', + audit: { + title: 'AI 审计', + subtitle: 'KP、NPC、结局和 TTS 调用的成本与失败记录。', + totalCalls: '调用', + failedCalls: '失败', + tokens: '输入/输出 token', + cost: '成本', + roles: '角色汇总', + models: '模型汇总', + recentCalls: '最近调用', + empty: '暂无 AI 调用记录。', + calls: '{count} 次', + tokenPair: 'token {tokens}', + }, + state: { + title: '状态可视化', + waiting: '等待载入 RunState。', + noRun: '没有 runId。请从游戏页面进入,或手动输入 runId。', + loading: '正在载入 {runId}...', + loaded: '已载入 {runId}', + }, + notes: { + title: '笔记', + waiting: '正在载入笔记。', + noRun: '没有找到当前存档。请先从游戏页开始或继续一局。', + loading: '正在载入 {runId}...', + loaded: '已载入 {runId}', + unavailable: '暂时无法读取这局游戏的笔记。', + emptyClues: '暂无线索', + emptyConclusions: '暂无结论', + emptyEndings: '暂无结局', + tabs: '笔记分类', + list: '条目列表', + detail: '条目详情', + locked: '未解锁', + lockedHint: '这个结局还没有被记录。', + recorded: '已记录', + importance: { minor: '次要', major: '重要', critical: '关键' }, + relatedClues: '相关线索', + noRelatedClues: '还没有发现支撑这个结论的线索。', + }, + }, + editor: { + title: '剧本编排', + pack: '剧本包', + section: '段落', + raw: 'JSON', + structured: '结构化编辑', + debugJson: '调试 JSON', + item: '条目', + noStructuredFields: '这个段落暂时没有可直接编辑的结构化字段。', + updated: '已保存', + assetUploaded: '资源已上传到草稿', + compiled: '已编译草稿', + published: '已发布版本', + rolledBack: '已回滚到历史版本', + versions: '版本', + assets: '资源索引', + current: '当前', + currentVersion: '当前版本', + noPublishedVersion: '未发布', + noVersions: '暂无版本。先编译生成草稿。', + noAssets: '暂无资源索引。先编译资源清单。', + noFilteredAssets: '没有符合筛选条件的资源。', + assetSearch: '搜索', + assetSearchPlaceholder: '标签、文件或 objectKey', + assetTypeFilter: '类型', + assetSourceFilter: '来源', + assetFilterAll: '全部', + assetFilterResult: '{shown}/{total}', + assetSource: { + uploaded: '上传对象', + static: '静态文件', + missing: '缺少定位', + }, + uploadFile: '文件', + uploadTag: '标签', + uploadTagPlaceholder: 'opening_cg', + uploadType: '类型', + noUploadFile: '未选择文件', + }, + waitingList: { + eyebrow: '抢先体验', + apply: '申请体验', + useInvite: '使用邀请码', + invitedRegister: '已有邀请?创建账号', + title: '加入 voko.world 候补名单', + body: '留下邮箱。获得抢先体验资格后,我们会向你发送专属邀请。', + displayName: '姓名', + displayNamePlaceholder: '选填', + useCase: '使用场景', + useCaseOptional: '使用场景(可选)', + useCasePlaceholder: '团队测试、创作者试用、活动开服', + useCaseRequired: '请填写使用场景,例如团队测试、创作者试用或活动开服。', + consent: '我同意接收体验邀请和相关产品邮件。', + join: '加入候补名单', + joined: '申请已收到,请等待邮箱通知。', + joinedTitle: '申请已提交', + changeEmail: '更换邮箱', + accountAlreadyRegistered: '该邮箱已经注册,请直接登录。', + emailRequired: '请填写邮箱。', + consentRequired: '加入名单前需要确认邮件授权。', + inviteEyebrow: '邀请', + inviteTitle: '创建 voko.world 账号', + inviteBody: '该邀请已绑定你的邮箱。设置密码后即可完成注册。', + inviteMissing: '缺少邀请 token。', + inviteEnterCode: '输入你收到的 8 位邀请码,验证后即可创建账号。', + inviteValidate: '验证邀请码', + inviteReenter: '重新输入邀请码', + inviteInvalid: '邀请无效或已过期。', + codeSent: '验证码已发送。', + passwordMismatch: '两次输入的密码不一致。', + acceptInvite: '注册账号', + }, + landing: { officialVideo: '观看官网视频', officialVideoEyebrow: '认识 VOKO', officialVideoTitle: '你的世界正在呼唤。', officialVideoBody: '创造自己的世界,进入会再次呼唤你的故事,并把每个瞬间分享成值得观看的影像。', homeLabel: 'voko.world 主页', navHome: 'Home', navScripts: '剧本', navVideos: '短剧', featuredStories: '精选剧本', featuredStoriesEyebrow: '开始游玩', endingCount: '{count} 个结局', allStories: '全部故事', dramaTitle: '短剧', brandSub: '互动叙事', navLabel: '主导航', navStories: '剧游', navGallery: '影像馆', eyebrow: 'AI 实时互动影游', loadError: '首页内容加载失败,请重试。', retry: '重新加载', emptyGames: '暂无已发布的剧游。', scenarioFallback: '进入这个世界,用你的选择改写故事。', publicVideos: '玩家影像', publicGames: '公开剧游', watchDrama: '观看短剧', enterPlay: '开始游玩', comingSoon: '敬请期待', carouselLabel: '剧游轮播', previousGame: '上一个剧游', nextGame: '下一个剧游', showcaseTitle: '玩家创造的故事', showcaseBody: '观看玩家从不同选择中走出的结局,再进入剧游创造你的版本。', searchLabel: '搜索故事', searchPlaceholder: '搜索剧游或关卡名称', sortLabel: '内容排序', latest: '最新', popular: '热门', videoCount: '{count} 条影像', levelCount: '共 {count} 个关卡', levelNumber: '第 {count} 关', singleLevel: '独立关卡', publicGame: '公开剧游', noSearchResults: '没有找到相关故事', clearSearch: '清除搜索', finalEyebrow: '你的故事正在等待', finalTitle: '你会成为谁?', finalBody: '跨过门槛,说出你的第一句话。', begin: '开启旅程', footer: '© 2026 voko.world', earlyAccess: '抢先体验', footerSocial: '社区', footerLegal: '协议与联系', privacy: '隐私协议', terms: '服务协议', contact: '联系邮箱' }, + landingIntro: { title: 'voko.world', apply: '申请体验', watchVideo: '观看视频', start: '开始游戏', continue: '继续游戏', viewJourney: '游玩历程', continueProgress: '第 {season} 季 {scenario}', storyLine: '故事线', viewMore: '查看更多' }, + landingCarousel: { introEyebrow: '生成式互动短剧游戏', introSummary: '先观看短剧,再进入角色,用每一次选择改写故事走向。', dramaEyebrow: '精选短剧 {count}', controls: '首页轮播', goToSlide: '切换到第 {count} 张' }, + landingCinema: { navHome: '首页', navDramas: '游戏', navPlayerStories: '短剧', navMy: '我的', featuredDramas: '游戏', playerStories: '短剧' }, + searchPage: { placeholder: '搜索故事、短剧或作者', popular: '热门搜索', history: '搜索历史', clearHistory: '清空', resultTypes: '搜索结果类型', all: '全部', accounts: '作者', accountStats: '{titles} 部作品 · {plays} 热度' }, + storyTree: { + eyebrow: '季节线路', + subtitle: '每一条线代表一季,每一个节点代表一关。', + root: '剧本', + branch: '分支', + active: '进行中', + completed: '已完成', + locked: '未到达', + empty: '还没有可展示的季节线路。', + loadError: '季节线路加载失败。', + navigation: '季节范围', + range: '第 {first}–{last} 季 · 共 {total} 季', + newer: '上一组', + older: '下一组', + startPlaying: '开始游玩', + turnProgress: '当前第 {turn} 回合', + continuePlaying: '继续游玩', + playLog: '游玩记录', + seasonEnding: '本季结局', + currentSeason: '当前季', + logLoadError: '游玩记录加载失败。', + logEmpty: '这一季还没有游玩记录。', + turnNumber: '第 {turn} 回合', + playerSaid: '你说', + playerDid: '你做了', + storyOpening: '故事开场', + turnVideos: '匹配视频', + playActionVideo: '动作视频', + playPlayerVideo: '玩家视频', + playNpcVideo: 'NPC 视频', + replayTurn: '回放', + replayTurnTitle: '第 {turn} 回合完整回放', + replayProgress: '第 {current}/{total} 段', + actionSegment: '动作片段', + playerSpeechSegment: '玩家发言', + npcSpeechSegment: 'NPC 发言', + pauseReplay: '暂停', + continueReplay: '继续播放', + restartReplay: '重新播放', + exportReplay: '导出完整回放', + replayQueued: '等待导出', + replayExporting: '正在导出', + playFullReplay: '播放完整回放', + retryReplayExport: '重新导出', + downloadReplay: '下载 MP4', + downloadSegment: '下载此片段', + downloadTurn: '下载本回合完整视频', + turnExporting: '正在生成回合视频', + turnExportError: '回合视频导出失败。', + segmentExporting: '正在生成片段', + segmentExportError: '片段导出失败。', + fullReplayTitle: '{title} · 完整回放', + replayExportError: '完整回放导出失败。', + videoLoadError: '视频加载失败。', + tools: '故事线搜索、筛选和排序', + search: '搜索', + searchPlaceholder: '搜索关卡、结局或最近行动', + filter: '状态', + filterAll: '全部', + filterInProgress: '进行中', + filterCompleted: '已完成', + sort: '排序', + sortRecent: '最近游玩', + sortSeasonDesc: '季数从高到低', + sortSeasonAsc: '季数从低到高', + resultCount: '共 {count} 季', + noResults: '没有符合条件的故事线。', + canvasHint: '拖动画布或滚动查看右侧关卡', + canvasLabel: '可横向平移的故事树画布', + storySwitch: '剧本', + }, + storyTreeCurrentSeasonHint: '当前最新进度', + storyTreeViewDetails: '查看第 {number} 季详情', + storyTreeDelete: { action: '删除分支', confirm: '确定删除第 {number} 季及其派生的所有分支吗?删除后无法恢复。', deleting: '正在删除…', error: '分支删除失败。' }, + storyTreeVideoUnavailable: '暂无影像', + storyTreeFork: { action: '从这里重新游玩', confirm: '将从第 {turn} 回合创建新的故事分支,当前故事记录不会被修改。', failed: '新故事分支创建失败,请稍后重试。', creating: '正在创建…' }, + runVideoGeneration: { title: '生成完整视频', description: '将本局内容生成完整视频。确认后扣除积分,生成失败会自动返还。', generate: '生成视频', regenerate: '重新生成', retry: '再次生成', queued: '等待生成', running: '生成中', price: '所需积分', balance: '当前余额', balanceAfter: '扣除后余额' }, + landingShowcase: { title: '众生演绎', body: '观看不同玩家走出的命运,再进入剧本,写下属于你的结局。' }, + landingMetrics: { playCount: '播放量', likeCount: '点赞量' }, + landingCard: { level: '关卡', searchPlaceholder: '想看什么?搜故事、视频或玩家', scriptFilter: '剧本', scriptAll: '全部剧本', playerVideoCount: '{count} 个公开视频', publishedAt: '发布时间', publishedAll: '全部', publishedToday: '今天', publishedWeek: '近 7 天', publishedMonth: '近 30 天', expandSynopsis: '展开', collapseSynopsis: '收起' }, + playerPage: { loadError: '玩家视频加载失败。', notFound: '未找到该玩家,或该玩家暂无公开视频。', searchPlaceholder: '搜索剧、关卡或季标题' }, + season: { choose: '选择一季', chooseBody: '当前关卡将接续到你选择的季中。', number: '第 {number} 季', empty: '请先在某一季中完成上一关,再继续当前关卡。' }, + status: { loading: '加载中', empty: '暂无数据', error: '加载失败', streaming: '推演中' }, + storiesPage: { tags: '剧本标签', allTags: '全部', swipeForNextStory: '向上滑动查看更多' }, + creator: { nav: '创作', queued: '排队中', attachmentTypeError: '仅支持上传一个 TXT、PDF、Word、PNG、JPG 或 WEBP 文件。', attachmentSizeError: '附件大小不能超过 10 MB。', attachmentReadError: '附件读取失败。', generationFailed: '生成失败', reviewRejected: '审核未通过', published: '已发布', archived: '已归档', composerTitle: '今天想创造一个怎样的世界?', submitIdea: '提交创意', examplesNote: '以下为站内真实已发布游戏,仅用于查看列表效果。', exampleStatus: '已发布示例', projectsEyebrow: 'Visual Game 创作中心', projectsTitle: '我的游戏', projectsBody: '管理你创建的游戏、查看生成进度与审核状态。', newGame: '创建新游戏', draftStatus: '草稿', continueEditing: '继续编辑', generatedScopes: '项生成内容', emptyTitle: '开始创作第一个游戏', emptyBody: '输入一个想法、剧本或世界观,创作系统会把它变成可以游玩的游戏。', eyebrow: 'Visual Game 创作中心', title: '把你的故事,变成一个可以进入的世界', subtitle: '提交创意、剧本或世界观设定。创作系统将完成游戏设计、素材生产、全路径校验,并自动进入平台审核。', stepIdea: '填写创意', stepGenerate: '生成游戏', stepReview: '平台审核', ideaTitle: '描述你想创作的游戏', ideaBody: '使用自然语言即可,不需要理解 Pack、JSON、状态机或规则 ID。', ideaLabel: '游戏创意或剧本', ideaHint: '建议包含世界、人物、矛盾,以及你希望玩家获得的体验。', ideaPlaceholder: '一名旅人在暴雨夜醒来,客栈里的每个人都记得一个不同版本的昨夜……', ideaError: '请至少输入 20 个字的创作内容。', languageLabel: '游戏语言', languages: { zh: '中文', en: '英语', ja: '日语', ko: '韩语' }, scopeTitle: '本次生成内容', scopeStory: '故事与关卡', scopeStoryBody: '人物、任务、状态和结局', scopeVisuals: '视觉素材', scopeVisualsBody: '角色、场景、道具和视频', scopeVoice: '角色音色', scopeVoiceBody: '玩家、NPC 与结局旁白', scopeError: '请至少选择一项生成内容。', assetsTitle: '上传参考素材', assetsBody: '可选。参考素材有助于保持人物和世界的一致性。', uploadAction: '添加参考文件', uploadHint: '单个附件 · TXT、PDF、Word 或图片 · 最大 10 MB', supportedTitle: '支持的素材类型', supportedBody: '人物、场景、道具与画风图片;剧本和世界观文档;视频或音频参考。', removeFile: '移除 {name}', saved: '草稿已保存在当前设备。', draftNote: '第一版先提供本地草稿保存,下一步接入正式生成任务。', save: '保存草稿' }, + labels: { scenario: '关卡', asset: '素材', mode: '模式', phase: '阶段', location: '地点', noRun: '未开始', none: '无' }, +} diff --git a/packages/i18n/src/runtime.js b/packages/i18n/src/runtime.js new file mode 100644 index 0000000..124e073 --- /dev/null +++ b/packages/i18n/src/runtime.js @@ -0,0 +1,69 @@ +/** + * runtime.js —— 只装配 vue-i18n(设 fallback='en')+ setLocale/getLocale。 + * 不读 env、不发网络;locale 初值由调用方注入。 + */ +import { createI18n as createVueI18n } from 'vue-i18n' +import { messages } from './messages/index.js' +import { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_META, isSupportedLocale } from './locales.js' + +/** + * @typedef {import('./locales.js').LocaleCode} LocaleCode + * @typedef {import('./locales.js').LocaleMeta} LocaleMeta + */ + +/** + * @typedef {object} CreateI18nOptions + * @property {LocaleCode} [locale] 初值(注入);缺省 `'en'`。非法值回落 `'en'`。 + * @property {LocaleCode} [fallbackLocale] 缺省 `'en'`(英语第一公民)。 + */ + +/** + * @typedef {object} I18nRuntime + * @property {import('vue-i18n').I18n} i18n vue-i18n 实例,`app.use(i18n)`。 + * @property {(code: LocaleCode) => void} setLocale 切换当前 locale(非法值回落 fallback)。 + * @property {() => LocaleCode} getLocale 读当前 locale(供 api-sdk 取 `x-locale`)。 + * @property {LocaleMeta[]} locales 语言元数据数组(供渲染切换器)。 + */ + +/** + * 唯一入口工厂:装配 vue-i18n 运行时。 + * @param {CreateI18nOptions} [opts] + * @returns {I18nRuntime} + */ +export function createI18n(opts = {}) { + const fallbackLocale = isSupportedLocale(opts.fallbackLocale) + ? opts.fallbackLocale + : DEFAULT_LOCALE + const initialLocale = isSupportedLocale(opts.locale) ? opts.locale : fallbackLocale + + const i18n = createVueI18n( + /** @type {any} */ ({ + legacy: false, // Composition API 模式 + locale: initialLocale, + fallbackLocale, // 缺失 key 自动回落 en,不显示原始 key、不抛错 + fallbackWarn: false, + missingWarn: false, + messages, + availableLocales: SUPPORTED_LOCALES, + }), + ) + + // legacy:false 下 `i18n.global.locale` 是一个 ref;vue-i18n 重载类型在 + // checkJs 下无法精确推断该分支,故对 global 取一次窄化引用集中处理。 + const composer = /** @type {{ locale: { value: LocaleCode } }} */ ( + /** @type {unknown} */ (i18n.global) + ) + + /** @param {LocaleCode} code */ + function setLocale(code) { + const next = isSupportedLocale(code) ? code : fallbackLocale + composer.locale.value = next + } + + /** @returns {LocaleCode} */ + function getLocale() { + return composer.locale.value + } + + return { i18n, setLocale, getLocale, locales: LOCALE_META } +} diff --git a/packages/i18n/test/coverage.test.js b/packages/i18n/test/coverage.test.js new file mode 100644 index 0000000..0ef6153 --- /dev/null +++ b/packages/i18n/test/coverage.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { computeCoverage, flattenKeys } from '../scripts/check-coverage.js' +import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../src/index.js' + +describe('@drama/i18n 覆盖率 / key 树同构', () => { + it('只暴露产品支持的 6 种语言', () => { + expect(SUPPORTED_LOCALES).toEqual(['en', 'zh', 'ja', 'ko', 'es', 'pt']) + }) + + it('en 基准非空且零缺失(硬门禁)', () => { + const { baseKeyCount, perLocale } = computeCoverage() + expect(baseKeyCount).toBeGreaterThan(0) + expect(perLocale[DEFAULT_LOCALE].missing).toEqual([]) + }) + + it('任何语言都不得含 en 没有的私有 key(防 key 树漂移,硬失败)', () => { + const { perLocale } = computeCoverage() + for (const code of SUPPORTED_LOCALES) { + expect(perLocale[code].extra, `${code} 出现私有 key`).toEqual([]) + } + }) + + it('flattenKeys 正确拍平嵌套', () => { + const keys = flattenKeys({ a: { b: 'x', c: 'y' }, d: 'z' }) + expect([...keys].sort()).toEqual(['a.b', 'a.c', 'd']) + }) + + it('当前全部 6 语言完整(缺失清单为空)—— 一期可放宽为告警,此处记录实际状态', () => { + const { perLocale } = computeCoverage() + for (const code of SUPPORTED_LOCALES) { + expect(perLocale[code].missing, `${code} 缺失 key`).toEqual([]) + } + }) +}) diff --git a/packages/llm-router/package.json b/packages/llm-router/package.json new file mode 100644 index 0000000..9f906d7 --- /dev/null +++ b/packages/llm-router/package.json @@ -0,0 +1,21 @@ +{ + "name": "@drama/llm-router", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js", + "./db/schema": "./src/db/tables/index.cjs" + }, + "scripts": { + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@drama/llm": "workspace:*", + "@drama/service-call": "workspace:*" + }, + "devDependencies": { + "@drama/config-source": "workspace:*", + "@drama/db": "workspace:*" + } +} diff --git a/packages/llm-router/src/config.js b/packages/llm-router/src/config.js new file mode 100644 index 0000000..53fe356 --- /dev/null +++ b/packages/llm-router/src/config.js @@ -0,0 +1,29 @@ +/** + * normalizeConfig —— configSource 出口的唯一归一点。 + * + * `mode_model_config` 全量行的多形态(chain 为 JSON 文本 vs 已解析数组、含 enabled=0 行 vs 不含) + * 在此一次性收敛为内部唯一形态 `{ [mode]: Array<{provider,model}> }`。经此之后,mode-router.js / + * fallback.js 内部零 JSON.parse、零 Array.isArray、零空值分支——形态收口即此函数职责。 + */ + +/** + * @typedef {object} ModeConfigRow + * @property {string} mode + * @property {string|Array<{provider:string,model:string}>} chain JSON 文本或已解析数组(驱动侧差异)。 + * @property {number|boolean} enabled + */ + +/** + * @param {ModeConfigRow[]} rows mode_model_config 全量行。 + * @returns {{ [mode: string]: Array<{ provider: string, model: string }> }} + */ +export function normalizeConfig(rows) { + /** @type {{ [mode: string]: Array<{ provider: string, model: string }> }} */ + const config = {} + for (const row of rows) { + if (!row.enabled) continue + const chain = typeof row.chain === 'string' ? JSON.parse(row.chain) : row.chain + config[row.mode] = chain.map(({ provider, model }) => ({ provider, model })) + } + return config +} diff --git a/packages/llm-router/src/db/tables/index.cjs b/packages/llm-router/src/db/tables/index.cjs new file mode 100644 index 0000000..6eab590 --- /dev/null +++ b/packages/llm-router/src/db/tables/index.cjs @@ -0,0 +1,12 @@ +/** + * @drama/llm-router 建表注册:mode_model_config 的 targetTables 汇出。 + * + * 供装配层(任务 11)经 @drama/db applyCurrentSchema(db, targetTables) 建表。 + * 小配置表(不分区),走 buildTable 形态;形态对齐 packages/llm-cost/src/db/tables/index.cjs。 + */ + +const tableModules = [require('./mode_model_config.cjs')] + +const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) + +module.exports = { targetTables } diff --git a/packages/llm-router/src/db/tables/mode_model_config.cjs b/packages/llm-router/src/db/tables/mode_model_config.cjs new file mode 100644 index 0000000..26c20c8 --- /dev/null +++ b/packages/llm-router/src/db/tables/mode_model_config.cjs @@ -0,0 +1,27 @@ +/** + * mode_model_config —— 场景 → 模型链(小配置表),定义抄录自 schema.md(唯一事实源)。 + * + * 无 version 列:配置变更的缓存失效走 @drama/config-source 的版本机制(version = max(updated_at))。 + * `chain` JSON 承载 `[{provider,model}, ...fallback]`,扩展加键不加列。`{ tableName, buildTable }` + * 形态与 llm_model.cjs 同构,经 @drama/db applyCurrentSchema 建表。 + */ + +const tableName = 'mode_model_config' + +/** + * @param {import('knex').Knex.CreateTableBuilder} t + * @param {import('knex').Knex} db + */ +function buildTable(t, db) { + t.bigIncrements('id').unsigned().primary() + t.string('mode', 64).notNullable() + t.json('chain').notNullable() + t.boolean('enabled').notNullable().defaultTo(1) + t.datetime('created_at', { precision: 3 }).notNullable().defaultTo(db.raw('CURRENT_TIMESTAMP(3)')) + t.datetime('updated_at', { precision: 3 }) + .notNullable() + .defaultTo(db.raw('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)')) + t.unique(['mode'], { indexName: 'uk_mode' }) +} + +module.exports = { tableName, buildTable } diff --git a/packages/llm-router/src/fallback.js b/packages/llm-router/src/fallback.js new file mode 100644 index 0000000..74c35ac --- /dev/null +++ b/packages/llm-router/src/fallback.js @@ -0,0 +1,50 @@ +/** + * withFallback —— 把一条模型链(主→备)包成与单实例 `Llm` 同形的实例。 + * + * 候选遍历与降级判定收敛在 @drama/service-call 的通用 withFallback:唯一降级信号是 + * 结构化错误的 `retryable === true`(duck-type;`@drama/llm` 已把 rateLimit/transient 归一为 true, + * auth/badRequest/aborted/unknown 为 false)。不读 `err.reason` 字符串、不匹配 `err.message`、 + * 不判 HTTP 状态。本文件只负责 LLM 域的 attempt 上下文:mode / correlation_id / attempt_index 注入。 + * + * 返回值 `{ chat }` 与 `createLLM` 逐字一致;`opts`(on_content/on_thinking/on_tool_call/signal/billing_ctx) + * 整体透传、不解构,故流式回调与 signal 天然透传给每个被尝试的实例。 + */ + +import { randomUUID } from 'node:crypto' +import { LLMError } from '@drama/llm' +import { withFallback as chainCandidates } from '@drama/service-call' + +/** @typedef {import('@drama/llm').Llm} Llm */ + +/** + * @param {Llm[]} llms 非空模型链,索引 0 为主、末位为最后兜底。 + * @param {object} [cfg] + * @param {string} [cfg.mode] 场景名(`mode_model_config.mode`);由 mode-router 传入,随每次 attempt 落审计。 + * @returns {Llm} + */ +export function withFallback(llms, { mode } = {}) { + if (llms.length < 1) { + throw new LLMError({ + provider: 'llm-router', + reason: 'badRequest', + retryable: false, + message: 'withFallback: llms must be a non-empty array', + }) + } + + /** + * @param {import('@drama/llm').ChatRequest} req + * @param {import('@drama/llm').ChatOpts} [opts] + * @returns {Promise} + */ + async function chat(req, opts) { + // 一次 chat = 一个 correlation_id:链上每个 attempt 各自是一行 llm_call_log,靠它串成"这次 turn 降级了"。 + // 链上每个实例各自 genId 出自己的 requestId(对账键,不可共用),故关联只能另立一个 id。 + const correlation_id = randomUUID() + return chainCandidates( + llms.map((llm, i) => () => llm.chat(req, { ...opts, mode, correlation_id, attempt_index: i })), + )() + } + + return { chat } +} diff --git a/packages/llm-router/src/index.js b/packages/llm-router/src/index.js new file mode 100644 index 0000000..f27e3dc --- /dev/null +++ b/packages/llm-router/src/index.js @@ -0,0 +1,20 @@ +/** + * @drama/llm-router 公共面(唯一对外入口)。 + * + * 场景路由与 fallback 链:包内不读 process.env、不建顶层连接、不写盘;db/configSource/factory 均注入。 + * 只依赖 @drama/llm 的端口契约(`LLMError`、`Llm` typedef),不 import 其它能力包。 + * + * @typedef {import('@drama/llm').Llm} Llm + */ + +import { createRequire } from 'node:module' + +export { withFallback } from './fallback.js' +export { createModeRouter } from './mode-router.js' +export { normalizeConfig } from './config.js' +export { createLlmFactory, withProviderBaseUrl } from './llm-factory.js' +export { validateAdapters, validateModes } from './validate.js' + +// db/tables 是 cjs(knex builder,与 @drama/db 建表机制同源),经 createRequire 载入后 re-export 供装配层注册。 +const require = createRequire(import.meta.url) +export const { targetTables } = require('./db/tables/index.cjs') diff --git a/packages/llm-router/src/llm-factory.js b/packages/llm-router/src/llm-factory.js new file mode 100644 index 0000000..8262b5a --- /dev/null +++ b/packages/llm-router/src/llm-factory.js @@ -0,0 +1,96 @@ +/** + * llm-factory.js —— `(文本能力接入, model) → Llm 实例` 的**唯一归一处**。 + * + * 三条覆盖顺序在此收口,两个 app 的装配都只调本文件,不再各自抄一份(此前 visual-game 用 + * `row.adapter`、主站直接把 env 的厂商名当 registry 键,drift 就是这么来的): + * + * 1. 协议适配器:`model_provider_capability.adapter` → registry 子类。能力代码只作 + * **key 池 vendor**(`llm:`)与计费主体,绝不当 registry 键——name/adapter 解耦的全部 + * 意义就在这里(`name='deepseek', adapter='openai'` 必须能跑)。 + * 2. base_url:`llm_key.base_url` > `model_provider_capability.base_url` > 适配器默认(见 withProviderBaseUrl)。 + * 3. 请求参数:请求体 > `llm_model.params` > `model_provider_capability.params`。 + * + * 包内不读 env、不建连接:ports 与两张表的读取函数全部注入。 + * + * @typedef {import('@drama/llm').Llm} Llm + */ + +import { createLLM, LLMError } from '@drama/llm' + +/** + * @param {object} deps + * @param {object} deps.ports 成本、结算、审计、key、trace、notify 端口。 + * @param {object} deps.billingPolicy composition root 明确选择的积分策略。 + * @param {() => Map} deps.getProviders `model_provider_capability.code` → 文本能力行(装配侧保持最新)。 + * @param {() => Map} deps.getModels `${provider}#${model}` → 行(装配侧保持最新)。 + * @returns {(cfg: { provider: string, model: string }) => Llm} + */ +export function createLlmFactory({ ports, billingPolicy, getProviders, getModels }) { + if (!billingPolicy?.mode) throw new Error('createLlmFactory: billingPolicy is required') + return ({ provider, model }) => { + const row = getProviders().get(provider) + if (!row) { + throw new LLMError({ + provider: 'llm-router', + reason: 'badRequest', + retryable: false, + message: `createLlmFactory: text provider capability '${provider}' is not registered/enabled`, + }) + } + const llm = createLLM({ ...ports, provider: row.adapter, vendor: row.name, model }) + const providerParams = paramsOf(row.params, `model_provider_capability ${provider}`) + const modelRow = getModels().get(`${provider}#${model}`) + if (!modelRow) throw new Error(`createLlmFactory: llm_model '${provider}#${model}' is not registered/enabled`) + const modelParams = paramsOf(modelRow.params, `llm_model ${provider}#${model}`) + return { + /** + * @param {import('@drama/llm').ChatRequest} req + * @param {import('@drama/llm').ChatOpts} [opts] + */ + chat(req, opts = {}) { + const billing_ctx = { ...opts.billing_ctx, policy: billingPolicy } + return llm.chat({ ...providerParams, ...modelParams, ...req }, { ...opts, billing_ctx }) + }, + } + } +} + +/** + * keyProvider 装配包装:lease.base_url 为空时回落 `model_provider_capability.base_url`。 + * keyrouter 只知 key 级 base_url,厂商级默认端点是装配层知识——不改 @drama/llm 与 @drama/keyrouter。 + * vendor 形如 `text:`(前缀 = 能力类型),去前缀得厂商身份查行。 + * @param {import('@drama/llm').KeyProvider} inner + * @param {() => Map} getProviders + * @returns {import('@drama/llm').KeyProvider} + */ +export function withProviderBaseUrl(inner, getProviders) { + return { + async acquire(q) { + const lease = await inner.acquire(q) + if (lease.base_url) return lease + const name = q.vendor.startsWith('text:') ? q.vendor.slice(5) : q.vendor + const base_url = getProviders().get(name).base_url + return base_url === null ? lease : { ...lease, base_url } + }, + report_usage: (key_id, usage) => inner.report_usage(key_id, usage), + report_rate_limited: (key_id, retry_after_ms) => inner.report_rate_limited(key_id, retry_after_ms), + report_error: (key_id, kind) => inner.report_error(key_id, kind), + // 包装不许把端口削薄:漏掉 stats 就等于「运行中的那个 keyrouter」对外不可观测, + // 后台面板只能另造一个实例去看——那看到的是另一份进程状态,不是这一份。 + stats: () => inner.stats(), + } +} + +/** + * MySQL JSON row 的唯一构造点:NULL 表示没有默认参数;非 NULL 必须是 mysql2 已解析的普通对象。 + * @param {unknown} params + * @param {string} owner + * @returns {Record} + */ +function paramsOf(params, owner) { + if (params === null) return {} + if (typeof params !== 'object' || Array.isArray(params)) { + throw new Error(`createLlmFactory: ${owner}.params 必须是 JSON object 或 NULL`) + } + return params +} diff --git a/packages/llm-router/src/mode-router.js b/packages/llm-router/src/mode-router.js new file mode 100644 index 0000000..a6c7998 --- /dev/null +++ b/packages/llm-router/src/mode-router.js @@ -0,0 +1,67 @@ +/** + * createModeRouter —— 场景名(mode)→ 与单实例 `Llm` 同形的实例(含其 fallback 链)。 + * + * configSource 出口已归一为 `{ [mode]: Array<{provider,model}> }`(见 config.js),故本文件内部 + * 零判空、零 JSON.parse、零形态分支:chain 恒为非空对象数组。加场景 = 加表行,零代码改动。 + */ + +import { withFallback } from './fallback.js' +import { LLMError } from '@drama/llm' + +/** @typedef {import('@drama/llm').Llm} Llm */ + +/** + * @param {object} deps + * @param {{ get: () => Promise<{ [mode: string]: Array<{ provider: string, model: string }> }>, invalidate: () => void }} deps.configSource + * @drama/config-source 的 createVersionedSource 产物;get() 返回已归一配置,invalidate() 失效版本缓存。 + * @param {(cfg: { provider: string, model: string }) => Llm} deps.factory + * 装配层绑定为 `(cfg) => createLLM({ ...sharedPorts, provider: cfg.provider, model: cfg.model })`。 + * @returns {{ forMode: (mode: string) => Promise, invalidate: () => void }} + */ +export function createModeRouter({ configSource, factory }) { + /** @type {Map} mode → 懒建缓存的实例(含其 fallback 链)。 */ + const cache = new Map() + /** @type {object | null} 上次 configSource.get() 的配置对象引用;据其变化判定热更新。 */ + let cachedConfig = null + + /** + * @param {string} mode + * @returns {Promise} + */ + async function forMode(mode) { + // 实例缓存与配置版本绑定:每次先 get()——createVersionedSource 版本未变返回同一对象引用, + // 版本变化返回新对象。引用变即热更新,清实例缓存令下次按新 chain 重建;引用未变直接走缓存。 + // 探测频率由装配层 ttlMs 节流(get 在新鲜窗口内不触达 DB),故此处每次调用无额外 IO 成本。 + const config = await configSource.get() + if (config !== cachedConfig) { + cache.clear() + cachedConfig = config + } + const cached = cache.get(mode) + if (cached) return cached + const chain = config[mode] + // 配置缺失是调用方错误,不可降级:抛 badRequest,与网关错误分类一致。 + if (!chain) { + throw new LLMError({ + provider: 'llm-router', + reason: 'badRequest', + retryable: false, + message: `forMode: no chain configured for mode '${mode}'`, + }) + } + const llms = chain.map(({ provider, model }) => factory({ provider, model })) + // mode 随链下沉:链上每次 attempt 的审计行都要认领"我是哪个场景的调用"。 + const llm = withFallback(llms, { mode }) + cache.set(mode, llm) + return llm + } + + /** 清本地实例缓存 + 透传失效 configSource 版本缓存,保证「改表 + invalidate → 下次走新 chain 重建」。 */ + function invalidate() { + cache.clear() + cachedConfig = null + configSource.invalidate() + } + + return { forMode, invalidate } +} diff --git a/packages/llm-router/src/validate.js b/packages/llm-router/src/validate.js new file mode 100644 index 0000000..a3f276b --- /dev/null +++ b/packages/llm-router/src/validate.js @@ -0,0 +1,42 @@ +/** + * 模型路由配置断言。调用方可用于写入门禁或非阻断启动诊断。 + * + * 与运行期错误的分工:运行期只应出现**环境性**失败(上游 429/超时、key 全冷却), + * 配置性失败必须在启动期一次性列清。判据是「这条不变量在启动时是否已经可判定」: + * - adapter 是否在网关注册表里 —— 启动时可判定。 + * - runtime 会调的 mode 是否配了链 —— 启动时可判定(mode 列表由装配层声明,是入口不变量)。 + * 校验「表里已有的行是否自洽」不够:**空表天然自洽**。零行配置一路绿灯启动、第一个请求才抛 + * `no chain configured for mode 'kp'`,正是这类校验漏掉入口不变量的后果。 + */ + +/** + * 文本能力接入的 adapter 必须是网关注册表里的键,否则该接入在运行期必然 unknown provider。 + * @param {Array<{ name:string, adapter:string }>} providerRows enabled 的 model_provider_capability 文本能力行 + * @param {string[]} adapters `registeredProviders()` 结果 + * @returns {void} + */ +export function validateAdapters(providerRows, adapters) { + const known = new Set(adapters) + const bad = providerRows.filter((row) => !known.has(row.adapter)) + if (bad.length === 0) return + const detail = bad.map((row) => `${row.name}(adapter=${row.adapter})`).join(', ') + throw new Error(`llm-router: 文本能力接入的 adapter 未注册: ${detail};可用适配器: ${adapters.join(', ')}`) +} + +/** + * runtime 实际会 `forMode(mode)` 的每个场景,必须在 `mode_model_config` 有 enabled 且链非空的行。 + * requiredModes 由装配层声明(它是唯一知道 runtime 调哪些 mode 的地方),不从表里反推。 + * @param {{ [mode: string]: Array<{ provider:string, model:string }> }} config normalizeConfig 产物 + * @param {string[]} requiredModes runtime 会调用的场景名 + * @returns {void} + */ +export function validateModes(config, requiredModes) { + const missing = requiredModes.filter((mode) => !config[mode] || config[mode].length === 0) + if (missing.length === 0) return + const configured = Object.keys(config) + throw new Error( + `llm-router: runtime 需要的场景在 mode_model_config 无可用链: ${missing.join(', ')};` + + `现有 enabled 场景: ${configured.length > 0 ? configured.join(', ') : '(空表)'}。` + + '配置说明:apps/visual-game-server/tools/model-config/README.md。', + ) +} diff --git a/packages/llm/README.md b/packages/llm/README.md new file mode 100644 index 0000000..9f7d4cf --- /dev/null +++ b/packages/llm/README.md @@ -0,0 +1,55 @@ +# @drama/llm + +多厂商 LLM 网关。公共面(`src/index.js`)导出 `createLLM`、`createStaticKeyProvider`、`LLMError` 和 provider 注册表;协议差异全部圈在 `src/providers/llm..js` 子类,基类(`src/base.js`)零厂商分支。生产、诊断和验收都只调用真实 provider,不提供 fake provider 或 fetch 注入缝。 + +运行时会计链由 composition root 成组注入供应商成本、审计和积分结算端口。成功响应先严格构造 token 用量并记录价格快照与人民币 micros 成本,再按显式策略结算用户积分;运营探针可以三者全部省略,但不得只省略其中一部分。 + +## OpenAI 兼容文本能力零文件接入 + +接入一个 OpenAI 兼容新厂商(DeepSeek 等)= 配置厂商主体、文本能力、key 和模型,**零代码、零新文件**: + +```sql +INSERT INTO model_provider (code, display_name) VALUES ('deepseek', 'DeepSeek'); +INSERT INTO model_provider_capability + (provider_id, code, capability, adapter, credential_source, base_url) +SELECT id, 'deepseek', 'text', 'openai', 'key_pool', 'https://api.deepseek.com/v1' +FROM model_provider WHERE code = 'deepseek'; +INSERT INTO llm_key (vendor, api_key) VALUES ('llm:deepseek', ''); +INSERT INTO llm_model (provider_capability_id, model, pricing) +SELECT id, 'deepseek-chat', '' +FROM model_provider_capability WHERE code = 'deepseek' AND capability = 'text'; +``` + +装配从文本能力行构造实例:`createLLM({ provider: row.adapter, vendor: row.name })`。 + +- `adapter` 决定复用哪个 provider 子类(`openai` = Chat Completions 适配器)。 +- `base_url` 经 `lease.baseUrl` 承载厂商端点,**永远优先于** `defaultBaseUrl()`。 +- `vendor` 使 key 池按厂商身份隔离(同用 `openai` 适配器的两个厂商各命中自己的 key 池,详见任务 07 的 `vendor` 选项)。 + +仅当**帧形态或多模态形态有厂商差异**时才需新增 `llm..js`(如 `gemini`/`volcengine`);纯 OpenAI 兼容厂商只配置数据。 + +## 两套 OpenAI 适配器定位 + +同一 OpenAI 生态的两套协议收敛为两个适配器文件,经 `model_provider_capability.adapter` 二选一,不建平行体系: + +| adapter | 文件 | 端点 | 用途 | +|---|---|---|---| +| `openai_responses` | `providers/llm.openai_responses.js` | `POST /responses` | **OpenAI 厂商默认适配器**(Responses API,`store:false`、独立 `prompt_cache_key`、缓存读写用量) | +| `openai` | `providers/llm.openai.js` | `POST /chat/completions` | OpenAI 兼容厂商(Moonshot / DeepSeek / Azure / 兼容网关) | + +Visual Game 持续 Agent 通过独立 `prompt_cache_key` 固定缓存身份。模型参数提供 +`prompt_cache_options: { mode: 'implicit' | 'explicit', ttl?: '30m' }` 时,static 消息同时发送 +`prompt_cache_breakpoint`;未提供时不发送这两个公开缓存控制字段。Responses usage 统一构造 +`cached_tokens` 和 `cache_write_tokens`,供应商未报告的可选明细在第三方响应入口构造为 `0`。 + +## 视频支持面 + +视频落地/拒绝由基类 `supportsVideo()` 能力钩子驱动(`base.js` 的 `request()` 单点护栏),基类零厂商分支: + +| provider | 视频 | +|---|---| +| `gemini` | 支持(`inline_data` base64 / `file_data` url) | +| `volcengine` | 支持(`video_url` 块) | +| `openai` / `openai_responses` / `anthropic` / `moonshot` / `azure_foundry` | 收到 `videos` 抛 `LLMError{ reason:'badRequest', retryable:false }` | + +扩展视频厂商 = 在子类覆写 `supportsVideo()` 返回 `true`,并在 `chat`/`_toOpenAIMessages` 落该厂商的视频块。 diff --git a/packages/llm/package.json b/packages/llm/package.json new file mode 100644 index 0000000..94c9b1b --- /dev/null +++ b/packages/llm/package.json @@ -0,0 +1,17 @@ +{ + "name": "@drama/llm", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js", + "./media": "./src/media.js" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@drama/service-call": "workspace:*", + "valibot": "^1.4.1" + } +} diff --git a/packages/llm/src/base.js b/packages/llm/src/base.js new file mode 100644 index 0000000..49d6735 --- /dev/null +++ b/packages/llm/src/base.js @@ -0,0 +1,658 @@ +/** + * LLMBase —— 适配器三段式的「公共一处」。 + * + * 公共能力全在这里、且只在这里(生命周期编排): + * - attempt 生命周期(acquire→fetch→消费流→release)、每 attempt 独立超时与组合 signal + * - SSE 帧切分外移至 sse.js、mime 魔数外移至 media.js,本文件一行 import 接入、不含其逻辑 + * - 增量回调编排(text→on_content、reasoning→on_thinking、tool_call→on_tool_call) + * - usage 汇总与 reasoning/text 累积 + * - 重试退避(瞬态错误重试,经 @drama/service-call 纯函数判定) + * - trace(经注入的 tracePort,缺省 no-op;本包不写盘、不读全局上下文) + * - 多模态消息中间态构建(images/videos → 统一中间态,交由子类落各厂商形态) + * + * 子类(providers/llm..js)只覆写两件事: + * - async chat(req, opts):拼本厂商 body/headers,调 this.request(...) + * - messageToValue(rawFrame):把本厂商 SSE 帧解析成统一 { type, ... } + * + * 边界纪律:构造里不建连接、不 mkdir、不 appendFile;apiKey/baseUrl 不静态持有, + * 每次 request 从注入的 keyProvider.acquire() 取、finally release()。 + */ + +import { LLMError } from './errors.js' +import { mapHttpStatus, mapThrownError, isAbortError, mapChatFinishReason } from './map-error.js' +import { iterateSse } from './sse.js' +import { sniffMime, parseDataUrl } from './media.js' +import { normalizeResponseFormat } from './response-format.js' +import { + DEFAULT_RETRY_POLICY, + shouldRetry, + backoffDelayMs, + isRetryableReason, +} from '@drama/service-call' + +/** 外部 abort reason 常量(与 map-error.js 的 'aborted' 对齐,消除魔法字符串)。 */ +const ABORTED_REASON = 'aborted' +export const EMPTY_USAGE = { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + cached_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, +} + +/** + * @typedef {import('./map-error.js').LLMErrorReason} LLMErrorReason + * @typedef {import('@drama/service-call').RetryPolicy} RetryPolicy + */ + +/** + * 多模态归一后的单一形态:恰含 (mime+data) 或 url 其一。 + * @typedef {object} MediaPart + * @property {'image'|'video'} kind + * @property {string} [mime] 本地字节的 mime(sniff/dataUrl 得),与 data 同时存在。 + * @property {string} [data] base64 负载。 + * @property {string} [url] 远端直传 URL(无本地 mime)。 + */ + +/** + * 统一中间帧(messageToValue 的产物)。messageToValue 可返回单个 ParsedValue、其数组、或 null; + * 同帧多产物(如 Gemini 同帧 text+usage、OpenAI 同帧多路 tool_call 分片)以数组返回,base 展开消费。 + * @typedef {object} ParsedValue + * @property {'text'|'reasoning'|'tool_call'|'tool_call_delta'|'usage'|'metadata'|'finish'} type + * @property {string} [text] type==='text'|'reasoning' 时的增量。 + * @property {Array} [tool_calls] type==='tool_call' 时的完整(中性)调用列表。 + * @property {number} [index] type==='tool_call_delta' 时的分片归属序号(同一 index 聚合为一个调用)。 + * @property {string} [id] type==='tool_call_delta' 时的调用 id(首帧给出)。 + * @property {string} [name] type==='tool_call_delta' 时的工具名(首帧给出)。 + * @property {string} [args_fragment] type==='tool_call_delta' 时的 arguments JSON 分片(跨帧拼接)。 + * @property {Partial} [usage] + * @property {string} [provider_request_id] + * @property {string} [service_tier] + * @property {string|null} [finish_reason] + */ + +/** + * request() 返回的中性聚合结果(不含任何厂商专有字段)。 + * @typedef {object} StreamResult + * @property {string} content + * @property {string} [reasoning] + * @property {Array} [tool_calls] + * @property {import('./index.js').ChatUsage} usage + * @property {string|null} provider_request_id + * @property {string|null} service_tier + * @property {string|null} [finish_reason] + * @property {{ ttft_ms:number|null, duration_ms:number }} [timings] + * 计时(仅成功路径有):ttft_ms 为首个 content/reasoning 帧相对 startedAt 的偏移(无帧则 null), + * duration_ms 为流消费完成相对 startedAt 的偏移。 + */ + +export class LLMBase { + /** + * @param {object} cfg + * @param {import('./index.js').KeyProvider} cfg.keyProvider + * @param {string} cfg.provider api_type,决定协议子类与 LLMError.provider。 + * @param {string} [cfg.vendor] keyProvider 隔离维度(厂商身份);缺省回落 provider。 + * @param {string} [cfg.model] 默认模型。 + * @param {import('./index.js').TracePort} cfg.tracePort + * @param {RetryPolicy} [cfg.retryPolicy] + * @param {() => number} [cfg.nowMs] 注入时钟(计时用);缺省 Date.now。 + */ + constructor({ keyProvider, provider, vendor, model, tracePort, retryPolicy, nowMs }) { + this.keyProvider = keyProvider + this.provider = provider + this.vendor = vendor ?? provider + this.model = model + this.tracePort = tracePort + this.retryPolicy = retryPolicy ?? DEFAULT_RETRY_POLICY + // 全局 fetch,无注入缝:测试打真实 HTTP 上游(base_url 指向本地真服务),不再注入假 fetch。 + this.httpFetch = fetch + this.nowMs = nowMs ?? Date.now + } + + /** + * 子类必须覆写:拼本厂商请求并返回中性 StreamResult。 + * @param {import('./index.js').ChatRequest} _req + * @param {import('./index.js').ChatOpts} [_opts] + * @returns {Promise} + */ + async chat(_req, _opts) { + throw new Error('LLMBase.chat must be overridden by a provider subclass') + } + + /** + * 子类必须覆写:把一帧厂商 SSE data 的已解析 JSON 转成统一 ParsedValue(或 null 跳过)。 + * @param {any} _rawFrame + * @returns {ParsedValue | null} + */ + messageToValue(_rawFrame) { + throw new Error('LLMBase.messageToValue must be overridden by a provider subclass') + } + + /** + * 能力声明钩子:本 provider 是否支持视频输入。缺省 false(openai/anthropic/moonshot/azure_foundry), + * 视频厂商(gemini/volcengine)覆写返回 true。与 supportsThinkingParam() 同为能力钩子, + * 非厂商 if 分支——video 护栏在 request() 单点消费它,基类零厂商分支。 + * @protected + * @returns {boolean} + */ + supportsVideo() { + return false + } + + /** + * 多模态归一入口(唯一 mime 归一点):把 images/videos(Buffer|string)按 MECE 四态归一为 + * 单一 MediaPart 形态(恰含 mime+data 或 url 其一),交由子类落各厂商消息形态。 + * 落地处零 mime 回退:未识别字节在此一次性 badRequest,子类信任 part.mime 非空。 + * @param {Array|undefined} items + * @param {'image'|'video'} kind + * @returns {Array} + */ + buildMediaParts(items, kind) { + if (!items || items.length === 0) return [] + return items.map((it) => { + if (Buffer.isBuffer(it)) { + const mime = sniffMime(it) + if (mime == null) throw this._mediaError(kind) + return { kind, mime, data: it.toString('base64') } + } + if (it.startsWith('data:')) { + const parsed = parseDataUrl(it) + if (parsed == null) throw this._mediaError(kind) + return { kind, mime: parsed.mime, data: parsed.data } + } + if (it.startsWith('http')) return { kind, url: it } + const mime = sniffMime(Buffer.from(it, 'base64')) + if (mime == null) throw this._mediaError(kind) + return { kind, mime, data: it } + }) + } + + /** + * @private + * @param {'image'|'video'} kind + * @returns {LLMError} + */ + _mediaError(kind) { + return new LLMError({ + provider: this.provider, + reason: 'badRequest', + retryable: false, + message: `[${this.provider}] unrecognized ${kind} bytes (no mime magic match)`, + }) + } + + /** + * responseFormat 分派表钩子:provider 覆写返回「归一 type → 落地函数」表。 + * 缺省空表——任何 responseFormat 都由 applyResponseFormat 统一 badRequest(不支持结构化输出)。 + * 支持厂商只声明自己能表达的 type(如 moonshot 仅 json_object),不能表达的 type 天然落 badRequest, + * 无需各写 if-else。 + * @protected + * @returns {Record, headers:Record, rf:{schema?:object, name?:string}) => void>} + */ + responseFormatHandlers() { + return {} + } + + /** + * responseFormat(structured output)唯一落地入口:入口一次归一 → 按归一 type 查 provider 分派表 → + * 命中即落地,未命中显式 badRequest。子类零 if-else、零静默忽略;每个 provider 的 chat 调它一次(null 时 no-op)。 + * @protected + * @param {Record} body + * @param {Record} headers + * @param {object|undefined} responseFormat + * @returns {void} + */ + applyResponseFormat(body, headers, responseFormat) { + if (responseFormat == null) return + const normalized = normalizeResponseFormat(responseFormat, this.provider) + const handler = this.responseFormatHandlers()[normalized.type] + if (!handler) { + throw new LLMError({ + provider: this.provider, + reason: 'badRequest', + retryable: false, + message: `[${this.provider}] does not support responseFormat type '${normalized.type}'`, + }) + } + handler(body, headers, normalized) + } + + /** + * 公共请求执行:acquire key → 带重试地发请求并解析流 → reportUsage/reportRateLimited → release。 + * 子类调它,传入已拼好的「发请求闭包」`doFetch(lease)`,本方法负责其余全部编排。 + * + * @param {object} args + * @param {(lease: import('./index.js').KeyLease) => Promise} args.doFetch + * 子类用 lease.apiKey/baseUrl 拼 body/headers 并发 fetch,返回原始 Response。 + * @param {import('./index.js').ChatRequest} args.req + * @param {import('./index.js').ChatOpts} [args.opts] + * @param {string} [args.affinity_key] + * @returns {Promise} + */ + async request({ doFetch, req, opts = {}, affinity_key }) { + // 视频护栏(唯一点):所有子类 chat 都经此。缺省 supportsVideo()===false 覆盖 + // openai/anthropic/moonshot/azure_foundry;视频厂商各自覆写返回 true。基类零厂商分支。 + if (req.videos?.length && !this.supportsVideo()) { + throw new LLMError({ + provider: this.provider, + reason: 'badRequest', + retryable: false, + message: `[${this.provider}] video input is not supported by this provider`, + }) + } + const span = this.tracePort.startSpan(`llm.chat:${this.provider}`) + span.setAttr('provider', this.provider) + span.setAttr('model', req.model ?? this.model ?? '') + // sessionId 一次入口归一:有值才落 span 属性(对齐 gen_ai 语义约定),无值不写占位。 + const sessionId = opts.trace_ctx?.session_id + if (sessionId) span.setAttr('gen_ai.conversation.id', sessionId) + + // 计时起点:整个 request(含重试)相对它测 ttft / duration,交由 _consumeStream 采样。 + const startedAt = this.nowMs() + // 单一重试循环:attempt 从 0(首次)起算;每次失败按 @drama/service-call 纯函数判定是否再试。 + let attempt = 0 + try { + for (;;) { + if (opts.signal?.aborted) { + throw new LLMError({ provider: this.provider, reason: ABORTED_REASON, retryable: false }) + } + try { + return await this._attempt({ doFetch, req, opts, affinity_key, span, startedAt }) + } catch (err) { + const mapped = this._toLLMError(err) + // 外部 abort 不算可重试错误、不重试、不告警;内部 timeout 不落此短路(reason 不同),正常进重试。 + if (isAbortError(err) || mapped.reason === ABORTED_REASON) { + span.addEvent('aborted', {}) + throw mapped + } + attempt += 1 + span.addEvent('error', { reason: mapped.reason, attempt }) + if (!shouldRetry(attempt, mapped.retryable && isRetryableReason(mapped.reason), this.retryPolicy)) { + throw mapped + } + await sleep(backoffDelayMs(attempt, this.retryPolicy), opts.signal) + } + } + } finally { + span.end() + } + } + + /** + * 单次尝试:acquire key → doFetch → 非 2xx 归一并上报 → 解析流 → reportUsage → 必 release。 + * 任何失败都抛(由 request 的循环决定是否重试)。 + * @private + * @param {object} a + * @param {(lease: import('./index.js').KeyLease) => Promise} a.doFetch + * @param {import('./index.js').ChatRequest} a.req + * @param {import('./index.js').ChatOpts} a.opts + * @param {string} [a.affinity_key] + * @param {import('./index.js').Span} a.span + * @param {number} a.startedAt + * @returns {Promise} + */ + async _attempt({ doFetch, req, opts, affinity_key, span, startedAt }) { + const lease = await this.keyProvider.acquire({ + vendor: `text:${this.vendor}`, + model: req.model ?? this.model, + affinity_key, + }) + // 这次 attempt 是哪把 key 服务的——审计要归因(认证失败时尤其),故 lease.keyId 必须离开这里。 + // 本包不认识 keyrouter:keyId 只是 lease 上的一个值,经回调原样交给上层,不做解释。 + opts.on_key_lease?.(lease.key_id) + // 每 attempt 独立计时:内部 AbortController 用 TimeoutError 中止,AbortSignal.any 组合外部 opts.signal。 + const ac = new AbortController() + const signals = opts.signal ? [ac.signal, opts.signal] : [ac.signal] + const attemptSignal = AbortSignal.any(signals) + const timer = opts.timeout_ms + ? setTimeout(() => ac.abort(new DOMException('llm attempt timeout', 'TimeoutError')), opts.timeout_ms) + : null + try { + const res = await doFetch(lease, { signal: attemptSignal }) + if (!res.ok) { + const { reason, retryable } = mapHttpStatus(res.status) + const bodyText = await safeReadText(res) + const body = httpErrorBody(res, bodyText) + const errorInfo = this.httpErrorInfo(res, body, reason) + if (reason === 'rateLimit') this.keyProvider.report_rate_limited(lease.key_id, retryAfterMs(res)) + else if (reason === 'auth') this.keyProvider.report_error(lease.key_id, 'auth') + else if (reason === 'transient') this.keyProvider.report_error(lease.key_id, 'transient') + throw new LLMError({ + provider: this.provider, + reason, + retryable, + cause: body, + ...errorInfo, + detail: { + type: 'http', + status: res.status, + headers: Object.fromEntries(res.headers.entries()), + body, + }, + }) + } + const result = await this._consumeStream(res, attemptSignal, opts, span, startedAt) + this.keyProvider.report_usage(lease.key_id, result.usage) + span.addEvent('usage', result.usage) + return result + } finally { + if (timer) clearTimeout(timer) + // release 必被 await(兼容同步/异步);失败不覆盖返回值,仅 span 落痕。 + try { + await lease.release() + } catch (e) { + span.addEvent('release_error', { message: String(e?.message ?? e) }) + } + } + } + + /** + * 消费 SSE 流:分帧 → messageToValue → 累积 text/reasoning/tool_calls/usage + 触发回调。 + * @private + * @param {Response} res + * @param {AbortSignal} attemptSignal 每 attempt 的组合信号(内部超时 + 外部 abort);中途可中断读循环。 + * @param {import('./index.js').ChatOpts} opts + * @param {import('./index.js').Span} span + * @param {number} startedAt 计时起点(this.nowMs() 域);ttft/duration 相对它测。 + * @returns {Promise} + */ + async _consumeStream(res, attemptSignal, opts, span, startedAt) { + let content = '' + let reasoning = '' + /** @type {number | undefined} 首个 content/reasoning 帧的到达时刻(TTFT 采样点,只采一次)。 */ + let firstFrameAt + /** @type {Array} */ + const tool_calls = [] + /** + * 分片 tool_call 聚合缓冲(协议无关,base 独有):index → { id?, name?, argsText }。 + * 真实流的 function.arguments 跨多帧分片,provider 逐帧发 tool_call_delta,base 按 index 聚合, + * 流结束时对每个完整调用一次 JSON.parse 得中性 { id, name, args(object) }。每次 _consumeStream 独立、不跨 attempt。 + * @type {Map} + */ + const toolCallBuffers = new Map() + /** @type {StreamResult['usage']} */ + let usage = { ...EMPTY_USAGE } + let provider_request_id = null + let service_tier = null + let finish_reason = null + // 只有输出已经通过回调交付给调用方,才存在不可撤销的流式副作用;无回调时结果尚未离开 + // Promise,失败后仍可安全重试。已交付后禁止 retry/fallback,避免混入两个 attempt 的内容。 + let emittedOutput = false + + try { + for await (const frame of iterateSse(res, attemptSignal)) { + opts.on_raw_frame?.(frame) + // messageToValue 单值/数组统一归一为数组,在入口一处展开(同帧多产物:text+usage、多路 tool_call 分片)。 + for (const value of toValues(this.messageToValue(frame))) { + switch (value.type) { + case 'text': + if (value.text) { + content += value.text + firstFrameAt ??= this.nowMs() + if (opts.on_content) { + emittedOutput = true + opts.on_content(value.text) + } + } + break + case 'reasoning': + if (value.text) { + reasoning += value.text + firstFrameAt ??= this.nowMs() + if (opts.on_thinking) { + emittedOutput = true + opts.on_thinking(value.text) + } + } + break + case 'tool_call': + // 完整(非分片)中性调用:provider 已给 { id, name, args(object) }(整帧下发的厂商)。 + if (value.tool_calls?.length) { + tool_calls.push(...value.tool_calls) + if (opts.on_tool_call) { + emittedOutput = true + opts.on_tool_call(value.tool_calls) + } + } + break + case 'tool_call_delta': + bufferToolCallDelta(toolCallBuffers, value) + break + case 'usage': + if (value.usage) usage = { ...usage, ...value.usage } + break + case 'metadata': + provider_request_id = value.provider_request_id + service_tier = value.service_tier + break + case 'finish': + finish_reason = normalizeFinishReason(value.finish_reason) + break + default: + throw new Error(`Unhandled parsed value type: ${value.type}`) + } + } + } + } catch (err) { + if (!emittedOutput) throw err + const mapped = this._toLLMError(err) + throw new LLMError({ + provider: this.provider, + reason: mapped.reason, + retryable: false, + cause: err, + message: `[${this.provider}] stream failed after output started: ${mapped.reason}`, + detail: { type: 'stream', error: mapped.detail }, + }) + } + // 流结束:把聚合完成的分片 tool_call 落成中性 { id, name, args(object) },一次性回调。 + const aggregated = finalizeToolCalls(toolCallBuffers) + if (aggregated.length) { + tool_calls.push(...aggregated) + opts.on_tool_call?.(aggregated) + } + span.addEvent('content', { length: content.length }) + + // Anthropic 分帧只给输入和输出分量,流结束时补齐总量。 + if (!usage.total_tokens) { + usage = { + ...usage, + total_tokens: usage.prompt_tokens + usage.completion_tokens, + } + } + + const finishError = chatFinishReasonError({ + provider: this.provider, + finish_reason, + content_length: content.length, + usage, + provider_request_id, + }) + if (finishError) { + if (emittedOutput) { + throw new LLMError({ + provider: this.provider, + reason: finishError.reason, + retryable: false, + cause: finishError, + message: `[${this.provider}] stream ended with finish_reason=${finish_reason} after output started`, + detail: { + type: 'stream_finish_reason', + finish_reason, + content_length: content.length, + usage, + }, + provider_request_id, + }) + } + throw finishError + } + + /** @type {StreamResult} */ + const out = { content, usage, provider_request_id, service_tier, finish_reason } + if (reasoning) out.reasoning = reasoning + if (tool_calls.length) out.tool_calls = tool_calls + // 计量取整只在出口一次:ttft 仅在有帧时有值,duration 恒为流消费总时长。 + out.timings = { + ttft_ms: firstFrameAt === undefined ? null : firstFrameAt - startedAt, + duration_ms: this.nowMs() - startedAt, + } + return out + } + + /** + * 把任意抛出物归一为 LLMError(已是 LLMError 则原样)。 + * @private + * @param {unknown} err + * @returns {LLMError} + */ + _toLLMError(err) { + if (err instanceof LLMError) return err + const { reason, retryable } = mapThrownError(err) + if (!(err instanceof Error)) throw new Error(`[${this.provider}] provider 抛出了非 Error 值`) + return new LLMError({ + provider: this.provider, + reason, + retryable, + cause: err, + message: err.name === 'KeyRouterError' ? `[${this.provider}] ${err.message}` : undefined, + detail: { type: 'runtime', name: err.name, message: err.message }, + }) + } + + httpErrorInfo(res, _body, reason) { + return { + message: `[${this.provider}] HTTP ${res.status}: ${reason}`, + provider_request_id: res.headers.get('x-request-id'), + } + } +} + +function normalizeFinishReason(reason) { + if (reason === null || typeof reason === 'string') return reason + throw new Error(`finish_reason must be string or null, got ${typeof reason}`) +} + +function chatFinishReasonError({ provider, finish_reason, content_length, usage, provider_request_id }) { + const mapped = mapChatFinishReason(finish_reason) + if (mapped === null) return null + return new LLMError({ + provider, + reason: mapped.reason, + retryable: mapped.retryable, + message: `[${provider}] chat completion ended with finish_reason=${finish_reason}`, + detail: { + type: 'finish_reason', + finish_reason, + content_length, + usage, + }, + provider_request_id, + }) +} + +/** + * messageToValue 返回归一为 ParsedValue 数组(单值/数组/null 三态一处收口),消费端零形态判断。 + * @param {import('./base.js').ParsedValue | Array | null | undefined} v + * @returns {Array} + */ +function toValues(v) { + if (v == null) return [] + return Array.isArray(v) ? v : [v] +} + +/** + * 把一帧 tool_call_delta 并入按 index 聚合的缓冲:id/name 首帧给出(合并),args_fragment 跨帧拼接。 + * @param {Map} buffers + * @param {{ index?:number, id?:string, name?:string, args_fragment?:string }} delta + * @returns {void} + */ +function bufferToolCallDelta(buffers, delta) { + const index = delta.index ?? 0 + let buf = buffers.get(index) + if (!buf) { + buf = { argsText: '' } + buffers.set(index, buf) + } + if (delta.id != null) buf.id = delta.id + if (delta.name != null) buf.name = delta.name + if (delta.args_fragment) buf.argsText += delta.args_fragment +} + +/** + * 聚合缓冲 → 中性 tool_call 列表:按 index 升序,每个 argsText 做一次 JSON.parse(args 恒为对象)。 + * args 为空时归一为 {};非法 JSON 由 JSON.parse 自然抛出(真实上游不会给坏 JSON,坏则是真错误)。 + * @param {Map} buffers + * @returns {Array<{ id?:string, name?:string, args:object }>} + */ +function finalizeToolCalls(buffers) { + return [...buffers.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, buf]) => ({ id: buf.id, name: buf.name, args: JSON.parse(buf.argsText || '{}') })) +} + +/** + * 安全读取响应文本(错误体),失败返回空串。 + * @param {Response} res + * @returns {Promise} + */ +async function safeReadText(res) { + try { + return await res.text() + } catch { + return '' + } +} + +function httpErrorBody(res, bodyText) { + const contentType = res.headers.get('content-type') + return contentType !== null && contentType.startsWith('application/json') + ? JSON.parse(bodyText) + : bodyText +} + +/** + * 从 429 响应取 Retry-After(秒)→ 毫秒;无则 undefined。 + * @param {Response} res + * @returns {number | undefined} + */ +function retryAfterMs(res) { + const h = res.headers?.get?.('retry-after') + if (!h) return undefined + const sec = Number(h) + return Number.isFinite(sec) ? sec * 1000 : undefined +} + +/** + * 可被 abort 的 sleep。 + * @param {number} ms + * @param {AbortSignal} [signal] + * @returns {Promise} + */ +function sleep(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortReason(signal)) + return + } + const t = setTimeout(() => { + signal?.removeEventListener?.('abort', onAbort) + resolve() + }, ms) + function onAbort() { + clearTimeout(t) + reject(abortReason(signal)) + } + signal?.addEventListener?.('abort', onAbort, { once: true }) + }) +} + +/** + * 中止原因:优先透传 signal.reason(内部超时的 TimeoutError / 外部的 AbortError),缺省造 AbortError。 + * @param {AbortSignal} [signal] + * @returns {unknown} + */ +function abortReason(signal) { + return signal?.reason ?? Object.assign(new Error('aborted'), { name: 'AbortError' }) +} diff --git a/packages/llm/src/errors.js b/packages/llm/src/errors.js new file mode 100644 index 0000000..e0ed311 --- /dev/null +++ b/packages/llm/src/errors.js @@ -0,0 +1,49 @@ +/** + * LLMError —— 全包唯一的结构化错误类型。 + * + * 单列一文件(而非塞进 index.js)以避免 base.js / llm.js 与公共面 index.js 形成 + * 循环 import。index.js 再 re-export 它,对外仍是契约 §3 声明的 `LLMError`。 + */ + +/** @typedef {import('./map-error.js').LLMErrorReason} LLMErrorReason */ + +const ERROR_RESPONSE = { + contentFilter: { code: 'CONTENT_FILTER', statusCode: 400 }, +} + +/** + * 结构化 LLM 错误。永不吞错、永不 console 兜底——失败一律抛它。 + */ +export class LLMError extends Error { + /** + * @param {object} info + * @param {string} info.provider 厂商标识(api_type)。 + * @param {LLMErrorReason} info.reason 归一后的失败类别。 + * @param {boolean} info.retryable 是否属于可重试类。 + * @param {unknown} [info.cause] 原始异常 / 响应体。 + * @param {string} [info.message] 可读消息(缺省由 reason 生成)。 + * @param {Record} [info.detail] 可持久化的结构化失败详情。 + * @param {string|null} [info.provider_request_id] 供应商失败响应中的请求 ID。 + */ + constructor({ provider, reason, retryable, cause, message, detail, provider_request_id = null }) { + const errorMessage = message ?? `[${provider}] LLM request failed: ${reason}` + super(errorMessage) + this.name = 'LLMError' + /** @type {string} */ + this.provider = provider + /** @type {LLMErrorReason} */ + this.reason = reason + /** @type {boolean} */ + this.retryable = retryable + /** @type {Record} */ + this.detail = detail ?? { type: 'message', message: errorMessage } + /** @type {string|null} */ + this.provider_request_id = provider_request_id + const response = ERROR_RESPONSE[reason] + if (response) { + this.code = response.code + this.statusCode = response.statusCode + } + if (cause !== undefined) this.cause = cause + } +} diff --git a/packages/llm/src/index.js b/packages/llm/src/index.js new file mode 100644 index 0000000..7698a17 --- /dev/null +++ b/packages/llm/src/index.js @@ -0,0 +1,187 @@ +/** + * @drama/llm —— 多厂商 LLM 网关公共面(唯一对外入口)。 + * + * 只导出契约 §3 声明的符号:createLLM / createStaticKeyProvider / LLMError / registeredProviders。 + * 内部实现(base/providers/ports/retry/map-error)不外泄;深导入由 ESLint 拦截。 + * + * 设计红线(见 docs/architecture/README.md §2):包内不读 process.env、不建顶层连接、不写盘; + * apiKey/baseUrl 经注入的 keyProvider 提供;成本、审计、结算端口由 composition root 成组注入。 + * + * **无假 provider、无 fetch 注入缝**:网关只会对着真实 socket 说话。E2E 打的是 `.env` 里的真厂商 + * (`pnpm llm:e2e`),不是本地假上游——假上游一旦存在,"代表性的那条成功调用"就会被它顶掉。 + */ + +export { createLLM, createStaticKeyProvider } from './llm.js' +export { LLMError } from './errors.js' +// 适配器名单是网关的知识:`model_provider_capability.adapter` 的合法取值即注册表键集。装配 / seed 侧据此在 +// 启动期校验配置(未注册的 adapter 不得起服务),不再各自抄一份「厂商名 → 协议」的猜测映射。 +export { registeredProviders } from './registry.js' + +/* ===== 端口与数据形态契约(JSDoc @typedef,checkJs 友好,0 个 .ts 文件) ===== */ + +/** + * KeyProvider 端口(由 @drama/keyrouter 或 createStaticKeyProvider 提供,包内只调)。 + * @typedef {object} KeyProvider + * @property {(q:{ vendor:string, model?:string, affinity_key?:string, lease_ttl_ms?:number }) => Promise} acquire + * @property {(key_id:string, usage:object) => void} report_usage + * @property {(key_id:string, retry_after_ms?:number) => void} report_rate_limited + * @property {(key_id:string, kind:string) => void} report_error + */ + +/** + * @typedef {object} KeyLease + * @property {string} api_key + * @property {string|null} account_id + * @property {string|null} base_url + * @property {string|null} key_id + * 网关只把它原样透传给 keyProvider 的回报方法与 AuditPort,不解释它的来源。 + * @property {() => void} release 释放并发槽;务必 finally 调用。 + */ + +/** + * @typedef {object} ModelCostPort + * @property {(input:{provider:string, model:string, usage:object}) => Promise<{pricing_snapshot:object, supplier_cost_micros:number, currency:'CNY', breakdown:object}>} calculate + */ + +/** + * @typedef {object} BillingPort + * @property {(input:{call_id:string,user_id?:string, call_ref:{type:string,id:string}, supplier_cost:{supplier_cost_micros:number,currency:'CNY'}, policy:object, idempotency_key:string}) => Promise<{billing_mode:string,billing_policy_snapshot:object,charged_points:number,ledger_id:string|null}>} settle + */ + +/** + * @typedef {object} NotifyPort + * @property {(event:object, ctx?:object) => Promise} notify + */ + +/** + * AuditPort 端口(由 @drama/llm-audit 提供)。失败记录异步入队;供应商成功事实与结算结果必须 await 落库。 + * @typedef {object} AuditPort + * @property {(entry: AuditEntry) => void} record + * @property {(entry: AuditEntry) => Promise} recordProviderSuccess + */ + +/** + * 一次 chat 的审计条目——在 chat 出口一次性拼齐的完整单一形态(含 null 显式化)。 + * 三出口(成功 / 失败 / abort)都拼齐全部字段:**失败也有 messages 与 key_id**——认证失败正是最需要 + * 知道"用户问了什么、哪把 key 挂了"的时候。 + * @typedef {object} AuditEntry + * @property {string} provider + * @property {string} model + * @property {'ok'|'error'|'aborted'} status + * @property {number|null} ttft_ms + * @property {number} duration_ms + * @property {ChatUsage} usage + * @property {string} request_id + * @property {string} billing_operation_id 业务操作幂等标识;同一业务重试保持不变,和 request_id 分离。 + * @property {string|null} session_id + * @property {string|null} user_id 计费主体(← billing_ctx.user_id);缺省 null。 + * @property {string|null} provider_request_id + * @property {string|null} service_tier + * @property {object|null} pricing_snapshot + * @property {number|null} supplier_cost_micros + * @property {'CNY'|null} currency + * @property {null} billing_mode provider 成功落库时尚未结算,固定为 null。 + * @property {null} billing_policy_snapshot provider 成功落库时尚未结算,固定为 null。 + * @property {null} charged_points + * @property {null} ledger_id + * @property {string|null} error_reason 映射后的 LLMError.reason(成功为 null)。 + * @property {string|null} error_message 可读错误消息(成功为 null)。 + * @property {Record|null} error_detail 第三方边界构造的结构化错误详情(成功为 null)。 + * @property {Date} created_at + * @property {ChatMessage[]} messages 发出去的消息(valibot 校验后的请求),三出口都有。 + * @property {string|null} response_text 回来的正文(← ChatResult.content);失败/abort 为 null。 + * @property {string|null} reasoning_text 回来的推理;无推理或失败为 null。 + * @property {string|null} key_id 服务这次调用的 key(← KeyLease.key_id = llm_key.id);static provider 为 null。 + * @property {string|null} mode 场景名(← ChatOpts.mode,由 mode-router 透传);直接 chat 时 null。 + * @property {string|null} correlation_id 同一次 forMode 调用的所有 attempt 共用(← ChatOpts.correlation_id)。 + * @property {number|null} attempt_index fallback 链上第几步,0 = 主力(← ChatOpts.attempt_index)。 + */ + +/** + * @typedef {object} Span + * @property {(k:string, v:any) => void} setAttr + * @property {(n:string, d?:object) => void} addEvent + * @property {() => void} end + */ + +/** + * @typedef {object} TracePort + * @property {(name:string) => Span} startSpan + */ + +/** + * @typedef {object} ChatMessage + * @property {'system'|'user'|'assistant'|'tool'} role + * @property {any} content + * @property {boolean} [prompt_cache_breakpoint] OpenAI Responses 静态前缀终点。 + */ + +/** + * @typedef {object} ChatRequest + * @property {ChatMessage[]} messages + * @property {string} [model] + * @property {number} [temperature] + * @property {number} [max_tokens] + * @property {{ type:'json_object' } | { type?:'json_schema', name?:string, schema:object }} [response_format] + * 网关规范化 structured output:两种归一形态——json_object(要求合法 JSON,无 schema) + * 与 json_schema(带 schema,name 可选)。入口一次归一,各 provider 按支持度映射/拒绝。 + * @property {Array} [tools] + * @property {object} [thinking] + * @property {Array} [images] + * @property {Array} [videos] + * @property {{ mode:'implicit'|'explicit', ttl?:'30m' }} [prompt_cache_options] + */ + +/** + * @typedef {object} ChatOpts + * @property {boolean} [cache] 请求级 prompt-caching(支持的厂商生效,否则忽略)。 + * @property {number} [timeout_ms] 每次 attempt 独立计时的内部超时毫秒(timeout 可重试)。 + * @property {(chunk:string) => void} [on_content] + * @property {(chunk:string) => void} [on_thinking] + * @property {(calls:Array) => void} [on_tool_call] + * @property {(frame:object) => void} [on_raw_frame] 真实上游 SSE JSON 帧;诊断脚本用于核对协议解析。 + * @property {AbortSignal} [signal] + * @property {{ user_id?:string, idempotency_key?:string, policy?:object }} [billing_ctx] + * `policy` 由 composition root 注入;付费模式下业务方提供 `user_id`,需要跨请求防重时提供稳定的 + * `idempotency_key`。provider、model 和 request_id 均由网关自己的调用事实确定。 + * @property {{ session_id?:string, [k:string]:any }} [trace_ctx] + * @property {string} [prompt_cache_key] OpenAI Prompt Caching 路由键;与 trace session 和 key affinity 独立。 + * @property {string} [mode] 场景名(`mode_model_config.mode`),由 `@drama/llm-router` 的 mode-router 透传;原样落审计。 + * @property {string} [correlation_id] 一次 forMode 调用的所有 attempt 共用的 id,由 `withFallback` 生成;原样落审计。 + * @property {number} [attempt_index] fallback 链上第几步(0 = 主力),由 `withFallback` 给出;原样落审计。 + * @property {(key_id: string|null) => void} [on_key_lease] + * **网关内部注入,调用方不要设置(设了也会被覆盖)**:base 每次 attempt acquire 到 key 后回调, + * 把 `KeyLease.key_id` 交回 createLLM 出口写进 AuditEntry.key_id。 + */ + +/** + * @typedef {object} ChatUsage + * @property {number} prompt_tokens + * @property {number} completion_tokens + * @property {number} total_tokens + * @property {number} cached_tokens + * @property {number} cache_write_tokens + * @property {number} reasoning_tokens + */ + +/** + * @typedef {object} ChatResult + * @property {string} content + * @property {string} [reasoning] + * @property {Array} [tool_calls] + * @property {ChatUsage} usage + * @property {string} provider + * @property {string} model + * @property {{ ttft_ms:number|null, duration_ms:number }} [timings] 成功路径的计时(透传自网关)。 + * @property {string} [request_id] 本次真实厂商调用的全局唯一标识(= llm_call_log.request_id),对账/回查用。 + * @property {string} [billing_operation_id] 稳定业务操作标识;同一业务重试保持不变。 + * @property {ChatUsage & {kind:'token'}} metered_usage + * @property {string|null} provider_request_id + * @property {string|null} service_tier + * @property {{ call_ref:{type:string,id:string}, usage:object, pricing_snapshot:object, supplier_cost_micros:number, currency:'CNY', billing_mode:string, charged_points:number, ledger_id:string|null }} [accounting] + */ + +/** + * @typedef {object} Llm + * @property {(req:ChatRequest, opts?:ChatOpts) => Promise} chat + */ diff --git a/packages/llm/src/llm.js b/packages/llm/src/llm.js new file mode 100644 index 0000000..53c1b58 --- /dev/null +++ b/packages/llm/src/llm.js @@ -0,0 +1,318 @@ +import { randomUUID } from 'node:crypto' +import * as v from 'valibot' +import { LLMError } from './errors.js' +import { EMPTY_USAGE } from './base.js' +import { loadRegistry, registeredProviders } from './registry.js' +import { noopNotifyPort, noopTracePort } from './ports/no-op.js' + +const NOTIFY_FAIL_THRESHOLD = 3 + +const KeyProviderShape = v.object({ + acquire: v.function(), + report_usage: v.function(), + report_rate_limited: v.function(), + report_error: v.function(), +}) + +const ChatRequestSchema = v.object({ + messages: v.pipe( + v.array(v.object({ + role: v.picklist(['system', 'user', 'assistant', 'tool']), + content: v.any(), + prompt_cache_breakpoint: v.optional(v.boolean()), + })), + v.minLength(1), + ), + model: v.optional(v.string()), + temperature: v.optional(v.number()), + max_tokens: v.optional(v.number()), + response_format: v.optional(v.any()), + tools: v.optional(v.array(v.any())), + thinking: v.optional(v.any()), + images: v.optional(v.array(v.any())), + videos: v.optional(v.array(v.any())), + prompt_cache_options: v.optional(v.strictObject({ + mode: v.picklist(['implicit', 'explicit']), + ttl: v.optional(v.literal('30m')), + })), +}) + +/** + * provider 调用、供应商成本、调用日志、积分结算的统一编排入口。 + * costPort/billingPort/auditPort 要么三者全部注入,要么全部省略用于运营探针。 + */ +export function createLLM({ + keyProvider, + provider = 'openai', + vendor = provider, + model, + costPort, + billingPort, + notifyPort, + tracePort, + auditPort, + nowMs, + newId, + retryPolicy, +}) { + assertKeyProvider(keyProvider, provider) + const accounting = accountingPorts({ costPort, billingPort, auditPort }) + const notify = notifyPort ?? noopNotifyPort + const trace = tracePort ?? noopTracePort + const now = nowMs ?? Date.now + const genId = newId ?? randomUUID + const table = loadRegistry() + if (!table[provider]) { + throw new LLMError({ + provider, + reason: 'badRequest', + retryable: false, + message: `createLLM: unknown provider '${provider}'. Registered providers: [${registeredProviders().join(', ')}]`, + }) + } + + let instance + let consecutiveFailures = 0 + + async function ensureInstance() { + if (instance) return instance + const module = await table[provider]() + instance = new module.default({ keyProvider, provider, vendor, model, tracePort: trace, retryPolicy, nowMs: now }) + return instance + } + + async function chat(req, opts = {}) { + const parsed = parseChatRequest(req, provider) + const impl = await ensureInstance() + const usedModel = parsed.model ?? model ?? '' + const requestId = genId() + const billingOperationId = opts.billing_ctx?.idempotency_key ?? requestId + const startedAt = now() + const sessionId = opts.trace_ctx?.session_id ?? null + let failureUsage = EMPTY_USAGE + let failureTtftMs = null + let keyId = null + const implOpts = { ...opts, on_key_lease: (id) => { keyId = id } } + + let result + try { + result = await impl.chat(parsed, implOpts) + failureUsage = result.usage + failureTtftMs = result.timings?.ttft_ms ?? null + consecutiveFailures = 0 + } catch (error) { + const llmError = normalizeLlmError(error, provider) + const { provider_request_id } = llmError + if (accounting) { + accounting.auditPort.record({ + provider: vendor, + model: usedModel, + request_id: requestId, + billing_operation_id: billingOperationId, + session_id: sessionId, + user_id: opts.billing_ctx?.user_id ?? null, + key_id: keyId, + created_at: new Date(now()), + status: llmError.reason === 'aborted' ? 'aborted' : 'error', + ttft_ms: failureTtftMs, + duration_ms: now() - startedAt, + usage: failureUsage, + provider_request_id, + service_tier: null, + pricing_snapshot: null, + supplier_cost_micros: null, + currency: null, + billing_mode: null, + billing_policy_snapshot: null, + charged_points: null, + ledger_id: null, + error_reason: llmError.reason, + error_message: llmError.message, + error_detail: llmError.detail, + messages: parsed.messages, + response_text: null, + reasoning_text: null, + mode: opts.mode ?? null, + correlation_id: opts.correlation_id ?? null, + attempt_index: opts.attempt_index ?? null, + }) + } + if (llmError.reason !== 'aborted') { + consecutiveFailures += 1 + if (consecutiveFailures >= NOTIFY_FAIL_THRESHOLD) { + consecutiveFailures = 0 + await notify.notify( + { kind: 'llm.consecutiveFailures', provider, reason: llmError.reason }, + opts.billing_ctx, + ) + } + } + throw llmError + } + + const { + content, + reasoning, + tool_calls, + usage, + provider_request_id, + service_tier, + timings = { ttft_ms: null, duration_ms: now() - startedAt }, + } = result + const metered_usage = { kind: 'token', ...usage } + const out = { + content, + usage, + metered_usage, + provider: vendor, + model: usedModel, + timings, + request_id: requestId, + billing_operation_id: billingOperationId, + provider_request_id, + service_tier, + } + if (reasoning) out.reasoning = reasoning + if (tool_calls) out.tool_calls = tool_calls + if (!accounting) return out + + const calculated = await accounting.costPort.calculate({ provider: vendor, model: usedModel, usage: metered_usage }) + await accounting.auditPort.recordProviderSuccess({ + provider: vendor, + model: usedModel, + request_id: requestId, + billing_operation_id: billingOperationId, + session_id: sessionId, + user_id: opts.billing_ctx?.user_id ?? null, + key_id: keyId, + created_at: new Date(now()), + status: 'ok', + ...timings, + usage, + provider_request_id, + service_tier, + pricing_snapshot: calculated.pricing_snapshot, + supplier_cost_micros: calculated.supplier_cost_micros, + currency: calculated.currency, + billing_mode: null, + billing_policy_snapshot: null, + charged_points: null, + ledger_id: null, + error_reason: null, + error_message: null, + error_detail: null, + messages: parsed.messages, + response_text: content, + reasoning_text: reasoning ?? null, + mode: opts.mode ?? null, + correlation_id: opts.correlation_id ?? null, + attempt_index: opts.attempt_index ?? null, + }) + + const settlement = await accounting.billingPort.settle({ + call_id: requestId, + user_id: opts.billing_ctx?.user_id, + call_ref: { type: 'llm_operation', id: billingOperationId }, + supplier_cost: { + supplier_cost_micros: calculated.supplier_cost_micros, + currency: calculated.currency, + }, + policy: opts.billing_ctx?.policy, + idempotency_key: billingOperationId, + }) + out.accounting = { + call_ref: { type: 'llm_operation', id: billingOperationId }, + usage: metered_usage, + pricing_snapshot: calculated.pricing_snapshot, + supplier_cost_micros: calculated.supplier_cost_micros, + currency: calculated.currency, + billing_mode: settlement.billing_mode, + billing_policy_snapshot: settlement.billing_policy_snapshot, + charged_points: settlement.charged_points, + ledger_id: settlement.ledger_id, + } + return out + } + + return { chat } +} + +function parseChatRequest(req, provider) { + try { + return v.parse(ChatRequestSchema, req) + } catch (cause) { + throw new LLMError({ + provider, + reason: 'badRequest', + retryable: false, + cause, + message: 'createLLM.chat: invalid ChatRequest', + }) + } +} + +function assertKeyProvider(keyProvider, provider) { + if (!keyProvider) { + throw new LLMError({ provider, reason: 'badRequest', retryable: false, message: 'createLLM: keyProvider is required' }) + } + try { + v.parse(KeyProviderShape, keyProvider) + } catch (cause) { + throw new LLMError({ + provider, + reason: 'badRequest', + retryable: false, + cause, + message: 'createLLM: keyProvider does not satisfy the KeyProvider port shape', + }) + } +} + +function accountingPorts({ costPort, billingPort, auditPort }) { + const ports = [costPort, billingPort, auditPort] + const present = ports.filter(Boolean).length + if (present === 0) return null + if (present !== ports.length) { + throw new Error('createLLM: costPort、billingPort、auditPort 必须同时注入') + } + if (typeof costPort.calculate !== 'function') throw new Error('createLLM: costPort.calculate 必须是函数') + if (typeof billingPort.settle !== 'function') throw new Error('createLLM: billingPort.settle 必须是函数') + if ( + typeof auditPort.record !== 'function' || + typeof auditPort.recordProviderSuccess !== 'function' + ) { + throw new Error('createLLM: auditPort 缺少 record/recordProviderSuccess') + } + return { costPort, billingPort, auditPort } +} + +function normalizeLlmError(error, provider) { + if (error instanceof LLMError) return error + if (!(error instanceof Error)) throw new Error(`[${provider}] provider 抛出了非 Error 值`) + return new LLMError({ + provider, + reason: 'unknown', + retryable: false, + cause: error, + detail: { type: 'runtime', name: error.name, message: error.message }, + }) +} + +export function createStaticKeyProvider({ api_key, base_url }) { + if (!api_key) { + throw new LLMError({ + provider: 'static', + reason: 'badRequest', + retryable: false, + message: 'createStaticKeyProvider: api_key is required', + }) + } + return { + async acquire() { + return { api_key, base_url, account_id: null, key_id: null, release() {} } + }, + report_usage() {}, + report_rate_limited() {}, + report_error() {}, + } +} diff --git a/packages/llm/src/map-error.js b/packages/llm/src/map-error.js new file mode 100644 index 0000000..86ab579 --- /dev/null +++ b/packages/llm/src/map-error.js @@ -0,0 +1,98 @@ +/** + * 错误归一 —— 把厂商 HTTP 状态 / 抛出的异常映射成 LLMError 的 reason + retryable。 + * + * 通用分类(HTTP 状态、超时/abort 判定)单源在 @drama/service-call,本文件导入并再导出, + * 只保留 LLM 域特有的判定(keyrouter 结构化错误、OpenAI Responses 终态、网络异常归并)。 + * 子类与编排层都用它,保证「错误分类」只有一处来源。 + * reason ∈ { 'auth', 'rateLimit', 'transient', 'badRequest', 'contentFilter', 'timeout', 'aborted', 'unknown' }。 + */ + +import { mapHttpStatus, isTimeoutError, isAbortError } from '@drama/service-call' + +export { mapHttpStatus, isTimeoutError, isAbortError } + +/** + * @typedef {'auth'|'rateLimit'|'transient'|'badRequest'|'contentFilter'|'timeout'|'aborted'|'unknown'} LLMErrorReason + */ + +/** + * @typedef {object} MappedError + * @property {LLMErrorReason} reason + * @property {boolean} retryable + */ + +/** + * OpenAI Responses `response.failed` 事件的 error.code → reason/retryable。 + * 与 mapHttpStatus 同源的分类惯例(终态语义错误无 HTTP 状态可依,按 code 归一): + * rate_limit_exceeded = rateLimit(重试);server_error = transient(重试);其余 = badRequest(不重试)。 + * @param {string|undefined} code + * @returns {MappedError} + */ +export function mapResponsesFailure(code) { + if (code === 'rate_limit_exceeded') return { reason: 'rateLimit', retryable: true } + if (code === 'server_error') return { reason: 'transient', retryable: true } + return { reason: 'badRequest', retryable: false } +} + +/** + * OpenAI-compatible Chat Completions 的 finish_reason → 错误分类。 + * stop/tool_calls/function_call 是完整终态;length 是供应商正常返回的截断结果, + * 对 JSON/工具调用上层都不可消费,必须进入可重试/降级链。 + * content_filter 在 HTTP 错误里代表输入被供应商拒绝;在 Chat finish_reason 里代表 + * 供应商已经生成了不可消费的截断输出,只允许跨供应商 fallback,不在同供应商内重试。 + * @param {string|null} reason + * @returns {MappedError | null} + */ +export function mapChatFinishReason(reason) { + if (reason === null || reason === 'stop' || reason === 'tool_calls' || reason === 'function_call') return null + if (reason === 'length') return { reason: 'transient', retryable: true } + if (reason === 'content_filter') return { reason: 'contentFilter', retryable: true } + return { reason: 'unknown', retryable: false } +} + +/** + * 判定并归一 keyrouter 抛出的结构化错误(不 import @drama/keyrouter,靠 name / duck-type 识别)。 + * 命中返回归一后的 reason/retryable;非 keyrouter 错误返回 null(交回后续通用判定)。 + * 有明确冷却窗口(retryAfterMs)→ rateLimit(可重试);否则按其 retryable 归一为 transient + * (keyrouter 的 noKeyAvailable/slotTimeout/store 都是「稍后可能好」的瞬态,config 缺失为硬错不重试)。 + * @param {unknown} err + * @returns {MappedError | null} + */ +export function mapKeyRouterError(err) { + if (!err || typeof err !== 'object') return null + const e = /** @type {{ name?:string, retryAfterMs?:number, kind?:string, retryable?:boolean }} */ (err) + const isKeyRouter = + e.name === 'KeyRouterError' || typeof e.retryAfterMs === 'number' || typeof e.kind === 'string' + if (!isKeyRouter) return null + if (typeof e.retryAfterMs === 'number') return { reason: 'rateLimit', retryable: true } + return { reason: 'transient', retryable: e.retryable !== false } +} + +/** + * 把任意抛出的异常(网络错误 / abort / keyrouter 结构化 / 已是结构化)映射成 reason/retryable。 + * 网络层错误(fetch 抛 TypeError、连接重置)当作 transient 可重试。 + * @param {unknown} err + * @returns {MappedError} + */ +export function mapThrownError(err) { + // timeout 先于 aborted 判定:内部超时可重试,外部 abort 不重试;二者 name 不同、互斥。 + if (isTimeoutError(err)) return { reason: 'timeout', retryable: true } + if (isAbortError(err)) return { reason: 'aborted', retryable: false } + // keyrouter 结构化错误(acquire 无 key / 槽超时等):归一为 rateLimit/transient,不落 unknown。 + const kr = mapKeyRouterError(err) + if (kr) return kr + // fetch 网络故障:TypeError('fetch failed') / ECONNRESET / ETIMEDOUT 等。 + if (err && typeof err === 'object') { + const e = /** @type {{ code?:string, name?:string }} */ (err) + if ( + e.name === 'TypeError' || + e.code === 'ECONNRESET' || + e.code === 'ETIMEDOUT' || + e.code === 'ECONNREFUSED' || + e.code === 'UND_ERR_SOCKET' + ) { + return { reason: 'transient', retryable: true } + } + } + return { reason: 'unknown', retryable: false } +} diff --git a/packages/llm/src/media.js b/packages/llm/src/media.js new file mode 100644 index 0000000..31d2ceb --- /dev/null +++ b/packages/llm/src/media.js @@ -0,0 +1,48 @@ +/** + * mime 魔数纯函数模块 —— 全仓唯一魔数落点。 + * + * 零副作用、零依赖:`base.js` 的 buildMediaParts 一行 import 消费;经 package.json 的 `"./media"` + * 子路径导出,供 `@drama/media-resolver` 复用(魔数表不在两包各写一份)。 + * + * 魔数表移植自 game-ui `utils/media-resolver.js` 的 detectMimeFromBuffer。 + */ + +/** + * 按字节魔数嗅探 mime;命中返回 mime 字符串,未识别返回 null(不兜底 octet-stream)。 + * @param {Buffer} buffer + * @returns {string | null} + */ +export function sniffMime(buffer) { + if (buffer.length < 4) return null + const h = buffer + // PNG 89 50 4E 47 + if (h[0] === 0x89 && h[1] === 0x50 && h[2] === 0x4e && h[3] === 0x47) return 'image/png' + // JPEG FF D8 FF + if (h[0] === 0xff && h[1] === 0xd8 && h[2] === 0xff) return 'image/jpeg' + // GIF 47 49 46 + if (h[0] === 0x47 && h[1] === 0x49 && h[2] === 0x46) return 'image/gif' + // WebP RIFF....WEBP + if ( + h[0] === 0x52 && h[1] === 0x49 && h[2] === 0x46 && h[3] === 0x46 && + h[8] === 0x57 && h[9] === 0x45 && h[10] === 0x42 && h[11] === 0x50 + ) return 'image/webp' + // BMP 42 4D + if (h[0] === 0x42 && h[1] === 0x4d) return 'image/bmp' + // MP4/MOV: offset4 66 74 79 70 (ftyp) + if (h[4] === 0x66 && h[5] === 0x74 && h[6] === 0x79 && h[7] === 0x70) return 'video/mp4' + return null +} + +/** data URL 解析正则:`data:;base64,`。 */ +const DATA_URL_RE = /^data:([^;,]+);base64,(.*)$/s + +/** + * 解析 base64 data URL;合法返回 { mime, data },非法返回 null。 + * @param {string} str + * @returns {{ mime:string, data:string } | null} + */ +export function parseDataUrl(str) { + const m = DATA_URL_RE.exec(str) + if (!m) return null + return { mime: m[1], data: m[2] } +} diff --git a/packages/llm/src/ports/no-op.js b/packages/llm/src/ports/no-op.js new file mode 100644 index 0000000..be3e18b --- /dev/null +++ b/packages/llm/src/ports/no-op.js @@ -0,0 +1,29 @@ +/** + * 无副作用端口缺省实现。成本、审计、结算是会计链路,不提供 no-op。 + * + * 纯函数 / 零副作用 / 零依赖。`llm.js` 在装配时用这些填补缺省端口, + * 使得包内其余代码无需到处写 `if (billingPort)` 守卫。 + */ + +/** @typedef {import('../index.js').NotifyPort} NotifyPort */ +/** @typedef {import('../index.js').TracePort} TracePort */ +/** @typedef {import('../index.js').Span} Span */ + +/** @type {NotifyPort} */ +export const noopNotifyPort = { + async notify() {}, +} + +/** @type {Span} */ +const noopSpan = { + setAttr() {}, + addEvent() {}, + end() {}, +} + +/** @type {TracePort} */ +export const noopTracePort = { + startSpan() { + return noopSpan + }, +} diff --git a/packages/llm/src/providers/llm.anthropic.js b/packages/llm/src/providers/llm.anthropic.js new file mode 100644 index 0000000..91f57ea --- /dev/null +++ b/packages/llm/src/providers/llm.anthropic.js @@ -0,0 +1,208 @@ +/** + * Anthropic Messages 子类 —— 只覆写 chat + messageToValue。 + * + * 厂商专有概念全部圈在本文件:`x-api-key`/`anthropic-version` header、system 顶级字段、 + * 必填 max_tokens、`content_block_delta`/`message_delta` 事件帧、`cache_control`(prompt-caching)。 + * prompt-caching 是请求级 opt(opts.cache):支持时在最后一条 system/user 注入 cache_control, + * 不另起并行 provider。流式/重试/trace/多模态归一仍全在 LLMBase。 + */ + +import { LLMBase } from '../base.js' + +const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1' +const ANTHROPIC_VERSION = '2023-06-01' +const DEFAULT_MAX_TOKENS = 4096 + +export default class AnthropicProvider extends LLMBase { + /** + * @param {import('../index.js').ChatRequest} req + * @param {import('../index.js').ChatOpts} [opts] + * @returns {Promise} + */ + async chat(req, opts = {}) { + const model = req.model ?? this.model + return this.request({ + req, + opts, + affinity_key: opts.trace_ctx?.session_id, + doFetch: (lease, { signal }) => { + const base = lease.base_url ?? DEFAULT_BASE_URL + const headers = { + 'content-type': 'application/json', + 'x-api-key': lease.api_key, + 'anthropic-version': ANTHROPIC_VERSION, + } + const { system, messages } = this._splitMessages(req, opts.cache === true) + /** @type {Record} */ + const body = { + model, + messages, + max_tokens: req.max_tokens ?? DEFAULT_MAX_TOKENS, + stream: true, + } + if (system) body.system = system + if (req.temperature != null) body.temperature = req.temperature + if (req.tools) body.tools = this._toAnthropicTools(req.tools) + if (req.thinking) body.thinking = req.thinking + this.applyResponseFormat(body, headers, req.response_format) + return this.httpFetch(`${base}/messages`, { + method: 'POST', + signal, + headers, + body: JSON.stringify(body), + }) + }, + }) + } + + /** + * Anthropic structured output 分派表:Messages API 以 output_config.format 表达 json_schema + * (+ beta gate header)。无 schema-less 的 json_object 模式,故只声明 json_schema; + * json_object 由 base 统一 badRequest。 + * @protected + * @returns {Record, headers:Record, rf:{schema?:object, name?:string}) => void>} + */ + responseFormatHandlers() { + return { + json_schema: (body, headers, rf) => { + body.output_config = { format: { type: 'json_schema', name: rf.name ?? 'response', schema: rf.schema } } + headers['anthropic-beta'] = 'structured-outputs-2025-11-13' + }, + } + } + + /** + * 拆 system 顶级 + user/assistant messages;按需注入 cache_control(prompt-caching)。 + * @private + * @param {import('../index.js').ChatRequest} req + * @param {boolean} cache + * @returns {{ system:any, messages:Array<{ role:string, content:any }> }} + */ + _splitMessages(req, cache) { + /** @type {any} */ + let system + /** @type {Array<{ role:string, content:any }>} */ + const messages = [] + for (const m of req.messages) { + if (m.role === 'system') { + system = m.content + continue + } + if (m.role === 'assistant' && m.content?.kind === 'tool_calls') { + messages.push({ + role: 'assistant', + content: m.content.tool_calls.map((c) => ({ type: 'tool_use', id: c.id, name: c.name, input: c.args })), + }) + continue + } + if (m.role === 'tool') { + // Anthropic 无 tool 角色:tool_result 作为 user block 落地。 + messages.push({ + role: 'user', + content: [{ type: 'tool_result', tool_use_id: m.content.tool_call_id, content: m.content.result }], + }) + continue + } + messages.push({ role: m.role, content: m.content }) + } + const images = this.buildMediaParts(req.images, 'image') + if (images.length && messages.length) { + const last = messages[messages.length - 1] + /** @type {Array} */ + const blocks = [{ type: 'text', text: typeof last.content === 'string' ? last.content : '' }] + for (const img of images) { + blocks.push({ + type: 'image', + source: img.url + ? { type: 'url', url: img.url } + : { type: 'base64', media_type: img.mime, data: img.data }, + }) + } + last.content = blocks + } + if (cache && system != null) { + // 把 system 升级为带 cache_control 的 block 列表(Anthropic 专有,留在本文件内)。 + system = [{ type: 'text', text: String(system), cache_control: { type: 'ephemeral' } }] + } + return { system, messages } + } + + /** + * 中性 tool spec → Anthropic tool 形态(parameters 即 input_schema)。 + * @protected + * @param {Array<{ name:string, description:string, parameters:object }>} tools + * @returns {Array} + */ + _toAnthropicTools(tools) { + return tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })) + } + + /** + * @param {any} frame Anthropic SSE data 帧(已解析 JSON)。 + * @returns {import('../base.js').ParsedValue | null} + */ + messageToValue(frame) { + if (!frame || typeof frame !== 'object') return null + switch (frame.type) { + case 'content_block_start': { + const cb = frame.content_block + if (cb?.type !== 'tool_use') return null + // 工具参数不在此帧(cb.input 恒 {}):只发 id/name 首帧,参数经后续 input_json_delta 分片,base 按 index 聚合。 + return { type: 'tool_call_delta', index: frame.index, id: cb.id, name: cb.name, args_fragment: '' } + } + case 'content_block_delta': { + const d = frame.delta + if (d?.type === 'thinking_delta' && typeof d.thinking === 'string') { + return { type: 'reasoning', text: d.thinking } + } + if (d?.type === 'text_delta' && typeof d.text === 'string') { + return { type: 'text', text: d.text } + } + // 工具参数分片:partial_json 拼进对应 index 的 argsText,base 流结束时一次 JSON.parse。 + if (d?.type === 'input_json_delta' && typeof d.partial_json === 'string') { + return { type: 'tool_call_delta', index: frame.index, args_fragment: d.partial_json } + } + return null + } + case 'message_start': { + const u = frame.message?.usage + if (!u) return null + return { + type: 'usage', + // 不在此设 total_tokens:output 由后续 message_delta 给最终值, + // base 在流结束时按 prompt+completion 兜底重算 total。 + usage: { + prompt_tokens: requiredUsageInteger(u.input_tokens, 'input_tokens'), + completion_tokens: requiredUsageInteger(u.output_tokens, 'output_tokens'), + cached_tokens: optionalUsageInteger(u.cache_read_input_tokens, 'cache_read_input_tokens'), + }, + } + } + case 'message_delta': { + const u = frame.usage + if (!u) return null + // message_delta 只更新 output_tokens;保留为 usage 增量(base 以最后值覆盖)。 + return { + type: 'usage', + usage: { + completion_tokens: requiredUsageInteger(u.output_tokens, 'output_tokens'), + }, + } + } + default: + return null + } + } +} + +function requiredUsageInteger(value, field) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`[anthropic] usage.${field} 必须是非负安全整数`) + } + return value +} + +function optionalUsageInteger(value, field) { + if (value === undefined) return 0 + return requiredUsageInteger(value, field) +} diff --git a/packages/llm/src/providers/llm.azure_foundry.js b/packages/llm/src/providers/llm.azure_foundry.js new file mode 100644 index 0000000..65e34a6 --- /dev/null +++ b/packages/llm/src/providers/llm.azure_foundry.js @@ -0,0 +1,92 @@ +/** + * Azure AI Foundry OpenAI-compatible provider. + * + * Azure Foundry endpoints used by this repo expose an OpenAI-compatible + * `/chat/completions` API when `LLM_BASE_URL` points at the resource's + * `/openai/v1` base URL. Request/stream parsing is therefore identical to the + * OpenAI provider. Azure resource keys use the `api-key` header, and there is + * no safe global default host for Azure deployments, so baseUrl must be + * injected by the key provider. + * + * Filename `llm.azure_foundry.js` registers provider key `azure_foundry`. + */ + +import { LLMError } from '../errors.js' +import OpenAIProvider from './llm.openai.js' + +export default class AzureFoundryProvider extends OpenAIProvider { + /** + * Azure 将内容安全拦截作为 HTTP 400 返回。对剧情运行时,这通常是供应商对完整 prompt + * 或生成方向的拒绝,不等价于玩家请求参数错误;允许路由层切到下一个供应商。 + * @protected + * @param {Response} res + * @param {unknown} body + * @param {string} reason + */ + httpErrorInfo(res, body, reason) { + if (isContentFilterError(body)) { + return { + reason: 'contentFilter', + retryable: true, + message: '[azure_foundry] content filtered', + provider_request_id: res.headers.get('x-request-id'), + } + } + return super.httpErrorInfo(res, body, reason) + } + + /** + * @protected + * @param {import('../index.js').KeyLease} lease + * @returns {Record} + */ + chatCompletionsHeaders(lease) { + return { + 'content-type': 'application/json', + 'api-key': lease.api_key, + } + } + + /** + * @protected + * @param {string|undefined} model + * @returns {string} + */ + maxTokensParamName(model) { + return isGpt5Model(model) ? 'max_completion_tokens' : 'max_tokens' + } + + /** + * Azure Foundry OpenAI v1 rejects the non-standard `thinking` parameter. + * @protected + * @returns {boolean} + */ + supportsThinkingParam() { + return false + } + + /** @returns {string} */ + defaultBaseUrl() { + throw new LLMError({ + provider: this.provider, + reason: 'badRequest', + retryable: false, + message: 'azure_foundry requires LLM_BASE_URL, for example https://.services.ai.azure.com/openai/v1', + }) + } +} + +/** + * @param {string|undefined} model + * @returns {boolean} + */ +function isGpt5Model(model) { + return /^gpt-5(?:[.-]|$)/i.test(String(model || '')) +} + +function isContentFilterError(body) { + if (!body || Object.getPrototypeOf(body) !== Object.prototype) return false + const error = body.error + if (!error || Object.getPrototypeOf(error) !== Object.prototype) return false + return error.code === 'content_filter' +} diff --git a/packages/llm/src/providers/llm.codex.js b/packages/llm/src/providers/llm.codex.js new file mode 100644 index 0000000..c7df74e --- /dev/null +++ b/packages/llm/src/providers/llm.codex.js @@ -0,0 +1,74 @@ +import { randomUUID } from 'node:crypto' + +import OpenAIResponsesProvider from './llm.openai_responses.js' + +const CODEX_RESPONSES_BASE_URL = 'https://chatgpt.com/backend-api/codex' + +/** ChatGPT Codex 内部 Responses 协议。认证由 composition root 通过 KeyLease 注入。 */ +export default class CodexProvider extends OpenAIResponsesProvider { + defaultBaseUrl() { + return CODEX_RESPONSES_BASE_URL + } + + responsesRequest(lease, _req, opts, _headers, body) { + const requestId = randomUUID() + const sessionId = opts.trace_ctx?.session_id ?? requestId + const turnId = randomUUID() + const headers = { + accept: 'text/event-stream', + authorization: `Bearer ${lease.api_key}`, + 'chatgpt-account-id': lease.account_id, + 'content-type': 'application/json', + originator: 'codex-tui', + 'x-client-request-id': requestId, + 'session-id': sessionId, + 'thread-id': sessionId, + 'x-openai-internal-codex-responses-lite': 'true', + } + delete body.prompt_cache_options + if (opts.trace_ctx?.session_id) body.prompt_cache_key = opts.trace_ctx.session_id + for (const message of body.input) { + for (const content of message.content) delete content.prompt_cache_breakpoint + } + delete body.max_output_tokens + delete body.temperature + for (const item of body.input) item.type = 'message' + body.tools = [] + body.tool_choice = 'auto' + body.parallel_tool_calls = false + body.reasoning = { effort: 'low', context: 'all_turns' } + body.include = ['reasoning.encrypted_content'] + body.text = { ...body.text, verbosity: 'low' } + body.client_metadata = { + session_id: sessionId, + thread_id: sessionId, + turn_id: turnId, + } + return { headers, body } + } + + responseFormatHandlers() { + return { + json_object: (body) => { + body.text = { + format: { + type: 'json_schema', + name: 'json_object', + strict: false, + schema: { type: 'object', additionalProperties: true }, + }, + } + }, + json_schema: (body, _headers, response_format) => { + body.text = { + format: { + type: 'json_schema', + name: response_format.name ?? 'response', + strict: true, + schema: response_format.schema, + }, + } + }, + } + } +} diff --git a/packages/llm/src/providers/llm.gemini.js b/packages/llm/src/providers/llm.gemini.js new file mode 100644 index 0000000..04b160c --- /dev/null +++ b/packages/llm/src/providers/llm.gemini.js @@ -0,0 +1,162 @@ +/** + * Gemini 子类 —— streamGenerateContent?alt=sse 原生协议。继承 LLMBase(协议与 OpenAI 异构)。 + * + * 厂商专有概念全部圈在本文件:`x-goog-api-key` 认证(无 authorization)、`contents`/`systemInstruction` + * 形态、image/video 的 `inline_data`(base64)/`file_data`(url)多模态块、`candidates[].content.parts` + * 与 `usageMetadata` 帧解析。代理不在包内探测,经 lease.baseUrl 注入承载。 + * + * 流式/重试/trace/usage 累积仍全在 LLMBase;`alt=sse` 输出标准 `data: {json}` 帧,iterateSse 直接分帧。 + */ + +import { LLMBase } from '../base.js' + +const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta' + +export default class GeminiProvider extends LLMBase { + /** + * @protected + * @returns {string} + */ + defaultBaseUrl() { + return DEFAULT_BASE_URL + } + + /** + * @protected + * @returns {boolean} + */ + supportsVideo() { + return true + } + + /** + * @param {import('../index.js').ChatRequest} req + * @param {import('../index.js').ChatOpts} [opts] + * @returns {Promise} + */ + async chat(req, opts = {}) { + const model = req.model ?? this.model + return this.request({ + req, + opts, + affinity_key: opts.trace_ctx?.session_id, + doFetch: (lease, { signal }) => { + const base = lease.base_url ?? this.defaultBaseUrl() + const { systemInstruction, contents } = this._toGeminiContents(req) + /** @type {Record} */ + const body = { contents } + if (systemInstruction) body.systemInstruction = systemInstruction + if (req.temperature != null) body.generationConfig = { temperature: req.temperature } + this.applyResponseFormat(body, {}, req.response_format) + return this.httpFetch(`${base}/models/${model}:streamGenerateContent?alt=sse`, { + method: 'POST', + signal, + headers: { 'content-type': 'application/json', 'x-goog-api-key': lease.api_key }, + body: JSON.stringify(body), + }) + }, + }) + } + + /** + * Gemini structured output 分派表: + * json_object → responseMimeType='application/json'(无 schema 约束)。 + * json_schema → responseMimeType + responseSchema。 + * @protected + * @returns {Record, headers:Record, rf:{schema?:object, name?:string}) => void>} + */ + responseFormatHandlers() { + return { + json_object: (body) => { + body.generationConfig = { + ...(body.generationConfig ?? {}), + responseMimeType: 'application/json', + } + }, + json_schema: (body, _headers, rf) => { + body.generationConfig = { + ...(body.generationConfig ?? {}), + responseMimeType: 'application/json', + responseSchema: rf.schema, + } + }, + } + } + + /** + * messages → { systemInstruction?, contents }。system 累积为 systemInstruction; + * 其余转 { role:'user'|'model', parts }。image/video 追加到最后一条 user parts。 + * @private + * @param {import('../index.js').ChatRequest} req + * @returns {{ systemInstruction?:object, contents:Array }} + */ + _toGeminiContents(req) { + /** @type {string[]} */ + const systemTexts = [] + /** @type {Array} */ + const contents = [] + for (const m of req.messages) { + if (m.role === 'system') { + systemTexts.push(String(m.content)) + continue + } + contents.push({ + role: m.role === 'assistant' ? 'model' : 'user', + parts: [{ text: String(m.content) }], + }) + } + if (contents.length) { + const last = contents[contents.length - 1] + for (const img of this.buildMediaParts(req.images, 'image')) last.parts.push(mediaPart(img)) + for (const vid of this.buildMediaParts(req.videos, 'video')) last.parts.push(mediaPart(vid)) + } + const out = { contents } + if (systemTexts.length) out.systemInstruction = { parts: [{ text: systemTexts.join('\n') }] } + return out + } + + /** + * @param {any} frame Gemini SSE data 帧(已解析 JSON)。 + * @returns {import('../base.js').ParsedValue | null} + */ + messageToValue(frame) { + if (!frame || typeof frame !== 'object') return null + // 同帧可同时带 candidates 文本与 usageMetadata:两者都发(数组返回),base 展开消费,杜绝丢 usage(漏计费)。 + /** @type {Array} */ + const out = [] + const part = frame.candidates?.[0]?.content?.parts?.[0] + if (typeof part?.text === 'string' && part.text.length > 0) out.push({ type: 'text', text: part.text }) + const u = frame.usageMetadata + if (u) { + out.push({ + type: 'usage', + usage: { + prompt_tokens: optionalUsageInteger(u.promptTokenCount, 'promptTokenCount'), + completion_tokens: optionalUsageInteger(u.candidatesTokenCount, 'candidatesTokenCount'), + total_tokens: optionalUsageInteger(u.totalTokenCount, 'totalTokenCount'), + cached_tokens: optionalUsageInteger(u.cachedContentTokenCount, 'cachedContentTokenCount'), + }, + }) + } + return out.length ? out : null + } +} + +function optionalUsageInteger(value, field) { + if (value === undefined) return 0 + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`[gemini] usageMetadata.${field} 必须是非负安全整数`) + } + return value +} + +/** + * MediaPart → Gemini part:url → file_data;base64 字节 → inline_data(mime 来自魔数归一)。 + * @param {import('../base.js').MediaPart} part + * @returns {object} + */ +function mediaPart(part) { + return part.url + ? { file_data: { file_uri: part.url, mime_type: part.mime } } + : { inline_data: { mime_type: part.mime, data: part.data } } +} diff --git a/packages/llm/src/providers/llm.moonshot.js b/packages/llm/src/providers/llm.moonshot.js new file mode 100644 index 0000000..cdfa151 --- /dev/null +++ b/packages/llm/src/providers/llm.moonshot.js @@ -0,0 +1,30 @@ +/** + * Moonshot 子类 —— Moonshot 的对话补全 API 与 OpenAI Chat Completions 兼容, + * 差异仅在默认 baseUrl。故继承 OpenAIProvider,只覆写 defaultBaseUrl(); + * chat 与 messageToValue 直接复用父类(同 OpenAI 帧形态)。不复制流式/重试/trace。 + * + * 文件名 `llm.moonshot.js` 即注册键 `moonshot`。 + */ + +import OpenAIProvider from './llm.openai.js' + +export default class MoonshotProvider extends OpenAIProvider { + /** @returns {string} */ + defaultBaseUrl() { + return 'https://api.moonshot.cn/v1' + } + + /** + * Moonshot 支持 json_object(response_format.type='json_object',与 OpenAI 同形,原样透传), + * 不支持 json_schema。故分派表只声明 json_object;json_schema 由 base 统一 badRequest。 + * @protected + * @returns {Record, headers:Record, rf:{schema?:object, name?:string}) => void>} + */ + responseFormatHandlers() { + return { + json_object: (body) => { + body.response_format = { type: 'json_object' } + }, + } + } +} diff --git a/packages/llm/src/providers/llm.openai.js b/packages/llm/src/providers/llm.openai.js new file mode 100644 index 0000000..49026a1 --- /dev/null +++ b/packages/llm/src/providers/llm.openai.js @@ -0,0 +1,253 @@ +/** + * OpenAI Chat Completions 子类 —— 只覆写 chat + messageToValue。 + * + * 流式解析 / 重试 / trace / 多模态归一全部在 LLMBase,本文件不复制其中任何一项。 + * 厂商专有概念(Bearer header、`choices[].delta`、`stream_options`、usage 字段名)只留在此。 + */ + +import { LLMBase } from '../base.js' + +const DEFAULT_BASE_URL = 'https://api.openai.com/v1' + +export default class OpenAIProvider extends LLMBase { + /** + * 缺省 baseUrl。OpenAI 兼容厂商(如 Moonshot)只需覆写本方法即可换 host, + * 无需复制 chat 逻辑。lease.baseUrl 永远优先。 + * @protected + * @returns {string} + */ + defaultBaseUrl() { + return DEFAULT_BASE_URL + } + + /** + * @param {import('../index.js').ChatRequest} req + * @param {import('../index.js').ChatOpts} [opts] + * @returns {Promise} + */ + async chat(req, opts = {}) { + const model = req.model ?? this.model + const result = await this.request({ + req, + opts, + affinity_key: opts.trace_ctx?.session_id, + doFetch: (lease, { signal }) => { + const base = lease.base_url ?? this.defaultBaseUrl() + const headers = this.chatCompletionsHeaders(lease) + /** @type {Record} */ + const body = { + model, + messages: this._toOpenAIMessages(req), + stream: true, + stream_options: { include_usage: true }, + } + if (req.temperature != null) body.temperature = req.temperature + if (req.max_tokens != null) body[this.maxTokensParamName(model)] = req.max_tokens + if (req.tools) body.tools = this._toOpenAITools(req.tools) + if (req.thinking && this.supportsThinkingParam(model)) body.thinking = req.thinking + this.applyResponseFormat(body, headers, req.response_format) + return this.httpFetch(`${base}/chat/completions`, { + method: 'POST', + signal, + headers, + body: JSON.stringify(body), + }) + }, + }) + return result + } + + /** + * @protected + * @param {import('../index.js').KeyLease} lease + * @returns {Record} + */ + chatCompletionsHeaders(lease) { + return { + 'content-type': 'application/json', + authorization: `Bearer ${lease.api_key}`, + } + } + + /** + * OpenAI Chat Completions structured output 分派表: + * json_object → response_format.type='json_object'(原样透传,无 schema)。 + * json_schema → response_format.json_schema(name+schema+strict)。 + * @protected + * @returns {Record, headers:Record, rf:{schema?:object, name?:string}) => void>} + */ + responseFormatHandlers() { + return { + json_object: (body) => { + body.response_format = { type: 'json_object' } + }, + json_schema: (body, _headers, rf) => { + body.response_format = { + type: 'json_schema', + json_schema: { name: rf.name ?? 'response', schema: rf.schema, strict: true }, + } + }, + } + } + + /** + * @protected + * @param {string|undefined} _model + * @returns {string} + */ + maxTokensParamName(_model) { + return 'max_tokens' + } + + /** + * @protected + * @param {string|undefined} _model + * @returns {boolean} + */ + supportsThinkingParam(_model) { + return true + } + + /** + * 把统一 ChatRequest.messages + 多模态归一成 OpenAI messages。 + * @protected + * @param {import('../index.js').ChatRequest} req + * @returns {Array<{ role:string, content:any }>} + */ + _toOpenAIMessages(req) { + /** @type {Array<{ role:string, content:any }>} */ + const messages = req.messages.map((m) => this._toOpenAIMessage(m)) + const images = this.buildMediaParts(req.images, 'image') + if (images.length && messages.length) { + const last = messages[messages.length - 1] + /** @type {Array} */ + const parts = [{ type: 'text', text: typeof last.content === 'string' ? last.content : '' }] + for (const img of images) { + const url = img.url ?? `data:${img.mime};base64,${img.data}` + parts.push({ type: 'image_url', image_url: { url } }) + } + last.content = parts + } + return messages + } + + /** + * 单条中性消息 → OpenAI 消息形态。tool 相关的两分支(中性载荷承载于 content)在此落地: + * assistant tool_calls → tool_calls 字段;tool 角色 → role:'tool' + tool_call_id。 + * @protected + * @param {import('../index.js').ChatMessage} m + * @returns {{ role:string, content:any, tool_calls?:Array, tool_call_id?:string }} + */ + _toOpenAIMessage(m) { + if (m.role === 'assistant' && m.content?.kind === 'tool_calls') { + return { + role: 'assistant', + content: null, + tool_calls: m.content.tool_calls.map((c) => ({ + id: c.id, + type: 'function', + function: { name: c.name, arguments: JSON.stringify(c.args) }, + })), + } + } + if (m.role === 'tool') { + return { role: 'tool', tool_call_id: m.content.tool_call_id, content: m.content.result } + } + return { role: m.role, content: m.content } + } + + /** + * 中性 tool spec → OpenAI function tool 形态。 + * @protected + * @param {Array<{ name:string, description:string, parameters:object }>} tools + * @returns {Array} + */ + _toOpenAITools(tools) { + return tools.map((t) => ({ + type: 'function', + function: { name: t.name, description: t.description, parameters: t.parameters }, + })) + } + + /** + * @param {any} frame OpenAI SSE 帧(已解析 JSON)。 + * @returns {import('../base.js').ParsedValue | null} + */ + messageToValue(frame) { + if (!frame || typeof frame !== 'object') return null + if (frame.usage) { + const usage = frame.usage + const prompt_tokens = readUsageInteger(usage.prompt_tokens, 'prompt_tokens', this.provider) + const completion_tokens = readUsageInteger(usage.completion_tokens, 'completion_tokens', this.provider) + const total_tokens = readUsageInteger(usage.total_tokens, 'total_tokens', this.provider) + if (total_tokens !== prompt_tokens + completion_tokens) { + throw new Error( + `[${this.provider}] usage.total_tokens=${total_tokens} 与 prompt_tokens + completion_tokens 不一致`, + ) + } + return [ + { + type: 'metadata', + provider_request_id: typeof frame.id === 'string' ? frame.id : null, + service_tier: typeof frame.service_tier === 'string' ? frame.service_tier : null, + }, + { + type: 'usage', + usage: { + prompt_tokens, + completion_tokens, + total_tokens, + cached_tokens: readOptionalUsageInteger(usage.prompt_tokens_details?.cached_tokens, 'cached_tokens', this.provider), + reasoning_tokens: readOptionalUsageInteger( + usage.completion_tokens_details?.reasoning_tokens, + 'reasoning_tokens', + this.provider, + ), + }, + }, + ] + } + const choice = frame.choices?.[0] + const delta = choice?.delta + /** @type {Array} */ + const values = [] + if (!delta && choice?.finish_reason === undefined) return null + if (delta?.tool_calls?.length) { + // 真实流:function.arguments 跨多帧分片。逐路发 tool_call_delta(首帧携 id/name,续帧仅 arguments 片段), + // 由 base 按 index 聚合、流结束时一次 JSON.parse。provider 保持无状态,不在此拼接或解析。 + values.push(...delta.tool_calls.map((t) => ({ + type: 'tool_call_delta', + index: t.index ?? 0, + id: t.id, + name: t.function?.name, + args_fragment: t.function?.arguments ?? '', + }))) + } + // content 优先:部分 OpenAI 兼容网关(如万界 maas)每帧都带空串 `reasoning_content`, + // 若先判 reasoning 会把空串当思维链返回、吞掉真正的 content。故仅当 reasoning_content + // 非空时才作为思维链,content 为非空字符串时优先作为正文。 + if (typeof delta?.content === 'string' && delta.content.length > 0) { + values.push({ type: 'text', text: delta.content }) + } else if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0) { + values.push({ type: 'reasoning', text: delta.reasoning_content }) + } else if (typeof delta?.content === 'string') { + values.push({ type: 'text', text: delta.content }) + } + if (choice?.finish_reason !== undefined) { + values.push({ type: 'finish', finish_reason: choice.finish_reason }) + } + return values.length ? values : null + } +} + +export function readUsageInteger(value, field, provider) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`[${provider}] usage.${field} 必须是非负安全整数`) + } + return value +} + +export function readOptionalUsageInteger(value, field, provider) { + if (value === undefined) return 0 + return readUsageInteger(value, field, provider) +} diff --git a/packages/llm/src/providers/llm.openai_responses.js b/packages/llm/src/providers/llm.openai_responses.js new file mode 100644 index 0000000..f35c945 --- /dev/null +++ b/packages/llm/src/providers/llm.openai_responses.js @@ -0,0 +1,248 @@ +/** + * OpenAI Responses API 子类 —— POST /responses 协议,OpenAI 厂商默认适配器。 + * + * 与 Chat Completions(llm.openai.js)的差异仅在:端点(/responses)、请求体 + * (`input` items / `instructions` / `store` / `prompt_cache_key`)与流事件类型。 + * 复用父类 OpenAIProvider 的 Bearer header(chatCompletionsHeaders)与 defaultBaseUrl + * (https://api.openai.com/v1);chat / messageToValue / 消息映射整体覆写。 + * + * 网关无状态(store:false,不依赖服务端会话历史):req.messages 全量映射为 input items。 + * + * 请求体字段与流事件类型以 OpenAI Responses API 当前官方文档为准: + * 请求:POST /responses,body { model, input, instructions?, stream, store, prompt_cache_key?, + * temperature?, max_output_tokens? }。 + * 流事件(SSE `event: \ndata: {json}`,data 内 `type` 即事件类型): + * response.output_text.delta → text 增量(取 delta) + * response.reasoning_summary_text.delta → reasoning 增量(取 delta) + * response.output_item.done(function_call) → tool_call + * response.completed → response.usage 用量 + * usage 字段:input_tokens / output_tokens / total_tokens / + * input_tokens_details.cached_tokens / input_tokens_details.cache_write_tokens。 + * + * 流解析沿用 LLMBase 的 iterateSse(标准 SSE `data:` 分帧,帧内 type 承载事件类型),不覆写。 + */ + +import { LLMError } from '../errors.js' +import { mapResponsesFailure } from '../map-error.js' +import OpenAIProvider, { + readOptionalUsageInteger, + readUsageInteger, +} from './llm.openai.js' + +// 成功终态:携 usage、走成功路径。completed=完整、incomplete=受控部分结果(如 max_output_tokens +// 耗尽,语义等同 chat 的 finish_reason=length)。response.failed 是明确失败,不在此列,单独抛错。 +const TERMINAL_FRAMES = new Set(['response.completed', 'response.incomplete']) + +export default class OpenAIResponsesProvider extends OpenAIProvider { + /** + * @param {import('../index.js').ChatRequest} req + * @param {import('../index.js').ChatOpts} [opts] + * @returns {Promise} + */ + async chat(req, opts = {}) { + const model = req.model ?? this.model + return this.request({ + req, + opts, + affinity_key: opts.trace_ctx?.session_id, + doFetch: (lease, { signal }) => { + const base = lease.base_url ?? this.defaultBaseUrl() + const headers = this.chatCompletionsHeaders(lease) + const { instructions, input } = this._toResponsesInput(req) + /** @type {Record} */ + const body = { model, input, stream: true, store: false } + if (instructions) body.instructions = instructions + if (opts.prompt_cache_key) body.prompt_cache_key = opts.prompt_cache_key + if (req.prompt_cache_options) body.prompt_cache_options = req.prompt_cache_options + if (req.temperature != null) body.temperature = req.temperature + if (req.max_tokens != null) body.max_output_tokens = req.max_tokens + this.applyResponseFormat(body, headers, req.response_format) + const request = this.responsesRequest(lease, req, opts, headers, body) + return this.httpFetch(`${base}/responses`, { + method: 'POST', + signal, + headers: request.headers, + body: JSON.stringify(request.body), + }) + }, + }) + } + + /** + * Responses 请求扩展点。公共端点原样返回;内部协议适配器在这里一次性改请求头和请求体。 + * @protected + * @param {import('../index.js').KeyLease} lease + * @param {import('../index.js').ChatRequest} _req + * @param {import('../index.js').ChatOpts} _opts + * @param {Record} headers + * @param {Record} body + */ + responsesRequest(_lease, _req, _opts, headers, body) { + return { headers, body } + } + + /** + * Responses API structured output 分派表:以 text.format 表达(非 Chat Completions 的 response_format)。 + * json_object → text.format.type='json_object'。 + * json_schema → text.format.json_schema(name+schema+strict)。 + * @protected + * @returns {Record, headers:Record, rf:{schema?:object, name?:string}) => void>} + */ + responseFormatHandlers() { + return { + json_object: (body) => { + body.text = { ...(body.text ?? {}), format: { type: 'json_object' } } + }, + json_schema: (body, _headers, rf) => { + body.text = { + ...(body.text ?? {}), + format: { type: 'json_schema', name: rf.name ?? 'response', schema: rf.schema, strict: true }, + } + }, + } + } + + /** + * messages → Responses 的 { instructions?, input }。system 累积为顶层 instructions, + * 其余转 input item(assistant→output_text、其余→input_text)。全量传入(store:false 无状态)。 + * @private + * @param {import('../index.js').ChatRequest} req + * @returns {{ instructions?:string, input:Array }} + */ + _toResponsesInput(req) { + /** @type {string[]} */ + const systemTexts = [] + /** @type {Array} */ + const input = [] + for (const m of req.messages) { + if (m.role === 'system') { + systemTexts.push(String(m.content)) + continue + } + const content = { + type: m.role === 'assistant' ? 'output_text' : 'input_text', + text: String(m.content), + } + if (req.prompt_cache_options && m.prompt_cache_breakpoint === true) { + content.prompt_cache_breakpoint = { mode: 'explicit' } + } + input.push({ + role: m.role, + content: [content], + }) + } + const images = this.buildMediaParts(req.images, 'image') + if (images.length && input.length) { + const last = input[input.length - 1] + for (const img of images) { + const url = img.url ?? `data:${img.mime};base64,${img.data}` + last.content.push({ type: 'input_image', image_url: url }) + } + } + const out = { input } + if (systemTexts.length) out.instructions = systemTexts.join('\n') + return out + } + + /** + * @param {any} frame Responses SSE data 帧(已解析 JSON),frame.type 为事件类型。 + * @returns {import('../base.js').ParsedValue | null} + */ + messageToValue(frame) { + if (!frame || typeof frame !== 'object') return null + if (frame.type === 'response.output_text.delta') return { type: 'text', text: frame.delta } + if (frame.type === 'response.reasoning_summary_text.delta') { + return { type: 'reasoning', text: frame.delta } + } + if (frame.type === 'response.output_item.done' && frame.item?.type === 'function_call') { + // Responses 的 function_call 参数经 response.function_call_arguments.delta 分片,最终在 output_item.done + // 携全量 arguments 字符串。归一为 base 的分片机制:发单条 tool_call_delta(整帧即全量片段), + // 由 base 按 index 聚合 + 一次 JSON.parse 得中性 { id, name, args(object) },不在 provider 拼装 wire 形态。 + return { + type: 'tool_call_delta', + index: frame.output_index ?? 0, + id: frame.item.call_id, + name: frame.item.name, + args_fragment: frame.item.arguments ?? '', + } + } + // 明确失败终态:从 response.error(code/message)构造 LLMError 抛出(沿用 azure_foundry + // 在 provider 内抛 LLMError 的既有约定)。经 base 的 _consumeStream→request 重试循环,reason + // 决定重试与否;该次调用以抛错结束,createLLM 不进成功计费路径,审计落 status='error'。 + if (frame.type === 'response.failed') { + const error = frame.response?.error ?? {} + const { reason, retryable } = mapResponsesFailure(error.code) + throw new LLMError({ + provider: this.provider, + reason, + retryable, + cause: error, + message: `[${this.provider}] response.failed (${error.code ?? 'unknown'}): ${error.message ?? ''}`, + detail: { type: 'provider_response', response: error }, + }) + } + // completed/incomplete 两终态是成功路径——incomplete(如思维链耗尽 max_output_tokens)时上游 + // 已对 reasoning token 计费,漏读即漏计费,故与 completed 同走成功 + usage 路径。 + // + // 终态必带 usage:Responses 规范允许 `usage: null`(见 API reference 的 incomplete 示例), + // 但零 usage 会让本次调用以 amount=0 静默跳账 + 审计 status='ok' 落地——上游已产生费用却无账可 + // 对,违反「运行期不存在免费调用路径」。故缺 usage 即显式失败,绝不降级为零成本成功。 + if (TERMINAL_FRAMES.has(frame.type)) { + const u = frame.response?.usage + if (!u) { + throw new LLMError({ + provider: this.provider, + reason: 'unknown', + retryable: false, + message: `[${this.provider}] ${frame.type} 未携带 usage(上游已计费但本地无法计价,拒绝记为零成本成功)`, + }) + } + const input_details = u.input_tokens_details + const prompt_tokens = readUsageInteger(u.input_tokens, 'input_tokens', this.provider) + const completion_tokens = readUsageInteger(u.output_tokens, 'output_tokens', this.provider) + const total_tokens = readUsageInteger(u.total_tokens, 'total_tokens', this.provider) + const cached_tokens = readOptionalUsageInteger( + input_details?.cached_tokens, + 'input_tokens_details.cached_tokens', + this.provider, + ) + const cache_write_tokens = readOptionalUsageInteger( + input_details?.cache_write_tokens, + 'input_tokens_details.cache_write_tokens', + this.provider, + ) + const reasoning_tokens = readOptionalUsageInteger( + u.output_tokens_details?.reasoning_tokens, + 'output_tokens_details.reasoning_tokens', + this.provider, + ) + if (cached_tokens + cache_write_tokens > prompt_tokens) { + throw new Error( + `[${this.provider}] cached_tokens + cache_write_tokens 不能大于 input_tokens`, + ) + } + if (total_tokens !== prompt_tokens + completion_tokens) { + throw new Error(`[${this.provider}] total_tokens 必须等于 input_tokens + output_tokens`) + } + return [ + { + type: 'metadata', + provider_request_id: typeof frame.response.id === 'string' ? frame.response.id : null, + service_tier: typeof frame.response.service_tier === 'string' ? frame.response.service_tier : null, + }, + { + type: 'usage', + usage: { + prompt_tokens, + completion_tokens, + total_tokens, + cached_tokens, + cache_write_tokens, + reasoning_tokens, + }, + }, + ] + } + return null + } +} diff --git a/packages/llm/src/providers/llm.openrouter.js b/packages/llm/src/providers/llm.openrouter.js new file mode 100644 index 0000000..0e2a084 --- /dev/null +++ b/packages/llm/src/providers/llm.openrouter.js @@ -0,0 +1,32 @@ +/** + * OpenRouter 子类 —— OpenAI 兼容,差异仅在默认 baseUrl 与 OpenRouter 转发 Gemini 的 reasoning 字段名。 + * + * 继承 OpenAIProvider,零复制 chat/流解析/重试/trace。仅覆写 defaultBaseUrl 与 messageToValue 的 + * reasoning 分支(OpenRouter 转发 Gemini 思维链用 `delta.reasoning`,非 `reasoning_content`)。 + * + * 仅当帧/多模态形态有厂商差异时才需新增此类文件;纯 OpenAI 兼容接入只配置文本能力行,不新增文件。 + * + * 文件名 `llm.openrouter.js` 即注册键 `openrouter`。 + */ + +import OpenAIProvider from './llm.openai.js' + +export default class OpenRouterProvider extends OpenAIProvider { + /** @returns {string} */ + defaultBaseUrl() { + return 'https://openrouter.ai/api/v1' + } + + /** + * OpenRouter 专有 reasoning 帧优先处理,其余委回父类。 + * @param {any} frame + * @returns {import('../base.js').ParsedValue | null} + */ + messageToValue(frame) { + const delta = frame?.choices?.[0]?.delta + if (typeof delta?.reasoning === 'string' && delta.reasoning.length > 0) { + return { type: 'reasoning', text: delta.reasoning } + } + return super.messageToValue(frame) + } +} diff --git a/packages/llm/src/providers/llm.volcengine.js b/packages/llm/src/providers/llm.volcengine.js new file mode 100644 index 0000000..b1daa81 --- /dev/null +++ b/packages/llm/src/providers/llm.volcengine.js @@ -0,0 +1,82 @@ +/** + * Volcengine(火山方舟 / 豆包)子类 —— OpenAI 兼容,差异在默认 baseUrl、video_url 多模态、 + * usage 的 cached_tokens。继承 OpenAIProvider,零复制 chat/流解析/重试/trace。 + * + * usage 与思维链帧沿用父类 messageToValue(父类已映射 delta.reasoning_content→reasoning、 + * prompt_tokens_details.cached_tokens→cached_tokens,豆包帧同形),无需覆写。 + * + * 仅当帧/多模态形态有厂商差异时才需新增此类文件;纯 OpenAI 兼容接入只配置文本能力行,不新增文件。 + * + * 文件名 `llm.volcengine.js` 即注册键 `volcengine`。 + */ + +import OpenAIProvider from './llm.openai.js' + +export default class VolcengineProvider extends OpenAIProvider { + /** @returns {string} */ + defaultBaseUrl() { + return 'https://ark.cn-beijing.volces.com/api/v3' + } + + /** + * @protected + * @returns {boolean} + */ + supportsVideo() { + return true + } + + /** Ark 的成功 usage 帧必须携带可对账的响应 id 与实际 service_tier。 */ + messageToValue(frame) { + if (frame && typeof frame === 'object' && frame.usage) { + if (typeof frame.id !== 'string' || frame.id.length === 0) { + throw new Error('[volcengine] 成功 usage 帧缺 provider request id') + } + if (typeof frame.service_tier !== 'string' || frame.service_tier.length === 0) { + throw new Error('[volcengine] 成功 usage 帧缺 service_tier') + } + const promptDetails = requiredDetails(frame.usage.prompt_tokens_details, 'prompt_tokens_details') + const completionDetails = requiredDetails(frame.usage.completion_tokens_details, 'completion_tokens_details') + requiredArkUsageInteger(promptDetails.cached_tokens, 'prompt_tokens_details.cached_tokens') + requiredArkUsageInteger(completionDetails.reasoning_tokens, 'completion_tokens_details.reasoning_tokens') + } + return super.messageToValue(frame) + } + + /** + * 复用父类 text+image 归一,再消费 videos → video_url 块。 + * @protected + * @param {import('../index.js').ChatRequest} req + * @returns {Array<{ role:string, content:any }>} + */ + _toOpenAIMessages(req) { + const messages = super._toOpenAIMessages(req) + const videos = this.buildMediaParts(req.videos, 'video') + if (videos.length && messages.length) { + const last = messages[messages.length - 1] + /** @type {Array} */ + const parts = Array.isArray(last.content) + ? last.content + : [{ type: 'text', text: typeof last.content === 'string' ? last.content : '' }] + for (const vid of videos) { + const url = vid.url ?? `data:${vid.mime};base64,${vid.data}` + parts.push({ type: 'video_url', video_url: { url } }) + } + last.content = parts + } + return messages + } +} + +function requiredArkUsageInteger(value, field) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`[volcengine] usage.${field} 必须是非负安全整数`) + } +} + +function requiredDetails(value, field) { + if (!value || Object.getPrototypeOf(value) !== Object.prototype) { + throw new Error(`[volcengine] usage.${field} 必须是 object`) + } + return value +} diff --git a/packages/llm/src/providers/llm.wanjie.js b/packages/llm/src/providers/llm.wanjie.js new file mode 100644 index 0000000..00a120c --- /dev/null +++ b/packages/llm/src/providers/llm.wanjie.js @@ -0,0 +1,30 @@ +import OpenAIProvider from './llm.openai.js' + +export default class WanjieProvider extends OpenAIProvider { + defaultBaseUrl() { + return 'https://maas-openapi.wanjiedata.com/api/v1' + } + + httpErrorInfo(_res, body, _reason) { + const response = requiredObject(body, 'response') + const error = requiredObject(response.error, 'error') + return { + message: requiredString(error.message, 'error.message'), + provider_request_id: requiredString(response.request_id, 'request_id'), + } + } +} + +function requiredObject(value, field) { + if (!value || Object.getPrototypeOf(value) !== Object.prototype) { + throw new Error(`[wanjie] ${field} 必须是 object`) + } + return value +} + +function requiredString(value, field) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`[wanjie] ${field} 必须是非空字符串`) + } + return value +} diff --git a/packages/llm/src/registry.js b/packages/llm/src/registry.js new file mode 100644 index 0000000..dbbbecc --- /dev/null +++ b/packages/llm/src/registry.js @@ -0,0 +1,48 @@ +/** + * Provider 自动注册表 —— 扫描 `providers/` 目录,按文件名 `llm..js` 建表。 + * + * 「加厂商 = 加一个文件」:放进 `providers/llm..js` 并默认导出一个继承 LLMBase 的类, + * 即被发现,无需改本文件或任何现有文件。注册键 = 文件名中 `llm.` 与 `.js` 之间的段。 + * + * 不在模块顶层执行扫描(边界纪律:无顶层副作用);`loadRegistry()` 被 `createLLM` 调用时才读目录。 + * 目录内只有真实厂商适配器:假 provider 已删除,网关不提供任何「不发真实请求」的实现。 + */ + +import { readdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const PROVIDERS_DIR = path.join(here, 'providers') + +/** 文件名形如 `llm.openai.js` / `llm.azure_foundry.js` → 捕获 provider key。 */ +const FILE_RE = /^llm\.([a-z0-9_-]+)\.js$/ + +/** + * 扫描 providers/ 目录,返回 `{ apiType: () => Promise }` 的惰性加载表。 + * 惰性:只在真正 `createLLM({ provider })` 命中某 apiType 时才 dynamic import 该文件, + * 避免加载未用到的子类(也避免任一子类的加载错误污染整张表)。 + * @param {string} [dir] 覆盖扫描目录(仅供测试自定义 providers 目录)。 + * @returns {Record Promise<{ default: new (cfg: any) => any }>>} + */ +export function loadRegistry(dir = PROVIDERS_DIR) { + /** @type {Record Promise<{ default: new (cfg: any) => any }>>} */ + const table = {} + for (const name of readdirSync(dir)) { + const m = FILE_RE.exec(name) + if (!m) continue + const apiType = m[1] + const fileUrl = pathToFileURL(path.join(dir, name)).href + table[apiType] = () => import(fileUrl) + } + return table +} + +/** + * 列出已注册的 apiType(用于未知 provider 的报错信息)。 + * @param {string} [dir] + * @returns {string[]} + */ +export function registeredProviders(dir = PROVIDERS_DIR) { + return Object.keys(loadRegistry(dir)).sort() +} diff --git a/packages/llm/src/response-format.js b/packages/llm/src/response-format.js new file mode 100644 index 0000000..ddf405b --- /dev/null +++ b/packages/llm/src/response-format.js @@ -0,0 +1,43 @@ +/** + * responseFormat(structured output)归一纯函数 —— 自 base.js 提取的职责单一模块。 + * + * 契约:入站 responseFormat 只有两种归一形态,入口一次判别、内部信任: + * { type:'json_object' } —— 要求返回合法 JSON,无 schema 约束。 + * { type:'json_schema', schema, name? } —— 按 schema 约束的结构化输出。 + * 各 provider 只声明「支持哪种归一 type → 怎么落地」的分派表(responseFormatHandlers), + * 不再各写 if-else;不支持的 type 由 base 统一 badRequest。 + */ + +import { LLMError } from './errors.js' + +/** + * 把入站 responseFormat 归一为两种单一形态之一。 + * 归一规则(MECE):显式 json_object → json_object;否则凡带 schema(或显式 json_schema)→ json_schema; + * 其余无法表达的形态在此一次性 badRequest(杜绝静默忽略)。 + * @param {{ type?:string, name?:string, schema?:object }} rf 已非空的入站 responseFormat。 + * @param {string} provider + * @returns {{ type:'json_object' } | { type:'json_schema', schema:object, name?:string }} + */ +export function normalizeResponseFormat(rf, provider) { + if (rf.type === 'json_object') return { type: 'json_object' } + if (rf.type === 'json_schema' || rf.schema != null) { + if (rf.schema == null) { + throw new LLMError({ + provider, + reason: 'badRequest', + retryable: false, + message: `[${provider}] responseFormat json_schema requires a 'schema'`, + }) + } + /** @type {{ type:'json_schema', schema:object, name?:string }} */ + const out = { type: 'json_schema', schema: rf.schema } + if (rf.name != null) out.name = rf.name + return out + } + throw new LLMError({ + provider, + reason: 'badRequest', + retryable: false, + message: `[${provider}] unrecognized responseFormat (expected json_object or json_schema)`, + }) +} diff --git a/packages/llm/src/sse.js b/packages/llm/src/sse.js new file mode 100644 index 0000000..a3d3aef --- /dev/null +++ b/packages/llm/src/sse.js @@ -0,0 +1,98 @@ +/** + * SSE 帧切分与迭代 —— 自 base.js 提取的纯函数模块(职责单一硬约束)。 + * + * 零副作用、零依赖:从 Response.body(ReadableStream)按 SSE 事件分隔(空行)分帧, + * 抽 `data:` 负载、`[DONE]` 终止、parseFrame 解析 JSON。base.js 一行 import 接入。 + * 中止时抛出 `signal.reason`(承接 base.js 的每 attempt 组合信号),使内部超时的 + * TimeoutError reason 能透传到 map-error(区别于外部 abort 的 AbortError)。 + */ + +/** + * 从 Response.body 按行解析 SSE,yield 每个 `data:` 帧的 JSON(或字符串)。 + * 兼容 `event: x\ndata: {...}` 多行帧;遇 `[DONE]` 停止。 + * @param {Response} res + * @param {AbortSignal} [signal] + * @returns {AsyncGenerator} + */ +export async function* iterateSse(res, signal) { + const body = res.body + if (!body) return + const decoder = new TextDecoder() + let buf = '' + const reader = body.getReader() + try { + for (;;) { + if (signal?.aborted) throw abortReason(signal) + const { value, done } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + // 按 SSE 事件分隔(空行)切块;块内取 data: 行拼接。 + let sepIdx + while ((sepIdx = indexOfEventSep(buf)) !== -1) { + const rawEvent = buf.slice(0, sepIdx) + buf = buf.slice(sepIdx).replace(/^(\r?\n){1,2}/, '') + const data = extractData(rawEvent) + if (data === null) continue + if (data === '[DONE]') return + yield parseFrame(data) + } + } + // 收尾:缓冲里可能残留最后一个无尾随空行的事件。 + const tail = extractData(buf) + if (tail !== null && tail !== '[DONE]') yield parseFrame(tail) + } finally { + reader.releaseLock?.() + } +} + +/** + * 中止原因:优先透传 signal.reason(内部超时的 TimeoutError / 外部的 AbortError), + * 缺省造 AbortError。 + * @param {AbortSignal} signal + * @returns {unknown} + */ +function abortReason(signal) { + return signal.reason ?? Object.assign(new Error('aborted'), { name: 'AbortError' }) +} + +/** + * 找事件分隔(`\n\n` 或 `\r\n\r\n`)的起点;无则 -1。 + * @param {string} buf + * @returns {number} + */ +function indexOfEventSep(buf) { + const a = buf.indexOf('\n\n') + const b = buf.indexOf('\r\n\r\n') + if (a === -1) return b + if (b === -1) return a + return Math.min(a, b) +} + +/** + * 从一个 SSE 事件块里抽出 data 负载(拼接多行 data:);无 data 行返回 null。 + * @param {string} rawEvent + * @returns {string | null} + */ +function extractData(rawEvent) { + const lines = rawEvent.split(/\r?\n/) + /** @type {string[]} */ + const dataLines = [] + for (const line of lines) { + if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart()) + } + if (dataLines.length === 0) return null + return dataLines.join('\n') +} + +/** + * data 负载若是 JSON 则解析,否则原样返回字符串。 + * @param {string} data + * @returns {any} + */ +function parseFrame(data) { + try { + return JSON.parse(data) + } catch { + return data + } +} diff --git a/packages/llm/test/azure-foundry.test.js b/packages/llm/test/azure-foundry.test.js new file mode 100644 index 0000000..7e2e170 --- /dev/null +++ b/packages/llm/test/azure-foundry.test.js @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import AzureFoundryProvider from '../src/providers/llm.azure_foundry.js' + +describe('AzureFoundryProvider', () => { + it('marks Azure content_filter HTTP errors as fallbackable', () => { + const provider = new AzureFoundryProvider({ + keyProvider: {}, + provider: 'azure_foundry', + tracePort: {}, + }) + const res = new Response('', { + status: 400, + headers: { 'x-request-id': 'azure-request-1' }, + }) + + const info = provider.httpErrorInfo(res, { error: { code: 'content_filter' } }, 'badRequest') + + expect(info).toEqual({ + reason: 'contentFilter', + retryable: true, + message: '[azure_foundry] content filtered', + provider_request_id: 'azure-request-1', + }) + }) +}) diff --git a/packages/llm/test/errors.test.js b/packages/llm/test/errors.test.js new file mode 100644 index 0000000..417b6f7 --- /dev/null +++ b/packages/llm/test/errors.test.js @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { LLMError } from '../src/errors.js' + +describe('LLMError response metadata', () => { + it('contentFilter is a client-visible expected failure', () => { + const error = new LLMError({ + provider: 'azure_foundry', + reason: 'contentFilter', + retryable: false, + message: '[azure_foundry] content filtered', + }) + + expect(error.code).toBe('CONTENT_FILTER') + expect(error.statusCode).toBe(400) + }) +}) diff --git a/packages/llm/test/map-error.test.js b/packages/llm/test/map-error.test.js new file mode 100644 index 0000000..fc89988 --- /dev/null +++ b/packages/llm/test/map-error.test.js @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' +import { + mapHttpStatus, + isAbortError, + mapThrownError, + mapKeyRouterError, + mapResponsesFailure, + mapChatFinishReason, +} from '../src/map-error.js' + +describe('mapHttpStatus', () => { + it('401/403 = auth, not retryable', () => { + expect(mapHttpStatus(401)).toEqual({ reason: 'auth', retryable: false }) + expect(mapHttpStatus(403)).toEqual({ reason: 'auth', retryable: false }) + }) + it('429 = rateLimit, retryable', () => { + expect(mapHttpStatus(429)).toEqual({ reason: 'rateLimit', retryable: true }) + }) + it('408 / 5xx = transient, retryable', () => { + expect(mapHttpStatus(408)).toEqual({ reason: 'transient', retryable: true }) + expect(mapHttpStatus(500)).toEqual({ reason: 'transient', retryable: true }) + expect(mapHttpStatus(503)).toEqual({ reason: 'transient', retryable: true }) + }) + it('other 4xx = badRequest, not retryable', () => { + expect(mapHttpStatus(400)).toEqual({ reason: 'badRequest', retryable: false }) + expect(mapHttpStatus(404)).toEqual({ reason: 'badRequest', retryable: false }) + }) + it('2xx/3xx = unknown (not an error path)', () => { + expect(mapHttpStatus(200)).toEqual({ reason: 'unknown', retryable: false }) + }) +}) + +describe('isAbortError', () => { + it('detects AbortError by name and code', () => { + expect(isAbortError({ name: 'AbortError' })).toBe(true) + expect(isAbortError({ code: 'ABORT_ERR' })).toBe(true) + expect(isAbortError(new Error('nope'))).toBe(false) + expect(isAbortError(null)).toBe(false) + }) +}) + +describe('mapThrownError', () => { + it('abort = aborted, not retryable', () => { + expect(mapThrownError({ name: 'AbortError' })).toEqual({ reason: 'aborted', retryable: false }) + }) + it('network errors = transient, retryable', () => { + expect(mapThrownError({ name: 'TypeError' })).toEqual({ reason: 'transient', retryable: true }) + expect(mapThrownError({ code: 'ECONNRESET' })).toEqual({ reason: 'transient', retryable: true }) + expect(mapThrownError({ code: 'ETIMEDOUT' })).toEqual({ reason: 'transient', retryable: true }) + }) + it('unknown otherwise', () => { + expect(mapThrownError(new Error('weird'))).toEqual({ reason: 'unknown', retryable: false }) + }) + it('KeyRouterError (by name) maps to transient, not unknown (F6)', () => { + const kr = Object.assign(new Error('all keys cooling'), { name: 'KeyRouterError', reason: 'noKeyAvailable', retryable: true }) + expect(mapThrownError(kr)).toEqual({ reason: 'transient', retryable: true }) + }) + it('non-retryable KeyRouterError maps to transient not-retryable (config hard error)', () => { + const kr = Object.assign(new Error('config'), { name: 'KeyRouterError', reason: 'configMissing', retryable: false }) + expect(mapThrownError(kr)).toEqual({ reason: 'transient', retryable: false }) + }) +}) + +describe('mapResponsesFailure — Responses response.failed error.code classification', () => { + it('rate_limit_exceeded = rateLimit, retryable', () => { + expect(mapResponsesFailure('rate_limit_exceeded')).toEqual({ reason: 'rateLimit', retryable: true }) + }) + it('server_error = transient, retryable', () => { + expect(mapResponsesFailure('server_error')).toEqual({ reason: 'transient', retryable: true }) + }) + it('other/unknown codes = badRequest, not retryable', () => { + expect(mapResponsesFailure('invalid_prompt')).toEqual({ reason: 'badRequest', retryable: false }) + expect(mapResponsesFailure(undefined)).toEqual({ reason: 'badRequest', retryable: false }) + }) +}) + +describe('mapChatFinishReason — Chat Completions finish_reason classification', () => { + it('complete terminal reasons are not errors', () => { + expect(mapChatFinishReason(null)).toBeNull() + expect(mapChatFinishReason('stop')).toBeNull() + expect(mapChatFinishReason('tool_calls')).toBeNull() + expect(mapChatFinishReason('function_call')).toBeNull() + }) + + it('length is transient so router fallback can try the next model', () => { + expect(mapChatFinishReason('length')).toEqual({ reason: 'transient', retryable: true }) + }) + + it('content_filter finish is fallbackable provider output truncation', () => { + expect(mapChatFinishReason('content_filter')).toEqual({ reason: 'contentFilter', retryable: true }) + }) + + it('unknown terminal reasons are not retried blindly', () => { + expect(mapChatFinishReason('weird')).toEqual({ reason: 'unknown', retryable: false }) + }) +}) + +describe('mapKeyRouterError (F6) — no keyrouter import, name/duck-type detection', () => { + it('detects by name === KeyRouterError', () => { + expect(mapKeyRouterError({ name: 'KeyRouterError', retryable: true })).toEqual({ reason: 'transient', retryable: true }) + }) + it('duck-types retryAfterMs → rateLimit', () => { + expect(mapKeyRouterError({ retryAfterMs: 1000 })).toEqual({ reason: 'rateLimit', retryable: true }) + }) + it('duck-types kind field → transient', () => { + expect(mapKeyRouterError({ kind: 'slotTimeout', retryable: true })).toEqual({ reason: 'transient', retryable: true }) + }) + it('returns null for unrelated errors', () => { + expect(mapKeyRouterError(new Error('plain'))).toBeNull() + expect(mapKeyRouterError(null)).toBeNull() + }) +}) diff --git a/packages/llm/tools/scripts/llm-provider-smoke.mjs b/packages/llm/tools/scripts/llm-provider-smoke.mjs new file mode 100644 index 0000000..c230341 --- /dev/null +++ b/packages/llm/tools/scripts/llm-provider-smoke.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * 真实 API smoke —— 对 Gemini 与一个 OpenAI 兼容厂商(volcengine / openrouter)各发一次真实 chat。 + * env 有对应 key 才跑,否则 SKIP。任一断言失败即 process.exit(1)。 + * + * env:GEMINI_API_KEY(+可选 GEMINI_BASE_URL)、VOLCENGINE_API_KEY(+可选 VOLCENGINE_BASE_URL)、 + * OPENROUTER_API_KEY(+可选 OPENROUTER_BASE_URL)。无 key 的 provider 跳过。 + * + * 结构参照 apps/visual-game-server/tools/scenarios/waitlist-e2e(真实链路 + 生成报告)。 + */ + +import { createLLM, createStaticKeyProvider } from '../../src/index.js' + +/** @type {Array<{ provider:string, envKey:string, envBase:string, model:string }>} */ +const TARGETS = [ + { provider: 'gemini', envKey: 'GEMINI_API_KEY', envBase: 'GEMINI_BASE_URL', model: process.env.GEMINI_MODEL ?? 'gemini-2.5-flash' }, + { provider: 'volcengine', envKey: 'VOLCENGINE_API_KEY', envBase: 'VOLCENGINE_BASE_URL', model: process.env.VOLCENGINE_MODEL ?? 'doubao-pro-32k' }, + { provider: 'openrouter', envKey: 'OPENROUTER_API_KEY', envBase: 'OPENROUTER_BASE_URL', model: process.env.OPENROUTER_MODEL ?? 'google/gemini-2.5-flash' }, +] + +/** @param {{ provider:string, model:string, apiKey:string, baseUrl?:string }} cfg */ +async function smokeOne({ provider, model, apiKey, baseUrl }) { + const llm = createLLM({ + provider, + model, + keyProvider: createStaticKeyProvider({ api_key: apiKey, base_url: baseUrl }), + }) + const r = await llm.chat({ messages: [{ role: 'user', content: 'Reply with the single word: ok' }] }) + if (typeof r.content !== 'string' || r.content.length === 0) { + throw new Error(`[${provider}] empty content`) + } + if (!(r.usage.total_tokens > 0)) { + throw new Error(`[${provider}] usage.total_tokens not > 0`) + } + return r +} + +async function main() { + const lines = [] + let ran = 0 + let failed = 0 + for (const t of TARGETS) { + const apiKey = process.env[t.envKey] + if (!apiKey) { + lines.push(`- ${t.provider} (${t.model}): SKIP (no ${t.envKey})`) + continue + } + ran += 1 + try { + const r = await smokeOne({ provider: t.provider, model: t.model, apiKey, baseUrl: process.env[t.envBase] }) + const snip = r.content.slice(0, 40).replace(/\n/g, ' ') + lines.push(`- ${t.provider} (${t.model}): PASS content="${snip}" usage.total_tokens=${r.usage.total_tokens}`) + } catch (err) { + failed += 1 + lines.push(`- ${t.provider} (${t.model}): FAIL ${err?.reason ?? ''} ${String(err?.message ?? err)}`) + } + } + console.log('# llm-provider-smoke') + console.log(lines.join('\n')) + console.log(`\nran=${ran} failed=${failed}`) + if (failed > 0) process.exit(1) +} + +main() diff --git a/packages/moderation/package.json b/packages/moderation/package.json new file mode 100644 index 0000000..ea766b7 --- /dev/null +++ b/packages/moderation/package.json @@ -0,0 +1,9 @@ +{ + "name": "@drama/moderation", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + } +} diff --git a/packages/moderation/src/decision.js b/packages/moderation/src/decision.js new file mode 100644 index 0000000..ab5d105 --- /dev/null +++ b/packages/moderation/src/decision.js @@ -0,0 +1,80 @@ +/** + * 多来源判定合并 —— 零依赖纯函数(§4 职责:最易测)。 + * + * 合并语义「取最严」:`block` > `flag` > `allow`。任一来源 block 即最终 block; + * 无 block 但有 flag 即 flag;全 allow 才 allow。这是审核域唯一的优先级, + * 显式可见、不藏隐式规则(§6 可读性)。 + * + * `guardLevel` 标注用于让调用方区分「rules + llm 双护栏」与「仅本地规则降级」 + * 两种结果,避免假装做了 LLM 护栏(§5 review 关注点 4)。 + */ + +/** + * 三档判定的严格序(数值越大越严)。 + * @type {Record} + */ +const SEVERITY = Object.freeze({ allow: 0, flag: 1, block: 2 }) + +const ORDER = Object.freeze(['allow', 'flag', 'block']) + +/** + * 归一一个 decision 字符串;非法值保守落为 'block'(fail-closed,安全优先)。 + * @param {unknown} d + * @returns {'allow'|'flag'|'block'} + */ +function normDecision(d) { + if (d === 'allow' || d === 'flag' || d === 'block') return d + return 'block' +} + +/** + * 取两个 decision 中更严的一个。 + * @param {'allow'|'flag'|'block'} a + * @param {'allow'|'flag'|'block'} b + * @returns {'allow'|'flag'|'block'} + */ +export function strictest(a, b) { + const na = normDecision(a) + const nb = normDecision(b) + return SEVERITY[na] >= SEVERITY[nb] ? na : nb +} + +/** + * 合并多个来源的判定为最终决策(取最严)。 + * + * @param {Array<{ decision?: string, reasons?: string[], categories?: string[] }>} sources + * 各来源的子判定(本地 rules、llm 护栏等)。空数组视为无问题 → 'allow'。 + * @returns {{ decision:'allow'|'flag'|'block', reasons:string[], categories:string[] }} + */ +export function mergeDecisions(sources) { + const list = Array.isArray(sources) ? sources : [] + let decision = /** @type {'allow'|'flag'|'block'} */ ('allow') + /** @type {string[]} */ + const reasons = [] + /** @type {string[]} */ + const categories = [] + + for (const s of list) { + if (!s) continue + decision = strictest(decision, normDecision(s.decision)) + if (Array.isArray(s.reasons)) { + for (const r of s.reasons) if (r && !reasons.includes(r)) reasons.push(r) + } + if (Array.isArray(s.categories)) { + for (const c of s.categories) if (c && !categories.includes(c)) categories.push(c) + } + } + + return { decision, reasons, categories } +} + +/** + * 标注护栏级别:注入了 llm 护栏 → 'rules+llm';否则降级 'rules-only'。 + * @param {boolean} hasLlmGuard + * @returns {'rules+llm'|'rules-only'} + */ +export function guardLevelOf(hasLlmGuard) { + return hasLlmGuard ? 'rules+llm' : 'rules-only' +} + +export { SEVERITY as DECISION_SEVERITY, ORDER as DECISION_ORDER } diff --git a/packages/moderation/src/errors.js b/packages/moderation/src/errors.js new file mode 100644 index 0000000..0b5b161 --- /dev/null +++ b/packages/moderation/src/errors.js @@ -0,0 +1,41 @@ +/** + * 结构化审核错误(§3 契约)。装配自检失败、`llmGuardPort` 协议违约等场景抛它, + * 绝不静默吞错。 + * + * 字段语义: + * - `code`:失败原因的稳定枚举字符串(见 `ModerationErrorCode`),调用方据此分流, + * 不依赖底层厂商内部码。 + * - `retryable`:是否值得重试。装配错误 / 入参非法 = false;Port 临时不可用 / 网络抖动 + * = true。调用方据此决定退避重试还是直接放弃。 + * - `cause`:底层原始错误(如 `llmGuardPort.classify` 抛出的错误),保留链路。 + */ + +/** + * 失败原因枚举(稳定字符串)。 + * @readonly + */ +export const ModerationErrorCode = Object.freeze({ + MISSING_RULES: 'missing_rules', + BAD_RULES: 'bad_rules', + BAD_REQUEST: 'bad_request', + GUARD_PORT_FAILED: 'guard_port_failed', + GUARD_PORT_BAD_SHAPE: 'guard_port_bad_shape', + UNKNOWN: 'unknown', +}) + +export class ModerationError extends Error { + /** + * @param {object} info + * @param {string} info.code + * @param {boolean} [info.retryable] + * @param {unknown} [info.cause] + * @param {string} [info.message] + */ + constructor({ code, retryable, cause, message }) { + super(message || `[moderation] ${code}`) + this.name = 'ModerationError' + this.code = code + this.retryable = Boolean(retryable) + if (cause !== undefined) this.cause = cause + } +} diff --git a/packages/moderation/src/fake.js b/packages/moderation/src/fake.js new file mode 100644 index 0000000..f83d879 --- /dev/null +++ b/packages/moderation/src/fake.js @@ -0,0 +1,69 @@ +/** + * 测试用假实现(§3 可选导出)。 + * + * 让调用方在**无网络**下单测全部判定路径(含降级、合并、block 阻断)——验收 8。 + * `createFakeRules` 给一份可注入的规则数据;`createFakeGuardPort` 给一个脚本化的 + * LLM 护栏 Port。词表本身由调用方在测试里给(seed),本文件不内置任何真实词表 + * (包内 grep 不到硬编码词表——验收 7)。 + */ + +/** + * 造一份可注入的规则。所有内容来自 `seed`,无内置词表。 + * + * @param {object} [seed] + * @param {string[]} [seed.words] 敏感词表(测试自定义) + * @param {number} [seed.lengthMin] + * @param {number} [seed.lengthMax] + * @param {import('./rules-check.js').FormatRule[]} [seed.formatRules] + * @param {Record} [seed.policies] 二期 policy 覆盖(一期默认不报错) + * @returns {import('./rules-check.js').Rules} + */ +export function createFakeRules(seed = {}) { + /** @type {import('./rules-check.js').Rules} */ + const rules = { + words: Array.isArray(seed.words) ? [...seed.words] : [], + formatRules: Array.isArray(seed.formatRules) ? [...seed.formatRules] : [], + } + if (typeof seed.lengthMin === 'number') rules.lengthMin = seed.lengthMin + if (typeof seed.lengthMax === 'number') rules.lengthMax = seed.lengthMax + if (seed.policies && typeof seed.policies === 'object') rules.policies = seed.policies + return rules +} + +/** + * 造一个脚本化的 LLM 护栏 Port,覆盖 unsafe / safe / 抛错 / 动态响应路径。 + * + * @param {object} [scripted] + * @param {boolean} [scripted.unsafe] 固定返回的 unsafe 标志 + * @param {string[]} [scripted.categories] 固定返回的基础安全类目 + * @param {string[]} [scripted.reasons] 固定返回的原因 + * @param {Error} [scripted.throws] 若提供,则每次 classify 直接抛它 + * @param {(args:{text:string,context?:any,region?:string}) => + * ({categories?:string[],unsafe?:boolean,reasons?:string[]}|undefined)} [scripted.respond] + * 动态响应钩子,优先于固定值;返回 undefined 视为 safe 空结果 + * @returns {{ classify:(args:any) => Promise, calls: any[] }} + */ +export function createFakeGuardPort(scripted = {}) { + /** @type {any[]} */ + const calls = [] + return { + calls, + async classify(args) { + calls.push(args) + if (scripted.throws) throw scripted.throws + if (typeof scripted.respond === 'function') { + const r = scripted.respond(args) + return { + categories: (r && r.categories) || [], + unsafe: Boolean(r && r.unsafe), + reasons: (r && r.reasons) || [], + } + } + return { + categories: Array.isArray(scripted.categories) ? scripted.categories : [], + unsafe: Boolean(scripted.unsafe), + reasons: Array.isArray(scripted.reasons) ? scripted.reasons : [], + } + }, + } +} diff --git a/packages/moderation/src/guard.js b/packages/moderation/src/guard.js new file mode 100644 index 0000000..593f7b2 --- /dev/null +++ b/packages/moderation/src/guard.js @@ -0,0 +1,98 @@ +/** + * `guardGeneration` —— 生成链路**前置闸**编排(§4 职责)。 + * + * 这是一期产品要点:护栏发生在生成产物落库/回流**之前**,不是事后扫描。 + * 流程:①先跑本地 `rules`(敏感词/长度/格式硬红线);②若注入了 `llmGuardPort`, + * 再调它做基础安全类目(暴力/色情/仇恨/未成年)分类;③`decision.js` 取最严合并。 + * + * 基础安全类目判定**只来自 `llmGuardPort`**,本包不自训/自写分类模型(验收 4/7)。 + * 未注入 `llmGuardPort` 时优雅降级为 `rules-only` 且如实标注,不假装做了 LLM 护栏 + * (验收 1,§5 review 关注点 4)。 + * + * @typedef {object} GuardPort + * @property {(args: { text:string, context?:any, region?:string }) => + * Promise<{ categories?:string[], unsafe?:boolean, reasons?:string[] }> | { categories?:string[], unsafe?:boolean, reasons?:string[] } + * } classify 薄封装厂商 moderation 端点 或 轻量提示词裁判 + * + * @typedef {object} GuardResult + * @property {'allow'|'flag'|'block'} decision + * @property {string[]} reasons + * @property {string[]} categories + * @property {'rules+llm'|'rules-only'} guardLevel + */ + +import { ModerationError, ModerationErrorCode } from './errors.js' +import { resolveRules, runRulesCheck } from './rules-check.js' +import { mergeDecisions, guardLevelOf } from './decision.js' + +/** + * 运行生成前置护栏。 + * + * @param {object} deps + * @param {import('./rules-check.js').Rules} deps.rules 注入的规则 + * @param {GuardPort} [deps.llmGuardPort] 可选注入的 LLM 护栏 Port + * @param {object} [args] + * @param {string} [args.text] + * @param {any} [args.context] 生成上下文(喂给 llmGuardPort) + * @param {string} [args.policy] 二期分级位(一期走默认 policy) + * @param {string} [args.region] 二期区域位(一期透传给 Port,可忽略) + * @returns {Promise} + */ +export async function runGuardGeneration({ rules, llmGuardPort }, { text, context, policy, region } = {}) { + if (typeof text !== 'string') { + throw new ModerationError({ + code: ModerationErrorCode.BAD_REQUEST, + retryable: false, + message: 'guardGeneration 需要 string 类型的 text', + }) + } + + const resolved = resolveRules(rules, policy) + + // ① 本地规则(前置硬红线)。 + const local = runRulesCheck({ text, kind: 'generated', rules: resolved }) + + /** @type {Array<{decision?:string, reasons?:string[], categories?:string[]}>} */ + const sources = [{ decision: local.decision, reasons: local.reasons, categories: [] }] + + const hasLlm = Boolean(llmGuardPort && typeof llmGuardPort.classify === 'function') + + // ② LLM 基础安全类目(仅当注入了 Port)。 + if (hasLlm && llmGuardPort) { + let cls + try { + cls = await llmGuardPort.classify({ text, context, region }) + } catch (err) { + throw new ModerationError({ + code: ModerationErrorCode.GUARD_PORT_FAILED, + retryable: true, + cause: err, + message: `llmGuardPort.classify 调用失败: ${err instanceof Error ? err.message : String(err)}`, + }) + } + if (!cls || typeof cls !== 'object') { + throw new ModerationError({ + code: ModerationErrorCode.GUARD_PORT_BAD_SHAPE, + retryable: false, + message: 'llmGuardPort.classify 必须返回 { categories, unsafe, reasons }', + }) + } + const categories = Array.isArray(cls.categories) ? cls.categories : [] + const llmReasons = Array.isArray(cls.reasons) ? cls.reasons : [] + // unsafe=true → block(前置阻断回流);否则该路 allow。 + sources.push({ + decision: cls.unsafe ? 'block' : 'allow', + reasons: llmReasons, + categories, + }) + } + + // ③ 取最严合并。 + const merged = mergeDecisions(sources) + return { + decision: merged.decision, + reasons: merged.reasons, + categories: merged.categories, + guardLevel: guardLevelOf(hasLlm), + } +} diff --git a/packages/moderation/src/index.js b/packages/moderation/src/index.js new file mode 100644 index 0000000..cad1c92 --- /dev/null +++ b/packages/moderation/src/index.js @@ -0,0 +1,28 @@ +/** + * `@drama/moderation` 公共面(唯一对外入口,§3 契约)。 + * + * 内容安全闸:把「判定内容是否安全/合规」封装成与生成解耦、与厂商解耦的注入式 package。 + * 对外只暴露 `createModeration`(工厂)、`ModerationError`(结构化错误)、 + * `createFakeRules` / `createFakeGuardPort`(测试注入)。 + * + * 一期能力:敏感词/长度/格式闸(`checkText`)+ 生成链路**前置**安全护栏 + * (`guardGeneration`,block 阻断回流)+ 供 `@drama/llm` 注入消费的 `ModerationPort` + * (经实例的 `asModerationPort()` 取得)。基础安全类目判定来自注入的 `llmGuardPort`, + * 本包不自训/自写分类模型。 + * + * 边界纪律(架构红线):包内不读 `process.env`、不建任何顶层连接;规则与 Port 全部 + * 由 `createModeration` 参数注入。moderation 不 import llm,llm 经注入 Port 调本包(架构 §8)。 + * + * 二期(分级 + 区域策略)将扩展 `policy`/`region` 的实现而不改本签名。 + * + * @typedef {import('./moderation.js').Moderation} Moderation + * @typedef {import('./moderation.js').CheckTextResult} CheckTextResult + * @typedef {import('./guard.js').GuardResult} GuardResult + * @typedef {import('./guard.js').GuardPort} GuardPort + * @typedef {import('./port.js').ModerationPort} ModerationPort + * @typedef {import('./rules-check.js').Rules} Rules + */ + +export { createModeration } from './moderation.js' +export { ModerationError } from './errors.js' +export { createFakeRules, createFakeGuardPort } from './fake.js' diff --git a/packages/moderation/src/moderation.js b/packages/moderation/src/moderation.js new file mode 100644 index 0000000..5b25564 --- /dev/null +++ b/packages/moderation/src/moderation.js @@ -0,0 +1,99 @@ +/** + * `createModeration` —— 装配层(§3 唯一工厂入口)。 + * + * 只做装配 + 编排:把注入的 `rules` 与可选 `llmGuardPort` 接到三个能力上—— + * `checkText`(敏感词/长度/格式闸)、`guardGeneration`(生成前置闸)、`asModerationPort` + * (供 llm 注入回调)。具体判定全在纯函数 `rules-check.js` / `decision.js` 与编排 + * `guard.js`,本文件不藏判定逻辑(§6 SRP/可读性)。 + * + * 边界纪律(架构红线):包内不读 `process.env`、不建任何顶层连接;规则与 Port 全部 + * 由本工厂参数注入。 + * + * @typedef {import('./rules-check.js').Rules} Rules + * @typedef {import('./guard.js').GuardPort} GuardPort + * @typedef {import('./guard.js').GuardResult} GuardResult + * @typedef {import('./port.js').ModerationPort} ModerationPort + * + * @typedef {object} CheckTextResult + * @property {'allow'|'block'|'flag'} decision + * @property {string[]} hits + * @property {string[]} reasons + * + * @typedef {object} Moderation + * @property {(args:{ text:string, kind?:string, policy?:string, region?:string }) => + * Promise} checkText + * @property {(args:{ text:string, context?:any, policy?:string, region?:string }) => + * Promise} guardGeneration + * @property {() => ModerationPort} asModerationPort + */ + +import { ModerationError, ModerationErrorCode } from './errors.js' +import { resolveRules, runRulesCheck } from './rules-check.js' +import { runGuardGeneration } from './guard.js' +import { asModerationPort } from './port.js' + +/** + * 工厂:唯一入口(§3)。`rules` 必注;`llmGuardPort` 可选(缺时 `guardGeneration` + * 降级 `rules-only`)。少注入 `rules` 在**装配时**即崩(验收 1,架构 §10.3 入口自检)。 + * + * @param {object} [opts] + * @param {Rules} [opts.rules] 数据驱动的规则(敏感词/阈值/格式),运营可改(缺则装配时崩) + * @param {GuardPort} [opts.llmGuardPort] 可选注入:LLM 基础安全分类 Port + * @returns {Moderation} + */ +export function createModeration({ rules, llmGuardPort } = {}) { + if (!rules || typeof rules !== 'object') { + throw new ModerationError({ + code: ModerationErrorCode.MISSING_RULES, + retryable: false, + message: 'createModeration 需要注入 rules(数据驱动的词表/阈值/格式规则)', + }) + } + if (llmGuardPort !== undefined && llmGuardPort !== null) { + if (typeof llmGuardPort.classify !== 'function') { + throw new ModerationError({ + code: ModerationErrorCode.GUARD_PORT_BAD_SHAPE, + retryable: false, + message: 'llmGuardPort 必须形如 { classify({ text, context, region }) }', + }) + } + } + + const deps = { rules, llmGuardPort: llmGuardPort || undefined } + + /** + * 敏感词 / 长度 / 格式闸(验收 2)。用户输入与生成文本统一入口。 + * @param {{ text?:string, kind?:string, policy?:string, region?:string }} [args] + * @returns {Promise} + */ + async function checkText({ text, kind, policy } = {}) { + if (typeof text !== 'string') { + throw new ModerationError({ + code: ModerationErrorCode.BAD_REQUEST, + retryable: false, + message: 'checkText 需要 string 类型的 text', + }) + } + const resolved = resolveRules(rules, policy) + const r = runRulesCheck({ text, kind, rules: resolved }) + return { decision: r.decision, hits: r.hits, reasons: r.reasons } + } + + /** + * 生成链路前置闸(验收 3):先本地规则,再(若注入)调 Port,取最严合并。 + * @param {{ text?:string, context?:any, policy?:string, region?:string }} [args] + * @returns {Promise} + */ + async function guardGeneration(args = {}) { + return runGuardGeneration(deps, args) + } + + return { + checkText, + guardGeneration, + /** @returns {ModerationPort} 供 @drama/llm 注入消费(验收 5)。 */ + asModerationPort() { + return asModerationPort(guardGeneration) + }, + } +} diff --git a/packages/moderation/src/port.js b/packages/moderation/src/port.js new file mode 100644 index 0000000..72009bb --- /dev/null +++ b/packages/moderation/src/port.js @@ -0,0 +1,37 @@ +/** + * `asModerationPort` —— 面收窄(§4 职责)。 + * + * 把 `guardGeneration` 收窄成 `@drama/llm` 生成时回调消费的最小面 `ModerationPort`。 + * llm 包内 grep 不到 `import moderation`:moderation 不 import llm,llm 经此注入式 Port + * 在生成时回调审核,`decision==='block'` 时 llm 必须中止回流该产物(架构 §8,验收 5)。 + * + * 本文件只做面收窄,不加新判定逻辑——保证 Port 形状稳定(§5 review 关注点 3)。 + * + * @typedef {object} ModerationPortReviewResult + * @property {'allow'|'flag'|'block'} decision + * @property {string[]} reasons + * @property {string[]} categories + * + * @typedef {object} ModerationPort + * @property {(args:{ text:string, context?:any, policy?:string, region?:string }) => + * Promise} review + */ + +/** + * @param {(args:{ text:string, context?:any, policy?:string, region?:string }) => + * Promise} guardGeneration + * @returns {ModerationPort} + */ +export function asModerationPort(guardGeneration) { + return { + /** + * @param {{ text:string, context?:any, policy?:string, region?:string }} args + * @returns {Promise} + */ + async review(args) { + const r = await guardGeneration(args) + // 只暴露 review 三元组,不外泄 guardLevel 等护栏内部细节。 + return { decision: r.decision, reasons: r.reasons, categories: r.categories } + }, + } +} diff --git a/packages/moderation/src/rules-check.js b/packages/moderation/src/rules-check.js new file mode 100644 index 0000000..1e6ce82 --- /dev/null +++ b/packages/moderation/src/rules-check.js @@ -0,0 +1,150 @@ +/** + * 本地规则校验 —— 零依赖纯函数(§4 职责:最易测)。 + * + * 三类检查全部**数据驱动**:敏感词命中、长度上下限、格式(空白/控制字符/注入痕迹)。 + * 词表与阈值**只来自注入的 `rules`**,包内 grep 不到硬编码词表(验收 2/7)。 + * 加一种规则 = 扩 `rules` 数据 + 本文件一种检查(§6 可扩展性)。 + * + * 本文件不做最终决策合并(那是 `decision.js` 的事),只产出本地这一路的子判定。 + * + * @typedef {object} Rules + * @property {string[]} [words] 敏感词表(运营注入,可改不改代码) + * @property {number} [lengthMin] 文本最短长度(含) + * @property {number} [lengthMax] 文本最长长度(含) + * @property {FormatRule[]} [formatRules] 格式规则(正则 / 内置类型) + * @property {Record>} [policies] 二期:按 policy 覆盖(一期走默认) + * + * @typedef {object} FormatRule + * @property {string} id 规则标识,进 reasons 用 + * @property {('control-char'|'regex')} [type] 内置 'control-char',或自定义 'regex' + * @property {string|RegExp} [pattern] type='regex' 时的匹配源;命中即视为非法 + * @property {string} [reason] 命中时的 reason 文案(缺省用 id) + * + * @typedef {object} RulesCheckResult + * @property {'allow'|'flag'|'block'} decision + * @property {string[]} hits 命中的敏感词原文 + * @property {string[]} reasons 结构化原因码 + */ + +/** + * 控制字符(C0 0x00-0x1F 去掉常规 \t \n \r、C1 0x7F-0x9F)—— 注入痕迹/不可见字符的 + * 内置检查。用 \u 转义写死,避免源码里出现裸控制字节。 + */ +// eslint-disable-next-line no-control-regex +const CONTROL_CHAR_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/ + +/** + * 解析「一期生效的 rules」:若注入了 `policies[policy]` 则浅覆盖默认字段, + * 否则原样返回默认(二期分级/区域在此扩展,一期默认不报错——验收 6)。 + * + * @param {Rules} [rules] + * @param {string} [policy] + * @returns {Rules} + */ +export function resolveRules(rules, policy) { + if (!rules || typeof rules !== 'object') return {} + if (!policy || !rules.policies || !rules.policies[policy]) return rules + return { ...rules, ...rules.policies[policy] } +} + +/** + * 敏感词命中(大小写不敏感的包含匹配)。 + * @param {string} text + * @param {string[]} [words] + * @returns {string[]} 命中的词 + */ +export function findWordHits(text, words) { + if (!Array.isArray(words) || words.length === 0) return [] + const lower = text.toLowerCase() + /** @type {string[]} */ + const hits = [] + for (const w of words) { + if (typeof w !== 'string' || !w) continue + if (lower.includes(w.toLowerCase()) && !hits.includes(w)) hits.push(w) + } + return hits +} + +/** + * 格式检查:内置 control-char + 注入式 regex 规则。命中返回 reason 列表。 + * @param {string} text + * @param {FormatRule[]} [formatRules] + * @returns {string[]} + */ +export function checkFormat(text, formatRules) { + /** @type {string[]} */ + const reasons = [] + + // 内置:控制字符 / 不可见注入字符。无需运营配置,默认开启。 + if (CONTROL_CHAR_RE.test(text)) reasons.push('format:control-char') + + if (!Array.isArray(formatRules)) return reasons + for (const rule of formatRules) { + if (!rule || typeof rule !== 'object') continue + const id = rule.id || 'format' + const reason = rule.reason || `format:${id}` + if (rule.type === 'control-char') { + if (CONTROL_CHAR_RE.test(text) && !reasons.includes(reason)) reasons.push(reason) + continue + } + if (rule.pattern !== undefined) { + const re = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern) + if (re.test(text) && !reasons.includes(reason)) reasons.push(reason) + } + } + return reasons +} + +/** + * 跑全部本地规则,给出本地这一路子判定。 + * + * 判定策略(一期固定,显式可见): + * - 敏感词命中 / 长度越界 / 非法格式 → `block`(本地硬红线)。 + * - 全部通过 → `allow`。 + * (`flag`(软标记)留给 llm 护栏或二期分级使用,一期本地不主动产出 flag, + * 避免半成品分级逻辑混入——§5 review 关注点 6。) + * + * @param {object} args + * @param {string} args.text + * @param {string} [args.kind] 'user-input' | 'generated' | 'profile' ...(一期不分支,进 reasons 备注) + * @param {Rules} args.rules 已解析的规则(调用方可先 resolveRules) + * @returns {RulesCheckResult} + */ +export function runRulesCheck({ text, kind, rules }) { + /** @type {string[]} */ + const reasons = [] + /** @type {string[]} */ + let hits = [] + + if (typeof text !== 'string') { + return { decision: 'block', hits: [], reasons: ['format:not-a-string'] } + } + + const r = rules || {} + + // 长度 + const len = text.length + if (typeof r.lengthMin === 'number' && len < r.lengthMin) { + reasons.push('length:too-short') + } + if (typeof r.lengthMax === 'number' && len > r.lengthMax) { + reasons.push('length:too-long') + } + + // 格式 + for (const fr of checkFormat(text, r.formatRules)) reasons.push(fr) + + // 敏感词 + hits = findWordHits(text, r.words) + for (const w of hits) reasons.push(`word:${w}`) + + if (kind) reasons.push(`kind:${kind}`) + + // 任何实质问题(非 kind 备注)即 block + const hasIssue = reasons.some((x) => !x.startsWith('kind:')) + return { + decision: hasIssue ? 'block' : 'allow', + hits, + reasons, + } +} diff --git a/packages/observability/CONVENTION.md b/packages/observability/CONVENTION.md new file mode 100644 index 0000000..3f38254 --- /dev/null +++ b/packages/observability/CONVENTION.md @@ -0,0 +1,34 @@ +# `@drama/observability` · `ctx` 贯穿约定(权威原文) + +> 本文件是「`RequestContext` 作为显式首参 `ctx` 贯穿所有服务签名」约定的**单一事实来源**。 +> 下游所有 service / application-service / repo / 路由的签名设计都引用本文。任何对 `RequestContext` +> 形状、`Logger` 形状或本约定的改动,**必须先改本文件与任务文档 §3,再改代码**,因为它影响全仓所有签名。 + +## 约定原文(硬性,被全仓引用) + +一切跨进程边界进来的逻辑入口(HTTP 路由、定时任务、消息消费)在**最外层**创建或续传 `RequestContext`, +并把它作为**显式首参 `ctx`** 一路传递到 service、application-service、必要时到 repo。形如: + +```js +dramaService.buildBundle(ctx, dramaId) +``` + +**全仓所有 service 方法的第一形参必须是 `ctx: RequestContext`。** + +## 红线 + +- **禁止**用 `AsyncLocalStorage` 等隐式穿透携带 ctx(架构基线点名旧参照版靠 ALS 隐式穿 billing 是反模式)。 + `ctx` 必须**显式、可见、可测**。 +- **禁止**用全局可变单例「偷偷」携带 ctx。 +- 包内**不读 `process.env`**、**不建顶层连接**、**不在顶层装 OTel SDK**;`import` 本包无副作用。 + +## 为什么 + +如果一期不定 `ctx` 贯穿约定,二期接 OpenTelemetry 就要回头给**每一个 service 函数**加 trace 参数、 +给**每一处日志**补 `trace_id`——改造面是全仓。一期花几行定下约定与日志工厂,把这个改造面压成接近零。 + +## 二期升级点(签名不变,调用方零改动) + +- `createRequestContext` 升级为携带真实 OTel span context。 +- `createLogger` 接 OTel log bridge。 +- `RequestContext` 形态与 `child` / 工厂入口**不变**——这是「一期约定先行」的开闭兑现点。 diff --git a/packages/observability/package.json b/packages/observability/package.json new file mode 100644 index 0000000..21d493b --- /dev/null +++ b/packages/observability/package.json @@ -0,0 +1,12 @@ +{ + "name": "@drama/observability", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + } +} diff --git a/packages/observability/src/context.js b/packages/observability/src/context.js new file mode 100644 index 0000000..a717949 --- /dev/null +++ b/packages/observability/src/context.js @@ -0,0 +1,46 @@ +import { randomUUID } from 'node:crypto' + +/** + * @typedef {Object} RequestContext + * @property {string} traceId 贯穿一次请求的 trace id + * @property {Object} fields 累积的结构化字段(如 userId/route) + * @property {(extraFields?: Object) => RequestContext} child + * 派生子上下文:继承 `traceId`,merge 字段,返回**新** ctx(不可变派生,不污染父) + */ + +/** + * 创建请求上下文。入口处一次,或从入站 trace header 续传。 + * 不传 `traceId` 时自动生成(`crypto.randomUUID`);传则续用(跨服务/入站续传)。 + * + * @param {Object} [opts] + * @param {string} [opts.traceId] 续传已有 trace_id;省略则自动生成 + * @param {Object} [opts.fields] 初始附加字段(如 route) + * @returns {RequestContext} + */ +export function createRequestContext(opts) { + const traceId = opts && opts.traceId ? opts.traceId : randomUUID() + const fields = opts && opts.fields ? { ...opts.fields } : {} + return makeContext(traceId, fields) +} + +/** + * 内部工厂:把 traceId + 已固化的 fields 包成不可变 RequestContext。 + * @param {string} traceId + * @param {Object} fields 调用方已复制好的字段对象,本函数不再外泄其可变引用 + * @returns {RequestContext} + */ +function makeContext(traceId, fields) { + /** @type {RequestContext} */ + const ctx = { + traceId, + fields, + child(extraFields) { + // 不可变派生:合并出全新字段对象,父 ctx.fields 不被触碰。 + const merged = { ...fields, ...(extraFields || {}) } + return makeContext(traceId, merged) + }, + } + // 冻结对外可见形态,防止下游意外篡改 traceId/child 造成 trace 漂移。 + Object.freeze(ctx.fields) + return Object.freeze(ctx) +} diff --git a/packages/observability/src/index.js b/packages/observability/src/index.js new file mode 100644 index 0000000..94681fa --- /dev/null +++ b/packages/observability/src/index.js @@ -0,0 +1,18 @@ +/** + * `@drama/observability` 公共面(唯一对外入口)。 + * + * 一期:`RequestContext(trace_id)` 贯穿约定 + 结构化日志工厂 + `withTrace`。 + * 「ctx 作为显式首参贯穿所有服务签名」的约定原文见 `CONVENTION.md`(下游引用源)。 + * + * @typedef {import('./context.js').RequestContext} RequestContext + * @typedef {import('./logger.js').Logger} Logger + */ + +export { createRequestContext } from './context.js' +export { createLogger } from './logger.js' +export { withTrace } from './with-trace.js' + +/* trace 面(任务02):span 结构 + 默认日志 sink / OTLP sink + TracePort 适配。 */ +export { startSpan, createLogSink } from './span.js' +export { createOtlpSink } from './span-sink-otlp.js' +export { asTracePort } from './trace-port.js' diff --git a/packages/observability/src/logger.js b/packages/observability/src/logger.js new file mode 100644 index 0000000..118d31e --- /dev/null +++ b/packages/observability/src/logger.js @@ -0,0 +1,84 @@ +import { serializeLine, serializePrettyLine } from './serialize.js' + +/** + * @typedef {import('./context.js').RequestContext} RequestContext + */ + +/** + * @typedef {Object} Logger + * @property {(msg: string, fields?: Object) => void} debug + * @property {(msg: string, fields?: Object) => void} info + * @property {(msg: string, fields?: Object) => void} warn + * @property {(msg: string, fields?: Object) => void} error fields 可含 `err`,会被结构化序列化 + * @property {(ctx: RequestContext) => Logger} child 派生自动带 trace_id + ctx.fields 的 logger + */ + +/** level 名 → 数值,用于过滤。 */ +const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 } + +/** + * 默认 sink:写一行到 stdout。不在 import 期执行,仅作为函数默认参数被引用。 + * @param {string} line + */ +function defaultSink(line) { + process.stdout.write(line + '\n') +} + +/** + * 结构化日志工厂。`service` 名固定写入每条日志。 + * + * @param {Object} cfg + * @param {string} cfg.service 服务/包名,如 'drama-service' + * @param {('debug'|'info'|'warn'|'error')} [cfg.level='info'] + * @param {('json'|'pretty')} [cfg.format='json'] + * @param {(line: string) => void} [cfg.sink] 输出目标,默认写 stdout;测试可注入 buffer + * @returns {Logger} + */ +export function createLogger(cfg) { + const service = cfg && cfg.service + const level = (cfg && cfg.level) || 'info' + const format = (cfg && cfg.format) || 'json' + const sink = (cfg && cfg.sink) || defaultSink + const threshold = LEVELS[level] ?? LEVELS.info + // bound:随 child(ctx) 绑定的 trace_id + ctx.fields;根 logger 无绑定字段。 + return makeLogger({ service, threshold, format, sink, bound: null }) +} + +/** + * @param {{ service: string, threshold: number, format: 'json'|'pretty', sink: (line:string)=>void, bound: { trace_id: string, fields: Object } | null }} state + * @returns {Logger} + */ +function makeLogger(state) { + /** + * @param {('debug'|'info'|'warn'|'error')} level + * @param {string} msg + * @param {Object} [fields] + */ + function emit(level, msg, fields) { + if (LEVELS[level] < state.threshold) return // level 过滤 + const record = { + time: new Date().toISOString(), + level, + service: state.service, + trace_id: state.bound ? state.bound.trace_id : undefined, + msg, + ...(state.bound ? state.bound.fields : null), + ...(fields || null), + } + state.sink(state.format === 'pretty' ? serializePrettyLine(record) : serializeLine(record)) + } + + return { + debug: (msg, fields) => emit('debug', msg, fields), + info: (msg, fields) => emit('info', msg, fields), + warn: (msg, fields) => emit('warn', msg, fields), + error: (msg, fields) => emit('error', msg, fields), + child(ctx) { + // 绑定 ctx 的 trace_id 与累积字段,派生新 logger;不修改当前 logger。 + return makeLogger({ + ...state, + bound: { trace_id: ctx.traceId, fields: ctx.fields || {} }, + }) + }, + } +} diff --git a/packages/observability/src/serialize.js b/packages/observability/src/serialize.js new file mode 100644 index 0000000..ce44e70 --- /dev/null +++ b/packages/observability/src/serialize.js @@ -0,0 +1,92 @@ +/** + * 纯函数:把一条结构化日志记录安全序列化成**一行** JSON 字符串。 + * + * 健壮性约定(见任务文档 §5.4 / §8): + * - `Error` 实例转成 `{ name, message, stack }`,不会丢成 `{}`。 + * - 循环引用不抛错(用 WeakSet 检测,替换为 `'[Circular]'`)。 + * - `undefined` 字段被丢弃;`null` 保留。 + * - `bigint`/`function`/`symbol` 等非 JSON 原生类型被安全降级为字符串描述,绝不抛。 + * - 永不抛异常:即便序列化彻底失败,也返回一个**仍带 trace_id 的**兜底 JSON 行。 + * + * @param {Record} record 已成形的日志记录(含 time/level/service/trace_id/msg + 自定义字段) + * @returns {string} 一行 JSON(不含换行) + */ +export function serializeLine(record) { + const seen = new WeakSet() + try { + return JSON.stringify(record, replacer(seen)) + } catch { + // 最后兜底:保证 trace_id 永不丢,且依然是合法 JSON。 + const traceId = + record && typeof record === 'object' ? record.trace_id : undefined + return JSON.stringify({ + time: new Date().toISOString(), + level: 'error', + service: 'observability', + trace_id: traceId ?? null, + msg: 'log serialization failed', + }) + } +} + +/** + * 本地运行使用的单行可读格式。生产环境仍使用 JSON,方便日志平台检索。 + * @param {Record} record + * @returns {string} + */ +export function serializePrettyLine(record) { + const fixed = new Set(['time', 'level', 'service', 'msg']) + const fields = [] + for (const [key, value] of Object.entries(record)) { + if (fixed.has(key) || value === undefined) continue + fields.push(`${key}=${prettyValue(value)}`) + } + const header = `${record.time} ${String(record.level).toUpperCase().padEnd(5)} ${record.service}` + return fields.length > 0 + ? `${header} ${record.msg} | ${fields.join(' ')}` + : `${header} ${record.msg}` +} + +/** + * 构造一个 `JSON.stringify` replacer:处理 Error、循环引用、非 JSON 原生类型。 + * @param {WeakSet} seen + * @returns {(this: any, key: string, value: any) => any} + */ +function replacer(seen) { + return function (key, value) { + if (value instanceof Error) { + return errorRecord(value) + } + if (typeof value === 'bigint') return value.toString() + if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]` + if (typeof value === 'symbol') return value.toString() + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) return '[Circular]' + seen.add(value) + } + return value + } +} + +/** @param {Error & { code?: unknown, sqlMessage?: unknown }} error */ +function errorRecord(error) { + const sqlMessage = typeof error.sqlMessage === 'string' ? error.sqlMessage : null + return { + name: error.name, + code: typeof error.code === 'string' ? error.code : undefined, + message: sqlMessage || error.message, + stack: sqlMessage ? undefined : error.stack, + } +} + +/** @param {any} value */ +function prettyValue(value) { + if (value instanceof Error) { + const error = errorRecord(value) + const code = error.code ? ` code=${error.code}` : '' + return `${error.name}${code} message=${JSON.stringify(error.message)}` + } + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'number' || typeof value === 'boolean' || value === null) return String(value) + return JSON.stringify(value, replacer(new WeakSet())) +} diff --git a/packages/observability/src/span-sink-otlp.js b/packages/observability/src/span-sink-otlp.js new file mode 100644 index 0000000..5d87c9a --- /dev/null +++ b/packages/observability/src/span-sink-otlp.js @@ -0,0 +1,86 @@ +/** + * OTLP/HTTP sink —— 把 `CompletedSpan` 编码为 OTLP JSON 并经注入 `fetchImpl` 发一次 POST。 + * + * body 结构:`resourceSpans[].scopeSpans[].spans[]`(OTLP/HTTP JSON 编码)。 + * 边界纪律:`fetchImpl` 注入,包内无全局 `fetch`、不持有连接、不读 env。 + */ + +/** + * @typedef {import('./span.js').CompletedSpan} CompletedSpan + * @typedef {import('./span.js').Sink} Sink + */ + +/** + * JS 值 → OTLP `AnyValue`。整数取 `intValue`(字符串编码),非整数取 `doubleValue`。 + * @param {any} v + * @returns {object} + */ +function toAnyValue(v) { + if (typeof v === 'string') return { stringValue: v } + if (typeof v === 'boolean') return { boolValue: v } + if (Number.isInteger(v)) return { intValue: String(v) } + return { doubleValue: v } +} + +/** + * 对象 → OTLP `KeyValue[]`。 + * @param {Record} obj + * @returns {Array<{ key: string, value: object }>} + */ +function toKeyValues(obj) { + return Object.entries(obj).map(([key, v]) => ({ key, value: toAnyValue(v) })) +} + +/** + * `CompletedSpan` → OTLP span。事件时间统一取 span 结束时刻(本包不逐事件采时钟)。 + * @param {CompletedSpan} span + * @returns {object} + */ +function toOtlpSpan(span) { + const timeUnixNano = String(span.endUnixNano) + return { + name: span.name, + startTimeUnixNano: String(span.startUnixNano), + endTimeUnixNano: timeUnixNano, + attributes: toKeyValues(span.attributes), + events: span.events.map((e) => ({ + name: e.name, + timeUnixNano, + attributes: toKeyValues(e.attributes), + })), + } +} + +/** + * OTLP/HTTP sink 工厂。 + * @param {object} deps + * @param {typeof fetch} deps.fetchImpl 注入的 fetch。 + * @param {string} deps.endpoint OTLP traces 端点(如 http://otel:4318/v1/traces)。 + * @param {Record} [deps.headers={}] 附加请求头(如鉴权)。 + * @param {Record} [deps.resourceAttributes={}] 资源属性(如 service.name)。 + * @returns {Sink} + */ +export function createOtlpSink({ fetchImpl, endpoint, headers = {}, resourceAttributes = {} }) { + return { + write(span) { + const body = { + resourceSpans: [ + { + resource: { attributes: toKeyValues(resourceAttributes) }, + scopeSpans: [ + { + scope: { name: '@drama/observability' }, + spans: [toOtlpSpan(span)], + }, + ], + }, + ], + } + fetchImpl(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }) + }, + } +} diff --git a/packages/observability/src/span.js b/packages/observability/src/span.js new file mode 100644 index 0000000..85a3639 --- /dev/null +++ b/packages/observability/src/span.js @@ -0,0 +1,143 @@ +/** + * trace 面(span 侧)—— `startSpan → Span → CompletedSpan` 与默认结构化日志 sink。 + * + * 属性对齐 OpenTelemetry `gen_ai.*` 语义约定:`@drama/llm` base.js 传入的中性键 + * (`provider`/`model`/usage 分量)在本包唯一一处(`mapAttrKey`/`mapEventAttrs`) + * 映射为 gen_ai 语义键;下游两个 sink 消费同一 `CompletedSpan`,不再判类型。 + * + * 边界纪律:时钟 `now`、`logger`、`sink` 全部注入;包内不读 env、不建连接、不写盘。 + */ + +/** + * @typedef {import('./logger.js').Logger} Logger + */ + +/** + * @typedef {object} SpanEvent + * @property {string} name + * @property {Record} attributes + */ + +/** + * `end()` 写给 sink 的完结产物(两个 sink 消费的唯一结构)。 + * @typedef {object} CompletedSpan + * @property {string} name + * @property {number} durationMs end 时刻 − startSpan 时刻。 + * @property {number} startUnixNano + * @property {number} endUnixNano + * @property {Record} attributes 已映射为 gen_ai.* 语义键。 + * @property {Array} events + */ + +/** + * 上报目标端口 —— 唯一方法 `write`。新增上报目标 = 新增一个 sink 工厂,不改本文件。 + * @typedef {object} Sink + * @property {(span: CompletedSpan) => void} write + */ + +/** + * @typedef {object} Span + * @property {(k: string, v: any) => void} setAttr + * @property {(n: string, d?: object) => void} addEvent + * @property {() => void} end + */ + +/** `setAttr` 键 → gen_ai 语义属性键。 */ +const ATTR_KEY_MAP = { + provider: 'gen_ai.system', + model: 'gen_ai.request.model', +} + +/** `usage` 事件属性键 → gen_ai 语义属性键。 */ +const USAGE_KEY_MAP = { + prompt_tokens: 'gen_ai.usage.input_tokens', + completion_tokens: 'gen_ai.usage.output_tokens', + total_tokens: 'gen_ai.usage.total_tokens', + cached_tokens: 'gen_ai.usage.cached_tokens', + cache_write_tokens: 'gen_ai.usage.cache_write_tokens', + reasoning_tokens: 'gen_ai.usage.reasoning_tokens', +} + +/** + * setAttr 键映射:命中语义表返回 gen_ai 键,未命中原样返回。 + * @param {string} k + * @returns {string} + */ +function mapAttrKey(k) { + return ATTR_KEY_MAP[k] ?? k +} + +/** + * 事件属性键映射:仅 `usage` 事件按 `USAGE_KEY_MAP` 重命名(只映射表内出现的键), + * 其余事件(`content`/`error`/`aborted`)属性原样透传。 + * @param {string} name + * @param {Record} data + * @returns {Record} + */ +function mapEventAttrs(name, data) { + if (name !== 'usage') return data + /** @type {Record} */ + const out = {} + for (const [k, val] of Object.entries(data)) { + out[USAGE_KEY_MAP[k] ?? k] = val + } + return out +} + +/** + * 开一个 span。返回值恒为 `{ setAttr, addEvent, end }` 三方法对象。 + * `end()` 恰调一次 `sink.write(CompletedSpan)`。 + * + * @param {string} name + * @param {object} deps + * @param {Sink} deps.sink 必注入;缺 tracePort 的降级由 @drama/llm noopTracePort 承担。 + * @param {() => number} [deps.now=Date.now] 注入时钟(毫秒),测试可推进。 + * @returns {Span} + */ +export function startSpan(name, { sink, now = Date.now }) { + const startMs = now() + /** @type {Record} */ + const attributes = {} + /** @type {Array} */ + const events = [] + return { + setAttr(k, v) { + attributes[mapAttrKey(k)] = v + }, + addEvent(n, d) { + events.push({ name: n, attributes: mapEventAttrs(n, d ?? {}) }) + }, + end() { + const endMs = now() + sink.write({ + name, + durationMs: endMs - startMs, + startUnixNano: startMs * 1e6, + endUnixNano: endMs * 1e6, + attributes, + events, + }) + }, + } +} + +/** + * 默认 sink:把一个完结 span 写成 `createLogger` 的**一条**结构化 JSON 行。 + * `logger` 为 `createLogger(...)` 实例(注入,本包不 new)。 + * + * @param {object} deps + * @param {Logger} deps.logger + * @returns {Sink} + */ +export function createLogSink({ logger }) { + return { + write(span) { + logger.info('llm.trace', { + span: span.name, + duration_ms: span.durationMs, + ...span.attributes, + events: span.events, + }) + }, + } +} diff --git a/packages/observability/src/trace-port.js b/packages/observability/src/trace-port.js new file mode 100644 index 0000000..c39e9bc --- /dev/null +++ b/packages/observability/src/trace-port.js @@ -0,0 +1,26 @@ +/** + * asTracePort —— 把 span 侧适配成 `@drama/llm` `TracePort` 形状(`startSpan(name) → Span`)。 + * + * `sink` 必注入、原样透传给 `startSpan`;无「可能没有 sink」的守卫分支—— + * 缺 tracePort 的降级由 `@drama/llm` 的 `noopTracePort` 承担,不在本包重复。 + */ + +import { startSpan } from './span.js' + +/** + * @typedef {import('./span.js').Sink} Sink + * @typedef {import('@drama/llm').TracePort} TracePort + */ + +/** + * @param {object} deps + * @param {Sink} deps.sink + * @returns {TracePort} + */ +export function asTracePort({ sink }) { + return { + startSpan(name) { + return startSpan(name, { sink }) + }, + } +} diff --git a/packages/observability/src/with-trace.js b/packages/observability/src/with-trace.js new file mode 100644 index 0000000..cc5b941 --- /dev/null +++ b/packages/observability/src/with-trace.js @@ -0,0 +1,35 @@ +import { createRequestContext } from './context.js' + +/** + * 判断一个值是否已是 RequestContext(鸭子类型:有 string traceId 与 child 方法)。 + * @param {any} v + * @returns {boolean} + */ +function isRequestContext(v) { + return ( + !!v && + typeof v === 'object' && + typeof v.traceId === 'string' && + typeof v.child === 'function' + ) +} + +/** + * 把「`(ctx, ...args) => ...`」的函数包成自动注入 ctx 的函数。 + * 调用时若首参不是 `RequestContext`,则 `createRequestContext()` 注入为首参(自动生成 traceId); + * 已传则透传同一 ctx(同一 trace)。 + * + * @template {(...args: any[]) => any} F + * @param {F} fn + * @returns {F} + */ +export function withTrace(fn) { + /** @type {any} */ + const wrapped = function (/** @type {any[]} */ ...args) { + // @ts-ignore — 透传调用方 this,保留任意可调用形态。 + if (isRequestContext(args[0])) return fn.apply(this, args) + // @ts-ignore + return fn.apply(this, [createRequestContext(), ...args]) + } + return wrapped +} diff --git a/packages/observability/test/logger.test.js b/packages/observability/test/logger.test.js new file mode 100644 index 0000000..72f10ed --- /dev/null +++ b/packages/observability/test/logger.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { createLogger } from '../src/logger.js' +import { createRequestContext } from '../src/context.js' + +/** 简易 buffer sink。 */ +function bufferSink() { + const lines = [] + return { sink: (l) => lines.push(l), lines } +} + +describe('createLogger', () => { + it('emits one valid JSON line per call with required fields', () => { + const buf = bufferSink() + const log = createLogger({ service: 'svc', sink: buf.sink }) + const ctx = createRequestContext({ traceId: 'tid' }) + log.child(ctx).info('hello', { k: 1 }) + expect(buf.lines).toHaveLength(1) + const rec = JSON.parse(buf.lines[0]) + expect(rec.service).toBe('svc') + expect(rec.trace_id).toBe('tid') + expect(rec.level).toBe('info') + expect(rec.msg).toBe('hello') + expect(typeof rec.time).toBe('string') + expect(rec.k).toBe(1) + }) + + it('filters levels below the configured threshold', () => { + const buf = bufferSink() + const log = createLogger({ service: 'svc', level: 'info', sink: buf.sink }) + log.debug('dropped') + log.info('kept') + expect(buf.lines).toHaveLength(1) + expect(JSON.parse(buf.lines[0]).msg).toBe('kept') + }) + + it('child(ctx) binds the correct trace_id and ctx.fields on every line', () => { + const buf = bufferSink() + const log = createLogger({ service: 'svc', sink: buf.sink }) + const ctx = createRequestContext({ traceId: 'T', fields: { userId: 'u1' } }) + const clog = log.child(ctx) + clog.warn('a') + clog.error('b', { err: new Error('x') }) + expect(buf.lines).toHaveLength(2) + for (const line of buf.lines) { + const rec = JSON.parse(line) + expect(rec.trace_id).toBe('T') + expect(rec.userId).toBe('u1') + } + expect(JSON.parse(buf.lines[1]).err.message).toBe('x') + }) + + it('per-call fields override bound ctx.fields', () => { + const buf = bufferSink() + const log = createLogger({ service: 'svc', sink: buf.sink }) + const ctx = createRequestContext({ traceId: 'T', fields: { a: 1 } }) + log.child(ctx).info('m', { a: 2 }) + expect(JSON.parse(buf.lines[0]).a).toBe(2) + }) + + it('child does not mutate the parent logger', () => { + const buf = bufferSink() + const root = createLogger({ service: 'svc', sink: buf.sink }) + const ctx = createRequestContext({ traceId: 'T' }) + root.child(ctx) + root.info('no-trace') + const rec = JSON.parse(buf.lines[0]) + expect('trace_id' in rec).toBe(false) + }) +}) diff --git a/packages/observability/test/serialize.test.js b/packages/observability/test/serialize.test.js new file mode 100644 index 0000000..4207010 --- /dev/null +++ b/packages/observability/test/serialize.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { serializeLine } from '../src/serialize.js' + +describe('serializeLine', () => { + it('produces a single-line valid JSON string', () => { + const line = serializeLine({ trace_id: 't1', msg: 'hi' }) + expect(line).not.toContain('\n') + expect(JSON.parse(line)).toEqual({ trace_id: 't1', msg: 'hi' }) + }) + + it('serializes Error with name/message/stack instead of {}', () => { + const err = new TypeError('boom') + const out = JSON.parse(serializeLine({ trace_id: 't2', err })) + expect(out.err.name).toBe('TypeError') + expect(out.err.message).toBe('boom') + expect(typeof out.err.stack).toBe('string') + }) + + it('does not throw on circular references and keeps trace_id', () => { + const a = { trace_id: 't3' } + a.self = a + const out = JSON.parse(serializeLine(a)) + expect(out.trace_id).toBe('t3') + expect(out.self).toBe('[Circular]') + }) + + it('drops undefined fields, keeps null', () => { + const out = JSON.parse(serializeLine({ trace_id: 't4', a: undefined, b: null })) + expect('a' in out).toBe(false) + expect(out.b).toBe(null) + expect(out.trace_id).toBe('t4') + }) + + it('downgrades bigint/function/symbol without throwing', () => { + const out = JSON.parse( + serializeLine({ trace_id: 't5', big: 10n, fn: function named() {}, sym: Symbol('x') }), + ) + expect(out.big).toBe('10') + expect(out.fn).toBe('[Function: named]') + expect(out.sym).toBe('Symbol(x)') + }) + + it('never throws and still carries trace_id on total failure', () => { + // 用一个会让 toJSON 抛错的对象逼出兜底分支。 + const evil = { + trace_id: 't6', + bad: { + toJSON() { + throw new Error('nope') + }, + }, + } + const out = JSON.parse(serializeLine(evil)) + expect(out.trace_id).toBe('t6') + expect(out.msg).toBe('log serialization failed') + }) +}) diff --git a/packages/observability/test/with-trace.test.js b/packages/observability/test/with-trace.test.js new file mode 100644 index 0000000..210c239 --- /dev/null +++ b/packages/observability/test/with-trace.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { withTrace } from '../src/with-trace.js' +import { createRequestContext } from '../src/context.js' + +describe('withTrace', () => { + it('injects a fresh ctx as first arg when none is passed', () => { + const wrapped = withTrace((ctx, x) => ({ traceId: ctx.traceId, x })) + const r = wrapped(42) + expect(typeof r.traceId).toBe('string') + expect(r.traceId.length).toBeGreaterThan(0) + expect(r.x).toBe(42) + }) + + it('passes through an existing ctx (same trace, no rewrap)', () => { + const ctx = createRequestContext({ traceId: 'keep-me' }) + const wrapped = withTrace((c, y) => ({ traceId: c.traceId, y, same: c === ctx })) + const r = wrapped(ctx, 7) + expect(r.traceId).toBe('keep-me') + expect(r.y).toBe(7) + expect(r.same).toBe(true) + }) + + it('generated traceIds differ across calls without ctx', () => { + const wrapped = withTrace((ctx) => ctx.traceId) + expect(wrapped()).not.toBe(wrapped()) + }) +}) diff --git a/packages/ratelimit/package.json b/packages/ratelimit/package.json new file mode 100644 index 0000000..2e3193b --- /dev/null +++ b/packages/ratelimit/package.json @@ -0,0 +1,12 @@ +{ + "name": "@drama/ratelimit", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + } +} diff --git a/packages/ratelimit/src/errors.js b/packages/ratelimit/src/errors.js new file mode 100644 index 0000000..01b5954 --- /dev/null +++ b/packages/ratelimit/src/errors.js @@ -0,0 +1,14 @@ +/** + * 结构化配置错误。工厂入口(`createRateLimiter`)在 store 缺失 / rules 非法时 + * 立即抛出,让少注入或写错规则在装配时即崩,而不是拖到运行某条限流判定。 + */ +export class RateLimitConfigError extends Error { + /** + * @param {string} message + * @param {{ cause?: unknown }} [opts] + */ + constructor(message, opts) { + super(message, opts) + this.name = 'RateLimitConfigError' + } +} diff --git a/packages/ratelimit/src/index.js b/packages/ratelimit/src/index.js new file mode 100644 index 0000000..8b6dcc1 --- /dev/null +++ b/packages/ratelimit/src/index.js @@ -0,0 +1,23 @@ +/** + * `@drama/ratelimit` 公共面(唯一对外入口,§3 契约)。 + * + * 给烧钱的 AI 端点(LLM 续写 / TTS 合成 / 生图)挂一层基础令牌桶限流, + * 防止 D0 上线即被脚本按 per-IP / 匿名维度薅穿真金白银。计数状态走注入的 + * store(生产 redis、测试/单机内存);本包不认识 redis、不读 env、不建任何 + * 顶层连接,store 与时钟全部由工厂参数注入。 + * + * 对外只暴露三个符号:`createRateLimiter`(工厂)、`createMemoryStore`(单机/单测 + * store 实现)、`RateLimitConfigError`(结构化配置错误)。桶数学(token-bucket)、 + * key 构造、规则匹配均为内部细节,不导出。 + * + * redis store 不在本包——它需要 redis client(建连接),属 composition root 关切。 + * `@app/server` 注入实现 RateLimitStore 接口的 redis 适配(client 仍由 server 注入)。 + * + * @typedef {import('./rules.js').RateRule} RateRule + * @typedef {import('./limiter.js').RateQuery} RateQuery + * @typedef {import('./limiter.js').RateDecision} RateDecision + */ + +export { createRateLimiter } from './limiter.js' +export { createMemoryStore } from './memory-store.js' +export { RateLimitConfigError } from './errors.js' diff --git a/packages/ratelimit/src/key.js b/packages/ratelimit/src/key.js new file mode 100644 index 0000000..4286e23 --- /dev/null +++ b/packages/ratelimit/src/key.js @@ -0,0 +1,25 @@ +/** + * store key 构造(纯函数)。 + * + * 由 (endpoint, by, subject) 拼出 store 内唯一的桶 key,保证: + * - 不同 endpoint / 不同 by 维度 / 不同 subject 互不串桶; + * - 带命名空间前缀,与同一 redis 实例上的其它业务 key 不撞。 + * + * subject 是不透明字符串(IP 或匿名 device-id),本包不解析其语义。 + * 用 `\x1f`(单元分隔符)做字段分隔,避免 subject 内含 `:` 时与结构分隔混淆。 + */ + +/** 命名空间前缀,隔离限流 key 与其它 redis key。 */ +export const KEY_NAMESPACE = 'rl' + +const SEP = '\x1f' + +/** + * 构造桶 key。 + * @param {{ endpoint: string, by: string }} rule + * @param {string} subject + * @returns {string} + */ +export function bucketKey(rule, subject) { + return `${KEY_NAMESPACE}${SEP}${rule.endpoint}${SEP}${rule.by}${SEP}${subject}` +} diff --git a/packages/ratelimit/src/limiter.js b/packages/ratelimit/src/limiter.js new file mode 100644 index 0000000..4b4e1de --- /dev/null +++ b/packages/ratelimit/src/limiter.js @@ -0,0 +1,129 @@ +/** + * limiter 编排:规则匹配 → key 构造 → 调注入 store 的 take/peek → 成形 RateDecision。 + * + * 本文件只做编排与判定成形,不碰桶数学(在 token-bucket.js)、不碰存储细节 + * (在 store 实现里)、不读 env、不建连接——store 与 clock 全由工厂参数注入。 + * + * 时钟可注入(`nowMs`),默认 `Date.now`,仅在此装配层引用一次,便于测试精确 + * 推进时间;桶数学文件本身不调时钟。 + * + * @typedef {import('./rules.js').RateRule} RateRule + * + * @typedef {Object} RateQuery + * @property {string} endpoint 端点类别 + * @property {string} subject 主体(IP 或匿名 device-id),不透明字符串 + * + * @typedef {Object} RateDecision + * @property {boolean} allowed + * @property {number} remaining 剩余配额(整数) + * @property {number} retryAfterMs 超限时建议等待毫秒;放行时为 0 + * @property {string} ruleId 命中的规则 id(无匹配规则时为 '') + * + * RateLimitStore(注入): + * { + * take(key, cost, rule, nowMs): Promise<{ allowed, remaining, retryAfterMs }>, + * peek(key, rule, nowMs): Promise<{ remaining, retryAfterMs }> + * } + */ + +import { RateLimitConfigError } from './errors.js' +import { validateRules, indexRules, selectRule, ruleIdOf } from './rules.js' +import { bucketKey } from './key.js' + +/** 一次扣减的令牌数。一期固定为 1(每次 AI 调用扣 1)。 */ +const COST = 1 + +/** + * 无匹配规则时的放行决策(fail-open):没给某端点配规则即不限流, + * 而不是默默拒绝合法流量。规则缺失是配置问题,应在 review 时发现,不在运行时静默拒绝。 + * @returns {RateDecision} + */ +function unlimited() { + return { allowed: true, remaining: Number.POSITIVE_INFINITY, retryAfterMs: 0, ruleId: '' } +} + +/** + * 校验 query 的最小形态。limiter 是内部边界,仅做轻量自检(HTTP 入口的 + * Valibot 校验在 @app/server 路由层)。 + * @param {unknown} q + * @returns {{ endpoint: string, subject: string }} + */ +function requireQuery(q) { + if (!q || typeof q !== 'object') { + throw new RateLimitConfigError('query 必须是 { endpoint, subject } 对象') + } + const { endpoint, subject } = /** @type {Record} */ (q) + if (typeof endpoint !== 'string' || endpoint.length === 0) { + throw new RateLimitConfigError('query.endpoint 必须是非空字符串') + } + if (typeof subject !== 'string' || subject.length === 0) { + throw new RateLimitConfigError('query.subject 必须是非空字符串') + } + return { endpoint, subject } +} + +/** + * 限流器工厂。store 与规则由调用方注入;包内不读 env、不建连接。 + * @param {Object} cfg + * @param {{ take: Function, peek: Function }} cfg.store 计数后端(注入) + * @param {RateRule[]} cfg.rules 规则表 + * @param {() => number} [cfg.now] 时钟(注入,默认 Date.now) + * @returns {{ + * check(ctx: unknown, q: RateQuery): Promise, + * consume(ctx: unknown, q: RateQuery): Promise + * }} + * @throws {RateLimitConfigError} + */ +export function createRateLimiter(cfg) { + if (!cfg || typeof cfg !== 'object') { + throw new RateLimitConfigError('createRateLimiter 需要配置对象 { store, rules }') + } + const { store, rules, now } = cfg + if (!store || typeof store.take !== 'function' || typeof store.peek !== 'function') { + throw new RateLimitConfigError('store 缺失或未实现 take/peek(RateLimitStore)') + } + const indexed = indexRules(validateRules(rules)) + const clock = typeof now === 'function' ? now : Date.now + + return { + /** + * 只读判定,不扣令牌。ctx 仅作日志/trace 透传(本包不直接依赖 observability)。 + * @param {unknown} _ctx + * @param {RateQuery} q + * @returns {Promise} + */ + async check(_ctx, q) { + const { endpoint, subject } = requireQuery(q) + const rule = selectRule(indexed, endpoint) + if (!rule) return unlimited() + const key = bucketKey(rule, subject) + const r = await store.peek(key, rule, clock()) + return { + allowed: r.retryAfterMs === 0, + remaining: r.remaining, + retryAfterMs: r.retryAfterMs, + ruleId: ruleIdOf(rule), + } + }, + + /** + * 扣令牌并判定。超限返回 allowed:false + retryAfterMs,供路由层做 429 Retry-After。 + * @param {unknown} _ctx + * @param {RateQuery} q + * @returns {Promise} + */ + async consume(_ctx, q) { + const { endpoint, subject } = requireQuery(q) + const rule = selectRule(indexed, endpoint) + if (!rule) return unlimited() + const key = bucketKey(rule, subject) + const r = await store.take(key, COST, rule, clock()) + return { + allowed: r.allowed, + remaining: r.remaining, + retryAfterMs: r.retryAfterMs, + ruleId: ruleIdOf(rule), + } + }, + } +} diff --git a/packages/ratelimit/src/memory-store.js b/packages/ratelimit/src/memory-store.js new file mode 100644 index 0000000..b0a0e48 --- /dev/null +++ b/packages/ratelimit/src/memory-store.js @@ -0,0 +1,57 @@ +/** + * 内存 store:实现 `RateLimitStore`,单机 / 单测用。 + * + * 用 `Map` 持桶状态,所有桶数学复用 `token-bucket.js`——保证与 + * 未来 redis 实现行为一致。JS 单线程下「读-补-扣」在一次同步函数调用内完成、其间 + * 不交还事件循环,因此天然原子:并发的 take 顺序执行、不会放行超过 capacity。 + * redis 实现需用 Lua/单命令复刻这一原子性。 + * + * @typedef {import('./rules.js').RateRule} RateRule + * @typedef {import('./token-bucket.js').BucketState} BucketState + * + * RateLimitStore 接口(§3,本包对其唯一要求): + * { + * take(key, cost, rule, nowMs): Promise<{ allowed, remaining, retryAfterMs }>, + * peek(key, rule, nowMs): Promise<{ remaining, retryAfterMs }> + * } + */ + +import * as bucket from './token-bucket.js' + +/** + * 创建一个内存 store。 + * @returns {{ + * take(key: string, cost: number, rule: RateRule, nowMs: number): Promise<{ allowed: boolean, remaining: number, retryAfterMs: number }>, + * peek(key: string, rule: RateRule, nowMs: number): Promise<{ remaining: number, retryAfterMs: number }> + * }} + */ +export function createMemoryStore() { + /** @type {Map} */ + const buckets = new Map() + + /** + * @param {string} key + * @param {RateRule} rule + * @param {number} nowMs + * @returns {BucketState} + */ + function stateOf(key, rule, nowMs) { + return buckets.get(key) ?? bucket.initialState(rule, nowMs) + } + + return { + async take(key, cost, rule, nowMs) { + const state = stateOf(key, rule, nowMs) + const r = bucket.take(state, cost, rule, nowMs) + buckets.set(key, r.next) + return { allowed: r.allowed, remaining: r.remaining, retryAfterMs: r.retryAfterMs } + }, + + async peek(key, rule, nowMs) { + const state = stateOf(key, rule, nowMs) + // 只读:不写回,不消耗令牌。cost 固定为 1(判定「下一次单位调用是否会放行」)。 + const r = bucket.peek(state, 1, rule, nowMs) + return { remaining: r.remaining, retryAfterMs: r.retryAfterMs } + }, + } +} diff --git a/packages/ratelimit/src/rules.js b/packages/ratelimit/src/rules.js new file mode 100644 index 0000000..0720e2b --- /dev/null +++ b/packages/ratelimit/src/rules.js @@ -0,0 +1,97 @@ +/** + * 规则纯函数:校验规则表、按 (endpoint, by) 选中规则、生成稳定 ruleId。 + * + * 零 IO、不碰桶数学、不碰存储。规则形态见 §3: + * `{ endpoint, by, capacity, refillPerSec }`。一期 `by ∈ {'ip','anon'}`。 + * + * @typedef {Object} RateRule + * @property {string} endpoint 端点类别,如 'llm' | 'tts' | 'image' + * @property {'ip'|'anon'} by 一期主体维度 + * @property {number} capacity 桶容量(突发上限),> 0 + * @property {number} refillPerSec 每秒补充令牌数(稳态速率),>= 0 + */ + +import { RateLimitConfigError } from './errors.js' + +/** 一期合法的主体维度。二期扩 'user' 等只动这里与 key.js。 */ +export const ALLOWED_BY = ['ip', 'anon'] + +/** + * 生成规则的稳定标识,用于决策回传与日志定位。形如 `llm:ip`。 + * @param {{ endpoint: string, by: string }} rule + * @returns {string} + */ +export function ruleIdOf(rule) { + return `${rule.endpoint}:${rule.by}` +} + +/** + * 校验整张规则表,非法立即抛 `RateLimitConfigError`。 + * 校验项:非空数组、每条字段类型/取值合法(capacity>0、refillPerSec>=0、 + * by 在白名单、endpoint 非空字符串)、ruleId 不重复(同 endpoint+by 只能配一条)。 + * @param {unknown} rules + * @returns {RateRule[]} 原样返回(校验通过) + * @throws {RateLimitConfigError} + */ +export function validateRules(rules) { + if (!Array.isArray(rules) || rules.length === 0) { + throw new RateLimitConfigError('rules 必须是非空数组') + } + const seen = new Set() + for (const r of rules) { + if (!r || typeof r !== 'object') { + throw new RateLimitConfigError('rule 必须是对象') + } + if (typeof r.endpoint !== 'string' || r.endpoint.length === 0) { + throw new RateLimitConfigError('rule.endpoint 必须是非空字符串') + } + if (!ALLOWED_BY.includes(r.by)) { + throw new RateLimitConfigError( + `rule.by 非法: ${String(r.by)}(一期仅支持 ${ALLOWED_BY.join('/')})`, + ) + } + if (typeof r.capacity !== 'number' || !(r.capacity > 0) || !Number.isFinite(r.capacity)) { + throw new RateLimitConfigError(`rule.capacity 必须是正有限数: ${ruleIdOf(r)}`) + } + if ( + typeof r.refillPerSec !== 'number' || + r.refillPerSec < 0 || + !Number.isFinite(r.refillPerSec) + ) { + throw new RateLimitConfigError(`rule.refillPerSec 必须是非负有限数: ${ruleIdOf(r)}`) + } + const id = ruleIdOf(r) + if (seen.has(id)) { + throw new RateLimitConfigError(`规则重复: ${id}(同 endpoint+by 只能配一条)`) + } + seen.add(id) + } + return /** @type {RateRule[]} */ (rules) +} + +/** + * 把规则表编入按 ruleId 索引的 Map,供 limiter 选规则。 + * 假定 `rules` 已通过 `validateRules`。 + * @param {RateRule[]} rules + * @returns {Map} + */ +export function indexRules(rules) { + const m = new Map() + for (const r of rules) m.set(ruleIdOf(r), r) + return m +} + +/** + * 在已编入的规则集中选中匹配 `endpoint` 的规则。 + * 一期一个 endpoint 只配一条规则(其 by 维度即该端点的限流维度),故按 + * endpoint 唯一选中;找不到返回 null(调用方决定 fail-open 还是 fail-closed)。 + * @param {Map} indexed + * @param {string} endpoint + * @returns {RateRule | null} + */ +export function selectRule(indexed, endpoint) { + for (const rule of indexed.values()) { + if (rule.endpoint === endpoint) return rule + } + return null +} diff --git a/packages/ratelimit/src/token-bucket.js b/packages/ratelimit/src/token-bucket.js new file mode 100644 index 0000000..0d9eca9 --- /dev/null +++ b/packages/ratelimit/src/token-bucket.js @@ -0,0 +1,105 @@ +/** + * 令牌桶数学(纯函数、零 IO、单一事实来源)。 + * + * 内存 store 与未来的 redis store 都复用这里的公式,保证两种后端行为一致。 + * 算法变更只动本文件,两边自动同步。时间一律以毫秒整数(`nowMs`)注入, + * 不在本文件调用 `Date.now()`,保证测试可精确推进时间、结果确定。 + * + * 模型:桶容量 `capacity`(突发上限),稳态速率 `refillPerSec`(每秒补充令牌)。 + * 每次操作先按 `(nowMs - lastRefillMs)` 经过的时间补充令牌(不超过 capacity), + * 再尝试扣减 `cost`。补满靠突发吃掉容量,之后退回 refill 速率——这就是「平滑限速 + + * 允许小幅突发」。 + * + * @typedef {Object} BucketState + * @property {number} tokens 当前令牌余额(可为小数,补充按速率连续累积) + * @property {number} lastRefillMs 上次结算时间戳(毫秒) + */ + +/** + * 桶的初始状态:满桶。首次使用即拥有完整突发额度。 + * @param {{ capacity: number }} rule + * @param {number} nowMs + * @returns {BucketState} + */ +export function initialState(rule, nowMs) { + return { tokens: rule.capacity, lastRefillMs: nowMs } +} + +/** + * 按经过时间把状态补充到「截至 nowMs」,不扣令牌、不越过 capacity。 + * `refillPerSec <= 0` 视为不补充(固定桶,配额耗尽不恢复)。 + * `nowMs` 早于 `lastRefillMs`(时钟回拨)时不倒扣,按 0 经过处理。 + * @param {BucketState} state + * @param {{ capacity: number, refillPerSec: number }} rule + * @param {number} nowMs + * @returns {BucketState} + */ +export function refill(state, rule, nowMs) { + const elapsedMs = Math.max(0, nowMs - state.lastRefillMs) + const added = (elapsedMs / 1000) * rule.refillPerSec + const tokens = Math.min(rule.capacity, state.tokens + added) + return { tokens, lastRefillMs: nowMs } +} + +/** + * 还差 `deficit` 个令牌时,按 refill 速率需要等待的毫秒数(向上取整)。 + * `refillPerSec <= 0`(不补充)时永远等不到,返回 `Number.POSITIVE_INFINITY`。 + * @param {number} deficit 缺口(正数) + * @param {{ refillPerSec: number }} rule + * @returns {number} + */ +export function waitMsFor(deficit, rule) { + if (deficit <= 0) return 0 + if (rule.refillPerSec <= 0) return Number.POSITIVE_INFINITY + return Math.ceil((deficit / rule.refillPerSec) * 1000) +} + +/** + * 在「截至 nowMs」的状态上尝试扣 `cost` 个令牌。 + * 余额足够则扣减并放行;不足则不动余额、拒绝,并算出补够缺口所需等待。 + * 返回新状态与判定,store 负责把 `next` 写回。 + * @param {BucketState} state 任意时间点的状态(内部会先 refill 到 nowMs) + * @param {number} cost + * @param {{ capacity: number, refillPerSec: number }} rule + * @param {number} nowMs + * @returns {{ next: BucketState, allowed: boolean, remaining: number, retryAfterMs: number }} + */ +export function take(state, cost, rule, nowMs) { + const filled = refill(state, rule, nowMs) + if (filled.tokens >= cost) { + const next = { tokens: filled.tokens - cost, lastRefillMs: nowMs } + return { + next, + allowed: true, + remaining: Math.floor(next.tokens), + retryAfterMs: 0, + } + } + const deficit = cost - filled.tokens + return { + next: filled, + allowed: false, + remaining: Math.floor(filled.tokens), + retryAfterMs: waitMsFor(deficit, rule), + } +} + +/** + * 只读判定:把状态补充到 nowMs,看 `cost` 是否能放行,但不写回、不扣减。 + * 余额与等待时间按「截至 nowMs」计算。 + * @param {BucketState} state + * @param {number} cost + * @param {{ capacity: number, refillPerSec: number }} rule + * @param {number} nowMs + * @returns {{ allowed: boolean, remaining: number, retryAfterMs: number }} + */ +export function peek(state, cost, rule, nowMs) { + const filled = refill(state, rule, nowMs) + const allowed = filled.tokens >= cost + const deficit = allowed ? 0 : cost - filled.tokens + return { + allowed, + remaining: Math.floor(filled.tokens), + retryAfterMs: waitMsFor(deficit, rule), + } +} diff --git a/packages/ratelimit/test/key.test.js b/packages/ratelimit/test/key.test.js new file mode 100644 index 0000000..1a24d23 --- /dev/null +++ b/packages/ratelimit/test/key.test.js @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { bucketKey, KEY_NAMESPACE } from '../src/key.js' + +describe('key', () => { + it('带命名空间前缀(防与其它 redis key 撞)', () => { + const k = bucketKey({ endpoint: 'llm', by: 'ip' }, '1.2.3.4') + expect(k.startsWith(`${KEY_NAMESPACE}\x1f`)).toBe(true) + }) + + it('不同 endpoint / subject / by 不串桶', () => { + const a = bucketKey({ endpoint: 'llm', by: 'ip' }, '1.2.3.4') + const b = bucketKey({ endpoint: 'tts', by: 'ip' }, '1.2.3.4') + const c = bucketKey({ endpoint: 'llm', by: 'ip' }, '5.6.7.8') + const d = bucketKey({ endpoint: 'llm', by: 'anon' }, '1.2.3.4') + expect(new Set([a, b, c, d]).size).toBe(4) + }) + + it('subject 含分隔符样字符不与结构分隔混淆', () => { + const a = bucketKey({ endpoint: 'llm', by: 'ip' }, 'a:b') + const b = bucketKey({ endpoint: 'llm', by: 'ip' }, 'a') + expect(a).not.toBe(b) + }) +}) diff --git a/packages/ratelimit/test/memory-store.test.js b/packages/ratelimit/test/memory-store.test.js new file mode 100644 index 0000000..7f90088 --- /dev/null +++ b/packages/ratelimit/test/memory-store.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { createMemoryStore } from '../src/memory-store.js' + +const rule = { endpoint: 'llm', by: 'ip', capacity: 2, refillPerSec: 1 } + +describe('memory-store', () => { + it('take 在 capacity 内连续放行、超限拒绝', async () => { + const store = createMemoryStore() + const a = await store.take('k', 1, rule, 0) + const b = await store.take('k', 1, rule, 0) + const c = await store.take('k', 1, rule, 0) + expect(a.allowed).toBe(true) + expect(b.allowed).toBe(true) + expect(c.allowed).toBe(false) + expect(c.retryAfterMs).toBeGreaterThan(0) + }) + + it('时间推进后恢复放行', async () => { + const store = createMemoryStore() + await store.take('k', 1, rule, 0) + await store.take('k', 1, rule, 0) + expect((await store.take('k', 1, rule, 0)).allowed).toBe(false) + // 1s 后补 1 个 + expect((await store.take('k', 1, rule, 1000)).allowed).toBe(true) + }) + + it('peek 不消耗令牌', async () => { + const store = createMemoryStore() + await store.take('k', 1, rule, 0) // 剩 1 + const p1 = await store.peek('k', rule, 0) + const p2 = await store.peek('k', rule, 0) + expect(p1.remaining).toBe(1) + expect(p2.remaining).toBe(1) + // peek 后仍能再 take 1 次成功 + expect((await store.take('k', 1, rule, 0)).allowed).toBe(true) + }) + + it('并发顺序调用不超发(同步原子,不超过 capacity)', async () => { + const store = createMemoryStore() + const results = await Promise.all( + Array.from({ length: 5 }, () => store.take('k', 1, rule, 0)), + ) + const allowed = results.filter((r) => r.allowed).length + expect(allowed).toBe(2) // 恰好 capacity 个 + }) + + it('不同 key 互不串桶', async () => { + const store = createMemoryStore() + await store.take('a', 1, rule, 0) + await store.take('a', 1, rule, 0) + expect((await store.take('a', 1, rule, 0)).allowed).toBe(false) + expect((await store.take('b', 1, rule, 0)).allowed).toBe(true) + }) +}) diff --git a/packages/ratelimit/test/token-bucket.test.js b/packages/ratelimit/test/token-bucket.test.js new file mode 100644 index 0000000..83c76d1 --- /dev/null +++ b/packages/ratelimit/test/token-bucket.test.js @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest' +import { + initialState, + refill, + waitMsFor, + take, + peek, +} from '../src/token-bucket.js' + +const rule = { capacity: 3, refillPerSec: 1 } // 1 token/s, 桶容量 3 + +describe('token-bucket', () => { + it('初始状态是满桶', () => { + const s = initialState(rule, 1000) + expect(s.tokens).toBe(3) + expect(s.lastRefillMs).toBe(1000) + }) + + it('突发:满桶内连续扣减全放行,扣到空', () => { + let s = initialState(rule, 0) + for (let i = 0; i < 3; i++) { + const r = take(s, 1, rule, 0) + expect(r.allowed).toBe(true) + s = r.next + } + expect(s.tokens).toBe(0) + }) + + it('空桶不放行,retryAfterMs 准确(缺 1 个、速率 1/s → 1000ms)', () => { + const empty = { tokens: 0, lastRefillMs: 0 } + const r = take(empty, 1, rule, 0) + expect(r.allowed).toBe(false) + expect(r.remaining).toBe(0) + expect(r.retryAfterMs).toBe(1000) + expect(r.next.tokens).toBe(0) // 拒绝不扣 + }) + + it('按注入 nowMs 推进补充令牌', () => { + const empty = { tokens: 0, lastRefillMs: 0 } + // 2.5s 后应补 2.5 个令牌 + const filled = refill(empty, rule, 2500) + expect(filled.tokens).toBeCloseTo(2.5) + // 取 1 个应放行 + const r = take(empty, 1, rule, 2500) + expect(r.allowed).toBe(true) + }) + + it('补充不超过 capacity', () => { + const empty = { tokens: 0, lastRefillMs: 0 } + const filled = refill(empty, rule, 10_000) // 想补 10 个 + expect(filled.tokens).toBe(3) // 封顶 capacity + }) + + it('突发到 capacity 后稳态退回 refill 速率', () => { + let s = initialState(rule, 0) + // 突发吃光 3 个 + for (let i = 0; i < 3; i++) s = take(s, 1, rule, 0).next + // 此后每秒只补 1 个:t=1000 放行 1 个,t=1001 再取被拒 + const a = take(s, 1, rule, 1000) + expect(a.allowed).toBe(true) + const b = take(a.next, 1, rule, 1001) + expect(b.allowed).toBe(false) + expect(b.retryAfterMs).toBeGreaterThan(0) + }) + + it('refillPerSec=0:固定桶耗尽不恢复,等待为无穷', () => { + const fixed = { capacity: 2, refillPerSec: 0 } + const empty = { tokens: 0, lastRefillMs: 0 } + const r = take(empty, 1, fixed, 999_999) + expect(r.allowed).toBe(false) + expect(r.retryAfterMs).toBe(Number.POSITIVE_INFINITY) + }) + + it('时钟回拨不倒扣令牌', () => { + const s = { tokens: 2, lastRefillMs: 5000 } + const filled = refill(s, rule, 1000) // nowMs 早于 lastRefillMs + expect(filled.tokens).toBe(2) + }) + + it('peek 不修改余额(无 next 写回语义)', () => { + const s = { tokens: 1, lastRefillMs: 0 } + const r = peek(s, 1, rule, 0) + expect(r.allowed).toBe(true) + expect(r.remaining).toBe(1) + }) + + it('waitMsFor: deficit<=0 → 0; 速率>0 向上取整', () => { + expect(waitMsFor(0, rule)).toBe(0) + expect(waitMsFor(-1, rule)).toBe(0) + expect(waitMsFor(0.5, { refillPerSec: 1 })).toBe(500) + expect(waitMsFor(0.001, { refillPerSec: 1 })).toBe(1) // ceil + expect(waitMsFor(1, { refillPerSec: 0 })).toBe(Number.POSITIVE_INFINITY) + }) +}) diff --git a/packages/render-pixi/README.md b/packages/render-pixi/README.md new file mode 100644 index 0000000..fe2ef51 --- /dev/null +++ b/packages/render-pixi/README.md @@ -0,0 +1,32 @@ +# @drama/render-pixi + +`@drama/core` 定义的 `Renderer` 接口的 **PixiJS v8 后端**。core 每帧算出「每个角色每个 slot 的 2D 变换、相机状态、表情/朝向、特效指令」,本包把这些抽象落到具体的 Pixi 显示对象上。它是 core 与 Pixi 之间唯一的翻译层:core 那侧全是数字与字符串,本包这侧才出现 `PIXI.Sprite`。 + +任务文档:[`../../plan/tasks/drama-render-pixi.md`](../../plan/tasks/drama-render-pixi.md)。 + +## 公共面 + +```js +import { createPixiRenderer, createPixiRendererAuto } from '@drama/render-pixi' + +// 浏览器 / 已装 pixi.js:自动注入 PIXI(player-vue / studio 用) +const renderer = await createPixiRendererAuto(app, { rig }) + +// 显式注入 PIXI(含 headless 单测注入 stub) +import * as PIXI from 'pixi.js' +const renderer = createPixiRenderer(app, { rig, PIXI }) +``` + +返回对象满足 core `Renderer`(`applyActorPose`/`applyFace`/`applyFacing`/`applyCamera`/`setActorWorldPos`/`playEffect`/`stopEffect`/`resetActor`/`runTransition`),并挂出场/换场扩展方法 `addActor`/`removeActor`/`loadScene`/`updateEffects`。**接口方法签名零 Pixi 入参**——`PIXI` 仅在工厂处一次注入。 + +## 设计决策(实现二选一处写明) + +- **PIXI 注入而非顶层静态 import**:`index.js` 不在模块顶层 `import * as PIXI from 'pixi.js'`,而是把 PIXI 命名空间作为工厂构造期注入项(`createPixiRenderer` 要求 `opts.PIXI`;`createPixiRendererAuto` 自动 `await import('pixi.js')`)。这让无 GPU 的 headless 单测可注入 stub Sprite 跑纯映射用例,且 Pixi 仍不外溢到任何接口方法签名(满足 §5.2「Pixi 只在本包」+ §3「零 Pixi 入参」)。 +- **求值不重复(红线)**:本包不做骨骼世界矩阵求值、关键帧插值、镜头补间——那些全在 `@drama/core`。core 把世界矩阵 decompose 成 `SlotTransform`(`{x,y,rotation,scaleX,scaleY,zOrder?,mirror?}`),本包只把它写进 Sprite。facing/扭头镜像的反射轴计算属于「依赖具体贴图锚点的纯渲染几何」,按 §3 留在本包(`geom.js`)。 + +## 内部结构 + +`geom.js` 纯渲染几何(facing 翻转/扭头反射轴/纵深透视,接收数字返回写入值,最易测)· +`assets.js` 贴图加载缓存+容错 · `actor.js` 单角色 slot 装配+pose 落地+表情+道具+镜像 · +`stage.js` 世界/背景/相机写入/遮挡/特效层 · `occluder.js` 背景像素裁多边形+zIndex=baseY · +`effects.js` 屏幕特效帧轮播 · `renderer.js` core 接口实现外壳 · `index.js` 公共面。 diff --git a/packages/render-pixi/package.json b/packages/render-pixi/package.json new file mode 100644 index 0000000..9202d67 --- /dev/null +++ b/packages/render-pixi/package.json @@ -0,0 +1,17 @@ +{ + "name": "@drama/render-pixi", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@drama/core": "workspace:*", + "@drama/rig": "workspace:*", + "pixi.js": "^8.6.0" + } +} diff --git a/packages/render-pixi/src/actor.js b/packages/render-pixi/src/actor.js new file mode 100644 index 0000000..6d18c03 --- /dev/null +++ b/packages/render-pixi/src/actor.js @@ -0,0 +1,475 @@ +// actor.js — 单角色 Pixi 容器:slot Sprite 装配 + SlotTransform 落地 + 表情帧 + 道具跟手 + 镜像。 +// +// 移植自原型 character-actor.js 的「落地」部分,**去掉求值**——世界矩阵求值已在 @drama/core。 +// core 每帧给 applyActorPose 一个 `{ slotName: SlotTransform }`(已 decompose 的 2D 变换), +// 本类只把这些数字摆到 Sprite 上;facing / 扭头镜像反射轴这类纯渲染几何留在本包(geom.js)。 + +import { bodyFlipSign, headMirrorSign, resolveSlotWrite, depthZIndex } from './geom.js' + +const HEAD_SLOTS = new Set(['head', 'face']) + +/** + * @typedef {Object} PartEntry 一个 slot 的装配条目。 + * @property {*} obj Pixi Sprite + * @property {string} bone 挂载骨 + * @property {*} cfg SlotSkin 配置 + * @property {number} zDefault 默认层级(无 SlotTransform.zOrder 时回落) + * @property {Record=} faceFrames face 部件预载帧(url->纹理) + * @property {number=} poseScaleX 最近一次骨骼姿势 + face 基础 scale 后的 x 缩放 + * @property {number=} poseScaleY 最近一次骨骼姿势 + face 基础 scale 后的 y 缩放 + */ +/** + * @typedef {Object} HeldProp 一件手持道具。 + * @property {*} spr + * @property {{bone:string,x?:number,y?:number,rotation?:number,zOrder?:number,url?:string,scale?:number}} cfg + * @property {number} scale + * @property {(import('@drama/core').SlotTransform|null)} attachTransform + */ + +export class PixiActor { + /** + * @param {Object} deps + * @param {*} deps.PIXI Pixi 命名空间 + * @param {import('@drama/rig').RigDefinition} deps.rig + * @param {import('./index.js').CharacterSkin} deps.skin + * @param {ReturnType} deps.assets + * @param {{x:number,y:number,facing:'left'|'right',scale:number}} deps.placement + * @param {((y:number)=>number)|null=} deps.depthScale 透视缩放 fn(worldY)->倍数;null=无透视 + */ + constructor({ PIXI, rig, skin, assets, placement, depthScale = null }) { + this.PIXI = PIXI + this.rig = rig + /** @type {import('./index.js').CharacterSkin} */ + this.skin = skin + this.assets = assets + /** @type {((y:number)=>number)|null} */ + this.depthScale = depthScale + /** @type {('left'|'right'|null)} */ + this.headFacing = null + + this.worldX = placement.x + this.worldY = placement.y + this.facing = placement.facing || 'right' + this.baseScale = placement.scale != null ? placement.scale : 1 + + this.emotion = 'normal' + this.talking = false + this.faceClock = 0 + /** @type {({fromX:number,fromY:number,toX:number,toY:number,dur:number,t:number,easing:string}|null)} 位移补间(走位 lerp,移植自原型 character-actor.moveTo/update) */ + this.moveTween = null + /** @type {Map} 上一帧 head/face 反射轴用 */ + this._lastPose = new Map() + + // 根容器:定位到世界坐标 + facing 翻转。 + this.root = new PIXI.Container() + this.root.position.set(this.worldX, this.worldY) + + // 部件层:全局 z-order(slot.zOrder / SlotSkin.zOrder)。 + this.layer = new PIXI.Container() + this.layer.sortableChildren = true + this.root.addChild(this.layer) + + /** @type {Map} slotName -> entry */ + this.parts = new Map() + /** @type {HeldProp[]} 手持道具 */ + this.heldProps = [] + + this._buildParts() + this._applyRootScale() + } + + /** 有效缩放 = 基础缩放 × 透视缩放(随世界 y)。 */ + effScale() { + const persp = this.depthScale ? this.depthScale(this.worldY) : 1 + return this.baseScale * persp + } + + _applyRootScale() { + const s = this.effScale() + // 贴图基准朝向=屏幕左:right 才水平翻转(geom.bodyFlipSign)。 + this.root.scale.set(s * bodyFlipSign(this.facing), s) + // 纵深遮挡:越靠下(worldY 越大=越近)越靠前。 + this.root.zIndex = depthZIndex(this.worldY) + } + + _buildParts() { + const skin = /** @type {{parts?:Record}|null} */ ( + this.skin + ) + /** @type {Record} */ + const parts = (skin && skin.parts) || {} + const builtSlots = new Set() + for (const slot of this.rig.slots) { + const sp = parts[slot.name] + if (!sp) continue // 真实贴皮:无贴图的 slot 跳过,不画占位 + builtSlots.add(slot.name) + const spr = new this.PIXI.Sprite() + const a = sp.anchor || { x: 0.5, y: 0.5 } + if (spr.anchor && typeof spr.anchor.set === 'function') spr.anchor.set(a.x, a.y) + const z = sp.zOrder != null ? sp.zOrder : slot.zOrder + spr.zIndex = z + this.layer.addChild(spr) + /** @type {PartEntry} */ + const entry = { obj: spr, bone: slot.bone, cfg: sp, zDefault: z } + if (sp.emotions) { + // 表情部件:预载所有情绪的静止 / 说话帧,update 里按说话状态轮播。 + /** @type {Record} */ + const faceFrames = {} + entry.faceFrames = faceFrames + /** @type {Set} */ + const urls = new Set() + for (const em of Object.values(sp.emotions)) { + if (em.static) urls.add(em.static) + if (em.talk) urls.add(em.talk) + } + for (const u of urls) { + this.assets.load(u).then((t) => { + if (t) faceFrames[u] = t + }) + } + // 初始表情帧:出场即显示 normal(或首个情绪)的静止帧,否则在被某行驱动 emotion 之前 + // face 一直无贴图 = 空白脸(实测漏点)。 + const defEmo = sp.emotions.normal || Object.values(sp.emotions)[0] + const initUrl = defEmo && defEmo.static + if (initUrl) { + const EMPTY = this.PIXI.Texture && this.PIXI.Texture.EMPTY + this.assets.load(initUrl).then((t) => { + // 仅当尚未被 applyFace 驱动(贴图仍是空纹理)时设初始帧,避免覆盖已驱动的情绪。 + if (t && (!EMPTY || spr.texture === EMPTY)) spr.texture = t + }) + } + } else if (sp.url) { + this.assets.load(sp.url).then((tex) => { + if (tex) spr.texture = tex + }) + } + this.parts.set(slot.name, entry) + } + + // 额外装饰部件:旧版 stage-test 的“区域分层”会产生 hair_back / skirt_back 这类 + // 不在 rig.slots 里的 part。它们必须声明 `bone`,跟随指定骨骼,但不参与骨架求值。 + for (const [name, sp] of Object.entries(parts)) { + if (builtSlots.has(name) || !sp || !sp.bone || !sp.url) continue + const spr = new this.PIXI.Sprite() + const a = sp.anchor || { x: 0.5, y: 0.5 } + if (spr.anchor && typeof spr.anchor.set === 'function') spr.anchor.set(a.x, a.y) + const z = sp.zOrder != null ? sp.zOrder : 10 + spr.zIndex = z + this.layer.addChild(spr) + /** @type {PartEntry} */ + const entry = { obj: spr, bone: sp.bone, cfg: sp, zDefault: z, extra: true } + this.assets.load(sp.url).then((tex) => { + if (tex) spr.texture = tex + }) + this.parts.set(name, entry) + } + } + + // ───────────────────────── Renderer 接口落地 ───────────────────────── + + /** + * 把每个 slot 的最终 2D 变换(core 已 decompose)落到对应 Sprite。 + * @param {Record} slotTransforms + */ + applyActorPose(slotTransforms) { + const headMirror = headMirrorSign(this.facing, this.headFacing) + // 反射轴 = 头骨节点 x(头贴图绕它镜像;脸偏移也绕它镜像才不错位)。 + const headT = slotTransforms.head + const headBoneX = headMirror === -1 && headT ? headT.x : 0 + + for (const [slotName, t] of Object.entries(slotTransforms)) { + const p = this.parts.get(slotName) + if (!p) continue + this._lastPose.set(slotName, t) + const isHeadSlot = HEAD_SLOTS.has(slotName) + const w = resolveSlotWrite(t, { isHeadSlot, headMirror, headBoneX }) + p.obj.position.set(w.x, w.y) + // 叠加 skin 的每部件静态对齐:rotation 偏置 + 贴图缩放(cfg.scale,如头 0.55)+ 镜像 + // (cfg.mirror:右肢复用左图水平翻转)。缺了 cfg.scale 会导致部件按骨骼原尺寸(≈1)渲染, + // 头/躯干显著过大——这是 worker/lady 头巨大的根因。 + p.obj.rotation = w.rotation + ((p.cfg.rotation || 0) * Math.PI) / 180 + const ps = p.cfg.scale != null ? p.cfg.scale : 1 + const mir = p.cfg.mirror ? -1 : 1 + p.poseScaleX = w.scaleX * ps * mir + p.poseScaleY = w.scaleY * ps + p.obj.scale.set(p.poseScaleX, p.poseScaleY) + if (slotName === 'face') this._applyFaceScale(p, this._currentFaceDef(p)) + // zOrder 覆盖部件层级(SlotTransform.zOrder > skin zDefault)。 + if (t.zOrder != null) p.obj.zIndex = t.zOrder + else p.obj.zIndex = p.zDefault + } + + // 额外装饰部件跟随其 cfg.bone 指向的标准骨骼/slot,bonePos 是相对骨骼偏移。 + // 这些 part 没有自己的 slotTransforms 键,所以不能走上面的标准 slot 循环。 + for (const [partName, p] of this.parts) { + if (!p.extra) continue + const t = slotTransforms[p.bone] + if (!t) continue + const bp = p.cfg.bonePos || { x: 0, y: 0 } + p.obj.position.set(t.x + (bp.x || 0), t.y + (bp.y || 0)) + p.obj.rotation = t.rotation + ((p.cfg.rotation || 0) * Math.PI) / 180 + const ps = p.cfg.scale != null ? p.cfg.scale : 1 + const mir = p.cfg.mirror ? -1 : 1 + p.obj.scale.set((t.scaleX || 1) * ps * mir, (t.scaleY || 1) * ps) + p.obj.zIndex = p.cfg.zOrder != null ? p.cfg.zOrder : p.zDefault + this._lastPose.set(partName, { ...t, x: t.x + (bp.x || 0), y: t.y + (bp.y || 0) }) + } + + // 手持道具:跟着附着骨骼走(位置 / 旋转 / 缩放 / 层级由 cfg 决定)。 + for (const h of this.heldProps) { + const bt = slotTransforms[h.cfg.bone] || h.attachTransform + if (!bt) continue + h.spr.position.set(bt.x + (h.cfg.x || 0), bt.y + (h.cfg.y || 0)) + h.spr.rotation = bt.rotation + (h.cfg.rotation || 0) * (Math.PI / 180) + h.spr.scale.set((h.scale || 1) * (bt.scaleX || 1)) + h.spr.zIndex = h.cfg.zOrder != null ? h.cfg.zOrder : 33 + } + } + + /** + * 切表情帧;talking=true 在静止 / 张嘴两帧按 talkFps 轮播口型,false 回静止帧。 + * @param {string} emotion + * @param {boolean} talking + * @param {number=} dtMs 推进口型时钟的帧时长(轮播用);不传则只切静止帧 + */ + applyFace(emotion, talking, dtMs = 0) { + if (emotion) this.emotion = emotion + this.talking = !!talking + const face = this.parts.get('face') + if (!face || !face.faceFrames) return + const emo = face.cfg.emotions || {} + const em = emo[this.emotion] || emo.normal || Object.values(emo)[0] + if (!em) return + let url = em.static + if (this.talking && em.talk) { + this.faceClock += dtMs + const fps = face.cfg.talkFps || 7 + url = Math.floor(this.faceClock / (1000 / fps)) % 2 ? em.talk : em.static + } else { + this.faceClock = 0 + } + const tex = url && face.faceFrames[url] + if (tex) face.obj.texture = tex + this._applyFaceScale(face, em) + } + + /** + * 每帧推进口型(仅在 talking 时轮播静止/张嘴帧,移植自原型 character-actor.js:261-276)。 + * 由 renderer.updateFaces → usePlayback.update 每帧调用,使 setTalking 后口型真按 talkFps 动。 + * @param {number} dtMs + */ + tickFace(dtMs) { + if (!this.talking) return + const face = this.parts.get('face') + if (!face || !face.faceFrames) return + const emo = face.cfg.emotions || {} + const em = emo[this.emotion] || emo.normal || Object.values(emo)[0] + if (!em || !em.talk) return + this.faceClock += dtMs + const fps = face.cfg.talkFps || 7 + const url = Math.floor(this.faceClock / (1000 / fps)) % 2 ? em.talk : em.static + const tex = url && face.faceFrames[url] + if (tex) face.obj.texture = tex + this._applyFaceScale(face, em) + } + + /** + * 当前表情定义:先取指定 emotion,再降级 normal/首个。 + * @param {PartEntry} face + */ + _currentFaceDef(face) { + const emo = face.cfg.emotions || {} + return emo[this.emotion] || emo.normal || Object.values(emo)[0] || null + } + + /** + * 表情级缩放:最终脸部缩放 = face 部件 scale × emotion.scale;mirror 只改变 x 符号,不累乘。 + * @param {PartEntry} face + * @param {*=} em + */ + _applyFaceScale(face, em) { + if (!face || !face.obj || !face.obj.scale) return + const baseX = face.poseScaleX != null ? face.poseScaleX : face.obj.scale.x + const baseY = face.poseScaleY != null ? face.poseScaleY : face.obj.scale.y + const emotionScale = em && typeof em.scale === 'number' && em.scale > 0 ? em.scale : 1 + const emotionMirror = em && em.mirror ? -1 : 1 + face.obj.scale.set(baseX * emotionScale * emotionMirror, baseY * emotionScale) + } + + /** + * 身体水平翻转(贴图基准朝向约定)+ 头部独立扭头。 + * 镜像在下一帧 applyActorPose 里随 facing/headFacing 生效(反射轴计算); + * 身体翻转即时写根容器 scale。 + * facing/headFacing 互不耦合(移植自 OLD setFacing 只改 body / setHeadFacing 只改头): + * facing=undefined → 不动身体;headFacing=undefined → 不动头(保留当前扭头)。 + * headFacing=null(显式)→ 头回到跟随身体。 + * @param {('left'|'right'|undefined)} facing + * @param {('left'|'right'|null|undefined)} headFacing + */ + applyFacing(facing, headFacing) { + if (facing && facing !== this.facing) { + this.facing = facing + this._applyRootScale() + } + if (headFacing !== undefined) this.headFacing = headFacing || null + // 立即对已有 head/face 姿势重算反射(不必等下一帧 pose)。 + const headT = this._lastPose.get('head') + const faceT = this._lastPose.get('face') + const headMirror = headMirrorSign(this.facing, this.headFacing) + const headBoneX = headMirror === -1 && headT ? headT.x : 0 + for (const slotName of HEAD_SLOTS) { + const t = this._lastPose.get(slotName) + const p = this.parts.get(slotName) + if (!t || !p) continue + const w = resolveSlotWrite(t, { isHeadSlot: true, headMirror, headBoneX }) + p.obj.position.set(w.x, w.y) + const ps = p.cfg.scale != null ? p.cfg.scale : 1 + const mir = p.cfg.mirror ? -1 : 1 + p.poseScaleX = w.scaleX * ps * mir + p.poseScaleY = w.scaleY * ps + p.obj.scale.set(p.poseScaleX, p.poseScaleY) + if (slotName === 'face') this._applyFaceScale(p, this._currentFaceDef(p)) + } + void faceT + } + + /** + * 手持道具:把 propDef 的贴图挂到指定握法(holds[hold])的骨骼上,随该骨骼世界变换走 + * (applyActorPose 的 heldProps 循环落地)。移植自原型 character-actor.holdProp。 + * @param {{url?:string,scale?:number,anchor?:{x:number,y:number},holds?:Record}} propDef + * @param {string=} hold 握法名(carry/raise…),缺省取第一个 + */ + holdProp(propDef, hold) { + if (!propDef) return + this.releaseProp() + const holds = propDef.holds || {} + const h = holds[hold || ''] || holds.carry || Object.values(holds)[0] || {} + const spr = new this.PIXI.Sprite() + const a = propDef.anchor || { x: 0.5, y: 0.5 } + if (spr.anchor && typeof spr.anchor.set === 'function') spr.anchor.set(a.x, a.y) + spr.zIndex = h.zOrder != null ? h.zOrder : 33 + this.layer.addChild(spr) + if (propDef.url) { + this.assets.load(propDef.url).then((t) => { + if (t) spr.texture = t + }) + } + this.heldProps.push({ + spr, + cfg: { bone: h.bone || 'lower_arm_l', x: h.x || 0, y: h.y || 0, rotation: h.rotation || 0, zOrder: h.zOrder }, + scale: propDef.scale != null ? propDef.scale : 1, + attachTransform: null, + }) + } + + /** 松开/移除所有手持道具。 */ + releaseProp() { + for (const hp of this.heldProps) { + if (hp.spr && hp.spr.parent) hp.spr.parent.removeChild(hp.spr) + if (hp.spr && typeof hp.spr.destroy === 'function') hp.spr.destroy() + } + this.heldProps = [] + } + + /** 设置世界位置:透视缩放与遮挡 zIndex 随 y 更新(核对原型 setWorldPos)。 */ + /** @param {number} x @param {number=} y */ + setWorldPos(x, y = this.worldY) { + this.worldX = x + this.worldY = y + this.root.position.set(x, y) + this._applyRootScale() + } + + /** + * 平滑移动到世界点 (x,y)。移植自原型 character-actor.js:186-189 `moveTo`。 + * dur<=0 直接置终点;否则建补间,每帧由 updateMove(dt) 推进 lerp。 + * @param {number} x @param {number=} y @param {number=} duration @param {string=} easing 'linear'|'easeOut'|'easeInOut' + */ + moveTo(x, y = this.worldY, duration = 0, easing = 'linear') { + if (duration <= 0) { + this.setWorldPos(x, y) + this.moveTween = null + return + } + this.moveTween = { fromX: this.worldX, fromY: this.worldY, toX: x, toY: y, dur: duration, t: 0, easing } + } + + /** + * 每帧推进位移补间(移植自原型 character-actor.js:210-220 的 update 头部)。 + * 走路默认匀速(linear);easeOut=缓出停;easeInOut=两头慢。回写 worldX/worldY。 + * @param {number} dtMs + */ + updateMove(dtMs) { + if (!this.moveTween) return + const m = this.moveTween + m.t += dtMs + const f = Math.min(m.t / m.dur, 1) + const e = + m.easing === 'easeOut' + ? 1 - Math.pow(1 - f, 3) + : m.easing === 'easeInOut' + ? f < 0.5 + ? 2 * f * f + : 1 - Math.pow(-2 * f + 2, 2) / 2 + : f + this.setWorldPos(m.fromX + (m.toX - m.fromX) * e, m.fromY + (m.toY - m.fromY) * e) + if (f >= 1) this.moveTween = null + } + + /** + * 相机跟随焦点(上半身高度,随透视缩放)。移植自原型 character-actor.js:198-200。 + * @returns {{x:number,y:number}} + */ + getFocusPoint() { + return { x: this.worldX, y: this.worldY - 360 * this.effScale() } + } + + /** + * 附着一件道具到指定 bone slot(道具跟手)。 + * @param {{bone:string,x?:number,y?:number,rotation?:number,zOrder?:number,url?:string,scale?:number}} cfg + */ + attachProp(cfg) { + const spr = new this.PIXI.Sprite() + if (spr.anchor && typeof spr.anchor.set === 'function') spr.anchor.set(0.5, 0.5) + spr.zIndex = cfg.zOrder != null ? cfg.zOrder : 33 + this.layer.addChild(spr) + if (cfg.url) { + this.assets.load(cfg.url).then((t) => { + if (t) spr.texture = t + }) + } + /** @type {HeldProp} */ + const held = { spr, cfg, scale: cfg.scale != null ? cfg.scale : 1, attachTransform: null } + this.heldProps.push(held) + return held + } + + /** 松开所有道具。 */ + releaseProps() { + for (const h of this.heldProps) { + try { + h.spr.destroy() + } catch (e) { + void e + } + } + this.heldProps = [] + } + + /** 复位:表情归位、停说话、松道具。动画归位(reset)由 core 的 animState 负责。 */ + reset() { + this.emotion = 'normal' + this.talking = false + this.faceClock = 0 + this.moveTween = null + this.releaseProps() + } + + destroy() { + try { + this.root.destroy({ children: true }) + } catch (e) { + void e + } + } +} diff --git a/packages/render-pixi/src/assets.js b/packages/render-pixi/src/assets.js new file mode 100644 index 0000000..8e3adb0 --- /dev/null +++ b/packages/render-pixi/src/assets.js @@ -0,0 +1,81 @@ +// assets.js — 贴图 / 背景 / 特效帧的加载与缓存。 +// +// 默认走 PIXI.Assets.load;可由工厂注入 `loadTexture(url)` 覆盖(测试 / 自定义管线)。 +// 容错(render-pixi §2 验收 10、§5.5):加载失败走 warn + 跳过,绝不抛断播放。 + +/** + * 创建贴图加载器。 + * @param {Object} [deps] + * @param {*=} deps.PIXI Pixi 命名空间(含 Assets) + * @param {((url:string)=>Promise<*>)=} deps.loadTexture 自定义加载器(默认 PIXI.Assets.load) + * @returns {{ load:(url:string)=>Promise<*|null>, get:(url:string)=>(*|null) }} + */ +export function createAssetLoader({ PIXI, loadTexture } = {}) { + /** @type {Map} 已就绪纹理缓存 */ + const cache = new Map() + /** @type {Map>} 进行中去重 */ + const inflight = new Map() + + // 回退加载:用 HTMLImageElement(crossOrigin) 取图再建纹理。PIXI.Assets.load 的 fetch 路径对 + // 某些跨域大图会 "Failed to fetch"(浏览器对大文件 fetch 的脆弱性/缓存污染),而 加载不受影响。 + // 资源公共可读 + CORS 放行 → crossOrigin='anonymous' 的纹理不污染画布。 + const loadViaImage = (/** @type {string} */ url) => + new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => { + try { resolve(PIXI.Texture.from(img)) } catch (e) { reject(e) } + } + img.onerror = () => reject(new Error(`image load failed: ${url}`)) + // 回退仅在主加载已失败时触发:用一个区分性 query 另起缓存条目,绕开可能被 no-cors 不透明响应 + // 污染的原 URL 缓存(cors 请求命中不透明缓存会失败)。该 URL 自身仍可缓存。 + img.src = url + (url.includes('?') ? '&' : '?') + '_pf=1' + }) + + const loader = + loadTexture || + (/** @param {string} url */ (url) => { + const hasAssets = PIXI && PIXI.Assets && typeof PIXI.Assets.load === 'function' + const canImage = + PIXI && PIXI.Texture && typeof PIXI.Texture.from === 'function' && typeof Image !== 'undefined' + // 优先 Assets.load(带缓存/批处理);其 fetch 路径失败时回退 加载,保证大背景也能上屏。 + if (hasAssets) { + const p = PIXI.Assets.load(url) + return canImage ? p.catch(() => loadViaImage(url)) : p + } + if (canImage) return loadViaImage(url) + return Promise.reject(new Error('PIXI loader unavailable')) + }) + + /** + * 加载并缓存一张纹理。失败返回 null(warn),不抛。 + * @param {string} url + * @returns {Promise<*|null>} + */ + function load(url) { + if (!url) return Promise.resolve(null) + if (cache.has(url)) return Promise.resolve(cache.get(url)) + if (inflight.has(url)) return /** @type {Promise<*|null>} */ (inflight.get(url)) + const p = Promise.resolve() + .then(() => loader(url)) + .then((tex) => { + cache.set(url, tex) + inflight.delete(url) + return tex + }) + .catch((e) => { + inflight.delete(url) + console.warn('[render-pixi] texture load fail', url, e && e.message) + return null + }) + inflight.set(url, p) + return p + } + + /** 同步取已缓存纹理(未就绪返回 null)。 @param {string} url */ + function get(url) { + return cache.has(url) ? cache.get(url) : null + } + + return { load, get } +} diff --git a/packages/render-pixi/src/effects.js b/packages/render-pixi/src/effects.js new file mode 100644 index 0000000..9e14a56 --- /dev/null +++ b/packages/render-pixi/src/effects.js @@ -0,0 +1,104 @@ +// effects.js — 屏幕特效播放 / 停止(帧序列轮播)。 +// +// 特效层浮在世界之上、不进 worldContainer(与 HUD 同理,不受相机 pan/zoom)。 +// 加一种特效 = 在调用方提供帧 url 序列即可,无需改接口(render-pixi §6 可扩展性)。 + +/** + * @typedef {Object} EffectDef + * @property {string[]} frames + * @property {number=} fps + * @property {boolean=} loop + */ +/** + * @typedef {Object} EffectEntry + * @property {*} spr + * @property {string[]} frames + * @property {number} fps + * @property {boolean} loop + * @property {number} clock + */ + +/** + * 创建特效播放器。 + * @param {Object} [deps] + * @param {*=} deps.PIXI + * @param {*=} deps.layer 特效层容器(建议挂在 app.stage 顶层,非 worldContainer) + * @param {ReturnType=} deps.assets + * @param {((name:string)=>(EffectDef|null))=} deps.resolve + * 把特效名解析成帧序列定义(由场景 / bundle 注入;缺省返回 null 跳过) + * @returns {{ play:(name:string,opts?:Partial)=>void, stop:(name:string)=>void, update:(dtMs:number)=>void, stopAll:()=>void }} + */ +export function createEffects({ PIXI, layer, assets, resolve } = {}) { + /** @type {Map} */ + const active = new Map() + + /** @param {string} name @param {Partial} [opts] */ + function play(name, opts = {}) { + if (!name) return + /** @type {EffectDef|null} */ + const def = + (resolve ? resolve(name) : null) || + (opts.frames ? /** @type {EffectDef} */ (opts) : null) + if (!def || !def.frames || !def.frames.length) { + console.warn('[render-pixi] effect has no frames, skip', name) + return + } + stop(name) + const spr = new PIXI.Sprite() + if (layer) layer.addChild(spr) + /** @type {EffectEntry} */ + const entry = { + spr, + frames: def.frames, + fps: def.fps || opts.fps || 12, + loop: opts.loop != null ? opts.loop : def.loop != null ? def.loop : false, + clock: 0, + } + for (const u of def.frames) if (assets) assets.load(u) + active.set(name, entry) + _renderFrame(entry, 0) + } + + /** @param {EffectEntry} entry @param {number} idx */ + function _renderFrame(entry, idx) { + const url = entry.frames[idx] + const tex = url && assets ? assets.get(url) : null + if (tex) entry.spr.texture = tex + } + + /** @param {number} dtMs */ + function update(dtMs) { + for (const [name, e] of active) { + e.clock += dtMs + const total = e.frames.length + const raw = Math.floor(e.clock / (1000 / e.fps)) + let idx = raw + if (raw >= total) { + if (e.loop) idx = raw % total + else { + stop(name) + continue + } + } + _renderFrame(e, idx) + } + } + + /** @param {string} name */ + function stop(name) { + const e = active.get(name) + if (!e) return + try { + e.spr.destroy() + } catch (err) { + void err + } + active.delete(name) + } + + function stopAll() { + for (const name of [...active.keys()]) stop(name) + } + + return { play, stop, update, stopAll } +} diff --git a/packages/render-pixi/src/geom.js b/packages/render-pixi/src/geom.js new file mode 100644 index 0000000..335c5c7 --- /dev/null +++ b/packages/render-pixi/src/geom.js @@ -0,0 +1,94 @@ +// geom.js — 纯渲染几何(接收数字、返回写入值,零 Pixi、零求值)。 +// +// 这里只放「把 core 已算好的 SlotTransform / facing / worldY 翻译成 Sprite 写入值」时 +// 需要的纯几何换算:facing 翻转符号、头部扭头反射轴镜像、纵深透视缩放与 zIndex。 +// +// 红线:本文件**绝不**出现矩阵相乘 / 关键帧采样 / 镜头补间——那些是 @drama/core 的活 +// (render-pixi §5.1)。core 已把世界矩阵 decompose 成 SlotTransform,这里只摆放。 +// 镜像反射轴这类「依赖具体贴图锚点的纯渲染几何」按 render-pixi §3 留在本包。 + +const DEG = Math.PI / 180 + +/** + * 贴图基准朝向约定(移植自原型 character-actor.js `_applyRootScale`): + * 本套角色贴图基准朝向 = 屏幕左;故 facing='left' 不翻转,facing='right' 才水平翻转。 + * 返回根容器水平缩放符号(±1),调用方乘到 `root.scale.x` 上。 + * @param {'left'|'right'} facing + * @returns {number} -1(翻转)或 1(不翻转) + */ +export function bodyFlipSign(facing) { + return facing === 'right' ? -1 : 1 +} + +/** + * 头部独立扭头镜像符号(移植自原型 `update` 的 headMirror): + * headFacing 与 facing 不同 → 头 + 脸水平翻转(看向另一边),返回 -1;否则 1。 + * @param {'left'|'right'} facing + * @param {('left'|'right'|null)=} headFacing + * @returns {number} -1 或 1 + */ +export function headMirrorSign(facing, headFacing) { + return headFacing && headFacing !== facing ? -1 : 1 +} + +/** + * 头/脸部件在扭头镜像下的位置反射(移植自原型 `px = 2*headBoneX - d.x`): + * 翻转的反射轴 = 头骨节点 x;脸的偏移也要绕它镜像才不错位。 + * 仅当 headMirror===-1 时反射;否则原样返回。 + * @param {number} x 部件原始 x(来自 SlotTransform) + * @param {number} headBoneX 头骨世界 x(反射轴) + * @param {number} headMirror -1 或 1 + * @returns {number} + */ +export function reflectHeadX(x, headBoneX, headMirror) { + return headMirror === -1 ? 2 * headBoneX - x : x +} + +/** + * 把一个 SlotTransform 解出最终要写进 Sprite 的属性值(纯数据→纯数据)。 + * mirror=true 触发水平翻转(scale.x *= -1);可选叠加头部扭头镜像(slot 为 head/face 时)。 + * @param {import('@drama/core').SlotTransform} t + * @param {object} [mods] + * @param {number=} mods.headMirror -1/1,仅对 head/face slot 传入 + * @param {number=} mods.headBoneX 反射轴 x,仅 headMirror=-1 时用 + * @param {boolean=} mods.isHeadSlot 该 slot 是否参与扭头镜像(head/face) + * @returns {{x:number,y:number,rotation:number,scaleX:number,scaleY:number}} + */ +export function resolveSlotWrite(t, mods = {}) { + const headMirror = mods.isHeadSlot ? (mods.headMirror || 1) : 1 + let scaleX = t.scaleX + if (t.mirror) scaleX = -scaleX + scaleX *= headMirror + let x = t.x + if (mods.isHeadSlot && headMirror === -1) { + x = reflectHeadX(t.x, mods.headBoneX || 0, headMirror) + } + return { x, y: t.y, rotation: t.rotation, scaleX, scaleY: t.scaleY } +} + +/** + * 纵深透视缩放(移植自原型 `effScale` 的 depthScale 部分): + * 线性插值:y=nearY(近/下)→ nearScale,y=farY(远/上)→ farScale,区间外夹紧。 + * 返回缩放倍数(相对 baseScale 的乘数)。无 depth 配置时调用方应跳过(恒 1)。 + * @param {number} worldY + * @param {{nearY:number,farY:number,nearScale:number,farScale:number}} depth + * @returns {number} + */ +export function perspectiveScale(worldY, depth) { + const { nearY, farY, nearScale, farScale } = depth + const t = (worldY - farY) / (nearY - farY || 1) + const clamped = Math.max(0, Math.min(1, t)) + return farScale + (nearScale - farScale) * clamped +} + +/** + * 纵深遮挡排序键(移植自原型 `zIndex = Math.round(worldY)`): + * 越靠下(worldY 越大=越近)越靠前。遮挡片用 baseY 同尺度,人物 worldY 小于 baseY 被遮。 + * @param {number} worldY + * @returns {number} + */ +export function depthZIndex(worldY) { + return Math.round(worldY) +} + +export { DEG } diff --git a/packages/render-pixi/src/index.js b/packages/render-pixi/src/index.js new file mode 100644 index 0000000..555e053 --- /dev/null +++ b/packages/render-pixi/src/index.js @@ -0,0 +1,79 @@ +// @drama/render-pixi —— Renderer 接口的 PixiJS v8 后端,公共面。 +// +// 唯一公共入口(架构「禁止深导入」红线)。只导出 createPixiRenderer + CharacterSkin/SlotSkin +// typedef(render-pixi §3 / §5.6 公共面最小化)。 +// +// Pixi 只在本包出现(render-pixi §5.2)。本包对外暴露的方法签名零 Pixi 入参:core 调 +// applyActorPose/applyCamera 等时只传 SlotTransform / CameraRenderState / string / number。 +// PIXI 命名空间在工厂处一次性解析(不外溢到接口): +// - 若 `opts.PIXI` 给了就用它(测试注入 stub、或调用方已持有的命名空间); +// - 否则动态 import 'pixi.js'。动态而非顶层静态 import,是为了让无 GPU 的 headless +// 单测(注入 stub Sprite)无需安装 / 加载真实 pixi.js 即可跑 §8 的纯映射用例。 + +import { createPixiRenderer as _createPixiRenderer } from './renderer.js' + +/** + * @typedef {{ + * rig: import('@drama/rig').RigDefinition, + * PIXI?: *, + * loadTexture?: (url:string)=>Promise<*>, + * resolveEffect?: (name:string)=>(import('./effects.js').EffectDef|null), + * faceFrameDt?: number, + * }} RendererOpts 工厂注入项:rig 必填;PIXI 在工厂处一次注入(不外溢到接口)。 + */ + +/** + * @typedef {import('@drama/core').Renderer & { + * addActor:(actorId:string, skin:CharacterSkin, placement:object)=>void, + * removeActor:(actorId:string)=>void, + * loadScene:(def?:object)=>Promise, + * updateEffects:(dtMs:number)=>void, + * }} PixiRenderer @drama/core Renderer(零 Pixi 入参)+ 出场 / 换场扩展方法 + */ + +/** + * 创建满足 @drama/core Renderer 接口的 PixiJS 渲染后端。 + * @param {import('pixi.js').Application} app 已 init 的 Pixi Application(由 player-vue / composition root 建) + * @param {RendererOpts} opts + * @returns {PixiRenderer} + */ +export function createPixiRenderer(app, opts) { + if (opts && opts.PIXI) return _createPixiRenderer(app, opts) + throw new Error( + 'createPixiRenderer: opts.PIXI is required. Pass the imported `* as PIXI` namespace ' + + '(e.g. `import * as PIXI from "pixi.js"`). Renderer 方法签名仍零 Pixi——PIXI 仅在工厂处一次注入。', + ) +} + +/** + * 与 createPixiRenderer 等价,但自动 `import('pixi.js')` 注入 PIXI(浏览器 / 已装 pixi.js 环境)。 + * 给 player-vue / studio 直接用;headless 单测走 createPixiRenderer + 注入 stub。 + * @param {import('pixi.js').Application} app + * @param {Omit} opts 同 createPixiRenderer(无需传 PIXI) + * @returns {Promise} + */ +export async function createPixiRendererAuto(app, opts) { + const PIXI = await import('pixi.js') + return _createPixiRenderer(app, { ...opts, PIXI }) +} + +// ───────────────────────── 公共形状契约(render-pixi §3) ───────────────────────── + +/** + * @typedef {Object} CharacterSkin 渲染需要的贴皮数据(来自 @drama/assets 的 character 出包)。 + * @property {string} rigId + * @property {Record} parts slot 名 -> 该部件贴皮 + */ + +/** + * @typedef {Object} SlotSkin + * @property {string=} url 普通部件贴图 + * @property {{x:number,y:number}=} anchor + * @property {number=} scale + * @property {number=} rotation + * @property {boolean=} mirror + * @property {number=} zOrder 覆盖 slot 默认层级 + * @property {{x:number,y:number}=} bonePos 关节位置覆盖(对齐用) + * @property {Record=} emotions face 专用情绪帧 + * @property {number=} talkFps 口型轮播帧率(face 用) + */ diff --git a/packages/render-pixi/src/occluder.js b/packages/render-pixi/src/occluder.js new file mode 100644 index 0000000..98d8507 --- /dev/null +++ b/packages/render-pixi/src/occluder.js @@ -0,0 +1,43 @@ +// occluder.js — 遮挡片:背景像素按多边形裁出 + zIndex=baseY(移植自 pixi-stage.js addOccluder)。 +// +// 不需额外素材,复用背景像素:把背景图按多边形裁出一块,放进人物 Y 排序层。 +// 人物脚下 worldY < baseY(在它后面/更远)→ 被遮挡;> baseY(更近)→ 盖住它。 + +import { depthZIndex } from './geom.js' + +/** + * 遮挡判定(纯函数,测试用):人物脚下 worldY 是否被 baseY 的遮挡片遮住。 + * @param {number} actorWorldY + * @param {number} occluderBaseY + * @returns {boolean} true=被遮(actor 在遮挡片后面) + */ +export function isOccluded(actorWorldY, occluderBaseY) { + return depthZIndex(actorWorldY) < depthZIndex(occluderBaseY) +} + +/** + * 用背景纹理按多边形裁出一块遮挡片,挂到 actorsLayer,zIndex=baseY。 + * @param {Object} deps + * @param {*} deps.PIXI + * @param {*} deps.bgTexture 背景纹理(已就绪) + * @param {*} deps.actorsLayer Y 排序层容器 + * @param {number} deps.worldW + * @param {number} deps.worldH + * @param {{polygon:number[][], baseY:number}} def polygon=[[x,y],...](世界坐标) + * @returns {*|null} 遮挡片容器;条件不满足返回 null + */ +export function createOccluder({ PIXI, bgTexture, actorsLayer, worldW, worldH }, def) { + const { polygon, baseY } = def || {} + if (!bgTexture || !polygon || polygon.length < 3) return null + const sprite = new PIXI.Sprite(bgTexture) + sprite.width = worldW + sprite.height = worldH + const mask = new PIXI.Graphics() + mask.poly(polygon.flat()).fill(0xffffff) + const cont = new PIXI.Container() + cont.addChild(sprite, mask) + sprite.mask = mask + cont.zIndex = depthZIndex(baseY) // 与人物 worldY 同一排序尺度 + actorsLayer.addChild(cont) + return cont +} diff --git a/packages/render-pixi/src/renderer.js b/packages/render-pixi/src/renderer.js new file mode 100644 index 0000000..440837d --- /dev/null +++ b/packages/render-pixi/src/renderer.js @@ -0,0 +1,218 @@ +// renderer.js — createPixiRenderer:实现 @drama/core Renderer 全部方法,持有 stage / actor 注册表。 +// +// 这是 core 接口的实现外壳:把每个接口方法转成对 stage / actor 的操作。 +// core 那侧全是数字与字符串(SlotTransform / CameraRenderState / 字符串 / 数字), +// 本包这侧才出现 PIXI.Sprite——本文件是「翻译层」的总装配。 +// +// addActor / removeActor / loadScene 作为 Renderer 的**扩展方法**挂在返回对象上 +// (scheduler 的 cast 出场 / transition 换班底调用),委托 stage(render-pixi §4)。 + +import { createAssetLoader } from './assets.js' +import { PixiStage } from './stage.js' +import { PixiActor } from './actor.js' + +/** + * 创建满足 @drama/core Renderer 接口的 PixiJS 渲染后端。 + * @param {import('pixi.js').Application} app 已 init 的 Pixi Application + * @param {import('./index.js').RendererOpts} opts + * @returns {import('./index.js').PixiRenderer} + */ +export function createPixiRenderer(app, opts) { + const o = opts || /** @type {*} */ ({}) + const { PIXI, rig } = o + // 工厂入口自检(架构 §10.3):少注入即在装配时崩,不拖到运行某行。 + if (!app) throw new Error('createPixiRenderer: app is required') + if (!PIXI) throw new Error('createPixiRenderer: opts.PIXI is required') + if (!rig) throw new Error('createPixiRenderer: opts.rig is required') + + const assets = createAssetLoader({ PIXI, loadTexture: o.loadTexture }) + const stage = new PixiStage({ app, PIXI, assets, resolveEffect: o.resolveEffect }) + // 道具库解析:name -> propDef({url,scale,anchor,holds}),供手持道具挂载(DramaPlayer 注入 bundle.props)。 + const resolveProp = typeof o.resolveProp === 'function' ? o.resolveProp : () => null + + /** @type {Map} */ + const actors = new Map() + + /** @param {string} id */ + const getActor = (id) => actors.get(id) || null + + // 场景表(id -> sceneDef)+ 转场镜头回调,由装配层(usePlayback)注入。 + // transition.to 是场景 id(移植自 OLD play-demo.html:100 `getScene(t.to)`),按表解析成 def → loadScene。 + /** @type {Record} */ + let sceneTable = {} + /** @type {((sceneDef:any)=>void)|null} 换场后施新场景标准镜头(OLD play-demo.html:104 `cut(def.camera)`) */ + let onSceneCut = null + + // ───────────────────── Renderer 扩展方法(出场 / 换场) ───────────────────── + + /** + * 注册一个角色到舞台(建容器、按 rig.slots 贴皮)。 + * @param {string} actorId + * @param {import('./index.js').CharacterSkin} skin + * @param {object} placement {x,y,facing,scale} + */ + function addActor(actorId, skin, placement) { + if (actors.has(actorId)) return + const p = /** @type {{x:number,y:number,facing:'left'|'right',scale:number}} */ ( + placement || { x: 0, y: 0, facing: 'right', scale: 1 } + ) + const actor = new PixiActor({ + PIXI, + rig, + skin, + assets, + placement: p, + depthScale: stage.depthScale, + }) + stage.addActorRoot(actor.root) + actors.set(actorId, actor) + } + + /** @param {string} actorId */ + function removeActor(actorId) { + const a = getActor(actorId) + if (!a) return + a.destroy() + actors.delete(actorId) + } + + /** + * 一条龙加载场景:背景 + 世界尺寸 + 纵深 + 遮挡片。 + * @param {{background?:string,world?:{w:number,h:number},depth?:{nearY:number,farY:number,nearScale:number,farScale:number}|null,occluders?:Array<{polygon:number[][],baseY:number}>}} [def] + */ + async function loadScene(def = {}) { + if (def.world) stage.setWorldSize(def.world.w, def.world.h) + stage.setDepth(def.depth || null) + // 同步已有角色的透视。 + for (const a of actors.values()) { + a.depthScale = stage.depthScale + a.setWorldPos(a.worldX, a.worldY) + } + let tex = null + if (def.background) tex = await assets.load(def.background) // 先就绪(遮挡片要裁它) + if (def.background) stage.setBackgroundImage(def.background, tex) + stage.clearOccluders() + for (const o of def.occluders || []) stage.addOccluder(o) + } + + // ───────────────────────── Renderer 接口(core §3.3) ───────────────────────── + + /** @type {import('@drama/core').Renderer} */ + const renderer = { + applyActorPose(actorId, slotTransforms) { + const a = getActor(actorId) + if (a) a.applyActorPose(slotTransforms || {}) + }, + + applyFace(actorId, emotion, talking) { + const a = getActor(actorId) + if (a) a.applyFace(emotion, talking, o.faceFrameDt || 16) + }, + + applyFacing(actorId, facing, headFacing) { + const a = getActor(actorId) + if (a) a.applyFacing(facing, headFacing) + }, + + applyCamera(state) { + stage.applyCamera(state) + }, + + setActorWorldPos(actorId, worldX, worldY) { + const a = getActor(actorId) + if (a) a.setWorldPos(worldX, worldY) + }, + + moveActor(actorId, worldX, worldY, durMs, easing) { + const a = getActor(actorId) + if (a) a.moveTo(worldX, worldY, durMs, easing) + }, + + playEffect(name, fxOpts) { + stage.effects.play(name, fxOpts || {}) + }, + + stopEffect(name) { + stage.effects.stop(name) + }, + + /** + * 角色手持道具:把 name 解析成 propDef(resolveProp)后挂到该角色(actor.holdProp)。 + * @param {string} actorId @param {string} name 道具名 @param {string=} hold 握法(carry/raise) + */ + holdProp(actorId, name, hold) { + const a = getActor(actorId) + const def = resolveProp(name) + if (a && def) a.holdProp(def, hold) + }, + + /** @param {string} actorId 松开该角色手持道具。 */ + releaseProp(actorId) { + const a = getActor(actorId) + if (a) a.releaseProp() + }, + + resetActor(actorId) { + const a = getActor(actorId) + if (a) a.reset() + }, + + async runTransition(transition) { + // 换场数据落地(移植自 OLD play-demo.html:100-104 transitionTo 的换场段): + // 黑场动画 / 停旧场景音频 / 换班底 由 player-vue(DramaPlayer overlay)与 ctx.applyCast 负责; + // 本包只负责「把 transition.to 解析成场景 def → loadScene(换背景/地面/遮挡/点位)→ 施新场景标准镜头」。 + /** @type {{to?:string,scene?:*,clearActors?:boolean,cast?:Array<{actorId:string,skin:*,placement:*}>}} */ + const t = transition || {} + if (t.clearActors) { + for (const id of [...actors.keys()]) removeActor(id) + } + // to=场景 id(OLD 行为):查场景表拿 def;也兼容直接传 scene 对象。 + const def = t.scene || (t.to != null ? sceneTable[t.to] : null) + if (def) { + await loadScene(def) + if (onSceneCut) onSceneCut(def) // 施新场景标准镜头 cut(def.camera)(OLD play-demo.html:104) + } + if (Array.isArray(t.cast)) { + for (const c of t.cast) addActor(c.actorId, c.skin, c.placement) + } + }, + } + + // 扩展方法挂上返回对象(scheduler 的 cast / transition 调用)。 + return Object.assign(renderer, { + addActor, + removeActor, + loadScene, + // 相机跟随焦点 / 当前世界坐标查询(usePlayback 的 ctx.focusLookup / getActorWorldPos 用, + // 从 renderer 取 actor 当前真实坐标,moveTo 补间后自动是移动后的最新值——见任务清单 6)。 + getActorFocus: (/** @type {string} */ id) => { + const a = getActor(id) + return a ? a.getFocusPoint() : null + }, + getActorWorldPos: (/** @type {string} */ id) => { + const a = getActor(id) + return a ? { x: a.worldX, y: a.worldY } : null + }, + // 转场场景表 + 标准镜头回调注入(transition.to 解析用)。 + setSceneTable: (/** @type {Record} */ t) => { + sceneTable = t || {} + }, + setSceneCut: (/** @type {((def:any)=>void)|null} */ fn) => { + onSceneCut = fn || null + }, + // 内部访问(player-vue 装配 ticker 时驱动特效轮播 + 走位补间;非 core 接口)。 + _stage: stage, + _actors: actors, + updateEffects: (/** @type {number} */ dtMs) => stage.effects.update(dtMs), + // 每帧推进所有角色走位补间(移植自 OLD:每帧 actor.update 里 lerp 位置)。 + updateMoves: (/** @type {number} */ dtMs) => { + for (const a of actors.values()) a.updateMove(dtMs) + }, + // 每帧推进所有角色口型(说话态 talkFps 轮播;移植自 OLD actor.update 的表情段,任务清单 11)。 + updateFaces: (/** @type {number} */ dtMs) => { + for (const a of actors.values()) a.tickFace(dtMs) + }, + // 停所有屏幕特效(转场清场用)。 + stopAllEffects: () => stage.effects.stopAll(), + }) +} diff --git a/packages/render-pixi/src/stage.js b/packages/render-pixi/src/stage.js new file mode 100644 index 0000000..368c8a9 --- /dev/null +++ b/packages/render-pixi/src/stage.js @@ -0,0 +1,165 @@ +// stage.js — PixiStage:worldContainer / 背景 / actorsLayer / 相机容器写入 / 遮挡层 / 特效层。 +// +// 移植自 pixi-stage.js,**剥离调度与求值**(ticker / camera 补间 / 角色 update 全在 core)。 +// 本类只持有 Pixi 世界结构、把 core 给的相机状态写进 worldContainer、管背景 / 遮挡 / 特效层。 +// +// HUD 约定(移植自原型注释):字幕 / 指示器等 HUD 不进 worldContainer,浮在 canvas 之上, +// 因此不受相机 pan/zoom/shake 影响——applyCamera 只写 worldContainer,HUD 层不碰。 + +import { createOccluder } from './occluder.js' +import { createEffects } from './effects.js' +import { perspectiveScale } from './geom.js' + +// 内容相机 zoom 的参考视口高度:剧目的镜头(cam.zoom / 场景标准镜头)在老 demo 的 16:9 舞台 +// (max-width 1000 → 1000×562.5)下调校。applyCamera 按实际画布高度相对此值归一,使取景与画布尺寸无关。 +const REF_VIEW_H = 562.5 + +export class PixiStage { + /** + * @param {Object} deps + * @param {import('pixi.js').Application} deps.app 已 init 的 Pixi Application + * @param {*} deps.PIXI + * @param {ReturnType} deps.assets + * @param {((name:string)=>(import('./effects.js').EffectDef|null))=} deps.resolveEffect + */ + constructor({ app, PIXI, assets, resolveEffect }) { + this.app = app + this.PIXI = PIXI + this.assets = assets + + this.worldW = 3200 + this.worldH = 1100 + /** @type {((y:number)=>number)|null} */ + this.depthScale = null + /** @type {*[]} */ + this.occluders = [] + /** @type {*|null} */ + this.sceneBg = null + + // 世界容器:相机 pan/zoom/shake 写在它身上。 + this.world = new PIXI.Container() + app.stage.addChild(this.world) + + // 角色 / 遮挡片层:sortableChildren 走 Y 纵深排序。 + this.actorsLayer = new PIXI.Container() + this.actorsLayer.sortableChildren = true + this.world.addChild(this.actorsLayer) + + // 特效 / HUD 层:挂在 app.stage 顶层(非 world),不受相机影响。 + this.fxLayer = new PIXI.Container() + app.stage.addChild(this.fxLayer) + + this.effects = createEffects({ PIXI, layer: this.fxLayer, assets, resolve: resolveEffect }) + } + + /** + * 设置场景纵深(线性透视):y=nearY→nearScale,y=farY→farScale。 + * @param {{nearY:number,farY:number,nearScale:number,farScale:number}|null} depth + */ + setDepth(depth) { + if (!depth) { + this.depthScale = null + return + } + this.depthScale = (/** @type {number} */ y) => perspectiveScale(y, depth) + } + + /** 设世界坐标系尺寸(= 场景图像素尺寸)。 @param {number} w @param {number} h */ + setWorldSize(w, h) { + if (w) this.worldW = w + if (h) this.worldH = h + } + + /** + * 设置背景图(剧情背景)。tex 已就绪则同步设置(遮挡片可立即裁剪)。 + * @param {string} url + * @param {*=} tex + */ + setBackgroundImage(url, tex = null) { + if (!url) return + const PIXI = this.PIXI + if (this.sceneBg) { + try { + this.sceneBg.destroy() + } catch (e) { + void e + } + } + const sprite = new PIXI.Sprite() + sprite.zIndex = -1000 + const apply = (/** @type {*} */ t) => { + if (!t) return + sprite.texture = t + sprite.width = this.worldW + sprite.height = this.worldH + } + if (tex) apply(tex) + else this.assets.load(url).then(apply) + this.world.addChildAt(sprite, 0) + this.sceneBg = sprite + } + + /** + * 相机状态写入 worldContainer(移植自 camera.js 的容器写入部分)。 + * world.scale = zoom × 视口归一; world.pivot = (x,y); + * world.position = (viewW/2 + shakeX, viewH/2 + shakeY)。 + * + * 视口归一(关键):内容相机的 zoom 是在**固定参考视口**(老 demo 16:9 舞台 1000×562.5)下调校的; + * 若直接用绝对 zoom,画布越小看到的世界区域越小 → 角色越大、头被裁(小播放器里取景全崩)。 + * 故按画布高度相对参考高度缩放:让「角色占画面比例 / 取景范围」与画布像素尺寸无关—— + * 任意大小的播放器(导戏台小窗 / C 端整屏 / 手机)都呈现同一份导演取景。 + * @param {import('@drama/core').CameraRenderState} state + */ + applyCamera(state) { + const w = this.world + const k = state.viewH ? state.viewH / REF_VIEW_H : 1 + w.scale.set(state.zoom * k) + w.pivot.set(state.x, state.y) + w.position.set(state.viewW / 2 + state.shakeX, state.viewH / 2 + state.shakeY) + } + + /** + * 加遮挡片(背景像素按多边形裁出,zIndex=baseY)。 + * @param {{polygon:number[][],baseY:number}} def + */ + addOccluder(def) { + const cont = createOccluder( + { + PIXI: this.PIXI, + bgTexture: this.sceneBg && this.sceneBg.texture, + actorsLayer: this.actorsLayer, + worldW: this.worldW, + worldH: this.worldH, + }, + def, + ) + if (cont) this.occluders.push(cont) + return cont + } + + clearOccluders() { + for (const o of this.occluders) { + try { + o.destroy() + } catch (e) { + void e + } + } + this.occluders = [] + } + + /** 把一个角色根容器加入 Y 排序层。 @param {*} root */ + addActorRoot(root) { + this.actorsLayer.addChild(root) + } + + destroy() { + this.effects.stopAll() + this.clearOccluders() + try { + this.world.destroy({ children: true }) + } catch (e) { + void e + } + } +} diff --git a/packages/render-pixi/test/depth.test.js b/packages/render-pixi/test/depth.test.js new file mode 100644 index 0000000..6d000fa --- /dev/null +++ b/packages/render-pixi/test/depth.test.js @@ -0,0 +1,78 @@ +// depth.test.js — 透视缩放 + zIndex 纵深 + 遮挡判定(render-pixi §8 验收 7)。 +// +// 对照原型 pixi-stage.js / character-actor.js: +// - setActorWorldPos 后角色按 worldY 做透视缩放(近大远小)+ zIndex=round(worldY)。 +// - 遮挡片 zIndex=baseY;人物 worldY < baseY 被遮、> baseY 盖住。 + +import { describe, it, expect } from 'vitest' +import { perspectiveScale, depthZIndex } from '../src/geom.js' +import { isOccluded } from '../src/occluder.js' +import { createPixiRenderer } from '../src/index.js' +import { makeStubPixi, StubApplication } from './stub-pixi.js' + +const depth = { nearY: 1000, farY: 200, nearScale: 1.5, farScale: 0.5 } + +describe('geom 纵深纯函数', () => { + it('perspectiveScale:近(下/大)→nearScale、远(上/小)→farScale、区间外夹紧', () => { + expect(perspectiveScale(1000, depth)).toBeCloseTo(1.5) + expect(perspectiveScale(200, depth)).toBeCloseTo(0.5) + expect(perspectiveScale(600, depth)).toBeCloseTo(1.0) // 中点 + expect(perspectiveScale(2000, depth)).toBeCloseTo(1.5) // 夹紧 near + expect(perspectiveScale(-100, depth)).toBeCloseTo(0.5) // 夹紧 far + }) + + it('perspectiveScale 单调:worldY 越大缩放越大(近大远小)', () => { + expect(perspectiveScale(400, depth)).toBeLessThan(perspectiveScale(800, depth)) + }) + + it('depthZIndex = round(worldY)', () => { + expect(depthZIndex(456.4)).toBe(456) + expect(depthZIndex(456.6)).toBe(457) + }) +}) + +describe('遮挡判定', () => { + it('人物脚下 worldY < baseY → 被遮;> baseY → 盖住;== 不被遮', () => { + expect(isOccluded(300, 500)).toBe(true) + expect(isOccluded(700, 500)).toBe(false) + expect(isOccluded(500, 500)).toBe(false) + }) +}) + +const rig = { + id: 'humanoid_v1', + canvas: { width: 1080, height: 1080 }, + bones: [{ name: 'root', parent: null, pos: { x: 0, y: 0 }, rotation: 0 }], + slots: [{ name: 'torso', bone: 'root', zOrder: 10 }], +} + +describe('setActorWorldPos 集成', () => { + function setup() { + const PIXI = makeStubPixi() + const r = createPixiRenderer(new StubApplication(), { PIXI, rig }) + return { r, PIXI } + } + + it('setActorWorldPos 写根容器位置 + zIndex=round(worldY)', () => { + const { r } = setup() + r.addActor('a', { rigId: 'humanoid_v1', parts: { torso: { url: 't.png' } } }, { x: 0, y: 0, facing: 'left', scale: 1 }) + r.setActorWorldPos('a', 250, 740.3) + const actor = r._actors.get('a') + expect(actor.root.position.x).toBe(250) + expect(actor.root.position.y).toBe(740.3) + expect(actor.root.zIndex).toBe(740) + }) + + it('loadScene 设 depth 后,角色按 worldY 透视缩放(baseScale=1)', async () => { + const { r } = setup() + r.addActor('a', { rigId: 'humanoid_v1', parts: { torso: { url: 't.png' } } }, { x: 0, y: 0, facing: 'left', scale: 1 }) + await r.loadScene({ world: { w: 2000, h: 1200 }, depth }) + r.setActorWorldPos('a', 0, 1000) // 近 + const near = Math.abs(r._actors.get('a').root.scale.x) + r.setActorWorldPos('a', 0, 200) // 远 + const far = Math.abs(r._actors.get('a').root.scale.x) + expect(near).toBeCloseTo(1.5) + expect(far).toBeCloseTo(0.5) + expect(near).toBeGreaterThan(far) + }) +}) diff --git a/packages/render-pixi/test/mirror.test.js b/packages/render-pixi/test/mirror.test.js new file mode 100644 index 0000000..84bc936 --- /dev/null +++ b/packages/render-pixi/test/mirror.test.js @@ -0,0 +1,108 @@ +// mirror.test.js — facing 翻转 + headFacing 扭头反射轴镜像(render-pixi §8 验收 5)。 +// +// 对照原型 character-actor.js: +// - 贴图基准朝向=屏幕左 → facing='right' 才翻转根容器 scale.x(bodyFlipSign)。 +// - headFacing≠facing → 头/脸 scale.x 翻转,且位置绕头骨节点 x 反射(reflectHeadX,不错位)。 + +import { describe, it, expect } from 'vitest' +import { + bodyFlipSign, + headMirrorSign, + reflectHeadX, + resolveSlotWrite, +} from '../src/geom.js' +import { createPixiRenderer } from '../src/index.js' +import { makeStubPixi, StubApplication } from './stub-pixi.js' + +describe('geom 纯几何', () => { + it('bodyFlipSign:left 不翻(1)、right 翻(-1)', () => { + expect(bodyFlipSign('left')).toBe(1) + expect(bodyFlipSign('right')).toBe(-1) + }) + + it('headMirrorSign:headFacing 与 facing 不同→-1,否则 1', () => { + expect(headMirrorSign('left', null)).toBe(1) + expect(headMirrorSign('left', 'left')).toBe(1) + expect(headMirrorSign('left', 'right')).toBe(-1) + expect(headMirrorSign('right', 'left')).toBe(-1) + }) + + it('reflectHeadX:headMirror=-1 时绕反射轴镜像,=1 时原样', () => { + expect(reflectHeadX(30, 50, -1)).toBe(70) // 2*50-30 + expect(reflectHeadX(30, 50, 1)).toBe(30) + }) + + it('resolveSlotWrite:head slot 扭头镜像翻转 scaleX 且反射位置', () => { + const t = { x: 30, y: 10, rotation: 0, scaleX: 1, scaleY: 1 } + const w = resolveSlotWrite(t, { isHeadSlot: true, headMirror: -1, headBoneX: 50 }) + expect(w.scaleX).toBe(-1) + expect(w.x).toBe(70) + expect(w.y).toBe(10) + }) + + it('resolveSlotWrite:非 head slot 不受扭头影响', () => { + const t = { x: 30, y: 10, rotation: 0, scaleX: 1, scaleY: 1 } + const w = resolveSlotWrite(t, { isHeadSlot: false, headMirror: -1, headBoneX: 50 }) + expect(w.scaleX).toBe(1) + expect(w.x).toBe(30) + }) + + it('resolveSlotWrite:mirror 与扭头镜像叠加(-1×-1=1)', () => { + const t = { x: 30, y: 10, rotation: 0, scaleX: 1, scaleY: 1, mirror: true } + const w = resolveSlotWrite(t, { isHeadSlot: true, headMirror: -1, headBoneX: 50 }) + expect(w.scaleX).toBe(1) // mirror -1,再 headMirror -1 + }) +}) + +const rig = { + id: 'humanoid_v1', + canvas: { width: 1080, height: 1080 }, + bones: [{ name: 'root', parent: null, pos: { x: 0, y: 0 }, rotation: 0 }], + slots: [ + { name: 'head', bone: 'root', zOrder: 20 }, + { name: 'face', bone: 'root', zOrder: 21 }, + ], +} + +describe('applyFacing 集成', () => { + function setup(facing) { + const PIXI = makeStubPixi() + const r = createPixiRenderer(new StubApplication(), { PIXI, rig }) + r.addActor('h', { rigId: 'humanoid_v1', parts: { head: { url: 'h.png' }, face: { url: 'f.png' } } }, { x: 0, y: 0, facing, scale: 1 }) + return { r, actor: r._actors.get('h') } + } + + it('facing=right 翻转根容器 scale.x(贴图基准=左)', () => { + const { actor } = setup('right') + expect(actor.root.scale.x).toBeLessThan(0) + const { actor: a2 } = setup('left') + expect(a2.root.scale.x).toBeGreaterThan(0) + }) + + it('headFacing≠facing:已落地的 head/face 位置绕头骨反射、scale.x 翻转', () => { + const { r, actor } = setup('left') + // 先落一帧 pose,head 在 x=30 + r.applyActorPose('h', { + head: { x: 30, y: 0, rotation: 0, scaleX: 1, scaleY: 1 }, + face: { x: 40, y: 5, rotation: 0, scaleX: 1, scaleY: 1 }, + }) + expect(actor.parts.get('head').obj.position.x).toBe(30) + // 扭头看右(与身体 left 不同)→ 反射轴=head.x=30 + r.applyFacing('h', 'left', 'right') + const head = actor.parts.get('head').obj + const face = actor.parts.get('face').obj + expect(head.scale.x).toBe(-1) + expect(head.position.x).toBe(30) // 2*30-30 + expect(face.position.x).toBe(20) // 2*30-40,绕头骨反射不错位 + expect(face.scale.x).toBe(-1) + }) + + it('headFacing 回 null:恢复跟随身体(不再镜像)', () => { + const { r, actor } = setup('left') + r.applyActorPose('h', { head: { x: 30, y: 0, rotation: 0, scaleX: 1, scaleY: 1 } }) + r.applyFacing('h', 'left', 'right') + r.applyFacing('h', 'left', null) + expect(actor.parts.get('head').obj.scale.x).toBe(1) + expect(actor.parts.get('head').obj.position.x).toBe(30) + }) +}) diff --git a/packages/render-pixi/test/stub-pixi.js b/packages/render-pixi/test/stub-pixi.js new file mode 100644 index 0000000..68dc860 --- /dev/null +++ b/packages/render-pixi/test/stub-pixi.js @@ -0,0 +1,107 @@ +// stub-pixi.js — headless stub Pixi 命名空间(Sprite / Container / Graphics / Application / Assets)。 +// +// 让 §8 的纯映射用例(SlotTransform→Sprite 写入、镜像、纵深)在无 GPU 的 Node 下可断言, +// 不依赖真实 pixi.js / WebGL。stub 只实现本包用到的最小 API 面。 + +class StubPoint { + constructor(x = 0, y = 0) { + this.x = x + this.y = y + } + set(x, y = x) { + this.x = x + this.y = y + } +} + +class StubContainer { + constructor() { + this.children = [] + this.position = new StubPoint() + this.scale = new StubPoint(1, 1) + this.pivot = new StubPoint() + this.zIndex = 0 + this.sortableChildren = false + this.alpha = 1 + this.visible = true + this.destroyed = false + } + addChild(...c) { + this.children.push(...c) + return c[0] + } + addChildAt(c, i) { + this.children.splice(i, 0, c) + return c + } + destroy() { + this.destroyed = true + } +} + +class StubSprite extends StubContainer { + constructor(texture = null) { + super() + this.texture = texture + this.anchor = new StubPoint(0.5, 0.5) + this.width = 0 + this.height = 0 + this.mask = null + this.rotation = 0 + } +} + +class StubGraphics extends StubContainer { + constructor() { + super() + this.calls = [] + } + poly(points) { + this.calls.push(['poly', points]) + return this + } + fill(color) { + this.calls.push(['fill', color]) + return this + } + rect(...a) { + this.calls.push(['rect', a]) + return this + } +} + +class StubApplication { + constructor() { + this.stage = new StubContainer() + this.screen = { width: 1280, height: 720 } + this.canvas = {} + } + async init() {} + destroy() {} +} + +/** 创建一个可控的 stub Assets:load 返回可预设的纹理或受控 reject。 */ +function makeStubAssets(behavior = {}) { + return { + loaded: [], + async load(url) { + this.loaded.push(url) + if (behavior.reject && behavior.reject(url)) throw new Error('load fail ' + url) + const t = behavior.texture ? behavior.texture(url) : { __tex: url } + return t + }, + } +} + +/** 组装一个 stub PIXI 命名空间。 */ +export function makeStubPixi(behavior = {}) { + return { + Container: StubContainer, + Sprite: StubSprite, + Graphics: StubGraphics, + Application: StubApplication, + Assets: makeStubAssets(behavior), + } +} + +export { StubContainer, StubSprite, StubGraphics, StubApplication } diff --git a/packages/rig/package.json b/packages/rig/package.json new file mode 100644 index 0000000..13afa94 --- /dev/null +++ b/packages/rig/package.json @@ -0,0 +1,9 @@ +{ + "name": "@drama/rig", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + } +} diff --git a/packages/rig/src/clips/arm.js b/packages/rig/src/clips/arm.js new file mode 100644 index 0000000..4b88f43 --- /dev/null +++ b/packages/rig/src/clips/arm.js @@ -0,0 +1,85 @@ +// arm.js — arm_l / arm_r 两条手臂轨道动作。 +// +// 几何标定(踩坑换来的,照搬保留):人物面向一个方向,故左右臂朝「同一屏幕方向」收, +// arm_l 与 arm_r 用相同旋转量(非镜像)—— pocket / raise / wave / punch 均如此, +// 双手同动时两臂平行。arm_l 在屏幕左、正角指向左;arm_r 在屏幕右、负角指向右。 + +/** @type {Record} */ +export const ARM_CLIPS = { + // ---- 左臂(屏幕左)---- + 'arm_l.down': { + id: 'arm_l.down', track: 'arm_l', kind: 'pose', duration: 0, + bones: { upper_arm_l: [{ t: 0 }], lower_arm_l: [{ t: 0 }] }, + }, + // 插兜:两手一起收向朝向侧的胯兜。 + 'arm_l.pocket': { + id: 'arm_l.pocket', track: 'arm_l', kind: 'pose', duration: 0, + bones: { upper_arm_l: [{ t: 0, drot: -22 }], lower_arm_l: [{ t: 0, drot: 30 }] }, + }, + 'arm_l.raise': { + id: 'arm_l.raise', track: 'arm_l', kind: 'pose', duration: 0, + bones: { upper_arm_l: [{ t: 0, drot: 158 }], lower_arm_l: [{ t: 0, drot: 6 }] }, + }, + // 指人分「指左/指右」:基准朝向下,指左=伸向屏幕左,指右=伸向屏幕右。 + 'arm_l.point_left': { + id: 'arm_l.point_left', track: 'arm_l', kind: 'pose', duration: 0, + bones: { upper_arm_l: [{ t: 0, drot: 80 }], lower_arm_l: [{ t: 0, drot: 8 }] }, + }, + 'arm_l.point_right': { + id: 'arm_l.point_right', track: 'arm_l', kind: 'pose', duration: 0, + bones: { upper_arm_l: [{ t: 0, drot: -92 }], lower_arm_l: [{ t: 0, drot: -8 }] }, + }, + 'arm_l.wave': { + id: 'arm_l.wave', track: 'arm_l', kind: 'loop', duration: 600, + bones: { + upper_arm_l: [{ t: 0, drot: 150 }, { t: 1, drot: 150 }], + lower_arm_l: [{ t: 0, drot: -22 }, { t: 0.5, drot: 24 }, { t: 1, drot: -22 }], + }, + }, + // 挥拳:预备(向后内带)→ 向行进/朝向方向水平前冲伸直 → 收回。左右臂同方向。 + // 基准朝向(facing=right 状态)下出拳朝屏幕左(-x),与 leg.walk 行进方向一致;facing 镜像翻到另一侧。 + 'arm_l.punch': { + id: 'arm_l.punch', track: 'arm_l', kind: 'oneshot', duration: 460, + bones: { + upper_arm_l: [{ t: 0, drot: 0 }, { t: 0.2, drot: -18 }, { t: 0.5, drot: 80 }, { t: 0.64, drot: 80 }, { t: 1, drot: 0 }], + lower_arm_l: [{ t: 0, drot: 0 }, { t: 0.2, drot: -28 }, { t: 0.5, drot: 6 }, { t: 0.64, drot: 6 }, { t: 1, drot: 0 }], + }, + }, + + // ---- 右臂(屏幕右)---- + 'arm_r.down': { + id: 'arm_r.down', track: 'arm_r', kind: 'pose', duration: 0, + bones: { upper_arm_r: [{ t: 0 }], lower_arm_r: [{ t: 0 }] }, + }, + 'arm_r.pocket': { + id: 'arm_r.pocket', track: 'arm_r', kind: 'pose', duration: 0, + bones: { upper_arm_r: [{ t: 0, drot: -22 }], lower_arm_r: [{ t: 0, drot: 30 }] }, + }, + // 举手:与 arm_l.raise 相同旋转量(左右同方向,非镜像),双手同举时两臂平行。 + 'arm_r.raise': { + id: 'arm_r.raise', track: 'arm_r', kind: 'pose', duration: 0, + bones: { upper_arm_r: [{ t: 0, drot: 158 }], lower_arm_r: [{ t: 0, drot: 6 }] }, + }, + 'arm_r.point_left': { + id: 'arm_r.point_left', track: 'arm_r', kind: 'pose', duration: 0, + bones: { upper_arm_r: [{ t: 0, drot: 92 }], lower_arm_r: [{ t: 0, drot: 8 }] }, + }, + 'arm_r.point_right': { + id: 'arm_r.point_right', track: 'arm_r', kind: 'pose', duration: 0, + bones: { upper_arm_r: [{ t: 0, drot: -80 }], lower_arm_r: [{ t: 0, drot: -8 }] }, + }, + 'arm_r.wave': { + id: 'arm_r.wave', track: 'arm_r', kind: 'loop', duration: 600, + bones: { + upper_arm_r: [{ t: 0, drot: 150 }, { t: 1, drot: 150 }], + lower_arm_r: [{ t: 0, drot: -22 }, { t: 0.5, drot: 24 }, { t: 1, drot: -22 }], + }, + }, + 'arm_r.punch': { + id: 'arm_r.punch', track: 'arm_r', kind: 'oneshot', duration: 480, + bones: { + upper_arm_r: [{ t: 0, drot: 0 }, { t: 0.2, drot: -18 }, { t: 0.5, drot: 80 }, { t: 0.64, drot: 80 }, { t: 1, drot: 0 }], + lower_arm_r: [{ t: 0, drot: 0 }, { t: 0.2, drot: -28 }, { t: 0.5, drot: 6 }, { t: 0.64, drot: 6 }, { t: 1, drot: 0 }], + }, + }, +} diff --git a/packages/rig/src/clips/base.js b/packages/rig/src/clips/base.js new file mode 100644 index 0000000..a634d85 --- /dev/null +++ b/packages/rig/src/clips/base.js @@ -0,0 +1,12 @@ +// base.js — base 轨道动作(全身兜底/呼吸)。 +// 关键帧值是「相对 setup pose 的增量」:drot(度) / dx / dy(rig 局部像素) / dscale(乘数,默认1)。 + +/** @type {Record} */ +export const BASE_CLIPS = { + 'base.idle': { + id: 'base.idle', track: 'base', kind: 'loop', duration: 2600, + bones: { + torso: [{ t: 0, dy: 0 }, { t: 0.5, dy: -3 }, { t: 1, dy: 0 }], + }, + }, +} diff --git a/packages/rig/src/clips/head.js b/packages/rig/src/clips/head.js new file mode 100644 index 0000000..49bebbe --- /dev/null +++ b/packages/rig/src/clips/head.js @@ -0,0 +1,33 @@ +// head.js — head 轨道动作(颈/头)。 + +/** @type {Record} */ +export const HEAD_CLIPS = { + 'head.idle': { + id: 'head.idle', track: 'head', kind: 'pose', duration: 0, + bones: { head: [{ t: 0 }], neck: [{ t: 0 }] }, + }, + 'head.talk_shake': { + id: 'head.talk_shake', track: 'head', kind: 'loop', duration: 320, + bones: { + head: [{ t: 0, drot: -2.5, dy: 0 }, { t: 0.5, drot: 2.5, dy: -1.5 }, { t: 1, drot: -2.5, dy: 0 }], + }, + }, + // 点头:向前下俯一下再回正(以竖直位移为主,俯仰方向随 facing)。 + 'head.nod': { + id: 'head.nod', track: 'head', kind: 'oneshot', duration: 620, + bones: { + head: [{ t: 0, drot: 0, dy: 0 }, { t: 0.32, drot: -10, dy: 10 }, { t: 0.62, drot: 4, dy: -2 }, { t: 1, drot: 0, dy: 0 }], + }, + }, + // 抬头 / 低头:以竖直位移(dy)为主表达上下,叠加小幅俯仰(drot)。 + // 符号约定(facing=right 基准):低头=向前下俯(drot 负, dy 正),抬头=向后上仰(drot 正, dy 负)。 + // 身体整体随 facing 做水平镜像,因此 facing=left 时这两个方向会自动翻转,与朝向保持一致。 + 'head.lookup': { + id: 'head.lookup', track: 'head', kind: 'pose', duration: 0, + bones: { head: [{ t: 0, drot: 9, dy: -10 }] }, + }, + 'head.lookdown': { + id: 'head.lookdown', track: 'head', kind: 'pose', duration: 0, + bones: { head: [{ t: 0, drot: -10, dy: 11 }] }, + }, +} diff --git a/packages/rig/src/clips/index.js b/packages/rig/src/clips/index.js new file mode 100644 index 0000000..8366504 --- /dev/null +++ b/packages/rig/src/clips/index.js @@ -0,0 +1,39 @@ +// clips/index.js — 汇总各轨道动作 + 轨道元数据(TRACKS / TRACK_BONES / TRACK_DEFAULTS)。 +// 仅做数据聚合,无求值/插值逻辑(那些全在 @drama/core)。 + +import { BASE_CLIPS } from './base.js' +import { LOWER_BODY_CLIPS } from './lower-body.js' +import { ARM_CLIPS } from './arm.js' +import { HEAD_CLIPS } from './head.js' + +/** @type {Record} */ +export const CLIPS = { + ...BASE_CLIPS, + ...LOWER_BODY_CLIPS, + ...ARM_CLIPS, + ...HEAD_CLIPS, +} + +/** 有序轨道列表。 @type {string[]} */ +export const TRACKS = ['base', 'lower_body', 'arm_l', 'arm_r', 'head'] + +// 每条轨道允许写入的 bone 子集(互不相交;face 走单独的程序化通道,不在此)。 +/** @type {Record} */ +export const TRACK_BONES = { + base: ['torso'], + // hip 归 lower_body,使「跪下」等下肢动作能同时降低胯部 + 折叠腿。 + lower_body: ['hip', 'thigh_l', 'shin_l', 'thigh_r', 'shin_r'], + arm_l: ['upper_arm_l', 'lower_arm_l'], + arm_r: ['upper_arm_r', 'lower_arm_r'], + head: ['neck', 'head'], +} + +// 每条轨道的默认 clip(没指定动作时回落到它)。 +/** @type {Record} */ +export const TRACK_DEFAULTS = { + base: 'base.idle', + lower_body: 'leg.stand_together', + arm_l: 'arm_l.down', + arm_r: 'arm_r.down', + head: 'head.idle', +} diff --git a/packages/rig/src/clips/lower-body.js b/packages/rig/src/clips/lower-body.js new file mode 100644 index 0000000..c6a636d --- /dev/null +++ b/packages/rig/src/clips/lower-body.js @@ -0,0 +1,42 @@ +// lower-body.js — lower_body 轨道动作(腿/胯)。 +// hip 归 lower_body,使「跪下」等下肢动作能同时降低胯部(下沉身体)+ 折叠腿。 + +/** @type {Record} */ +export const LOWER_BODY_CLIPS = { + 'leg.stand_together': { + id: 'leg.stand_together', track: 'lower_body', kind: 'pose', duration: 0, + bones: { thigh_l: [{ t: 0 }], thigh_r: [{ t: 0 }], shin_l: [{ t: 0 }], shin_r: [{ t: 0 }] }, + }, + 'leg.stand_apart': { + id: 'leg.stand_apart', track: 'lower_body', kind: 'pose', duration: 0, + bones: { + thigh_l: [{ t: 0, drot: 11 }], thigh_r: [{ t: 0, drot: -11 }], + shin_l: [{ t: 0, drot: -5 }], shin_r: [{ t: 0, drot: 5 }], + }, + }, + // 走路(基准朝向状态下向屏幕左 -x 行进;facing 镜像翻到另一侧): + // 两个易错点都已校正: + // 1) 支撑腿伸直留地面、随身体往后(+x)蹬;摆动腿屈膝把脚明显抬离地面(否则「地面滑」=月球漫步)。 + // 2) 摆动脚必须在「后侧」抬起、再向「前(行进方向)」荡落 —— 若在前侧抬起会变成「倒着走」。 + // 故 shin 与 thigh 的方向需一致镜像(本组为向左行进版本)。 + 'leg.walk': { + id: 'leg.walk', track: 'lower_body', kind: 'loop', duration: 760, + bones: { + thigh_r: [{ t: 0, drot: 16 }, { t: 0.5, drot: -16 }, { t: 1, drot: 16 }], + thigh_l: [{ t: 0, drot: -16 }, { t: 0.5, drot: 16 }, { t: 1, drot: -16 }], + shin_r: [{ t: 0, drot: -6 }, { t: 0.5, drot: -6 }, { t: 0.72, drot: -72 }, { t: 1, drot: -6 }], + shin_l: [{ t: 0, drot: -6 }, { t: 0.22, drot: -72 }, { t: 0.5, drot: -6 }, { t: 1, drot: -6 }], + }, + }, + // 跪下:降低胯部使身体下沉(hip dy),双膝落地(大腿近竖直略外展),小腿向后折叠贴地,上身保持直立。 + 'leg.kneel': { + id: 'leg.kneel', track: 'lower_body', kind: 'pose', duration: 0, + bones: { + hip: [{ t: 0, dy: 132 }], + thigh_l: [{ t: 0, drot: 6 }], + thigh_r: [{ t: 0, drot: -6 }], + shin_l: [{ t: 0, drot: -88 }], + shin_r: [{ t: 0, drot: -88 }], + }, + }, +} diff --git a/packages/rig/src/index.js b/packages/rig/src/index.js new file mode 100644 index 0000000..87ebf64 --- /dev/null +++ b/packages/rig/src/index.js @@ -0,0 +1,90 @@ +// index.js — @drama/rig 唯一公共面(§3 契约)。 +// +// 职责:汇总 rigs/clips/poses 三类纯数据,做「命名姿势覆盖同名内置 clip」的合并,深冻结, +// 暴露查询函数与轨道元数据。**无任何求值 / 插值 / 渲染逻辑**(那些全在 @drama/core)。 +// +// 不可变性约定:采用「方案 A —— 返回深冻结(Object.freeze 递归)的共享常量」。 +// 零拷贝、调用方误改即抛(严格模式)/ 静默失败(非严格)。 +// core 侧约定不修改 rig 返回的数据(增量算在 core 内部临时结构里),故方案 A 足够。 + +// 形状契约 typedef 经公共面再导出,供下游 `import('@drama/rig').RigDefinition` 等消费 +// (@drama/core / @drama/render-pixi 基于此对齐,详见 types.js)。 +/** + * @typedef {import('./types.js').RigDefinition} RigDefinition + * @typedef {import('./types.js').Bone} Bone + * @typedef {import('./types.js').Slot} Slot + * @typedef {import('./types.js').AnimationClip} AnimationClip + * @typedef {import('./types.js').Keyframe} Keyframe + * @typedef {import('./types.js').NamedPose} NamedPose + */ + +import { RIGS } from './rigs/index.js' +import { CLIPS, TRACKS as _TRACKS, TRACK_BONES as _TRACK_BONES, TRACK_DEFAULTS as _TRACK_DEFAULTS } from './clips/index.js' +import { POSES } from './poses.js' + +/** + * 递归深冻结一个普通对象/数组(就地冻结,纯数据无循环引用)。 + * @template {unknown} T + * @param {T} obj + * @returns {T} + */ +function deepFreeze(obj) { + if (obj === null || typeof obj !== 'object' || Object.isFrozen(obj)) return obj + for (const value of Object.values(/** @type {Record} */ (obj))) { + deepFreeze(value) + } + return Object.freeze(obj) +} + +// —— 合并:命名姿势覆盖同名内置 clip(验收 §2.4:调用方拿到合并后结果,无需自己 merge)—— +/** @type {Record} */ +const MERGED_CLIPS = deepFreeze({ ...CLIPS, ...POSES }) + +/** @type {Record} */ +const FROZEN_POSES = deepFreeze({ ...POSES }) + +/** @type {Record} */ +const FROZEN_RIGS = deepFreeze({ ...RIGS }) + +/** 有序轨道:['base','lower_body','arm_l','arm_r','head']。 @type {string[]} */ +export const TRACKS = deepFreeze([..._TRACKS]) + +/** 各 track 拥有的 bone 子集(互不相交)。 @type {Record} */ +export const TRACK_BONES = deepFreeze({ ..._TRACK_BONES }) + +/** 无指定动作时回落的默认 clip id。 @type {Record} */ +export const TRACK_DEFAULTS = deepFreeze({ ..._TRACK_DEFAULTS }) + +/** + * 取骨架定义。未知 id 返回 null(不抛)。 + * @param {string} id + * @returns {import('./types.js').RigDefinition|null} + */ +export function getRig(id) { + return FROZEN_RIGS[id] ?? null +} + +/** + * 取内置动作库全集(含命名姿势合并后结果)。 + * @returns {Record} + */ +export function getClips() { + return MERGED_CLIPS +} + +/** + * 取命名姿势集。 + * @returns {Record} + */ +export function getPoses() { + return FROZEN_POSES +} + +/** + * 取单个 clip(含命名姿势,姿势覆盖同名内置)。未知 id 返回 null。 + * @param {string} id + * @returns {import('./types.js').AnimationClip|null} + */ +export function getClip(id) { + return MERGED_CLIPS[id] ?? null +} diff --git a/packages/rig/src/poses.js b/packages/rig/src/poses.js new file mode 100644 index 0000000..e73b9e7 --- /dev/null +++ b/packages/rig/src/poses.js @@ -0,0 +1,26 @@ +// poses.js — 命名姿势集(移植自旧原型 custom-poses.js)。 +// +// 命名姿势与内置 clip 同形(可被同一求值器消费),可携带部件级 zOrder / mirror 覆盖。 +// 与内置 clip 同名时,姿势覆盖内置(合并规则在 index.js 完成,调用方拿到合并后结果)。 + +/** @type {Record} */ +export const POSES = { + 'arm_l.clasp_front': { + id: 'arm_l.clasp_front', track: 'arm_l', kind: 'pose', duration: 0, + bones: { upper_arm_l: [{ t: 0, drot: 34 }], lower_arm_l: [{ t: 0, drot: 12 }] }, + }, + 'arm_r.clasp_front': { + id: 'arm_r.clasp_front', track: 'arm_r', kind: 'pose', duration: 0, + bones: { upper_arm_r: [{ t: 0, drot: -34 }], lower_arm_r: [{ t: 0, drot: -12 }] }, + zOrder: { upper_arm_r: 28, lower_arm_r: 29 }, + }, + 'arm_l.古代女性站姿': { + id: 'arm_l.古代女性站姿', track: 'arm_l', kind: 'pose', duration: 0, + bones: { upper_arm_l: [{ t: 0, drot: 13 }], lower_arm_l: [{ t: 0, drot: -16 }] }, + }, + 'arm_r.古代女性站姿': { + id: 'arm_r.古代女性站姿', track: 'arm_r', kind: 'pose', duration: 0, + bones: {}, + zOrder: { lower_arm_r: 14 }, + }, +} diff --git a/packages/rig/src/rigs/humanoid-chibi.js b/packages/rig/src/rigs/humanoid-chibi.js new file mode 100644 index 0000000..197c32c --- /dev/null +++ b/packages/rig/src/rigs/humanoid-chibi.js @@ -0,0 +1,54 @@ +// humanoid-chibi.js — 分件人形「大头 chibi」骨架(RigDefinition)。 +// 移植自旧原型 client/js/stage/rig/humanoid-chibi.js(注册名 humanoid_parts,全角色实际使用的就是它)。 +// +// 与 humanoid_v1 的关键差异:这是按真实角色立绘(worker/lady 等,画布 454×750)调好骨距的骨架—— +// neck 距 torso -273、head -30、face -120、upper_arm 85/-235、shin 间距 130。worker/lady 的部件对齐 +// 数值(anchor/bonePos/scale)全部按本骨架的 setup-pose 调出,故角色必须绑本骨架而非 humanoid_v1, +// 否则骨距不符会把身体压扁、头显得过大(实测过的坑)。 +// +// 坐标系:+y 向下,角度「度」;root 在双脚之间贴地(y=0),身体向上为负 y。bones 顺序父先于子。 + +/** @type {import('../types.js').RigDefinition} */ +export const HUMANOID_CHIBI = { + id: 'humanoid_parts', + // rig 设计基准画布(部件图按此坐标系/尺度出图)。 + canvas: { width: 454, height: 750 }, + groundY: 0, + + bones: [ + { name: 'root', parent: null, pos: { x: 0, y: 0 }, rotation: 0 }, + { name: 'hip', parent: 'root', pos: { x: 0, y: -244 }, rotation: 0 }, + { name: 'torso', parent: 'hip', pos: { x: 0, y: 0 }, rotation: 0 }, + { name: 'neck', parent: 'torso', pos: { x: 0, y: -273 }, rotation: 0 }, + { name: 'head', parent: 'neck', pos: { x: 0, y: -30 }, rotation: 0 }, + // 表情子骨骼:挂在头下,跟随头一起动;表情图(眼/嘴)叠在脸区。 + { name: 'face', parent: 'head', pos: { x: 0, y: -120 }, rotation: 0 }, + + { name: 'upper_arm_l', parent: 'torso', pos: { x: 85, y: -235 }, rotation: 4 }, + { name: 'lower_arm_l', parent: 'upper_arm_l', pos: { x: 0, y: 120 }, rotation: 0 }, + { name: 'upper_arm_r', parent: 'torso', pos: { x: -85, y: -235 }, rotation: -4 }, + { name: 'lower_arm_r', parent: 'upper_arm_r', pos: { x: 0, y: 120 }, rotation: 0 }, + + { name: 'thigh_l', parent: 'hip', pos: { x: 30, y: 0 }, rotation: 0 }, + { name: 'shin_l', parent: 'thigh_l', pos: { x: 0, y: 130 }, rotation: 0 }, + { name: 'thigh_r', parent: 'hip', pos: { x: -30, y: 0 }, rotation: 0 }, + { name: 'shin_r', parent: 'thigh_r', pos: { x: 0, y: 130 }, rotation: 0 }, + ], + + // z-order(低=靠后)。右臂/右腿在身后,左臂/左腿在身前。 + slots: [ + { name: 'upper_arm_r', bone: 'upper_arm_r', zOrder: 2 }, + { name: 'lower_arm_r', bone: 'lower_arm_r', zOrder: 3 }, + { name: 'thigh_r', bone: 'thigh_r', zOrder: 4 }, + { name: 'shin_r', bone: 'shin_r', zOrder: 5 }, + { name: 'thigh_l', bone: 'thigh_l', zOrder: 6 }, + { name: 'shin_l', bone: 'shin_l', zOrder: 7 }, + { name: 'hip', bone: 'hip', zOrder: 9 }, + { name: 'torso', bone: 'torso', zOrder: 10 }, + { name: 'neck', bone: 'neck', zOrder: 11 }, + { name: 'head', bone: 'head', zOrder: 20 }, + { name: 'face', bone: 'face', zOrder: 25 }, + { name: 'upper_arm_l', bone: 'upper_arm_l', zOrder: 30 }, + { name: 'lower_arm_l', bone: 'lower_arm_l', zOrder: 31 }, + ], +} diff --git a/packages/rig/src/rigs/humanoid-v1.js b/packages/rig/src/rigs/humanoid-v1.js new file mode 100644 index 0000000..8fafb7a --- /dev/null +++ b/packages/rig/src/rigs/humanoid-v1.js @@ -0,0 +1,59 @@ +// humanoid-v1.js — 全角色共享的固定骨架定义(RigDefinition)。 +// 移植自旧原型 client/js/stage/rig/humanoid-v1.js(算法已验证,本处仅搬运成纯数据)。 +// +// 坐标系:Pixi 习惯,+y 向下,角度单位「度」(顺时针为正),在 rig 局部画布坐标里描述。 +// root 位于双脚之间、贴地面(y=0),身体向上为负 y。 +// +// 这是一份「与具体角色无关」的常量:所有角色复用同一棵骨骼树和同一批 slot。 +// 角色差异只体现在 CharacterSkin(每个 slot 贴哪张图 + 对齐元数据),属于 @drama/assets,不在本包。 +// +// 约定: +// - bone.pos 是相对父骨骼的局部坐标(setup pose) +// - bone.rotation 是 setup pose 的局部旋转(度) +// - slot.bone 指明该部件挂在哪根骨骼上(随骨骼变换) +// - slot.zOrder 是全局渲染层级(低=靠后),用于解决 2.5D 遮挡(左臂在身前/右臂在身后) +// +// bones 顺序保证「父先于子」:root 在最前,每根子骨都排在其父之后,core 可一次遍历求世界变换。 + +/** @type {import('../types.js').RigDefinition} */ +export const HUMANOID_V1 = { + id: 'humanoid_v1', + // rig 设计基准画布:AI 出部件图时按这个坐标系/尺度出图。 + canvas: { width: 400, height: 620 }, + + bones: [ + { name: 'root', parent: null, pos: { x: 0, y: 0 }, rotation: 0 }, + { name: 'hip', parent: 'root', pos: { x: 0, y: -250 }, rotation: 0 }, + { name: 'torso', parent: 'hip', pos: { x: 0, y: 0 }, rotation: 0 }, + { name: 'neck', parent: 'torso', pos: { x: 0, y: -150 }, rotation: 0 }, + { name: 'head', parent: 'neck', pos: { x: 0, y: -22 }, rotation: 0 }, + + // 左 = 角色自身左 = 屏幕右侧(facing 默认朝向观众)。这里 _l/_r 直接用屏幕语义命名以免混淆: + // upper_arm_l 渲染在屏幕左,upper_arm_r 渲染在屏幕右。 + { name: 'upper_arm_l', parent: 'torso', pos: { x: 48, y: -138 }, rotation: 6 }, + { name: 'lower_arm_l', parent: 'upper_arm_l', pos: { x: 0, y: 78 }, rotation: 0 }, + { name: 'upper_arm_r', parent: 'torso', pos: { x: -48, y: -138 }, rotation: -6 }, + { name: 'lower_arm_r', parent: 'upper_arm_r', pos: { x: 0, y: 78 }, rotation: 0 }, + + { name: 'thigh_l', parent: 'hip', pos: { x: 24, y: 0 }, rotation: 0 }, + { name: 'shin_l', parent: 'thigh_l', pos: { x: 0, y: 118 }, rotation: 0 }, + { name: 'thigh_r', parent: 'hip', pos: { x: -24, y: 0 }, rotation: 0 }, + { name: 'shin_r', parent: 'thigh_r', pos: { x: 0, y: 118 }, rotation: 0 }, + ], + + // 渲染层级(低 z 靠后)。右臂/右腿在身后,左臂/左腿在身前。 + slots: [ + { name: 'upper_arm_r', bone: 'upper_arm_r', zOrder: 1 }, + { name: 'lower_arm_r', bone: 'lower_arm_r', zOrder: 2 }, + { name: 'thigh_r', bone: 'thigh_r', zOrder: 3 }, + { name: 'shin_r', bone: 'shin_r', zOrder: 4 }, + { name: 'thigh_l', bone: 'thigh_l', zOrder: 5 }, + { name: 'shin_l', bone: 'shin_l', zOrder: 6 }, + { name: 'torso', bone: 'torso', zOrder: 10 }, + { name: 'neck', bone: 'neck', zOrder: 11 }, + { name: 'head', bone: 'head', zOrder: 20 }, + { name: 'face', bone: 'head', zOrder: 25 }, // 表情挂在 head 上,随头动,贴图独立换 + { name: 'upper_arm_l', bone: 'upper_arm_l', zOrder: 30 }, + { name: 'lower_arm_l', bone: 'lower_arm_l', zOrder: 31 }, + ], +} diff --git a/packages/rig/src/rigs/index.js b/packages/rig/src/rigs/index.js new file mode 100644 index 0000000..94a3e70 --- /dev/null +++ b/packages/rig/src/rigs/index.js @@ -0,0 +1,13 @@ +// rigs/index.js — 骨架注册表。加新角色骨架 = 加一个文件 + 在此注册一行(开闭原则)。 + +import { HUMANOID_V1 } from './humanoid-v1.js' +import { HUMANOID_CHIBI } from './humanoid-chibi.js' + +/** @type {Record} */ +export const RIGS = { + humanoid_v1: HUMANOID_V1, + // 真实角色(worker/lady 等)实际使用的大头 chibi 骨架。注册名沿用原项目的 humanoid_parts, + // 使角色 skin 的 rigId 与其对齐数值天然吻合(别名 humanoid_chibi 指向同一骨架)。 + humanoid_parts: HUMANOID_CHIBI, + humanoid_chibi: HUMANOID_CHIBI, +} diff --git a/packages/rig/src/types.js b/packages/rig/src/types.js new file mode 100644 index 0000000..ccd2eb3 --- /dev/null +++ b/packages/rig/src/types.js @@ -0,0 +1,53 @@ +// types.js — JSDoc 形状契约集中地(@drama/core 与 @drama/render-pixi 共同消费)。 +// +// 本文件只含 @typedef,无运行时导出。是 §3 公共面 typedef 的单一事实来源。 +// 关键语义(移植自旧原型,core 依赖、本包负责保证数据满足): +// - 关键帧值是「相对 setup pose 的增量」,未给的属性回落默认(drot/dx/dy=0、dscale=1)。 +// - 各 track 拥有互不相交的 bone 子集,使「走+说+指」并行可由 core 的「后写覆盖」实现。 + +/** + * @typedef {Object} RigDefinition + * @property {string} id + * @property {{width:number,height:number}} canvas 设计基准画布(AI 出图坐标系) + * @property {Bone[]} bones 父先于子有序(本包保证拓扑有序:root 在前,子骨在其父之后) + * @property {Slot[]} slots 部件挂点 + 渲染层级 + */ + +/** + * @typedef {Object} Bone + * @property {string} name + * @property {string|null} parent 根骨 parent=null + * @property {{x:number,y:number}} pos 相对父骨的局部坐标(setup pose) + * @property {number} rotation setup pose 局部旋转(度,顺时针正) + */ + +/** + * @typedef {Object} Slot + * @property {string} name + * @property {string} bone 挂在哪根骨上(随骨变换) + * @property {number} zOrder 全局渲染层级(低=靠后),解决 2.5D 遮挡 + */ + +/** + * @typedef {Object} AnimationClip + * @property {string} id + * @property {'base'|'lower_body'|'arm_l'|'arm_r'|'head'} track + * @property {'pose'|'loop'|'oneshot'} kind + * @property {number} duration ms;pose 为 0 + * @property {Record} bones boneName -> 关键帧序列 + * @property {Record=} zOrder 部件级层级覆盖(姿势可抬前/压后) + * @property {Record=} mirror 部件级镜像覆盖 + */ + +/** + * @typedef {Object} Keyframe + * @property {number} t 0..1 归一化时间 + * @property {number=} drot 相对 setup 的旋转增量(度) + * @property {number=} dx 相对 setup 的位移增量(rig 局部像素) + * @property {number=} dy + * @property {number=} dscale 缩放乘数(默认 1) + */ + +/** @typedef {AnimationClip} NamedPose 命名姿势与 clip 同形,kind 通常为 'pose' */ + +export {} diff --git a/packages/server-kit/package.json b/packages/server-kit/package.json new file mode 100644 index 0000000..8e18932 --- /dev/null +++ b/packages/server-kit/package.json @@ -0,0 +1,21 @@ +{ + "name": "@drama/server-kit", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@drama/contracts": "workspace:*", + "@drama/observability": "workspace:*", + "valibot": "^1.4.1" + }, + "devDependencies": { + "fastify": "^5.1.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/server-kit/src/errors.js b/packages/server-kit/src/errors.js new file mode 100644 index 0000000..e5d67fa --- /dev/null +++ b/packages/server-kit/src/errors.js @@ -0,0 +1,129 @@ +/** + * `registerErrorHandler` —— 全局错误成形:Valibot 校验失败 → `400` + 结构化 issues;业务 + * 错误按 `err.statusCode` 或 `err.code`→HTTP 状态表映射;其余 → `500`。 + * + * 错误码→HTTP 状态是唯一一张表(`CODE_STATUS`),两个 server 合并去重后共用。业务错误 + * (`status < 500`)若携带 `err.details` 且为对象则透传(vg 版行为,保留)。 + */ + +import { ValiError, parse as vparse } from 'valibot' +import { ApiError } from '@drama/contracts/platform' + +/** + * 错误码 → HTTP 状态单表(drama + visual-game 两 server 合并去重)。 + * 无显式 `err.statusCode` 时按 `err.code` 推断,避免业务拒绝退化成 500。 + * @type {Record} + */ +export const CODE_STATUS = { + // 认证 / 授权 + INVALID_CREDENTIALS: 401, + USER_NOT_FOUND: 401, + UNAUTHORIZED: 401, + TOKEN_EXPIRED: 401, + TOKEN_INVALID: 401, + ERR_JWT_EXPIRED: 401, + REFRESH_REUSED: 401, + // 冲突 + USER_EXISTS: 409, + EMAIL_TAKEN: 409, + CONFLICT: 409, + SAVE_SLOT_CONFLICT: 409, + // 限流 + RATE_LIMITED: 429, + TOO_MANY_ATTEMPTS: 429, + // 通用 + FORBIDDEN: 403, + NOT_FOUND: 404, + BAD_REQUEST: 400, + CONTENT_FILTER: 400, + AI_OUTPUT_INVALID: 502, + // 校验 / 验证码 + EMAIL_VERIFICATION_REQUIRED: 400, + CODE_INVALID: 400, + CODE_EXPIRED: 400, + // 计费 / 支付 + INSUFFICIENT_BALANCE: 402, + PRODUCT_NOT_FOUND: 404, + RULE_NOT_FOUND: 404, + ORDER_NOT_FOUND: 404, + IAP_NOT_CONFIGURED: 400, + IAP_VERIFY_FAILED: 400, + STRIPE_NOT_CONFIGURED: 400, + STRIPE_ERROR: 502, + WEBHOOK_SIGNATURE_INVALID: 400, + // waiting list 预期业务失败 + 'waitlist.invalid_input': 400, + 'waitlist.invalid_email': 400, + 'waitlist.invalid_source': 400, + 'waitlist.invalid_status': 400, + 'waitlist.invalid_limit': 400, + 'waitlist.invalid_cursor': 400, + 'waitlist.consent_required': 400, + 'waitlist.invite_expired': 400, + 'waitlist.invite_email_mismatch': 400, + 'waitlist.invite_not_usable': 400, + 'waitlist.account_already_registered': 409, + 'waitlist.duplicate_email': 409, + 'waitlist.invalid_status_transition': 409, + 'waitlist.entry_not_found': 404, + 'waitlist.delivery_not_found': 404, + 'waitlist.invite_not_found': 404, + 'waitlist.invite_delivery_failed': 502, +} + +/** + * @param {import('fastify').FastifyInstance} app + */ +export function registerErrorHandler(app) { + app.setErrorHandler((/** @type {any} */ err, request, reply) => { + if (err instanceof ValiError) { + reply.code(400) + return reply.send( + vparse(ApiError, { + error: { + code: 'validation_error', + message: 'Request validation failed', + retryable: false, + details: { issues: err.issues.map((i) => ({ path: pathOf(i), message: i.message })) }, + }, + }), + ) + } + + const status = + typeof err.statusCode === 'number' + ? err.statusCode + : (err.code && CODE_STATUS[err.code]) || 500 + const requestId = request?.id + const traceId = request?.ctx?.traceId + const details = status < 500 && err.details && typeof err.details === 'object' ? err.details : {} + const traceDetails = { + ...details, + ...(traceId ? { traceId } : {}), + ...(requestId ? { requestId } : {}), + } + if (request && request.log_ && typeof request.log_.error === 'function') { + request.log_.error('request failed', { err, status, traceId, requestId }) + } + reply.code(status) + return reply.send( + vparse(ApiError, { + error: { + code: err.code && typeof err.code === 'string' ? err.code : 'internal_error', + message: status >= 500 ? 'Internal server error' : String(err.message || 'Error'), + retryable: status >= 500, + ...(Object.keys(traceDetails).length > 0 ? { details: traceDetails } : {}), + }, + }), + ) + }) +} + +/** + * @param {any} issue + * @returns {string} + */ +function pathOf(issue) { + if (!issue.path) return '' + return issue.path.map((/** @type {any} */ p) => p.key).join('.') +} diff --git a/packages/server-kit/src/index.js b/packages/server-kit/src/index.js new file mode 100644 index 0000000..7eb7b08 --- /dev/null +++ b/packages/server-kit/src/index.js @@ -0,0 +1,13 @@ +/** + * `@drama/server-kit` —— 两个 server 共用的装配骨架(fastify 横切件)。 + * + * buildApp 骨架的可复用零件:rawBody parser、trace hook、错误成形 + 错误码→HTTP 状态单表、 + * AI 端点限流 preHandler 工厂。每个 server 的 `buildApp` 组合这些零件 + 各自挂路由。 + * + * 边界纪律:包内不读 `process.env`、不建连接;logger / limiter 由 composition root 注入。 + */ + +export { registerRawBodyParser } from './raw-body.js' +export { registerTrace } from './trace.js' +export { registerErrorHandler, CODE_STATUS } from './errors.js' +export { createAiLimit } from './ratelimit.js' diff --git a/packages/server-kit/src/ratelimit.js b/packages/server-kit/src/ratelimit.js new file mode 100644 index 0000000..c511af3 --- /dev/null +++ b/packages/server-kit/src/ratelimit.js @@ -0,0 +1,55 @@ +/** + * `createAiLimit` —— 给烧钱的 AI 端点(LLM 续写 / TTS 合成 / 生图)挂 per-IP / 匿名基础 + * 限流的 Fastify preHandler 工厂,超限返结构化 `429`。 + * + * 只做「按端点类别构造 RateQuery → consume → 超限成形 429」,桶数学 / store 在 @drama/ratelimit + * 内。subject 取匿名 device-id(`x-device-id`)优先,否则回退客户端 IP(per-IP 防脚本薅穿)。 + */ + +import { parse } from 'valibot' +import { ApiError } from '@drama/contracts/platform' + +/** + * @param {{ limiter: { consume: Function } }} deps @drama/ratelimit 的 limiter 实例 + * @returns {(endpoint: 'llm'|'tts'|'image') => import('fastify').preHandlerHookHandler} + */ +export function createAiLimit({ limiter }) { + if (!limiter || typeof limiter.consume !== 'function') { + throw new Error('createAiLimit: 缺少注入的 limiter(需实现 consume)') + } + + return function aiLimit(endpoint) { + return async function preHandler(request, reply) { + const subject = subjectOf(request) + const decision = await limiter.consume(request.ctx, { endpoint, subject }) + if (!decision.allowed) { + const retryAfterSec = Math.ceil(decision.retryAfterMs / 1000) + reply + .code(429) + .header('retry-after', String(retryAfterSec)) + .header('x-ratelimit-remaining', String(Math.max(0, decision.remaining))) + return reply.send( + parse(ApiError, { + error: { + code: 'rate_limited', + message: `Too many ${endpoint} requests, retry after ${retryAfterSec}s`, + retryable: true, + details: { endpoint, retryAfterMs: decision.retryAfterMs, ruleId: decision.ruleId }, + }, + }), + ) + } + reply.header('x-ratelimit-remaining', String(Math.max(0, decision.remaining))) + } + } +} + +/** + * @param {import('fastify').FastifyRequest} request + * @returns {string} + */ +function subjectOf(request) { + const dev = request.headers['x-device-id'] + if (typeof dev === 'string' && dev) return `anon:${dev}` + return `ip:${request.ip || 'unknown'}` +} diff --git a/packages/server-kit/src/raw-body.js b/packages/server-kit/src/raw-body.js new file mode 100644 index 0000000..6614754 --- /dev/null +++ b/packages/server-kit/src/raw-body.js @@ -0,0 +1,26 @@ +/** + * `registerRawBodyParser` —— JSON content-type parser,给标记了 `config.rawBody` 的路由 + * 保留原始字节(Stripe webhook 验签要求未改写的 body),其余路由正常 `JSON.parse`。 + * + * 横切,无业务:两个 server 的装配骨架共用一份。 + */ + +/** + * @param {import('fastify').FastifyInstance} app + */ +export function registerRawBodyParser(app) { + app.addContentTypeParser( + 'application/json', + { parseAs: 'buffer' }, + (request, /** @type {Buffer} */ body, done) => { + if (request.routeOptions?.config?.rawBody) request.rawBody = body + try { + const text = body.length ? body.toString('utf8') : '' + done(null, text === '' ? undefined : JSON.parse(text)) + } catch (err) { + /** @type {any} */ (err).statusCode = 400 + done(/** @type {Error} */ (err), undefined) + } + }, + ) +} diff --git a/packages/server-kit/src/trace.js b/packages/server-kit/src/trace.js new file mode 100644 index 0000000..d8e9c60 --- /dev/null +++ b/packages/server-kit/src/trace.js @@ -0,0 +1,25 @@ +/** + * `registerTrace` —— 每请求生成 / 续传 `trace_id` 贯穿入口→服务→DB,回带响应头。 + * + * 入站带 `x-trace-id` 则续传(跨服务同一 trace),否则新建。把 `ctx` 与绑定该 trace 的 + * `log_` 挂到 `request` 供路由 / 服务取用;响应统一回带 `x-trace-id`。纯横切,无业务。 + */ + +import { createRequestContext } from '@drama/observability' + +/** + * @param {import('fastify').FastifyInstance} app + * @param {{ logger: { child: Function } }} deps 根 logger(@drama/observability createLogger 产物) + */ +export function registerTrace(app, { logger }) { + app.addHook('onRequest', async (request, reply) => { + const inbound = request.headers['x-trace-id'] + const ctx = createRequestContext({ + ...(typeof inbound === 'string' && inbound ? { traceId: inbound } : {}), + fields: { route: request.url, method: request.method }, + }) + request.ctx = ctx + request.log_ = logger.child(ctx) + reply.header('x-trace-id', ctx.traceId) + }) +} diff --git a/packages/server-kit/test/server-kit.test.js b/packages/server-kit/test/server-kit.test.js new file mode 100644 index 0000000..530101c --- /dev/null +++ b/packages/server-kit/test/server-kit.test.js @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from 'vitest' +import Fastify from 'fastify' +import { + registerRawBodyParser, + registerTrace, + registerErrorHandler, + createAiLimit, + CODE_STATUS, +} from '../src/index.js' + +function silentLogger() { + const child = () => ({ error: vi.fn(), info: vi.fn() }) + return { child, info: vi.fn() } +} + +function build({ limiter } = {}) { + const app = Fastify({ logger: false }) + registerRawBodyParser(app) + registerTrace(app, { logger: silentLogger() }) + registerErrorHandler(app) + if (limiter) { + const aiLimit = createAiLimit({ limiter }) + app.post('/llm', { preHandler: aiLimit('llm') }, async () => ({ ok: true })) + } + return app +} + +describe('server-kit 装配骨架', () => { + it('trace hook 回带 x-trace-id,rawBody 标记路由拿到原始字节', async () => { + const app = build() + let seenRaw = null + app.post('/hook', { config: { rawBody: true } }, async (request) => { + seenRaw = request.rawBody + return { ok: true } + }) + const res = await app.inject({ method: 'POST', url: '/hook', payload: { a: 1 } }) + expect(res.headers['x-trace-id']).toBeTruthy() + expect(Buffer.isBuffer(seenRaw)).toBe(true) + expect(JSON.parse(seenRaw.toString('utf8'))).toEqual({ a: 1 }) + }) + + it('错误码 → HTTP 状态映射,且 status<500 透传 details', async () => { + const app = build() + app.get('/boom', async () => { + const err = new Error('nope') + err.code = 'INSUFFICIENT_BALANCE' + err.details = { needed: 5 } + throw err + }) + const res = await app.inject({ method: 'GET', url: '/boom' }) + expect(res.statusCode).toBe(402) + const body = res.json() + expect(body.error.code).toBe('INSUFFICIENT_BALANCE') + expect(body.error.details.needed).toBe(5) + expect(body.error.details.requestId).toBeTruthy() + expect(body.error.details.traceId).toBe(res.headers['x-trace-id']) + expect(CODE_STATUS.INSUFFICIENT_BALANCE).toBe(402) + }) + + it('waiting list 预期业务错误不退化成 500', async () => { + const app = build() + app.get('/waitlist-expired', async () => { + const err = new Error('Invite token expired') + err.code = 'waitlist.invite_expired' + throw err + }) + const res = await app.inject({ method: 'GET', url: '/waitlist-expired' }) + expect(res.statusCode).toBe(400) + expect(res.json().error).toMatchObject({ + code: 'waitlist.invite_expired', + message: 'Invite token expired', + retryable: false, + }) + expect(CODE_STATUS['waitlist.invite_not_found']).toBe(404) + }) + + it('waiting list 邀请投递失败映射为可识别的上游错误', async () => { + const app = build() + app.post('/waitlist-delivery', async () => { + const err = new Error('Waitlist invite delivery failed') + err.code = 'waitlist.invite_delivery_failed' + throw err + }) + const res = await app.inject({ method: 'POST', url: '/waitlist-delivery' }) + expect(res.statusCode).toBe(502) + expect(res.json().error).toMatchObject({ + code: 'waitlist.invite_delivery_failed', + message: 'Internal server error', + retryable: true, + }) + expect(CODE_STATUS['waitlist.invite_delivery_failed']).toBe(502) + }) + + it('服务器错误按 trace 记录结构化日志', async () => { + const error = vi.fn() + const logger = { child: () => ({ error }), info: vi.fn() } + const app = Fastify({ logger: false }) + registerTrace(app, { logger }) + registerErrorHandler(app) + app.get('/logged-error', async () => { + throw new Error('logged failure') + }) + + const res = await app.inject({ method: 'GET', url: '/logged-error' }) + const body = res.json() + expect(error).toHaveBeenCalledWith('request failed', expect.objectContaining({ + err: expect.any(Error), + status: 500, + traceId: res.headers['x-trace-id'], + requestId: body.error.details.requestId, + })) + }) + + it('500 类错误不泄漏内部信息、不透传 details', async () => { + const app = build() + app.get('/oops', async () => { + const err = new Error('secret stack') + err.details = { internal: true } + throw err + }) + const res = await app.inject({ method: 'GET', url: '/oops' }) + expect(res.statusCode).toBe(500) + const body = res.json() + expect(body.error.message).toBe('Internal server error') + expect(body.error.details.internal).toBeUndefined() + expect(body.error.details.requestId).toBeTruthy() + expect(body.error.details.traceId).toBe(res.headers['x-trace-id']) + }) + + it('aiLimit 超限返 429 + retry-after', async () => { + const limiter = { consume: vi.fn(async () => ({ allowed: false, remaining: 0, retryAfterMs: 5000, ruleId: 'llm:ip' })) } + const app = build({ limiter }) + const res = await app.inject({ method: 'POST', url: '/llm', payload: {} }) + expect(res.statusCode).toBe(429) + expect(res.headers['retry-after']).toBe('5') + expect(res.json().error.code).toBe('rate_limited') + }) +}) diff --git a/packages/service-call/package.json b/packages/service-call/package.json new file mode 100644 index 0000000..02d529a --- /dev/null +++ b/packages/service-call/package.json @@ -0,0 +1,12 @@ +{ + "name": "@drama/service-call", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + } +} diff --git a/packages/service-call/src/combinators.js b/packages/service-call/src/combinators.js new file mode 100644 index 0000000..de569e8 --- /dev/null +++ b/packages/service-call/src/combinators.js @@ -0,0 +1,146 @@ +/** + * 高阶组合器 —— 把「一次外部服务调用」包成带重试/降级的同形函数。 + * + * 本包不认识 key、凭据、厂商、审计:候选就是 `async fn`,key 属于调用方闭包。 + * 唯一判定契约是结构化错误上的 `retryable === true`(duck-type,LLMError/ImageError/ + * TtsError/VideoError 天然满足);判定可经 `isRetryable` 覆盖,缺省不读 message、不判状态码。 + * 嵌套顺序即编排语义:`withFallback(fns.map(withRetry))` = 每候选重试耗尽再降级。 + */ + +import { DEFAULT_RETRY_POLICY, shouldRetry, backoffDelayMs } from './retry.js' + +/** + * 缺省重试/降级判定:结构化错误的 retryable 布尔。 + * 普通 Error / null / 缺字段一律不可重试——未归一的错误宁可上抛也不重试。 + * @param {unknown} err + * @returns {boolean} + */ +export function isRetryableError(err) { + return /** @type {{ retryable?: unknown } | null | undefined} */ (err)?.retryable === true +} + +/** + * withRetry —— 单函数重试(指数退避;错误带数值 `retryAfterMs` 时优先采用并封顶)。 + * 返回与 `fn` 同形的函数,参数原样透传。 + * @template {(...args: any[]) => Promise} F + * @param {F} fn + * @param {object} [cfg] + * @param {import('./retry.js').RetryPolicy} [cfg.policy] + * @param {(err: unknown) => boolean} [cfg.isRetryable] + * @param {AbortSignal} [cfg.signal] 中止后不再发起新 attempt,退避等待中被中止立即抛。 + * @param {(event: { attempt: number, error: unknown }) => void} [cfg.observe] 每次失败回调;attempt 从 1 起算。 + * @param {(ms: number, signal?: AbortSignal) => Promise} [cfg.sleep] 可注入(测试免等真实退避)。 + * @returns {F} + */ +export function withRetry(fn, { + policy = DEFAULT_RETRY_POLICY, + isRetryable = isRetryableError, + signal, + observe, + sleep = abortableSleep, +} = {}) { + if (typeof fn !== 'function') throw new TypeError('withRetry: fn must be a function') + return /** @type {F} */ (async (...args) => { + let attempt = 0 + for (;;) { + if (signal?.aborted) throw abortReason(signal) + try { + return await fn(...args) + } catch (err) { + attempt += 1 + observe?.({ attempt, error: err }) + if (!shouldRetry(attempt, isRetryable(err), policy)) throw err + await sleep(backoffDelayMs(attempt, policy, retryAfterMsOf(err)), signal) + } + } + }) +} + +/** + * withFallback —— 候选按序降级:可重试错误滑到下一候选,不可重试立即上抛, + * 链尾无论何因原样上抛(不吞、不替换)。返回与候选同形的函数,参数原样透传给每个被尝试的候选。 + * @template {(...args: any[]) => Promise} F + * @param {F[]} fns 非空候选数组,索引 0 为主、末位为最后兜底。 + * @param {object} [cfg] + * @param {(err: unknown) => boolean} [cfg.isRetryable] + * @param {(event: { candidateIndex: number, error: unknown }) => void} [cfg.observe] 每个候选失败时回调(含链尾)。 + * @returns {F} + */ +export function withFallback(fns, { isRetryable = isRetryableError, observe } = {}) { + if (!Array.isArray(fns) || fns.length < 1) { + throw new TypeError('withFallback: fns must be a non-empty array') + } + for (const fn of fns) { + if (typeof fn !== 'function') throw new TypeError('withFallback: every candidate must be a function') + } + return /** @type {F} */ (async (...args) => { + for (let i = 0; ; i++) { + try { + return await fns[i](...args) + } catch (err) { + observe?.({ candidateIndex: i, error: err }) + if (i === fns.length - 1 || !isRetryable(err)) throw err + } + } + }) +} + +/** + * bindEach —— 一个函数 × N 份绑定(key、endpoint、账号……)展开成候选数组, + * 绑定作为最后一个参数传入。配合 withFallback 表达「一个函数几把 key」。 + * @template {(...args: any[]) => Promise} F + * @template B + * @param {F} fn + * @param {B[]} bindings 非空。 + * @returns {Array<(...args: any[]) => Promise>} + */ +export function bindEach(fn, bindings) { + if (typeof fn !== 'function') throw new TypeError('bindEach: fn must be a function') + if (!Array.isArray(bindings) || bindings.length < 1) { + throw new TypeError('bindEach: bindings must be a non-empty array') + } + return bindings.map((binding) => (...args) => fn(...args, binding)) +} + +/** + * 错误上的显式等待提示(如 429 的 Retry-After、keyrouter 冷却窗口)。 + * @param {unknown} err + * @returns {number | undefined} + */ +function retryAfterMsOf(err) { + const v = /** @type {{ retryAfterMs?: unknown } | null | undefined} */ (err)?.retryAfterMs + return typeof v === 'number' ? v : undefined +} + +/** + * 可被 abort 的 sleep。 + * @param {number} ms + * @param {AbortSignal} [signal] + * @returns {Promise} + */ +function abortableSleep(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortReason(signal)) + return + } + const t = setTimeout(() => { + signal?.removeEventListener?.('abort', onAbort) + resolve() + }, ms) + function onAbort() { + clearTimeout(t) + reject(abortReason(signal)) + } + signal?.addEventListener?.('abort', onAbort, { once: true }) + }) +} + +/** + * 中止原因:优先透传 signal.reason,缺省造 AbortError。 + * @param {AbortSignal} [signal] + * @returns {unknown} + */ +function abortReason(signal) { + return signal?.reason ?? Object.assign(new Error('aborted'), { name: 'AbortError' }) +} diff --git a/packages/service-call/src/http.js b/packages/service-call/src/http.js new file mode 100644 index 0000000..834ff02 --- /dev/null +++ b/packages/service-call/src/http.js @@ -0,0 +1,52 @@ +/** + * HTTP / 异常归一纯函数 —— 服务无关的错误分类,「什么错算什么类」的唯一来源。 + * + * 零副作用、零依赖。`@drama/llm` 的 map-error 与各服务的 execute 都从这里取分类, + * 保证 429/5xx/超时/abort 的判定全仓只有一处。 + */ + +/** @typedef {import('./retry.js').CallErrorReason} CallErrorReason */ + +/** + * @typedef {object} MappedError + * @property {CallErrorReason} reason + * @property {boolean} retryable + */ + +/** + * HTTP 状态码 → reason/retryable。 + * 401/403 = auth(不重试);429 = rateLimit(重试);408/5xx = transient(重试); + * 4xx 其余 = badRequest(不重试)。 + * @param {number} status + * @returns {MappedError} + */ +export function mapHttpStatus(status) { + if (status === 401 || status === 403) return { reason: 'auth', retryable: false } + if (status === 429) return { reason: 'rateLimit', retryable: true } + if (status === 408 || status >= 500) return { reason: 'transient', retryable: true } + if (status >= 400) return { reason: 'badRequest', retryable: false } + return { reason: 'unknown', retryable: false } +} + +/** + * 判定一个抛出的异常是否为内部超时(每 attempt 计时器的 abort reason)。 + * 内部超时用 DOMException('...', 'TimeoutError') 作 abort reason,与外部 abort(AbortError)互斥。 + * @param {unknown} err + * @returns {boolean} + */ +export function isTimeoutError(err) { + if (!err || typeof err !== 'object') return false + return /** @type {{ name?:string }} */ (err).name === 'TimeoutError' +} + +/** + * 判定一个抛出的异常是否为 AbortError(外部调用方中断)。 + * DOMException name==='AbortError' 或 code==='ABORT_ERR'。 + * @param {unknown} err + * @returns {boolean} + */ +export function isAbortError(err) { + if (!err || typeof err !== 'object') return false + const e = /** @type {{ name?:string, code?:string }} */ (err) + return e.name === 'AbortError' || e.code === 'ABORT_ERR' +} diff --git a/packages/service-call/src/index.js b/packages/service-call/src/index.js new file mode 100644 index 0000000..caf5aa3 --- /dev/null +++ b/packages/service-call/src/index.js @@ -0,0 +1,15 @@ +/** + * @drama/service-call —— 外部服务调用的稳定性组合器(唯一对外入口)。 + * + * 纯高阶函数:输入若干个同形 `async fn`,输出一个同形 `fn`。零 IO、零 DB、零依赖。 + * 包不认识 key/凭据/厂商/审计——候选数组怎么构造、key 从哪来(env 常量或 keyrouter 池)、 + * observe 落不落审计,全部属于调用方(装配层)。 + */ + +export { DEFAULT_RETRY_POLICY, isRetryableReason, shouldRetry, backoffDelayMs } from './retry.js' +export { mapHttpStatus, isTimeoutError, isAbortError } from './http.js' +export { isRetryableError, withRetry, withFallback, bindEach } from './combinators.js' + +/** @typedef {import('./retry.js').RetryPolicy} RetryPolicy */ +/** @typedef {import('./retry.js').CallErrorReason} CallErrorReason */ +/** @typedef {import('./http.js').MappedError} MappedError */ diff --git a/packages/service-call/src/retry.js b/packages/service-call/src/retry.js new file mode 100644 index 0000000..687c9ee --- /dev/null +++ b/packages/service-call/src/retry.js @@ -0,0 +1,63 @@ +/** + * 重试纯函数 —— 瞬态错误判定 + 指数退避计算。 + * + * 零副作用、零依赖:被本包 `withRetry` 与 `@drama/llm` 的网关编排层调用,可独立单测。 + * 不在此处 sleep、不读时钟、不发请求;只做判定与算术。 + */ + +/** @typedef {'auth'|'rateLimit'|'transient'|'badRequest'|'timeout'|'aborted'|'unknown'} CallErrorReason */ + +/** + * 默认重试策略。调用方可在包装时覆盖。 + * @typedef {object} RetryPolicy + * @property {number} maxRetries 最大重试次数(不含首次尝试)。 + * @property {number} baseDelayMs 首次退避基数。 + * @property {number} maxDelayMs 退避上限。 + * @property {number} factor 指数因子。 + */ + +/** @type {RetryPolicy} */ +export const DEFAULT_RETRY_POLICY = { + maxRetries: 2, + baseDelayMs: 500, + maxDelayMs: 8000, + factor: 2, +} + +/** + * 该 reason 是否属于「可重试的瞬态类」。 + * rateLimit / transient / timeout = true;auth / badRequest / aborted / unknown = false。 + * @param {CallErrorReason} reason + * @returns {boolean} + */ +export function isRetryableReason(reason) { + return reason === 'rateLimit' || reason === 'transient' || reason === 'timeout' +} + +/** + * 在给定策略下,第 `attempt` 次失败后是否还应再试。 + * @param {number} attempt 已失败的次数(首次失败时为 1)。 + * @param {boolean} retryable 该错误是否可重试。 + * @param {RetryPolicy} [policy] + * @returns {boolean} + */ +export function shouldRetry(attempt, retryable, policy = DEFAULT_RETRY_POLICY) { + if (!retryable) return false + return attempt <= policy.maxRetries +} + +/** + * 第 `attempt` 次重试前的退避毫秒数(指数退避 + 抖动可选)。 + * attempt 从 1 起算(第一次重试)。`retryAfterMs` 若由 429 头给出则优先采用。 + * @param {number} attempt + * @param {RetryPolicy} [policy] + * @param {number} [retryAfterMs] 厂商显式给出的等待时间(如 Retry-After)。 + * @returns {number} + */ +export function backoffDelayMs(attempt, policy = DEFAULT_RETRY_POLICY, retryAfterMs) { + if (typeof retryAfterMs === 'number' && retryAfterMs >= 0) { + return Math.min(retryAfterMs, policy.maxDelayMs) + } + const raw = policy.baseDelayMs * Math.pow(policy.factor, attempt - 1) + return Math.min(raw, policy.maxDelayMs) +} diff --git a/packages/service-call/test/retry.test.js b/packages/service-call/test/retry.test.js new file mode 100644 index 0000000..5888911 --- /dev/null +++ b/packages/service-call/test/retry.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { + DEFAULT_RETRY_POLICY, + isRetryableReason, + shouldRetry, + backoffDelayMs, +} from '../src/retry.js' + +describe('isRetryableReason', () => { + it('rateLimit / transient / timeout = true', () => { + expect(isRetryableReason('rateLimit')).toBe(true) + expect(isRetryableReason('transient')).toBe(true) + expect(isRetryableReason('timeout')).toBe(true) + }) + it('auth / badRequest / aborted / unknown = false', () => { + expect(isRetryableReason('auth')).toBe(false) + expect(isRetryableReason('badRequest')).toBe(false) + expect(isRetryableReason('aborted')).toBe(false) + expect(isRetryableReason('unknown')).toBe(false) + }) +}) + +describe('shouldRetry', () => { + const p = { maxRetries: 2, baseDelayMs: 1, maxDelayMs: 10, factor: 2 } + it('never retries non-retryable', () => { + expect(shouldRetry(1, false, p)).toBe(false) + }) + it('retries up to maxRetries then stops', () => { + expect(shouldRetry(1, true, p)).toBe(true) + expect(shouldRetry(2, true, p)).toBe(true) + expect(shouldRetry(3, true, p)).toBe(false) + }) + it('uses DEFAULT_RETRY_POLICY when omitted', () => { + expect(shouldRetry(1, true)).toBe(true) + expect(shouldRetry(DEFAULT_RETRY_POLICY.maxRetries + 1, true)).toBe(false) + }) +}) + +describe('backoffDelayMs', () => { + const p = { maxRetries: 5, baseDelayMs: 100, maxDelayMs: 1000, factor: 2 } + it('grows exponentially from attempt 1', () => { + expect(backoffDelayMs(1, p)).toBe(100) + expect(backoffDelayMs(2, p)).toBe(200) + expect(backoffDelayMs(3, p)).toBe(400) + }) + it('caps at maxDelayMs', () => { + expect(backoffDelayMs(10, p)).toBe(1000) + }) + it('prefers explicit retryAfterMs but still capped', () => { + expect(backoffDelayMs(1, p, 250)).toBe(250) + expect(backoffDelayMs(1, p, 99999)).toBe(1000) + }) +}) diff --git a/packages/ui/dev/UiGallery.vue b/packages/ui/dev/UiGallery.vue new file mode 100644 index 0000000..7cb9482 --- /dev/null +++ b/packages/ui/dev/UiGallery.vue @@ -0,0 +1,646 @@ + + +