diff --git a/.env.example b/.env.example index cef5f848..73beebcc 100644 --- a/.env.example +++ b/.env.example @@ -20,13 +20,69 @@ NODE_SERVER_TARGET="http://127.0.0.1:8081" # Local Caddy upstream target used for dist-based testing. CADDY_API_UPSTREAM="http://127.0.0.1:8081" -# Node backend SQLite database path. -SQLITE_PATH="" +# Editor and asset tool APIs. Defaults are enabled outside production and +# disabled in production unless explicitly enabled. +EDITOR_API_ENABLED="true" +ASSETS_API_ENABLED="true" + +# Node backend PostgreSQL connection string. +# Runtime persistence now uses PostgreSQL as the only formal backend baseline. +DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/genarrative" # Node backend JWT settings. JWT_SECRET="CHANGE_ME_FOR_PRODUCTION" -# 当前默认签发永久 JWT,此字段暂未使用,后续如果恢复有限期 token 再启用。 -JWT_EXPIRES_IN="7d" +# Access token 有效期。 +JWT_EXPIRES_IN="2h" + +# Refresh session 配置。 +AUTH_REFRESH_COOKIE_NAME="genarrative_refresh_session" +AUTH_REFRESH_SESSION_TTL_DAYS="30" +AUTH_REFRESH_COOKIE_PATH="/api/auth" +AUTH_REFRESH_COOKIE_SAME_SITE="Lax" +AUTH_REFRESH_COOKIE_SECURE="false" + +# 手机号验证码登录配置(阿里云 PNVS)。 +# 正式环境请改成你自己的 AccessKey 和短信签名/模板。 +SMS_AUTH_ENABLED="false" +SMS_AUTH_PROVIDER="aliyun" +ALIYUN_SMS_ACCESS_KEY_ID="" +ALIYUN_SMS_ACCESS_KEY_SECRET="" +ALIYUN_SMS_ENDPOINT="dypnsapi.aliyuncs.com" +# 默认使用阿里云文档中的赠送测试签名/模板,可按控制台实际配置覆盖。 +ALIYUN_SMS_SIGN_NAME="速通互联验证码" +ALIYUN_SMS_TEMPLATE_CODE="100001" +ALIYUN_SMS_TEMPLATE_PARAM_KEY="code" +ALIYUN_SMS_COUNTRY_CODE="86" +ALIYUN_SMS_CODE_LENGTH="6" +ALIYUN_SMS_CODE_TYPE="1" +ALIYUN_SMS_VALID_TIME_SECONDS="300" +ALIYUN_SMS_INTERVAL_SECONDS="60" +ALIYUN_SMS_DUPLICATE_POLICY="1" +ALIYUN_SMS_CASE_AUTH_POLICY="1" +ALIYUN_SMS_RETURN_VERIFY_CODE="false" +SMS_AUTH_MAX_SEND_PER_PHONE_PER_DAY="20" +SMS_AUTH_MAX_SEND_PER_IP_PER_HOUR="30" +SMS_AUTH_MAX_VERIFY_FAILURES_PER_PHONE_PER_HOUR="12" +SMS_AUTH_MAX_VERIFY_FAILURES_PER_IP_PER_HOUR="24" +SMS_AUTH_CAPTCHA_TTL_SECONDS="180" +SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_PHONE="3" +SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_IP="5" +SMS_AUTH_BLOCK_PHONE_FAILURE_THRESHOLD="6" +SMS_AUTH_BLOCK_IP_FAILURE_THRESHOLD="10" +SMS_AUTH_BLOCK_PHONE_DURATION_MINUTES="30" +SMS_AUTH_BLOCK_IP_DURATION_MINUTES="30" + +# 仅开发环境可选:允许无短信配置时自动走游客账号。 +VITE_AUTH_ALLOW_DEV_GUEST="false" + +# 微信登录配置。 +# 当前实现已支持微信登录骨架与 mock 联调;正式联调需补齐开放平台 AppID / AppSecret。 +WECHAT_AUTH_ENABLED="false" +WECHAT_AUTH_PROVIDER="wechat" +WECHAT_APP_ID="" +WECHAT_APP_SECRET="" +WECHAT_CALLBACK_PATH="/api/auth/wechat/callback" +WECHAT_REDIRECT_PATH="/" # Model name for chat completions. VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" diff --git a/AGENTS.md b/AGENTS.md index afcec428..2674bb44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,9 @@ - UI设计需要兼顾网页端、移动端双端的使用体验,确保在不同设备上都能正常显示和操作,移动端优先考虑。 - 不要在gitignore中添加.env.local文件。 - 严格遵循简洁的代码风格 +- 前端只负责做表现,所有的逻辑、数据都放到Express后端进行运算和存储。 +- 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。 +- 禁止将功能说明描述类的文本默认写入UI界面中。 ## 文档图谱 diff --git a/README.md b/README.md index 8dadca1e..a21e17ef 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ npm install npm run dev ``` +补充说明: + +- `npm run dev` 会同时启动 Vite 与 Express 后端,适合完整联调。 +- 如果没有显式配置 `DATABASE_URL`,且本机 `PostgreSQL` 不可用,开发模式会自动回退到内存版 `pg-mem`,方便先跑通鉴权与存档主链。 +- 如果只想单独启动前端页面,可使用 `npm run dev:web`。 + 构建生产包: ```bash diff --git a/docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md b/docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md new file mode 100644 index 00000000..870e6058 --- /dev/null +++ b/docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md @@ -0,0 +1,444 @@ +# 自定义世界创作工具问题审计与优化建议 + +更新时间:`2026-04-08` + +## 0. 结论先说 + +当前自定义世界创作工具已经有了比较强的生成骨架、锚点结构和结果编辑能力,但整体仍处在一个很明显的“半收口状态”: + +**设计目标已经走到“创作者工作台”,数据结构已经支持“锚点化输入”,但实际体验仍然更像“大文本生成器 + 大型结果总表编辑器”。** + +如果用一句话概括当前问题,就是: + +**高杠杆创作入口还不够强,低杠杆编辑负担还偏重,局部重生成与后端收口也还没有真正闭环。** + +--- + +## 1. 审计范围 + +本次审计主要对照了三类信息: + +1. 目标设计与 PRD + - `docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md` + - `docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md` + - `docs/design/CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md` + - `docs/design/CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md` + +2. 当前前端主流程与工作台 + - `src/components/SelectionCustomizationModals.tsx` + - `src/components/game-shell/PreGameSelectionFlow.tsx` + - `src/components/CustomWorldGenerationView.tsx` + - `src/components/CustomWorldResultView.tsx` + - `src/components/CustomWorldEntityCatalog.tsx` + - `src/components/CustomWorldEntityEditorModal.tsx` + +3. 当前生成链与后端会话层 + - `src/services/ai.ts` + - `src/services/aiService.ts` + - `src/services/customWorldCreatorIntent.ts` + - `src/services/customWorld.ts` + - `server-node/src/services/customWorldSessionStore.ts` + - `server-node/src/services/customWorldGenerationService.ts` + - `server-node/src/bridges/legacyAiRuntimeBridge.ts` + +--- + +## 2. 当前主要问题 + +## 2.1 输入层和意图结构已经脱节 + +当前数据结构已经支持: + +- 世界一句话 +- 主题关键词 +- 气质约束 +- 玩家身份 +- 开局处境 +- 核心冲突 +- 关键势力 +- 关键角色 +- 关键地点 +- 标志性要素 +- 禁止事项 + +但实际入口 `src/components/SelectionCustomizationModals.tsx` 里,创作者弹窗仍然基本只有: + +- 生成模式 +- 一块大 textarea + +这导致两个直接后果: + +1. 设计里已经想清楚的“高杠杆锚点输入”,还没有真正变成主入口。 +2. `CustomWorldCreatorIntent` 虽然已经能表达卡片化输入,但 UI 并没有把它转成真正可用的创作工作台。 + +目前 `card` 模式更多还停留在类型和测试层,没有成为真实用户路径。 + +可优化点: + +- 把当前创建弹窗升级成“快速文本模式 / 创作卡片模式”双入口。 +- 快速文本模式保留,但提交后应自动拆出建议锚点,而不是直接把整段文本原封不动送进生成链。 +- 卡片模式先只做最关键的 `5~6` 张卡,不要一开始把所有高级字段都铺开。 +- 允许空卡提交,但明确区分“已锁定锚点”和“允许 AI 自由补全”的内容。 + +--- + +## 2.2 澄清机制已经存在,但没有真正服务创作者 + +`server-node/src/services/customWorldSessionStore.ts` 已经支持: + +- 根据输入缺口生成澄清问题 +- 世界核心不足时追问 +- 玩家身份缺失时追问 +- 开局处境缺失时追问 +- 核心冲突缺失时追问 + +但 `src/services/aiService.ts` 当前做法是: + +- 创建 session +- 读取问题 +- 直接用 fallback 文案自动回答 +- 然后继续生成 + +这意味着: + +**系统表面上已经有“先澄清再生成”的能力,但实际体验里,创作者并没有真正参与这一步。** + +结果就是: + +- 低信息量输入并没有被真正补强 +- 澄清问题变成了内部兜底,而不是创作协作 +- 很容易继续生成出“完整但不够像用户想要的世界” + +可优化点: + +- 把 session question 真正接到前端,作为生成前的二次确认步骤。 +- 每次只问 `1~3` 个最关键问题,不要把它做成问卷。 +- 支持“一键使用系统建议”,但必须让创作者可见,而不是静默自动填充。 +- 把回答结果回写到 `creatorIntent`,而不是只作为一次性会话答案。 + +--- + +## 2.3 新建完成后的工作台闭环没有成立 + +设计文档里,自定义世界应该是: + +`输入 -> 生成 -> 结果页确认/编辑 -> 保存并进入世界` + +但当前 `src/components/game-shell/PreGameSelectionFlow.tsx` 里,新建世界生成成功后会: + +- 直接保存到世界库 +- 清空 `generatedCustomWorldProfile` +- 返回世界列表页 + +而不是进入 `custom-world-result` 结果工作台。 + +这会带来几个问题: + +1. 新建后的第一时间确认感不够强。 +2. 快速模式生成出的“关键对象预览”没有自然承接页。 +3. 用户生成完后,如果想继续看结果、继续补全、继续编辑,还要再从世界列表里点一次“编辑”。 + +这条链路会让“创作中”与“已保存”之间的体验断开。 + +可优化点: + +- 新建完成后默认进入结果工作台,而不是直接跳回世界列表。 +- 保存动作留在结果页显式触发。 +- 可以增加“自动保存草稿,但仍停留在结果页”的策略,兼顾安全感和连贯性。 +- 世界列表更适合作为“已归档内容入口”,不适合作为新建完成页。 + +--- + +## 2.4 结果页仍然偏“数据总表”,低杠杆编辑负担偏重 + +当前结果页已经有 `世界 / 锚点 / 可扮演角色 / 场景角色 / 场景` 五个页签,这是正确方向。 + +但问题在于,进入编辑器后暴露出来的字段仍然太“底层”了,例如: + +- `backstoryReveal` +- 技能列表 +- 初始物品 +- 场景 NPC 分配 +- 场景连接关系 + +这些字段对少数深度编辑场景有用,但不应该成为默认主编辑内容。 + +当前 `src/components/CustomWorldEntityEditorModal.tsx` 已经变成一个非常重的综合编辑器,里面同时承担: + +- 角色完整档案编辑 +- 形象编辑入口 +- AI 资产生成入口 +- 背景章节编辑 +- 技能与初始物品编辑 +- 场景内 NPC 分配 +- 场景连接维护 + +这会带来三层问题: + +1. 创作者负担过重 + - 很多字段属于“系统编译层”,不属于“创作决策层”。 + +2. 移动端负担过重 + - 大量长表单、长弹窗和多级编辑,对手机创作并不友好。 + +3. 工程复杂度过高 + - 前端工作台承担了太多不同层级的编辑职责。 + +可优化点: + +- 默认只暴露高杠杆编辑: + - 世界核心命题 + - 主题与气质 + - 玩家身份与开局 + - 关键势力 + - 关键角色 + - 关键地点 + - 标志性要素 +- 把技能、初始物品、章节 reveal、连接网络等移到“高级模式”或“系统层编辑”。 +- 结果页结构从“按对象字段堆表单”改成“按创作价值组织”。 +- 移动端优先改成分段式面板或底部工作台,不要把长表单都塞进同一个大 modal。 + +--- + +## 2.5 锁定与局部重生成机制还不完整 + +当前已经有两套看起来相关的能力: + +1. `creatorIntent` 里的 `locked` +2. `lockState` 里的角色 / 地点 / 势力锁定字段 + +但实际重生成时,`src/components/game-shell/PreGameSelectionFlow.tsx` 里的合并逻辑只真正处理了: + +- 已锁定角色 +- 已锁定地点 + +而且还是按“名称匹配”保留,不是按稳定 id 或字段级锁定来处理。 + +这带来的问题很明显: + +1. 角色或地点一旦重命名,锁定可能失效。 +2. 势力、冲突、世界概述等高价值内容没有真正进入局部重生成保护范围。 +3. 当前“锁定能力”更像一个早期过渡实现,还没有形成统一的重生成规则。 + +同时,结果页的“重新生成”提示文案仍然是“整世界覆盖式”的语义,这也会进一步削弱用户对重生成的信任感。 + +可优化点: + +- 把锁定语义统一收口到后端,以 `lockState` 为唯一事实来源。 +- 锁定粒度改成: + - 世界字段锁定 + - 势力锁定 + - 关键角色锁定 + - 关键地点锁定 + - 长尾内容可重生成 +- 局部重生成至少拆成几类: + - 仅补长尾角色 + - 仅补长尾场景 + - 仅重做场景网络 + - 仅重做支持性 NPC +- 合并逻辑不要再靠名称匹配,改成稳定 id 或锚点映射。 + +--- + +## 2.6 快速模式还不够“快”,生成页也还不够“创作者视角” + +当前快速模式的主要区别,是把数量降成: + +- 可扮演角色 `3` +- 场景角色 `8` +- 场景 `4` + +但主生成链本身仍然会继续跑: + +- framework +- theme pack +- story graph +- role narrative +- role dossier +- narrative profile +- finalization + +也就是说: + +**现在的 fast 更像“缩数量版 full”,还不是“先出关键锚点与关键对象的创作预览模式”。** + +同时,`src/components/CustomWorldGenerationView.tsx` 当前展示的重点仍然是: + +- 当前批次 +- 预计等待 +- 计时 +- 模型阶段 + +而不是创作者真正关心的: + +- 关键角色有没有成型 +- 核心冲突有没有稳定 +- 哪些锚点已经锁定 +- 当前正在补的是关键对象还是长尾内容 + +可优化点: + +- 快速模式改成真正的“关键锚点预览模式”: + - 先只生成关键角色、关键地点、核心冲突摘要 + - 暂不补全所有长尾档案 +- 生成页改成“创作者视角进度”: + - 世界灵魂已确定 + - 关键角色已成型 + - 关键地点已落地 + - 长尾扩展准备开始 +- 把技术批次隐藏到二级信息里,默认只展示创作状态。 + +--- + +## 2.7 自定义世界底层仍然没有完全脱离模板世界依赖 + +这部分设计文档和参考清单已经说得比较清楚,代码里也能看到对应痕迹: + +- `templateWorldType` +- `WUXIA / XIANXIA` 兼容字段 +- 规则层 fallback +- 视觉参考池 fallback +- 角色骨架与怪物池 fallback + +当前问题不在于“还没完全去模板化”本身,而在于: + +**这层依赖仍然深入到了生成、运行时规则、表现词汇和参考资源,不只是一个兼容字段。** + +这会限制: + +1. 跨题材表达稳定性 +2. 自定义世界的自有设定层独立性 +3. 后续真正做到“任何题材都能稳定跑” + +可优化点: + +- 继续按现有设计稿,把模板依赖逐步迁成: + - 语义锚层 + - 规则层 + - 表现层 + - 原型参考层 + - 兼容迁移层 +- 在迁移完成前,保留兼容字段,但让新逻辑优先读取 `ownedSettingLayers`。 +- 明确哪些是“兼容桥”,哪些还是“真实主依赖”,避免继续混用。 + +--- + +## 2.8 前后端边界仍处于过渡态,和项目约束还有距离 + +当前自定义世界已经有了: + +- Node 路由 +- session +- 流式生成 +- 世界库存储接口 + +这是对的。 + +但问题是,`server-node/src/services/customWorldGenerationService.ts` 仍然通过 `server-node/src/bridges/legacyAiRuntimeBridge.ts` 去桥接 `src/services/ai.ts`。 + +这说明: + +**核心生成逻辑虽然已经被路由包起来了,但真正的生成实现还没有完全成为 Express 侧自己的领域服务。** + +同时,前端仍然承担了不少流程语义: + +- 锁定内容合并 +- 重生成确认 +- 结果页覆盖提示 +- 工作台状态切换 + +这和当前仓库“前端只负责表现,逻辑与数据尽量收口到 Express 后端”的方向还有距离。 + +可优化点: + +- 把自定义世界生成链正式下沉到 `server-node` 领域服务。 +- 把锁定、局部重生成、澄清会话、结果归档规则都放到后端。 +- 前端只负责: + - 输入展示 + - 进度展示 + - 结果工作台展示 + - 明确的用户确认动作 + +--- + +## 2.9 移动端工作台仍有明显压迫感 + +项目文档已经明确要求移动端优先,但当前自定义世界工作台里仍有几个典型问题: + +- 大量长 modal +- 多段长表单 +- `window.confirm / window.alert` 原生弹框较多 +- 结果页与编辑器中同时承载太多操作密度 + +这些在桌面端还能勉强接受,但在手机上会很容易变成: + +- 滚动层级混乱 +- 退出成本高 +- 修改焦点不明确 +- 确认感不稳定 + +可优化点: + +- 移动端改成底部工作台 / 分步面板 / 可折叠分区。 +- 用项目内统一确认弹层替换 `window.confirm / window.alert`。 +- 让“保存”“继续补全”“局部重生成”这些关键动作固定在底部安全区附近。 +- 把搜索、批量删除、创建动作做成更轻的操作条,而不是持续挤压正文区。 + +--- + +## 3. 优先级建议 + +### P0:先修主链路闭环 + +- 补卡片化输入入口,至少把关键锚点输入真正开放出来。 +- 把澄清问题正式接入创作者流程,不再静默自动兜底。 +- 修正“新建完成后直接回世界列表”的流程,生成后默认进入结果工作台。 +- 统一锁定与局部重生成规则,先让“创作者不怕重生成”成立。 + +### P1:再降低工作台负担 + +- 结果页默认只展示高杠杆编辑。 +- 低杠杆字段进入高级模式。 +- 快速模式改成真正的关键对象预览模式。 +- 生成页改成创作者视角进度,而不是模型批次视角。 + +### P2:最后做架构收口与去模板化 + +- 把生成链、锁定规则、会话澄清彻底收回 Express 后端。 +- 持续推进 `ownedSettingLayers` 成为真实主设定层。 +- 逐步去掉自定义世界对模板世界的深依赖。 +- 针对移动端重做工作台的操作密度和确认路径。 + +--- + +## 4. 推荐落地顺序 + +如果只按“最小投入、最大体验收益”排序,建议按下面四步做: + +1. 先改输入与结果闭环 + - 卡片化最小入口 + - 澄清问题接入 + - 新建后进入结果页 + +2. 再改锁定与局部重生成 + - 用稳定 id + - 用统一 lockState + - 增加局部重生成类型 + +3. 再改结果工作台结构 + - 默认高杠杆 + - 高级模式收纳低杠杆字段 + - 移动端拆分长表单 + +4. 最后做后端收口与去模板化 + - 服务端领域化 + - 设定层自有化 + - 跨题材泛化 + +--- + +## 5. 一句话判断 + +当前自定义世界创作工具最需要的,不是再继续补更多字段或更多生成步骤,而是: + +**把“创作者先决定灵魂锚点,系统再稳定展开世界”这条主逻辑真正落到 UI、流程和后端边界上。** diff --git a/docs/audits/README.md b/docs/audits/README.md index 430a4e89..0673170b 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -11,6 +11,7 @@ - [FUNCTION_DESIGN_AUDIT_2026-04-03.md](./FUNCTION_DESIGN_AUDIT_2026-04-03.md):Function 体系分层、职责边界和当前结构问题。 - [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。 +- [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。 ## 推荐使用方式 diff --git a/docs/design/README.md b/docs/design/README.md index 3a55daa6..2898ca71 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,7 @@ - [EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md](./EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md):配装构筑与合成/锻造闭环设计。 - [COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md](./COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md):角色首遇感、关系分层解锁、私聊系统设计。 - [SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md](./SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md):把每个场景收束成章节单元,并在首进场景时开启章节任务的设计稿。 +- [SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md](./SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md):对标仙剑、博德之门、黑神话,分析单场景章节的体验缺口,并给出 AI 原生补强方案。 - [npc-conversation-situation-draft.md](./npc-conversation-situation-draft.md):NPC 对话阶段和情景注入草案。 ## 推荐阅读 @@ -21,4 +22,5 @@ - 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。 - 做角色关系、同伴互动、对话表现时,先看后两份。 - 做剧情引擎章节化、场景闭环、章节任务接入时,优先看新增的场景章节设计稿。 +- 做“单章节体验还缺什么、该补哪种情感 / 抉择 / 试炼模块”时,优先看新增的章节对标补强设计稿。 - 如果要判断是否符合目标,再和 `docs/prd/` 中对应 PRD 对照阅读。 diff --git a/docs/design/SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md b/docs/design/SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md new file mode 100644 index 00000000..c6104250 --- /dev/null +++ b/docs/design/SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md @@ -0,0 +1,434 @@ +# 单场景章节对标缺口与 AI 原生体验补强设计 + +更新时间:`2026-04-08` + +## 0. 目标 + +这份文档补在: + +- `docs/design/SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md` +- `docs/prd/AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md` + +之后,专门回答一个更具体的问题: + +**如果把当前每个场景都视为一个章节,那么和《仙剑》《博德之门》《黑神话:悟空》里一个成立的章节相比,我们现在到底缺什么;以及结合当前项目的 AI 原生设计,应该补哪些体验层。** + +这份文档不重复讨论“场景如何开章、任务如何挂接”的骨架问题,而是聚焦: + +1. 单章节的体验密度还缺什么 +2. 每章最该补的体验模块是什么 +3. 哪些能力必须由服务端承接,前端只负责表现 + +--- + +## 1. 结论先说 + +当前项目把“场景 = 章节单元”的方向已经立住了一半骨架,但和这三类标杆作品相比,单章节体验还普遍缺 5 件事: + +1. 缺一个让玩家记住“这章是关于谁”的情感锚点 +2. 缺一个让玩家中途改判的转折点 +3. 缺一个让玩家感到“这一段路在压着我走”的空间推进链 +4. 缺一个让玩家立场真正被表达出来的选择节点 +5. 缺一个能把结果写回人物、物件、后续章节的余波回响 + +一句话判断: + +**现在的场景章节更像“带任务 lead 的剧情地点”,还不够像“有人物、有抉择、有试炼、有收束余味的一整章”。** + +--- + +## 2. 对标三个标杆后,当前单章节缺什么 + +## 2.1 相比《仙剑》式章节,缺的是“人和情” + +《仙剑》式章节最强的不是题材,而是: + +1. 一章里总有一个会被记住的人 +2. 这一章结束时,人物关系一定发生了变化 +3. 玩家记住的不是“我做了一个任务”,而是“我和谁经历了一段事” + +当前场景章节的主要缺口如下: + +| 维度 | 标杆章节会给玩家什么 | 当前缺口 | 应补什么 | +| --- | --- | --- | --- | +| 章节记忆点 | 明确的人物高光或情感切口 | 现在更多是场景 lead,不够像人物立题 | 每章必须有 `情感锚点 NPC / 关系对象` | +| 关系推进 | 误会、试探、信任、失去、承诺会推进 | 现在 NPC 更像信息点或任务点 | 在 `opening / turning_point / aftermath` 至少安排一次关系变化 | +| 章节余味 | 章节结束后仍有余波、牵挂、回看价值 | 现在更多是“交给下一场景” | 章节结束后补 `私聊 / 留言 / 赠礼反应 / 人物口风变化` | + +结论: + +**当前章节最缺的不是人物数量,而是“这一章让玩家和谁产生了关系变化”。** + +## 2.2 相比《博德之门》式章节,缺的是“选择和反应” + +《博德之门》式章节最强的不是分支数量,而是: + +1. 玩家会在一章里表达立场 +2. 队友和 NPC 会明确回应这个立场 +3. 同一个问题通常不止一种处理方式 + +当前场景章节的主要缺口如下: + +| 维度 | 标杆章节会给玩家什么 | 当前缺口 | 应补什么 | +| --- | --- | --- | --- | +| 处理路径 | 同一章通常有 `2~3` 种解法 | 现在大多仍是单主解推进 | 每章至少提供一次 `有限分歧结算` | +| 队友反应 | 队友会认可、反对、沉默、插话 | 现在同伴存在感偏弱 | 每章至少有一次 `同伴反应批次` | +| 后果落地 | 选择会改任务理解、人物态度、后续信息 | 现在选择更多是流程推进,不够像立场表达 | 把章节选择写回 `关系 / 线索可见性 / 后续 handoff` | + +结论: + +**当前章节最缺的不是做无限分支,而是做“有限分歧 + 强反馈”。** + +## 2.3 相比《黑神话:悟空》式章节,缺的是“路和压迫” + +《黑神话:悟空》式章节最强的不是题材,而是: + +1. 一章的空间推进本身就在讲故事 +2. 玩家会记住路上的地标、残痕、敌人和压迫感 +3. 一章通常有一个明显的试炼点或高潮演出点 + +当前场景章节的主要缺口如下: + +| 维度 | 标杆章节会给玩家什么 | 当前缺口 | 应补什么 | +| --- | --- | --- | --- | +| 空间推进 | 场景不是背景,而是一段穿行过程 | 现在场景更像承载节点,不够像旅程 | 每章补 `路线压力链` 和 `地标记忆点` | +| 残痕叙事 | 地点、物件、敌人都在讲旧事 | 现在残痕有素材,但链不够强 | 每章至少串起 `2~3` 个残痕 / 证据 / 地标 | +| 高潮试炼 | 一章通常有一个明显 setpiece / 强敌 / 对峙 | 现在收束常偏文本或任务状态 | 每章补一个 `高潮收束动作面` | + +结论: + +**当前章节最缺的不是更多场景描述,而是“空间本身要承担承压和收束”。** + +## 2.4 三个标杆放在一起看,当前单章节的综合缺口 + +综合来看,当前每个场景章节最明显的缺口可以收束成下面 7 条: + +1. 场景有主题,但缺“这一章是谁在发光” +2. 任务有步骤,但缺“中途为什么会改判” +3. 残痕有素材,但缺“沿路逐步加压的链条” +4. 同伴会跟着走,但缺“针对你的立场说话” +5. 战斗或冲突会发生,但缺“本章高潮” +6. 章节会结束,但缺“结束后谁变了” +7. 章节会交棒,但缺“这一章留下了什么可回响之物” + +--- + +## 3. 每个场景章节都该补的标准体验包 + +建议以后把每个场景章节都按“九件套”来补,不要求每件都做得很重,但每章都不能缺位。 + +| 模块 | 每章最低要求 | 主要对标 | +| --- | --- | --- | +| 开章钩子 | 一进入场景就抛出一个异常、冲突或错位对白 | 三者共同 | +| 情感锚点 | 至少一个本章承担关系变化的人物 | 仙剑 | +| 路线压力链 | 至少 `2~3` 个地标、残痕或承压节点串起来 | 黑神话 | +| 承压事件 | 一次调查、对峙、战斗或阻拦,证明这一章真有事在发生 | 三者共同 | +| 改判节点 | 至少一条反证、误会破口或旧痕翻案 | 仙剑 + 博德之门 | +| 立场选择 | 至少一次有代价的二选一 / 三选一 | 博德之门 | +| 同伴反应 | 至少一次认可、反对、担忧、沉默中的一种反馈 | 博德之门 | +| 高潮收束 | 一次明确的 confrontation、试炼、交付或揭示 | 黑神话 + 仙剑 | +| 余波回响 | 至少留下一个后续会被提起的人、物、线索或态度变化 | 三者共同 | + +这里的重点不是把每章都做成大体量内容,而是让每章都具备: + +1. 可记住的人 +2. 可承压的路 +3. 可表达的立场 +4. 可回响的结果 + +--- + +## 4. 结合 AI 原生设计,单章节应该怎么补 + +## 4.1 不是做“无限分支”,而是做“有限模块 + 动态编排” + +当前项目最适合的做法,不是给每个场景写爆炸式手工分支,而是: + +1. 先给每章补稳定的体验模块 +2. 再让 AI 按当前线程、关系、残痕和场景状态去动态编排表达 +3. 本地规则负责章节状态、选择裁决、任务推进、关系变化和奖励回写 + +一句话说: + +**AI 负责把这一章写活,本地规则负责保证这一章成立。** + +## 4.2 建议新增一个章节体验画像 + +建议在现有 `ChapterState` 外,给每个场景章节补一个更偏体验编排的数据层,例如: + +```ts +interface ChapterExperienceProfile { + sceneId: string; + chapterArchetype: 'emotion' | 'choice' | 'trial' | 'hybrid'; + chapterPromise: string; + emotionalAnchorNpcId?: string | null; + guideNpcId?: string | null; + opposingForceId?: string | null; + routePressureBeats: string[]; + turningEvidenceIds: string[]; + stanceChoiceId?: string | null; + climaxSetpieceId?: string | null; + rewardCarrierSeed?: string | null; + aftermathEchoTargets: string[]; +} +``` + +它的作用不是替代现有 `ChapterState`,而是补“这一章体验上到底靠什么成立”。 + +## 4.3 每章都要有一个“人物轴” + +这是补仙剑型体验最重要的一步。 + +建议规则: + +1. 每章必须指定一个 `emotionalAnchorNpcId` +2. 这个角色不一定是最强、最重要的人,但一定是本章最会改变玩家理解的人 +3. 这个角色至少承担下面 2 件事: + - `opening` 里立题或制造错位 + - `turning_point / aftermath` 里改口、揭示或留下余波 + +AI 原生补法: + +1. AI 负责生成角色此章的表层说辞、压力表现和错位感 +2. 本地规则负责控制此章能披露哪些事实、关系如何变化 + +## 4.4 每章都要有一个“路线轴” + +这是补黑神话型体验最重要的一步。 + +建议规则: + +1. 每章至少有 `2~3` 个 `routePressureBeats` +2. 每个 beat 都不只是位置点,而是下面三类之一: + - 地标记忆点 + - 场景残痕点 + - 敌对 / 阻拦 / 追索点 +3. `climax` 前必须至少有一次“越往前越不安”的空间升级 + +AI 原生补法: + +1. AI 负责根据 `scene residue / thread / scar / motif` 生成现场感和残痕文本 +2. 本地规则负责决定 beat 顺序、是否已触发、何时进入高潮 + +## 4.5 每章都要有一个“立场轴” + +这是补博德之门型体验最重要的一步。 + +建议规则: + +1. 每章至少抛一次立场问题 +2. 立场问题优先围绕: + - 要不要信谁 + - 要不要公开某条线索 + - 要不要保一个人还是保规则 + - 要不要立刻动手还是继续查 +3. 不追求无限解,只要求: + - `2~3` 个有限方案 + - 每个方案都能触发明确反应 + +AI 原生补法: + +1. AI 负责把选择包装成角色化、情境化的动作与对白 +2. 本地规则负责裁决结果、写回 `CompanionStanceProfile / Quest / Knowledge / Echo` + +## 4.6 每章都要有一个“高潮动作面” + +目前很多场景的高潮更像“任务完成”,还不够像“一章真正收束了”。 + +建议每章在 `climax` 至少落成下面四类之一: + +1. 正面对峙 +2. 小型试炼 / 强敌 +3. 真相交付 +4. 关系摊牌 + +要求: + +1. 高潮不能只体现在任务状态变化 +2. 必须有明确前后差 +3. 必须能回写一条 `chapter aftermath` + +## 4.7 每章都要留下一个可回响的载体 + +这是把三类标杆合在一起后最容易被低估的一层。 + +建议每章至少留下一个: + +1. 章节证物 +2. 章节遗物 +3. 章节残痕 +4. 章节口风变化 + +它们至少要满足一条: + +1. 下一章还能被提起 +2. 某个 NPC 会认出它 +3. 某个同伴会追加评论 +4. 某个任务会因为它改变说明或目标 + +--- + +## 5. 章节类型建议 + +不是每章都要平均模仿三款游戏,更稳的做法是:每章明确一个主类型,再配一个副类型。 + +| 章节类型 | 主对标 | 章节重点 | 必选模块 | +| --- | --- | --- | --- | +| 情感章 | 仙剑 | 人物关系、误会、牺牲、承诺、旧债 | 情感锚点、改判节点、余波回响 | +| 抉择章 | 博德之门 | 立场表达、队友反应、有限分歧结算 | 立场选择、同伴反应、后果回写 | +| 试炼章 | 黑神话 | 路线承压、地标残痕、高潮试炼 | 路线压力链、高潮收束、章节载体 | +| 混合章 | 任选两者 | 既要有情感,也要有试炼或选择 | 视主副轴组合决定 | + +建议规则: + +1. 每个场景章节都标一个 `主类型` +2. 再选一个 `副类型` +3. 不要让所有章节都只剩“调查 + 对话 + 交任务” + +--- + +## 6. 服务端与前端的职责边界 + +结合当前项目约束,章节体验补强必须坚持: + +**前端只负责表现,章节逻辑、状态、选择裁决、数据编排全部放在 Express 后端。** + +## 6.1 服务端负责什么 + +建议把下面这些能力放在 `server-node/`: + +1. 章节体验画像生成 +2. 章节选择裁决 +3. 同伴反应批次生成 +4. 章节残痕与路线 beat 编排 +5. 章节余波与回响写回 +6. 章节奖励载体编译 + +服务端输入应来自: + +1. 当前 `sceneId` +2. 当前 `ChapterState` +3. 当前队伍与关系状态 +4. 当前激活线程与知识可见性 +5. 当前章节已触发的 beat、choice、residue、setpiece + +服务端输出应编译成前端可直接消费的结果,例如: + +1. 当前章节标题与阶段 +2. 当前章节 promise +3. 当前 route beats +4. 当前可选立场 +5. 同伴反应 feed +6. 本章高潮结果 +7. 本章 aftermath 摘要 + +## 6.2 前端负责什么 + +前端只负责: + +1. 表现章节标题、章节目标、路线节拍 +2. 表现情感锚点人物和同伴反应 +3. 表现本章高潮演出与余波反馈 +4. 表现章节奖励载体和 chronicle 回写结果 + +前端不要负责: + +1. 计算选择结果 +2. 推导章节阶段 +3. 生成 route beat 顺序 +4. 决定同伴 stance 更新 + +--- + +## 7. 单章节设计卡模板 + +为了让后续每个场景都能按统一方式补体验,建议每章都补一张简版设计卡: + +```md +- 章节标题: +- 主类型 / 副类型: +- 本章 promise: +- 情感锚点人物: +- 开章钩子: +- 路线压力链: +- 承压事件: +- 改判证据: +- 立场选择: +- 同伴反应: +- 高潮收束: +- 章节奖励载体: +- 余波回响: +- 下一章 handoff: +``` + +只要这张卡填不满,说明这一章还没有真正成立。 + +--- + +## 8. 推荐补强顺序 + +如果按当前项目最稳的节奏推进,建议顺序如下: + +## 阶段 A:先补“可被记住的人” + +先做: + +1. 情感锚点 NPC +2. 改判节点 +3. 章节余波 + +原因: + +这是最接近当前项目已有 NPC、关系和私聊链的部分,投入最小,感知提升最快。 + +## 阶段 B:再补“可被表达的立场” + +先做: + +1. 每章一次立场选择 +2. 同伴反应批次 +3. 后果写回任务 / 关系 / 线索可见性 + +原因: + +这是对标《博德之门》最关键,同时也是最能让 AI 原生叙事摆脱“只会往前写”的一步。 + +## 阶段 C:最后补“可被承压的路” + +先做: + +1. route pressure beats +2. 地标残痕链 +3. 高潮 setpiece + +原因: + +空间承压最依赖完整的场景节拍和演出支持,适合在前两层站稳后继续增强。 + +--- + +## 9. 章节体验验收标准 + +如果说一个场景章节已经明显更接近《仙剑》《博德之门》《黑神话:悟空》那类标杆,至少应满足下面这些结果: + +1. 玩家玩完这一章后,能明确说出“这章主要在讲谁 / 讲什么” +2. 玩家在中段经历过一次改判,而不是从头到尾只在确认原判断 +3. 玩家能记住至少一个地标、残痕或证物,而不只是任务标题 +4. 玩家至少做过一次立场表达,并收到过明确反应 +5. 玩家在章节结束时感到“这一章局部收住了”,而不只是“系统让我去下个场景” +6. 本章至少有一条人物态度、物件、线索或 chronicle 被写进后续回响 +7. 首遇与低披露阶段不会把整章暗线和角色完整背景直接灌给模型 + +--- + +## 10. 一句话结论 + +如果把每个场景都视为一个章节,那么当前最该补的,不是继续堆更多场景文本,而是让每章都真正长出: + +1. 一个会被记住的人 +2. 一段会逐步加压的路 +3. 一次会暴露立场的选择 +4. 一个能把本章收住的高潮 +5. 一条能延续到后面的余波 + +只有这样,当前项目的“场景章节化”才会从结构成立,进一步走到体验成立。 diff --git a/docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md b/docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md new file mode 100644 index 00000000..d37d65ec --- /dev/null +++ b/docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md @@ -0,0 +1,588 @@ +# Express 后端化并行任务拆分规划(2026-04-08) + +## 1. 目的 + +这份文档用于把 [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md) 进一步拆成可并行推进、尽量互不冲突的任务流。 + +目标不是把大重构拆成很多零碎 TODO,而是把它拆成: + +- 可以同时开工 +- 写入边界清晰 +- 交付物明确 +- 依赖关系稳定 +- 最后容易集成 + +--- + +## 2. 并行拆分原则 + +## 2.1 基本原则 + +- 每条任务尽量拥有独占目录或独占模块,不去抢同一批热点文件。 +- 热点集成文件只由“集成岗”或最后一轮集成处理,不作为多个任务的日常编辑目标。 +- 先搭协议边界,再迁规则执行,再收缩前端。 +- 前端与后端可以并行推进,但前提是先冻结 contract。 +- 编辑器链路和正式运行时链路分开拆,避免互相阻塞。 + +## 2.2 当前最容易冲突的文件 + +以下文件建议默认只由集成岗或最后一轮联调处理: + +- `server-node/src/context.ts` +- `server-node/src/routes/runtimeRoutes.ts` +- `server-node/src/app.ts` +- `src/services/apiClient.ts` +- `src/hooks/useStoryGeneration.ts` +- `src/hooks/useGameFlow.ts` +- `src/components/GameShell.tsx` + +其他任务如果必须影响这些文件,优先通过: + +- 新增独立模块 +- 新增 adapter +- 新增中间层入口 + +而不是直接在热点文件中大改。 + +--- + +## 3. 建议并行批次 + +## 批次 A:可立即并行开工 + +- 任务 0:集成岗与接口冻结 +- 任务 1:共享 contract 与目录抽离 +- 任务 2:PostgreSQL 持久化基线收口 +- 任务 3:服务端 HTTP 基础设施与统一响应壳层 +- 任务 8:编辑器 API 归口与工具链隔离 +- 任务 9:测试、观测与部署基线 + +## 批次 B:在 contract 初版落地后并行开工 + +- 任务 4:服务端 AI 编排收口 +- 任务 5:运行时领域模块 A,Story / Combat / NPC +- 任务 6:运行时领域模块 B,Inventory / Quest / Build / Runtime Item +- 任务 7:前端 SDK、鉴权、持久化瘦身 + +## 批次 C:在服务端 action 和 view model 稳定后开工 + +- 任务 10:前端主流程壳层与大 hook 瘦身 + +--- + +## 4. 任务拆分 + +## 任务 0:集成岗与接口冻结 + +### 目标 + +负责冻结边界、维护接口文档、控制热点文件的合并节奏,避免多人同时改核心入口。 + +### 独占范围 + +- `docs/planning/**` +- `docs/technical/**` +- 最终集成时的热点入口文件 + +### 主要输出 + +- 统一任务看板 +- contract 版本表 +- 热点文件编辑规则 +- 每日或每阶段集成清单 + +### 验收标准 + +- 团队知道哪些文件不能多人同时改 +- 每条任务都有明确的上游 contract 与下游接入点 + +--- + +## 任务 1:共享 Contract 与目录抽离 + +### 目标 + +先把前后端共同识别的类型、schema、响应结构、错误结构抽出来,切断 `server-node -> src/**` 的长期反向依赖。 + +### 独占范围 + +- `packages/shared/**` +- 新建的共享类型、schema、contract 目录 + +### 可改边界 + +- `server-node/src/**` 中的 import 替换入口 +- `src/**` 中的 import 替换入口 + +### 暂不负责 + +- 具体业务规则迁移 +- 前端页面行为调整 +- 数据库实现细节 + +### 主要输出 + +- 统一 API envelope +- 统一错误对象 +- 统一 action / response contract +- 统一领域类型和状态枚举 + +### 验收标准 + +- 新增服务端模块不需要继续直接依赖前端目录里的实现细节 +- 前后端都以共享 contract 为边界协作 + +### 并行关系 + +- 可与任务 2、任务 3、任务 8、任务 9 同时启动 +- 是任务 4、任务 5、任务 6、任务 7 的上游基础 + +--- + +## 任务 2:PostgreSQL 持久化基线收口 + +### 目标 + +把“已经切到 PostgreSQL”的状态收成真正稳定的后端基线,清掉 SQLite 残留口径与仓储层耦合问题。 + +### 独占范围 + +- `server-node/src/config.ts` +- `server-node/src/db.ts` +- `server-node/src/repositories/**` +- `server-node/src/app.test.ts` +- `.env.example` + +### 暂不负责 + +- 剧情规则 +- 选项结算 +- 前端状态瘦身 + +### 主要输出 + +- PostgreSQL 连接配置 +- 仓储层接口统一 +- 数据表初始化/迁移方案 +- 运行时持久化测试基线 +- 文档中的数据库现状统一 + +### 验收标准 + +- 后端运行时数据完全以后端数据库为准 +- 配置、日志、测试、文档里不再把 SQLite 写成当前正式现状 + +### 并行关系 + +- 可与任务 1、任务 3、任务 8、任务 9 同时启动 +- 为任务 5、任务 6、任务 7 提供稳定持久化基础 + +--- + +## 任务 3:服务端 HTTP 基础设施与统一响应壳层 + +### 目标 + +建立统一的服务端响应结构、错误结构、请求链路日志、版本字段和中间件壳层。 + +### 独占范围 + +- `server-node/src/http.ts` +- `server-node/src/errors.ts` +- `server-node/src/middleware/**` +- `server-node/src/app.ts` + +### 可改边界 + +- 为 route 层提供新的响应 helper +- 为后续 action 接口提供统一 envelope + +### 暂不负责 + +- 具体 story / combat / quest 业务逻辑 +- 前端页面层接入 + +### 主要输出 + +- 统一 JSON 响应格式 +- 统一错误格式 +- `requestId` +- `latency` 与关键日志字段 +- 路由级版本与元信息壳层 + +### 验收标准 + +- 后端所有新接口都能套用同一层响应约定 +- 前端不需要为不同接口写多套错误解析逻辑 + +### 并行关系 + +- 可与任务 1、任务 2、任务 8、任务 9 同时启动 +- 是任务 4、任务 5、任务 6、任务 7 的共同基础 + +--- + +## 任务 4:服务端 AI 编排收口 + +### 目标 + +把正式运行时的 prompt 组装、模型调用、容错、SSE 转发都收回后端,浏览器不再保留正式运行时 AI fallback。 + +### 独占范围 + +- `server-node/src/services/llmClient.ts` +- `server-node/src/services/chatService.ts` +- `server-node/src/services/storyService.ts` +- `server-node/src/services/customWorldGenerationService.ts` +- `server-node/src/services/questService.ts` +- `server-node/src/services/runtimeItemService.ts` + +### 可新增目录 + +- `server-node/src/modules/ai/**` + +### 暂不负责 + +- 前端主流程组件 +- 数据库存储实现 + +### 主要输出 + +- 后端统一 AI orchestration 层 +- 流式接口统一适配 +- prompt 复用策略 +- 前端 fallback 清理清单 + +### 验收标准 + +- 正式运行时不再依赖浏览器端大体量 AI 实现作为兜底 +- AI 失败、超时、流式中断都能在后端统一处理 + +### 并行关系 + +- 建议在任务 1、任务 3 有初版后启动 +- 可与任务 5、任务 6、任务 7 并行 + +--- + +## 任务 5:运行时领域模块 A,Story / Combat / NPC + +### 目标 + +把剧情推进、战斗结算、NPC 交互这些最核心的运行时状态迁移到后端领域模块。 + +### 独占范围 + +- `server-node/src/modules/story/**` +- `server-node/src/modules/combat/**` +- `server-node/src/modules/npc/**` + +### 可改边界 + +- 为 route/action 层提供服务接口 +- 为前端提供 view model 所需聚合结果 + +### 暂不负责 + +- 背包、Build、任务奖励编排 +- 编辑器接口 + +### 主要输出 + +- story action resolver +- combat resolution service +- npc interaction service +- 统一返回给 UI 的 presentation/view model 结构 + +### 验收标准 + +- 前端不再本地决定 function 合法性、战斗结果、NPC 关键关系变化 +- 点击选项时,后端能返回完整下一步展示结果 + +### 并行关系 + +- 依赖任务 1、任务 3 +- 可与任务 4、任务 6、任务 7 并行 + +--- + +## 任务 6:运行时领域模块 B,Inventory / Quest / Build / Runtime Item + +### 目标 + +把任务推进、运行时物品、背包/装备、Build 收益等剩余核心规则迁到后端。 + +### 独占范围 + +- `server-node/src/modules/inventory/**` +- `server-node/src/modules/quest/**` +- `server-node/src/modules/build/**` +- `server-node/src/modules/runtime-item/**` + +### 可改边界 + +- 调用任务 2 的仓储层 +- 使用任务 1 的共享 contract + +### 暂不负责 + +- 前端页面层改造 +- Story / Combat / NPC 主链路 + +### 主要输出 + +- inventory mutation service +- quest signal progression service +- build calculation service +- runtime item resolution service + +### 验收标准 + +- 背包、任务、Build、运行时物品不再由前端保留正式结算逻辑 +- 这些领域能独立测试,不依赖 UI hook + +### 并行关系 + +- 依赖任务 1、任务 2、任务 3 +- 可与任务 4、任务 5、任务 7 并行 + +--- + +## 任务 7:前端 SDK、鉴权、持久化瘦身 + +### 目标 + +让前端从“业务执行层”退回“API 消费层 + 表现层状态协调层”。 + +### 独占范围 + +- `src/services/apiClient.ts` +- `src/services/authService.ts` +- `src/services/storageService.ts` +- `src/services/aiService.ts` +- `src/hooks/useGamePersistence.ts` +- `src/hooks/useGameSettings.ts` + +### 暂不负责 + +- 页面组件大范围重构 +- `useStoryGeneration.ts` 主流程瘦身 + +### 主要输出 + +- 轻量前端 SDK +- 统一鉴权请求层 +- 统一错误态与重试策略 +- 远端快照/设置消费层 +- 正式运行时浏览器 fallback 下线方案 + +### 验收标准 + +- 前端服务层不再保留完整正式规则或正式 AI 编排 +- 存档与设置以后端返回结果为准 + +### 并行关系 + +- 依赖任务 1、任务 3 +- 可与任务 4、任务 5、任务 6 并行 +- 为任务 10 提供稳定的 API 消费层 + +--- + +## 任务 8:编辑器 API 归口与工具链隔离 + +### 目标 + +把编辑器的写盘、生成、任务查询能力从“散落接口”整理成清晰的编辑器后端模块,避免继续污染正式运行时。 + +### 独占范围 + +- `src/editor/shared/**` +- `src/components/preset-editor/**` +- `src/components/npcVisualEditorPersistence.ts` +- `src/components/preset-editor/characterAssetStudioPersistence.ts` +- `scripts/dev-server/**` +- `server-node/src/modules/editor/**` +- `server-node/src/modules/assets/**` + +### 暂不负责 + +- 主游戏运行时 action 逻辑 +- 正式剧情流转 + +### 主要输出 + +- `/api/editor/*` 与 `/api/assets/*` 命名空间 +- 统一 editor client SDK +- 写接口权限边界 +- 编辑器工具链迁移清单 + +### 验收标准 + +- 编辑器组件不再散落直连多个写接口 +- 编辑器 API 与运行时 API 的职责边界清晰 + +### 并行关系 + +- 可与任务 1、任务 2、任务 3、任务 9 同时启动 +- 与任务 5、任务 6、任务 10 基本不冲突 + +--- + +## 任务 9:测试、观测与部署基线 + +### 目标 + +为整个后端化改造提供自动回归、链路日志和部署基线,避免“功能迁过去了但不可验证”。 + +### 独占范围 + +- `server-node/src/**/*.test.ts` +- `scripts/**` +- 部署与运维相关文档 +- 反向代理与 smoke 测试脚本 + +### 暂不负责 + +- 具体业务模块实现 + +### 主要输出 + +- 后端接口测试 +- 关键主链路 smoke +- request/response 日志校验 +- 同域部署基线 +- 回滚、备份、迁移检查清单 + +### 验收标准 + +- `web + server` 改造过程有最小自动回归保护 +- 关键接口失败时能追踪到请求链路 + +### 并行关系 + +- 可与任务 1、任务 2、任务 3、任务 8 同时启动 +- 后续持续跟进任务 4、任务 5、任务 6、任务 7、任务 10 的交付 + +--- + +## 任务 10:前端主流程壳层与大 Hook 瘦身 + +### 目标 + +在服务端 action 和前端 SDK 稳定后,把 `GameShell`、`useStoryGeneration` 这一层改成真正的表现层协调器。 + +### 独占范围 + +- `src/hooks/useStoryGeneration.ts` +- `src/hooks/story/**` +- `src/hooks/useGameFlow.ts` +- `src/components/GameShell.tsx` +- `src/components/AdventurePanel.tsx` +- `src/components/NpcModals.tsx` +- `src/components/auth/**` + +### 暂不负责 + +- 数据库、服务端仓储 +- 编辑器 API + +### 主要输出 + +- 面向 action/view model 的前端流程层 +- 页面表现态与业务态分离 +- 大 hook 拆分后的协调层 +- 更容易测试和替换的主流程壳层 + +### 验收标准 + +- 前端主流程不再直接吞下完整运行时规则 +- 页面层主要消费后端 view model,而不是本地自算结果 + +### 并行关系 + +- 依赖任务 5、任务 6、任务 7 至少有一轮稳定输出 +- 是最后一批大规模前端接入任务 + +--- + +## 5. 推荐协作顺序 + +## 第一步:先定边界 + +先启动: + +- 任务 0 +- 任务 1 +- 任务 2 +- 任务 3 + +这一轮完成后,团队会得到: + +- 统一 contract +- 稳定数据库基线 +- 稳定后端响应壳层 +- 稳定任务分工边界 + +## 第二步:领域层和工具层分头推进 + +在第一步基础上并行启动: + +- 任务 4 +- 任务 5 +- 任务 6 +- 任务 7 +- 任务 8 +- 任务 9 + +这一轮是整个改造的主生产阶段。 + +## 第三步:最后收前端主流程 + +最后启动: + +- 任务 10 + +原因很简单: + +- 如果太早改 `useStoryGeneration` 和 `GameShell`,前端还没有稳定的 action contract 和 view model,会反复返工。 + +--- + +## 6. 建议的多人分工方式 + +如果是 4 人并行,建议: + +- 1 人负责任务 1 + 任务 0 的 contract/集成 +- 1 人负责任务 2 + 任务 3 的后端基建 +- 1 人负责任务 4 + 任务 5 的运行时主链 +- 1 人负责任务 8 + 任务 9,之后转入任务 7 或任务 10 + +如果是 6 人并行,建议: + +- 1 人负责任务 0 + 任务 1 +- 1 人负责任务 2 +- 1 人负责任务 3 + 任务 9 +- 1 人负责任务 4 +- 1 人负责任务 5 +- 1 人负责任务 6 + 任务 8 + +前端主流程任务 10 建议在第二轮由最熟悉当前 UI 壳层的人接手。 + +--- + +## 7. 合并规则建议 + +- 每条任务优先新增目录和新模块,少直接改热点文件。 +- 热点文件统一在集成窗口合并,不在多个任务里同步推进。 +- 任何任务如果需要改 `useStoryGeneration.ts`,默认先暂停并和任务 10 对齐。 +- 任何任务如果需要改 `server-node/src/routes/runtimeRoutes.ts`,默认先走任务 0 的接口冻结表。 +- 编辑器链路和正式运行时链路不要混在同一个 PR 里。 + +--- + +## 8. 一句话结论 + +这次重构最稳的并行方式不是“大家一起改前后端”,而是: + +**先用 contract、数据库基线和 HTTP 壳层把边界钉死,再让服务端领域迁移、编辑器归口、前端瘦身分轨并行,最后由主流程壳层统一接入。** diff --git a/docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md b/docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md new file mode 100644 index 00000000..7e78a1ba --- /dev/null +++ b/docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md @@ -0,0 +1,447 @@ +# Express 后端化工程重构规划(2026-04-08) + +## 1. 背景 + +当前项目已经引入 `Express` 后端,且 `server-node/` 已经承接了运行时鉴权、存档、设置、自定义世界、剧情生成、角色聊天、NPC 对话、运行时物品意图、任务生成等能力。 + +但从当前工程状态看,项目仍处于“后端已存在,但运行时领域层尚未完全脱前端”的过渡态,主要表现为: + +- 前端 `src/hooks/useStoryGeneration.ts` 仍然承担了大量运行时编排、规则拼接与状态推进职责。 +- 前端 `src/services/ai.ts` 仍然保留了完整的 AI 调用、提示词拼装和本地兜底实现。 +- 前端 `src/hooks/useGamePersistence.ts` 仍在承担较重的存档恢复、schema 纠偏与归一化职责。 +- `server-node/src/**` 当前仍在直接引用 `src/types`、`src/data`、`src/services` 中的内容,分层尚未真正闭合。 +- 编辑器相关写接口仍然散落在前端组件与 `jsonClient` 中,运行时 API 与编辑器 API 还没有完全归口。 + +现在既然已经明确“前端只负责做表现,所有逻辑、数据都放到后端进行运算和存储”,就需要把这个原则升级成整个工程的硬边界,而不是只停留在一部分接口迁移完成的状态。 + +--- + +## 2. 重构总原则 + +本轮重构只坚持一个核心原则: + +**前端不是业务执行层,而是表现层;后端才是唯一的运行时真相来源。** + +进一步展开为: + +- 前端只负责页面结构、动画演出、输入采集、局部交互态、加载态和错误态展示。 +- 后端负责鉴权、会话、规则计算、剧情推进、AI 编排、任务推进、道具结算、Build 结算、存档读写与持久化。 +- 浏览器内不再保留“正式运行时业务规则”的第二套实现。 +- 浏览器内允许存在少量纯表现计算,但不允许成为游戏状态真相来源。 +- 编辑器能力与正式运行时能力分离,避免 dev 工具链继续污染正式运行时边界。 + +--- + +## 3. 重构目标 + +## 3.1 目标状态 + +- 浏览器只发送“玩家意图”和必要的展示参数,不直接提交完整运行时真相。 +- `Express` 后端成为唯一的运行时状态源、规则执行源和 AI 调度源。 +- 运行时快照、任务状态、NPC 状态、背包、属性、Build、剧情历史全部以后端持久化结果为准。 +- 前端不再直接 import 正式运行时 AI 逻辑、提示词逻辑和关键规则逻辑。 +- `server-node` 不再依赖 `src/**` 中的前端实现细节,而是依赖独立共享层。 +- 编辑器 API、运行时 API、资产生成 API 形成清晰命名空间和权限边界。 + +## 3.2 非目标 + +- 本轮不追求一次性重写所有玩法系统。 +- 本轮不再讨论关系型数据库选型切换,当前后端以 `PostgreSQL` 为准。 +- 本轮不改动已有中文剧情、设定和文案方向。 +- 本轮不为了“前后端分离”牺牲移动端体验与当前主流程可玩性。 + +--- + +## 4. 职责边界 + +| 领域 | 前端职责 | 后端职责 | +| --- | --- | --- | +| 页面与流程壳层 | 页面切换、面板开关、布局、自适应、动效、加载态 | 不负责页面 UI | +| 用户输入 | 收集点击、拖拽、表单输入、选项选择 | 校验输入是否合法,解释输入对应的运行时动作 | +| 游戏状态 | 仅持有当前展示所需 view model 和局部 UI state | 持有完整游戏状态、快照、事件日志、版本号 | +| 剧情推进 | 展示文本流、选项、动画时间线 | 生成剧情、决定选项集合、推进故事状态 | +| 战斗与数值 | 播放攻击、受击、死亡、位移 | 计算伤害、蓝耗、CD、死亡、掉落、逃跑结果 | +| NPC/同伴交互 | 展示面板、聊天输入框、关系反馈演出 | 计算关系变化、招募条件、交易合法性、对话结果 | +| 背包/装备/Build | 展示背包、装备栏、Build 面板 | 计算背包变化、装备结果、Build 收益与约束 | +| 任务系统 | 展示任务卡片、任务进度、奖励动画 | 生成任务、推进 signal、发放奖励 | +| AI 调用 | 不直接请求正式运行时模型 | 统一做 prompt 组装、模型调用、超时重试、日志 | +| 持久化 | 最多保留极少量表现态缓存 | 负责存档、设置、用户数据、迁移、恢复 | +| 编辑器 | 调用 SDK、展示工具面板 | 负责写盘、生成任务、队列、权限与审计 | + +## 4.1 前端允许保留的状态 + +- 当前面板是否打开 +- 当前动画是否播放中 +- 当前流式文本已经显示到哪一段 +- 表单草稿、搜索词、临时筛选条件 +- 与展示相关的 viewport / media / motion 状态 + +## 4.2 前端禁止继续承载的职责 + +- function 合法性判定 +- 怪物/NPC/任务/物品结算 +- 正式运行时 prompt 组装 +- 正式运行时 AI fallback +- 存档 schema 迁移主逻辑 +- 以 `localStorage` 作为正式运行时主存储 +- 编辑器组件直接散落 `fetch('/api/...')` 访问写接口 + +--- + +## 5. 当前工程问题归纳 + +## 5.1 运行时领域逻辑仍然偏前端中心 + +- `useStoryGeneration` 仍然是大体量编排热区,承接了剧情、NPC、战斗后续、任务和部分故事引擎逻辑。 +- `src/services/ai.ts` 体量很大,说明正式运行时 AI 编排尚未完全移出浏览器。 +- 当前“后端接口 + 前端兜底”的过渡模式,容易让正式逻辑继续双份存在。 + +## 5.2 服务端分层还没真正闭合 + +- `server-node` 当前仍直接引用 `src/types`、`src/data`、`src/services`。 +- 这意味着后端虽然有了入口,但核心领域模型仍然绑在前端目录结构上。 +- 继续沿着这条路开发,会让后端无法独立测试、独立构建和独立演进。 + +## 5.3 运行时持久化边界还不够干净 + +- 虽然正式存档已经走远端接口,但前端仍承担较重的恢复、归一化、迁移纠偏逻辑。 +- 这会导致“存档解释权”同时存在于前后端两边,后续迭代容易失配。 + +## 5.4 编辑器与运行时 API 仍然混杂 + +- 编辑器读写接口目前仍然有散落访问点。 +- 资产生成、JSON 写盘、运行时 API 还没有形成清晰的接口分域。 +- 继续混用会让权限控制、生产部署和后续多人协作变得困难。 + +## 5.5 当前协议更像“接口迁移”,还不是“后端驱动运行时” + +- 目前很多接口是把已有前端逻辑搬成了远端调用入口。 +- 但真正理想状态应该是:玩家点击后,后端完成规则结算、状态推进、AI 调用和持久化,再把展示模型返回给前端。 + +--- + +## 6. 目标架构 + +```text +Browser +├─ 页面 / 动画 / 交互 / ViewModel 渲染 +├─ 轻量前端 SDK(只负责请求与状态绑定) +└─ 局部 UI State + +packages/shared +├─ contracts +├─ schemas +├─ domain-types +└─ api-client-types + +server-node +├─ src/modules/auth +├─ src/modules/runtime-session +├─ src/modules/story +├─ src/modules/combat +├─ src/modules/npc +├─ src/modules/inventory +├─ src/modules/build +├─ src/modules/quest +├─ src/modules/custom-world +├─ src/modules/editor +├─ src/shared/http +├─ src/shared/infra +└─ src/shared/llm + +storage +├─ postgres +├─ uploads +└─ generated +``` + +## 6.1 共享层原则 + +- `packages/shared` 只放类型、schema、协议、纯函数和序列化约定。 +- 共享层不放浏览器专属实现,也不放 Node 专属 IO。 +- 所有可执行运行时规则默认放后端,不放共享层。 + +## 6.2 前端目录目标 + +前端建议逐步收敛成下面的职责结构: + +```text +src/ +├─ app +├─ pages +├─ widgets +├─ features +├─ entities +├─ shared/api +├─ shared/ui +└─ shared/lib +``` + +其中: + +- `shared/api` 只保留面向后端 contract 的 SDK。 +- `features` 只组织交互流程和 UI 组合,不再承载正式运行时规则。 +- 超大 hook 逐步拆成“页面状态协调层 + 远端 action 调用层 + 表现层状态”。 + +--- + +## 7. 关键协议重构方向 + +当前最值得尽快统一的,不是继续加接口数量,而是把协议升级成“意图驱动”。 + +推荐核心动作协议: + +```json +{ + "sessionId": "runtime-session-id", + "clientVersion": 12, + "action": { + "type": "story_choice", + "functionId": "fight_attack", + "targetId": "npc_merchant_01", + "payload": { + "optionId": "opt_02" + } + } +} +``` + +后端统一返回: + +```json +{ + "sessionId": "runtime-session-id", + "serverVersion": 13, + "viewModel": {}, + "presentation": { + "storyText": "", + "options": [], + "battlePlayback": null, + "toast": null + }, + "patches": [], + "meta": { + "requestId": "req_xxx" + } +} +``` + +协议约束: + +- 前端不再提交完整 `gameState` 作为后端运算依据。 +- 前端提交的是“玩家意图”,不是“玩家已经算好的结果”。 +- 后端返回的是“下一帧该怎么演”的展示模型,而不是只回一个零散字段。 + +--- + +## 8. 分阶段重构路线 + +## P0:先冻结边界,建立共享协议层 + +### 本阶段目标 + +把“前端只做表现,后端负责运行时真相”从口头原则变成工程边界。 + +### 主要任务 + +- 提取 `shared contracts`,把 `server-node` 对 `src/**` 的依赖逐步迁出。 +- 固化统一的 API 响应结构、错误结构、`requestId`、版本字段。 +- 明确运行时 API 命名空间与编辑器 API 命名空间。 +- 新功能一律禁止再把正式运行时规则写回前端。 +- 为关键运行时入口补健康检查、日志字段、耗时统计。 + +### 交付物 + +- 共享类型与 schema 目录 +- 统一 API 约定文档 +- 服务端模块边界草图 +- 前端 SDK 基础层 + +### 验收标准 + +- `server-node` 可以不依赖 `src/**` 中的前端运行时实现继续编译。 +- 新增运行时需求不再允许“前端先写一版、后端再补一版”。 + +## P1:把运行时状态与持久化解释权收回后端 + +### 本阶段目标 + +让后端成为运行时状态、快照、恢复和迁移的唯一解释者。 + +### 主要任务 + +- 建立 `runtime session` / `snapshot aggregate`。 +- 将存档恢复、版本迁移、默认值补齐、schema 纠偏迁到后端。 +- 把前端 `useGamePersistence` 收敛为“拉取快照 + 触发保存 + 接收 view model”。 +- 设置、快照、自定义世界库统一归入运行时仓储接口。 +- 明确哪些内容允许本地缓存,哪些必须以后端结果为准。 + +### 交付物 + +- 统一的运行时 session API +- 快照版本迁移服务 +- 服务端持久化 schema 文档 + +### 验收标准 + +- 前端不再承担正式存档恢复迁移的主逻辑。 +- 同一份存档的解释权只存在于后端。 + +## P2:把核心规则结算从前端迁到后端 + +### 本阶段目标 + +把“剧情推进、战斗、NPC、任务、物品、Build”这些真正影响状态的领域结算全部后端化。 + +### 主要任务 + +- 把 function 合法性过滤迁入后端。 +- 把战斗结算、蓝耗、伤害、死亡、掉落、逃跑结果迁入后端。 +- 把 NPC 交互决策、招募条件、关系变化、交易合法性迁入后端。 +- 把任务推进 signal、奖励结算、运行时物品结果、Build 结果迁入后端。 +- 前端收到的只是一份下一步展示所需的聚合 view model 与演出计划。 + +### 交付物 + +- 运行时 action resolver +- 统一领域服务接口 +- 面向 UI 的 view model assembler + +### 验收标准 + +- 前端点击一个选项时,发送的是 action,不是本地先算完再上传结果。 +- 正式运行时的数值、资源、状态迁移不再依赖浏览器逻辑。 + +## P3:把 AI 编排彻底收口到后端 + +### 本阶段目标 + +让浏览器彻底退出正式运行时 AI 调用与 prompt 组装。 + +### 主要任务 + +- 把剧情生成、角色聊天、NPC 对话、自定义世界生成、任务生成、物品意图生成等统一后端执行。 +- 清理前端正式运行时代码中的 AI fallback。 +- 将 prompt 构造、模型容错、超时、重试、日志、SSE 转发统一收口到后端。 +- 对需要复用的 prompt 纯函数进行共享层抽取,但执行权只留在后端。 + +### 交付物 + +- 后端 AI orchestration 模块 +- 统一 SSE/streaming 适配层 +- 精简后的前端 AI SDK + +### 验收标准 + +- 浏览器正式运行时代码不再直接 import 大体量 AI 编排模块。 +- 无后端时,正式运行时不再默默回退到另一套浏览器逻辑。 + +## P4:把编辑器与资产流程独立成正式后端模块 + +### 本阶段目标 + +让编辑器能力不再作为运行时副产物存在,而是成为有边界的工具后端模块。 + +### 主要任务 + +- 建立 `/api/editor/*`、`/api/assets/*` 等明确命名空间。 +- 给编辑器写接口补权限、环境门禁、审计日志。 +- 统一编辑器 JSON 读写、资产生成、任务查询接口。 +- 前端编辑器组件全部改走统一 SDK,不再散落直连接口。 + +### 交付物 + +- editor API contract +- 统一 editor client SDK +- 生成任务与写盘适配器 + +### 验收标准 + +- 编辑器写接口不再散落在多个组件内部。 +- 运行时 API 与编辑器 API 的职责边界清晰。 + +## P5:补齐质量门禁、部署路径和观测能力 + +### 本阶段目标 + +让这次后端化重构可以稳定上线,而不是只在本地联调成立。 + +### 主要任务 + +- 为后端补单测、接口测试和关键链路 smoke。 +- 为前端补 contract 测试,确保 UI 不依赖本地规则。 +- 建立 `Nginx/Caddy -> dist + /api` 的同域部署路径。 +- 为流式接口补代理配置、超时、取消和日志。 +- 为数据库迁移、备份、回滚预留脚本。 + +### 验收标准 + +- `web + server` 可以独立构建、独立测试、联合部署。 +- 关键主流程至少具备一条可自动验证的 smoke path。 + +--- + +## 9. 具体迁移清单 + +## 9.1 优先迁移对象 + +- `src/hooks/useStoryGeneration.ts` +- `src/hooks/useGamePersistence.ts` +- `src/services/ai.ts` +- `src/services/aiService.ts` +- `src/services/storageService.ts` +- `src/services/authService.ts` +- 编辑器持久化模块与 `src/editor/shared/jsonClient.ts` + +## 9.2 优先抽离到共享层的内容 + +- 领域类型定义 +- zod schema 或等价校验协议 +- API 请求与响应 contract +- 纯序列化函数 +- 前后端都要认识的 enum / id / status 常量 + +## 9.3 不建议抽到共享层的内容 + +- 依赖数据库、文件系统、LLM、日志的服务 +- 正式运行时规则执行器 +- 存档迁移执行器 +- 资产生成任务调度器 + +--- + +## 10. 实施顺序建议 + +推荐顺序如下: + +1. 先抽共享类型与协议,切断 `server-node -> src/**` 的反向依赖。 +2. 再把运行时 session、快照解释权、存档迁移收回后端。 +3. 再迁核心规则结算,让前端从“业务执行层”退回“表现协调层”。 +4. 然后彻底收口 AI 编排,移除正式运行时浏览器 fallback。 +5. 最后归整编辑器 API、部署路径、测试门禁和观测能力。 + +不建议的顺序: + +1. 先零散把几个接口改成后端。 +2. 继续保留前端完整 fallback。 +3. 最后再补共享层和协议。 + +这个顺序会把“双份逻辑并存”的过渡期拖得很长,后面会越来越难收口。 + +--- + +## 11. 风险与控制点 + +- 最大风险不是“迁不动”,而是长期维持双份规则。 +- 后端化期间必须避免再往前端加新的正式运行时规则。 +- 协议演进要带版本号,否则快照和 UI 很容易错位。 +- 前端瘦身不能牺牲移动端一屏体验,表现层拆分仍要遵守移动端优先。 +- 编辑器 API 必须和正式运行时隔离,不要为了方便继续走混用路径。 + +--- + +## 12. 一句话结论 + +这次重构的核心不是“把几个请求改成走 Express”,而是: + +**把项目从“前端主导运行时、后端承接部分接口”的过渡架构,升级成“Express 后端统一持有运行时真相,前端只负责表现和交互”的正式工程架构。** diff --git a/docs/planning/README.md b/docs/planning/README.md index 22c7c861..0d0a5ab2 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -3,6 +3,8 @@ ## 当前入口 - [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。 +- [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。 +- [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](./EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md):将后端化重构拆成可并行推进、尽量不冲突的任务流与协作顺序。 ## 使用建议 diff --git a/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md new file mode 100644 index 00000000..254b09f5 --- /dev/null +++ b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md @@ -0,0 +1,963 @@ +# 账号系统与登录入口重构 PRD + +更新时间:`2026-04-09` + +## 0. 目标 + +这份 PRD 面向当前仓库,要解决的是一个已经非常明确的产品问题: + +**游戏开始界面目前还是“开始游戏”导向,但项目已经进入后端账号模式,真正缺的是一套正式可运营的账号登录入口和账号体系。** + +这次要完成的不是单纯把按钮文案改掉,而是把当前入口、鉴权、存档归属和跨设备使用体验一起重构成完整闭环: + +1. 游戏开始界面从“开始游戏”切换为“账号登录” +2. 支持 `手机号验证码登录` +3. 支持 `微信登录` +4. 微信登录后必须先绑定手机号,绑定完成后才能进入游戏 +5. 账号与存档、自定义世界、运行时设置统一绑定到后端用户体系 +6. 整体体验保持移动端优先、界面清爽、规则不堆满前台 + +一句话目标: + +**让“登录账号”成为进入游戏的第一步,让手机号和微信成为正式身份入口,并把账号与游戏数据归属真正接到 Express 后端。** + +--- + +## 1. 当前现状 + +## 1.1 当前仓库已经有基础鉴权,但不是正式账号系统 + +从当前代码看: + +- `src/components/auth/AuthGate.tsx` + - 当前会在无 token 时自动创建或恢复匿名账号 +- `src/services/authService.ts` + - 当前支持“用户名 + 密码”直进,且带自动生成游客凭据逻辑 +- `server-node/src/routes/authRoutes.ts` + - 当前只有 `/api/auth/entry`、`/api/auth/me`、`/api/auth/logout` +- `server-node/src/auth/authService.ts` + - 当前后端仍是“用户名不存在就自动创建,存在就校验密码”的轻量方案 +- `src/components/game-shell/PreGameSelectionFlow.tsx` + - 开始页前台主语仍然是“开始游戏 / 新游戏 / 继续游戏” + +这说明当前系统已经有“用户 ID + JWT + 用户隔离存档”,但还没有正式运营可用的: + +- 手机号体系 +- 微信身份体系 +- 绑定关系体系 +- 验证码体系 +- 会话过期与刷新体系 +- 账号归并与换绑规则 + +## 1.2 当前方案存在的直接问题 + +### 1.2.1 入口主语错位 + +当前开始页告诉玩家的是: + +- 开始游戏 + +但真实系统需要的是: + +- 先确认你是谁 +- 再把你的存档和世界数据挂到你的账号下 + +也就是说,前台入口和后端真实数据归属已经不一致。 + +### 1.2.2 匿名自动账号不适合正式用户体系 + +当前自动生成随机账号的方案适合开发期快速联调,但不适合正式版本,因为它会带来: + +- 用户无法理解自己的账号是什么 +- 跨设备登录和找回几乎不可用 +- 微信登录无法落地 +- 手机号绑定没有锚点 +- 用户会误以为“换设备 = 进度丢失” + +### 1.2.3 微信登录缺少落点 + +如果只做“微信授权成功即进入游戏”,会出现两个问题: + +1. 账号恢复能力弱 + - 一旦微信环境变化,恢复和找回仍然不稳。 +2. 数据归属不稳定 + - 仅靠微信身份,不利于后续账号运营、客服排查、风控与跨端同步。 + +因此: + +**微信登录不是最终归属,手机号绑定才是正式账号落点。** + +### 1.2.4 当前永久 JWT 不适合正式登录系统 + +现状文档已经说明当前 JWT 是永久签发。 + +这对开发期方便,但对正式账号系统有明显风险: + +- token 泄露后难以及时失效 +- 多设备会话管理能力不足 +- 换绑手机号和安全操作缺少会话边界 + +--- + +## 2. 设计原则 + +## 2.1 账号先于游戏 + +正式版本中,玩家进入游戏主流程前,应该先完成账号确认。 + +前台语义改为: + +- 未登录:账号登录 +- 已登录:继续冒险 / 选择世界 / 开始新档 + +而不是一上来先点“开始游戏”。 + +## 2.2 移动端优先,前台保持清爽 + +开始界面和登录面板要继续遵循当前项目已经沉淀出的 UI 经验: + +- 手机竖屏优先 +- 入口动作少而清晰 +- 不堆大段规则说明 +- 主按钮只保留关键动作 + +所以登录前台默认只呈现: + +- 手机号登录 +- 微信登录 +- 开发团队(次级) + +## 2.3 前端只负责表现,账号逻辑全部进 Express 后端 + +必须严格遵守当前项目约束: + +- 前端只渲染登录状态、错误态、输入态、成功态 +- 验证码发送、验证码校验、微信 OAuth、绑定决策、账号归并、token 签发全部由 Express 后端处理 + +前端不能自行决定: + +- 账号是否新建 +- 是否允许绑定 +- 是否归并到已有账号 +- 是否允许进入游戏 + +## 2.4 微信登录不是免绑定通行证 + +微信登录后,如果账号没有绑定手机号,则: + +- 允许展示绑定手机号页 +- 不允许进入游戏主流程 +- 不允许创建正式存档 +- 不允许进入世界选择和冒险流程 + +一句话约束: + +**微信授权成功 != 可以开始游戏,绑定手机号成功后才算正式激活账号。** + +## 2.5 一个手机号对应一个正式主账号 + +MVP 阶段建议采用最稳妥规则: + +- 一个手机号只能绑定一个正式账号 +- 一个微信身份只能绑定一个正式账号 +- 一个正式账号必须有已验证手机号 +- 微信是可选登录方式,手机号是正式归属方式 + +--- + +## 3. 产品范围 + +## 3.1 本期要做 + +1. 开始界面改成账号登录入口 +2. 手机号验证码登录 +3. 微信登录 +4. 微信后强制绑定手机号 +5. 账号会话管理 +6. 账号与存档/自定义世界/运行时设置统一绑定 +7. 基础账号中心与退出登录 + +## 3.2 本期不做 + +1. 用户名密码注册 +2. 游客正式入口 +3. 账号密码找回 +4. 实名认证 +5. 社交好友体系 +6. 多微信绑定同一账号 + +说明: + +当前用户名密码模式可仅保留为开发环境兜底能力,不作为正式前台入口。 + +--- + +## 4. 用户状态设计 + +建议把账号状态统一为下面 5 类: + +## 4.1 未登录 + +特征: + +- 本地无有效会话 +- 前台只显示登录入口 + +允许动作: + +- 手机号登录 +- 微信登录 +- 查看开发团队 + +不允许动作: + +- 开始游戏 +- 选择世界 +- 进入已有存档 + +## 4.2 手机号已登录 + +特征: + +- 已完成手机号验证码校验 +- 已有正式账号 + +允许动作: + +- 继续冒险 +- 开始新档 +- 选择世界 +- 查看账号信息 +- 退出登录 + +## 4.3 微信已授权但未绑定手机号 + +特征: + +- 已拿到微信身份 +- 后端已创建待激活账号壳 +- 账号状态为 `pending_bind_phone` + +允许动作: + +- 绑定手机号 +- 切换其他登录方式 +- 退出当前授权态 + +不允许动作: + +- 进入游戏 +- 创建正式存档 +- 使用正式运行时能力 + +## 4.4 微信已授权且已绑定手机号 + +特征: + +- 微信身份已归属到正式账号 +- 手机号已验证 + +允许动作: + +- 与手机号登录用户相同 + +## 4.5 会话过期或失效 + +特征: + +- access token 过期 +- refresh session 无效 +- 或账号状态被强制失效 + +表现: + +- 前台回到登录入口 +- 如果是绑定中状态失效,则回到重新登录 + +--- + +## 5. 开始界面重构方案 + +## 5.1 未登录状态下的开始界面 + +当前开始界面的主按钮“开始游戏”应被替换为“账号登录”语义。 + +建议界面结构: + +### 顶部 + +- 游戏名 +- 一句极短副标题 + +副标题只保留类似: + +- 登录后同步你的冒险进度 + +不要再出现规则说明式长文案。 + +### 中部主按钮 + +只保留两个一级主按钮: + +1. `手机号登录` +2. `微信登录` + +按钮样式要求: + +- 手机端纵向堆叠 +- 桌面端仍以单列为主,不做复杂双栏表单 +- 点击目标区域足够大 +- 视觉主语明确是“登录方式”,不是“功能菜单” + +### 底部次级入口 + +- `开发团队` + +联系方式面板不建议再作为开始页主信息块长期占据核心位置,避免稀释登录主动作。 + +## 5.2 微信未绑定状态下的开始界面 + +微信授权成功但未绑定手机号时,开始界面应切成“待激活账号”形态。 + +只展示: + +- 微信头像/昵称(可选) +- 当前提示:请绑定手机号后进入游戏 +- 绑定手机号按钮 +- 返回其他登录方式按钮 + +这个状态下不要显示: + +- 继续游戏 +- 新游戏 +- 世界选择 + +## 5.3 已登录状态下的开始界面 + +当账号已正式激活后,开始界面才恢复游戏入口动作,但按钮文案要重新组织。 + +建议一级动作: + +- `继续冒险`(存在存档时) +- `开始新档` +- `选择世界` + +建议次级动作: + +- `账号设置` +- `退出登录` +- `开发团队` + +也就是说: + +**“开始游戏”不再是未登录态按钮,而是登录后的游戏内动作集合。** + +--- + +## 6. 核心功能设计 + +## 6.1 手机号验证码登录 + +### 交互 + +用户输入手机号后: + +1. 点击发送验证码 +2. 收到 6 位短信验证码 +3. 输入验证码完成登录 + +### 规则 + +- 验证码有效期:`5` 分钟 +- 重新发送冷却:`60` 秒 +- 单手机号日发送上限:需配置 +- 单 IP / 单设备分钟级频控:需配置 +- 验证成功后自动登录 + +### 账号策略 + +- 手机号已存在:登录已有账号 +- 手机号不存在:创建正式账号并登录 + +MVP 阶段不需要单独设置密码。 + +## 6.2 微信登录 + +微信登录按终端拆分: + +### 微信内 H5 + +- 走微信 OAuth 授权 +- 后端换取 `code` +- 优先获取 `unionid` + +### 桌面网页 + +- 走微信扫码登录 +- 用户扫码确认后由后端完成登录回调 + +### 普通手机浏览器 + +如果不在微信环境内,不强行做复杂跳转,建议: + +- 优先提示使用手机号登录 +- 或提示“请在微信内打开以使用微信登录” + +这样比做一套体验不稳定的浏览器跳转更稳。 + +### 微信账号策略 + +- 若微信身份已绑定正式账号:直接登录 +- 若微信身份首次出现:创建 `pending_bind_phone` 账号壳,并进入绑定手机号流程 + +## 6.3 微信后绑定手机号 + +这是本期最关键规则。 + +### 绑定目标 + +把微信身份最终归属到一个已验证手机号的正式账号上。 + +### 绑定流程 + +1. 用户完成微信授权 +2. 前台进入“绑定手机号”页 +3. 输入手机号 +4. 获取短信验证码 +5. 后端校验验证码并处理账号归属 + +### 绑定结果分两类 + +#### 情况 A:手机号未被使用 + +结果: + +- 将该手机号绑定到当前微信待激活账号 +- 账号转为正式激活 +- 登录成功,进入游戏开始界面已登录态 + +#### 情况 B:手机号已绑定已有正式账号 + +建议采用最稳的归并策略: + +1. 短信验证码校验通过 +2. 后端确认该手机号已有正式账号 +3. 将当前微信身份直接绑定到该正式账号 +4. 废弃当前待激活账号壳 +5. 用户登录到已有手机号账号 + +这样做的好处是: + +- 规则简单 +- 不需要做复杂数据合并 +- 因为未绑定手机号前禁止进入游戏,所以待激活账号壳上不会挂正式存档数据 + +## 6.4 账号中心 + +MVP 阶段建议至少提供一个轻量账号中心,包含: + +- 当前登录方式 +- 已绑定手机号(脱敏展示) +- 微信绑定状态 +- 最近登录时间 +- 退出登录 + +二期可以再补: + +- 更换手机号 +- 退出全部设备 +- 查看登录设备 + +## 6.5 存档与数据归属 + +当前项目已有: + +- 存档快照 +- 自定义世界库 +- 运行时设置 + +这些数据已经在后端按用户隔离。 + +本期要求是把它们统一稳定归属到正式账号,而不是匿名随机用户。 + +也就是说: + +- 手机号登录后,数据归属到该账号 +- 微信登录并绑定手机号后,数据归属到绑定后的正式账号 +- 同一账号跨设备登录,应读取同一份用户数据 + +--- + +## 7. 后端账号模型设计 + +## 7.1 设计思路 + +建议保留当前 `users` 主表,但把“登录方式”和“账号归属”拆到独立身份表中。 + +正式模型应区分: + +- 用户主实体 +- 登录身份 +- 验证码记录 +- 会话记录 + +## 7.2 建议数据表 + +### `users` + +建议保留并扩展: + +- `id` +- `display_name` +- `account_status` +- `primary_phone` +- `phone_verified_at` +- `token_version` +- `created_at` +- `updated_at` +- `last_login_at` + +其中: + +- `account_status` 需至少支持: + - `active` + - `pending_bind_phone` + - `disabled` + +### `auth_identities` + +用于记录登录身份。 + +建议字段: + +- `id` +- `user_id` +- `provider` +- `provider_uid` +- `provider_unionid` +- `phone_e164` +- `is_verified` +- `is_primary` +- `bound_at` +- `last_login_at` +- `meta_json` + +其中 `provider` 至少支持: + +- `phone` +- `wechat` + +说明: + +- 手机号登录身份存 `phone_e164` +- 微信登录身份优先存 `unionid` +- 若极端场景拿不到 `unionid`,需明确风险并做降级策略,但正式上线应优先确保 `unionid` + +### `phone_verification_codes` + +用于短信验证码管理。 + +建议字段: + +- `id` +- `phone_e164` +- `scene` +- `code_hash` +- `expires_at` +- `consumed_at` +- `send_ip` +- `device_id` +- `created_at` + +`scene` 建议至少区分: + +- `login` +- `bind_phone` +- `change_phone` + +### `user_sessions` + +用于会话刷新和设备管理。 + +建议字段: + +- `id` +- `user_id` +- `refresh_token_hash` +- `client_type` +- `user_agent` +- `ip` +- `expires_at` +- `revoked_at` +- `created_at` +- `last_seen_at` + +## 7.3 与当前仓库的关系 + +当前仓库已有: + +- `users` +- JWT 鉴权 +- `token_version` + +因此本期不是推翻重做,而是: + +1. 保留 `users` 作为账号主表 +2. 废弃“用户名密码自动注册”作为正式入口 +3. 增加手机号与微信身份层 +4. 增加验证码表与会话表 + +--- + +## 8. 接口设计 + +所有接口均由 Express 后端承接。 + +## 8.1 手机号登录相关 + +### `POST /api/auth/phone/send-code` + +用途: + +- 发送登录验证码 + +入参: + +- `phone` +- `scene` + +出参: + +- `ok` +- `cooldownSeconds` + +### `POST /api/auth/phone/login` + +用途: + +- 使用手机号 + 验证码登录或注册 + +入参: + +- `phone` +- `code` + +出参: + +- `accessToken` +- `accessTokenExpiresAt` +- `user` +- `bindingStatus` + +## 8.2 微信登录相关 + +### `GET /api/auth/wechat/start` + +用途: + +- 获取微信授权跳转地址或扫码会话 + +### `GET /api/auth/wechat/callback` + +用途: + +- 微信授权完成后的后端回调 + +回调结果分两类: + +1. 已绑定正式账号 + - 直接签发会话 +2. 未绑定手机号 + - 签发临时登录态,并标记 `pending_bind_phone` + +### `POST /api/auth/wechat/bind-phone` + +用途: + +- 微信授权后绑定手机号 + +入参: + +- `phone` +- `code` + +出参: + +- `accessToken` +- `accessTokenExpiresAt` +- `user` +- `bindingStatus: active` + +## 8.3 会话与账号信息 + +### `GET /api/auth/me` + +返回建议扩展为: + +- `user` +- `bindingStatus` +- `boundPhoneMasked` +- `wechatBound` +- `availableLoginMethods` + +### `POST /api/auth/logout` + +用途: + +- 当前设备退出登录 + +### `POST /api/auth/refresh` + +用途: + +- 刷新 access token + +说明: + +当前仓库已有 Bearer token 调用链,MVP 阶段可继续保留 Bearer access token 的前端接法,但不应再使用永久 token。 + +--- + +## 9. 会话与安全设计 + +## 9.1 token 策略 + +建议从“永久 JWT”切为: + +- `access token`:短期有效 +- `refresh session`:中期有效 + +建议时长: + +- access token:`2` 小时 +- refresh session:`30` 天 + +## 9.2 失效策略 + +- 退出登录:失效当前 refresh session,并提升 `token_version` +- 账号异常:可强制失效全部 session +- 换绑手机号:旧高风险会话应重新校验 + +## 9.3 验证码风控 + +至少要有: + +- 发送频控 +- 校验次数限制 +- 图形验证码扩展点 +- 日志审计 + +## 9.4 微信安全要求 + +- 校验 `state` +- 回调只能由后端处理 +- 不在前端直接换 token +- 优先使用 `unionid` 作为跨应用稳定身份标识 + +--- + +## 10. 详细用户流程 + +## 10.1 手机号登录流程 + +1. 用户打开游戏开始界面 +2. 点击 `手机号登录` +3. 输入手机号 +4. 点击 `获取验证码` +5. 输入验证码 +6. 后端校验验证码 +7. 若账号存在则登录,若不存在则创建正式账号 +8. 返回已登录开始界面 +9. 用户再选择 `继续冒险 / 开始新档 / 选择世界` + +## 10.2 微信登录流程 + +1. 用户打开游戏开始界面 +2. 点击 `微信登录` +3. 进入微信授权或扫码授权 +4. 后端拿到微信身份 +5. 若该微信已绑定正式账号,则直接登录 +6. 若该微信未绑定正式账号,则进入 `绑定手机号` 页 +7. 用户完成手机号验证码校验 +8. 后端完成绑定或归并 +9. 返回已登录开始界面 + +## 10.3 微信绑定已有手机号账号流程 + +1. 用户微信首次登录 +2. 系统要求绑定手机号 +3. 用户输入一个已注册手机号 +4. 用户完成短信验证码校验 +5. 后端识别该手机号已有正式账号 +6. 系统将当前微信身份绑定到该正式账号 +7. 当前待激活账号壳废弃 +8. 用户直接登录到已有正式账号 + +--- + +## 11. 前端实现要求 + +## 11.1 前端只维护这些状态 + +前端主要只需要表现这些状态: + +- `idle` +- `sending_code` +- `code_sent` +- `wechat_authorizing` +- `pending_bind_phone` +- `authenticated` +- `session_expired` +- `error` + +## 11.2 前端不做业务裁决 + +前端不能自行决定: + +- 当前手机号是新账号还是老账号 +- 微信是否允许直进 +- 当前绑定是“绑定”还是“归并” +- 当前账号是否已经正式激活 + +前端只根据后端返回的: + +- `bindingStatus` +- `availableLoginMethods` +- `user` +- `errorCode` + +来切换页面表现。 + +## 11.3 与当前代码结构的建议映射 + +建议优先改造这些区域: + +- `src/components/auth/AuthGate.tsx` + - 去掉正式环境的匿名自动登录逻辑 +- `src/components/auth/LoginScreen.tsx` + - 重做为手机号 / 微信双入口 +- `src/components/game-shell/PreGameSelectionFlow.tsx` + - 将未登录态入口从“开始游戏”调整为“账号登录后进入” +- `src/services/authService.ts` + - 改为手机号、微信、绑定手机号、刷新会话等服务层 +- `src/services/apiClient.ts` + - 增加 refresh / 过期处理 +- `packages/shared/src/contracts/auth.ts` + - 扩展新的鉴权 contract + +后端建议优先改造: + +- `server-node/src/routes/authRoutes.ts` +- `server-node/src/auth/authService.ts` +- `server-node/src/repositories/userRepository.ts` +- `server-node/src/middleware/auth.ts` + +--- + +## 12. 分阶段落地建议 + +## 阶段 A:登录入口重构 + 手机号登录 MVP + +目标: + +- 开始界面从“开始游戏”切为“账号登录” +- 手机号验证码登录可用 +- 正式环境关闭匿名自动账号 + +建议同时保留一个仅开发环境可开的兜底开关: + +- `AUTH_ALLOW_DEV_GUEST=true` + +但默认不开,不进入正式前台。 + +## 阶段 B:微信登录 + 强制绑定手机号 + +目标: + +- 微信授权可用 +- 微信首次登录后强制绑定手机号 +- 已有手机号账号与微信身份可自动归并 + +## 阶段 C:会话完善 + 账号中心 + +目标: + +- access/refresh 机制稳定 +- 账号中心可查看绑定状态 +- 支持更稳的退出登录与过期恢复 + +## 阶段 D:安全与运营补强 + +目标: + +- 更细的风控策略 +- 登录审计 +- 换绑手机号 +- 退出全部设备 + +--- + +## 13. 验收标准 + +做到以下几点,才说明这套账号系统真正成立。 + +## 13.1 入口验收 + +1. 未登录时,开始界面不再出现“开始游戏”作为主按钮。 +2. 未登录时,前台主动作只剩手机号登录和微信登录。 +3. 已登录后,才出现继续冒险、开始新档、选择世界等游戏动作。 + +## 13.2 功能验收 + +1. 手机号验证码登录可稳定完成注册/登录。 +2. 微信首次登录后不能直接进入游戏,必须绑定手机号。 +3. 微信绑定一个已存在手机号时,能够归并到已有正式账号。 +4. 同一正式账号在不同设备登录后能看到同一份用户数据。 + +## 13.3 安全验收 + +1. 不再使用永久有效 access token。 +2. 验证码具备有效期、冷却和频控。 +3. 退出登录后旧会话不能继续访问受保护接口。 + +## 13.4 体验验收 + +1. 手机竖屏下登录入口一屏内就能看懂并操作。 +2. 登录页文案简洁,不堆规则说明。 +3. 微信未绑定状态下,用户能清楚知道下一步就是绑定手机号。 + +--- + +## 14. 为什么这套方案适合当前仓库 + +这套 PRD 不是凭空换一套系统,而是顺着当前仓库现状做升级: + +1. 当前后端已经具备 `users + JWT + 用户隔离存档` + - 所以正式账号体系不是从零开始。 +2. 当前前端已经有 `AuthGate + authService` + - 所以登录入口和登录态切换已有承载点。 +3. 当前项目是移动端优先 + - 所以“两个主按钮 + 一个绑定流程”的轻量方案比厚重注册页更适合。 +4. 当前项目明确要求前端只做表现、逻辑下沉 Express + - 所以这套方案把所有裁决都压到后端,和现有工程原则一致。 + +--- + +## 15. 最后结论 + +当前项目真正需要的,不是把“开始游戏”按钮换成另一个文案,而是把进入游戏的第一步从“点开始”升级成“确认正式账号身份”。 + +最合理的产品路径是: + +1. 用手机号验证码建立正式账号主入口 +2. 用微信登录补充更低摩擦的登录方式 +3. 用“微信后必须绑定手机号”保证账号归属稳定 +4. 用后端统一承接身份、绑定、会话、存档归属 + +这样改完之后,玩家进入游戏时感知到的将不再是: + +- “先点开始再说” + +而会更接近: + +- “先登录我的正式账号,再继续我的冒险进度。” diff --git a/docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md b/docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md new file mode 100644 index 00000000..0e205057 --- /dev/null +++ b/docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md @@ -0,0 +1,1102 @@ +# AI 原生场景章节内玩法设计 PRD 与执行方案 + +更新时间:`2026-04-08` + +## 0. 文档目的 + +这份文档建立在以下设计之上: + +- `docs/design/SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md` +- `docs/design/SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md` +- `docs/prd/AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md` +- `docs/prd/AI_NATIVE_TASK_DRIVEN_GOAL_EXPERIENCE_PRD_2026-04-07.md` +- `docs/prd/AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md` + +专门回答下面这个落地问题: + +**如果每个场景都视为一个章节,那么章节内部的玩法内容应该怎样设计,才能既对标《仙剑》《博德之门》《黑神话:悟空》的章节体验,又符合当前项目“AI 负责叙事表达、Node 后端负责逻辑与状态、前端只做表现”的开发约束。** + +这份文档不是抽象设计稿,而是一份可以直接排期的: + +1. 章节内玩法 PRD +2. 服务端编排方案 +3. 内容生产方案 +4. 分阶段执行方案 + +--- + +## 1. 目标 + +本次要解决的不是“每个场景有没有任务”,而是: + +**每个场景章节有没有真正成立的一章体验。** + +要让每个章节至少同时做到: + +1. 有一个会被记住的人 +2. 有一段会逐步加压的路 +3. 有一次会暴露立场的选择 +4. 有一个能把本章收住的高潮 +5. 有一条能延续到后面的余波 + +一句话目标: + +**把当前“场景章节化”从结构成立,推进到玩法体验成立。** + +--- + +## 2. 当前问题判断 + +结合现有设计和主链,当前章节体验已经有: + +1. `ChapterState` +2. `QuestContract / QuestStep / Goal Stack` +3. `Scene residues / treasure hints / NPC 首遇 / 同伴关系` +4. `Node 后端承接运行时 AI 与持久化` + +但当前单章节仍然普遍存在 6 个问题: + +1. 章节有 lead,但缺人物情感锚点 +2. 章节有任务推进,但缺中途改判 +3. 章节有残痕素材,但缺路线承压链 +4. 章节有选项,但缺立场型选择与强反馈 +5. 章节能结束,但缺高潮动作面 +6. 章节会 handoff,但缺余波回响 + +当前玩家更容易感受到的是: + +- 我到了一个新地点 +- 我接了一个任务 +- 我做了一步推进 + +还不够容易感受到: + +- 我刚刚走完了这一章 +- 我在这一章里和谁发生了变化 +- 我这一章的判断、代价、战斗、路和收尾是连着的 + +--- + +## 3. 设计原则 + +## 3.1 场景章节必须是“可游玩的闭环” + +每个场景章节必须回答: + +1. 这章是谁在立题 +2. 这章压的是什么 +3. 这章中途哪里改判 +4. 这章最后靠什么收住 +5. 这章留下些什么进入后续 + +## 3.2 前端只负责表现,玩法逻辑全部放 Node 后端 + +必须继续遵守项目约束: + +1. Express / Node 后端负责章节编排、选择裁决、关系变化、奖励编译、回响写回 +2. 前端只负责展示章节目标、路线节拍、同伴反应、高潮演出和余波结果 + +## 3.3 AI 负责叙事表达,本地规则负责裁决 + +AI 可负责: + +1. 章节 promise 文本 +2. 情感锚点人物说辞 +3. 残痕、地标、证物的叙事表达 +4. 立场选项话术 +5. 同伴反应语气 +6. aftermath 摘要 + +本地规则必须负责: + +1. 章节阶段推进 +2. 选择结果裁决 +3. 好感与立场更新 +4. route beat 顺序与触发 +5. 高潮触发条件 +6. 物品 / 证物 / 关系 / chronicle 回写 + +## 3.4 不做无限分支,做有限模块化强反馈 + +每章不追求无限解,而是追求: + +1. 有限模块 +2. 稳定编排 +3. 明确反馈 +4. 后续回响 + +更准确地说: + +**每章至少做一处分歧,不必每处都做爆炸分支。** + +## 3.5 章节玩法优先服务移动端体验 + +因为项目移动端优先,章节内玩法表达必须满足: + +1. 首屏就能看懂当前章在追什么 +2. 不靠厚重面板堆玩法说明 +3. 目标、立场、反应、高潮反馈都以轻量组件表达 + +--- + +## 4. 单章节完成定义 + +如果说一个场景章节已经达到可上线标准,至少应同时满足: + +1. 有一名 `情感锚点人物` +2. 有 `2~3` 个 `route pressure beats` +3. 有一处 `承压事件` +4. 有一处 `改判节点` +5. 有一处 `立场选择` +6. 有一次 `同伴反应批次` +7. 有一个 `高潮动作面` +8. 有一个 `章节奖励载体` +9. 有一条 `余波回响` + +这九项就是章节内玩法的最小标准包。 + +--- + +## 5. 章节内九件套玩法设计 + +## 5.1 开章钩子 + +### 目标 + +让玩家一进场景就知道: + +1. 这里现在有事 +2. 这章的问题是什么 +3. 第一件该做的事是什么 + +### 产品要求 + +每章的 `opening` 至少使用下面三者之一立题: + +1. 错位对白 +2. 现场异常 +3. 残痕 / 证物 / 旧物线索 + +### 运行时要求 + +服务端在玩家首次进入场景时生成: + +1. `chapterTitle` +2. `chapterPromise` +3. `openingHookText` +4. `firstLeadStep` + +### 前端表现 + +1. 章节开启 pulse +2. 轻量章节标题 +3. 一句当前 lead + +## 5.2 情感锚点 + +### 目标 + +让这一章不是“在某地做任务”,而是“围绕某个人发生了一段事”。 + +### 产品要求 + +每章必须指定一个 `emotionalAnchorNpcId`,它至少承担下面两件事中的两件: + +1. 开章立题 +2. 中段制造错位 +3. 转折改口 +4. 结尾留余波 + +### 玩法要求 + +这个人物至少要有: + +1. 一句让玩家记住的表层说辞 +2. 一个不愿直说的压力源 +3. 一处中途改口或暴露破绽 +4. 一条结尾口风变化或关系变化 + +### 后端要求 + +生成并维护: + +1. `anchorPressure` +2. `anchorContradiction` +3. `anchorRevealGate` +4. `anchorAftermathState` + +## 5.3 路线压力链 + +### 目标 + +补齐章节的“路感”和“压迫感”。 + +### 产品要求 + +每章至少配置 `2~3` 个 `routePressureBeat`,类型应覆盖下列之一: + +1. 地标记忆点 +2. 残痕调查点 +3. 阻拦 / 敌对点 +4. 试探 / 误导点 + +### 玩法要求 + +路线链必须满足: + +1. 越接近高潮,信息密度或危险度越高 +2. 至少有一个 beat 直接推动改判 +3. 至少有一个 beat 能回写到后续章节 + +### 后端要求 + +服务端要编排: + +1. beat 顺序 +2. beat 触发条件 +3. beat 已读 / 已解 / 已跳过状态 +4. beat 对 quest / chapter stage 的推进贡献 + +## 5.4 承压事件 + +### 目标 + +证明这章不是空壳,玩家需要处理真实压力。 + +### 产品要求 + +每章至少有一个承压事件,优先从下面四类中选: + +1. 调查异常 +2. 对峙阻拦 NPC +3. 强制遭遇 / 战斗 +4. 时间感 / 风险感推进 + +### 运行时要求 + +承压事件必须能改变至少一项后台状态: + +1. `quest step` +2. `chapter stage` +3. `companion reaction opportunity` +4. `knowledge fact visibility` + +## 5.5 改判节点 + +### 目标 + +让章节不只是确认原有判断,而是发生一次理解变化。 + +### 产品要求 + +每章至少一处 `turningEvidence`,优先从下面三类里做: + +1. 反证 +2. 旧痕翻案 +3. 关键人物说辞破口 + +### 玩法要求 + +改判后必须改变至少一项: + +1. 当前目标描述 +2. 当前立场选项 +3. 情感锚点人物定位 +4. 高潮对象或高潮理由 + +## 5.6 立场选择 + +### 目标 + +让玩家在一章里真正表达立场。 + +### 产品要求 + +每章至少一次 `stanceChoice`,推荐围绕: + +1. 信谁 +2. 保谁 +3. 公开还是隐瞒 +4. 立刻动手还是继续查 + +### 选择设计要求 + +每次立场选择推荐提供 `2~3` 个选项,且每个选项都要有: + +1. 明确倾向 +2. 明确代价 +3. 明确反应对象 + +### 后端要求 + +选择结果必须写回: + +1. `CompanionStanceProfile` +2. `QuestLogEntry` +3. `Knowledge visibility` +4. `aftermath echo` + +## 5.7 同伴反应 + +### 目标 + +补齐《博德之门》式“有限分歧 + 强反馈”。 + +### 产品要求 + +每章至少一次 `reaction batch`,同伴可输出: + +1. 认可 +2. 反对 +3. 担忧 +4. 沉默 +5. 追问 + +### 运行时要求 + +同伴反应不只写一句对白,还要能改变: + +1. trust +2. warmth +3. ideologicalFit +4. fearOrGuard +5. loyalty + +### 前端表现 + +1. 聊天流内短反馈 +2. 章节结尾简短追加评论 +3. 必要时进入后续私聊或营地事件机会 + +## 5.8 高潮动作面 + +### 目标 + +让章节不是静态完成,而是靠一个动作或冲突收住。 + +### 产品要求 + +每章 `climax` 至少落成下面四类之一: + +1. 正面对峙 +2. 强敌 / 小型试炼 +3. 真相交付 +4. 关系摊牌 + +### 玩法要求 + +高潮一定要带来至少两个结果: + +1. 当前章局部问题收束 +2. 下一章 handoff 或世界余波被明确交出 + +## 5.9 余波回响 + +### 目标 + +让章节结束后仍然有后劲。 + +### 产品要求 + +每章至少留下一种回响: + +1. 章节证物 +2. 章节遗物 +3. NPC 口风变化 +4. 同伴追加评论 +5. chronicle 记录 + +### 后端要求 + +余波必须进入至少一个系统: + +1. `storyEngineMemory` +2. `chronicle` +3. `runtime item / carrier` +4. `npc aftermath state` +5. `followup thread` + +--- + +## 6. 章节类型模板 + +建议每个章节都标记一个 `主类型` 和一个 `副类型`,不要所有场景都平均铺。 + +## 6.1 情感章 + +主对标:`仙剑` + +### 重点 + +1. 人物关系变化 +2. 情感错位 +3. 改判后的余波 + +### 必选模块 + +1. 情感锚点 +2. 改判节点 +3. 余波回响 + +## 6.2 抉择章 + +主对标:`博德之门` + +### 重点 + +1. 立场表达 +2. 同伴反应 +3. 有限分歧结算 + +### 必选模块 + +1. 立场选择 +2. 同伴反应 +3. 后果写回 + +## 6.3 试炼章 + +主对标:`黑神话:悟空` + +### 重点 + +1. 路线承压 +2. 地标残痕 +3. 强冲突高潮 + +### 必选模块 + +1. 路线压力链 +2. 承压事件 +3. 高潮动作面 + +## 6.4 混合章 + +### 重点 + +1. 用主类型决定章节主要记忆点 +2. 用副类型补另一条体验轴 + +建议组合: + +1. `情感 + 抉择` +2. `情感 + 试炼` +3. `抉择 + 试炼` + +--- + +## 7. 章节数据结构设计 + +## 7.1 新增章节体验画像 + +建议新增: + +```ts +export interface ChapterExperienceProfile { + sceneId: string; + chapterId: string; + chapterArchetype: 'emotion' | 'choice' | 'trial' | 'hybrid'; + secondaryArchetype?: 'emotion' | 'choice' | 'trial' | null; + chapterPromise: string; + emotionalAnchorNpcId?: string | null; + guideNpcId?: string | null; + opposingForceId?: string | null; + routePressureBeatIds: string[]; + turningEvidenceIds: string[]; + stanceChoiceId?: string | null; + climaxSetpieceId?: string | null; + rewardCarrierSeed?: string | null; + aftermathEchoTargetIds: string[]; +} +``` + +## 7.2 新增路线 beat + +```ts +export interface ChapterRoutePressureBeat { + id: string; + sceneId: string; + beatType: 'landmark' | 'residue' | 'blocker' | 'enemy' | 'misdirection'; + title: string; + pressureText: string; + linkedFactIds: string[]; + linkedThreadIds: string[]; + triggerSignalIds: string[]; + completionSignalIds: string[]; +} +``` + +## 7.3 新增立场选择结构 + +```ts +export interface ChapterStanceChoice { + id: string; + sceneId: string; + title: string; + choiceType: 'trust' | 'reveal' | 'protect' | 'attack_now' | 'delay'; + options: Array<{ + id: string; + label: string; + consequenceSummary: string; + reactionBias: 'approve' | 'disapprove' | 'concern' | 'mixed'; + unlockFactIds?: string[]; + hideFactIds?: string[]; + aftermathTags?: string[]; + }>; +} +``` + +## 7.4 新增高潮结构 + +```ts +export interface ChapterClimaxSetpiece { + id: string; + sceneId: string; + setpieceType: 'confrontation' | 'trial' | 'reveal' | 'relationship_showdown'; + title: string; + preludeText: string; + resolutionModes: string[]; + aftermathSummary: string; +} +``` + +## 7.5 新增余波结构 + +```ts +export interface ChapterAftermathEcho { + id: string; + sceneId: string; + echoType: 'npc_attitude' | 'carrier' | 'chronicle' | 'followup_hook' | 'companion_comment'; + targetId?: string | null; + summary: string; + linkedThreadIds: string[]; +} +``` + +--- + +## 8. 服务端模块方案 + +当前唯一有效服务端是 `server-node/`,本次章节玩法逻辑建议全部接在这里。 + +## 8.1 建议新增模块 + +建议新增目录: + +```text +server-node/src/modules/runtime/chapter/ +├─ chapterExperienceDirector.ts +├─ chapterExperienceCompiler.ts +├─ chapterRoutePressureDirector.ts +├─ chapterChoiceResolver.ts +├─ chapterReactionDirector.ts +├─ chapterSetpieceDirector.ts +├─ chapterEchoRouter.ts +├─ chapterTypes.ts +└─ chapterPrompts.ts +``` + +## 8.2 模块职责 + +### `chapterExperienceDirector.ts` + +负责: + +1. 根据 `sceneId + currentChapter + activeThreads + partyState` + 生成当前章节体验画像 +2. 裁决本章主副类型 +3. 输出本章 promise、情感锚点、路线轴、立场轴、高潮轴 + +### `chapterRoutePressureDirector.ts` + +负责: + +1. 编排 route beats +2. 判断当前 beat 是否已触发、已推进、已完成 +3. 产出下一步路线 lead + +### `chapterChoiceResolver.ts` + +负责: + +1. 生成当前可用立场选项 +2. 裁决选择结果 +3. 回写关系、任务、知识、余波 + +### `chapterReactionDirector.ts` + +负责: + +1. 生成同伴 reaction batch +2. 写回 `CompanionStanceProfile` +3. 决定是否派生私聊 / 营地事件机会 + +### `chapterSetpieceDirector.ts` + +负责: + +1. 判断高潮是否到达触发点 +2. 产出高潮前奏、高潮执行、高潮收束结果 + +### `chapterEchoRouter.ts` + +负责: + +1. 把本章结果写回 chronicle +2. 生成章节证物 / 遗物 / NPC 态度变化 +3. 给下一章提供 echo hook + +--- + +## 9. 服务端接口方案 + +## 9.1 建议新增接口 + +在 `/api/runtime` 下建议增加: + +1. `GET /api/runtime/chapters/current` + - 获取当前章节体验数据 +2. `POST /api/runtime/chapters/advance` + - 推进章节 beat / 事件 +3. `POST /api/runtime/chapters/resolve-choice` + - 提交立场选择并获取反应结果 +4. `POST /api/runtime/chapters/resolve-setpiece` + - 结算高潮 +5. `GET /api/runtime/chapters/aftermath` + - 获取本章余波摘要 + +## 9.2 `GET /api/runtime/chapters/current` 返回建议 + +```ts +interface CurrentChapterRuntimePayload { + chapter: ChapterState | null; + experienceProfile: ChapterExperienceProfile | null; + routeBeats: ChapterRoutePressureBeat[]; + activeChoice: ChapterStanceChoice | null; + pendingReactionBatch: CompanionReactionRecord[]; + activeSetpiece: ChapterClimaxSetpiece | null; + aftermathPreview: ChapterAftermathEcho[]; +} +``` + +## 9.3 `POST /api/runtime/chapters/resolve-choice` + +输入: + +```ts +{ + chapterId: string; + choiceId: string; + optionId: string; +} +``` + +输出: + +```ts +{ + updatedQuest: QuestLogEntry | null; + updatedChapter: ChapterState | null; + reactions: CompanionReactionRecord[]; + unlockedFacts: string[]; + aftermathEchoes: ChapterAftermathEcho[]; +} +``` + +--- + +## 10. 与现有系统接入关系 + +## 10.1 与 `ChapterState` + +`ChapterState` 继续负责: + +1. 章节标题 +2. 章节主题 +3. 当前阶段 +4. 章节总结 + +`ChapterExperienceProfile` 负责补: + +1. 本章到底靠谁成立 +2. 本章的路怎么压 +3. 本章的选择在哪里 +4. 本章的高潮与余波是什么 + +## 10.2 与 `QuestLogEntry` + +章节任务仍然是前台动作壳层,建议补: + +1. `chapterId` +2. `linkedChoiceIds` +3. `linkedBeatIds` +4. `setpieceId` + +章节任务负责: + +1. 玩家现在做什么 +2. 这一步推进到了哪里 + +章节体验画像负责: + +1. 这一章为什么成立 +2. 为什么这一跳重要 + +## 10.3 与 `Goal Stack` + +Goal Stack 继续作为前台目标感编译层: + +1. `northStarGoal` + - 用章节 promise +2. `activeGoal` + - 用当前章节任务或当前承压事件 +3. `immediateStep` + - 用当前 route beat 或当前选择前置动作 + +## 10.4 与 `KnowledgeFact / VisibilitySlice` + +章节玩法要直接消费: + +1. 可见事实 +2. 可推测事实 +3. 禁止提前说出的事实 + +这样才能保证: + +1. 改判节点不是硬拐 +2. 立场选择不是全知选择 +3. 情感锚点人物不会首遇就泄露完整背景 + +--- + +## 11. 章节内容生产方案 + +## 11.1 单章节设计卡模板 + +建议内容制作统一使用: + +```md +- 章节标题: +- 场景 ID: +- 主类型 / 副类型: +- 本章 promise: +- 情感锚点人物: +- guide / opposition: +- 开章钩子: +- route beat 1: +- route beat 2: +- route beat 3: +- 承压事件: +- 改判证据: +- 立场选择: +- 同伴反应重点: +- 高潮 setpiece: +- 章节奖励载体: +- 余波回响: +- 下一章 handoff: +``` + +## 11.2 内容制作流程 + +建议每个场景按下面顺序补齐: + +1. 先定主副类型 +2. 再定情感锚点人物 +3. 再补路线压力链 +4. 再补改判证据 +5. 再补立场选择 +6. 再补高潮收束 +7. 最后补余波与 handoff + +顺序不能反过来。 + +如果先写高潮、再补人物和路,最后很容易只剩“任务收尾”。 + +## 11.3 内容生产验收卡 + +每章出稿后,内容侧要先自检: + +1. 玩家记不记得住一个人 +2. 玩家中段会不会改判 +3. 玩家路上能不能记住一处地标 / 残痕 +4. 玩家有没有一次明确表达立场 +5. 本章高潮是不是动作化收束 +6. 本章结尾有没有余波 + +只要有两项答不出来,这章就不算完成。 + +--- + +## 12. 样章执行示例 + +继续沿用 `宫苑内庭` 示例,补成完整章节内玩法卡。 + +## 12.1 章节类型 + +- 主类型:`情感章` +- 副类型:`试炼章` + +## 12.2 九件套落点 + +1. 开章钩子 + - 旧宫侍女提醒“最近不该过去的回廊” + +2. 情感锚点 + - `旧宫侍女` + - 表层是劝阻,底层是她在压着旧案 + +3. 路线压力链 + - beat1:回廊异常安静 + - beat2:暗格香囊 + - beat3:花圃石座下旧金牌 + +4. 承压事件 + - 侍卫残影或禁制阻拦 + +5. 改判节点 + - 金牌指向旧宫侍女曾参与旧案,而不是只是知情 + +6. 立场选择 + - 立刻逼问她 / 暂时替她压下 / 继续独自追查 + +7. 同伴反应 + - 一人主张逼问 + - 一人主张先保她 + +8. 高潮动作面 + - 回廊深处正面对话或短战后摊牌 + +9. 余波回响 + - 获得旧宫金牌 + - 雨夜长街 NPC 认出该物 + - chronicle 记一条“内庭旧案露出一角” + +这个例子说明: + +**同一场景完全可以在现有素材基础上补成一章完整玩法,而不是额外新造大量地图内容。** + +--- + +## 13. 执行方案 + +## 阶段 A:先补“情感锚点 + 改判 + 余波” + +### 目标 + +最快补齐最容易被玩家感知的章节记忆点。 + +### 研发重点 + +1. `ChapterExperienceProfile` +2. `emotionalAnchorNpcId` +3. `turningEvidenceIds` +4. `ChapterAftermathEcho` + +### 内容重点 + +1. 先给现有重点场景补情感锚点 +2. 每章至少补一个改判证据 +3. 每章至少补一条章节余波 + +### 验收 + +玩家玩完一章后,能回答: + +1. 这一章主要在讲谁 +2. 这一章中途哪里改判了 +3. 这一章结束后留下了什么 + +## 阶段 B:补“立场选择 + 同伴反应” + +### 目标 + +让章节开始拥有真正的玩家立场表达。 + +### 研发重点 + +1. `ChapterStanceChoice` +2. `chapterChoiceResolver` +3. `chapterReactionDirector` +4. 选择结果写回 `Quest / Knowledge / Affinity` + +### 内容重点 + +1. 每章至少设计一处二选一 / 三选一 +2. 每章至少设计一轮 reaction batch + +### 验收 + +1. 玩家做过一次立场选择 +2. 至少一名同伴明确回应 +3. 选择结果进入后续描述或关系状态 + +## 阶段 C:补“路线压力链 + 高潮 setpiece” + +### 目标 + +让章节真正长出路感、压迫感和收束动作面。 + +### 研发重点 + +1. `ChapterRoutePressureBeat` +2. `chapterRoutePressureDirector` +3. `ChapterClimaxSetpiece` +4. `chapterSetpieceDirector` + +### 内容重点 + +1. 每章补 `2~3` 个 route beats +2. 每章补一个高潮 setpiece + +### 验收 + +1. 玩家能记住一处地标或残痕 +2. 玩家能感到这一路越走越压 +3. 本章结尾不是静态任务完成 + +## 阶段 D:现有场景批量迁移 + +### 目标 + +把已经有素材的重点场景批量补齐成章节玩法卡。 + +### 建议优先级 + +1. 已有明确 NPC + 残痕 + treasure hint 的场景优先 +2. 已有 handoff 关系明确的场景优先 +3. 已有强情感人物的场景优先 + +### 交付要求 + +1. 每批 `3~5` 个场景 +2. 每个场景必须配一张章节卡 +3. 每批都跑一次章节体验回归 + +--- + +## 14. 涉及文件建议 + +## 14.1 服务端 + +建议优先涉及: + +- `server-node/src/routes/runtimeRoutes.ts` +- `server-node/src/modules/runtime/chapter/chapterExperienceDirector.ts` +- `server-node/src/modules/runtime/chapter/chapterRoutePressureDirector.ts` +- `server-node/src/modules/runtime/chapter/chapterChoiceResolver.ts` +- `server-node/src/modules/runtime/chapter/chapterReactionDirector.ts` +- `server-node/src/modules/runtime/chapter/chapterSetpieceDirector.ts` +- `server-node/src/modules/runtime/chapter/chapterEchoRouter.ts` + +## 14.2 共享类型 + +建议补: + +- `src/types/storyEngine.ts` +- `src/types/story.ts` +- `src/types/scene.ts` + +## 14.3 前端表现层 + +前端只做表现,建议接入: + +- `src/services/apiClient.ts` +- `src/services/aiService.ts` +- `src/components/AdventurePanel.tsx` +- `src/components/adventure-panel/AdventurePanelOverlays.tsx` + +重点只加: + +1. 章节 promise +2. route beat 提示 +3. 同伴 reaction pulse +4. 立场选项展示 +5. aftermath 结果展示 + +--- + +## 15. 测试与验收方案 + +## 15.1 单元测试 + +建议新增: + +1. `chapterExperienceDirector.test.ts` +2. `chapterRoutePressureDirector.test.ts` +3. `chapterChoiceResolver.test.ts` +4. `chapterReactionDirector.test.ts` +5. `chapterSetpieceDirector.test.ts` +6. `chapterEchoRouter.test.ts` + +## 15.2 集成测试 + +至少补这 5 条: + +1. 首进场景 -> 开章 -> 返回章节体验数据 +2. route beat 推进 -> quest step / chapter stage 更新 +3. 立场选择 -> 同伴反应 -> 关系写回 +4. 高潮结算 -> aftermath echo 生成 +5. 本章余波 -> 下一章 handoff 可见 + +## 15.3 体验验收 + +上线前至少用下面标准评估: + +1. 玩家玩完一章后,能说出“这章主要讲谁” +2. 玩家能指出至少一次改判 +3. 玩家能记住至少一个地标、残痕或证物 +4. 玩家做过一次明确立场表达 +5. 玩家感到本章有明确高潮 +6. 玩家离章时能感到有后续余波 + +--- + +## 16. 风险与处理 + +## 风险 1:章节玩法做成厚系统,开发过重 + +处理: + +1. 先做九件套最小版 +2. 每章不追求都做到重度 +3. 先补情感锚点、改判、余波,再扩路线和高潮 + +## 风险 2:前端被迫承接过多逻辑 + +处理: + +1. 所有章节编排与裁决放 `server-node` +2. 前端只拿编译后的 payload +3. 前端只做表现组件 + +## 风险 3:内容制作成本过高 + +处理: + +1. 用统一章节卡模板 +2. 优先改已有高价值场景 +3. 复用现有 NPC、残痕、treasure、quest 素材 + +## 风险 4:章节分歧造成状态失控 + +处理: + +1. 坚持有限分歧 +2. 选择只改有限字段 +3. 统一走 `choice resolver` + +--- + +## 17. 最后结论 + +这次章节内玩法 PRD 的核心,不是再给每个场景多堆一点文本,而是让每章都真正具备: + +1. 人物轴 +2. 路线轴 +3. 立场轴 +4. 高潮轴 +5. 余波轴 + +并且把这些能力全部收口到 Node 后端,由后端统一编排、裁决和回写,前端只负责把章节 promise、路线节拍、选择反应、高潮和余波清爽地表现出来。 + +只有这样,当前项目的“场景章节化”才会从“每章有任务”,真正走向“每章都像一章”。 diff --git a/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md b/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md new file mode 100644 index 00000000..3bcbbbf4 --- /dev/null +++ b/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md @@ -0,0 +1,106 @@ +# 编辑器与资产 API 迁移清单(2026-04-08) + +## 1. 任务定位 + +对应 [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](../planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md) 中的任务 8:编辑器 API 归口与工具链隔离。 + +本轮目标是把编辑器写盘、资产生成、生成任务查询从旧的 Vite 本地 API 插件里收口到 `server-node`,并把前端编辑器组件改成通过统一 SDK 访问。 + +--- + +## 2. 新命名空间 + +编辑器写盘与读取: + +- `GET /api/editor/catalog/items` +- `GET /api/editor/json/:resourceId` +- `POST /api/editor/json/:resourceId` + +资产生成与任务查询: + +- `POST /api/assets/character-visual/generate` +- `POST /api/assets/character-visual/publish` +- `GET /api/assets/character-visual/jobs/:taskId` +- `POST /api/assets/character-animation/generate` +- `POST /api/assets/character-animation/publish` +- `GET /api/assets/character-animation/jobs/:taskId` +- `POST /api/assets/character-animation/import-video` +- `GET /api/assets/character-animation/templates` +- `POST /api/assets/qwen-sprite/master` +- `POST /api/assets/qwen-sprite/sheet` +- `POST /api/assets/qwen-sprite/frame-repair` +- `POST /api/assets/qwen-sprite/save` + +--- + +## 3. 前端接入 + +统一入口: + +- `src/editor/shared/editorApiClient.ts` + +已切换的编辑器链路: + +- 角色预设覆盖保存 +- 敌人预设覆盖保存 +- 场景预设覆盖保存 +- 场景角色覆盖保存 +- NPC 形象覆盖与布局配置保存 +- 物品目录读取与物品覆盖保存 +- 状态行为覆盖保存 +- 角色主形象生成、发布与任务查询 +- 角色动作生成、导入、发布、模板读取与任务查询 +- Qwen 精灵主图、精灵表、修帧与资产保存 + +--- + +## 4. 权限与环境边界 + +`server-node` 通过环境变量控制工具接口: + +- `EDITOR_API_ENABLED`:控制 `/api/editor/*`。 +- `ASSETS_API_ENABLED`:控制 `/api/assets/*`。 + +默认策略: + +- 非 `production` 环境默认开启。 +- `production` 环境默认关闭。 +- `ASSETS_API_ENABLED` 未设置时跟随 `EDITOR_API_ENABLED`。 + +这批接口会读写 `src/data/*.json` 与 `public/generated-*`,不应作为正式运行时 API 使用。 + +--- + +## 5. 旧工具链隔离状态 + +`scripts/dev-server/**` 中的旧 Vite 本地插件已经不再由 `vite.config.ts` 注入,也不再作为当前开发入口使用。 + +旧文件保留用途: + +- 作为历史迁移参考。 +- 对照旧 DashScope 调用与文件写入逻辑。 + +新增编辑器或资产能力时,应优先写入: + +- `server-node/src/modules/editor/**` +- `server-node/src/modules/assets/**` +- `src/editor/shared/editorApiClient.ts` + +不要再新增旧式散落接口: + +- `/api/item-overrides` +- `/api/npc-visual-overrides` +- `/api/character-overrides` +- `/api/character-visual/*` +- `/api/animation/*` +- `/api/qwen-sprite/*` + +--- + +## 6. 当前验收状态 + +- `/api/editor/*` 与 `/api/assets/*` 命名空间已落地。 +- 前端编辑器组件已通过统一 SDK 或资源 ID 访问编辑器 API。 +- Vite 已代理 `/api/editor` 与 `/api/assets` 到 Node 后端。 +- 写接口已经有环境门禁。 +- 旧 Vite 本地插件不再是当前工具链入口。 diff --git a/docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md b/docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md new file mode 100644 index 00000000..34b861d0 --- /dev/null +++ b/docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md @@ -0,0 +1,108 @@ +# Express 后端接口冻结与集成清单(2026-04-09) + +## 1. 目的 + +这份文档补齐 `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` 中任务 0 缺失的仓库内产物,用来明确: + +- 当前 contract 的冻结版本 +- 热点文件的编辑规则 +- 各类改动进入集成窗口前的最小检查清单 + +它不是新的重构计划,而是给当前并行改造提供一个统一落库的“不要互相踩”的边界表。 + +--- + +## 2. Contract 版本表 + +| 范围 | 当前版本 | 源头文件 | 说明 | +| --- | --- | --- | --- | +| 统一 API envelope | `2026-04-08 / v1` | `packages/shared/src/http.ts` | `ApiResponse`、错误结构、`meta` 字段、envelope 头约定的统一来源。 | +| auth contract | `2026-04-08` | `packages/shared/src/contracts/auth.ts` | 前后端都以 shared auth contract 识别登录、用户信息与 token 响应。 | +| runtime snapshot/settings contract | `2026-04-08` | `packages/shared/src/contracts/runtime.ts` | 存档、设置、自定义世界会话与库表相关请求/响应来源。 | +| runtime story action contract | `2026-04-08` | `packages/shared/src/contracts/story.ts` | `RuntimeStoryActionRequest/Response`、Task5/Task6 function id 与 view model 来源。 | +| Node HTTP route meta | `2026-04-08` | `server-node/src/app.ts` | `/api/auth`、`/api/runtime/story`、`/api/editor`、`/api/assets` 都以这一轮 route version 为当前冻结口径。 | +| editor/assets route 命名空间 | `2026-04-08` | `server-node/src/modules/editor/editorRoutes.ts`、`server-node/src/modules/assets/**` | 编辑器与资产接口统一走 `/api/editor/*`、`/api/assets/*`。 | + +--- + +## 3. 热点文件编辑规则 + +以下文件继续视为高冲突入口,默认不要在多个任务里并行大改: + +- `server-node/src/context.ts` +- `server-node/src/routes/runtimeRoutes.ts` +- `server-node/src/app.ts` +- `src/services/apiClient.ts` +- `src/hooks/useStoryGeneration.ts` +- `src/hooks/useGameFlow.ts` +- `src/components/GameShell.tsx` + +统一规则: + +- 新需求优先新增独立模块,再通过桥接或小入口接入,不要直接把逻辑堆进热点文件。 +- 需要改 `server-node/src/routes/runtimeRoutes.ts` 时,先确认 shared contract 是否已落库,再补 route 接入。 +- 需要改 `src/hooks/useStoryGeneration.ts` 时,优先确认是否其实应该落到 `server-node/src/modules/**`、`src/services/runtimeStoryService.ts` 或 `src/hooks/story/**`。 +- 编辑器链路与正式运行时链路不要混在同一轮提交里。 +- 如果同一轮同时碰到后端 action 与前端 UI 壳层,先冻结 action/view model,再接 UI。 + +--- + +## 4. 集成窗口清单 + +### 4.1 shared contract 变更 + +- 只在 `packages/shared/**` 改类型、schema、纯序列化约定。 +- 同步检查 `server-node/src/**` 和 `src/**` 是否都已切到 shared contract。 +- 至少跑一次 `npm run server-node:test`。 +- 如果前端消费层也改了,再补 `npm run typecheck`。 + +### 4.2 runtime action / domain module 变更 + +- 业务规则优先写在 `server-node/src/modules/**`,不要直接写回前端 hook。 +- 如果影响 `RuntimeStoryActionResponse`,同步检查 `packages/shared/src/contracts/story.ts`。 +- 至少覆盖对应模块测试,或补到 `server-node/src/modules/story/storyActionRoutes.test.ts`。 +- 合并前至少跑一次 `npm run server-node:test`。 + +### 4.3 persistence / repository / config 变更 + +- 只把 PostgreSQL 视为正式基线。 +- 如果改到 `server-node/src/db.ts`、`repositories/**`、迁移脚本,优先确认 `pg-mem` 测试仍通过。 +- 合并前至少跑一次 `npm run server-node:test`。 + +### 4.4 editor / assets 变更 + +- 后端入口只放在 `/api/editor/*`、`/api/assets/*`。 +- 前端统一从 `src/editor/shared/editorApiClient.ts` 或对应 persistence 层进入。 +- 不要新增旧 Vite 本地插件式散落接口。 + +### 4.5 前端壳层接入变更 + +- 优先消费 `runtime story state / action response / shared contract`,不要把正式规则写回前端。 +- 如果恢复流程有改动,优先以后端 runtime state 为准。 +- 若影响主流程,至少补对应 hook / view model 测试并跑 `npm run typecheck`。 + +--- + +## 5. 当前剩余非冻结区 + +以下几块仍处于“可继续收口但尚未完全冻结”的状态,改动时要额外小心: + +- `server-node/src/bridges/legacyBuildRuntimeBridge.ts` +- `server-node/src/bridges/legacyInventoryRuntimeBridge.ts` +- `server-node/src/modules/ai/storyOrchestrator.ts` +- `server-node/src/modules/ai/chatOrchestrator.ts` +- `server-node/src/modules/ai/customWorldOrchestrator.ts` + +它们目前仍残留一部分对 `src/**` 历史实现的复用,不建议在没有额外测试兜底时顺手混改。 + +--- + +## 6. 本轮落库结论 + +从 2026-04-09 起,仓库内已经具备任务 0 要求的这几类最小产物: + +- contract 版本表 +- 热点文件编辑规则 +- 集成窗口检查清单 + +后续如果 shared contract、runtime action 或热点入口发生明显演进,应优先更新这份文档,而不是让口径只停留在聊天记录里。 diff --git a/docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md b/docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md new file mode 100644 index 00000000..bc20b053 --- /dev/null +++ b/docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md @@ -0,0 +1,109 @@ +# Express 后端任务 4 AI 编排收口状态(2026-04-08) + +## 1. 结论 + +按 `EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` 的任务 4 定义,本轮已经把正式运行时的 `story / character chat / npc chat / custom world generation / quest intent / runtime item intent` 的主要 AI 编排入口收回到 Express 后端。 + +当前可以视为: + +- 正式运行时主链已不再依赖浏览器端大体量 AI 实现作为兜底。 +- prompt 组装、上游模型请求、SSE 转发已以后端为主。 +- 前端保留的本地 AI 大模块只通过懒加载方式服务于非正式运行时遗留入口,不再作为正式运行时默认路径。 + +--- + +## 2. 已完成项 + +### 2.1 后端统一 orchestration 入口 + +- `server-node/src/modules/ai/storyOrchestrator.ts` +- `server-node/src/modules/ai/chatOrchestrator.ts` +- `server-node/src/modules/ai/customWorldOrchestrator.ts` + +这些模块承接: + +- story prompt 组装 +- character chat prompt 组装 +- npc chat / recruit prompt 组装 +- custom world generation 后端入口封装 + +### 2.2 服务层收口 + +已收口到后端服务或模块的文件: + +- `server-node/src/services/llmClient.ts` +- `server-node/src/services/storyService.ts` +- `server-node/src/services/chatService.ts` +- `server-node/src/services/customWorldGenerationService.ts` +- `server-node/src/services/questService.ts` +- `server-node/src/services/runtimeItemService.ts` + +### 2.3 前端正式运行时 fallback 清理 + +`src/services/aiService.ts` 已完成以下收缩: + +- story 正式路径不再 fallback 到浏览器本地 AI 编排 +- character suggestions / summary / reply stream 不再 fallback 到浏览器本地 AI 编排 +- npc dialogue / recruit stream 不再 fallback 到浏览器本地 AI 编排 +- 移除了正式运行时对 `VITE_ENABLE_BROWSER_RUNTIME_AI_FALLBACK` 的依赖 +- 移除了正式运行时对本地轻量离线文案 fallback 的默认依赖 +- 对 `./ai` 的引用改为懒加载,避免正式运行时默认把大体量 AI 模块打进主路径 +- 正式运行时主流程 hook 已统一改走 `aiService`,不再直接从 `src/services/ai.ts` 获取 story/chat 主链能力 + +### 2.4 旧 bridge 清理 + +已移除: + +- `server-node/src/bridges/legacyAiRuntimeBridge.ts` + +--- + +## 3. 当前边界说明 + +以下项仍然存在于前端目录,但不再属于正式运行时默认 AI 执行路径: + +- `src/services/ai.ts` +- `src/services/aiFallbacks.ts` +- `src/components/CustomWorldEntityEditorModal.tsx` 中的工具链直连调用 + +它们当前主要作为: + +- 兼容性遗留实现 +- 懒加载的非主路径工具能力 +- 非本轮正式运行时链路的复用来源 + +这不再构成任务 4 的主阻塞,但后续仍应继续配合任务 1 / 任务 7 做彻底分层。 + +--- + +## 4. 非任务 4 主阻塞但需要记录的事项 + +### 4.1 仍属编辑器/工具链范畴的遗留调用 + +- `generateCustomWorldSceneImage` 仍通过懒加载复用旧实现。 + +原因: + +- 该能力属于自定义世界工具链,不是正式运行时剧情 / 对话主链。 +- 当前不会再影响“浏览器正式运行时是否依赖本地大 AI 编排”这一任务 4 验收项。 + +### 4.2 分层彻底闭合仍需后续任务配合 + +尽管任务 4 已完成主链收口,但以下更深层收敛仍建议交由后续任务继续推进: + +- 继续减少 `server-node` 对 `src/**` 纯提示词/纯规则模块的历史复用 +- 继续把共享 contract / schema 下沉到 `packages/shared` +- 继续把工具链与正式运行时拆分 + +这些属于任务 1、任务 7、任务 8 的后续工作,不再阻塞任务 4 验收。 + +--- + +## 5. 本轮建议验收口径 + +任务 4 可按以下口径验收: + +- 浏览器正式运行时不再默认兜底到本地大体量 AI 编排 +- story/chat/custom world generation 主链 prompt 组装与请求执行权在后端 +- SSE 主链以后端转发为准 +- upstream timeout / abort / error 统一走后端处理链 diff --git a/docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md b/docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md new file mode 100644 index 00000000..0002d48f --- /dev/null +++ b/docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md @@ -0,0 +1,425 @@ +# Express 后端并行任务完成度审计(2026-04-09) + +## 1. 审计范围 + +本次审计以 `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` 为基准,只检查仓库内可直接验证的代码、测试、脚本和文档产物。 + +不能仅靠仓库静态内容确认的团队协作物,例如看板、口头冻结流程、每日集成节奏,只按“仓库内可见状态”记录,不把它们误判成完全验收。 + +--- + +## 2. 任务完成度总览 + +| 任务 | 状态 | 结论 | +| --- | --- | --- | +| 任务 0:集成岗与接口冻结 | 基本完成 | 已补齐接口冻结与集成清单文档,contract 版本表、热点文件编辑规则、集成窗口检查项都已落库;但团队执行节奏本身仍无法仅凭仓库静态确认。 | +| 任务 1:共享 Contract 与目录抽离 | 部分完成 | `packages/shared` 已建立并承接 auth/runtime/story contract,且 NPC Task6 bridge 与 chat prompt builder 已进一步下沉到后端本地模块;但 AI 编排主链仍残留少量 `src/**` 反向依赖,分层尚未完全闭合。 | +| 任务 2:PostgreSQL 持久化基线收口 | 已完成 | `server-node/src/config.ts`、`db.ts`、`repositories/**`、迁移脚本、`pg-mem` 测试与部署文档均已到位。 | +| 任务 3:HTTP 基础设施与统一响应壳层 | 已完成 | 统一错误格式、`requestId`、route meta、响应壳层、观测测试已落地。 | +| 任务 4:服务端 AI 编排收口 | 基本完成 | orchestration 模块、SSE 转发和主链调用已回到后端,但仍有少量 prompt/规则模块通过 `src/**` 复用,属于后续继续收敛项。 | +| 任务 5:Story / Combat / NPC | 已完成 | 后端 story action route、session 组装、combat/npc 领域服务和对应回归测试已落地。 | +| 任务 6:Inventory / Quest / Build / Runtime Item | 已完成 | 对应模块、服务与回归测试已经覆盖主要正式运行时结算。 | +| 任务 7:前端 SDK、鉴权、持久化瘦身 | 部分完成 | `apiClient`、`authService`、`storageService` 已统一,但前端仍保留一部分存档归一化和主流程协调职责。 | +| 任务 8:编辑器 API 归口与工具链隔离 | 基本完成 | editor/assets 模块、前端 editor client、迁移文档均已出现,职责边界基本清晰。 | +| 任务 9:测试、观测与部署基线 | 已完成 | baseline test、smoke、proxy smoke、部署与回滚清单、日志链路均已具备。 | +| 任务 10:前端主流程壳层与大 Hook 瘦身 | 部分完成 | `GameShellRuntime` / `useGameShellRuntimeViewModel` 以及新的 `storyRequestCoordinator` 已拆出,但 `useStoryGeneration.ts` 仍然过重,主流程尚未彻底退回“表现层协调器”。 | + +--- + +## 3. 本轮补齐项 + +本轮先补齐了任务 0 缺失的仓库内协作产物: + +- 新增 `docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md` + - 落库当前 contract 版本表 + - 明确热点文件编辑规则 + - 补齐 shared/runtime/editor/frontend 壳层各自的集成窗口清单 + +这让任务 0 不再只停留在规划文档里,而是有了一份可随版本一起更新的冻结口径。 + +随后补上了一段此前仍偏向前端快照解释的恢复链路: + +- `src/hooks/story/runtimeStoryCoordinator.ts` + - 新增 `resumeServerRuntimeStory` + - 继续游戏时,若当前是正式运行时故事快照,会先请求 `/api/runtime/story/state/:sessionId` + - 让当前 `storyText` 与可用 `options` 优先以后端 runtime state 为准 + +- `src/hooks/useGamePersistence.ts` + - `continueSavedGame()` 现在会优先走后端 runtime story 恢复 + - 如果服务端恢复失败,再回退到本地快照归一化结果 + - 额外补了 `bottomTab` 归一化,避免恢复时吃到宽泛字符串 + +- `src/hooks/story/runtimeStoryCoordinator.test.ts` + - 新增“继续游戏时优先从后端恢复 runtime story”的测试 + - 新增“非正式运行时快照不额外请求后端”的测试 + +这次补丁对应的是任务 7 与任务 10 之间的一段未完全闭合边界:前端在恢复流程里不应该只把远端存档当作“原始 JSON 缓存”,而应优先相信后端当前 runtime state。 + +此外,本轮还继续补了任务 1 的一段后端分层收口: + +- 新增 `server-node/src/modules/runtime/runtimeStatePrimitives.ts` + - 后端本地承接 `addInventoryItems` + - 后端本地承接 `removeInventoryItem` + - 后端本地承接 `incrementGameRuntimeStats` + - 后端本地承接 `buildRelationState` +- 新增 `server-node/src/modules/runtime/runtimeStatePrimitives.test.ts` + - 校验背包合并、移除、运行时统计累加、关系阶段映射 +- 调整桥接层: + - `server-node/src/bridges/legacyInventoryRuntimeBridge.ts` + - `server-node/src/bridges/legacyNpcTask6Bridge.ts` + - `server-node/src/modules/quest/questTask6Bridge.ts` + +这意味着任务 5/6 主链里最基础的一批状态原语,已经不再依赖前端 `src/data/runtimeStats.ts`、`src/data/attributeResolver.ts`、`src/data/npcInteractions.ts` 的对应实现。 + +本轮又继续把 NPC Task6 bridge 里最后一批直接挂到前端 `npcInteractions.ts` 的函数下沉到了后端本地: + +- 新增 `server-node/src/modules/npc/npcTask6Primitives.ts` + - 后端本地承接 `applyStoryChoiceToStanceProfile` + - 后端本地承接 `buildInitialNpcState` + - 后端本地承接 `syncNpcTradeInventory` + - 后端本地承接 `getGiftCandidates` + - 后端本地承接 `buildNpcGiftCommitActionText` + - 后端本地承接 `buildNpcGiftResultText` + - 后端本地承接 `buildNpcTradeTransactionActionText` + - 后端本地承接 `buildNpcTradeTransactionResultText` +- 新增 `server-node/src/modules/npc/npcTask6Primitives.test.ts` + - 覆盖 NPC 初始库存生成 + - 覆盖交易库存刷新时保留非交易物品 + - 覆盖赠礼偏好排序 +- 调整桥接层: + - `server-node/src/bridges/legacyNpcTask6Bridge.ts` + +这意味着 Task6 的 NPC 交易/赠礼/初始库存这条支链,已经不再直接依赖前端 `src/data/npcInteractions.ts`。 + +同时还把叙事语言检测工具下沉到了共享层: + +- 新增 `packages/shared/src/llm/narrativeLanguage.ts` +- `src/services/narrativeLanguage.ts` 改为复用共享实现 +- `server-node/src/modules/ai/storyOrchestrator.ts` 改为直接依赖共享层 + +这部分虽然不是大块业务迁移,但它属于任务 1 最稳定的一类收口:把纯函数共识从前端目录中抽离出来。 + +再补充一批已经完成的后端纯原语迁移: + +- 新增 `server-node/src/modules/runtime/runtimeEconomyPrimitives.ts` + - 后端本地承接 `formatCurrency` + - 后端本地承接 `getInventoryItemValue` + - 后端本地承接 `getNpcPurchasePrice` + - 后端本地承接 `getNpcBuybackPrice` +- 新增 `server-node/src/modules/runtime/runtimeTreasureTexts.ts` + - 后端本地承接 `buildTreasureResultText` +- 新增 `server-node/src/modules/runtime/runtimeEconomyPrimitives.test.ts` + - 覆盖交易定价、货币文本、宝藏奖励文本 + +对应桥接层: + +- `server-node/src/bridges/legacyNpcTask6Bridge.ts` + - 不再依赖前端 `src/data/economy.ts` +- `server-node/src/bridges/legacyTreasureRuntimeBridge.ts` + - 不再从前端导出 `buildTreasureResultText` + +这说明任务 5/6 主链中一部分交易、礼物、宝藏结算反馈文本,也已经从前端数据层抽离。 + +本轮又进一步补了 NPC 状态与叙事记忆的后端本地原语: + +- 新增 `server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts` + - 后端本地承接 `normalizeNpcPersistentState` + - 后端本地承接 `markNpcFirstMeaningfulContactResolved` +- 新增 `server-node/src/modules/runtime/runtimeNarrativeMemory.ts` + - 后端本地承接 `appendStoryEngineCarrierMemory` +- 新增 `server-node/src/modules/runtime/runtimeNpcStatePrimitives.test.ts` + - 覆盖 NPC 状态归一化、首次有效接触标记、叙事载体记忆写入 + +对应桥接层: + +- `server-node/src/bridges/legacyNpcTask6Bridge.ts` + - 不再依赖前端 `src/services/storyEngine/echoMemory.ts` + - 不再依赖前端 `src/data/npcInteractions.ts` 中的 NPC 状态归一化与首次接触标记逻辑 + +这进一步缩小了后端在任务 5/6 主链上对前端 story-engine 服务目录的借用范围。 + +本轮还整块收口了 Quest 与 Runtime Item 两条桥接链: + +- 新增 `server-node/src/modules/quest/runtimeQuestModule.ts` + - 后端本地承接 `buildQuestForEncounter` + - 后端本地承接 `evaluateQuestOpportunity` + - 后端本地承接 `buildFallbackQuestIntent` + - 后端本地承接 `compileQuestIntentToQuest` + - 后端本地承接 `buildQuestGenerationContextFromState` + - 后端本地承接 `buildQuestIntentPrompt` + - 后端本地承接 Quest 进度归一化与 signal 推进 +- 更新桥接层: + - `server-node/src/bridges/legacyQuestProgressBridge.ts` + - `server-node/src/bridges/legacyQuestRuntimeBridge.ts` + +这意味着 Quest 的“确定性委托构建 + AI 意图上下文 + 任务推进”已经不再依赖前端 `src/data/questFlow.ts`、`src/services/questDirector.ts`、`src/services/questPrompt.ts`。 + +同时,Runtime Item 也已经收回到后端本地: + +- 新增 `server-node/src/modules/runtime-item/runtimeItemModule.ts` + - 后端本地承接 `buildRuntimeItemAiIntent` + - 后端本地承接 `buildRuntimeItemIntentPrompt` + - 后端本地承接 `buildLooseRuntimeItemGenerationContext` + - 后端本地承接 `buildQuestRuntimeItemGenerationContext` + - 后端本地承接 `buildDirectedRuntimeReward` + - 后端本地承接 `buildRuntimeInventoryStock` + - 后端本地承接 `flattenDirectedRuntimeRewardItems` +- 新增 `server-node/src/modules/runtime-item/runtimeTreasureModule.ts` + - 后端本地承接 `resolveTreasureReward` +- 更新桥接层: + - `server-node/src/bridges/legacyRuntimeItemBridge.ts` + - `server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts` + - `server-node/src/bridges/legacyTreasureRuntimeBridge.ts` + +这说明 Runtime Item / Treasure 相关的 AI 意图、奖励生成和库存生成,也已经从前端目录中抽离。 + +本轮还继续整块收口了 Build / Inventory / Forge / Equipment 规则桥接: + +- 新增 `server-node/src/modules/runtime/runtimeEquipmentModule.ts` + - 后端本地承接 `getEquipmentSlotFromItem` + - 后端本地承接 `getEquipmentSlotLabel` + - 后端本地承接 `getEquipmentBonuses` + - 后端本地承接 `applyEquipmentLoadoutToState` +- 新增 `server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts` + - 后端本地承接 `isInventoryItemUsable` + - 后端本地承接 `resolveInventoryItemUseEffect` + - 后端本地承接 `buildInventoryUseResultText` +- 新增 `server-node/src/modules/runtime/runtimeForgeModule.ts` + - 后端本地承接 `getForgeRecipeViews` + - 后端本地承接 `executeForgeRecipe` + - 后端本地承接 `executeDismantleItem` + - 后端本地承接 `executeReforgeItem` + - 后端本地承接 `getReforgeCostView` + - 后端本地承接 `buildForgeSuccessText` +- 新增 `server-node/src/modules/runtime/runtimeBuildModule.ts` + - 后端本地承接 `appendBuildBuffs` + - 后端本地承接 `getPlayerBuildDamageBreakdown` + - 后端本地承接 `resolvePlayerOutgoingDamageResult` + +对应桥接层: + +- `server-node/src/bridges/legacyBuildRuntimeBridge.ts` +- `server-node/src/bridges/legacyInventoryRuntimeBridge.ts` + +这意味着 Build / Inventory / Forge / Equipment 相关的后端主链结算,已经不再依赖前端 `src/data/buildDamage.ts`、`src/data/equipmentEffects.ts`、`src/data/forgeSystem.ts`、`src/data/inventoryEffects.ts`。 + +本轮又补了一段任务 1 的 AI 编排收口: + +- 新增 `server-node/src/modules/ai/chatPromptBuilders.ts` + - 后端本地承接 character chat reply / suggestions / summary prompt 组装 + - 后端本地承接 npc chat / recruit prompt 组装 +- 调整: + - `server-node/src/modules/ai/chatOrchestrator.ts` + - `server-node/src/modules/ai/orchestrator.test.ts` + +这意味着 chat orchestration 已不再依赖前端 `src/services/characterChatPrompt.ts` 与 `src/services/prompt.ts`。 + +本轮继续把 story orchestration 主链也收回到了后端本地: + +- 新增 `server-node/src/modules/ai/storyPromptBuilders.ts` + - 后端本地承接 `SYSTEM_PROMPT` + - 后端本地承接 `buildUserPrompt` +- 重写 `server-node/src/modules/ai/storyOrchestrator.ts` + - 正式生产链不再依赖前端 `src/services/prompt.ts` + - 正式生产链不再依赖前端 `src/data/stateFunctions.ts` + - 正式生产链不再依赖前端 `src/data/scenePresets.ts` + - 正式生产链不再依赖前端 `src/data/hostileNpcs.ts` +- 调整: + - `server-node/src/modules/ai/orchestrator.test.ts` + +到这里,`server-node` 正式生产代码路径里,Story / Chat / Quest / Runtime Item / Treasure / Build / Inventory / Forge / NPC Task6 主链都已经从前端 `src/**` 目录脱钩。 + +本轮也继续推进了任务 10 的主流程瘦身: + +- 新增 `src/hooks/story/storyRequestCoordinator.ts` + - 抽离运行时 option source 解析 + - 抽离服务端 option catalog 回退策略 + - 抽离 initial / next story 请求参数协调 +- 新增 `src/hooks/story/storyRequestCoordinator.test.ts` + - 覆盖服务端 option catalog 切换 + - 覆盖显式 option catalog 短路 + - 覆盖服务端目录加载失败时回退本地可用项 +- 调整: + - `src/hooks/useStoryGeneration.ts` + +这说明 `useStoryGeneration.ts` 虽然仍重,但“故事请求协调”已经不再和主流程 UI 状态、NPC/战斗/宝藏后续处理混在同一个大段里。 + +本轮又补了一段任务 10 的纯展示逻辑拆分: + +- 新增 `src/hooks/story/storyPresentation.ts` + - 抽离 story options 去重与补齐 + - 抽离对白 turn 解析 + - 抽离 dialogue story moment 组装 + - 抽离 typewriter delay +- 新增 `src/hooks/story/storyPresentation.test.ts` + - 覆盖对白解析与 dialogue story moment + - 覆盖选项池去重与补齐 +- 调整: + - `src/hooks/useStoryGeneration.ts` + +这意味着 `useStoryGeneration.ts` 又减少了一批与 React 状态本身无关的纯函数逻辑,任务 10 的主流程壳层拆分继续向前推进。 + +本轮还补上了任务 1 的最后一条后端反向依赖: + +- 删除 `server-node/src/bridges/legacyCustomWorldAiBridge.ts` +- 重写 `server-node/src/modules/ai/customWorldOrchestrator.ts` + - 后端本地承接 custom world generation 的 deterministic 生成流程 + - 后端本地承接 generation progress 汇报 + +这意味着 `server-node/src/**` 的正式生产代码路径已经不再反向依赖前端 `src/**` 目录。 + +--- + +## 4. 仍需继续收口的重点 + +### 4.1 任务 1 的剩余问题 + +当前从仓库内直接扫描看,`server-node/src/**` 的正式生产代码路径已经不再存在对前端 `src/**` 的反向依赖。 + +其中 Story / Chat / Quest / Runtime Item / Treasure / Build / Inventory / Forge / Equipment / NPC Task6 / Custom World generation 相关正式生产链都已经从这个列表中退出。 + +同时,`server-node/src/modules/ai/orchestrator.test.ts` 已不再直接依赖前端 `src/services/prompt.ts` 与 `src/data/stateFunctions.ts`。 + +这说明任务 1 在“后端正式生产运行时不反向依赖前端目录”这一层面已经完成。 + +#### 4.1.1 当前残留依赖的真实形态 + +从当前状态看,任务 1 后续不再是“补洞”,而是“优化”: + +- 继续提高 custom world generation 的质量与保真度 +- 继续把真正通用的 prompt / JSON repair / batch helper 整理进 `packages/shared` 或 `server-node/src/modules/ai/**` +- 维持后端不再回流引用前端目录的约束 + +#### 4.1.2 更适合的继续收口顺序 + +结合当前状态,任务 1 后续更适合做的是能力优化: + +1. 继续增强 custom world generation 的语义保真度与校验强度。 +2. 继续把 prompt builder、JSON repair、批处理工具整理到 `packages/shared` 或 `server-node/src/modules/ai/**`。 +3. 持续维持“后端正式生产代码不反向依赖前端目录”的约束,避免后续重新开洞。 + +### 4.2 任务 7 的剩余问题 + +前端虽然已经大量改成 SDK 消费层,但 `src/persistence/runtimeSnapshot.ts` 里仍保留较重的存档归一化与迁移修复逻辑。 + +这部分后续仍建议继续向后端迁移,避免前后端双边解释快照。 + +#### 4.2.1 `runtimeSnapshot.ts` 当前仍承担的职责 + +从文件本身看,`src/persistence/runtimeSnapshot.ts` 仍不是一个单纯的“读取后端结果并转成 UI 状态”的轻薄消费层。 + +它当前仍直接负责: + +- 存档迁移 manifest 应用 +- roster 归一化 +- player currency 默认值补齐 +- equipment loadout 回填与属性重算 +- NPC persistent state 归一化 +- quest log 归一化 +- runtime stats 归一化 +- scene encounter preview 修复 +- story engine memory 缺省补齐 + +这说明任务 7 的剩余问题,不只是“前端还有一点 normalize 代码”,而是: + +- 前端仍在解释正式存档的结构语义 +- 前端仍在决定若干正式运行时字段的缺省和修复策略 +- 后端与前端之间仍存在“双边都能定义快照有效形态”的空间 + +#### 4.2.2 下一步更合适的迁移方向 + +结合本轮已经补上的“继续游戏优先以后端 runtime story state 为准”这段恢复链路,任务 7 后续更适合继续向这个方向推进: + +1. 把 `runtimeSnapshot.ts` 中的迁移、归一化、缺省补齐继续下沉到后端持久化层或专门的 runtime snapshot service。 +2. 让前端拿到的远端快照尽量已经是“可直接消费的正式形态”,而不是“仍需前端补算的半成品”。 +3. 把前端保留的本地 hydrate 逻辑收缩到纯 UI 恢复字段,例如当前页签、面板开合、局部显示态。 + +只有这样,任务 7 才算真正回到“前端只做消费层,后端才是正式状态解释者”的目标。 + +### 4.3 任务 10 的剩余问题 + +当前最大的未完成项仍然是: + +- `src/hooks/useStoryGeneration.ts` + +它仍承担了大量: + +- 正式运行时故事编排 +- 本地 fallback 组织 +- NPC / 宝藏 / 战斗后续协调 +- 主流程状态推进 + +现阶段虽然已经有: + +- `src/hooks/useGameShellRuntime.ts` +- `src/components/game-shell/useGameShellRuntimeViewModel.ts` +- `src/hooks/story/runtimeStoryCoordinator.ts` +- `src/hooks/story/storyRequestCoordinator.ts` + +但主流程还没有彻底完成 action/view model 化,任务 10 仍应视为“进行中”。 + +#### 4.3.1 `useStoryGeneration.ts` 当前仍然为什么过重 + +从仓库现状看,`src/hooks/useStoryGeneration.ts` 目前仍有约 1700 行,且其过重问题已经不是单纯“文件太长”,而是它还保留着大量正式业务协调职责。 + +当前这个 hook 里仍集中着: + +- `buildStoryContextFromState` 这一整块故事上下文拼装 +- AI 请求前的 option catalog / fallback 组织 +- NPC / 宝藏 / 战斗 / inventory / goal / session 多条子链的集中协调 +- 本地 fallback story 生成 +- 一部分运行时规则与 narrative context 的最终拼装入口 + +这意味着即使已经拆出了: + +- `runtimeStoryCoordinator` +- `storyRequestCoordinator` +- `choiceActions` +- `npcEncounterActions` +- `sessionActions` + +`useStoryGeneration.ts` 仍然不是“单纯的 UI 壳层 hook”,而更像前端侧的运行时总协调器。 + +#### 4.3.2 任务 10 后续更值得优先拆的部分 + +按当前文件结构看,后续最值得优先继续抽离的不是零散按钮逻辑,而是下面三类真正还握在主 hook 里的核心块: + +1. `buildStoryContextFromState` 这一整块 story-engine 叙事上下文装配。 +2. `buildFallbackStoryForState` / `getAvailableOptionsForState` 这类“正式规则兜底与选项来源判断”。 +3. NPC / Treasure / Character Chat / Session 这些子流之间的最终总装协调。 + +如果只是继续把零散函数拆到 `src/hooks/story/**`,但上述三块还留在主 hook 里,那么任务 10 仍然不会真正完成。 + +### 4.4 建议的下一轮补齐顺序 + +结合任务 1、任务 7、任务 10 当前的剩余形态,后续更合适的补齐顺序建议是: + +1. 先继续收任务 7 的 runtime snapshot 解释权,把正式快照的迁移、修复、归一化口径继续回收到后端。 +2. 再继续收任务 10,把 `useStoryGeneration.ts` 压回主流程协调壳层,而不是继续让它承担正式运行时总装职责。 + +这样排的原因是: + +- 任务 1 已经完成后,新的主阻塞就变成了任务 7 和任务 10。 +- 如果任务 7 不继续收,前端仍会在恢复链路里保留正式状态解释权,任务 10 也很难真正变薄。 +- 等到 runtime state 与 snapshot 口径都更稳定后,再继续瘦 `useStoryGeneration.ts`,返工会更少。 + +--- + +## 5. 本轮验证 + +已通过: + +- `npm run server-node:test` +- `npx vitest run src/hooks/story/storyRequestCoordinator.test.ts src/hooks/story/runtimeStoryCoordinator.test.ts` +- `npm run typecheck` +- `npm run check:encoding` + +--- + +## 6. 结论 + +从仓库可验证结果看,任务 2、3、5、6、9 已经达到“可认为完成”的状态;任务 0、4、8 基本完成;任务 1、7、10 仍有明显后续收口空间。 + +当前最主要的未完成中心已经不再是后端基建,而是: + +**把前端主流程和存档恢复彻底收成“以后端 runtime state 和 view model 为唯一真相源”。** diff --git a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md index bbaab796..d078eb90 100644 --- a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md +++ b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md @@ -11,17 +11,18 @@ - 承接运行时鉴权 - 承接运行时持久化 - 承接运行时 AI 接口 +- 承接编辑器写盘与资产生成工具接口 - 为 Vite 前端提供开发期代理目标 当前不再使用: -- Vite 本地 API 插件 `scripts/dev-server/` +- Vite 本地 API 插件 `scripts/dev-server/` 作为当前接口入口 ## 2. 技术栈 - HTTP 框架:`Express` - 语言与构建:`TypeScript` + `tsx` + `esbuild` -- 数据库:`better-sqlite3` +- 数据库:`PostgreSQL` - JWT:`jose` - 密码哈希:`@node-rs/argon2` - 日志:`pino` + `pino-http` + `pino-roll` @@ -31,12 +32,13 @@ 推荐命令: ```bash -npm run dev:node +npm run dev ``` 相关脚本: -- 根目录联调:`npm run dev:node` +- 根目录联调:`npm run dev` / `npm run dev:node` +- 仅前端开发:`npm run dev:web` - 单独启动后端开发模式:`npm run server-node:dev` - 构建后端:`npm run server-node:build` - 运行后端测试:`npm run server-node:test` @@ -57,6 +59,9 @@ npm run dev:node - `server-node/src/routes/authRoutes.ts` - `server-node/src/routes/runtimeRoutes.ts` +- `server-node/src/modules/editor/editorRoutes.ts` +- `server-node/src/modules/assets/characterAssetRoutes.ts` +- `server-node/src/modules/assets/qwenSpriteRoutes.ts` 基础设施: @@ -102,8 +107,12 @@ JWT 现状: 当前数据库: -- 默认 SQLite 文件:`server-node/data/genarrative.sqlite` -- 可通过 `SQLITE_PATH` 覆盖 +- 当前运行时持久化已切换到 `PostgreSQL` +- 连接信息由后端 `DATABASE_URL` 环境变量注入 +- 不再以本地 `SQLite` 文件作为正式运行时数据库 +- 后端测试默认使用 `pg-mem` 作为内存级 PostgreSQL 兼容实现 +- 基础表初始化通过 `schema_migrations` 基线管理 +- 可通过 `npm run server-node:db:migrate` 主动校验并补齐数据库基线 当前核心表: @@ -153,6 +162,33 @@ JWT 现状: - `POST /api/runtime/items/runtime-intent` - `POST /api/runtime/quests/generate` +编辑器工具: + +- `GET /api/editor/catalog/items` +- `GET /api/editor/json/:resourceId` +- `POST /api/editor/json/:resourceId` + +资产工具: + +- `POST /api/assets/character-visual/generate` +- `POST /api/assets/character-visual/publish` +- `GET /api/assets/character-visual/jobs/:taskId` +- `POST /api/assets/character-animation/generate` +- `POST /api/assets/character-animation/publish` +- `GET /api/assets/character-animation/jobs/:taskId` +- `POST /api/assets/character-animation/import-video` +- `GET /api/assets/character-animation/templates` +- `POST /api/assets/qwen-sprite/master` +- `POST /api/assets/qwen-sprite/sheet` +- `POST /api/assets/qwen-sprite/frame-repair` +- `POST /api/assets/qwen-sprite/save` + +编辑器与资产接口门禁: + +- `EDITOR_API_ENABLED` 控制 `/api/editor/*` +- `ASSETS_API_ENABLED` 控制 `/api/assets/*` +- 非生产环境默认开启,生产环境默认关闭 + ## 8. Story 与 Custom World 现状 Story: @@ -179,6 +215,13 @@ Custom World: - `src/services/storageService.ts` - `src/services/aiService.ts` +编辑器与资产工具层: + +- `src/editor/shared/editorApiClient.ts` +- `src/editor/shared/useJsonSave.ts` +- `src/components/preset-editor/characterAssetStudioPersistence.ts` +- `src/tools/qwenSpriteSheetToolPersistence.ts` + ## 10. 当前 Vite 角色 Vite 当前只负责代理,不再提供本地 API 插件。 @@ -187,8 +230,12 @@ Vite 当前只负责代理,不再提供本地 API 插件。 - `/api/auth` - `/api/runtime` +- `/api/editor` +- `/api/assets` - `/api/llm` - `/api/custom-world/scene-image` - `/api/ws` 全部转发到 Node 后端。 + +旧 `scripts/dev-server/**` 文件仅保留为迁移参考,不再由 `vite.config.ts` 注入。 diff --git a/docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md b/docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md new file mode 100644 index 00000000..8ec3082e --- /dev/null +++ b/docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md @@ -0,0 +1,226 @@ +# Node 后端测试、观测与部署基线 + +日期:`2026-04-08` + +## 1. 文档目标 + +这份文档用于落实 `EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` 中的任务 9: + +- 给 Node 后端补最小自动回归 +- 给请求链路补最小可追踪基线 +- 给部署、回滚、迁移补最小操作清单 + +当前目标不是一次性把监控平台、CI/CD、容器编排全部做完,而是先确保: + +- 后端改动后有脚本能快速验证主链路 +- 线上或联调失败时能快速定位到具体请求 +- 发布前后有统一检查口径 + +## 2. 当前基线命令 + +推荐在仓库根目录执行: + +```bash +npm run server-node:db:migrate +npm run server-node:test:baseline +npm run server-node:smoke +npm run server-node:smoke:proxy +``` + +说明: + +- `npm run server-node:db:migrate` + - 用当前 `DATABASE_URL` 主动校验 PostgreSQL 基线是否可连接、可初始化 + - 会确保 `schema_migrations` 和运行时基础表已补齐 +- `npm run server-node:test:baseline` + - 当前先固定为任务 9 自己维护的观测基线测试 + - 已覆盖 `requestId` 回传、访问日志字段、错误日志链路 +- `npm run server-node:smoke` + - 启动一套基于 `pg-mem` 的临时 Express 服务 + - 不依赖本地 PostgreSQL + - 走真实 HTTP 调用验证 `healthz -> auth -> runtime save/settings -> logout` +- `npm run server-node:smoke:proxy` + - 基于已构建的 `dist + server-node/dist` + - 自动拉起 `server-node + 同域反向代理 harness` + - 用 `pg-mem` 跑同域反向代理链路 smoke + - 验证 `web -> reverse proxy -> /api/* -> server-node` 主链路 + +如果要一口气跑完整发布前基线,可执行: + +```bash +npm run server-node:check:deploy +``` + +补充说明: + +- `npm run server-node:test` 仍然可以继续作为更大范围的后端接口套件入口 +- 但它会跟随其他并行任务一起变化,不应替代任务 9 自己的稳定基线 + +## 2.1 任务 9 对照清单 + +当前按并行任务规划中的任务 9 逐项对照: + +- 后端接口测试:`npm run server-node:test` +- 关键主链路 smoke:`npm run server-node:smoke` +- request/response 日志校验:`npm run server-node:test:baseline` +- 同域部署基线:本文第 6 节与 `npm run server-node:smoke:proxy` +- 反向代理 smoke 测试脚本:`scripts/smoke-same-origin-stack.ts` +- 回滚、备份、迁移检查清单:本文第 8 节与 `npm run server-node:db:migrate` +- 发布前一键检查:`npm run server-node:check:deploy` + +## 3. 当前 smoke 覆盖范围 + +当前 smoke 脚本验证以下链路: + +- `GET /healthz` +- `POST /api/auth/entry` +- `GET /api/auth/me` +- `PUT /api/runtime/save/snapshot` +- `GET /api/runtime/save/snapshot` +- `PUT /api/runtime/settings` +- `GET /api/runtime/settings` +- `DELETE /api/runtime/save/snapshot` +- `POST /api/auth/logout` + +当前代理 smoke 额外验证: + +- `GET /` +- `GET /healthz`(本地反向代理健康探针) +- `POST /api/auth/entry` 经反代可用 +- `GET /api/auth/me` 经反代可用 +- `PUT /api/runtime/save/snapshot` 经反代可用 +- `GET /api/runtime/save/snapshot` 经反代可用 +- `X-Request-Id` 能穿过反向代理返回给调用方 + +当前 smoke 的定位是: + +- 优先覆盖后端化后最容易断的基础链路 +- 优先覆盖前端最依赖的鉴权和持久化能力 +- 不把 AI 上游依赖拉进最小回归集,避免把第三方波动误判成主链路回归 + +## 4. 当前观测基线 + +当前请求链路至少要满足以下约束: + +- 支持读取外部传入的 `X-Request-Id` +- 如果调用方没有传 `X-Request-Id`,后端自动生成 +- 响应头回写 `x-request-id` +- 访问日志至少包含: + - `request_id` + - `user_id` + - `method` + - `path` + - `status` + - `latency_ms` +- 错误日志至少包含: + - `request_id` + - `user_id` + - `err` + +当前目的很明确: + +- 浏览器、反向代理、Node 后端至少有一个共同可追踪的请求标识 +- 接口失败后,能从日志里快速找到对应请求 + +## 5. 部署前检查清单 + +发布前至少执行一次: + +- `npm run check:encoding` +- `npm run server-node:db:migrate` +- `npm run server-node:test:baseline` +- `npm run server-node:smoke` +- `npm run server-node:build` +- `npm run build` +- `npm run server-node:smoke:proxy` + +环境变量至少确认: + +- `DATABASE_URL` +- `JWT_SECRET` +- `NODE_SERVER_ADDR` +- `LOG_LEVEL` +- `LLM_API_KEY` 或 `ARK_API_KEY` +- `DASHSCOPE_API_KEY` + +部署前数据库检查: + +- 确认目标 PostgreSQL 可连接 +- 确认发布账号具备建表或执行初始化所需权限 +- 确认已执行 `npm run server-node:db:migrate` 或等效迁移步骤 +- 确认现网数据已完成备份 + +## 6. 同域部署基线 + +当前推荐仍然是同域部署: + +- Web 静态资源:`https://game.example.com/` +- Node API:`https://game.example.com/api/*` + +最小拓扑: + +```text +Browser + -> Nginx / Caddy + -> dist + -> server-node +``` + +反向代理至少要保留这些头: + +- `Host` +- `X-Forwarded-For` +- `X-Forwarded-Proto` +- `X-Request-Id` + +流式接口还要确保: + +- `proxy_buffering off` +- `X-Accel-Buffering: no` + +## 7. 发布后 smoke 清单 + +发布完成后至少人工或脚本确认一次: + +1. `GET /healthz` 返回 `200` +2. 响应头里能看到 `x-request-id` +3. `POST /api/auth/entry` 可正常注册或恢复账号 +4. `GET /api/auth/me` 可正常识别 token +5. `PUT /api/runtime/save/snapshot` 和 `GET /api/runtime/save/snapshot` 正常 +6. 日志中能用同一个 `request_id` 串起访问记录 + +如果线上使用反向代理生成请求 ID,还要额外确认: + +- 代理传入的 `X-Request-Id` 没有在 Node 层丢失 +- 同域入口 `/` 与 `/api/*` 可以通过同一个站点域名访问 + +## 8. 回滚与备份清单 + +回滚前先确认: + +- 当前发布包版本号或 commit 可定位 +- 当前数据库备份可恢复 +- 当前 `.env` 或 secret 版本可回退 + +需要回滚时按顺序执行: + +1. 停止新版本 Node 进程 +2. 切回上一个稳定前端静态包和 Node 构建产物 +3. 恢复上一个稳定环境变量版本 +4. 如果本次发布包含数据库结构变更,先确认是否需要回滚数据 +5. 回滚后重新执行 `healthz + auth + runtime save` 最小 smoke + +如果本次发布已经写入了不兼容数据结构: + +- 不要只回滚代码不验证数据兼容性 +- 必须先确认旧版本代码是否还能读取当前数据 + +## 9. 后续扩展方向 + +任务 9 的下一轮可以继续补: + +- 把 smoke 纳入 CI +- 为关键 API 增加结构化 contract 测试 +- 给上游 AI 调用补 vendor/model/errorCode 维度日志 +- 增加数据库迁移前后的自动检查脚本 +- 增加反向代理与正式环境的联调 smoke diff --git a/docs/technical/README.md b/docs/technical/README.md index e281bf04..09b8038b 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,9 @@ ## 文档列表 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 +- [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md):Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。 +- [EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md](./EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md):按并行工作流文档逐项核对后的完成度审计与剩余收口点。 +- [EDITOR_ASSET_API_MIGRATION_2026-04-08.md](./EDITOR_ASSET_API_MIGRATION_2026-04-08.md):编辑器写盘、资产生成、任务查询从 Vite 本地插件迁到 Node 后端的接口与工具链清单。 - [GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md](./GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md):Go 服务端接入、运行时持久化迁移与当前进展记录。 - [GO_SERVER_TASKLIST_2026-04-08.md](./GO_SERVER_TASKLIST_2026-04-08.md):Go 服务端已完成与未完成事项的执行清单。 - [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md):AI 生成角色形象与角色动画的技术路线。 diff --git a/package.json b/package.json index 39222333..c71e3775 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,18 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0", + "dev": "node scripts/dev-node.mjs", + "dev:web": "node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0", "dev:node": "node scripts/dev-node.mjs", "serve:caddy": "node scripts/run-caddy-dev.mjs", "server-node:dev": "npm --prefix server-node run dev", "server-node:build": "npm --prefix server-node run build", + "server-node:db:migrate": "npm --prefix server-node run db:migrate", "server-node:test": "npm --prefix server-node run test", + "server-node:test:baseline": "npx tsx --test server-node/src/observability.test.ts", + "server-node:smoke": "npx tsx scripts/smoke-server-node.ts", + "server-node:smoke:proxy": "npx tsx scripts/smoke-same-origin-stack.ts", + "server-node:check:deploy": "npm run check:encoding && npm run server-node:test && npm run server-node:smoke && npm run server-node:build && npm run build && npm run server-node:smoke:proxy", "build": "node scripts/build-gate.mjs", "build:raw": "node scripts/vite-cli.mjs build", "preview": "node scripts/vite-cli.mjs preview", diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..61866ae7 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,6 @@ +{ + "name": "@genarrative/shared", + "private": true, + "version": "0.1.0", + "type": "module" +} diff --git a/packages/shared/src/assets/qwenSprite.ts b/packages/shared/src/assets/qwenSprite.ts new file mode 100644 index 00000000..2dff962d --- /dev/null +++ b/packages/shared/src/assets/qwenSprite.ts @@ -0,0 +1,157 @@ +export type QwenSpriteActionTemplateId = + | 'idle' + | 'run' + | 'attack_slash' + | 'hurt' + | 'die'; + +export type QwenSpriteActionTemplate = { + id: QwenSpriteActionTemplateId; + label: string; + loop: boolean; + defaultFps: number; + bodyTravel: string; + weaponRule: string; + sequenceLines: [string, string, string, string]; + ending: string; +}; + +export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [ + { + id: 'idle', + label: '待机循环', + loop: true, + defaultFps: 8, + bodyTravel: '原地', + weaponRule: '武器始终在主手,位置稳定', + sequenceLines: [ + '1-4 帧:稳定站姿,轻微呼吸起伏', + '5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化', + '9-12 帧:呼气回落,重心恢复', + '13-16 帧:逐渐回到与首帧接近的站姿', + ], + ending: '第 16 帧自然衔接第 1 帧', + }, + { + id: 'run', + label: '奔跑循环', + loop: true, + defaultFps: 12, + bodyTravel: '小幅前移但角色中心基本固定', + weaponRule: '武器始终在主手,不换手', + sequenceLines: [ + '1-4 帧:右腿前摆,左腿后蹬,身体略前倾', + '5-8 帧:双腿交叉经过身体下方,手臂反向摆动', + '9-12 帧:左腿前摆,右腿后蹬,继续前倾', + '13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态', + ], + ending: '第 16 帧能无缝接回第 1 帧', + }, + { + id: 'attack_slash', + label: '横斩攻击', + loop: false, + defaultFps: 12, + bodyTravel: '中幅前探', + weaponRule: '右手持武器,始终右手,不换手', + sequenceLines: [ + '1-4 帧:轻微收身蓄力,武器向后收', + '5-8 帧:重心前压,挥击开始', + '9-12 帧:斩击达到最大幅度,动作力量最强', + '13-16 帧:顺势收招,回到可接下一动作的稳定姿态', + ], + ending: '第 16 帧停在收招后稳定姿态', + }, + { + id: 'hurt', + label: '受击后仰', + loop: false, + defaultFps: 10, + bodyTravel: '原地或极小后仰', + weaponRule: '武器不要脱手,不要换手', + sequenceLines: [ + '1-4 帧:突然受击,头肩后仰', + '5-8 帧:身体失衡最明显', + '9-12 帧:手臂和武器随惯性摆动', + '13-16 帧:逐渐恢复到勉强站稳的姿态', + ], + ending: '第 16 帧能接回 idle 或下一个动作', + }, + { + id: 'die', + label: '倒地死亡', + loop: false, + defaultFps: 8, + bodyTravel: '明显倒地位移', + weaponRule: '武器不可瞬间消失', + sequenceLines: [ + '1-4 帧:受创失衡,重心被打断', + '5-8 帧:身体明显下坠或后仰', + '9-12 帧:倒地过程完成,动作幅度最大', + '13-16 帧:停在清晰的终止姿态', + ], + ending: '第 16 帧停在死亡结束姿态,不需要循环', + }, +]; + +const CHIBI_STYLE_TEXT = + 'Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。'; +const PIXEL_STYLE_TEXT = + '参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。'; +const STYLE_REFERENCE_SCOPE_TEXT = + '参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。'; +const CONCEPT_INTERPRETATION_TEXT = + '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。'; +const HUMANLIKE_PRIORITY_TEXT = + '默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。'; +const JELLYFISH_THEME_EXAMPLE_TEXT = + '示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。'; +const CONCEPT_HIERARCHY_TEXT = + '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。'; +const CHIBI_CHARACTER_TEXT = + '即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。'; + +export function getActionTemplateById(id: QwenSpriteActionTemplateId) { + return ( + QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ?? + QWEN_SPRITE_ACTION_TEMPLATES[0] + ); +} + +export function buildMasterPrompt(characterBrief: string) { + return [ + '单人,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。', + '画面要求:1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。', + `风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, + CONCEPT_INTERPRETATION_TEXT, + HUMANLIKE_PRIORITY_TEXT, + CONCEPT_HIERARCHY_TEXT, + JELLYFISH_THEME_EXAMPLE_TEXT, + characterBrief.trim(), + ] + .filter(Boolean) + .join('\n'); +} + +export function buildVideoActionPrompt(options: { + actionTemplate: QwenSpriteActionTemplate; + actionDetailText: string; + useChromaKey: boolean; + characterBrief: string; +}) { + return [ + `单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`, + `角色固定为图1同一角色,始终侧身朝右,镜头稳定,轮廓清晰。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`, + CONCEPT_INTERPRETATION_TEXT, + HUMANLIKE_PRIORITY_TEXT, + CONCEPT_HIERARCHY_TEXT, + JELLYFISH_THEME_EXAMPLE_TEXT, + `动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`, + options.useChromaKey + ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' + : '背景简洁纯净,无复杂场景。', + `动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`, + `角色设定:${options.characterBrief.trim()}`, + '目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。', + ].join(' '); +} diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts new file mode 100644 index 00000000..ebe0a2bc --- /dev/null +++ b/packages/shared/src/contracts/auth.ts @@ -0,0 +1,158 @@ +export type AuthBindingStatus = 'active' | 'pending_bind_phone'; +export type AuthLoginMethod = 'password' | 'phone' | 'wechat'; + +export type AuthUser = { + id: string; + username: string; + displayName: string; + phoneNumberMasked: string | null; + loginMethod: AuthLoginMethod; + bindingStatus: AuthBindingStatus; + wechatBound: boolean; +}; + +export type AuthEntryRequest = { + username: string; + password: string; +}; + +export type AuthEntryResponse = { + token: string; + user: AuthUser; +}; + +export type AuthPhoneSendCodeRequest = { + phone: string; + scene?: 'login' | 'bind_phone' | 'change_phone'; + captchaChallengeId?: string; + captchaAnswer?: string; +}; + +export type AuthPhoneSendCodeResponse = { + ok: true; + cooldownSeconds: number; + expiresInSeconds: number; + providerRequestId: string | null; +}; + +export type AuthPhoneLoginRequest = { + phone: string; + code: string; +}; + +export type AuthPhoneLoginResponse = { + token: string; + user: AuthUser; +}; + +export type AuthMeResponse = { + user: AuthUser | null; + availableLoginMethods: AuthLoginMethod[]; +}; + +export type AuthWechatStartResponse = { + authorizationUrl: string; +}; + +export type AuthWechatBindPhoneRequest = { + phone: string; + code: string; +}; + +export type AuthWechatBindPhoneResponse = { + token: string; + user: AuthUser; +}; + +export type AuthPhoneChangeRequest = { + phone: string; + code: string; +}; + +export type AuthPhoneChangeResponse = { + user: AuthUser; +}; + +export type AuthRefreshResponse = { + token: string; +}; + +export type AuthSessionSummary = { + sessionId: string; + clientType: string; + clientLabel: string; + userAgent: string | null; + ipMasked: string | null; + isCurrent: boolean; + createdAt: string; + lastSeenAt: string; + expiresAt: string; +}; + +export type AuthSessionsResponse = { + sessions: AuthSessionSummary[]; +}; + +export type AuthLogoutAllResponse = { + ok: true; +}; + +export type AuthRevokeSessionResponse = { + ok: true; +}; + +export type AuthAuditLogEventType = + | 'password_login' + | 'phone_login' + | 'wechat_login' + | 'wechat_bind_phone' + | 'change_phone' + | 'captcha_required' + | 'logout' + | 'logout_all' + | 'revoke_session' + | 'risk_block_phone' + | 'risk_block_ip' + | 'risk_unblock_phone' + | 'risk_unblock_ip'; + +export type AuthAuditLogEntry = { + id: string; + eventType: AuthAuditLogEventType; + title: string; + detail: string; + ipMasked: string | null; + userAgent: string | null; + createdAt: string; +}; + +export type AuthAuditLogsResponse = { + logs: AuthAuditLogEntry[]; +}; + +export type AuthCaptchaChallenge = { + challengeId: string; + promptText: string; + imageDataUrl: string; + expiresInSeconds: number; +}; + +export type AuthRiskBlockSummary = { + scopeType: 'phone' | 'ip'; + title: string; + detail: string; + expiresAt: string; + remainingSeconds: number; +}; + +export type AuthRiskBlocksResponse = { + blocks: AuthRiskBlockSummary[]; +}; + +export type AuthLiftRiskBlockResponse = { + ok: true; +}; + +export type LogoutResponse = { + ok: true; +}; diff --git a/packages/shared/src/contracts/common.ts b/packages/shared/src/contracts/common.ts new file mode 100644 index 00000000..9d46376f --- /dev/null +++ b/packages/shared/src/contracts/common.ts @@ -0,0 +1,3 @@ +export type JsonObject = Record; + +export type JsonArray = unknown[]; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts new file mode 100644 index 00000000..6ab314be --- /dev/null +++ b/packages/shared/src/contracts/runtime.ts @@ -0,0 +1,124 @@ +import type { JsonObject } from './common'; + +export const SAVE_SNAPSHOT_VERSION = 2; +export const DEFAULT_MUSIC_VOLUME = 0.42; + +export type SavedGameSnapshot< + TGameState = unknown, + TBottomTab extends string = string, + TCurrentStory = unknown, +> = { + version: number; + savedAt: string; + gameState: TGameState; + bottomTab: TBottomTab; + currentStory: TCurrentStory | null; +}; + +export type SavedGameSnapshotInput< + TGameState = unknown, + TBottomTab extends string = string, + TCurrentStory = unknown, +> = Omit< + SavedGameSnapshot, + 'savedAt' | 'version' +> & { + savedAt?: string; +}; + +export type RuntimeSettings = { + musicVolume: number; +}; + +export type BasicOkResult = { + ok: true; +}; + +export type CustomWorldProfileRecord = JsonObject & { + id?: string; +}; + +export type CustomWorldLibraryResponse< + TProfile = CustomWorldProfileRecord, +> = { + profiles: TProfile[]; +}; + +export const CUSTOM_WORLD_GENERATION_MODES = ['fast', 'full'] as const; +export type CustomWorldGenerationMode = + (typeof CUSTOM_WORLD_GENERATION_MODES)[number]; + +export const CUSTOM_WORLD_SESSION_STATUSES = [ + 'clarifying', + 'ready_to_generate', + 'generating', + 'completed', + 'generation_error', +] as const; +export type CustomWorldSessionStatus = + (typeof CUSTOM_WORLD_SESSION_STATUSES)[number]; + +export type CustomWorldQuestion = { + id: string; + label: string; + question: string; + answer?: string; +}; + +export type CustomWorldGenerationStep = { + id: string; + label: string; + detail: string; + completed: number; + total: number; + status: 'pending' | 'active' | 'completed'; +}; + +export type CustomWorldGenerationProgress = { + phaseId: string; + phaseLabel: string; + phaseDetail: string; + batchLabel?: string; + overallProgress: number; + completedWeight: number; + totalWeight: number; + elapsedMs: number; + estimatedRemainingMs: number | null; + activeStepIndex: number; + steps: CustomWorldGenerationStep[]; +}; + +export type GenerateCustomWorldProfileOptions = { + onProgress?: (progress: CustomWorldGenerationProgress) => void; + signal?: AbortSignal; +}; + +export type GenerateCustomWorldProfileInput = { + settingText: string; + creatorIntent?: JsonObject | null; + generationMode?: CustomWorldGenerationMode; +}; + +export type CreateCustomWorldSessionRequest = { + settingText: string; + creatorIntent?: JsonObject | null; + generationMode: CustomWorldGenerationMode; +}; + +export type AnswerCustomWorldSessionQuestionRequest = { + questionId: string; + answer: string; +}; + +export type CustomWorldSessionSummary = { + sessionId: string; + status: CustomWorldSessionStatus; + questions: CustomWorldQuestion[]; +}; + +export type CustomWorldSessionRecord = CustomWorldSessionSummary & { + settingText: string; + generationMode: CustomWorldGenerationMode; + result?: JsonObject; + lastError?: string; +}; diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts new file mode 100644 index 00000000..1e8ea1b3 --- /dev/null +++ b/packages/shared/src/contracts/story.ts @@ -0,0 +1,408 @@ +import type { JsonObject } from './common'; +import type { SavedGameSnapshot } from './runtime'; + +export const QUEST_NARRATIVE_TYPES = [ + 'bounty', + 'escort', + 'investigation', + 'retrieval', + 'relationship', + 'trial', +] as const; +export type SharedQuestNarrativeType = + (typeof QUEST_NARRATIVE_TYPES)[number]; + +export const QUEST_OBJECTIVE_KINDS = [ + 'defeat_hostile_npc', + 'inspect_treasure', + 'spar_with_npc', + 'talk_to_npc', + 'reach_scene', + 'deliver_item', +] as const; +export type SharedQuestObjectiveKind = + (typeof QUEST_OBJECTIVE_KINDS)[number]; + +export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const; +export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number]; + +export const QUEST_INTIMACY_LEVELS = [ + 'transactional', + 'cooperative', + 'trust_based', +] as const; +export type SharedQuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number]; + +export const QUEST_REWARD_THEMES = [ + 'currency', + 'resource', + 'relationship', + 'intel', + 'rare_item', +] as const; +export type SharedQuestRewardTheme = + (typeof QUEST_REWARD_THEMES)[number]; + +export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [ + 'heal', + 'mana', + 'cooldown', + 'guard', + 'damage', +] as const; +export type SharedRuntimeItemFunctionalBias = + (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; + +export const RUNTIME_ITEM_TONE_VALUES = [ + 'grim', + 'mysterious', + 'martial', + 'ritual', + 'survival', +] as const; +export type SharedRuntimeItemTone = + (typeof RUNTIME_ITEM_TONE_VALUES)[number]; + +export type StoryRequestOptionsPayload = { + availableOptions?: JsonObject[]; + optionCatalog?: JsonObject[]; +}; + +export type StoryRequestPayload = { + worldType: TWorldType; + character: JsonObject; + monsters?: JsonObject[]; + history?: JsonObject[]; + choice?: string; + context: JsonObject; + requestOptions?: StoryRequestOptionsPayload; +}; + +export type PlainTextPromptRequest = { + systemPrompt: string; + userPrompt: string; +}; + +export type PlainTextResponse = { + text: string; +}; + +export type CharacterChatReplyRequest< + TCharacter = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TTargetStatus = unknown, +> = { + worldType: string; + playerCharacter: TCharacter; + targetCharacter: TCharacter; + storyHistory: TStoryMoment[]; + context: TContext; + conversationHistory: TConversationTurn[]; + conversationSummary: string; + playerMessage: string; + targetStatus: TTargetStatus; +}; + +export type CharacterChatSuggestionsRequest< + TCharacter = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TTargetStatus = unknown, +> = { + worldType: string; + playerCharacter: TCharacter; + targetCharacter: TCharacter; + storyHistory: TStoryMoment[]; + context: TContext; + conversationHistory: TConversationTurn[]; + conversationSummary: string; + targetStatus: TTargetStatus; +}; + +export type CharacterChatSummaryRequest< + TCharacter = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TTargetStatus = unknown, +> = { + worldType: string; + playerCharacter: TCharacter; + targetCharacter: TCharacter; + storyHistory: TStoryMoment[]; + context: TContext; + conversationHistory: TConversationTurn[]; + previousSummary: string; + targetStatus: TTargetStatus; +}; + +export type NpcChatDialogueRequest< + TCharacter = unknown, + TEncounter = unknown, + TMonster = unknown, + TStoryMoment = unknown, + TContext = unknown, +> = { + worldType: string; + character: TCharacter; + encounter: TEncounter; + monsters: TMonster[]; + history: TStoryMoment[]; + context: TContext; + topic: string; + resultSummary: string; +}; + +export type NpcRecruitDialogueRequest< + TCharacter = unknown, + TEncounter = unknown, + TMonster = unknown, + TStoryMoment = unknown, + TContext = unknown, +> = { + worldType: string; + character: TCharacter; + encounter: TEncounter; + monsters: TMonster[]; + history: TStoryMoment[]; + context: TContext; + invitationText: string; + recruitSummary: string; +}; + +export type RuntimeItemIntentRequest< + TContext = JsonObject, + TPlan = JsonObject, +> = { + context: TContext; + plans: TPlan[]; +}; + +export type RuntimeItemIntentResponse = { + intents: TIntent[]; +}; + +export type QuestGenerationRequest< + TState = JsonObject, + TEncounter = JsonObject, +> = { + state: TState; + encounter: TEncounter; +}; + +export type RuntimeAction< + TType extends string = string, + TPayload = JsonObject, +> = { + type: TType; + functionId?: string; + targetId?: string; + payload?: TPayload; +}; + +export type RuntimeActionRequest< + TAction extends RuntimeAction = RuntimeAction, +> = { + sessionId: string; + clientVersion?: number; + action: TAction; +}; + +export type RuntimeActionResponse< + TViewModel = JsonObject, + TPresentation = JsonObject, + TPatch = JsonObject, +> = { + sessionId: string; + serverVersion: number; + viewModel: TViewModel; + presentation: TPresentation; + patches: TPatch[]; +}; + +export const TASK5_RUNTIME_FUNCTION_IDS = [ + 'story_continue_adventure', + 'story_opening_camp_dialogue', + 'camp_travel_home_scene', + 'idle_call_out', + 'idle_explore_forward', + 'idle_observe_signs', + 'idle_rest_focus', + 'idle_travel_next_scene', + 'battle_all_in_crush', + 'battle_escape_breakout', + 'battle_feint_step', + 'battle_finisher_window', + 'battle_guard_break', + 'battle_probe_pressure', + 'battle_recover_breath', + 'npc_chat', + 'npc_fight', + 'npc_help', + 'npc_leave', + 'npc_preview_talk', + 'npc_recruit', + 'npc_spar', +] as const; +export type Task5RuntimeFunctionId = + (typeof TASK5_RUNTIME_FUNCTION_IDS)[number]; + +export const TASK6_RUNTIME_FUNCTION_IDS = [ + 'equipment_equip', + 'equipment_unequip', + 'forge_craft', + 'forge_dismantle', + 'forge_reforge', + 'inventory_use', + 'npc_gift', + 'npc_quest_accept', + 'npc_quest_turn_in', + 'npc_trade', + 'treasure_inspect', + 'treasure_leave', + 'treasure_secure', +] as const; +export type Task6RuntimeFunctionId = + (typeof TASK6_RUNTIME_FUNCTION_IDS)[number]; + +export const SERVER_RUNTIME_FUNCTION_IDS = [ + ...TASK5_RUNTIME_FUNCTION_IDS, + ...TASK6_RUNTIME_FUNCTION_IDS, +] as const; +export type ServerRuntimeFunctionId = + (typeof SERVER_RUNTIME_FUNCTION_IDS)[number]; + +export const TASK5_RUNTIME_OPTION_SCOPES = ['story', 'combat', 'npc'] as const; +export type Task5RuntimeOptionScope = + (typeof TASK5_RUNTIME_OPTION_SCOPES)[number]; + +export type RuntimeStoryChoicePayload = JsonObject & { + optionText?: string; + note?: string; +}; + +export type RuntimeStoryChoiceAction = RuntimeAction< + 'story_choice', + RuntimeStoryChoicePayload +> & { + functionId: string; + targetId?: string; +}; + +export type RuntimeStoryOptionView = { + functionId: string; + actionText: string; + detailText?: string; + scope: Task5RuntimeOptionScope; + disabled?: boolean; + reason?: string; +}; + +export type RuntimeStoryPlayerViewModel = { + hp: number; + maxHp: number; + mana: number; + maxMana: number; +}; + +export type RuntimeStoryCompanionViewModel = { + npcId: string; + characterId?: string; + joinedAtAffinity: number; +}; + +export type RuntimeStoryEncounterViewModel = { + id: string; + kind: 'npc' | 'treasure'; + npcName: string; + hostile: boolean; + affinity?: number; + recruited?: boolean; + interactionActive: boolean; + battleMode?: 'fight' | 'spar' | null; +}; + +export type RuntimeStoryStatusViewModel = { + inBattle: boolean; + npcInteractionActive: boolean; + currentNpcBattleMode: 'fight' | 'spar' | null; + currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; +}; + +export type RuntimeBattlePresentation = { + targetId?: string; + targetName?: string; + damageDealt?: number; + damageTaken?: number; + outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; +}; + +export type RuntimeStoryViewModel = { + player: RuntimeStoryPlayerViewModel; + encounter: RuntimeStoryEncounterViewModel | null; + companions: RuntimeStoryCompanionViewModel[]; + availableOptions: RuntimeStoryOptionView[]; + status: RuntimeStoryStatusViewModel; +}; + +export type RuntimeStoryPresentation = { + actionText: string; + resultText: string; + storyText: string; + options: RuntimeStoryOptionView[]; + toast?: string | null; + battle?: RuntimeBattlePresentation | null; +}; + +export type RuntimeStoryPatch = + | { + type: 'story_history_append'; + actionText: string; + resultText: string; + } + | { + type: 'npc_affinity_changed'; + npcId: string; + previousAffinity: number; + nextAffinity: number; + } + | { + type: 'battle_resolved'; + functionId: string; + targetId?: string; + damageDealt?: number; + damageTaken?: number; + outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; + } + | { + type: 'status_changed'; + inBattle: boolean; + npcInteractionActive: boolean; + currentNpcBattleMode: 'fight' | 'spar' | null; + currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; + } + | { + type: 'encounter_changed'; + encounterId: string | null; + }; + +export type RuntimeStoryActionRequest = + RuntimeActionRequest; + +export type RuntimeStoryActionResponse< + TSnapshotGameState = JsonObject, + TSnapshotCurrentStory = JsonObject, +> = RuntimeActionResponse< + RuntimeStoryViewModel, + RuntimeStoryPresentation, + RuntimeStoryPatch +> & { + snapshot: SavedGameSnapshot< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; +}; diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts new file mode 100644 index 00000000..94e609ca --- /dev/null +++ b/packages/shared/src/http.ts @@ -0,0 +1,199 @@ +export const API_VERSION = '2026-04-08'; +export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; +export const API_RESPONSE_ENVELOPE_VERSION = 'v1'; + +export type ApiErrorCode = + | 'BAD_REQUEST' + | 'INVALID_REQUEST' + | 'VALIDATION_ERROR' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'UPSTREAM_ERROR' + | 'INTERNAL_SERVER_ERROR' + | 'bad_request' + | 'validation_error' + | 'unauthorized' + | 'forbidden' + | 'not_found' + | 'conflict' + | 'upstream_error' + | 'internal_error' + | (string & {}); + +export type ApiErrorPayload = { + code: ApiErrorCode; + message: string; + details?: Record | null; +}; + +export type ApiMeta = { + apiVersion: string; + requestId?: string; + routeVersion?: string; + operation?: string | null; + latencyMs?: number; + timestamp?: string; +}; + +export type ApiSuccessResponse = { + ok: true; + data: T; + error: null; + meta: ApiMeta; +}; + +export type ApiErrorResponse = { + ok: false; + data: null; + error: ApiErrorPayload; + meta: ApiMeta; +}; + +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function buildApiMeta(meta: Partial = {}): ApiMeta { + return { + apiVersion: meta.apiVersion ?? API_VERSION, + requestId: + typeof meta.requestId === 'string' && meta.requestId.trim() + ? meta.requestId.trim() + : undefined, + routeVersion: + typeof meta.routeVersion === 'string' && meta.routeVersion.trim() + ? meta.routeVersion.trim() + : undefined, + operation: + typeof meta.operation === 'string' && meta.operation.trim() + ? meta.operation.trim() + : meta.operation === null + ? null + : undefined, + latencyMs: + typeof meta.latencyMs === 'number' && Number.isFinite(meta.latencyMs) + ? meta.latencyMs + : undefined, + timestamp: + typeof meta.timestamp === 'string' && meta.timestamp.trim() + ? meta.timestamp.trim() + : undefined, + }; +} + +export function createApiSuccess( + data: T, + meta: Partial = {}, +): ApiSuccessResponse { + return { + ok: true, + data, + error: null, + meta: buildApiMeta(meta), + }; +} + +export function createApiError( + error: ApiErrorPayload, + meta: Partial = {}, +): ApiErrorResponse { + return { + ok: false, + data: null, + error: { + code: error.code, + message: error.message, + details: error.details ?? null, + }, + meta: buildApiMeta(meta), + }; +} + +export function isApiResponse(value: unknown): value is ApiResponse { + if (!isRecord(value) || typeof value.ok !== 'boolean' || !('meta' in value)) { + return false; + } + + if (!isRecord(value.meta) || typeof value.meta.apiVersion !== 'string') { + return false; + } + + if (value.ok) { + return 'data' in value && value.error === null; + } + + return ( + value.data === null && + isRecord(value.error) && + typeof value.error.code === 'string' && + typeof value.error.message === 'string' + ); +} + +export function unwrapApiResponse(value: ApiResponse | T): T { + if (!isApiResponse(value)) { + return value as T; + } + + if (value.ok) { + return value.data; + } + + throw new Error(value.error.message || '请求失败'); +} + +export function parseApiErrorMessage(rawText: string, fallbackMessage: string) { + if (!rawText.trim()) { + return fallbackMessage; + } + + try { + const parsed = JSON.parse(rawText) as + | ApiErrorResponse + | { + error?: { + message?: string; + code?: string; + }; + message?: string; + code?: string; + }; + + if ( + typeof parsed.error?.message === 'string' && + parsed.error.message.trim() + ) { + return parsed.error.message.trim(); + } + + const topLevelMessage = + 'message' in parsed && typeof parsed.message === 'string' + ? parsed.message.trim() + : ''; + + if (topLevelMessage) { + return topLevelMessage; + } + + const errorCode = + typeof parsed.error?.code === 'string' && parsed.error.code.trim() + ? parsed.error.code.trim() + : 'code' in parsed && + typeof parsed.code === 'string' && + parsed.code.trim() + ? parsed.code.trim() + : ''; + + if (errorCode) { + return `${fallbackMessage}(${errorCode})`; + } + } catch { + // Ignore malformed json responses. + } + + return rawText.trim() || fallbackMessage; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 00000000..85f44d3a --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,8 @@ +export * from './assets/qwenSprite'; +export * from './contracts/auth'; +export * from './contracts/common'; +export * from './contracts/runtime'; +export * from './contracts/story'; +export * from './http'; +export * from './llm/narrativeLanguage'; +export * from './llm/parsers'; diff --git a/packages/shared/src/llm/narrativeLanguage.ts b/packages/shared/src/llm/narrativeLanguage.ts new file mode 100644 index 00000000..63162dfa --- /dev/null +++ b/packages/shared/src/llm/narrativeLanguage.ts @@ -0,0 +1,66 @@ +const CJK_CHAR_PATTERN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/gu; +const LATIN_WORD_PATTERN = /[A-Za-z][A-Za-z'’-]{1,}/g; +const LATIN_FRAGMENT_PATTERN = + /[A-Za-z][A-Za-z0-9'"“”‘’()\-,:;!?/]*(?:\s+[A-Za-z0-9'"“”‘’()\-,:;!?/]+)+/gu; +const SAFE_LATIN_TOKENS = new Set([ + 'act', + 'ai', + 'boss', + 'cd', + 'hp', + 'json', + 'llm', + 'mp', + 'npc', + 'qa', + 'rpg', +]); + +function getCjkCharCount(text: string) { + return text.match(CJK_CHAR_PATTERN)?.length ?? 0; +} + +function getSignificantLatinWords(text: string) { + return (text.match(LATIN_WORD_PATTERN) ?? []) + .map((word) => word.toLowerCase()) + .filter((word) => word.length >= 4 && !SAFE_LATIN_TOKENS.has(word)); +} + +export function hasMixedNarrativeLanguage(text: string) { + const trimmed = text.trim(); + if (!trimmed) { + return false; + } + + const cjkCharCount = getCjkCharCount(trimmed); + const latinSentenceFragments = (trimmed.match(LATIN_FRAGMENT_PATTERN) ?? []) + .map((fragment) => fragment.trim()) + .filter((fragment) => fragment.split(/\s+/u).length >= 2); + const significantLatinWords = getSignificantLatinWords(trimmed); + + if (latinSentenceFragments.length > 0) { + return true; + } + + if (cjkCharCount > 0 && significantLatinWords.length >= 2) { + return true; + } + + return cjkCharCount === 0 && significantLatinWords.length >= 3; +} + +export function sanitizePromptNarrativeText( + text: string | null | undefined, + fallback: string | null = null, +) { + if (typeof text !== 'string') { + return fallback; + } + + const trimmed = text.trim(); + if (!trimmed) { + return fallback; + } + + return hasMixedNarrativeLanguage(trimmed) ? fallback : trimmed; +} diff --git a/packages/shared/src/llm/parsers.ts b/packages/shared/src/llm/parsers.ts new file mode 100644 index 00000000..e8dcfb5f --- /dev/null +++ b/packages/shared/src/llm/parsers.ts @@ -0,0 +1,28 @@ +export function parseJsonResponseText(text: string) { + const trimmed = text.trim(); + if (!trimmed) { + throw new Error('LLM returned an empty response.'); + } + + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu); + if (fencedMatch?.[1]) { + return JSON.parse(fencedMatch[1].trim()); + } + + const firstBrace = trimmed.indexOf('{'); + const lastBrace = trimmed.lastIndexOf('}'); + if (firstBrace >= 0 && lastBrace > firstBrace) { + return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1)); + } + + return JSON.parse(trimmed); +} + +export function parseLineListContent(text: string, maxItems = 3) { + return text + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim().replace(/^[-*\d.)\s]+/u, '').trim()) + .filter(Boolean) + .slice(0, maxItems); +} diff --git a/scripts/dev-node.mjs b/scripts/dev-node.mjs index 16c42143..1cae3835 100644 --- a/scripts/dev-node.mjs +++ b/scripts/dev-node.mjs @@ -1,14 +1,26 @@ +import net from 'node:net'; +import path from 'node:path'; import {spawn} from 'node:child_process'; import {existsSync, readFileSync} from 'node:fs'; -import path from 'node:path'; import {fileURLToPath} from 'node:url'; const repoRoot = fileURLToPath(new URL('../', import.meta.url)); const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url)); const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url)); +const serverTsxCliPath = fileURLToPath( + new URL('../server-node/node_modules/tsx/dist/cli.mjs', import.meta.url), +); const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url)); const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url)); +const bundledNodePath = fileURLToPath( + new URL('../.tools/node-v22.22.2-win-x64/node.exe', import.meta.url), +); +const bundledNpmCliPath = fileURLToPath( + new URL('../.tools/node-v22.22.2-win-x64/node_modules/npm/bin/npm-cli.js', import.meta.url), +); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative'; +const DEV_MEMORY_DATABASE_URL = 'pg-mem://genarrative-dev'; function parseEnvContents(contents) { return contents @@ -47,6 +59,44 @@ function readEnvFile(filePath) { return parseEnvContents(readFileSync(filePath, 'utf8')); } +function resolveDatabaseProbeTarget(databaseUrl) { + const trimmed = databaseUrl.trim(); + if (!trimmed || !/^postgres(?:ql)?:\/\//u.test(trimmed)) { + return null; + } + + try { + const url = new URL(trimmed); + return { + host: url.hostname === '0.0.0.0' ? '127.0.0.1' : url.hostname, + port: Number(url.port || 5432), + }; + } catch { + return null; + } +} + +function checkTcpReachable(target, timeoutMs = 1500) { + return new Promise((resolve) => { + const socket = net.createConnection(target); + let settled = false; + + const finish = (result) => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + resolve(result); + }; + + socket.setTimeout(timeoutMs); + socket.once('connect', () => finish(true)); + socket.once('timeout', () => finish(false)); + socket.once('error', () => finish(false)); + }); +} + function resolveServerTarget(serverAddr) { const trimmed = serverAddr.trim(); @@ -77,23 +127,104 @@ function resolveServerTarget(serverAddr) { return `http://${trimmed}`; } +function redactDatabaseUrl(databaseUrl) { + const trimmed = `${databaseUrl || ''}`.trim(); + + if (!trimmed) { + return '[missing]'; + } + + if (trimmed.startsWith('pg-mem://')) { + return trimmed; + } + + try { + const url = new URL(trimmed); + const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres'; + const portSuffix = url.port ? `:${url.port}` : ''; + return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`; + } catch { + return '[configured]'; + } +} + +function resolvePathEnvKey(envMap) { + return Object.keys(envMap).find((key) => key.toLowerCase() === 'path') || 'PATH'; +} + +function prependEnvPath(envMap, nextEntry) { + const pathKey = resolvePathEnvKey(envMap); + const currentValue = envMap[pathKey] || ''; + const normalizedEntry = path.resolve(nextEntry); + const segments = currentValue + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) + .filter((entry) => { + try { + return path.resolve(entry) !== normalizedEntry; + } catch { + return entry !== nextEntry; + } + }); + + envMap[pathKey] = [nextEntry, ...segments].join(path.delimiter); +} + +const exampleEnv = readEnvFile(envExamplePath); +const localEnv = readEnvFile(envLocalPath); + const mergedEnv = { - ...readEnvFile(envExamplePath), - ...readEnvFile(envLocalPath), + ...exampleEnv, + ...localEnv, ...process.env, }; +const runtimeNodePath = existsSync(bundledNodePath) + ? bundledNodePath + : process.execPath; +const runtimeNpmCliPath = existsSync(bundledNpmCliPath) + ? bundledNpmCliPath + : ''; +const runtimeNodeDir = path.dirname(runtimeNodePath); + mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot; mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081'; mergedEnv.NODE_SERVER_TARGET = mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR); -mergedEnv.SQLITE_PATH = - mergedEnv.SQLITE_PATH || path.join(repoRoot, 'server-node', 'data', 'genarrative.sqlite'); +mergedEnv.DATABASE_URL = + mergedEnv.DATABASE_URL || DEFAULT_DEV_DATABASE_URL; +prependEnvPath(mergedEnv, runtimeNodeDir); +mergedEnv.npm_config_scripts_prepend_node_path = 'true'; + +const exampleDatabaseUrl = `${exampleEnv.DATABASE_URL || ''}`.trim(); +const localDatabaseUrl = `${localEnv.DATABASE_URL || ''}`.trim(); +const processDatabaseUrl = `${process.env.DATABASE_URL || ''}`.trim(); +const hasExplicitDatabaseUrl = + Boolean(processDatabaseUrl) || + (Boolean(localDatabaseUrl) && localDatabaseUrl !== exampleDatabaseUrl); + +if (!hasExplicitDatabaseUrl) { + const databaseProbeTarget = resolveDatabaseProbeTarget(mergedEnv.DATABASE_URL); + if (databaseProbeTarget) { + const isReachable = await checkTcpReachable(databaseProbeTarget); + if (!isReachable) { + console.warn( + `[dev:node] PostgreSQL unavailable at ${databaseProbeTarget.host}:${databaseProbeTarget.port}; falling back to ${DEV_MEMORY_DATABASE_URL} for local dev.`, + ); + console.warn( + '[dev:node] Current session will use in-memory persistence only. Set DATABASE_URL in .env.local to restore PostgreSQL-backed runtime data.', + ); + mergedEnv.DATABASE_URL = DEV_MEMORY_DATABASE_URL; + } + } +} console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`); console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`); console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`); -console.log(`[dev:node] SQLITE_PATH=${mergedEnv.SQLITE_PATH}`); +console.log(`[dev:node] DATABASE_URL=${redactDatabaseUrl(mergedEnv.DATABASE_URL)}`); +console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`); const children = new Set(); let shuttingDown = false; @@ -167,15 +298,27 @@ function registerChild(name, child, siblingProvider) { }); } -const serverProcess = spawn(npmCommand, ['run', 'dev'], { - cwd: serverRoot, - env: mergedEnv, - shell: process.platform === 'win32', - stdio: 'inherit', -}); +const serverProcess = existsSync(serverTsxCliPath) + ? spawn(runtimeNodePath, [serverTsxCliPath, 'watch', 'src/server.ts'], { + cwd: serverRoot, + env: mergedEnv, + stdio: 'inherit', + }) + : runtimeNpmCliPath + ? spawn(runtimeNodePath, [runtimeNpmCliPath, 'run', 'dev'], { + cwd: serverRoot, + env: mergedEnv, + stdio: 'inherit', + }) + : spawn(npmCommand, ['run', 'dev'], { + cwd: serverRoot, + env: mergedEnv, + shell: process.platform === 'win32', + stdio: 'inherit', + }); const viteProcess = spawn( - process.execPath, + runtimeNodePath, [viteCliPath, '--port=3000', '--host=0.0.0.0'], { cwd: repoRoot, diff --git a/scripts/dev-server/README.md b/scripts/dev-server/README.md new file mode 100644 index 00000000..dc13462f --- /dev/null +++ b/scripts/dev-server/README.md @@ -0,0 +1,11 @@ +# 旧 Vite 本地 API 插件 + +`scripts/dev-server/**` 已不再是当前开发入口。 + +当前编辑器与资产接口已经迁移到: + +- `server-node/src/modules/editor/**` +- `server-node/src/modules/assets/**` +- `src/editor/shared/editorApiClient.ts` + +这些文件仅保留为迁移参考,不要在这里继续新增 `/api/*` 编辑器写盘或资产生成接口。 diff --git a/scripts/smoke-same-origin-stack.ts b/scripts/smoke-same-origin-stack.ts new file mode 100644 index 00000000..649430b7 --- /dev/null +++ b/scripts/smoke-same-origin-stack.ts @@ -0,0 +1,414 @@ +import assert from 'node:assert/strict'; +import { spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { httpRequest } from '../server-node/src/testHttp.ts'; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(scriptPath), '..'); +const bundledNodePath = path.join( + repoRoot, + '.tools', + 'node-v22.22.2-win-x64', + process.platform === 'win32' ? 'node.exe' : 'bin/node', +); +const runtimeNodePath = fs.existsSync(bundledNodePath) + ? bundledNodePath + : process.execPath; +const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.js'); +const webBuildPath = path.join(repoRoot, 'dist', 'index.html'); +const proxyPort = 18080; +const nodePort = 18081; +const proxyBaseUrl = `http://127.0.0.1:${proxyPort}`; +const nodeBaseUrl = `http://127.0.0.1:${nodePort}`; + +type ManagedChild = { + name: string; + process: ChildProcess; +}; + +function assertBuildArtifacts() { + if (!fs.existsSync(serverBuildPath)) { + throw new Error( + 'server-node/dist/server.js 不存在,请先运行 npm run server-node:build', + ); + } + + if (!fs.existsSync(webBuildPath)) { + throw new Error('dist/index.html 不存在,请先运行 npm run build'); + } +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForReady( + label: string, + url: string, + validate: (bodyText: string, status: number) => void, + timeoutMs = 20000, +) { + const startedAt = Date.now(); + let lastError: unknown = null; + + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await httpRequest(url); + const bodyText = await response.text(); + validate(bodyText, response.status); + return; + } catch (error) { + lastError = error; + await sleep(250); + } + } + + throw new Error( + `[smoke:proxy] ${label} 未在 ${timeoutMs}ms 内就绪: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + ); +} + +function spawnManagedChild( + name: string, + command: string, + args: string[], + env: NodeJS.ProcessEnv, +): ManagedChild { + const child = spawn(command, args, { + cwd: repoRoot, + env, + stdio: 'inherit', + shell: false, + }); + + child.on('error', (error) => { + console.error(`[smoke:proxy] ${name} 启动失败`, error); + }); + + return { + name, + process: child, + }; +} + +async function stopChild(child: ManagedChild | null) { + if (!child || child.process.exitCode !== null) { + return; + } + + child.process.kill('SIGTERM'); + + await Promise.race([ + new Promise((resolve) => { + child.process.once('exit', () => resolve()); + }), + sleep(2000), + ]); + + if (child.process.exitCode === null) { + child.process.kill('SIGKILL'); + await new Promise((resolve) => { + child.process.once('exit', () => resolve()); + }); + } +} + +function contentTypeFor(filePath: string) { + if (filePath.endsWith('.html')) { + return 'text/html; charset=utf-8'; + } + if (filePath.endsWith('.js')) { + return 'text/javascript; charset=utf-8'; + } + if (filePath.endsWith('.css')) { + return 'text/css; charset=utf-8'; + } + if (filePath.endsWith('.json')) { + return 'application/json; charset=utf-8'; + } + + return 'application/octet-stream'; +} + +function resolveStaticFile(urlPath: string) { + const cleanPath = decodeURIComponent(urlPath.split('?')[0] || '/'); + const normalizedPath = cleanPath === '/' ? '/index.html' : cleanPath; + const trimmedRelativePath = normalizedPath.replace(/^\/+/u, ''); + const candidatePath = path.resolve(repoRoot, 'dist', trimmedRelativePath); + const distRoot = path.resolve(repoRoot, 'dist'); + + if ( + candidatePath.startsWith(distRoot) && + fs.existsSync(candidatePath) && + fs.statSync(candidatePath).isFile() + ) { + return candidatePath; + } + + return webBuildPath; +} + +async function startSameOriginProxy() { + const server = http.createServer((request, response) => { + const requestUrl = request.url || '/'; + + if (requestUrl === '/healthz') { + response.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end('ok'); + return; + } + + if (requestUrl.startsWith('/api/')) { + const upstream = http.request( + { + hostname: '127.0.0.1', + port: nodePort, + path: requestUrl, + method: request.method, + headers: { + ...request.headers, + host: `127.0.0.1:${nodePort}`, + }, + }, + (upstreamResponse) => { + response.writeHead( + upstreamResponse.statusCode ?? 502, + upstreamResponse.headers, + ); + upstreamResponse.pipe(response); + }, + ); + + upstream.on('error', (error) => { + response.writeHead(502, { + 'Content-Type': 'application/json; charset=utf-8', + }); + response.end( + JSON.stringify({ + error: { + message: + error instanceof Error ? error.message : 'proxy upstream failed', + }, + }), + ); + }); + + request.pipe(upstream); + return; + } + + const filePath = resolveStaticFile(requestUrl); + response.writeHead(200, { + 'Content-Type': contentTypeFor(filePath), + }); + fs.createReadStream(filePath).pipe(response); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(proxyPort, '127.0.0.1', () => resolve()); + }); + + return server; +} + +async function stopProxyServer(server: http.Server | null) { + if (!server) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function authEntry(baseUrl: string) { + const requestId = 'proxy-smoke-auth-entry'; + const username = `proxy_${Date.now().toString(36)}`; + const response = await httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Request-Id': requestId, + }, + body: JSON.stringify({ + username, + password: 'proxy-secret-123', + }), + }); + const payload = (await response.json()) as { + token: string; + user: { + id: string; + username: string; + }; + }; + + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-request-id'), requestId); + assert.equal(payload.user.username, username); + assert.ok(payload.token); + + return payload; +} + +async function main() { + assertBuildArtifacts(); + + let serverChild: ManagedChild | null = null; + let proxyServer: http.Server | null = null; + + try { + console.log('[smoke:proxy] starting built node server'); + serverChild = spawnManagedChild( + 'server-node', + runtimeNodePath, + [serverBuildPath], + { + ...process.env, + PROJECT_ROOT: repoRoot, + NODE_ENV: 'test', + NODE_SERVER_ADDR: `:${nodePort}`, + DATABASE_URL: 'pg-mem://genarrative-proxy-smoke', + LOG_LEVEL: 'silent', + JWT_SECRET: 'proxy-smoke-secret', + JWT_ISSUER: 'genarrative-proxy-smoke', + LLM_API_KEY: '', + DASHSCOPE_API_KEY: '', + }, + ); + + await waitForReady( + 'node server', + `${nodeBaseUrl}/healthz`, + (bodyText, status) => { + assert.equal(status, 200); + const payload = JSON.parse(bodyText) as { + ok: boolean; + service: string; + }; + assert.equal(payload.ok, true); + assert.equal(payload.service, 'genarrative-node-server'); + }, + ); + console.log('[smoke:proxy] node server ready'); + + console.log('[smoke:proxy] starting same-origin reverse proxy harness'); + proxyServer = await startSameOriginProxy(); + + await waitForReady( + 'reverse proxy', + `${proxyBaseUrl}/healthz`, + (bodyText, status) => { + assert.equal(status, 200); + assert.equal(bodyText.trim(), 'ok'); + }, + ); + console.log('[smoke:proxy] reverse proxy ready'); + + const homeResponse = await httpRequest(`${proxyBaseUrl}/`); + const homeHtml = await homeResponse.text(); + assert.equal(homeResponse.status, 200); + assert.match(homeHtml, /
<\/div>/u); + console.log('[smoke:proxy] static web entry ok'); + + const entry = await authEntry(proxyBaseUrl); + console.log('[smoke:proxy] proxied auth entry ok'); + + const meResponse = await httpRequest(`${proxyBaseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const mePayload = (await meResponse.json()) as { + user: { + username: string; + }; + }; + assert.equal(meResponse.status, 200); + assert.equal(mePayload.user.username, entry.user.username); + console.log('[smoke:proxy] proxied auth me ok'); + + const saveResponse = await httpRequest( + `${proxyBaseUrl}/api/runtime/save/snapshot`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${entry.token}`, + 'Content-Type': 'application/json', + 'X-Genarrative-Response-Envelope': 'v1', + }, + body: JSON.stringify({ + gameState: { + worldType: 'WUXIA', + chapter: 2, + }, + bottomTab: 'adventure', + currentStory: { + text: 'proxy smoke story', + }, + }), + }, + ); + const savePayload = (await saveResponse.json()) as { + ok: true; + data: { + gameState: { + chapter: number; + }; + }; + meta: { + requestId: string; + operation: string; + }; + }; + assert.equal(saveResponse.status, 200); + assert.equal(savePayload.ok, true); + assert.equal(savePayload.data.gameState.chapter, 2); + assert.equal(savePayload.meta.operation, 'PUT /api/runtime/save/snapshot'); + assert.ok(savePayload.meta.requestId); + console.log('[smoke:proxy] proxied runtime save ok'); + + const getResponse = await httpRequest( + `${proxyBaseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const getPayload = (await getResponse.json()) as { + gameState: { + chapter: number; + }; + bottomTab: string; + }; + assert.equal(getResponse.status, 200); + assert.equal(getPayload.gameState.chapter, 2); + assert.equal(getPayload.bottomTab, 'adventure'); + console.log('[smoke:proxy] proxied runtime snapshot read ok'); + + console.log('[smoke:proxy] all checks passed'); + } finally { + await stopProxyServer(proxyServer); + await stopChild(serverChild); + } +} + +void main().catch((error) => { + console.error('[smoke:proxy] failed'); + console.error(error); + process.exit(1); +}); diff --git a/scripts/smoke-server-node.ts b/scripts/smoke-server-node.ts new file mode 100644 index 00000000..65bbf396 --- /dev/null +++ b/scripts/smoke-server-node.ts @@ -0,0 +1,287 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +import { createApp } from '../server-node/src/app.ts'; +import type { AppConfig } from '../server-node/src/config.ts'; +import { createAppContext } from '../server-node/src/server.ts'; +import { httpRequest, type TestRequestInit } from '../server-node/src/testHttp.ts'; + +function createSmokeConfig(): AppConfig { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-server-node-smoke-'), + ); + + return { + nodeEnv: 'test', + projectRoot: tempRoot, + publicDir: path.join(tempRoot, 'public'), + logsDir: path.join(tempRoot, 'logs'), + dataDir: path.join(tempRoot, 'data'), + rawEnv: {}, + databaseUrl: 'pg-mem://genarrative-smoke', + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test-secret', + jwtExpiresIn: '7d', + jwtIssuer: 'genarrative-server-node-smoke', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + }; +} + +async function withSmokeServer( + run: (options: { baseUrl: string }) => Promise, +) { + const context = await createAppContext(createSmokeConfig()); + const app = createApp(context); + const server = await new Promise((resolve) => { + const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); + }); + + try { + const address = server.address() as AddressInfo; + return await run({ + baseUrl: `http://127.0.0.1:${address.port}`, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await context.db.close(); + } +} + +function withBearer(token: string, init: TestRequestInit = {}) { + return { + ...init, + headers: { + ...(init.headers ?? {}), + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } satisfies TestRequestInit; +} + +async function authEntry(baseUrl: string) { + const username = `smoke_${Date.now().toString(36)}`; + const response = await httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password: 'smoke-secret-123', + }), + }); + const payload = (await response.json()) as { + token: string; + user: { + id: string; + username: string; + }; + }; + + assert.equal(response.status, 200); + assert.ok(payload.token); + assert.equal(payload.user.username, username); + + return payload; +} + +async function main() { + console.log('[server-node:smoke] booting ephemeral Express server'); + + await withSmokeServer(async ({ baseUrl }) => { + const healthzRequestId = 'smoke-healthz-request'; + const healthzResponse = await httpRequest(`${baseUrl}/healthz`, { + headers: { + 'X-Request-Id': healthzRequestId, + }, + }); + const healthzPayload = (await healthzResponse.json()) as { + ok: boolean; + service: string; + }; + + assert.equal(healthzResponse.status, 200); + assert.equal(healthzResponse.headers.get('x-request-id'), healthzRequestId); + assert.equal(healthzPayload.ok, true); + assert.equal(healthzPayload.service, 'genarrative-node-server'); + console.log('[server-node:smoke] healthz ok'); + + const entry = await authEntry(baseUrl); + console.log('[server-node:smoke] auth entry ok'); + + const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const mePayload = (await meResponse.json()) as { + user: { + id: string; + username: string; + }; + }; + + assert.equal(meResponse.status, 200); + assert.equal(mePayload.user.username, entry.user.username); + console.log('[server-node:smoke] auth me ok'); + + const putSnapshotResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(entry.token, { + method: 'PUT', + body: JSON.stringify({ + gameState: { + worldType: 'WUXIA', + chapter: 1, + }, + bottomTab: 'adventure', + currentStory: { + text: 'smoke story', + }, + }), + }), + ); + const putSnapshotPayload = (await putSnapshotResponse.json()) as { + version: number; + bottomTab: string; + gameState: { + chapter: number; + }; + }; + + assert.equal(putSnapshotResponse.status, 200); + assert.equal(putSnapshotPayload.version, 2); + assert.equal(putSnapshotPayload.bottomTab, 'adventure'); + assert.equal(putSnapshotPayload.gameState.chapter, 1); + + const getSnapshotResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const getSnapshotPayload = (await getSnapshotResponse.json()) as { + bottomTab: string; + gameState: { + chapter: number; + }; + }; + + assert.equal(getSnapshotResponse.status, 200); + assert.equal(getSnapshotPayload.bottomTab, 'adventure'); + assert.equal(getSnapshotPayload.gameState.chapter, 1); + console.log('[server-node:smoke] runtime snapshot roundtrip ok'); + + const putSettingsResponse = await httpRequest( + `${baseUrl}/api/runtime/settings`, + withBearer(entry.token, { + method: 'PUT', + body: JSON.stringify({ + musicVolume: 0.3, + }), + }), + ); + const putSettingsPayload = (await putSettingsResponse.json()) as { + musicVolume: number; + }; + + assert.equal(putSettingsResponse.status, 200); + assert.equal(putSettingsPayload.musicVolume, 0.3); + + const getSettingsResponse = await httpRequest(`${baseUrl}/api/runtime/settings`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const getSettingsPayload = (await getSettingsResponse.json()) as { + musicVolume: number; + }; + + assert.equal(getSettingsResponse.status, 200); + assert.equal(getSettingsPayload.musicVolume, 0.3); + console.log('[server-node:smoke] runtime settings roundtrip ok'); + + const deleteSnapshotResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(entry.token, { + method: 'DELETE', + }), + ); + const deleteSnapshotPayload = (await deleteSnapshotResponse.json()) as { + ok: boolean; + }; + + assert.equal(deleteSnapshotResponse.status, 200); + assert.equal(deleteSnapshotPayload.ok, true); + + const emptySnapshotResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const emptySnapshotPayload = await emptySnapshotResponse.json(); + + assert.equal(emptySnapshotResponse.status, 200); + assert.equal(emptySnapshotPayload, null); + console.log('[server-node:smoke] runtime snapshot delete ok'); + + const logoutResponse = await httpRequest( + `${baseUrl}/api/auth/logout`, + withBearer(entry.token, { + method: 'POST', + }), + ); + const logoutPayload = (await logoutResponse.json()) as { + ok: boolean; + }; + + assert.equal(logoutResponse.status, 200); + assert.equal(logoutPayload.ok, true); + + const expiredTokenResponse = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + + assert.equal(expiredTokenResponse.status, 401); + console.log('[server-node:smoke] logout invalidation ok'); + }); + + console.log('[server-node:smoke] all checks passed'); +} + +void main().catch((error) => { + console.error('[server-node:smoke] failed'); + console.error(error); + process.exit(1); +}); diff --git a/server-node/package-lock.json b/server-node/package-lock.json index f2db6739..da59b4bf 100644 --- a/server-node/package-lock.json +++ b/server-node/package-lock.json @@ -8,26 +8,227 @@ "name": "genarrative-server-node", "version": "0.1.0", "dependencies": { + "@alicloud/dypnsapi20170525": "^2.0.0", + "@alicloud/openapi-client": "^0.4.15", + "@alicloud/tea-util": "^1.4.11", "@node-rs/argon2": "^2.0.2", - "better-sqlite3": "^12.4.1", "cors": "^2.8.5", "express": "^4.21.2", "jose": "^6.1.0", + "pg": "^8.16.3", "pino": "^9.9.5", "pino-http": "^10.5.0", "pino-roll": "^3.1.0", "zod": "^4.1.8" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.18", "@types/express": "^5.0.3", "@types/node": "^24.6.0", + "@types/pg": "^8.20.0", "esbuild": "^0.28.0", + "pg-mem": "^3.0.14", "tsx": "^4.21.0", "typescript": "^5.9.3" } }, + "node_modules/@alicloud/credentials": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.4.tgz", + "integrity": "sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q==", + "dependencies": { + "@alicloud/tea-typescript": "^1.8.0", + "httpx": "^2.3.3", + "ini": "^1.3.5", + "kitx": "^2.0.0" + } + }, + "node_modules/@alicloud/darabonba-array": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz", + "integrity": "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==", + "dependencies": { + "@alicloud/tea-typescript": "^1.7.1" + } + }, + "node_modules/@alicloud/darabonba-encode-util": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz", + "integrity": "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==", + "dependencies": { + "moment": "^2.29.1" + } + }, + "node_modules/@alicloud/darabonba-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz", + "integrity": "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==", + "dependencies": { + "@alicloud/tea-typescript": "^1.7.1" + } + }, + "node_modules/@alicloud/darabonba-signature-util": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz", + "integrity": "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==", + "dependencies": { + "@alicloud/darabonba-encode-util": "^0.0.1" + } + }, + "node_modules/@alicloud/darabonba-signature-util/node_modules/@alicloud/darabonba-encode-util": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz", + "integrity": "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==", + "dependencies": { + "@alicloud/tea-typescript": "^1.7.1", + "moment": "^2.29.1" + } + }, + "node_modules/@alicloud/darabonba-string": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz", + "integrity": "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==", + "dependencies": { + "@alicloud/tea-typescript": "^1.5.1" + } + }, + "node_modules/@alicloud/dypnsapi20170525": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@alicloud/dypnsapi20170525/-/dypnsapi20170525-2.0.0.tgz", + "integrity": "sha512-eVh1dJ2HA82bBHt+YZFIBzPEYW80FK+TSpcxSR9o0W+FgfTqBaj6eeIHnN7NFhyDAD/3+HtZ146Pmvr51JEEAg==", + "dependencies": { + "@alicloud/openapi-core": "^1.0.0", + "@darabonba/typescript": "^1.0.0" + } + }, + "node_modules/@alicloud/endpoint-util": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz", + "integrity": "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==", + "dependencies": { + "@alicloud/tea-typescript": "^1.5.1", + "kitx": "^2.0.0" + } + }, + "node_modules/@alicloud/gateway-pop": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz", + "integrity": "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==", + "dependencies": { + "@alicloud/credentials": "^2", + "@alicloud/darabonba-array": "^0.1.0", + "@alicloud/darabonba-encode-util": "^0.0.2", + "@alicloud/darabonba-map": "^0.0.1", + "@alicloud/darabonba-signature-util": "^0.0.4", + "@alicloud/darabonba-string": "^1.0.2", + "@alicloud/endpoint-util": "^0.0.1", + "@alicloud/gateway-spi": "^0.0.8", + "@alicloud/openapi-util": "^0.3.2", + "@alicloud/tea-typescript": "^1.7.1", + "@alicloud/tea-util": "^1.4.8" + } + }, + "node_modules/@alicloud/gateway-spi": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz", + "integrity": "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==", + "dependencies": { + "@alicloud/credentials": "^2", + "@alicloud/tea-typescript": "^1.7.1" + } + }, + "node_modules/@alicloud/openapi-client": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/@alicloud/openapi-client/-/openapi-client-0.4.15.tgz", + "integrity": "sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==", + "dependencies": { + "@alicloud/credentials": "^2.4.2", + "@alicloud/gateway-spi": "^0.0.8", + "@alicloud/openapi-util": "^0.3.2", + "@alicloud/tea-typescript": "^1.7.1", + "@alicloud/tea-util": "1.4.9", + "@alicloud/tea-xml": "0.0.3" + } + }, + "node_modules/@alicloud/openapi-client/node_modules/@alicloud/tea-util": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.9.tgz", + "integrity": "sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==", + "dependencies": { + "@alicloud/tea-typescript": "^1.5.1", + "kitx": "^2.0.0" + } + }, + "node_modules/@alicloud/openapi-core": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.7.tgz", + "integrity": "sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ==", + "hasInstallScript": true, + "dependencies": { + "@alicloud/credentials": "^2.4.2", + "@alicloud/gateway-pop": "0.0.6", + "@alicloud/gateway-spi": "^0.0.8", + "@darabonba/typescript": "^1.0.2" + } + }, + "node_modules/@alicloud/openapi-util": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.3.tgz", + "integrity": "sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==", + "dependencies": { + "@alicloud/tea-typescript": "^1.7.1", + "@alicloud/tea-util": "^1.3.0", + "kitx": "^2.1.0", + "sm3": "^1.0.3" + } + }, + "node_modules/@alicloud/tea-typescript": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz", + "integrity": "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==", + "dependencies": { + "@types/node": "^12.0.2", + "httpx": "^2.2.6" + } + }, + "node_modules/@alicloud/tea-typescript/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" + }, + "node_modules/@alicloud/tea-util": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.11.tgz", + "integrity": "sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==", + "dependencies": { + "@alicloud/tea-typescript": "^1.5.1", + "@darabonba/typescript": "^1.0.0", + "kitx": "^2.0.0" + } + }, + "node_modules/@alicloud/tea-xml": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@alicloud/tea-xml/-/tea-xml-0.0.3.tgz", + "integrity": "sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==", + "dependencies": { + "@alicloud/tea-typescript": "^1", + "@types/xml2js": "^0.4.5", + "xml2js": "^0.6.0" + } + }, + "node_modules/@darabonba/typescript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.4.tgz", + "integrity": "sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==", + "dependencies": { + "@alicloud/tea-typescript": "^1.5.1", + "httpx": "^2.3.2", + "lodash": "^4.17.21", + "moment": "^2.30.1", + "moment-timezone": "^0.5.45", + "xml2js": "^0.6.2" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -778,16 +979,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -855,12 +1046,22 @@ "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -896,6 +1097,14 @@ "@types/node": "*" } }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -924,60 +1133,6 @@ "node": ">=8.0.0" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -1002,30 +1157,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1035,6 +1166,24 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1064,11 +1213,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true }, "node_modules/content-disposition": { "version": "0.5.4", @@ -1142,28 +1291,21 @@ "ms": "2.0.0" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "dependencies": { - "mimic-response": "^3.1.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/depd": { @@ -1185,14 +1327,11 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "dev": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -1223,15 +1362,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1319,15 +1449,6 @@ "node": ">= 0.6" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -1374,12 +1495,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1416,12 +1531,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1446,6 +1555,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1505,12 +1620,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1523,6 +1632,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1567,6 +1688,49 @@ "url": "https://opencollective.com/express" } }, + "node_modules/httpx": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz", + "integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==", + "dependencies": { + "@types/node": "^20", + "debug": "^4.1.1" + } + }, + "node_modules/httpx/node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/httpx/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/httpx/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/httpx/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1579,25 +1743,11 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" + "node_modules/immutable": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", + "dev": true }, "node_modules/inherits": { "version": "2.0.4", @@ -1608,8 +1758,7 @@ "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "node_modules/ipaddr.js": { "version": "1.9.1", @@ -1620,6 +1769,12 @@ "node": ">= 0.10" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, "node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", @@ -1629,6 +1784,72 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/kitx": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/kitx/-/kitx-2.2.0.tgz", + "integrity": "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==", + "dependencies": { + "@types/node": "^22.5.4" + } + }, + "node_modules/kitx/node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/kitx/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1698,32 +1919,30 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "engines": { - "node": ">=10" + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "dependencies": { + "moment": "^2.29.4" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { + "node_modules/moo": { "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "dev": true }, "node_modules/ms": { "version": "2.0.0", @@ -1731,11 +1950,27 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dev": true, + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } }, "node_modules/negotiator": { "version": "0.6.3", @@ -1746,18 +1981,6 @@ "node": ">= 0.6" } }, - "node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1767,6 +1990,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1779,6 +2011,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1800,15 +2041,6 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1824,6 +2056,155 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-mem": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/pg-mem/-/pg-mem-3.0.14.tgz", + "integrity": "sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==", + "dev": true, + "dependencies": { + "functional-red-black-tree": "^1.0.1", + "immutable": "^4.3.4", + "json-stable-stringify": "^1.0.1", + "lru-cache": "^6.0.0", + "moment": "^2.27.0", + "object-hash": "^2.0.3", + "pgsql-ast-parser": "^12.0.2" + }, + "peerDependencies": { + "@mikro-orm/core": ">=4.5.3", + "@mikro-orm/postgresql": ">=4.5.3", + "knex": ">=0.20", + "kysely": ">=0.26", + "pg-promise": ">=10.8.7", + "pg-server": "^0.1.5", + "postgres": "^3.4.4", + "slonik": ">=23.0.1", + "typeorm": ">=0.2.29" + }, + "peerDependenciesMeta": { + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/postgresql": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mikro-orm": { + "optional": true + }, + "pg-promise": { + "optional": true + }, + "pg-server": { + "optional": true + }, + "postgres": { + "optional": true + }, + "slonik": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pgsql-ast-parser": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/pgsql-ast-parser/-/pgsql-ast-parser-12.0.2.tgz", + "integrity": "sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==", + "dev": true, + "dependencies": { + "moo": "^0.5.1", + "nearley": "^2.19.5" + } + }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -1883,31 +2264,39 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" + "xtend": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, "node_modules/process-warning": { @@ -1939,16 +2328,6 @@ "node": ">= 0.10" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -1970,6 +2349,25 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "dev": true + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1994,35 +2392,6 @@ "node": ">= 0.8" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -2042,6 +2411,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "engines": { + "node": ">=0.12" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2077,16 +2455,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "engines": { - "node": ">=10" + "node": ">=11.0.0" } }, "node_modules/send": { @@ -2134,6 +2508,23 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2212,50 +2603,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } + "node_modules/sm3": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sm3/-/sm3-1.0.3.tgz", + "integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==" }, "node_modules/sonic-boom": { "version": "4.2.1", @@ -2284,52 +2635,6 @@ "node": ">= 0.8" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -2859,18 +3164,6 @@ "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2902,7 +3195,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -2914,12 +3206,6 @@ "node": ">= 0.8" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -2938,11 +3224,39 @@ "node": ">= 0.8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/zod": { "version": "4.3.6", diff --git a/server-node/package.json b/server-node/package.json index 5f524348..363b0187 100644 --- a/server-node/package.json +++ b/server-node/package.json @@ -7,25 +7,30 @@ "dev": "tsx watch src/server.ts", "build": "node build.mjs", "start": "node dist/server.js", - "test": "node --test --import tsx src/**/*.test.ts" + "test": "node test.mjs", + "db:migrate": "tsx src/migrate.ts" }, "dependencies": { + "@alicloud/dypnsapi20170525": "^2.0.0", + "@alicloud/openapi-client": "^0.4.15", + "@alicloud/tea-util": "^1.4.11", "@node-rs/argon2": "^2.0.2", - "better-sqlite3": "^12.4.1", "cors": "^2.8.5", "express": "^4.21.2", "jose": "^6.1.0", + "pg": "^8.16.3", "pino": "^9.9.5", "pino-http": "^10.5.0", "pino-roll": "^3.1.0", "zod": "^4.1.8" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.18", "@types/express": "^5.0.3", "@types/node": "^24.6.0", + "@types/pg": "^8.20.0", "esbuild": "^0.28.0", + "pg-mem": "^3.0.14", "tsx": "^4.21.0", "typescript": "^5.9.3" } diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index a3e8e5d3..e40b796c 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -5,9 +5,14 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { createApp } from './app.js'; -import type { AppConfig } from './config.js'; -import { createAppContext } from './server.js'; +import express from 'express'; + +import { createApp } from './app.ts'; +import type { AppConfig } from './config.ts'; +import { prepareEventStreamResponse } from './http.ts'; +import { requestIdMiddleware } from './middleware/requestId.ts'; +import { createAppContext } from './server.ts'; +import { httpRequest, type TestRequestInit } from './testHttp.ts'; function createTestConfig(testName: string): AppConfig { const tempRoot = fs.mkdtempSync( @@ -20,9 +25,12 @@ function createTestConfig(testName: string): AppConfig { publicDir: path.join(tempRoot, 'public'), logsDir: path.join(tempRoot, 'logs'), dataDir: path.join(tempRoot, 'data'), - sqlitePath: path.join(tempRoot, 'data', 'test.sqlite'), + rawEnv: {}, + databaseUrl: `pg-mem://genarrative-${testName}`, serverAddr: ':0', logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, jwtSecret: 'test-secret', jwtExpiresIn: '7d', jwtIssuer: 'genarrative-server-node-test', @@ -37,6 +45,59 @@ function createTestConfig(testName: string): AppConfig { imageModel: 'test-image-model', requestTimeoutMs: 1000, }, + smsAuth: { + enabled: true, + provider: 'mock', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: '', + accessKeySecret: '', + signName: 'Test Sign', + templateCode: '100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: true, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', + accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', + userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', + callbackPath: '/api/auth/wechat/callback', + defaultRedirectPath: '/', + mockUserId: 'mock_wechat_user', + mockUnionId: 'mock_wechat_union', + mockDisplayName: '微信旅人', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'genarrative_refresh_session', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/api/auth', + }, }; } @@ -44,7 +105,7 @@ async function withTestServer( testName: string, run: (options: { baseUrl: string }) => Promise, ) { - const context = createAppContext(createTestConfig(testName)); + const context = await createAppContext(createTestConfig(testName)); const app = createApp(context); const server = await new Promise((resolve) => { const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); @@ -65,12 +126,12 @@ async function withTestServer( resolve(); }); }); - context.db.close(); + await context.db.close(); } } async function authEntry(baseUrl: string, username: string, password: string) { - const response = await fetch(`${baseUrl}/api/auth/entry`, { + const response = await httpRequest(`${baseUrl}/api/auth/entry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -78,20 +139,140 @@ async function authEntry(baseUrl: string, username: string, password: string) { password, }), }); - const payload = await response.json() as { + const payload = (await response.json()) as { token: string; user: { id: string; username: string; }; }; + const refreshCookie = response.headers.get('set-cookie'); assert.equal(response.status, 200); assert.ok(payload.token); + return { + ...payload, + refreshCookie, + }; +} + +async function sendPhoneCode( + baseUrl: string, + phone: string, + scene: 'login' | 'bind_phone' | 'change_phone' = 'login', +) { + const response = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone, + scene, + }), + }); + const payload = (await response.json()) as { + ok: true; + cooldownSeconds: number; + expiresInSeconds: number; + }; + + assert.equal(response.status, 200); + assert.equal(payload.ok, true); return payload; } -function withBearer(token: string, init: RequestInit = {}) { +async function phoneLogin(baseUrl: string, phone: string, code = '123456') { + const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone, + code, + }), + }); + const payload = (await response.json()) as { + token: string; + user: { + id: string; + username: string; + displayName: string; + phoneNumberMasked: string | null; + loginMethod: 'phone' | 'password' | 'wechat'; + bindingStatus: 'active' | 'pending_bind_phone'; + wechatBound: boolean; + }; + }; + const refreshCookie = response.headers.get('set-cookie'); + + assert.equal(response.status, 200); + assert.ok(payload.token); + return { + ...payload, + refreshCookie, + }; +} + +function parseRedirectHash(location: string) { + const url = new URL(location, 'http://127.0.0.1'); + return new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash); +} + +async function startWechatMockFlow( + baseUrl: string, + redirectPath = '/', +) { + const startResponse = await httpRequest( + `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`, + ); + const startPayload = (await startResponse.json()) as { + authorizationUrl: string; + }; + + assert.equal(startResponse.status, 200); + assert.ok(startPayload.authorizationUrl); + + const callbackResponse = await httpRequest(startPayload.authorizationUrl); + assert.equal(callbackResponse.status, 302); + const location = callbackResponse.headers.get('location') || ''; + assert.ok(location); + const hash = parseRedirectHash(location); + const token = hash.get('auth_token') || ''; + + assert.ok(token); + + return { + location, + hash, + token, + }; +} + +async function withListeningApp( + app: express.Express, + run: (options: { baseUrl: string }) => Promise, +) { + const server = await new Promise((resolve) => { + const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); + }); + + try { + const address = server.address() as AddressInfo; + return await run({ + baseUrl: `http://127.0.0.1:${address.port}`, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +function withBearer(token: string, init: TestRequestInit = {}) { return { ...init, headers: { @@ -99,19 +280,51 @@ function withBearer(token: string, init: RequestInit = {}) { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - } satisfies RequestInit; + } satisfies TestRequestInit; } +test('legacy json responses remain compatible and include response metadata headers', async () => { + await withTestServer('legacy-http', async ({ baseUrl }) => { + const requestId = 'req-legacy-http'; + const response = await httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Request-Id': requestId, + }, + body: JSON.stringify({ + username: 'header_user', + password: 'secret123', + }), + }); + const payload = await response.json<{ + token: string; + user: { + username: string; + }; + }>(); + + assert.equal(response.status, 200); + assert.ok(payload.token); + assert.equal(payload.user.username, 'header_user'); + assert.equal(response.headers.get('x-request-id'), requestId); + assert.equal(response.headers.get('x-api-version'), '2026-04-08'); + assert.equal(response.headers.get('x-route-version'), '2026-04-08'); + assert.ok(Number(response.headers.get('x-response-time-ms')) >= 0); + }); +}); + test('auth entry auto-registers, me works, logout invalidates old token', async () => { await withTestServer('auth', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'hero_test', 'secret123'); + assert.ok(entry.refreshCookie); - const meResponse = await fetch(`${baseUrl}/api/auth/me`, { + const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { headers: { Authorization: `Bearer ${entry.token}`, }, }); - const mePayload = await meResponse.json() as { + const mePayload = (await meResponse.json()) as { user: { username: string; }; @@ -120,13 +333,13 @@ test('auth entry auto-registers, me works, logout invalidates old token', async assert.equal(meResponse.status, 200); assert.equal(mePayload.user.username, 'hero_test'); - const logoutResponse = await fetch( + const logoutResponse = await httpRequest( `${baseUrl}/api/auth/logout`, withBearer(entry.token, { method: 'POST' }), ); assert.equal(logoutResponse.status, 200); - const expiredResponse = await fetch(`${baseUrl}/api/auth/me`, { + const expiredResponse = await httpRequest(`${baseUrl}/api/auth/me`, { headers: { Authorization: `Bearer ${entry.token}`, }, @@ -135,8 +348,454 @@ test('auth entry auto-registers, me works, logout invalidates old token', async }); }); -test('issued jwt remains valid without exp until logout invalidates token version', async () => { - await withTestServer('permanent-jwt', async ({ baseUrl }) => { +test('phone login sends code, creates a user and returns masked profile info', async () => { + await withTestServer('phone-login', async ({ baseUrl }) => { + const sendResult = await sendPhoneCode(baseUrl, '13800138000'); + assert.equal(sendResult.cooldownSeconds, 60); + assert.equal(sendResult.expiresInSeconds, 300); + + const entry = await phoneLogin(baseUrl, '13800138000'); + assert.equal(entry.user.username, '138****8000'); + assert.equal(entry.user.displayName, '138****8000'); + assert.equal(entry.user.phoneNumberMasked, '138****8000'); + assert.equal(entry.user.loginMethod, 'phone'); + + const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const mePayload = (await meResponse.json()) as { + user: { + username: string; + phoneNumberMasked: string | null; + loginMethod: string; + }; + }; + + assert.equal(meResponse.status, 200); + assert.equal(mePayload.user.username, '138****8000'); + assert.equal(mePayload.user.phoneNumberMasked, '138****8000'); + assert.equal(mePayload.user.loginMethod, 'phone'); + }); +}); + +test('phone send-code accepts change_phone scene', async () => { + await withTestServer('phone-change-code', async ({ baseUrl }) => { + const sendResult = await sendPhoneCode( + baseUrl, + '13800138001', + 'change_phone', + ); + + assert.equal(sendResult.cooldownSeconds, 60); + assert.equal(sendResult.expiresInSeconds, 300); + }); +}); + +test('phone login reuses the same account for repeated verification', async () => { + await withTestServer('phone-login-reuse', async ({ baseUrl }) => { + await sendPhoneCode(baseUrl, '13900139000'); + const firstEntry = await phoneLogin(baseUrl, '13900139000'); + + await sendPhoneCode(baseUrl, '13900139000'); + const secondEntry = await phoneLogin(baseUrl, '13900139000'); + + assert.equal(firstEntry.user.id, secondEntry.user.id); + }); +}); + +test('phone login rejects incorrect verification codes', async () => { + await withTestServer('phone-login-invalid-code', async ({ baseUrl }) => { + await sendPhoneCode(baseUrl, '13700137000'); + + const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13700137000', + code: '000000', + }), + }); + const payload = (await response.json()) as { + error: { + code: string; + message: string; + }; + }; + + assert.equal(response.status, 401); + assert.equal(payload.error.code, 'UNAUTHORIZED'); + assert.equal(payload.error.message, '验证码错误或已失效'); + }); +}); + +test('captcha challenge is required after repeated verification failures', async () => { + await withTestServer('phone-login-captcha', async ({ baseUrl }) => { + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13600136000', + code: '000000', + }), + }); + assert.equal(response.status, 401); + } + + const sendCodeResponse = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13600136000', + scene: 'login', + }), + }); + const sendCodePayload = (await sendCodeResponse.json()) as { + error: { + code: string; + message: string; + details?: { + captchaChallenge?: { + challengeId: string; + imageDataUrl: string; + }; + }; + }; + }; + + assert.equal(sendCodeResponse.status, 403); + assert.equal(sendCodePayload.error.code, 'CAPTCHA_REQUIRED'); + assert.ok(sendCodePayload.error.details?.captchaChallenge?.challengeId); + assert.match( + sendCodePayload.error.details?.captchaChallenge?.imageDataUrl ?? '', + /^data:image\/svg\+xml;base64,/u, + ); + }); +}); + +test('phone number enters temporary protection after repeated failed verifications', async () => { + await withTestServer('phone-risk-block', async ({ baseUrl }) => { + await sendPhoneCode(baseUrl, '13800138000'); + const entry = await phoneLogin(baseUrl, '13800138000'); + + for (let attempt = 0; attempt < 6; attempt += 1) { + const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13800138000', + code: '000000', + }), + }); + assert.equal(response.status, 401); + } + + const blockedResponse = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13800138000', + scene: 'login', + }), + }); + const blockedPayload = (await blockedResponse.json()) as { + error: { + code: string; + message: string; + }; + }; + + assert.equal(blockedResponse.status, 429); + assert.equal(blockedPayload.error.code, 'TOO_MANY_REQUESTS'); + + const auditResponse = await httpRequest(`${baseUrl}/api/auth/audit-logs`, { + headers: { + Authorization: `Bearer ${entry.token}`, + Cookie: entry.refreshCookie || '', + }, + }); + const auditPayload = (await auditResponse.json()) as { + logs: Array<{ + eventType: string; + }>; + }; + + assert.ok( + auditPayload.logs.some((log) => log.eventType === 'risk_block_phone'), + ); + }); +}); + +test('ip enters temporary protection after repeated failed verifications across phones', async () => { + await withTestServer('ip-risk-block', async ({ baseUrl }) => { + for (let attempt = 0; attempt < 10; attempt += 1) { + const phone = `13900139${String(attempt).padStart(3, '0')}`; + const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone, + code: '000000', + }), + }); + assert.equal(response.status, 401); + } + + const blockedResponse = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13700137000', + scene: 'login', + }), + }); + const blockedPayload = (await blockedResponse.json()) as { + error: { + code: string; + }; + }; + + assert.equal(blockedResponse.status, 429); + assert.equal(blockedPayload.error.code, 'TOO_MANY_REQUESTS'); + }); +}); + +test('risk block endpoint returns active phone protection for the signed-in account', async () => { + await withTestServer('risk-blocks-endpoint', async ({ baseUrl }) => { + await sendPhoneCode(baseUrl, '13800138000'); + const entry = await phoneLogin(baseUrl, '13800138000'); + + for (let attempt = 0; attempt < 6; attempt += 1) { + const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13800138000', + code: '000000', + }), + }); + assert.equal(response.status, 401); + } + + const blocksResponse = await httpRequest(`${baseUrl}/api/auth/risk-blocks`, { + headers: { + Authorization: `Bearer ${entry.token}`, + Cookie: entry.refreshCookie || '', + }, + }); + const blocksPayload = (await blocksResponse.json()) as { + blocks: Array<{ + scopeType: string; + remainingSeconds: number; + }>; + }; + + assert.equal(blocksResponse.status, 200); + assert.ok(blocksPayload.blocks.some((block) => block.scopeType === 'phone')); + assert.ok((blocksPayload.blocks[0]?.remainingSeconds ?? 0) > 0); + }); +}); + +test('risk block lift endpoint clears current phone protection', async () => { + await withTestServer('risk-block-lift', async ({ baseUrl }) => { + await sendPhoneCode(baseUrl, '13800138000'); + const entry = await phoneLogin(baseUrl, '13800138000'); + + for (let attempt = 0; attempt < 6; attempt += 1) { + const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13800138000', + code: '000000', + }), + }); + assert.equal(response.status, 401); + } + + const liftResponse = await httpRequest( + `${baseUrl}/api/auth/risk-blocks/phone/lift`, + withBearer(entry.token, { + method: 'POST', + headers: { + Cookie: entry.refreshCookie || '', + }, + }), + ); + const liftPayload = (await liftResponse.json()) as { + ok: true; + }; + + assert.equal(liftResponse.status, 200); + assert.equal(liftPayload.ok, true); + + const blocksResponse = await httpRequest(`${baseUrl}/api/auth/risk-blocks`, { + headers: { + Authorization: `Bearer ${entry.token}`, + Cookie: entry.refreshCookie || '', + }, + }); + const blocksPayload = (await blocksResponse.json()) as { + blocks: Array<{ + scopeType: string; + }>; + }; + + assert.equal(blocksResponse.status, 200); + assert.equal( + blocksPayload.blocks.some((block) => block.scopeType === 'phone'), + false, + ); + }); +}); + +test('wechat mock login redirects back with pending bind status and token', async () => { + await withTestServer('wechat-mock-login', async ({ baseUrl }) => { + const result = await startWechatMockFlow(baseUrl, '/'); + + assert.equal(result.hash.get('auth_provider'), 'wechat'); + assert.equal(result.hash.get('auth_binding_status'), 'pending_bind_phone'); + + const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${result.token}`, + }, + }); + const mePayload = (await meResponse.json()) as { + user: { + loginMethod: 'wechat'; + bindingStatus: 'pending_bind_phone'; + wechatBound: boolean; + phoneNumberMasked: string | null; + }; + availableLoginMethods: string[]; + }; + + assert.equal(meResponse.status, 200); + assert.equal(mePayload.user.loginMethod, 'wechat'); + assert.equal(mePayload.user.bindingStatus, 'pending_bind_phone'); + assert.equal(mePayload.user.wechatBound, true); + assert.equal(mePayload.user.phoneNumberMasked, null); + assert.deepEqual(mePayload.availableLoginMethods, ['phone', 'wechat']); + }); +}); + +test('wechat pending user can bind a new phone number and become active', async () => { + await withTestServer('wechat-bind-phone', async ({ baseUrl }) => { + const wechatSession = await startWechatMockFlow(baseUrl, '/'); + await sendPhoneCode(baseUrl, '13600136000'); + + const bindResponse = await httpRequest( + `${baseUrl}/api/auth/wechat/bind-phone`, + withBearer(wechatSession.token, { + method: 'POST', + body: JSON.stringify({ + phone: '13600136000', + code: '123456', + }), + }), + ); + const bindPayload = (await bindResponse.json()) as { + token: string; + user: { + loginMethod: 'wechat'; + bindingStatus: 'active'; + phoneNumberMasked: string; + wechatBound: boolean; + }; + }; + + assert.equal(bindResponse.status, 200); + assert.ok(bindPayload.token); + assert.equal(bindPayload.user.loginMethod, 'wechat'); + assert.equal(bindPayload.user.bindingStatus, 'active'); + assert.equal(bindPayload.user.phoneNumberMasked, '136****6000'); + assert.equal(bindPayload.user.wechatBound, true); + }); +}); + +test('wechat binding to an existing phone account merges into that account', async () => { + await withTestServer('wechat-bind-existing-phone', async ({ baseUrl }) => { + await sendPhoneCode(baseUrl, '13500135000'); + const phoneAccount = await phoneLogin(baseUrl, '13500135000'); + + const wechatSession = await startWechatMockFlow(baseUrl, '/'); + await sendPhoneCode(baseUrl, '13500135000'); + + const bindResponse = await httpRequest( + `${baseUrl}/api/auth/wechat/bind-phone`, + withBearer(wechatSession.token, { + method: 'POST', + body: JSON.stringify({ + phone: '13500135000', + code: '123456', + }), + }), + ); + const bindPayload = (await bindResponse.json()) as { + token: string; + user: { + id: string; + loginMethod: 'phone' | 'wechat'; + bindingStatus: 'active'; + phoneNumberMasked: string; + wechatBound: boolean; + }; + }; + + assert.equal(bindResponse.status, 200); + assert.equal(bindPayload.user.id, phoneAccount.user.id); + assert.equal(bindPayload.user.bindingStatus, 'active'); + assert.equal(bindPayload.user.phoneNumberMasked, '135****5000'); + assert.equal(bindPayload.user.wechatBound, true); + }); +}); + +test('response envelope can be explicitly enabled without breaking existing routes', async () => { + await withTestServer('response-envelope', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'hero_envelope', 'secret123'); + + const response = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + 'X-Genarrative-Response-Envelope': 'v1', + }, + }); + const payload = await response.json<{ + ok: true; + data: { + user: { + username: string; + }; + }; + error: null; + meta: { + requestId: string; + apiVersion: string; + routeVersion: string; + operation: string; + latencyMs: number; + timestamp: string; + }; + }>(); + + assert.equal(response.status, 200); + assert.equal(payload.ok, true); + assert.equal(payload.data.user.username, 'hero_envelope'); + assert.equal(payload.error, null); + assert.equal(payload.meta.apiVersion, '2026-04-08'); + assert.equal(payload.meta.routeVersion, '2026-04-08'); + assert.equal(payload.meta.operation, 'auth.me'); + assert.ok(payload.meta.requestId); + assert.ok(payload.meta.latencyMs >= 0); + assert.ok(payload.meta.timestamp); + }); +}); + +test('issued jwt now carries exp and refresh route can mint a new access token', async () => { + await withTestServer('expiring-jwt', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'hero_eternal', 'secret123'); const tokenParts = entry.token.split('.'); assert.equal(tokenParts.length, 3); @@ -151,22 +810,42 @@ test('issued jwt remains valid without exp until logout invalidates token versio assert.equal(typeof payloadJson.sub, 'string'); assert.equal(typeof payloadJson.ver, 'number'); - assert.equal('exp' in payloadJson, false); + assert.equal(typeof payloadJson.exp, 'number'); + assert.ok((payloadJson.exp ?? 0) > 0); - const meResponse = await fetch(`${baseUrl}/api/auth/me`, { + const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { headers: { Authorization: `Bearer ${entry.token}`, }, }); assert.equal(meResponse.status, 200); - const logoutResponse = await fetch( + const refreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, { + method: 'POST', + headers: { + Cookie: entry.refreshCookie || '', + }, + }); + const refreshPayload = (await refreshResponse.json()) as { + token: string; + }; + + assert.equal(refreshResponse.status, 200); + assert.ok(refreshPayload.token); + assert.ok(refreshResponse.headers.get('set-cookie')); + + const logoutResponse = await httpRequest( `${baseUrl}/api/auth/logout`, - withBearer(entry.token, { method: 'POST' }), + withBearer(entry.token, { + method: 'POST', + headers: { + Cookie: entry.refreshCookie || '', + }, + }), ); assert.equal(logoutResponse.status, 200); - const invalidatedResponse = await fetch(`${baseUrl}/api/auth/me`, { + const invalidatedResponse = await httpRequest(`${baseUrl}/api/auth/me`, { headers: { Authorization: `Bearer ${entry.token}`, }, @@ -175,12 +854,472 @@ test('issued jwt remains valid without exp until logout invalidates token versio }); }); +test('refresh route rejects revoked refresh sessions after logout', async () => { + await withTestServer('refresh-revoked', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'hero_refresh_revoked', 'secret123'); + + const logoutResponse = await httpRequest( + `${baseUrl}/api/auth/logout`, + withBearer(entry.token, { + method: 'POST', + headers: { + Cookie: entry.refreshCookie || '', + }, + }), + ); + assert.equal(logoutResponse.status, 200); + + const refreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, { + method: 'POST', + headers: { + Cookie: entry.refreshCookie || '', + }, + }); + const refreshPayload = (await refreshResponse.json()) as { + error: { + code: string; + message: string; + }; + }; + + assert.equal(refreshResponse.status, 401); + assert.equal(refreshPayload.error.code, 'UNAUTHORIZED'); + }); +}); + +test('session list returns current active browser sessions for the user', async () => { + await withTestServer('session-list', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'hero_sessions', 'secret123'); + + const sessionsResponse = await httpRequest(`${baseUrl}/api/auth/sessions`, { + headers: { + Authorization: `Bearer ${entry.token}`, + Cookie: entry.refreshCookie || '', + }, + }); + const sessionsPayload = (await sessionsResponse.json()) as { + sessions: Array<{ + sessionId: string; + clientType: string; + clientLabel: string; + isCurrent: boolean; + userAgent: string | null; + ipMasked: string | null; + }>; + }; + + assert.equal(sessionsResponse.status, 200); + assert.equal(sessionsPayload.sessions.length, 1); + assert.equal(sessionsPayload.sessions[0]?.clientType, 'browser'); + assert.equal(sessionsPayload.sessions[0]?.clientLabel, '网页端浏览器'); + assert.equal(sessionsPayload.sessions[0]?.isCurrent, true); + }); +}); + +test('session revoke removes a remote device but keeps the current session alive', async () => { + await withTestServer('session-revoke', async ({ baseUrl }) => { + const firstEntry = await authEntry(baseUrl, 'hero_session_revoke', 'secret123'); + const secondEntry = await authEntry(baseUrl, 'hero_session_revoke', 'secret123'); + + const sessionsResponse = await httpRequest(`${baseUrl}/api/auth/sessions`, { + headers: { + Authorization: `Bearer ${secondEntry.token}`, + Cookie: secondEntry.refreshCookie || '', + }, + }); + const sessionsPayload = (await sessionsResponse.json()) as { + sessions: Array<{ + sessionId: string; + isCurrent: boolean; + }>; + }; + const remoteSession = sessionsPayload.sessions.find((session) => !session.isCurrent); + assert.ok(remoteSession); + + const revokeResponse = await httpRequest( + `${baseUrl}/api/auth/sessions/${encodeURIComponent(remoteSession?.sessionId || '')}/revoke`, + withBearer(secondEntry.token, { + method: 'POST', + headers: { + Cookie: secondEntry.refreshCookie || '', + }, + }), + ); + const revokePayload = (await revokeResponse.json()) as { + ok: true; + }; + + assert.equal(revokeResponse.status, 200); + assert.equal(revokePayload.ok, true); + + const remoteRefreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, { + method: 'POST', + headers: { + Cookie: firstEntry.refreshCookie || '', + }, + }); + assert.equal(remoteRefreshResponse.status, 401); + + const currentMeResponse = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${secondEntry.token}`, + }, + }); + assert.equal(currentMeResponse.status, 200); + }); +}); + +test('audit log endpoint returns recent auth activities', async () => { + await withTestServer('audit-logs', async ({ baseUrl }) => { + await sendPhoneCode(baseUrl, '13800138000'); + const entry = await phoneLogin(baseUrl, '13800138000'); + await sendPhoneCode(baseUrl, '13900139000'); + + const changeResponse = await httpRequest( + `${baseUrl}/api/auth/phone/change`, + withBearer(entry.token, { + method: 'POST', + headers: { + Cookie: entry.refreshCookie || '', + }, + body: JSON.stringify({ + phone: '13900139000', + code: '123456', + }), + }), + ); + assert.equal(changeResponse.status, 200); + + const logsResponse = await httpRequest(`${baseUrl}/api/auth/audit-logs`, { + headers: { + Authorization: `Bearer ${entry.token}`, + Cookie: entry.refreshCookie || '', + }, + }); + const logsPayload = (await logsResponse.json()) as { + logs: Array<{ + eventType: string; + title: string; + }>; + }; + + assert.equal(logsResponse.status, 200); + assert.ok(logsPayload.logs.length >= 2); + assert.ok(logsPayload.logs.some((log) => log.eventType === 'phone_login')); + assert.ok(logsPayload.logs.some((log) => log.eventType === 'change_phone')); + }); +}); + +test('active account can change phone number after verifying the new phone', async () => { + await withTestServer('change-phone', async ({ baseUrl }) => { + await sendPhoneCode(baseUrl, '13800138000'); + const entry = await phoneLogin(baseUrl, '13800138000'); + + await sendPhoneCode(baseUrl, '13900139000'); + const changeResponse = await httpRequest( + `${baseUrl}/api/auth/phone/change`, + withBearer(entry.token, { + method: 'POST', + headers: { + Cookie: entry.refreshCookie || '', + }, + body: JSON.stringify({ + phone: '13900139000', + code: '123456', + }), + }), + ); + const changePayload = (await changeResponse.json()) as { + user: { + phoneNumberMasked: string; + displayName: string; + }; + }; + + assert.equal(changeResponse.status, 200); + assert.equal(changePayload.user.phoneNumberMasked, '139****9000'); + assert.equal(changePayload.user.displayName, '139****9000'); + + const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const mePayload = (await meResponse.json()) as { + user: { + phoneNumberMasked: string; + }; + }; + + assert.equal(meResponse.status, 200); + assert.equal(mePayload.user.phoneNumberMasked, '139****9000'); + }); +}); + +test('change phone rejects numbers already bound to another account', async () => { + await withTestServer('change-phone-conflict', async ({ baseUrl }) => { + await sendPhoneCode(baseUrl, '13800138000'); + const sourceEntry = await phoneLogin(baseUrl, '13800138000'); + + await sendPhoneCode(baseUrl, '13900139000'); + await phoneLogin(baseUrl, '13900139000'); + + const changeResponse = await httpRequest( + `${baseUrl}/api/auth/phone/change`, + withBearer(sourceEntry.token, { + method: 'POST', + headers: { + Cookie: sourceEntry.refreshCookie || '', + }, + body: JSON.stringify({ + phone: '13900139000', + code: '123456', + }), + }), + ); + const changePayload = (await changeResponse.json()) as { + error: { + code: string; + message: string; + }; + }; + + assert.equal(changeResponse.status, 409); + assert.equal(changePayload.error.code, 'CONFLICT'); + assert.equal(changePayload.error.message, '该手机号已绑定其他账号'); + }); +}); + +test('logout-all revokes all refresh sessions and invalidates existing access tokens', async () => { + await withTestServer('logout-all', async ({ baseUrl }) => { + const entryA = await authEntry(baseUrl, 'hero_logout_all', 'secret123'); + const refreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, { + method: 'POST', + headers: { + Cookie: entryA.refreshCookie || '', + }, + }); + const refreshPayload = (await refreshResponse.json()) as { + token: string; + }; + + assert.equal(refreshResponse.status, 200); + const entryB = { + token: refreshPayload.token, + refreshCookie: refreshResponse.headers.get('set-cookie') || '', + }; + + const logoutAllResponse = await httpRequest( + `${baseUrl}/api/auth/logout-all`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${entryB.token}`, + Cookie: entryB.refreshCookie, + 'Content-Type': 'application/json', + }, + }, + ); + const logoutAllPayload = (await logoutAllResponse.json()) as { + ok: true; + }; + + assert.equal(logoutAllResponse.status, 200); + assert.equal(logoutAllPayload.ok, true); + + const meAResponse = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entryA.token}`, + }, + }); + assert.equal(meAResponse.status, 401); + + const meBResponse = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + Authorization: `Bearer ${entryB.token}`, + }, + }); + assert.equal(meBResponse.status, 401); + + const refreshAfterLogoutAll = await httpRequest(`${baseUrl}/api/auth/refresh`, { + method: 'POST', + headers: { + Cookie: entryB.refreshCookie, + }, + }); + assert.equal(refreshAfterLogoutAll.status, 401); + }); +}); + +test('error responses share one structure and preserve request ids', async () => { + await withTestServer('error-envelope', async ({ baseUrl }) => { + const requestId = 'req-error-envelope'; + const response = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + 'X-Request-Id': requestId, + }, + }); + const payload = await response.json<{ + error: { + code: string; + message: string; + }; + meta: { + requestId: string; + apiVersion: string; + routeVersion: string; + operation: string; + }; + }>(); + + assert.equal(response.status, 401); + assert.equal(payload.error.code, 'UNAUTHORIZED'); + assert.equal(payload.error.message, '缺少 Authorization Bearer Token'); + assert.equal(payload.meta.requestId, requestId); + assert.equal(payload.meta.apiVersion, '2026-04-08'); + assert.equal(payload.meta.routeVersion, '2026-04-08'); + assert.equal(payload.meta.operation, 'auth.me'); + assert.equal(response.headers.get('x-request-id'), requestId); + }); +}); + +test('validation errors are normalized with code, meta and issue details', async () => { + await withTestServer('invalid-request', async ({ baseUrl }) => { + const response = await httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + const payload = (await response.json()) as { + error: { + code: string; + message: string; + details?: { + issues?: Array<{ + path: string; + message: string; + code: string; + }>; + }; + }; + meta: { + operation: string; + }; + }; + + assert.equal(response.status, 400); + assert.equal(payload.error.code, 'INVALID_REQUEST'); + assert.equal(payload.error.message, '请求参数不合法'); + assert.equal(payload.meta.operation, 'auth.entry'); + assert.ok(Array.isArray(payload.error.details?.issues)); + assert.ok((payload.error.details?.issues?.length ?? 0) > 0); + }); +}); + +test('malformed json bodies are normalized as bad requests', async () => { + await withTestServer('malformed-json', async ({ baseUrl }) => { + const response = await httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: '{"username":"broken"', + }); + const payload = (await response.json()) as { + error: { + code: string; + message: string; + }; + meta: { + operation: string; + }; + }; + + assert.equal(response.status, 400); + assert.equal(payload.error.code, 'BAD_REQUEST'); + assert.equal(payload.error.message, 'JSON 请求体格式错误'); + assert.equal(payload.meta.operation, 'POST /api/auth/entry'); + }); +}); + +test('authenticated missing routes return unified not found errors', async () => { + await withTestServer('not-found', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'hero_not_found', 'secret123'); + const response = await httpRequest(`${baseUrl}/api/runtime/unknown-route`, { + headers: { + Authorization: `Bearer ${entry.token}`, + 'X-Genarrative-Response-Envelope': 'v1', + }, + }); + const payload = await response.json<{ + ok: false; + data: null; + error: { + code: string; + message: string; + }; + meta: { + operation: string; + }; + }>(); + + assert.equal(response.status, 404); + assert.equal(payload.ok, false); + assert.equal(payload.data, null); + assert.equal(payload.error.code, 'NOT_FOUND'); + assert.match( + payload.error.message, + /^接口不存在:GET \/api\/runtime\/unknown-route$/u, + ); + assert.equal(payload.meta.operation, 'GET /api/runtime/unknown-route'); + }); +}); + +test('stream responses also carry api version and route metadata headers', async () => { + const app = express(); + app.use(requestIdMiddleware); + app.get('/events', (request, response) => { + prepareEventStreamResponse(request, response, { + routeMeta: { + operation: 'test.events.stream', + }, + }); + response.write('event: ping\n'); + response.write('data: {"ok":true}\n\n'); + response.end(); + }); + + await withListeningApp(app, async ({ baseUrl }) => { + const requestId = 'req-stream-metadata'; + const response = await httpRequest(`${baseUrl}/events`, { + headers: { + 'X-Request-Id': requestId, + }, + }); + const body = await response.text(); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-request-id'), requestId); + assert.equal(response.headers.get('x-api-version'), '2026-04-08'); + assert.equal(response.headers.get('x-route-version'), '2026-04-08'); + assert.ok(Number(response.headers.get('x-response-time-ms')) >= 0); + assert.match( + response.headers.get('content-type') ?? '', + /^text\/event-stream/u, + ); + assert.match(body, /event: ping/u); + assert.ok(body.includes('data: {"ok":true}')); + }); +}); + test('runtime persistence is isolated by user', async () => { await withTestServer('persistence', async ({ baseUrl }) => { const userA = await authEntry(baseUrl, 'player_one', 'secret123'); const userB = await authEntry(baseUrl, 'player_two', 'secret123'); - const saveResponse = await fetch( + const saveResponse = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, withBearer(userA.token, { method: 'PUT', @@ -193,7 +1332,7 @@ test('runtime persistence is isolated by user', async () => { ); assert.equal(saveResponse.status, 200); - const settingsResponse = await fetch( + const settingsResponse = await httpRequest( `${baseUrl}/api/runtime/settings`, withBearer(userA.token, { method: 'PUT', @@ -204,7 +1343,7 @@ test('runtime persistence is isolated by user', async () => { ); assert.equal(settingsResponse.status, 200); - const libraryResponse = await fetch( + const libraryResponse = await httpRequest( `${baseUrl}/api/runtime/custom-world-library/world-a`, withBearer(userA.token, { method: 'PUT', @@ -218,37 +1357,43 @@ test('runtime persistence is isolated by user', async () => { ); assert.equal(libraryResponse.status, 200); - const userASave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, { - headers: { - Authorization: `Bearer ${userA.token}`, + const userASave = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${userA.token}`, + }, }, - }); - const userASavePayload = await userASave.json() as { + ); + const userASavePayload = (await userASave.json()) as { gameState: { value: number; }; }; assert.equal(userASavePayload.gameState.value, 1); - const userBSave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, { - headers: { - Authorization: `Bearer ${userB.token}`, + const userBSave = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${userB.token}`, + }, }, - }); + ); const userBSavePayload = await userBSave.json(); assert.equal(userBSavePayload, null); - const userBSettings = await fetch(`${baseUrl}/api/runtime/settings`, { + const userBSettings = await httpRequest(`${baseUrl}/api/runtime/settings`, { headers: { Authorization: `Bearer ${userB.token}`, }, }); - const userBSettingsPayload = await userBSettings.json() as { + const userBSettingsPayload = (await userBSettings.json()) as { musicVolume: number; }; assert.equal(userBSettingsPayload.musicVolume, 0.42); - const userBLibrary = await fetch( + const userBLibrary = await httpRequest( `${baseUrl}/api/runtime/custom-world-library`, { headers: { @@ -256,9 +1401,278 @@ test('runtime persistence is isolated by user', async () => { }, }, ); - const userBLibraryPayload = await userBLibrary.json() as { + const userBLibraryPayload = (await userBLibrary.json()) as { profiles: unknown[]; }; assert.deepEqual(userBLibraryPayload.profiles, []); }); }); + +test('runtime snapshot persistence accepts null currentStory payloads', async () => { + await withTestServer('persistence-null-story', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'player_null_story', 'secret123'); + + const saveResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(entry.token, { + method: 'PUT', + body: JSON.stringify({ + gameState: { + worldType: 'WUXIA', + currentScene: 'Story', + }, + bottomTab: 'adventure', + currentStory: null, + }), + }), + ); + const savePayload = (await saveResponse.json()) as { + currentStory: null; + }; + + assert.equal(saveResponse.status, 200); + assert.equal(savePayload.currentStory, null); + + const loadResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const loadPayload = (await loadResponse.json()) as { + currentStory: null; + }; + + assert.equal(loadResponse.status, 200); + assert.equal(loadPayload.currentStory, null); + }); +}); + +test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => { + await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'player_hydrated_snapshot', 'secret123'); + + const saveResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(entry.token, { + method: 'PUT', + body: JSON.stringify({ + gameState: { + currentScene: 'Story', + worldType: 'WUXIA', + playerCharacter: { + id: 'hero', + title: '试剑客', + description: '在风里试探局势的人。', + personality: '谨慎而果断', + attributes: { + strength: 8, + spirit: 6, + }, + skills: [], + }, + playerHp: 140, + playerMaxHp: 140, + playerMana: 60, + playerMaxMana: 60, + }, + bottomTab: 'unknown-tab', + currentStory: { + text: '恢复中的故事', + options: [], + streaming: true, + }, + }), + }), + ); + const savePayload = (await saveResponse.json()) as { + bottomTab: string; + currentStory: { + streaming: boolean; + }; + gameState: { + storyEngineMemory: { + saveMigrationManifest?: { + version: string; + } | null; + }; + playerMaxHp: number; + playerMaxMana: number; + playerEquipment: { + weapon: { id: string } | null; + armor: { id: string } | null; + relic: { id: string } | null; + }; + }; + }; + + assert.equal(saveResponse.status, 200); + assert.equal(savePayload.bottomTab, 'adventure'); + assert.equal(savePayload.currentStory.streaming, false); + assert.equal( + savePayload.gameState.storyEngineMemory.saveMigrationManifest?.version, + 'story-engine-v5', + ); + assert.equal(savePayload.gameState.playerMaxHp, 208); + assert.equal(savePayload.gameState.playerMaxMana, 1009); + assert.equal(savePayload.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon'); + assert.equal(savePayload.gameState.playerEquipment.armor?.id, 'starter:hero:armor'); + assert.equal(savePayload.gameState.playerEquipment.relic?.id, 'starter:hero:relic'); + + const loadResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const loadPayload = (await loadResponse.json()) as typeof savePayload; + + assert.equal(loadResponse.status, 200); + assert.equal(loadPayload.bottomTab, 'adventure'); + assert.equal(loadPayload.currentStory.streaming, false); + assert.equal( + loadPayload.gameState.storyEngineMemory.saveMigrationManifest?.version, + 'story-engine-v5', + ); + assert.equal(loadPayload.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon'); + assert.equal(loadPayload.gameState.playerEquipment.armor?.id, 'starter:hero:armor'); + assert.equal(loadPayload.gameState.playerEquipment.relic?.id, 'starter:hero:relic'); + }); +}); + +test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => { + await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'player_hydrated_story', 'secret123'); + + const saveResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(entry.token, { + method: 'PUT', + body: JSON.stringify({ + gameState: { + currentScene: 'Story', + worldType: 'WUXIA', + playerCharacter: { + id: 'hero', + title: '试剑客', + description: '在风里试探局势的人。', + personality: '谨慎而果断', + attributes: { + strength: 8, + spirit: 6, + }, + skills: [{ id: 'skill-1' }], + resourceProfile: { + maxHp: 150, + maxMana: 80, + }, + }, + playerHp: 80, + playerMaxHp: 70, + playerMana: 90, + playerMaxMana: 18, + playerEquipment: { + weapon: null, + armor: { + id: 'armor-1', + category: '护甲', + name: '试炼轻甲', + quantity: 1, + rarity: 'rare', + tags: ['armor'], + statProfile: { + maxHpBonus: 20, + }, + }, + relic: { + id: 'relic-1', + category: '饰品', + name: '回气坠', + quantity: 1, + rarity: 'rare', + tags: ['relic'], + statProfile: { + maxManaBonus: 15, + }, + }, + }, + }, + bottomTab: 'unknown-tab', + currentStory: { + text: '服务端恢复故事', + options: [], + streaming: true, + }, + }), + }), + ); + const savePayload = (await saveResponse.json()) as { + bottomTab: string; + currentStory: { + streaming: boolean; + }; + gameState: { + runtimeActionVersion: number; + storyEngineMemory: { + activeThreadIds: string[]; + saveMigrationManifest?: { + version: string; + } | null; + }; + runtimeStats: { + itemsUsed: number; + }; + playerEquipment: { + weapon: null; + }; + playerHp: number; + playerMaxHp: number; + playerMana: number; + playerMaxMana: number; + }; + }; + + assert.equal(saveResponse.status, 200); + assert.equal(savePayload.bottomTab, 'adventure'); + assert.equal(savePayload.currentStory.streaming, false); + assert.equal(savePayload.gameState.runtimeActionVersion, 0); + assert.deepEqual(savePayload.gameState.storyEngineMemory.activeThreadIds, []); + assert.equal( + savePayload.gameState.storyEngineMemory.saveMigrationManifest?.version, + 'story-engine-v5', + ); + assert.equal(savePayload.gameState.runtimeStats.itemsUsed, 0); + assert.equal(savePayload.gameState.playerEquipment.weapon, null); + assert.equal(savePayload.gameState.playerHp, 80); + assert.equal(savePayload.gameState.playerMaxHp, 170); + assert.equal(savePayload.gameState.playerMana, 90); + assert.equal(savePayload.gameState.playerMaxMana, 95); + + const loadResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const loadPayload = (await loadResponse.json()) as { + bottomTab: string; + currentStory: { + streaming: boolean; + }; + gameState: { + storyEngineMemory: { + saveMigrationManifest?: { + version: string; + } | null; + }; + playerMaxHp: number; + }; + }; + + assert.equal(loadResponse.status, 200); + assert.equal(loadPayload.bottomTab, 'adventure'); + assert.equal(loadPayload.currentStory.streaming, false); + assert.equal( + loadPayload.gameState.storyEngineMemory.saveMigrationManifest?.version, + 'story-engine-v5', + ); + assert.equal(loadPayload.gameState.playerMaxHp, 170); + }); +}); diff --git a/server-node/src/app.ts b/server-node/src/app.ts index d8d79a05..7f0b3097 100644 --- a/server-node/src/app.ts +++ b/server-node/src/app.ts @@ -2,14 +2,52 @@ import express from 'express'; import pinoHttp from 'pino-http'; import type { AppContext } from './context.js'; +import { buildApiLogContext, withRouteMeta } from './http.js'; import { errorHandler } from './middleware/errorHandler.js'; import { requestIdMiddleware } from './middleware/requestId.js'; +import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js'; +import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js'; +import { createQwenSpriteRoutes } from './modules/assets/qwenSpriteRoutes.js'; +import { createEditorRoutes } from './modules/editor/editorRoutes.js'; +import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js'; import { createAuthRoutes } from './routes/authRoutes.js'; import { createRuntimeRoutes } from './routes/runtimeRoutes.js'; +import { notFound } from './errors.js'; + +function matchesRoutePrefix( + request: express.Request, + prefixes: readonly string[], +) { + const requestPath = request.path || request.originalUrl || request.url || '/'; + + return prefixes.some((prefix) => { + const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; + return ( + requestPath === normalizedPrefix || + requestPath.startsWith(`${normalizedPrefix}/`) + ); + }); +} + +function scopeToPrefixes( + prefixes: readonly string[], + handler: express.RequestHandler, +): express.RequestHandler { + return (request, response, next) => { + if (!matchesRoutePrefix(request, prefixes)) { + next(); + return; + } + + handler(request, response, next); + }; +} export function createApp(context: AppContext) { const app = express(); - const createHttpLogger = pinoHttp as unknown as (options: Record) => express.RequestHandler; + const createHttpLogger = pinoHttp as unknown as ( + options: Record, + ) => express.RequestHandler; app.disable('x-powered-by'); @@ -17,18 +55,14 @@ export function createApp(context: AppContext) { app.use( createHttpLogger({ logger: context.logger, - genReqId: (request) => request.requestId, - customProps: (request: express.Request) => ({ - request_id: request.requestId, - user_id: request.userId ?? null, - }), + genReqId: (request: express.Request) => request.requestId, customSuccessObject: ( request: express.Request, response: express.Response, baseObject: Record & { responseTime?: number }, ) => ({ ...baseObject, - request_id: request.requestId, + ...buildApiLogContext(request, response), user_id: request.userId ?? null, method: request.method, path: request.url, @@ -42,7 +76,7 @@ export function createApp(context: AppContext) { baseObject: Record & { responseTime?: number }, ) => ({ ...baseObject, - request_id: request.requestId, + ...buildApiLogContext(request, response), user_id: request.userId ?? null, method: request.method, path: request.url, @@ -53,17 +87,67 @@ export function createApp(context: AppContext) { }), ); app.use(express.json({ limit: '10mb' })); + app.use(responseEnvelopeMiddleware); - app.get('/healthz', (_request, response) => { - response.json({ - ok: true, - service: 'genarrative-node-server', - }); + app.get( + '/healthz', + withRouteMeta({ operation: 'health.check' }), + (_request, response) => { + response.json({ + ok: true, + service: 'genarrative-node-server', + }); + }, + ); + + app.use( + scopeToPrefixes( + ['/api/editor'], + withRouteMeta({ routeVersion: '2026-04-08', operation: 'editor.api' }), + ), + ); + app.use(scopeToPrefixes(['/api/editor'], createEditorRoutes(context.config))); + app.use( + scopeToPrefixes( + ['/api/assets'], + withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.api' }), + ), + ); + app.use( + scopeToPrefixes(['/api/assets'], createCharacterAssetRoutes(context.config)), + ); + app.use( + scopeToPrefixes( + ['/api/assets/qwen-sprite'], + withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.qwen' }), + ), + ); + app.use( + scopeToPrefixes( + ['/api/assets/qwen-sprite'], + createQwenSpriteRoutes(context.config), + ), + ); + app.use( + '/api/auth', + withRouteMeta({ routeVersion: '2026-04-08' }), + createAuthRoutes(context), + ); + app.use( + '/api/runtime/story', + withRouteMeta({ routeVersion: '2026-04-08' }), + createStoryActionRoutes(context), + ); + app.use( + '/api', + withRouteMeta({ routeVersion: '2026-04-08' }), + createRuntimeRoutes(context), + ); + + app.use((request, _response, next) => { + next(notFound(`接口不存在:${request.method} ${request.originalUrl}`)); }); - app.use('/api/auth', createAuthRoutes(context)); - app.use('/api', createRuntimeRoutes(context)); - app.use(errorHandler); return app; } diff --git a/server-node/src/auth/authRequestContext.ts b/server-node/src/auth/authRequestContext.ts new file mode 100644 index 00000000..bf5f19d4 --- /dev/null +++ b/server-node/src/auth/authRequestContext.ts @@ -0,0 +1,15 @@ +import type { Request } from 'express'; + +export type AuthRequestContext = { + clientType: string; + userAgent: string | null; + ip: string | null; +}; + +export function buildAuthRequestContext(request: Request): AuthRequestContext { + return { + clientType: 'browser', + userAgent: request.header('user-agent')?.trim() || null, + ip: request.ip || null, + }; +} diff --git a/server-node/src/auth/authService.ts b/server-node/src/auth/authService.ts index aecfeeaa..ee3c4896 100644 --- a/server-node/src/auth/authService.ts +++ b/server-node/src/auth/authService.ts @@ -1,6 +1,48 @@ +import crypto from 'node:crypto'; + +import type { + AuthAuditLogEntry, + AuthAuditLogEventType, + AuthAuditLogsResponse, + AuthBindingStatus, + AuthEntryResponse, + AuthLiftRiskBlockResponse, + AuthLoginMethod, + AuthLogoutAllResponse, + AuthMeResponse, + AuthPhoneChangeResponse, + AuthPhoneLoginResponse, + AuthPhoneSendCodeResponse, + AuthRefreshResponse, + AuthRevokeSessionResponse, + AuthRiskBlocksResponse, + AuthRiskBlockSummary, + AuthSessionsResponse, + AuthSessionSummary, + AuthUser, + AuthWechatBindPhoneResponse, + AuthWechatStartResponse, + LogoutResponse, +} from '../../../packages/shared/src/contracts/auth.js'; import type { AppContext } from '../context.js'; -import { badRequest, unauthorized } from '../errors.js'; +import { + badRequest, + captchaRequired, + conflict, + tooManyRequests, + unauthorized, +} from '../errors.js'; +import type { UserRecord } from '../repositories/userRepository.js'; import { hashPassword, verifyPassword } from './password.js'; +import { + normalizeMainlandChinaPhoneNumber, + validateSmsVerifyCode, +} from './phoneNumber.js'; +import { + createRefreshSessionToken, + hashRefreshSessionToken, + type RefreshSessionRequestContext, +} from './refreshSessionCookie.js'; import { signAccessToken } from './token.js'; const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,24}$/u; @@ -18,18 +60,875 @@ function validateCredentials(username: string, password: string) { } } +function buildMaskedPhoneDisplay(phoneNumber: string) { + const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneNumber); + return normalizedPhone.maskedNationalNumber; +} + +function buildSystemUsername(prefix: string) { + return `${prefix}_${crypto.randomBytes(10).toString('hex')}`; +} + +function buildRandomPasswordSeed() { + return crypto.randomBytes(24).toString('hex'); +} + +function mapAccountStatusToBindingStatus( + status: UserRecord['accountStatus'], +): AuthBindingStatus { + return status === 'pending_bind_phone' ? 'pending_bind_phone' : 'active'; +} + +function resolveDisplayName(user: { + displayName?: string | null; + username?: string | null; + phoneNumber?: string | null; +}) { + return ( + user.displayName?.trim() || + (user.phoneNumber ? buildMaskedPhoneDisplay(user.phoneNumber) : '') || + user.username?.trim() || + '玩家' + ); +} + +function resolveDisplayNameAfterPhoneChange(user: UserRecord, nextPhoneNumber: string) { + const nextMaskedPhone = buildMaskedPhoneDisplay(nextPhoneNumber); + const currentMaskedPhone = user.phoneNumber + ? buildMaskedPhoneDisplay(user.phoneNumber) + : ''; + + if ( + user.loginProvider === 'phone' || + !user.displayName?.trim() || + user.displayName.trim() === currentMaskedPhone + ) { + return nextMaskedPhone; + } + + return user.displayName; +} + +function resolveAvailableLoginMethods(context: AppContext): AuthLoginMethod[] { + const methods: AuthLoginMethod[] = []; + if (context.config.smsAuth.enabled) { + methods.push('phone'); + } + if (context.config.wechatAuth.enabled) { + methods.push('wechat'); + } + return methods; +} + +async function toAuthUser( + context: AppContext, + user: UserRecord, +): Promise { + const identities = await context.authIdentityRepository.listByUserId(user.id); + const wechatBound = identities.some((identity) => identity.provider === 'wechat'); + const displayName = resolveDisplayName(user); + + return { + id: user.id, + username: displayName, + displayName, + phoneNumberMasked: user.phoneNumber + ? buildMaskedPhoneDisplay(user.phoneNumber) + : null, + loginMethod: user.loginProvider, + bindingStatus: mapAccountStatusToBindingStatus(user.accountStatus), + wechatBound, + }; +} + +export async function buildAuthMeResponse( + context: AppContext, + user: UserRecord | null, +): Promise { + return { + user: user ? await toAuthUser(context, user) : null, + availableLoginMethods: resolveAvailableLoginMethods(context), + }; +} + +async function signUserAuthPayload( + context: AppContext, + user: UserRecord, +) { + const token = await signAccessToken( + { + userId: user.id, + tokenVersion: user.tokenVersion, + }, + context.config, + ); + + return { + token, + user: await toAuthUser(context, user), + }; +} + +function buildRefreshSessionExpiry(config: AppContext['config']) { + const expiresAt = new Date(); + expiresAt.setDate( + expiresAt.getDate() + Math.max(1, config.authSession.refreshSessionTtlDays), + ); + return expiresAt.toISOString(); +} + +function buildRelativeTimeIso(params: { + hours?: number; + days?: number; +}) { + const date = new Date(); + if (params.hours) { + date.setHours(date.getHours() - params.hours); + } + if (params.days) { + date.setDate(date.getDate() - params.days); + } + return date.toISOString(); +} + +function buildCaptchaScopeKey(params: { + scene: 'login' | 'bind_phone' | 'change_phone'; + phoneNumber: string; + ip: string | null; +}) { + return `${params.scene}:${params.phoneNumber}:${params.ip ?? 'no-ip'}`; +} + +function buildFutureTimeIso(params: { + minutes: number; +}) { + const date = new Date(); + date.setMinutes(date.getMinutes() + Math.max(1, params.minutes)); + return date.toISOString(); +} + +function maskIpAddress(ip: string | null) { + if (!ip) { + return null; + } + + if (ip.includes(':')) { + const parts = ip.split(':').filter(Boolean); + if (parts.length <= 2) { + return ip; + } + return `${parts.slice(0, 2).join(':')}::*`; + } + + const parts = ip.split('.'); + if (parts.length !== 4) { + return ip; + } + return `${parts[0]}.${parts[1]}.*.*`; +} + +function buildSessionClientLabel(session: { + clientType: string; + userAgent: string | null; +}) { + const userAgent = session.userAgent?.toLowerCase() || ''; + if (userAgent.includes('mobile') || userAgent.includes('android') || userAgent.includes('iphone')) { + return '移动端浏览器'; + } + if (session.clientType === 'browser') { + return '网页端浏览器'; + } + return session.clientType || '未知设备'; +} + +function buildAuditLogTitle(eventType: AuthAuditLogEventType) { + switch (eventType) { + case 'password_login': + return '账号密码登录'; + case 'phone_login': + return '手机号登录'; + case 'wechat_login': + return '微信登录'; + case 'wechat_bind_phone': + return '绑定手机号'; + case 'change_phone': + return '更换手机号'; + case 'captcha_required': + return '需要图形验证码'; + case 'logout': + return '退出当前设备'; + case 'logout_all': + return '退出全部设备'; + case 'revoke_session': + return '移除登录设备'; + case 'risk_block_phone': + return '手机号临时保护'; + case 'risk_block_ip': + return '网络临时保护'; + case 'risk_unblock_phone': + return '解除手机号保护'; + case 'risk_unblock_ip': + return '解除网络保护'; + default: + return '账号操作'; + } +} + +async function writeAuthAuditLog( + context: AppContext, + input: { + userId: string; + eventType: AuthAuditLogEventType; + detail: string; + ip: string | null; + userAgent: string | null; + metaJson?: Record | null; + }, +) { + await context.authAuditLogRepository.create(input); +} + +async function recordSmsAuthEvent( + context: AppContext, + input: { + phoneNumber: string; + scene: 'login' | 'bind_phone' | 'change_phone'; + action: 'send_code' | 'verify_code'; + success: boolean; + requestContext: RefreshSessionRequestContext | null; + }, +) { + await context.smsAuthEventRepository.create({ + phoneNumber: input.phoneNumber, + scene: input.scene, + action: input.action, + success: input.success, + ip: input.requestContext?.ip ?? null, + userAgent: input.requestContext?.userAgent ?? null, + }); +} + +function buildCaptchaRequiredError( + context: AppContext, + params: { + scene: 'login' | 'bind_phone' | 'change_phone'; + phoneNumber: string; + requestContext: RefreshSessionRequestContext | null; + message?: string; + }, +) { + const challenge = context.captchaChallenges.create( + buildCaptchaScopeKey({ + scene: params.scene, + phoneNumber: params.phoneNumber, + ip: params.requestContext?.ip ?? null, + }), + context.config.smsAuth.captchaTtlSeconds, + ); + + return captchaRequired( + params.message ?? '当前操作需要完成人机校验', + { + captchaChallenge: challenge, + }, + ); +} + +async function enforceSmsSendRateLimit( + context: AppContext, + phoneNumber: string, + requestContext: RefreshSessionRequestContext | null, +) { + const phoneSendCount = await context.smsAuthEventRepository.countSinceByPhone({ + phoneNumber, + action: 'send_code', + since: buildRelativeTimeIso({ days: 1 }), + }); + if (phoneSendCount >= context.config.smsAuth.maxSendPerPhonePerDay) { + throw tooManyRequests('该手机号今日验证码发送次数已达上限,请明天再试'); + } + + const ipSendCount = await context.smsAuthEventRepository.countSinceByIp({ + ip: requestContext?.ip ?? null, + action: 'send_code', + since: buildRelativeTimeIso({ hours: 1 }), + }); + if (ipSendCount >= context.config.smsAuth.maxSendPerIpPerHour) { + throw tooManyRequests('当前网络请求验证码过于频繁,请稍后再试'); + } +} + +async function enforceSmsVerifyFailureLimit( + context: AppContext, + phoneNumber: string, + requestContext: RefreshSessionRequestContext | null, +) { + const phoneFailureCount = await context.smsAuthEventRepository.countSinceByPhone({ + phoneNumber, + action: 'verify_code', + success: false, + since: buildRelativeTimeIso({ hours: 1 }), + }); + if ( + phoneFailureCount >= + context.config.smsAuth.maxVerifyFailuresPerPhonePerHour + ) { + throw tooManyRequests('该手机号验证码尝试次数过多,请稍后再试'); + } + + const ipFailureCount = await context.smsAuthEventRepository.countSinceByIp({ + ip: requestContext?.ip ?? null, + action: 'verify_code', + success: false, + since: buildRelativeTimeIso({ hours: 1 }), + }); + if ( + ipFailureCount >= context.config.smsAuth.maxVerifyFailuresPerIpPerHour + ) { + throw tooManyRequests('当前网络验证码尝试次数过多,请稍后再试'); + } +} + +function buildRiskBlockMessage(scopeType: 'phone' | 'ip', expiresAt: string) { + const expiresDate = new Date(expiresAt); + const remainingMinutes = Math.max( + 1, + Math.ceil((expiresDate.getTime() - Date.now()) / 60000), + ); + + if (scopeType === 'phone') { + return `该手机号因异常尝试已被临时保护,请约 ${remainingMinutes} 分钟后再试`; + } + + return `当前网络因异常尝试已被临时保护,请约 ${remainingMinutes} 分钟后再试`; +} + +function toAuthRiskBlockSummary(block: { + scopeType: 'phone' | 'ip'; + expiresAt: string; +}): AuthRiskBlockSummary { + const remainingSeconds = Math.max( + 0, + Math.floor((new Date(block.expiresAt).getTime() - Date.now()) / 1000), + ); + + return { + scopeType: block.scopeType, + title: block.scopeType === 'phone' ? '手机号保护中' : '当前网络保护中', + detail: buildRiskBlockMessage(block.scopeType, block.expiresAt), + expiresAt: block.expiresAt, + remainingSeconds, + }; +} + +async function enforceNoActiveRiskBlocks( + context: AppContext, + params: { + phoneNumber: string; + requestContext: RefreshSessionRequestContext | null; + }, +) { + const phoneBlock = await context.authRiskBlockRepository.findActive( + 'phone', + params.phoneNumber, + ); + if (phoneBlock) { + throw tooManyRequests( + buildRiskBlockMessage('phone', phoneBlock.expiresAt), + { + blockExpiresAt: phoneBlock.expiresAt, + scopeType: 'phone', + }, + ); + } + + const ip = params.requestContext?.ip ?? null; + if (!ip) { + return; + } + + const ipBlock = await context.authRiskBlockRepository.findActive('ip', ip); + if (ipBlock) { + throw tooManyRequests( + buildRiskBlockMessage('ip', ipBlock.expiresAt), + { + blockExpiresAt: ipBlock.expiresAt, + scopeType: 'ip', + }, + ); + } +} + +async function applyRiskBlocksIfNeeded( + context: AppContext, + params: { + phoneNumber: string; + requestContext: RefreshSessionRequestContext | null; + }, +) { + const phoneFailureCount = await context.smsAuthEventRepository.countSinceByPhone({ + phoneNumber: params.phoneNumber, + action: 'verify_code', + success: false, + since: buildRelativeTimeIso({ hours: 1 }), + }); + if ( + phoneFailureCount >= context.config.smsAuth.blockPhoneFailureThreshold + ) { + const expiresAt = buildFutureTimeIso({ + minutes: context.config.smsAuth.blockPhoneDurationMinutes, + }); + const block = await context.authRiskBlockRepository.createOrRefresh({ + scopeType: 'phone', + scopeKey: params.phoneNumber, + reason: 'sms_verify_failures', + expiresAt, + }); + const existingUser = await context.userRepository.findByPhoneNumber( + params.phoneNumber, + ); + if (existingUser) { + await writeAuthAuditLog(context, { + userId: existingUser.id, + eventType: 'risk_block_phone', + detail: `手机号 ${buildMaskedPhoneDisplay(params.phoneNumber)} 已被临时保护`, + ip: params.requestContext?.ip ?? null, + userAgent: params.requestContext?.userAgent ?? null, + metaJson: { + scopeKey: params.phoneNumber, + expiresAt: block?.expiresAt ?? expiresAt, + }, + }); + } + } + + const ip = params.requestContext?.ip ?? null; + if (!ip) { + return; + } + + const ipFailureCount = await context.smsAuthEventRepository.countSinceByIp({ + ip, + action: 'verify_code', + success: false, + since: buildRelativeTimeIso({ hours: 1 }), + }); + if (ipFailureCount >= context.config.smsAuth.blockIpFailureThreshold) { + const expiresAt = buildFutureTimeIso({ + minutes: context.config.smsAuth.blockIpDurationMinutes, + }); + await context.authRiskBlockRepository.createOrRefresh({ + scopeType: 'ip', + scopeKey: ip, + reason: 'sms_verify_failures', + expiresAt, + }); + } +} + +async function enforceCaptchaRequirement( + context: AppContext, + params: { + scene: 'login' | 'bind_phone' | 'change_phone'; + phoneNumber: string; + requestContext: RefreshSessionRequestContext | null; + captchaChallengeId?: string | null; + captchaAnswer?: string | null; + }, +) { + const phoneFailureCount = await context.smsAuthEventRepository.countSinceByPhone({ + phoneNumber: params.phoneNumber, + action: 'verify_code', + success: false, + since: buildRelativeTimeIso({ hours: 1 }), + }); + const ipFailureCount = await context.smsAuthEventRepository.countSinceByIp({ + ip: params.requestContext?.ip ?? null, + action: 'verify_code', + success: false, + since: buildRelativeTimeIso({ hours: 1 }), + }); + + const requiresCaptcha = + phoneFailureCount >= + context.config.smsAuth.captchaTriggerVerifyFailuresPerPhone || + ipFailureCount >= context.config.smsAuth.captchaTriggerVerifyFailuresPerIp; + + if (!requiresCaptcha) { + return; + } + + const challengeId = params.captchaChallengeId?.trim() || ''; + const captchaAnswer = params.captchaAnswer?.trim() || ''; + if (!challengeId || !captchaAnswer) { + const existingUser = await context.userRepository.findByPhoneNumber( + params.phoneNumber, + ); + if (existingUser) { + await writeAuthAuditLog(context, { + userId: existingUser.id, + eventType: 'captcha_required', + detail: `手机号 ${buildMaskedPhoneDisplay(params.phoneNumber)} 需要图形验证码`, + ip: params.requestContext?.ip ?? null, + userAgent: params.requestContext?.userAgent ?? null, + }); + } + throw buildCaptchaRequiredError(context, params); + } + + const isValid = context.captchaChallenges.verify({ + challengeId, + scopeKey: buildCaptchaScopeKey({ + scene: params.scene, + phoneNumber: params.phoneNumber, + ip: params.requestContext?.ip ?? null, + }), + answer: captchaAnswer, + }); + if (!isValid) { + const existingUser = await context.userRepository.findByPhoneNumber( + params.phoneNumber, + ); + if (existingUser) { + await writeAuthAuditLog(context, { + userId: existingUser.id, + eventType: 'captcha_required', + detail: `手机号 ${buildMaskedPhoneDisplay(params.phoneNumber)} 图形验证码错误`, + ip: params.requestContext?.ip ?? null, + userAgent: params.requestContext?.userAgent ?? null, + }); + } + throw buildCaptchaRequiredError(context, { + ...params, + message: '图形验证码错误或已过期,请重新输入', + }); + } +} + +function toAuthAuditLogEntry(log: { + id: string; + eventType: AuthAuditLogEventType; + detail: string; + ip: string | null; + userAgent: string | null; + createdAt: string; +}): AuthAuditLogEntry { + return { + id: log.id, + eventType: log.eventType, + title: buildAuditLogTitle(log.eventType), + detail: log.detail, + ipMasked: maskIpAddress(log.ip), + userAgent: log.userAgent, + createdAt: log.createdAt, + }; +} + +export async function createRefreshSession( + context: AppContext, + user: UserRecord, + sessionContext: RefreshSessionRequestContext, +) { + const refreshToken = createRefreshSessionToken(); + const refreshTokenHash = hashRefreshSessionToken(refreshToken); + const expiresAt = buildRefreshSessionExpiry(context.config); + + await context.userSessionRepository.create({ + userId: user.id, + refreshTokenHash, + clientType: sessionContext.clientType, + userAgent: sessionContext.userAgent, + ip: sessionContext.ip, + expiresAt, + }); + + return { + refreshToken, + expiresAt, + }; +} + +export async function listAuthAuditLogs( + context: AppContext, + userId: string, +): Promise { + const logs = await context.authAuditLogRepository.listRecentByUserId(userId, 20); + return { + logs: logs.map((log) => toAuthAuditLogEntry(log)), + }; +} + +export async function listActiveRiskBlocks( + context: AppContext, + user: UserRecord, + requestContext: RefreshSessionRequestContext | null, +): Promise { + const blocks: AuthRiskBlockSummary[] = []; + + if (user.phoneNumber) { + const phoneBlock = await context.authRiskBlockRepository.findActive( + 'phone', + user.phoneNumber, + ); + if (phoneBlock) { + blocks.push(toAuthRiskBlockSummary(phoneBlock)); + } + } + + const ip = requestContext?.ip ?? null; + if (ip) { + const ipBlock = await context.authRiskBlockRepository.findActive('ip', ip); + if (ipBlock) { + blocks.push(toAuthRiskBlockSummary(ipBlock)); + } + } + + return { + blocks, + }; +} + +export async function liftRiskBlock( + context: AppContext, + user: UserRecord, + requestContext: RefreshSessionRequestContext | null, + scopeType: 'phone' | 'ip', +): Promise { + if (scopeType === 'phone') { + if (!user.phoneNumber) { + throw badRequest('当前账号没有可解除的手机号保护'); + } + + const liftedBlocks = await context.authRiskBlockRepository.liftActive( + 'phone', + user.phoneNumber, + ); + if (liftedBlocks.length === 0) { + throw badRequest('当前没有生效中的手机号保护'); + } + + await writeAuthAuditLog(context, { + userId: user.id, + eventType: 'risk_unblock_phone', + detail: `已手动解除手机号 ${buildMaskedPhoneDisplay(user.phoneNumber)} 的保护`, + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + }); + + return { + ok: true, + }; + } + + const ip = requestContext?.ip ?? null; + if (!ip) { + throw badRequest('当前网络没有可解除的保护'); + } + + const liftedBlocks = await context.authRiskBlockRepository.liftActive('ip', ip); + if (liftedBlocks.length === 0) { + throw badRequest('当前没有生效中的网络保护'); + } + + await writeAuthAuditLog(context, { + userId: user.id, + eventType: 'risk_unblock_ip', + detail: '已手动解除当前网络保护', + ip, + userAgent: requestContext?.userAgent ?? null, + }); + + return { + ok: true, + }; +} + +export async function refreshAuthSession( + context: AppContext, + rawRefreshToken: string, +): Promise< + AuthRefreshResponse & { + refreshToken: string; + refreshExpiresAt: string; + } +> { + const refreshToken = rawRefreshToken.trim(); + if (!refreshToken) { + throw unauthorized('缺少刷新会话'); + } + + const refreshTokenHash = hashRefreshSessionToken(refreshToken); + const session = await context.userSessionRepository.findActiveByRefreshTokenHash( + refreshTokenHash, + ); + if (!session || session.revokedAt) { + throw unauthorized('刷新会话已失效,请重新登录'); + } + if (new Date(session.expiresAt).getTime() <= Date.now()) { + throw unauthorized('刷新会话已过期,请重新登录'); + } + + const user = await context.userRepository.findById(session.userId); + if (!user) { + throw unauthorized('用户不存在'); + } + if (user.accountStatus === 'disabled') { + throw unauthorized('账号已被禁用'); + } + + const nextRefreshToken = createRefreshSessionToken(); + const nextRefreshTokenHash = hashRefreshSessionToken(nextRefreshToken); + const nextExpiresAt = buildRefreshSessionExpiry(context.config); + const lastSeenAt = new Date().toISOString(); + + await context.userSessionRepository.rotate(session.id, { + refreshTokenHash: nextRefreshTokenHash, + expiresAt: nextExpiresAt, + lastSeenAt, + }); + + const accessPayload = await signUserAuthPayload(context, user); + return { + token: accessPayload.token, + refreshToken: nextRefreshToken, + refreshExpiresAt: nextExpiresAt, + }; +} + +export async function listUserSessions( + context: AppContext, + userId: string, + rawRefreshToken: string, +): Promise { + const sessions = await context.userSessionRepository.listActiveByUserId(userId); + const currentRefreshTokenHash = rawRefreshToken.trim() + ? hashRefreshSessionToken(rawRefreshToken.trim()) + : ''; + + return { + sessions: sessions.map( + (session) => + ({ + sessionId: session.id, + clientType: session.clientType, + clientLabel: buildSessionClientLabel(session), + userAgent: session.userAgent, + ipMasked: maskIpAddress(session.ip), + isCurrent: + Boolean(currentRefreshTokenHash) && + session.refreshTokenHash === currentRefreshTokenHash, + createdAt: session.createdAt, + lastSeenAt: session.lastSeenAt, + expiresAt: session.expiresAt, + }) satisfies AuthSessionSummary, + ), + }; +} + +export async function revokeRefreshSession( + context: AppContext, + rawRefreshToken: string, +) { + const refreshToken = rawRefreshToken.trim(); + if (!refreshToken) { + return; + } + + const refreshTokenHash = hashRefreshSessionToken(refreshToken); + const session = await context.userSessionRepository.findActiveByRefreshTokenHash( + refreshTokenHash, + ); + if (!session || session.revokedAt) { + return; + } + + await context.userSessionRepository.revoke(session.id); +} + +export async function revokeUserSession( + context: AppContext, + userId: string, + sessionId: string, + rawRefreshToken: string, + requestContext: RefreshSessionRequestContext | null = null, +): Promise { + const targetSession = await context.userSessionRepository.findById(sessionId); + if (!targetSession || targetSession.userId !== userId || targetSession.revokedAt) { + throw badRequest('目标登录设备不存在或已失效'); + } + + const currentRefreshTokenHash = rawRefreshToken.trim() + ? hashRefreshSessionToken(rawRefreshToken.trim()) + : ''; + if ( + currentRefreshTokenHash && + targetSession.refreshTokenHash === currentRefreshTokenHash + ) { + throw badRequest('当前设备请直接使用退出登录'); + } + + const revokedSession = await context.userSessionRepository.revokeByUserIdAndSessionId( + userId, + sessionId, + ); + if (!revokedSession) { + throw badRequest('目标登录设备不存在或已失效'); + } + + await writeAuthAuditLog(context, { + userId, + eventType: 'revoke_session', + detail: `已移除设备:${buildSessionClientLabel(revokedSession)}`, + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + metaJson: { + sessionId: revokedSession.id, + targetIp: revokedSession.ip, + targetUserAgent: revokedSession.userAgent, + }, + }); + + return { + ok: true, + }; +} + +export async function logoutAllUserSessions( + context: AppContext, + userId: string, + requestContext: RefreshSessionRequestContext | null = null, +): Promise { + const user = await context.userRepository.incrementTokenVersion(userId); + if (!user) { + throw unauthorized('用户不存在'); + } + + await context.userSessionRepository.revokeAllByUserId(userId); + await writeAuthAuditLog(context, { + userId, + eventType: 'logout_all', + detail: '已退出全部设备登录', + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + }); + return { + ok: true, + }; +} + export async function entryWithPassword( context: AppContext, usernameInput: string, password: string, -) { + requestContext: RefreshSessionRequestContext | null = null, +): Promise { const username = normalizeUsername(usernameInput); validateCredentials(username, password); - let user = context.userRepository.findByUsername(username); + let user = await context.userRepository.findByUsername(username); if (!user) { const passwordHash = await hashPassword(password); - user = context.userRepository.create(username, passwordHash); + user = await context.userRepository.create(username, passwordHash); } else { const isValid = await verifyPassword(user.passwordHash, password); if (!isValid) { @@ -41,29 +940,439 @@ export async function entryWithPassword( throw new Error('failed to resolve user after auth entry'); } - const token = await signAccessToken( - { - userId: user.id, - tokenVersion: user.tokenVersion, - }, - context.config, + await writeAuthAuditLog(context, { + userId: user.id, + eventType: 'password_login', + detail: '使用账号密码完成登录', + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + }); + + return signUserAuthPayload(context, user); +} + +export async function sendPhoneLoginCode( + context: AppContext, + phoneInput: string, + scene: 'login' | 'bind_phone' | 'change_phone' = 'login', + requestContext: RefreshSessionRequestContext | null = null, + captchaParams: { + captchaChallengeId?: string | null; + captchaAnswer?: string | null; + } = {}, +): Promise { + const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); + await enforceNoActiveRiskBlocks(context, { + phoneNumber: normalizedPhone.e164, + requestContext, + }); + await enforceCaptchaRequirement(context, { + scene, + phoneNumber: normalizedPhone.e164, + requestContext, + captchaChallengeId: captchaParams.captchaChallengeId, + captchaAnswer: captchaParams.captchaAnswer, + }); + await enforceSmsSendRateLimit( + context, + normalizedPhone.e164, + requestContext, ); + const result = await context.smsVerificationService.sendLoginCode( + normalizedPhone, + ); + await recordSmsAuthEvent(context, { + phoneNumber: normalizedPhone.e164, + scene, + action: 'send_code', + success: true, + requestContext, + }); return { - token, - user: { - id: user.id, - username: user.username, - }, + ok: true, + cooldownSeconds: result.cooldownSeconds, + expiresInSeconds: result.expiresInSeconds, + providerRequestId: result.providerRequestId, }; } -export async function logoutUser(context: AppContext, userId: string) { - const user = context.userRepository.incrementTokenVersion(userId); +export async function entryWithPhoneCode( + context: AppContext, + phoneInput: string, + verifyCodeInput: string, + requestContext: RefreshSessionRequestContext | null = null, +): Promise { + const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); + const verifyCode = validateSmsVerifyCode(verifyCodeInput); + + await enforceNoActiveRiskBlocks(context, { + phoneNumber: normalizedPhone.e164, + requestContext, + }); + await enforceSmsVerifyFailureLimit( + context, + normalizedPhone.e164, + requestContext, + ); + + try { + await context.smsVerificationService.verifyLoginCode( + normalizedPhone, + verifyCode, + ); + await recordSmsAuthEvent(context, { + phoneNumber: normalizedPhone.e164, + scene: 'login', + action: 'verify_code', + success: true, + requestContext, + }); + } catch (error) { + await recordSmsAuthEvent(context, { + phoneNumber: normalizedPhone.e164, + scene: 'login', + action: 'verify_code', + success: false, + requestContext, + }); + await applyRiskBlocksIfNeeded(context, { + phoneNumber: normalizedPhone.e164, + requestContext, + }); + throw error; + } + + let user = await context.userRepository.findByPhoneNumber( + normalizedPhone.e164, + ); + + if (!user) { + const passwordHash = await hashPassword(buildRandomPasswordSeed()); + user = await context.userRepository.createPhoneUser({ + username: buildSystemUsername('phone'), + passwordHash, + displayName: normalizedPhone.maskedNationalNumber, + phoneNumber: normalizedPhone.e164, + phoneVerifiedAt: new Date().toISOString(), + }); + } + + if (!user) { + throw new Error('failed to resolve user after phone auth entry'); + } + + await writeAuthAuditLog(context, { + userId: user.id, + eventType: 'phone_login', + detail: `使用手机号 ${normalizedPhone.maskedNationalNumber} 完成登录`, + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + }); + + return signUserAuthPayload(context, user); +} + +export async function startWechatLogin( + context: AppContext, + callbackUrl: string, + redirectPath: string, +): Promise { + const stateRecord = context.wechatAuthStates.create(redirectPath); + return { + authorizationUrl: context.wechatAuthService.buildAuthorizationUrl({ + callbackUrl, + state: stateRecord.state, + }), + }; +} + +export async function resolveWechatCallback( + context: AppContext, + params: { + code?: string | null; + mockCode?: string | null; + }, + requestContext: RefreshSessionRequestContext | null = null, +) { + const profile = await context.wechatAuthService.resolveCallbackProfile(params); + + let identity = await context.authIdentityRepository.findWechatIdentityByProfile( + { + providerUid: profile.providerUid, + providerUnionId: profile.providerUnionId, + }, + ); + let user = identity + ? await context.userRepository.findById(identity.userId) + : null; + + if (!user) { + const passwordHash = await hashPassword(buildRandomPasswordSeed()); + user = await context.userRepository.createWechatPendingUser({ + username: buildSystemUsername('wechat'), + passwordHash, + displayName: profile.displayName?.trim() || '微信旅人', + }); + if (!user) { + throw new Error('failed to create pending wechat user'); + } + + identity = await context.authIdentityRepository.createWechatIdentity({ + userId: user.id, + providerUid: profile.providerUid, + providerUnionId: profile.providerUnionId, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + metaJson: profile.metaJson, + }); + } + + if (!identity || !user) { + throw new Error('failed to resolve wechat auth identity'); + } + + await writeAuthAuditLog(context, { + userId: user.id, + eventType: 'wechat_login', + detail: '使用微信身份完成登录', + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + }); + + return signUserAuthPayload(context, user); +} + +export async function bindWechatPhone( + context: AppContext, + userId: string, + phoneInput: string, + verifyCodeInput: string, + requestContext: RefreshSessionRequestContext | null = null, +): Promise { + const currentUser = await context.userRepository.findById(userId); + if (!currentUser) { + throw unauthorized('用户不存在'); + } + if (currentUser.accountStatus !== 'pending_bind_phone') { + throw badRequest('当前账号无需绑定手机号'); + } + + const identities = await context.authIdentityRepository.listByUserId(userId); + const hasWechatIdentity = identities.some((identity) => identity.provider === 'wechat'); + if (!hasWechatIdentity) { + throw badRequest('当前账号缺少微信身份,无法绑定手机号'); + } + + const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); + const verifyCode = validateSmsVerifyCode(verifyCodeInput); + + await enforceNoActiveRiskBlocks(context, { + phoneNumber: normalizedPhone.e164, + requestContext, + }); + await enforceSmsVerifyFailureLimit( + context, + normalizedPhone.e164, + requestContext, + ); + + try { + await context.smsVerificationService.verifyLoginCode( + normalizedPhone, + verifyCode, + ); + await recordSmsAuthEvent(context, { + phoneNumber: normalizedPhone.e164, + scene: 'bind_phone', + action: 'verify_code', + success: true, + requestContext, + }); + } catch (error) { + await recordSmsAuthEvent(context, { + phoneNumber: normalizedPhone.e164, + scene: 'bind_phone', + action: 'verify_code', + success: false, + requestContext, + }); + await applyRiskBlocksIfNeeded(context, { + phoneNumber: normalizedPhone.e164, + requestContext, + }); + throw error; + } + + const existingPhoneUser = await context.userRepository.findByPhoneNumber( + normalizedPhone.e164, + ); + + if (existingPhoneUser && existingPhoneUser.id !== currentUser.id) { + await context.db.query('BEGIN'); + try { + await context.authIdentityRepository.moveWechatIdentitiesToUser( + currentUser.id, + existingPhoneUser.id, + ); + await context.userRepository.deleteUser(currentUser.id); + await context.db.query('COMMIT'); + } catch (error) { + await context.db.query('ROLLBACK'); + throw error; + } + + const mergedUser = await context.userRepository.findById(existingPhoneUser.id); + if (!mergedUser) { + throw new Error('failed to resolve merged phone user'); + } + + await writeAuthAuditLog(context, { + userId: mergedUser.id, + eventType: 'wechat_bind_phone', + detail: `已将微信身份绑定到手机号 ${normalizedPhone.maskedNationalNumber}`, + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + }); + + return signUserAuthPayload(context, mergedUser); + } + + const activatedUser = await context.userRepository.activatePendingWechatUser( + currentUser.id, + { + displayName: + currentUser.displayName?.trim() || normalizedPhone.maskedNationalNumber, + phoneNumber: normalizedPhone.e164, + phoneVerifiedAt: new Date().toISOString(), + }, + ); + + if (!activatedUser) { + throw new Error('failed to activate pending wechat user'); + } + + await writeAuthAuditLog(context, { + userId: activatedUser.id, + eventType: 'wechat_bind_phone', + detail: `已绑定手机号 ${normalizedPhone.maskedNationalNumber}`, + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + }); + + return signUserAuthPayload(context, activatedUser); +} + +export async function changeUserPhone( + context: AppContext, + userId: string, + phoneInput: string, + verifyCodeInput: string, + requestContext: RefreshSessionRequestContext | null = null, +): Promise { + const currentUser = await context.userRepository.findById(userId); + if (!currentUser) { + throw unauthorized('用户不存在'); + } + if (currentUser.accountStatus !== 'active') { + throw badRequest('当前账号状态不允许更换手机号'); + } + + const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); + const verifyCode = validateSmsVerifyCode(verifyCodeInput); + + if (currentUser.phoneNumber === normalizedPhone.e164) { + throw badRequest('新手机号不能与当前手机号相同'); + } + + const existingPhoneUser = await context.userRepository.findByPhoneNumber( + normalizedPhone.e164, + ); + if (existingPhoneUser && existingPhoneUser.id !== currentUser.id) { + throw conflict('该手机号已绑定其他账号'); + } + + await enforceNoActiveRiskBlocks(context, { + phoneNumber: normalizedPhone.e164, + requestContext, + }); + await enforceSmsVerifyFailureLimit( + context, + normalizedPhone.e164, + requestContext, + ); + + try { + await context.smsVerificationService.verifyLoginCode( + normalizedPhone, + verifyCode, + ); + await recordSmsAuthEvent(context, { + phoneNumber: normalizedPhone.e164, + scene: 'change_phone', + action: 'verify_code', + success: true, + requestContext, + }); + } catch (error) { + await recordSmsAuthEvent(context, { + phoneNumber: normalizedPhone.e164, + scene: 'change_phone', + action: 'verify_code', + success: false, + requestContext, + }); + await applyRiskBlocksIfNeeded(context, { + phoneNumber: normalizedPhone.e164, + requestContext, + }); + throw error; + } + + const updatedUser = await context.userRepository.updatePhoneInfo(userId, { + phoneNumber: normalizedPhone.e164, + phoneVerifiedAt: new Date().toISOString(), + displayName: resolveDisplayNameAfterPhoneChange( + currentUser, + normalizedPhone.e164, + ), + }); + + if (!updatedUser) { + throw new Error('failed to update user phone'); + } + + await writeAuthAuditLog(context, { + userId: updatedUser.id, + eventType: 'change_phone', + detail: `已更换手机号为 ${normalizedPhone.maskedNationalNumber}`, + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + }); + + return { + user: await toAuthUser(context, updatedUser), + }; +} + +export async function logoutUser( + context: AppContext, + userId: string, + requestContext: RefreshSessionRequestContext | null = null, +): Promise { + const user = await context.userRepository.incrementTokenVersion(userId); if (!user) { throw unauthorized('用户不存在'); } + await writeAuthAuditLog(context, { + userId, + eventType: 'logout', + detail: '已退出当前设备登录', + ip: requestContext?.ip ?? null, + userAgent: requestContext?.userAgent ?? null, + }); + return { ok: true as const, }; diff --git a/server-node/src/auth/phoneNumber.ts b/server-node/src/auth/phoneNumber.ts new file mode 100644 index 00000000..54dca8a2 --- /dev/null +++ b/server-node/src/auth/phoneNumber.ts @@ -0,0 +1,55 @@ +import { badRequest } from '../errors.js'; + +export type NormalizedPhoneNumber = { + countryCode: string; + nationalNumber: string; + e164: string; + maskedNationalNumber: string; +}; + +function stripPhoneInput(input: string) { + return input.replace(/[^\d+]/gu, '').trim(); +} + +export function maskNationalPhoneNumber(phoneNumber: string) { + if (phoneNumber.length < 7) { + return phoneNumber; + } + + return `${phoneNumber.slice(0, 3)}****${phoneNumber.slice(-4)}`; +} + +export function normalizeMainlandChinaPhoneNumber( + phoneInput: string, +): NormalizedPhoneNumber { + const trimmed = stripPhoneInput(phoneInput); + if (!trimmed) { + throw badRequest('请输入手机号'); + } + + let nationalNumber = trimmed; + if (nationalNumber.startsWith('+86')) { + nationalNumber = nationalNumber.slice(3); + } else if (nationalNumber.startsWith('86') && nationalNumber.length === 13) { + nationalNumber = nationalNumber.slice(2); + } + + if (!/^1\d{10}$/u.test(nationalNumber)) { + throw badRequest('请输入正确的中国大陆手机号'); + } + + return { + countryCode: '86', + nationalNumber, + e164: `+86${nationalNumber}`, + maskedNationalNumber: maskNationalPhoneNumber(nationalNumber), + }; +} + +export function validateSmsVerifyCode(verifyCode: string) { + const normalizedVerifyCode = verifyCode.trim(); + if (!/^[A-Za-z0-9]{4,8}$/u.test(normalizedVerifyCode)) { + throw badRequest('请输入正确的验证码'); + } + return normalizedVerifyCode; +} diff --git a/server-node/src/auth/refreshSessionCookie.ts b/server-node/src/auth/refreshSessionCookie.ts new file mode 100644 index 00000000..ad51d8f0 --- /dev/null +++ b/server-node/src/auth/refreshSessionCookie.ts @@ -0,0 +1,96 @@ +import crypto from 'node:crypto'; + +import type { Request, Response } from 'express'; + +import type { AppConfig } from '../config.js'; + +export type RefreshSessionRequestContext = { + clientType: string; + userAgent: string | null; + ip: string | null; +}; + +function buildCookieParts( + config: AppConfig, + value: string, + options: { + maxAgeSeconds: number; + }, +) { + const parts = [ + `${config.authSession.refreshCookieName}=${encodeURIComponent(value)}`, + `Path=${config.authSession.refreshCookiePath}`, + 'HttpOnly', + `SameSite=${config.authSession.refreshCookieSameSite}`, + `Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`, + ]; + + if (config.authSession.refreshCookieSecure) { + parts.push('Secure'); + } + + return parts.join('; '); +} + +export function hashRefreshSessionToken(token: string) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +export function createRefreshSessionToken() { + return crypto.randomBytes(32).toString('base64url'); +} + +export function setRefreshSessionCookie( + response: Response, + config: AppConfig, + token: string, + maxAgeSeconds: number, +) { + response.setHeader( + 'Set-Cookie', + buildCookieParts(config, token, { + maxAgeSeconds, + }), + ); +} + +export function clearRefreshSessionCookie(response: Response, config: AppConfig) { + response.setHeader( + 'Set-Cookie', + buildCookieParts(config, '', { + maxAgeSeconds: 0, + }), + ); +} + +export function readRefreshSessionToken(request: Request, config: AppConfig) { + const cookieHeader = request.header('cookie')?.trim() || ''; + if (!cookieHeader) { + return ''; + } + + const cookieEntries = cookieHeader.split(';'); + for (const entry of cookieEntries) { + const [rawName, ...valueParts] = entry.split('='); + const name = rawName?.trim(); + if (name !== config.authSession.refreshCookieName) { + continue; + } + + const rawValue = valueParts.join('=').trim(); + return rawValue ? decodeURIComponent(rawValue) : ''; + } + + return ''; +} + +export function buildRefreshSessionRequestContext( + request: Request, +): RefreshSessionRequestContext { + const userAgent = request.header('user-agent')?.trim() || null; + return { + clientType: 'browser', + userAgent, + ip: request.ip || null, + }; +} diff --git a/server-node/src/auth/token.ts b/server-node/src/auth/token.ts index 98d5e15f..5033f6d4 100644 --- a/server-node/src/auth/token.ts +++ b/server-node/src/auth/token.ts @@ -1,8 +1,24 @@ +import crypto from 'node:crypto'; + import { jwtVerify, SignJWT } from 'jose'; import type { AppConfig } from '../config.js'; import { unauthorized } from '../errors.js'; +if (!globalThis.crypto?.subtle) { + Object.assign(globalThis, { + crypto: crypto.webcrypto, + }); +} + +if (typeof globalThis.structuredClone !== 'function') { + Object.assign(globalThis, { + structuredClone(value: T) { + return JSON.parse(JSON.stringify(value)) as T; + }, + }); +} + export type AccessTokenClaims = { userId: string; tokenVersion: number; @@ -21,6 +37,7 @@ export async function signAccessToken( .setSubject(claims.userId) .setIssuer(config.jwtIssuer) .setIssuedAt() + .setExpirationTime(config.jwtExpiresIn) .sign(getSecret(config)); } diff --git a/server-node/src/bridges/legacyBuildRuntimeBridge.ts b/server-node/src/bridges/legacyBuildRuntimeBridge.ts new file mode 100644 index 00000000..63310ec3 --- /dev/null +++ b/server-node/src/bridges/legacyBuildRuntimeBridge.ts @@ -0,0 +1,6 @@ +// Temporary bridge for legacy pure build calculation logic from src/**. +export { getEquipmentBonuses } from '../modules/runtime/runtimeEquipmentModule.js'; +export { + getPlayerBuildDamageBreakdown, + resolvePlayerOutgoingDamageResult, +} from '../modules/runtime/runtimeBuildModule.js'; diff --git a/server-node/src/bridges/legacyInventoryRuntimeBridge.ts b/server-node/src/bridges/legacyInventoryRuntimeBridge.ts new file mode 100644 index 00000000..3c9e1cbb --- /dev/null +++ b/server-node/src/bridges/legacyInventoryRuntimeBridge.ts @@ -0,0 +1,25 @@ +// Temporary bridge for legacy pure inventory/build mutation logic from src/**. +export { appendBuildBuffs } from '../modules/runtime/runtimeBuildModule.js'; +export { + applyEquipmentLoadoutToState, + getEquipmentSlotFromItem, + getEquipmentSlotLabel, +} from '../modules/runtime/runtimeEquipmentModule.js'; +export { + buildForgeSuccessText, + executeDismantleItem, + executeForgeRecipe, + executeReforgeItem, + getForgeRecipeViews, + getReforgeCostView, +} from '../modules/runtime/runtimeForgeModule.js'; +export { + buildInventoryUseResultText, + isInventoryItemUsable, + resolveInventoryItemUseEffect, +} from '../modules/runtime/runtimeInventoryEffectsModule.js'; +export { + addInventoryItems, + incrementGameRuntimeStats, + removeInventoryItem, +} from '../modules/runtime/runtimeStatePrimitives.js'; diff --git a/server-node/src/bridges/legacyNpcTask6Bridge.ts b/server-node/src/bridges/legacyNpcTask6Bridge.ts new file mode 100644 index 00000000..697ebc56 --- /dev/null +++ b/server-node/src/bridges/legacyNpcTask6Bridge.ts @@ -0,0 +1,26 @@ +// Temporary bridge for legacy pure NPC inventory/task6 logic from src/**. +export { buildRelationState } from '../modules/runtime/runtimeStatePrimitives.js'; +export { + formatCurrency, + getNpcBuybackPrice, + getNpcPurchasePrice, +} from '../modules/runtime/runtimeEconomyPrimitives.js'; +export { + applyStoryChoiceToStanceProfile, + buildInitialNpcState, + buildNpcGiftCommitActionText, + buildNpcGiftResultText, + buildNpcTradeTransactionActionText, + buildNpcTradeTransactionResultText, + getGiftCandidates, + syncNpcTradeInventory, +} from '../modules/npc/npcTask6Primitives.js'; +export { + markNpcFirstMeaningfulContactResolved, + normalizeNpcPersistentState, +} from '../modules/runtime/runtimeNpcStatePrimitives.js'; +export { appendStoryEngineCarrierMemory } from '../modules/runtime/runtimeNarrativeMemory.js'; +export { + addInventoryItems, + removeInventoryItem, +} from '../modules/runtime/runtimeStatePrimitives.js'; diff --git a/server-node/src/bridges/legacyQuestProgressBridge.ts b/server-node/src/bridges/legacyQuestProgressBridge.ts new file mode 100644 index 00000000..2a62f1bb --- /dev/null +++ b/server-node/src/bridges/legacyQuestProgressBridge.ts @@ -0,0 +1,15 @@ +// Temporary bridge for legacy pure quest progression logic from src/**. +export { + acceptQuest, + buildQuestAcceptResultText, + buildQuestForEncounter, + buildQuestTurnInResultText, + applyQuestProgressSignal, + getQuestForIssuer, + buildChapterQuestForScene, + findQuestById, + isQuestReadyToClaim, + markQuestCompletionNotified, + markQuestTurnedIn, + normalizeQuestLogEntries, +} from '../modules/quest/runtimeQuestModule.js'; diff --git a/server-node/src/bridges/legacyQuestRuntimeBridge.ts b/server-node/src/bridges/legacyQuestRuntimeBridge.ts new file mode 100644 index 00000000..1564f469 --- /dev/null +++ b/server-node/src/bridges/legacyQuestRuntimeBridge.ts @@ -0,0 +1,9 @@ +// Temporary bridge for legacy pure quest runtime composition from src/**. +export { + buildFallbackQuestIntent, + compileQuestIntentToQuest, + evaluateQuestOpportunity, + buildQuestIntentPrompt, + buildQuestGenerationContextFromState, + QUEST_INTENT_SYSTEM_PROMPT, +} from '../modules/quest/runtimeQuestModule.js'; diff --git a/server-node/src/bridges/legacyRuntimeItemBridge.ts b/server-node/src/bridges/legacyRuntimeItemBridge.ts new file mode 100644 index 00000000..235917b8 --- /dev/null +++ b/server-node/src/bridges/legacyRuntimeItemBridge.ts @@ -0,0 +1,6 @@ +// Temporary bridge for legacy pure runtime item composition from src/**. +export { + buildRuntimeItemAiIntent, + buildRuntimeItemIntentPrompt, + RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, +} from '../modules/runtime-item/runtimeItemModule.js'; diff --git a/server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts b/server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts new file mode 100644 index 00000000..7e624643 --- /dev/null +++ b/server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts @@ -0,0 +1,8 @@ +// Temporary bridge for legacy pure runtime item resolution logic from src/**. +export { + buildLooseRuntimeItemGenerationContext, + buildQuestRuntimeItemGenerationContext, + buildDirectedRuntimeReward, + buildRuntimeInventoryStock, + flattenDirectedRuntimeRewardItems, +} from '../modules/runtime-item/runtimeItemModule.js'; diff --git a/server-node/src/bridges/legacyTreasureRuntimeBridge.ts b/server-node/src/bridges/legacyTreasureRuntimeBridge.ts new file mode 100644 index 00000000..01d15efa --- /dev/null +++ b/server-node/src/bridges/legacyTreasureRuntimeBridge.ts @@ -0,0 +1,3 @@ +// Temporary bridge for legacy pure treasure/runtime item logic from src/**. +export { buildTreasureResultText } from '../modules/runtime/runtimeTreasureTexts.js'; +export { resolveTreasureReward } from '../modules/runtime-item/runtimeTreasureModule.js'; diff --git a/server-node/src/config.ts b/server-node/src/config.ts index 15bbc14b..9a806f5c 100644 --- a/server-node/src/config.ts +++ b/server-node/src/config.ts @@ -7,9 +7,12 @@ export type AppConfig = { publicDir: string; logsDir: string; dataDir: string; - sqlitePath: string; + rawEnv: Record; + databaseUrl: string; serverAddr: string; logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; + editorApiEnabled: boolean; + assetsApiEnabled: boolean; jwtSecret: string; jwtExpiresIn: string; jwtIssuer: string; @@ -24,6 +27,59 @@ export type AppConfig = { imageModel: string; requestTimeoutMs: number; }; + smsAuth: { + enabled: boolean; + provider: 'aliyun' | 'mock'; + endpoint: string; + accessKeyId: string; + accessKeySecret: string; + signName: string; + templateCode: string; + templateParamKey: string; + countryCode: string; + schemeName: string; + codeLength: number; + codeType: number; + validTimeSeconds: number; + intervalSeconds: number; + duplicatePolicy: number; + caseAuthPolicy: number; + returnVerifyCode: boolean; + mockVerifyCode: string; + maxSendPerPhonePerDay: number; + maxSendPerIpPerHour: number; + maxVerifyFailuresPerPhonePerHour: number; + maxVerifyFailuresPerIpPerHour: number; + captchaTtlSeconds: number; + captchaTriggerVerifyFailuresPerPhone: number; + captchaTriggerVerifyFailuresPerIp: number; + blockPhoneFailureThreshold: number; + blockIpFailureThreshold: number; + blockPhoneDurationMinutes: number; + blockIpDurationMinutes: number; + }; + wechatAuth: { + enabled: boolean; + provider: 'wechat' | 'mock'; + appId: string; + appSecret: string; + authorizeEndpoint: string; + accessTokenEndpoint: string; + userInfoEndpoint: string; + callbackPath: string; + defaultRedirectPath: string; + mockUserId: string; + mockUnionId: string; + mockDisplayName: string; + mockAvatarUrl: string; + }; + authSession: { + refreshCookieName: string; + refreshSessionTtlDays: number; + refreshCookieSecure: boolean; + refreshCookieSameSite: 'Lax' | 'Strict' | 'None'; + refreshCookiePath: string; + }; }; type LoadConfigOptions = { @@ -101,11 +157,80 @@ function readPositiveInt( return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; } +function readIntInRange( + env: Record, + key: string, + fallback: number, + options: { + min: number; + max: number; + }, +) { + const parsed = Number(env[key]); + if (!Number.isFinite(parsed)) { + return fallback; + } + + const rounded = Math.round(parsed); + if (rounded < options.min || rounded > options.max) { + return fallback; + } + + return rounded; +} + +function readBoolean( + env: Record, + key: string, + fallback: boolean, +) { + const value = env[key]?.trim().toLowerCase(); + if (!value) { + return fallback; + } + if (value === '1' || value === 'true' || value === 'yes' || value === 'on') { + return true; + } + if (value === '0' || value === 'false' || value === 'no' || value === 'off') { + return false; + } + return fallback; +} + export function loadConfig(options: LoadConfigOptions = {}): AppConfig { const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot(); const env = readMergedEnv(projectRoot, options.env ?? process.env); const logsDir = path.join(projectRoot, 'server-node', 'logs'); const dataDir = path.join(projectRoot, 'server-node', 'data'); + const defaultEditorApiEnabled = readString(env, 'NODE_ENV', 'development') !== 'production'; + const editorApiEnabled = readBoolean( + env, + 'EDITOR_API_ENABLED', + defaultEditorApiEnabled, + ); + const smsProvider = readString( + env, + 'SMS_AUTH_PROVIDER', + readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'aliyun', + ) as AppConfig['smsAuth']['provider']; + const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', ''); + const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', ''); + const defaultSmsEnabled = + smsProvider === 'mock' || Boolean(smsAccessKeyId && smsAccessKeySecret); + const wechatProvider = readString( + env, + 'WECHAT_AUTH_PROVIDER', + readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'wechat', + ) as AppConfig['wechatAuth']['provider']; + const wechatAppId = readString(env, 'WECHAT_APP_ID', ''); + const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', ''); + const defaultWechatEnabled = + wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret); + const refreshSameSite = readString( + env, + 'AUTH_REFRESH_COOKIE_SAME_SITE', + 'Lax', + ); return { nodeEnv: readString(env, 'NODE_ENV', 'development'), @@ -113,15 +238,26 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { publicDir: path.join(projectRoot, 'public'), logsDir, dataDir, - sqlitePath: readString( + rawEnv: Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value]] : [], + ), + ), + databaseUrl: readString( env, - 'SQLITE_PATH', - path.join(dataDir, 'genarrative.sqlite'), + 'DATABASE_URL', + 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative', ), serverAddr: readString(env, 'NODE_SERVER_ADDR', ':8081'), logLevel: readString(env, 'LOG_LEVEL', 'info') as AppConfig['logLevel'], + editorApiEnabled, + assetsApiEnabled: readBoolean( + env, + 'ASSETS_API_ENABLED', + editorApiEnabled, + ), jwtSecret: readString(env, 'JWT_SECRET', 'genarrative-dev-secret'), - jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '7d'), + jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '2h'), jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'), llm: { baseUrl: readString( @@ -158,5 +294,177 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { 150000, ), }, + smsAuth: { + enabled: readBoolean(env, 'SMS_AUTH_ENABLED', defaultSmsEnabled), + provider: smsProvider, + endpoint: readString( + env, + 'ALIYUN_SMS_ENDPOINT', + 'dypnsapi.aliyuncs.com', + ), + accessKeyId: smsAccessKeyId, + accessKeySecret: smsAccessKeySecret, + signName: readString( + env, + 'ALIYUN_SMS_SIGN_NAME', + '速通互联验证码', + ), + templateCode: readString(env, 'ALIYUN_SMS_TEMPLATE_CODE', '100001'), + templateParamKey: readString( + env, + 'ALIYUN_SMS_TEMPLATE_PARAM_KEY', + 'code', + ), + countryCode: readString(env, 'ALIYUN_SMS_COUNTRY_CODE', '86'), + schemeName: readString(env, 'ALIYUN_SMS_SCHEME_NAME', ''), + codeLength: readIntInRange(env, 'ALIYUN_SMS_CODE_LENGTH', 6, { + min: 4, + max: 8, + }), + codeType: readIntInRange(env, 'ALIYUN_SMS_CODE_TYPE', 1, { + min: 1, + max: 7, + }), + validTimeSeconds: readPositiveInt( + env, + 'ALIYUN_SMS_VALID_TIME_SECONDS', + 300, + ), + intervalSeconds: readPositiveInt( + env, + 'ALIYUN_SMS_INTERVAL_SECONDS', + 60, + ), + duplicatePolicy: readIntInRange( + env, + 'ALIYUN_SMS_DUPLICATE_POLICY', + 1, + { min: 1, max: 2 }, + ), + caseAuthPolicy: readIntInRange( + env, + 'ALIYUN_SMS_CASE_AUTH_POLICY', + 1, + { min: 1, max: 2 }, + ), + returnVerifyCode: readBoolean( + env, + 'ALIYUN_SMS_RETURN_VERIFY_CODE', + false, + ), + mockVerifyCode: readString(env, 'SMS_AUTH_MOCK_VERIFY_CODE', '123456'), + maxSendPerPhonePerDay: readPositiveInt( + env, + 'SMS_AUTH_MAX_SEND_PER_PHONE_PER_DAY', + 20, + ), + maxSendPerIpPerHour: readPositiveInt( + env, + 'SMS_AUTH_MAX_SEND_PER_IP_PER_HOUR', + 30, + ), + maxVerifyFailuresPerPhonePerHour: readPositiveInt( + env, + 'SMS_AUTH_MAX_VERIFY_FAILURES_PER_PHONE_PER_HOUR', + 12, + ), + maxVerifyFailuresPerIpPerHour: readPositiveInt( + env, + 'SMS_AUTH_MAX_VERIFY_FAILURES_PER_IP_PER_HOUR', + 24, + ), + captchaTtlSeconds: readPositiveInt( + env, + 'SMS_AUTH_CAPTCHA_TTL_SECONDS', + 180, + ), + captchaTriggerVerifyFailuresPerPhone: readPositiveInt( + env, + 'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_PHONE', + 3, + ), + captchaTriggerVerifyFailuresPerIp: readPositiveInt( + env, + 'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_IP', + 5, + ), + blockPhoneFailureThreshold: readPositiveInt( + env, + 'SMS_AUTH_BLOCK_PHONE_FAILURE_THRESHOLD', + 6, + ), + blockIpFailureThreshold: readPositiveInt( + env, + 'SMS_AUTH_BLOCK_IP_FAILURE_THRESHOLD', + 10, + ), + blockPhoneDurationMinutes: readPositiveInt( + env, + 'SMS_AUTH_BLOCK_PHONE_DURATION_MINUTES', + 30, + ), + blockIpDurationMinutes: readPositiveInt( + env, + 'SMS_AUTH_BLOCK_IP_DURATION_MINUTES', + 30, + ), + }, + wechatAuth: { + enabled: readBoolean(env, 'WECHAT_AUTH_ENABLED', defaultWechatEnabled), + provider: wechatProvider, + appId: wechatAppId, + appSecret: wechatAppSecret, + authorizeEndpoint: readString( + env, + 'WECHAT_AUTHORIZE_ENDPOINT', + 'https://open.weixin.qq.com/connect/qrconnect', + ), + accessTokenEndpoint: readString( + env, + 'WECHAT_ACCESS_TOKEN_ENDPOINT', + 'https://api.weixin.qq.com/sns/oauth2/access_token', + ), + userInfoEndpoint: readString( + env, + 'WECHAT_USER_INFO_ENDPOINT', + 'https://api.weixin.qq.com/sns/userinfo', + ), + callbackPath: readString( + env, + 'WECHAT_CALLBACK_PATH', + '/api/auth/wechat/callback', + ), + defaultRedirectPath: readString(env, 'WECHAT_REDIRECT_PATH', '/'), + mockUserId: readString(env, 'WECHAT_MOCK_USER_ID', 'mock_wechat_user'), + mockUnionId: readString(env, 'WECHAT_MOCK_UNION_ID', 'mock_wechat_union'), + mockDisplayName: readString(env, 'WECHAT_MOCK_DISPLAY_NAME', '微信旅人'), + mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''), + }, + authSession: { + refreshCookieName: readString( + env, + 'AUTH_REFRESH_COOKIE_NAME', + 'genarrative_refresh_session', + ), + refreshSessionTtlDays: readPositiveInt( + env, + 'AUTH_REFRESH_SESSION_TTL_DAYS', + 30, + ), + refreshCookieSecure: readBoolean( + env, + 'AUTH_REFRESH_COOKIE_SECURE', + readString(env, 'NODE_ENV', 'development') === 'production', + ), + refreshCookieSameSite: + refreshSameSite === 'None' || refreshSameSite === 'Strict' + ? (refreshSameSite as AppConfig['authSession']['refreshCookieSameSite']) + : 'Lax', + refreshCookiePath: readString( + env, + 'AUTH_REFRESH_COOKIE_PATH', + '/api/auth', + ), + }, }; } diff --git a/server-node/src/context.ts b/server-node/src/context.ts index 9d64b375..3e3641d4 100644 --- a/server-node/src/context.ts +++ b/server-node/src/context.ts @@ -2,17 +2,35 @@ import type { Logger } from 'pino'; import type { AppConfig } from './config.js'; import type { AppDatabase } from './db.js'; +import { AuthIdentityRepository } from './repositories/authIdentityRepository.js'; +import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js'; +import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js'; import { RuntimeRepository } from './repositories/runtimeRepository.js'; +import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js'; import { UserRepository } from './repositories/userRepository.js'; +import { UserSessionRepository } from './repositories/userSessionRepository.js'; import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; +import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; import { UpstreamLlmClient } from './services/llmClient.js'; +import type { SmsVerificationService } from './services/smsVerificationService.js'; +import type { WechatAuthService } from './services/wechatAuthService.js'; +import { WechatAuthStateStore } from './services/wechatAuthStateStore.js'; export type AppContext = { config: AppConfig; logger: Logger; db: AppDatabase; userRepository: UserRepository; + authIdentityRepository: AuthIdentityRepository; + authAuditLogRepository: AuthAuditLogRepository; + authRiskBlockRepository: AuthRiskBlockRepository; + smsAuthEventRepository: SmsAuthEventRepository; + userSessionRepository: UserSessionRepository; runtimeRepository: RuntimeRepository; llmClient: UpstreamLlmClient; customWorldSessions: CustomWorldSessionStore; + smsVerificationService: SmsVerificationService; + wechatAuthService: WechatAuthService; + wechatAuthStates: WechatAuthStateStore; + captchaChallenges: CaptchaChallengeStore; }; diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts new file mode 100644 index 00000000..303f8671 --- /dev/null +++ b/server-node/src/db.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; + +import type { AppConfig } from './config.js'; +import { createDatabase, listAppliedMigrations } from './db.js'; + +function createTestConfig(databaseUrl: string): AppConfig { + const projectRoot = path.resolve(process.cwd(), '..'); + + return { + nodeEnv: 'test', + projectRoot, + publicDir: path.join(projectRoot, 'public'), + logsDir: path.join(projectRoot, 'server-node', 'logs'), + dataDir: path.join(projectRoot, 'server-node', 'data'), + rawEnv: {}, + databaseUrl, + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test-secret', + jwtExpiresIn: '7d', + jwtIssuer: 'genarrative-server-node-test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: true, + provider: 'mock', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: '', + accessKeySecret: '', + signName: 'Test Sign', + templateCode: '100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: true, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', + accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', + userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', + callbackPath: '/api/auth/wechat/callback', + defaultRedirectPath: '/', + mockUserId: 'mock_wechat_user', + mockUnionId: 'mock_wechat_union', + mockDisplayName: '微信旅人', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'genarrative_refresh_session', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/api/auth', + }, + }; +} + +test('createDatabase applies runtime baseline migrations for pg-mem', async () => { + const db = await createDatabase( + createTestConfig('pg-mem://genarrative-db-test'), + ); + + try { + const migrations = await listAppliedMigrations(db); + assert.deepEqual( + migrations.map((migration) => migration.id), + [ + '20260408_001_runtime_postgres_baseline', + '20260408_002_allow_null_current_story_snapshot', + '20260409_003_phone_auth_user_extensions', + '20260409_004_auth_identities_and_account_status', + '20260409_005_user_sessions', + '20260409_006_auth_audit_logs', + '20260409_007_sms_auth_events', + '20260409_008_auth_risk_blocks', + ], + ); + + const tablesResult = await db.query<{ tableName: string }>( + `SELECT table_name AS "tableName" + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + 'schema_migrations', + 'users', + 'auth_identities', + 'auth_audit_logs', + 'auth_risk_blocks', + 'sms_auth_events', + 'user_sessions', + 'save_snapshots', + 'runtime_settings', + 'custom_world_profiles' + ) + ORDER BY table_name`, + ); + + assert.deepEqual( + tablesResult.rows.map((row) => row.tableName), + [ + 'auth_audit_logs', + 'auth_identities', + 'auth_risk_blocks', + 'custom_world_profiles', + 'runtime_settings', + 'save_snapshots', + 'schema_migrations', + 'sms_auth_events', + 'user_sessions', + 'users', + ], + ); + } finally { + await db.close(); + } +}); + +test('createDatabase rejects non-postgresql database urls', async () => { + await assert.rejects( + () => + createDatabase( + createTestConfig( + 'mysql://root:root@127.0.0.1:3306/genarrative', + ), + ), + /DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接/u, + ); +}); diff --git a/server-node/src/db.ts b/server-node/src/db.ts index 5422f3c8..c8ddc464 100644 --- a/server-node/src/db.ts +++ b/server-node/src/db.ts @@ -1,57 +1,168 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import Database from 'better-sqlite3'; +import { Pool, type QueryResult, type QueryResultRow } from 'pg'; import type { AppConfig } from './config.js'; +import { databaseMigrations } from './db/migrations.js'; -const schemaSql = ` -CREATE TABLE IF NOT EXISTS users ( +const migrationTableSql = ` +CREATE TABLE IF NOT EXISTS schema_migrations ( id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - token_version INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS save_snapshots ( - user_id TEXT PRIMARY KEY, - version INTEGER NOT NULL, - saved_at TEXT NOT NULL, - bottom_tab TEXT NOT NULL, - game_state_json TEXT NOT NULL, - current_story_json TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS runtime_settings ( - user_id TEXT PRIMARY KEY, - music_volume REAL NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS custom_world_profiles ( - user_id TEXT NOT NULL, - profile_id TEXT NOT NULL, - payload_json TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (user_id, profile_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); + name TEXT NOT NULL, + applied_at TEXT NOT NULL +) `; -export type AppDatabase = Database.Database; +type MigrationRow = QueryResultRow & { + id: string; + name: string; + appliedAt: string; +}; -export function createDatabase(config: AppConfig) { - const sqliteDir = path.dirname(config.sqlitePath); - fs.mkdirSync(sqliteDir, { recursive: true }); +export type AppDatabase = { + query( + text: string, + params?: readonly unknown[], + ): Promise>; + close(): Promise; +}; - const db = new Database(config.sqlitePath); - db.pragma('journal_mode = WAL'); - db.pragma('foreign_keys = ON'); - db.exec(schemaSql); +type QueryablePool = Pick; + +function wrapPool(pool: QueryablePool): AppDatabase { + return { + query( + text: string, + params: readonly unknown[] = [], + ) { + return pool.query(text, [...params]); + }, + async close() { + await pool.end(); + }, + }; +} + +function validateDatabaseUrl(databaseUrl: string) { + const trimmed = databaseUrl.trim(); + + if (!trimmed) { + throw new Error('DATABASE_URL 不能为空'); + } + + if (trimmed.startsWith('pg-mem://')) { + return; + } + + let protocol = ''; + try { + protocol = new URL(trimmed).protocol; + } catch { + throw new Error( + 'DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接', + ); + } + + if (protocol !== 'postgresql:' && protocol !== 'postgres:') { + throw new Error( + 'DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接', + ); + } +} + +export function summarizeDatabaseTarget(databaseUrl: string) { + const trimmed = databaseUrl.trim(); + if (!trimmed) { + return '[missing]'; + } + + if (trimmed.startsWith('pg-mem://')) { + return trimmed; + } + + try { + const url = new URL(trimmed); + const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres'; + const portSuffix = url.port ? `:${url.port}` : ''; + return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`; + } catch { + return '[configured]'; + } +} + +async function ensureMigrationTable(db: AppDatabase) { + await db.query(migrationTableSql); +} + +export async function listAppliedMigrations(db: AppDatabase) { + await ensureMigrationTable(db); + const result = await db.query( + `SELECT id, name, applied_at AS "appliedAt" + FROM schema_migrations + ORDER BY id`, + ); + + return result.rows.map((row) => ({ + id: row.id, + name: row.name, + appliedAt: row.appliedAt, + })); +} + +async function runMigrations(db: AppDatabase) { + await ensureMigrationTable(db); + const appliedMigrations = new Set( + (await listAppliedMigrations(db)).map((migration) => migration.id), + ); + + for (const migration of databaseMigrations) { + if (appliedMigrations.has(migration.id)) { + continue; + } + + await db.query('BEGIN'); + try { + for (const statement of migration.statements) { + await db.query(statement); + } + await db.query( + `INSERT INTO schema_migrations (id, name, applied_at) + VALUES ($1, $2, $3)`, + [migration.id, migration.name, new Date().toISOString()], + ); + await db.query('COMMIT'); + } catch (error) { + await db.query('ROLLBACK'); + throw new Error( + `failed to apply database migration ${migration.id}: ${error instanceof Error ? error.message : 'unknown error'}`, + ); + } + } +} + +async function createInMemoryDatabase() { + const { newDb } = await import('pg-mem'); + const memoryDb = newDb({ + autoCreateForeignKeyIndices: true, + noAstCoverageCheck: true, + }); + const adapter = memoryDb.adapters.createPg(); + const pool = new adapter.Pool() as unknown as QueryablePool; + const db = wrapPool(pool); + await runMigrations(db); + return db; +} + +export async function createDatabase(config: AppConfig) { + validateDatabaseUrl(config.databaseUrl); + + if (config.databaseUrl.startsWith('pg-mem://')) { + return createInMemoryDatabase(); + } + + const pool = new Pool({ + connectionString: config.databaseUrl, + }); + const db = wrapPool(pool); + await db.query('SELECT 1'); + await runMigrations(db); return db; } diff --git a/server-node/src/db/migrations.ts b/server-node/src/db/migrations.ts new file mode 100644 index 00000000..fa47f22d --- /dev/null +++ b/server-node/src/db/migrations.ts @@ -0,0 +1,192 @@ +export type DatabaseMigration = { + id: string; + name: string; + statements: readonly string[]; +}; + +export const databaseMigrations: readonly DatabaseMigration[] = [ + { + id: '20260408_001_runtime_postgres_baseline', + name: 'runtime postgres baseline', + statements: [ + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + token_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS save_snapshots ( + user_id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + saved_at TEXT NOT NULL, + bottom_tab TEXT NOT NULL, + game_state_json JSONB NOT NULL, + current_story_json JSONB NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS runtime_settings ( + user_id TEXT PRIMARY KEY, + music_volume REAL NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS custom_world_profiles ( + user_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + payload_json JSONB NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, profile_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS custom_world_profiles_user_updated_idx + ON custom_world_profiles (user_id, updated_at DESC)`, + ], + }, + { + id: '20260408_002_allow_null_current_story_snapshot', + name: 'allow null current story snapshot', + statements: [ + `ALTER TABLE save_snapshots + ALTER COLUMN current_story_json DROP NOT NULL`, + ], + }, + { + id: '20260409_003_phone_auth_user_extensions', + name: 'phone auth user extensions', + statements: [ + `ALTER TABLE users + ADD COLUMN IF NOT EXISTS display_name TEXT`, + `UPDATE users + SET display_name = COALESCE( + CASE + WHEN display_name = '' THEN NULL + ELSE display_name + END, + username, + '玩家' + ) + WHERE display_name IS NULL OR display_name = ''`, + `ALTER TABLE users + ALTER COLUMN display_name SET NOT NULL`, + `ALTER TABLE users + ADD COLUMN IF NOT EXISTS login_provider TEXT NOT NULL DEFAULT 'password'`, + `ALTER TABLE users + ADD COLUMN IF NOT EXISTS phone_number TEXT`, + `ALTER TABLE users + ADD COLUMN IF NOT EXISTS phone_verified_at TEXT`, + `CREATE UNIQUE INDEX IF NOT EXISTS users_phone_number_unique_idx + ON users (phone_number)`, + ], + }, + { + id: '20260409_004_auth_identities_and_account_status', + name: 'auth identities and account status', + statements: [ + `ALTER TABLE users + ADD COLUMN IF NOT EXISTS account_status TEXT NOT NULL DEFAULT 'active'`, + `CREATE TABLE IF NOT EXISTS auth_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + provider_uid TEXT NOT NULL, + provider_unionid TEXT, + display_name TEXT, + avatar_url TEXT, + is_verified BOOLEAN NOT NULL DEFAULT TRUE, + meta_json JSONB, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_uid_unique_idx + ON auth_identities (provider, provider_uid)`, + `CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_unionid_unique_idx + ON auth_identities (provider, provider_unionid) + WHERE provider_unionid IS NOT NULL`, + `CREATE INDEX IF NOT EXISTS auth_identities_user_idx + ON auth_identities (user_id, provider)`, + ], + }, + { + id: '20260409_005_user_sessions', + name: 'user sessions', + statements: [ + `CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + refresh_token_hash TEXT NOT NULL UNIQUE, + client_type TEXT NOT NULL, + user_agent TEXT, + ip TEXT, + expires_at TEXT NOT NULL, + revoked_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS user_sessions_user_idx + ON user_sessions (user_id, expires_at DESC)`, + ], + }, + { + id: '20260409_006_auth_audit_logs', + name: 'auth audit logs', + statements: [ + `CREATE TABLE IF NOT EXISTS auth_audit_logs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, + detail TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + meta_json JSONB, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS auth_audit_logs_user_created_idx + ON auth_audit_logs (user_id, created_at DESC)`, + ], + }, + { + id: '20260409_007_sms_auth_events', + name: 'sms auth events', + statements: [ + `CREATE TABLE IF NOT EXISTS sms_auth_events ( + id TEXT PRIMARY KEY, + phone_number TEXT NOT NULL, + scene TEXT NOT NULL, + action TEXT NOT NULL, + success BOOLEAN NOT NULL, + ip TEXT, + user_agent TEXT, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS sms_auth_events_phone_created_idx + ON sms_auth_events (phone_number, created_at DESC)`, + `CREATE INDEX IF NOT EXISTS sms_auth_events_ip_created_idx + ON sms_auth_events (ip, created_at DESC)`, + ], + }, + { + id: '20260409_008_auth_risk_blocks', + name: 'auth risk blocks', + statements: [ + `CREATE TABLE IF NOT EXISTS auth_risk_blocks ( + id TEXT PRIMARY KEY, + scope_type TEXT NOT NULL, + scope_key TEXT NOT NULL, + reason TEXT NOT NULL, + expires_at TEXT NOT NULL, + lifted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS auth_risk_blocks_scope_idx + ON auth_risk_blocks (scope_type, scope_key, expires_at DESC)`, + ], + }, +]; diff --git a/server-node/src/errors.ts b/server-node/src/errors.ts index 7beca192..c40fae85 100644 --- a/server-node/src/errors.ts +++ b/server-node/src/errors.ts @@ -1,35 +1,173 @@ -export class HttpError extends Error { - statusCode: number; - expose: boolean; +import { ZodError } from 'zod'; - constructor(statusCode: number, message: string, expose = true) { - super(message); - this.name = 'HttpError'; - this.statusCode = statusCode; - this.expose = expose; +type HttpErrorOptions = { + code?: string; + details?: unknown; + expose?: boolean; +}; + +type JsonBodyParseError = SyntaxError & { + status?: number; + type?: string; +}; + +export function resolveHttpErrorCode(statusCode: number) { + switch (statusCode) { + case 400: + return 'BAD_REQUEST'; + case 401: + return 'UNAUTHORIZED'; + case 429: + return 'TOO_MANY_REQUESTS'; + case 403: + return 'FORBIDDEN'; + case 404: + return 'NOT_FOUND'; + case 409: + return 'CONFLICT'; + case 502: + return 'UPSTREAM_ERROR'; + default: + return 'INTERNAL_SERVER_ERROR'; } } -export function badRequest(message: string) { - return new HttpError(400, message); +export function resolveHttpErrorMessage(statusCode: number) { + switch (statusCode) { + case 400: + return '请求参数不合法'; + case 401: + return '未授权访问'; + case 429: + return '请求过于频繁'; + case 403: + return '禁止访问'; + case 404: + return '资源不存在'; + case 409: + return '请求冲突'; + case 502: + return '上游服务请求失败'; + default: + return '服务器内部错误'; + } +} + +function isJsonBodyParseError(error: unknown): error is JsonBodyParseError { + return ( + error instanceof SyntaxError && + typeof error === 'object' && + 'status' in error && + 'type' in error && + (error.status === 400 || error.type === 'entity.parse.failed') + ); +} + +function serializeZodIssues(error: ZodError) { + return error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + code: issue.code, + })); +} + +export class HttpError extends Error { + statusCode: number; + expose: boolean; + code: string; + details?: unknown; + + constructor( + statusCode: number, + message: string, + options: HttpErrorOptions = {}, + ) { + super(message); + this.name = 'HttpError'; + this.statusCode = statusCode; + this.expose = options.expose ?? statusCode < 500; + this.code = options.code ?? resolveHttpErrorCode(statusCode); + this.details = options.details; + } +} + +export function badRequest(message: string, details?: unknown) { + return new HttpError(400, message, { + code: 'BAD_REQUEST', + details, + }); +} + +export function invalidRequest(message = '请求参数不合法', details?: unknown) { + return new HttpError(400, message, { + code: 'INVALID_REQUEST', + details, + }); } export function unauthorized(message = '未授权访问') { - return new HttpError(401, message); + return new HttpError(401, message, { + code: 'UNAUTHORIZED', + }); } export function forbidden(message = '禁止访问') { - return new HttpError(403, message); + return new HttpError(403, message, { + code: 'FORBIDDEN', + }); +} + +export function tooManyRequests(message = '请求过于频繁', details?: unknown) { + return new HttpError(429, message, { + code: 'TOO_MANY_REQUESTS', + details, + }); +} + +export function captchaRequired(message = '需要完成人机校验', details?: unknown) { + return new HttpError(403, message, { + code: 'CAPTCHA_REQUIRED', + details, + }); } export function notFound(message = '资源不存在') { - return new HttpError(404, message); + return new HttpError(404, message, { + code: 'NOT_FOUND', + }); } -export function conflict(message: string) { - return new HttpError(409, message); +export function conflict(message: string, details?: unknown) { + return new HttpError(409, message, { + code: 'CONFLICT', + details, + }); } -export function upstreamError(message: string) { - return new HttpError(502, message); +export function upstreamError(message: string, details?: unknown) { + return new HttpError(502, message, { + code: 'UPSTREAM_ERROR', + details, + }); +} + +export function toHttpError(error: unknown) { + if (error instanceof HttpError) { + return error; + } + + if (error instanceof ZodError) { + return invalidRequest('请求参数不合法', { + issues: serializeZodIssues(error), + }); + } + + if (isJsonBodyParseError(error)) { + return badRequest('JSON 请求体格式错误'); + } + + return new HttpError(500, '服务器内部错误', { + code: 'INTERNAL_SERVER_ERROR', + expose: false, + }); } diff --git a/server-node/src/http.ts b/server-node/src/http.ts index e3a59d1c..9c731190 100644 --- a/server-node/src/http.ts +++ b/server-node/src/http.ts @@ -1,5 +1,299 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { + API_VERSION, + createApiError, + createApiSuccess, + parseApiErrorMessage, +} from '../../packages/shared/src/http.js'; +import { resolveHttpErrorCode, resolveHttpErrorMessage } from './errors.js'; + +export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; +export const API_VERSION_HEADER = 'x-api-version'; +export const ROUTE_VERSION_HEADER = 'x-route-version'; +export const RESPONSE_TIME_HEADER = 'x-response-time-ms'; + +const DEFAULT_API_VERSION = API_VERSION; +const DEFAULT_ROUTE_VERSION = DEFAULT_API_VERSION; + +export type ApiRouteMeta = { + operation?: string; + routeVersion?: string; +}; + +export type ApiResponseMeta = { + requestId: string; + apiVersion: string; + routeVersion: string; + operation: string | null; + latencyMs: number; + timestamp: string; +}; + +export type ApiSuccessEnvelope = { + ok: true; + data: T; + error: null; + meta: ApiResponseMeta; +}; + +type LegacyApiErrorBody = { + error?: { + code?: string; + message?: string; + details?: unknown; + } | null; + message?: string; + code?: string; + details?: unknown; + meta?: unknown; +}; + +function readRouteMeta(response: Response): ApiRouteMeta { + const routeMeta = response.locals.apiRouteMeta; + if (!routeMeta || typeof routeMeta !== 'object') { + return {}; + } + + return routeMeta as ApiRouteMeta; +} + +function inferOperation(request: Request) { + if (request.originalUrl) { + return `${request.method} ${request.originalUrl}`; + } + + if (request.route?.path) { + return `${request.method} ${request.baseUrl}${request.route.path}`; + } + + return `${request.method} ${request.originalUrl || request.url}`; +} + +export function setRouteMeta(response: Response, meta: ApiRouteMeta) { + response.locals.apiRouteMeta = { + ...readRouteMeta(response), + ...meta, + }; +} + +export function withRouteMeta(meta: ApiRouteMeta): RequestHandler { + return (_request, response, next) => { + setRouteMeta(response, meta); + next(); + }; +} + +export function wantsApiEnvelope(request: Request) { + const value = + request.header(API_RESPONSE_ENVELOPE_HEADER)?.trim().toLowerCase() || ''; + + return ( + value === '1' || value === 'true' || value === 'v1' || value === 'envelope' + ); +} + +export function buildApiResponseMeta( + request: Request, + response: Response, +): ApiResponseMeta { + const routeMeta = readRouteMeta(response); + + return { + requestId: request.requestId, + apiVersion: DEFAULT_API_VERSION, + routeVersion: routeMeta.routeVersion || DEFAULT_ROUTE_VERSION, + operation: routeMeta.operation || inferOperation(request), + latencyMs: Math.max(0, Date.now() - request.requestStartedAt), + timestamp: new Date().toISOString(), + }; +} + +export function applyApiResponseHeaders(request: Request, response: Response) { + const meta = buildApiResponseMeta(request, response); + + response.setHeader('X-Request-Id', meta.requestId); + response.setHeader(API_VERSION_HEADER, meta.apiVersion); + response.setHeader(ROUTE_VERSION_HEADER, meta.routeVersion); + response.setHeader(RESPONSE_TIME_HEADER, String(meta.latencyMs)); + + return meta; +} + +function buildSharedApiMeta(meta: ApiResponseMeta) { + return { + requestId: meta.requestId, + apiVersion: meta.apiVersion, + routeVersion: meta.routeVersion, + operation: meta.operation, + latencyMs: meta.latencyMs, + timestamp: meta.timestamp, + }; +} + +export function buildApiLogContext(request: Request, response: Response) { + const meta = buildApiResponseMeta(request, response); + + return { + request_id: meta.requestId, + api_version: meta.apiVersion, + route_version: meta.routeVersion, + operation: meta.operation, + }; +} + +export function toApiSuccessBody( + request: Request, + response: Response, + data: T, +): T | ApiSuccessEnvelope { + const meta = applyApiResponseHeaders(request, response); + + if (!wantsApiEnvelope(request)) { + return data; + } + + return createApiSuccess(data, buildSharedApiMeta(meta)); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export function isStandardApiSuccessEnvelope( + body: unknown, +): body is ApiSuccessEnvelope { + return ( + isRecord(body) && + body.ok === true && + 'data' in body && + 'error' in body && + body.error === null && + isRecord(body.meta) && + typeof body.meta.apiVersion === 'string' + ); +} + +export function isStandardApiErrorResponse(body: unknown) { + if ( + !isRecord(body) || + !isRecord(body.meta) || + typeof body.meta.apiVersion !== 'string' + ) { + return false; + } + + if (body.ok === false) { + return ( + body.data === null && + isRecord(body.error) && + typeof body.error.code === 'string' && + typeof body.error.message === 'string' + ); + } + + return ( + 'error' in body && + isRecord(body.error) && + typeof body.error.code === 'string' && + typeof body.error.message === 'string' + ); +} + +function normalizeLegacyApiErrorBody(body: unknown, statusCode: number) { + const legacyBody = isRecord(body) ? (body as LegacyApiErrorBody) : {}; + const nestedError = isRecord(legacyBody.error) ? legacyBody.error : null; + const code = + (typeof nestedError?.code === 'string' && nestedError.code.trim()) || + (typeof legacyBody.code === 'string' && legacyBody.code.trim()) || + resolveHttpErrorCode(statusCode); + const message = + (typeof nestedError?.message === 'string' && nestedError.message.trim()) || + (typeof legacyBody.message === 'string' && legacyBody.message.trim()) || + resolveHttpErrorMessage(statusCode); + const details = nestedError?.details ?? legacyBody.details; + + return { + code, + message, + ...(details !== undefined ? { details } : {}), + }; +} + +export function toApiErrorBody( + request: Request, + response: Response, + body: unknown, +) { + const meta = applyApiResponseHeaders(request, response); + const error = normalizeLegacyApiErrorBody(body, response.statusCode || 500); + + if (wantsApiEnvelope(request)) { + return createApiError(error, buildSharedApiMeta(meta)); + } + + return { + error, + meta, + }; +} + +export function sendApiResponse( + response: Response, + data: T, + statusCode = 200, +) { + response.status(statusCode); + response.json(data); +} + +export function prepareApiResponse( + request: Request, + response: Response, + options: { + statusCode?: number; + headers?: Record; + routeMeta?: ApiRouteMeta; + } = {}, +) { + if (options.routeMeta) { + setRouteMeta(response, options.routeMeta); + } + if (typeof options.statusCode === 'number') { + response.status(options.statusCode); + } + + const meta = applyApiResponseHeaders(request, response); + + for (const [name, value] of Object.entries(options.headers ?? {})) { + response.setHeader(name, value); + } + + return meta; +} + +export function prepareEventStreamResponse( + request: Request, + response: Response, + options: { + statusCode?: number; + routeMeta?: ApiRouteMeta; + headers?: Record; + } = {}, +) { + return prepareApiResponse(request, response, { + statusCode: options.statusCode ?? 200, + routeMeta: options.routeMeta, + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + ...(options.headers ?? {}), + }, + }); +} + export function asyncHandler( handler: ( request: Request, @@ -16,31 +310,7 @@ export function extractApiErrorMessage( rawText: string, fallbackMessage: string, ) { - if (!rawText.trim()) { - return fallbackMessage; - } - - try { - const parsed = JSON.parse(rawText) as { - error?: { message?: string }; - message?: string; - code?: string; - }; - - if (typeof parsed.error?.message === 'string' && parsed.error.message.trim()) { - return parsed.error.message.trim(); - } - if (typeof parsed.message === 'string' && parsed.message.trim()) { - return parsed.message.trim(); - } - if (typeof parsed.code === 'string' && parsed.code.trim()) { - return `${fallbackMessage}(${parsed.code.trim()})`; - } - } catch { - // Ignore malformed json responses. - } - - return rawText.trim() || fallbackMessage; + return parseApiErrorMessage(rawText, fallbackMessage); } export function jsonClone(value: T): T { diff --git a/server-node/src/middleware/auth.ts b/server-node/src/middleware/auth.ts index 88df1a29..a67a415a 100644 --- a/server-node/src/middleware/auth.ts +++ b/server-node/src/middleware/auth.ts @@ -22,10 +22,13 @@ export function requireJwtAuth(config: AppConfig, userRepository: UserRepository } const claims = await verifyAccessToken(token, config); - const user = userRepository.findById(claims.userId); + const user = await userRepository.findById(claims.userId); if (!user) { throw unauthorized('用户不存在'); } + if (user.accountStatus === 'disabled') { + throw unauthorized('账号已被禁用'); + } if (user.tokenVersion !== claims.tokenVersion) { throw unauthorized('登录状态已失效,请重新登录'); } diff --git a/server-node/src/middleware/errorHandler.ts b/server-node/src/middleware/errorHandler.ts index b7473a44..2f4e6277 100644 --- a/server-node/src/middleware/errorHandler.ts +++ b/server-node/src/middleware/errorHandler.ts @@ -1,27 +1,54 @@ import type { ErrorRequestHandler } from 'express'; -import { HttpError } from '../errors.js'; +import { toHttpError } from '../errors.js'; +import { + applyApiResponseHeaders, + buildApiLogContext, + wantsApiEnvelope, +} from '../http.js'; -export const errorHandler: ErrorRequestHandler = (error, request, response, _next) => { - const statusCode = - error instanceof HttpError ? error.statusCode : 500; - const message = - error instanceof HttpError - ? error.message - : '服务器内部错误'; +export const errorHandler: ErrorRequestHandler = ( + error, + request, + response, + _next, +) => { + const normalizedError = toHttpError(error); + const meta = applyApiResponseHeaders(request, response); request.log?.error( { err: error, - request_id: request.requestId, + ...buildApiLogContext(request, response), user_id: request.userId ?? null, + status: normalizedError.statusCode, + error_code: normalizedError.code, }, 'request failed', ); - response.status(statusCode).json({ - error: { - message, - }, + response.status(normalizedError.statusCode); + + const errorPayload = { + code: normalizedError.code, + message: normalizedError.message, + ...(normalizedError.expose && normalizedError.details !== undefined + ? { details: normalizedError.details } + : {}), + }; + + if (wantsApiEnvelope(request)) { + response.json({ + ok: false, + data: null, + error: errorPayload, + meta, + }); + return; + } + + response.json({ + error: errorPayload, + meta, }); }; diff --git a/server-node/src/middleware/requestId.ts b/server-node/src/middleware/requestId.ts index 1bfebcd0..66ebab5c 100644 --- a/server-node/src/middleware/requestId.ts +++ b/server-node/src/middleware/requestId.ts @@ -2,7 +2,15 @@ import crypto from 'node:crypto'; import type { RequestHandler } from 'express'; -export const requestIdMiddleware: RequestHandler = (request, _response, next) => { - request.requestId = request.header('x-request-id')?.trim() || crypto.randomUUID(); +export const requestIdMiddleware: RequestHandler = ( + request, + response, + next, +) => { + const requestId = + request.header('x-request-id')?.trim() || crypto.randomUUID(); + request.requestId = requestId; + request.requestStartedAt = Date.now(); + response.setHeader('x-request-id', requestId); next(); }; diff --git a/server-node/src/middleware/responseEnvelope.ts b/server-node/src/middleware/responseEnvelope.ts new file mode 100644 index 00000000..83ed6f61 --- /dev/null +++ b/server-node/src/middleware/responseEnvelope.ts @@ -0,0 +1,49 @@ +import type { RequestHandler, Response } from 'express'; + +import { + applyApiResponseHeaders, + isStandardApiErrorResponse, + isStandardApiSuccessEnvelope, + toApiErrorBody, + toApiSuccessBody, +} from '../http.js'; + +function isLegacyApiErrorBody(body: unknown) { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return false; + } + + return ( + ('error' in body || 'message' in body || 'code' in body) && + !('meta' in body && 'ok' in body) + ); +} + +function patchJsonResponse(response: Response) { + const originalJson = response.json.bind(response); + + response.json = ((body: unknown) => { + if ( + isStandardApiSuccessEnvelope(body) || + isStandardApiErrorResponse(body) + ) { + applyApiResponseHeaders(response.req, response); + return originalJson(body); + } + + if (response.statusCode >= 400 || isLegacyApiErrorBody(body)) { + return originalJson(toApiErrorBody(response.req, response, body)); + } + + return originalJson(toApiSuccessBody(response.req, response, body)); + }) as Response['json']; +} + +export const responseEnvelopeMiddleware: RequestHandler = ( + _request, + response, + next, +) => { + patchJsonResponse(response); + next(); +}; diff --git a/server-node/src/middleware/routeMeta.ts b/server-node/src/middleware/routeMeta.ts new file mode 100644 index 00000000..85178850 --- /dev/null +++ b/server-node/src/middleware/routeMeta.ts @@ -0,0 +1,10 @@ +import type { RequestHandler } from 'express'; + +import { setRouteMeta, type ApiRouteMeta } from '../http.js'; + +export function routeMeta(meta: ApiRouteMeta): RequestHandler { + return (_request, response, next) => { + setRouteMeta(response, meta); + next(); + }; +} diff --git a/server-node/src/migrate.ts b/server-node/src/migrate.ts new file mode 100644 index 00000000..cc4b1331 --- /dev/null +++ b/server-node/src/migrate.ts @@ -0,0 +1,30 @@ +import { loadConfig } from './config.js'; +import { + createDatabase, + listAppliedMigrations, + summarizeDatabaseTarget, +} from './db.js'; + +async function main() { + const config = loadConfig(); + const db = await createDatabase(config); + + try { + const migrations = await listAppliedMigrations(db); + console.log( + `[db:migrate] database=${summarizeDatabaseTarget(config.databaseUrl)}`, + ); + console.log(`[db:migrate] applied migrations=${migrations.length}`); + + for (const migration of migrations) { + console.log(`[db:migrate] ${migration.id} ${migration.name}`); + } + } finally { + await db.close(); + } +} + +void main().catch((error) => { + console.error('[db:migrate] failed', error); + process.exit(1); +}); diff --git a/server-node/src/modules/ai/chatOrchestrator.ts b/server-node/src/modules/ai/chatOrchestrator.ts new file mode 100644 index 00000000..678565b9 --- /dev/null +++ b/server-node/src/modules/ai/chatOrchestrator.ts @@ -0,0 +1,90 @@ +import type { Request, Response } from 'express'; + +import type { + CharacterChatReplyRequest, + CharacterChatSuggestionsRequest, + CharacterChatSummaryRequest, + NpcChatDialogueRequest, + NpcRecruitDialogueRequest, +} from '../../../../packages/shared/src/contracts/story.js'; +import { + buildCharacterPanelChatPrompt, + buildCharacterPanelChatSuggestionPrompt, + buildCharacterPanelChatSummaryPrompt, + CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, + CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, + CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, + buildNpcRecruitDialoguePrompt, + buildStrictNpcChatDialoguePrompt, + NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, + NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, +} from './chatPromptBuilders.js'; +import type { UpstreamLlmClient } from '../../services/llmClient.js'; + +export async function generateCharacterChatSuggestionsFromOrchestrator( + llmClient: UpstreamLlmClient, + payload: CharacterChatSuggestionsRequest, +) { + return llmClient.requestMessageContent({ + systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, + userPrompt: buildCharacterPanelChatSuggestionPrompt(payload), + }); +} + +export async function generateCharacterChatSummaryFromOrchestrator( + llmClient: UpstreamLlmClient, + payload: CharacterChatSummaryRequest, +) { + return llmClient.requestMessageContent({ + systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, + userPrompt: buildCharacterPanelChatSummaryPrompt(payload), + }); +} + +export async function streamCharacterChatReplyFromOrchestrator( + llmClient: UpstreamLlmClient, + params: { + request: Request; + response: Response; + payload: CharacterChatReplyRequest; + }, +) { + await llmClient.forwardSseText({ + request: params.request, + response: params.response, + systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, + userPrompt: buildCharacterPanelChatPrompt(params.payload), + }); +} + +export async function streamNpcChatDialogueFromOrchestrator( + llmClient: UpstreamLlmClient, + params: { + request: Request; + response: Response; + payload: NpcChatDialogueRequest; + }, +) { + await llmClient.forwardSseText({ + request: params.request, + response: params.response, + systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, + userPrompt: buildStrictNpcChatDialoguePrompt(params.payload), + }); +} + +export async function streamNpcRecruitDialogueFromOrchestrator( + llmClient: UpstreamLlmClient, + params: { + request: Request; + response: Response; + payload: NpcRecruitDialogueRequest; + }, +) { + await llmClient.forwardSseText({ + request: params.request, + response: params.response, + systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, + userPrompt: buildNpcRecruitDialoguePrompt(params.payload), + }); +} diff --git a/server-node/src/modules/ai/chatPromptBuilders.ts b/server-node/src/modules/ai/chatPromptBuilders.ts new file mode 100644 index 00000000..04f19684 --- /dev/null +++ b/server-node/src/modules/ai/chatPromptBuilders.ts @@ -0,0 +1,372 @@ +import type { + CharacterChatReplyRequest, + CharacterChatSuggestionsRequest, + CharacterChatSummaryRequest, + NpcChatDialogueRequest, + NpcRecruitDialogueRequest, +} from '../../../../packages/shared/src/contracts/story.js'; + +type JsonRecord = Record; + +export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。 +只回复这名角色此刻会对玩家说的话。 +不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。 +保持人设,结合最近剧情和关系变化,回复简洁自然。`; + +export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。 +只输出纯文本,共 3 行,每行一条。 +不要加编号、项目符号、Markdown 或额外说明。 +三条建议语气要有区分:关心、追问、轻松或拉近关系。`; + +export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。 +只输出一段简洁文字。 +包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`; + +export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。 +你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 + +硬性规则: +- 每一行都必须严格以“你:”或“角色名字:”开头。 +- 第一行必须是“你:”开头。 +- 总行数控制在 4 到 6 行。 +- 玩家和对方至少各说 2 次。 +- 这段内容只是聊天,不是做决定。 +- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。 +- 禁止把情报直接写成对玩家的指令。 +- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`; + +export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。 +你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 + +硬性规则: +- 每一行都必须严格以“你:”或“角色名字:”开头。 +- 第一行必须是“你:”开头。 +- 总行数控制在 4 到 6 行。 +- 玩家和对方至少各说 2 次。 +- 这段对话的目标是把“邀请对方入队”自然谈成。 +- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。 +- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。 +- 最后一行必须由对方明确答应加入队伍。`; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as JsonRecord) + : null; +} + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function readNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function readStringArray(value: unknown) { + return Array.isArray(value) + ? value + .map((item) => readString(item)) + .filter((item): item is string => Boolean(item)) + : []; +} + +function describeWorld(worldType: string) { + switch (worldType) { + case 'WUXIA': + return '武侠'; + case 'XIANXIA': + return '仙侠'; + case 'CUSTOM': + return '自定义世界'; + default: + return worldType || '未知世界'; + } +} + +function describeStats(label: string, record: JsonRecord | null) { + const hp = readNumber(record?.hp); + const maxHp = Math.max(1, readNumber(record?.maxHp, hp)); + const mana = readNumber(record?.mana); + const maxMana = Math.max(1, readNumber(record?.maxMana, mana)); + + return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`; +} + +function describeCharacter(label: string, value: unknown) { + const record = asRecord(value); + const name = readString(record?.name) ?? '未知角色'; + const title = readString(record?.title) ?? '未知称号'; + const description = readString(record?.description) ?? '暂无额外描述'; + const personality = readString(record?.personality) ?? '性格信息未显式提供'; + + return [ + `${label}姓名:${name}`, + `${label}称号:${title}`, + `${label}描述:${description}`, + `${label}性格:${personality}`, + ].join('\n'); +} + +function describeStoryHistory(history: unknown) { + if (!Array.isArray(history) || history.length === 0) { + return '近期剧情:暂无。'; + } + + const lines = history + .slice(-4) + .map((item) => readString(asRecord(item)?.text)) + .filter((item): item is string => Boolean(item)); + + return lines.length > 0 + ? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n') + : '近期剧情:暂无。'; +} + +function describeConversationHistory(history: unknown) { + if (!Array.isArray(history) || history.length === 0) { + return '聊天记录:暂无。'; + } + + const lines = history + .slice(-12) + .map((item) => { + const record = asRecord(item); + const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色'; + const text = readString(record?.text); + + return text ? `- ${speaker}:${text}` : null; + }) + .filter((item): item is string => Boolean(item)); + + return lines.length > 0 + ? ['聊天记录:', ...lines].join('\n') + : '聊天记录:暂无。'; +} + +function describeSceneContext(context: unknown) { + const record = asRecord(context); + const sceneName = readString(record?.sceneName) ?? '当前区域'; + const sceneDescription = + readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。'; + const inBattle = record?.inBattle === true ? '战斗中' : '非战斗'; + const customWorldProfile = asRecord(record?.customWorldProfile); + const customWorldName = readString(customWorldProfile?.name); + const customWorldSummary = readString(customWorldProfile?.summary); + + return [ + `世界补充:${customWorldName ?? '无'}`, + customWorldSummary ? `世界摘要:${customWorldSummary}` : null, + `场景:${sceneName}`, + `场景描述:${sceneDescription}`, + `当前状态:${inBattle}`, + describeStats('玩家', record), + ] + .filter(Boolean) + .join('\n'); +} + +function describeTargetStatus(status: unknown) { + const record = asRecord(status); + const roleLabel = readString(record?.roleLabel) ?? '同行角色'; + const affinity = record?.affinity; + + return [ + `对方身份:${roleLabel}`, + describeStats('对方', record), + typeof affinity === 'number' ? `当前好感:${affinity}` : null, + ] + .filter(Boolean) + .join('\n'); +} + +function describeEncounter(encounter: unknown) { + const record = asRecord(encounter); + const npcName = readString(record?.npcName) ?? '眼前角色'; + const contextText = + readString(record?.context) ?? + readString(record?.npcDescription) ?? + '你们正在当前遭遇里继续对话。'; + + return { + npcName, + block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'), + }; +} + +function describeMonsters(monsters: unknown) { + if (!Array.isArray(monsters) || monsters.length === 0) { + return '当前敌对目标:无。'; + } + + const lines = monsters + .slice(0, 4) + .map((item) => { + const record = asRecord(item); + const name = + readString(record?.name) ?? + readString(record?.npcName) ?? + readString(record?.id); + const hp = readNumber(record?.hp); + const maxHp = Math.max(1, readNumber(record?.maxHp, hp)); + + return name ? `- ${name}(生命 ${hp}/${maxHp})` : null; + }) + .filter((item): item is string => Boolean(item)); + + return lines.length > 0 + ? ['当前敌对目标:', ...lines].join('\n') + : '当前敌对目标:无。'; +} + +function describeTargetCharacterName(payload: { + targetCharacter?: unknown; + encounter?: unknown; +}) { + return ( + readString(asRecord(payload.targetCharacter)?.name) ?? + readString(asRecord(payload.encounter)?.npcName) ?? + '对方' + ); +} + +export function buildCharacterPanelChatPrompt( + payload: CharacterChatReplyRequest, +) { + const targetName = describeTargetCharacterName(payload); + + return [ + `世界:${describeWorld(payload.worldType)}`, + describeSceneContext(payload.context), + describeCharacter('玩家 / ', payload.playerCharacter), + describeCharacter('对方 / ', payload.targetCharacter), + describeTargetStatus(payload.targetStatus), + describeStoryHistory(payload.storyHistory), + payload.conversationSummary + ? `之前聊天摘要:${payload.conversationSummary}` + : '之前聊天摘要:暂无。', + describeConversationHistory(payload.conversationHistory), + `玩家刚刚对 ${targetName} 说:${payload.playerMessage}`, + `现在请以 ${targetName} 的身份,直接回复玩家。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildCharacterPanelChatSuggestionPrompt( + payload: CharacterChatSuggestionsRequest, +) { + const targetName = describeTargetCharacterName(payload); + const latestCharacterReply = Array.isArray(payload.conversationHistory) + ? [...payload.conversationHistory] + .reverse() + .map((item) => asRecord(item)) + .find((record) => readString(record?.speaker) === 'character') + : null; + const latestReplyText = readString(latestCharacterReply?.text); + + return [ + `世界:${describeWorld(payload.worldType)}`, + describeSceneContext(payload.context), + describeCharacter('玩家 / ', payload.playerCharacter), + describeCharacter('对方 / ', payload.targetCharacter), + describeTargetStatus(payload.targetStatus), + describeStoryHistory(payload.storyHistory), + payload.conversationSummary + ? `之前聊天摘要:${payload.conversationSummary}` + : '之前聊天摘要:暂无。', + describeConversationHistory(payload.conversationHistory), + latestReplyText + ? `角色刚刚的回复:${latestReplyText}` + : `玩家正准备与 ${targetName} 开始一段新的私聊。`, + `请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildCharacterPanelChatSummaryPrompt( + payload: CharacterChatSummaryRequest, +) { + const targetName = describeTargetCharacterName(payload); + + return [ + `世界:${describeWorld(payload.worldType)}`, + describeSceneContext(payload.context), + describeCharacter('玩家 / ', payload.playerCharacter), + describeCharacter('对方 / ', payload.targetCharacter), + describeTargetStatus(payload.targetStatus), + describeStoryHistory(payload.storyHistory), + payload.previousSummary + ? `旧摘要:${payload.previousSummary}` + : '旧摘要:暂无。', + describeConversationHistory(payload.conversationHistory), + `请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +function buildNpcDialoguePromptBase( + payload: NpcChatDialogueRequest | NpcRecruitDialogueRequest, +) { + const encounter = describeEncounter(payload.encounter); + + return [ + `世界:${describeWorld(payload.worldType)}`, + describeSceneContext(payload.context), + describeCharacter('玩家 / ', payload.character), + encounter.block, + describeMonsters(payload.monsters), + describeStoryHistory(payload.history), + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildStrictNpcChatDialoguePrompt( + payload: NpcChatDialogueRequest, +) { + const encounter = describeEncounter(payload.encounter); + const context = asRecord(payload.context); + const openingCampBackground = readString(context?.openingCampBackground); + const openingCampDialogue = readString(context?.openingCampDialogue); + const allowedTopics = readStringArray(context?.encounterAllowedTopics); + const blockedTopics = readStringArray(context?.encounterBlockedTopics); + + return [ + buildNpcDialoguePromptBase(payload), + openingCampBackground ? `营地开场背景:${openingCampBackground}` : null, + openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null, + allowedTopics.length > 0 + ? `当前更适合谈的内容:${allowedTopics.join('、')}` + : null, + blockedTopics.length > 0 + ? `当前避免直接说破:${blockedTopics.join('、')}` + : null, + `当前聊天主题:${payload.topic}`, + payload.resultSummary + ? `这段聊天希望带来的变化:${payload.resultSummary}` + : '这段聊天要让气氛、情报或关系出现一层新的变化。', + `请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export function buildNpcRecruitDialoguePrompt( + payload: NpcRecruitDialogueRequest, +) { + const encounter = describeEncounter(payload.encounter); + + return [ + buildNpcDialoguePromptBase(payload), + `玩家邀请:${payload.invitationText}`, + payload.recruitSummary + ? `招募补充条件:${payload.recruitSummary}` + : '这轮对话已经具备自然邀请对方入队的条件。', + '这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。', + `最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`, + ] + .filter(Boolean) + .join('\n\n'); +} diff --git a/server-node/src/modules/ai/customWorldOrchestrator.ts b/server-node/src/modules/ai/customWorldOrchestrator.ts new file mode 100644 index 00000000..fef54f5a --- /dev/null +++ b/server-node/src/modules/ai/customWorldOrchestrator.ts @@ -0,0 +1,461 @@ +import type { + CustomWorldGenerationProgress, + GenerateCustomWorldProfileInput, +} from '../../../../packages/shared/src/contracts/runtime.js'; + +type GeneratedProfile = Record; + +const PLAYABLE_ROLE_TEMPLATES = [ + { title: '断桥行者', role: '游历剑客', style: '快剑追击', tags: ['快剑', '突进', '追击'] }, + { title: '听风客', role: '远行弓手', style: '远射游击', tags: ['远射', '游击', '风行'] }, + { title: '守夜人', role: '前列护卫', style: '守御护体', tags: ['守御', '护体', '先锋'] }, + { title: '观火者', role: '术式使', style: '法修过载', tags: ['法修', '过载', '法力'] }, + { title: '逐潮者', role: '浪客拳师', style: '重击压制', tags: ['重击', '爆发', '压制'] }, +] as const; + +const STORY_ROLE_TEMPLATES = [ + { role: '沿街商贩', danger: 'low', tags: ['交易', '情报'] }, + { role: '巡路探子', danger: 'medium', tags: ['巡守', '警觉'] }, + { role: '旧案见证人', danger: 'medium', tags: ['旧案', '隐情'] }, + { role: '守桥武人', danger: 'high', tags: ['守御', '敌意'] }, + { role: '异变潜伏者', danger: 'high', tags: ['异变', '威胁'] }, +] as const; + +const LANDMARK_TEMPLATES = [ + '断桥口', + '旧市桥廊', + '潮痕渡口', + '灰塔前庭', + '沉钟小巷', + '碑下荒庭', + '雾潮栈道', + '封灯码头', + '裂潮前哨', + '残照高台', +] as const; + +function nowMs() { + return Date.now(); +} + +function inferWorldType(settingText: string) { + return /仙|灵|宗门|飞升|法器|秘境|星/u.test(settingText) + ? 'XIANXIA' + : 'WUXIA'; +} + +function seedText(input: GenerateCustomWorldProfileInput) { + return input.settingText.trim().replace(/\s+/g, ' '); +} + +function slugify(value: string) { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || 'entry'; +} + +function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') { + return { + id: `schema:${worldType.toLowerCase()}:default`, + worldId: `world:${worldType.toLowerCase()}`, + schemaVersion: 1, + generatedFrom: { + worldType, + worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城', + settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的修行世界' : '旧桥与边城交错的武侠世界', + tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、江湖余震', + conflictCore: '旧秩序与新威胁正在同时逼近', + }, + schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴', + slots: [ + { + slotId: 'axis_a', + name: '锋势', + definition: '临战时的主动压迫与破面能力', + positiveSignals: ['先手', '破势'], + negativeSignals: ['迟疑', '退缩'], + combatUseText: '决定压制与追击能力', + socialUseText: '决定发起对峙的胆气', + explorationUseText: '决定冒险前推的强度', + }, + { + slotId: 'axis_b', + name: '守意', + definition: '承压、稳住阵脚与保全同伴的能力', + positiveSignals: ['护持', '稳守'], + negativeSignals: ['失衡', '溃散'], + combatUseText: '决定承伤与稳场', + socialUseText: '决定是否可靠', + explorationUseText: '决定穿越危险区的稳定性', + }, + { + slotId: 'axis_c', + name: '灵运', + definition: '资源调度、法力回转与术式适配能力', + positiveSignals: ['回转', '灵感'], + negativeSignals: ['枯竭', '滞涩'], + combatUseText: '决定灵力和术式运转', + socialUseText: '决定理解复杂信息的能力', + explorationUseText: '决定破解机关与异象', + }, + { + slotId: 'axis_d', + name: '机变', + definition: '借势应变、换位与局势判断能力', + positiveSignals: ['借势', '换位'], + negativeSignals: ['僵硬', '迟钝'], + combatUseText: '决定机动与变招', + socialUseText: '决定读懂弦外之音', + explorationUseText: '决定追踪与绕险', + }, + { + slotId: 'axis_e', + name: '因缘', + definition: '人与人之间的牵连、信任与旧债张力', + positiveSignals: ['信任', '牵连'], + negativeSignals: ['隔阂', '背离'], + combatUseText: '决定协同与互援', + socialUseText: '决定关系推进', + explorationUseText: '决定是否能得到帮助', + }, + { + slotId: 'axis_f', + name: '秘痕', + definition: '旧案、禁忌与隐秘线索的承载程度', + positiveSignals: ['旧痕', '秘线'], + negativeSignals: ['空白', '浅表'], + combatUseText: '决定异象与特殊效果', + socialUseText: '决定话题深度', + explorationUseText: '决定发现隐藏真相的能力', + }, + ], + }; +} + +function buildBackstoryReveal(name: string) { + return { + publicSummary: `${name}在表面上只露出一层足以自保的说辞。`, + privateChatUnlockAffinity: 60, + chapters: [ + { + id: `${slugify(name)}-surface`, + title: '表层来意', + affinityRequired: 15, + teaser: `${name}对你仍留着一层试探。`, + content: `${name}先承认自己并非偶然出现在这里,而是被同一场异动推到了前线。`, + contextSnippet: `${name}的真正来意还没有完全摊开。`, + }, + { + id: `${slugify(name)}-scar`, + title: '旧事裂痕', + affinityRequired: 30, + teaser: `${name}提到过一次不愿重说的旧伤。`, + content: `${name}曾在上一轮风暴里失去过重要的人,因此对类似局势格外警觉。`, + contextSnippet: `${name}和旧案之间存在未平的裂痕。`, + }, + { + id: `${slugify(name)}-hidden`, + title: '隐藏执念', + affinityRequired: 60, + teaser: `${name}其实一直在盯着更深一层的线索。`, + content: `${name}真正想追索的不是眼前纷乱本身,而是它背后那只一直没露面的手。`, + contextSnippet: `${name}的行动始终绕着一条更深的暗线。`, + }, + { + id: `${slugify(name)}-final`, + title: '最终底牌', + affinityRequired: 90, + teaser: `${name}手里一直留着最后一道底牌。`, + content: `${name}早就为最坏结局准备了最后的应对,但不到绝境绝不会轻易亮出。`, + contextSnippet: `${name}仍保留着能改写局面的最后筹码。`, + }, + ], + }; +} + +function buildSkills(name: string) { + return [ + { + id: `${slugify(name)}-skill-1`, + name: `${name}起手`, + summary: '先用短促动作压住眼前节奏。', + style: '起手压制', + }, + { + id: `${slugify(name)}-skill-2`, + name: `${name}变招`, + summary: '在试探后迅速换位改势。', + style: '机动周旋', + }, + { + id: `${slugify(name)}-skill-3`, + name: `${name}底牌`, + summary: '在局势逼紧时打出保留手段。', + style: '爆发终结', + }, + ]; +} + +function buildInitialItems(name: string) { + return [ + { + id: `${slugify(name)}-item-1`, + name: `${name}常备武具`, + category: '武器', + quantity: 1, + rarity: 'rare', + description: '随身不离手的主战物件。', + tags: ['战斗', '随身'], + }, + { + id: `${slugify(name)}-item-2`, + name: `${name}补给包`, + category: '消耗品', + quantity: 2, + rarity: 'uncommon', + description: '为了久战和撤离准备的基础补给。', + tags: ['补给', '行动'], + }, + { + id: `${slugify(name)}-item-3`, + name: `${name}私人物件`, + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: '不愿轻易交出的旧信物。', + tags: ['信物', '线索'], + }, + ]; +} + +function buildPlayableNpcs(seed: string) { + return PLAYABLE_ROLE_TEMPLATES.map((template, index) => { + const name = `${seed.slice(0, 2) || '裂潮'}${['岚', '砺', '遥', '烛', '澜'][index]}`; + return { + id: `playable-npc-${index + 1}`, + name, + title: template.title, + role: template.role, + description: `${name}习惯先观察再出手,对局势变化反应极快。`, + backstory: `${name}长期在风暴边缘活动,对眼前这场失衡局势并不陌生。`, + personality: '谨慎、沉稳、保留余地', + motivation: '想先查清是谁把局势推到这一步。', + combatStyle: template.style, + initialAffinity: 18 + index * 4, + relationshipHooks: ['共同求生', '交换情报'], + tags: [...template.tags], + backstoryReveal: buildBackstoryReveal(name), + skills: buildSkills(name), + initialItems: buildInitialItems(name), + templateCharacterId: ['sword-princess', 'archer-hero', 'girl-hero', 'punch-hero', 'fighter-4'][index], + }; + }); +} + +function buildStoryNpcs(seed: string) { + return Array.from({ length: 25 }, (_, index) => { + const template = STORY_ROLE_TEMPLATES[index % STORY_ROLE_TEMPLATES.length]!; + const name = `${seed.slice(0, 2) || '裂潮'}${['青', '玄', '沉', '洛', '霁'][index % 5]}${index + 1}`; + return { + id: `story-npc-${index + 1}`, + name, + title: `第${index + 1}位见证者`, + role: template.role, + description: `${name}始终在观察这场异动会把谁先逼到台前。`, + backstory: `${name}和这片地界的旧事牵连很深,只是还没有把来历说透。`, + personality: '警觉、克制、善于藏话', + motivation: '想确认这轮动荡背后真正的引线。', + combatStyle: template.danger === 'high' ? '先压后断' : '先试后动', + initialAffinity: template.danger === 'high' ? -12 : 6 + (index % 3) * 6, + relationshipHooks: ['旧案牵连', '局势试探'], + tags: [...template.tags], + backstoryReveal: buildBackstoryReveal(name), + skills: buildSkills(name), + initialItems: buildInitialItems(name), + }; + }); +} + +function buildLandmarks(seed: string, storyNpcIds: string[]) { + return LANDMARK_TEMPLATES.map((baseName, index, all) => { + const name = `${seed.slice(0, 2) || '裂潮'}${baseName}`; + return { + id: `landmark-${index + 1}`, + name, + description: `${name}附近同时压着旧痕、异动与尚未收束的危险。`, + dangerLevel: index < 3 ? 'medium' : index < 7 ? 'high' : 'extreme', + sceneNpcIds: [ + storyNpcIds[index % storyNpcIds.length], + storyNpcIds[(index + 7) % storyNpcIds.length], + storyNpcIds[(index + 13) % storyNpcIds.length], + ], + connections: [ + { + targetLandmarkId: `landmark-${((index + 1) % all.length) + 1}`, + relativePosition: 'forward', + summary: '沿着当前道路继续前推就能抵达。', + }, + { + targetLandmarkId: `landmark-${((index + all.length - 1) % all.length) + 1}`, + relativePosition: 'back', + summary: '沿原路回撤可以折返到上一处节点。', + }, + ], + }; + }); +} + +function buildProgress( + phaseId: string, + phaseLabel: string, + phaseDetail: string, + overallProgress: number, + activeStepIndex: number, + startedAt: number, +): CustomWorldGenerationProgress { + const steps = [ + { id: 'framework', label: '世界框架', detail: '整理世界基础骨架。', status: overallProgress >= 0.25 ? 'completed' : phaseId === 'framework' ? 'active' : 'pending', completed: overallProgress >= 0.25 ? 1 : 0, total: 1 }, + { id: 'roles', label: '角色群像', detail: '生成可玩与场景角色。', status: overallProgress >= 0.6 ? 'completed' : phaseId === 'roles' ? 'active' : 'pending', completed: overallProgress >= 0.6 ? 1 : 0, total: 1 }, + { id: 'landmarks', label: '场景网络', detail: '生成地标与连接关系。', status: overallProgress >= 0.85 ? 'completed' : phaseId === 'landmarks' ? 'active' : 'pending', completed: overallProgress >= 0.85 ? 1 : 0, total: 1 }, + { id: 'finalize', label: '最终归档', detail: '整理最终世界资料。', status: overallProgress >= 1 ? 'completed' : phaseId === 'finalize' ? 'active' : 'pending', completed: overallProgress >= 1 ? 1 : 0, total: 1 }, + ] as CustomWorldGenerationProgress['steps']; + + return { + phaseId, + phaseLabel, + phaseDetail, + overallProgress, + completedWeight: Math.round(overallProgress * 100), + totalWeight: 100, + elapsedMs: nowMs() - startedAt, + estimatedRemainingMs: overallProgress >= 1 ? 0 : Math.max(1000, Math.round((1 - overallProgress) * 4000)), + activeStepIndex, + steps, + }; +} + +function inferMajorFactions(seed: string) { + return [ + `${seed.slice(0, 2) || '裂潮'}守桥司`, + `${seed.slice(0, 2) || '裂潮'}旧案会`, + `${seed.slice(0, 2) || '裂潮'}商旅盟`, + ]; +} + +function inferCoreConflicts(seedText: string) { + const core = seedText.slice(0, 24) || '旧秩序与新威胁的失衡'; + return [ + `围绕“${core}”的旧秩序正在松动。`, + '各方都在争夺谁来解释眼前的异变。', + '真正推动局势的人始终没有完全现身。', + ]; +} + +function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) { + const setting = seedText(input); + const worldType = inferWorldType(setting); + const seed = setting.replace(/\s+/g, '').slice(0, 6) || (worldType === 'XIANXIA' ? '云潮' : '裂潮'); + const playableNpcs = buildPlayableNpcs(seed); + const storyNpcs = buildStoryNpcs(seed); + const landmarks = buildLandmarks( + seed, + storyNpcs.map((npc) => npc.id), + ); + + return { + id: `custom-world-${Date.now().toString(36)}-${slugify(seed)}`, + settingText: setting, + name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`, + subtitle: '前路未明', + summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`, + tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、江湖余震', + playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事', + templateWorldType: worldType, + majorFactions: inferMajorFactions(seed), + coreConflicts: inferCoreConflicts(setting), + attributeSchema: buildAttributeSchema(worldType), + playableNpcs, + storyNpcs, + items: [], + camp: { + name: worldType === 'XIANXIA' ? `${seed}归云舍` : `${seed}归桥居`, + description: '这是玩家开局时暂时安身、整理情报与调整队伍的位置。', + dangerLevel: 'low', + }, + landmarks, + themePack: null, + storyGraph: null, + knowledgeFacts: [], + threadContracts: [], + creatorIntent: input.creatorIntent ?? null, + anchorPack: null, + lockState: null, + ownedSettingLayers: null, + generationMode: input.generationMode ?? 'full', + generationStatus: input.generationMode === 'fast' ? 'key_only' : 'complete', + scenarioPackId: null, + campaignPackId: null, + } satisfies GeneratedProfile; +} + +export async function generateCustomWorldProfileFromOrchestrator( + input: GenerateCustomWorldProfileInput, + options: { + onProgress?: (progress: CustomWorldGenerationProgress) => void; + signal?: AbortSignal; + } = {}, +) { + if (options.signal?.aborted) { + throw new Error('世界生成已中断。'); + } + + const startedAt = nowMs(); + options.onProgress?.( + buildProgress( + 'framework', + '世界框架', + '正在整理世界基础设定与主矛盾。', + 0.2, + 0, + startedAt, + ), + ); + options.onProgress?.( + buildProgress( + 'roles', + '角色群像', + '正在生成可扮演角色与场景角色骨架。', + 0.55, + 1, + startedAt, + ), + ); + options.onProgress?.( + buildProgress( + 'landmarks', + '场景网络', + '正在生成地标与场景连接关系。', + 0.82, + 2, + startedAt, + ), + ); + + const profile = buildDeterministicProfile(input); + + options.onProgress?.( + buildProgress( + 'finalize', + '最终归档', + `世界“${String(profile.name)}”已完成归档。`, + 1, + 3, + startedAt, + ), + ); + + return profile; +} diff --git a/server-node/src/modules/ai/orchestrator.test.ts b/server-node/src/modules/ai/orchestrator.test.ts new file mode 100644 index 00000000..ff194a5a --- /dev/null +++ b/server-node/src/modules/ai/orchestrator.test.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { + CharacterChatSuggestionsRequest, +} from '../../../../packages/shared/src/contracts/story.js'; +import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; +import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js'; +import { SYSTEM_PROMPT } from './storyPromptBuilders.js'; +import { + generateCharacterChatSuggestionsFromOrchestrator, +} from './chatOrchestrator.js'; +import { generateInitialStoryFromOrchestrator } from './storyOrchestrator.js'; + +type TestStoryContext = Parameters[4]; +type TestStoryOption = Awaited< + ReturnType +>['options'][number]; +const TEST_WORLD = 'WUXIA' as Parameters< + typeof generateInitialStoryFromOrchestrator +>[1]; +type TestCharacter = Parameters[2]; + +function createTestCharacter(overrides: Partial = {}) { + return { + ...createTestPlayerCharacter(), + ...overrides, + }; +} + +function createStoryContext(): TestStoryContext { + return { + playerHp: 120, + playerMaxHp: 120, + playerMana: 40, + playerMaxMana: 40, + inBattle: false, + playerX: 320, + playerFacing: 'right', + playerAnimation: 'idle', + skillCooldowns: {}, + sceneId: 'inn_room', + sceneName: '客栈内室', + sceneDescription: '昏黄灯火照着刚刚停下脚步的木桌。', + pendingSceneEncounter: false, + }; +} + +function createAvailableOptions(context: TestStoryContext) { + void context; + return [ + { + functionId: 'idle_explore_forward', + actionText: '继续向前探索前路', + text: '继续向前探索前路', + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + { + functionId: 'idle_observe_signs', + actionText: '停步观察附近的风吹草动', + text: '停步观察附近的风吹草动', + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + ] as TestStoryOption[]; +} + +test('story orchestrator repairs mixed-language narrative on the server side', async () => { + const context = createStoryContext(); + const availableOptions = createAvailableOptions(context); + const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = []; + const llmClient = { + requestMessageContent: async ({ + systemPrompt, + userPrompt, + }: { + systemPrompt: string; + userPrompt: string; + }) => { + capturedPrompts.push({ systemPrompt, userPrompt }); + + if (capturedPrompts.length === 1) { + return JSON.stringify({ + storyText: 'The room falls quiet for a moment.', + encounter: null, + options: availableOptions.map((option) => ({ + functionId: option.functionId, + actionText: option.actionText, + })), + }); + } + + return JSON.stringify({ + storyText: '房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。', + encounter: null, + options: availableOptions.map((option) => ({ + functionId: option.functionId, + actionText: option.actionText, + })), + }); + }, + } as const; + + const response = await generateInitialStoryFromOrchestrator( + llmClient as never, + TEST_WORLD, + createTestCharacter(), + [], + context, + { + availableOptions, + }, + ); + + assert.equal(capturedPrompts.length, 2); + assert.equal(capturedPrompts[0]?.systemPrompt, SYSTEM_PROMPT); + assert.match(capturedPrompts[0]?.userPrompt ?? '', /客栈内室/u); + assert.equal( + response.storyText, + '房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。', + ); + assert.deepEqual( + response.options.map((option) => option.functionId), + availableOptions.map((option) => option.functionId), + ); +}); + +test('chat orchestrator builds character suggestion prompts on the server side', async () => { + const payload = { + worldType: TEST_WORLD, + playerCharacter: createTestCharacter(), + targetCharacter: createTestCharacter({ + id: 'test-companion', + name: '测试同伴', + title: '听风客', + }), + storyHistory: [], + context: createStoryContext(), + conversationHistory: [ + { speaker: 'player', text: '刚才那阵风是不是也不太对劲?' }, + { speaker: 'character', text: '像是有人故意把门帘掀起来了一样。' }, + ], + conversationSummary: '两人刚在客栈里察觉到不寻常的动静。', + targetStatus: { + roleLabel: '同行角色', + hp: 95, + maxHp: 120, + mana: 28, + maxMana: 40, + affinity: 18, + }, + } satisfies CharacterChatSuggestionsRequest; + const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = []; + const llmClient = { + requestMessageContent: async ({ + systemPrompt, + userPrompt, + }: { + systemPrompt: string; + userPrompt: string; + }) => { + capturedPrompts.push({ systemPrompt, userPrompt }); + return '先别急,我们再听一轮。\n你刚才看见谁动门帘了吗?\n要不我先去门边探一眼。'; + }, + } as const; + + const text = await generateCharacterChatSuggestionsFromOrchestrator( + llmClient as never, + payload, + ); + + assert.equal(text.split('\n').length, 3); + assert.equal( + capturedPrompts[0]?.systemPrompt, + CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, + ); + assert.match(capturedPrompts[0]?.userPrompt ?? '', /客栈/u); + assert.match(capturedPrompts[0]?.userPrompt ?? '', /两人刚在客栈里察觉到不寻常的动静/u); + assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u')); +}); diff --git a/server-node/src/modules/ai/storyOrchestrator.ts b/server-node/src/modules/ai/storyOrchestrator.ts new file mode 100644 index 00000000..54a38690 --- /dev/null +++ b/server-node/src/modules/ai/storyOrchestrator.ts @@ -0,0 +1,615 @@ +import { hasMixedNarrativeLanguage } from '../../../../packages/shared/src/llm/narrativeLanguage.js'; +import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js'; +import type { UpstreamLlmClient } from '../../services/llmClient.js'; +import { buildUserPrompt, SYSTEM_PROMPT } from './storyPromptBuilders.js'; + +type JsonRecord = Record; +type PromptWorldType = string; +type PromptCharacter = JsonRecord; +type PromptMonster = JsonRecord; +type PromptMonsters = PromptMonster[]; +type PromptStoryMoment = JsonRecord; +type PromptHistory = PromptStoryMoment[]; +type PromptContext = JsonRecord; +type PromptStoryOption = { + functionId: string; + actionText: string; + text?: string; + detailText?: string; + priority?: number; + visuals: { + playerAnimation: 'idle' | 'attack' | 'run' | 'hurt' | 'jump' | 'dash'; + playerMoveMeters: number; + playerOffsetY: number; + playerFacing: 'left' | 'right'; + scrollWorld: boolean; + monsterChanges: Array<{ + id: string; + action: string; + animation: 'idle' | 'move' | 'attack'; + moveMeters?: number; + yOffset?: number; + }>; + }; + interaction?: { + kind: 'npc' | 'treasure'; + npcId?: string; + action?: string; + }; + skillProbabilities?: Record; + goalAffordance?: { + goalId: string; + relation: 'advance' | 'support' | 'detour'; + label: string; + } | null; +}; +type PromptAvailableOptions = PromptStoryOption[]; +type PromptOptionCatalog = PromptStoryOption[]; +type StoryRequestOptions = { + availableOptions?: PromptAvailableOptions; + optionCatalog?: PromptOptionCatalog; +}; +type SceneEncounterResult = + | { kind: 'none' } + | { kind: 'npc'; npcId?: string } + | { kind: 'treasure'; treasureText?: string }; +type AIResponse = { + storyText: string; + options: PromptStoryOption[]; + encounter?: SceneEncounterResult; +}; + +type RawOptionItem = { + functionId: string; + actionText?: string; +}; + +const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。 +你会收到一个已经解析过的剧情 JSON 对象。 +你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。 +必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。 +只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`; + +const DEFAULT_VISUALS = { + playerAnimation: 'idle' as const, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right' as const, + scrollWorld: false, + monsterChanges: [], +}; + +const STATIC_FALLBACK_OPTION_MAP: Record< + string, + Partial & { actionText: string } +> = { + battle_all_in_crush: { actionText: '正面强压敌人' }, + battle_escape_breakout: { actionText: '先脱离眼前追杀' }, + battle_feint_step: { actionText: '借假动作切进身位' }, + battle_finisher_window: { actionText: '抓住破绽补上终结一击' }, + battle_guard_break: { actionText: '重击破开对手架势' }, + battle_probe_pressure: { actionText: '稳扎稳打继续试探' }, + battle_recover_breath: { actionText: '边守边调息稳住节奏' }, + idle_call_out: { actionText: '朝前方主动出声试探' }, + idle_explore_forward: { actionText: '继续向前探索前路' }, + idle_observe_signs: { actionText: '停步观察附近的风吹草动' }, + idle_rest_focus: { actionText: '原地调息整理状态' }, + idle_travel_next_scene: { actionText: '前往相邻场景' }, + npc_chat: { + actionText: '继续交谈', + interaction: { kind: 'npc', action: 'chat' }, + }, + npc_help: { + actionText: '请求援手', + interaction: { kind: 'npc', action: 'help' }, + }, + npc_fight: { + actionText: '直接开战', + interaction: { kind: 'npc', action: 'fight' }, + }, + npc_leave: { + actionText: '先拉开距离', + interaction: { kind: 'npc', action: 'leave' }, + }, + npc_preview_talk: { + actionText: '先试着接一句话', + interaction: { kind: 'npc', action: 'chat' }, + }, + npc_recruit: { + actionText: '正式邀请同行', + interaction: { kind: 'npc', action: 'recruit' }, + }, + npc_spar: { + actionText: '点到为止地切磋', + interaction: { kind: 'npc', action: 'spar' }, + }, + npc_trade: { + actionText: '看看能交换什么', + interaction: { kind: 'npc', action: 'trade' }, + }, + npc_gift: { + actionText: '送上一份礼物', + interaction: { kind: 'npc', action: 'gift' }, + }, + npc_quest_accept: { + actionText: '接下这份委托', + interaction: { kind: 'npc', action: 'quest_accept' }, + }, + npc_quest_turn_in: { + actionText: '交付已经完成的委托', + interaction: { kind: 'npc', action: 'quest_turn_in' }, + }, + treasure_inspect: { + actionText: '仔细检查', + interaction: { kind: 'treasure', action: 'inspect' }, + }, + treasure_leave: { + actionText: '先记下位置', + interaction: { kind: 'treasure', action: 'leave' }, + }, + treasure_secure: { + actionText: '直接收取', + interaction: { kind: 'treasure', action: 'secure' }, + }, +}; + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function inferNpcId(context: PromptContext, encounter?: SceneEncounterResult) { + if (encounter?.kind === 'npc' && encounter.npcId) { + return encounter.npcId; + } + + return readString(context.encounterId) || readString(context.encounterName); +} + +function createGenericOption(params: { + functionId: string; + actionText?: string; + context: PromptContext; + encounter?: SceneEncounterResult; +}) { + const functionId = params.functionId; + const preset = STATIC_FALLBACK_OPTION_MAP[functionId]; + const npcId = inferNpcId(params.context, params.encounter); + const interaction = + preset?.interaction?.kind === 'npc' && npcId + ? { + ...preset.interaction, + npcId, + } + : preset?.interaction; + + return { + functionId, + actionText: readString(params.actionText) || preset?.actionText || functionId, + text: readString(params.actionText) || preset?.actionText || functionId, + visuals: DEFAULT_VISUALS, + interaction, + } satisfies PromptStoryOption; +} + +function cloneStoryOption(option: PromptStoryOption): PromptStoryOption { + return { + ...option, + visuals: { + ...DEFAULT_VISUALS, + ...option.visuals, + monsterChanges: option.visuals?.monsterChanges?.map((change) => ({ + ...change, + })) ?? [], + }, + interaction: option.interaction ? { ...option.interaction } : undefined, + skillProbabilities: option.skillProbabilities + ? { ...option.skillProbabilities } + : undefined, + goalAffordance: option.goalAffordance ? { ...option.goalAffordance } : option.goalAffordance, + }; +} + +function normalizeEncounterResult( + raw: unknown, + context: PromptContext, +): SceneEncounterResult | undefined { + if (!context.pendingSceneEncounter) { + return undefined; + } + + if (!raw || typeof raw !== 'object') { + return { kind: 'none' }; + } + + const item = raw as Record; + const kind = readString(item.kind); + + if (kind === 'npc' || kind === 'monster') { + return { + kind: 'npc', + npcId: readString(item.npcId) || readString(context.encounterId) || undefined, + }; + } + + if (kind === 'treasure') { + return { + kind: 'treasure', + treasureText: readString(item.treasureText) || undefined, + }; + } + + return { kind: 'none' }; +} + +function resolveSafeGeneratedActionText(actionText: string | undefined) { + const trimmed = actionText?.trim(); + if (!trimmed || hasMixedNarrativeLanguage(trimmed)) { + return undefined; + } + + return trimmed; +} + +function resolveOptionsFromProvidedOptions( + items: RawOptionItem[], + availableOptions: PromptAvailableOptions, +) { + if (items.length === 0) { + return availableOptions.map(cloneStoryOption); + } + + const optionBuckets = new Map(); + const consumedOptions = new Set(); + availableOptions.forEach((option) => { + const bucket = optionBuckets.get(option.functionId) ?? []; + bucket.push(option); + optionBuckets.set(option.functionId, bucket); + }); + + const resolved: PromptStoryOption[] = []; + items.forEach((item) => { + const bucket = optionBuckets.get(item.functionId); + const matchedOption = bucket?.shift(); + if (!matchedOption) { + return; + } + consumedOptions.add(matchedOption); + + const rewrittenText = resolveSafeGeneratedActionText(item.actionText); + resolved.push({ + ...cloneStoryOption(matchedOption), + actionText: rewrittenText || matchedOption.actionText, + text: rewrittenText || matchedOption.text || matchedOption.actionText, + }); + }); + + if (resolved.length === availableOptions.length) { + return resolved; + } + + const remainingOptions = availableOptions.filter( + (option) => !consumedOptions.has(option), + ); + return [...resolved, ...remainingOptions.map(cloneStoryOption)]; +} + +function resolveOptionsFromOptionCatalog( + items: RawOptionItem[], + optionCatalog: PromptOptionCatalog, + context: PromptContext, + encounter?: SceneEncounterResult, +) { + if (items.length === 0) { + return optionCatalog.map(cloneStoryOption); + } + + const optionBuckets = new Map(); + optionCatalog.forEach((option) => { + const bucket = optionBuckets.get(option.functionId) ?? []; + bucket.push(option); + optionBuckets.set(option.functionId, bucket); + }); + + return items.map((item) => { + const bucket = optionBuckets.get(item.functionId); + const matchedOption = bucket?.shift(); + if (!matchedOption) { + return createGenericOption({ + functionId: item.functionId, + actionText: item.actionText, + context, + encounter, + }); + } + + const rewrittenText = resolveSafeGeneratedActionText(item.actionText); + return { + ...cloneStoryOption(matchedOption), + actionText: rewrittenText || matchedOption.actionText, + text: rewrittenText || matchedOption.text || matchedOption.actionText, + }; + }); +} + +function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) { + if (context.inBattle === true) { + return [ + 'battle_probe_pressure', + 'battle_guard_break', + 'battle_recover_breath', + 'battle_feint_step', + 'battle_finisher_window', + 'battle_escape_breakout', + ]; + } + + if (encounter?.kind === 'npc') { + return [ + 'npc_chat', + 'npc_help', + 'npc_trade', + 'npc_gift', + 'npc_recruit', + 'npc_leave', + ]; + } + + if (encounter?.kind === 'treasure') { + return ['treasure_inspect', 'treasure_secure', 'treasure_leave']; + } + + return [ + 'idle_explore_forward', + 'idle_call_out', + 'idle_observe_signs', + 'idle_rest_focus', + 'idle_travel_next_scene', + 'idle_explore_forward', + ]; +} + +function getFallbackOptions( + context: PromptContext, + encounter?: SceneEncounterResult, +) { + return getFallbackFunctionIds(context, encounter).map((functionId, index) => + createGenericOption({ + functionId: functionId === 'idle_explore_forward' && index > 0 ? `idle_explore_forward` : functionId, + context, + encounter, + }), + ); +} + +function buildStoryLanguageRepairPrompt(response: AIResponse) { + return [ + '请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。', + '只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。', + JSON.stringify( + { + storyText: response.storyText, + encounter: response.encounter ?? null, + options: response.options.map((option) => ({ + functionId: option.functionId, + actionText: option.actionText, + })), + }, + null, + 2, + ), + ].join('\n\n'); +} + +function needsStoryLanguageRepair(response: AIResponse) { + return hasMixedNarrativeLanguage(response.storyText); +} + +function buildStoryLanguageFallbackText(context: PromptContext) { + if (context.inBattle === true) { + return '敌意仍压在眼前,战斗局势还没有真正松开。'; + } + + if (readString(context.encounterName)) { + return `${readString(context.encounterName)}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`; + } + + return `${readString(context.sceneName) || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`; +} + +function finalizeStoryNarrativeLanguage( + response: AIResponse, + context: PromptContext, +): AIResponse { + if (!needsStoryLanguageRepair(response)) { + return response; + } + + return { + ...response, + storyText: buildStoryLanguageFallbackText(context), + }; +} + +function normalizeResponse( + raw: unknown, + context: PromptContext, + requestOptions: StoryRequestOptions = {}, +): AIResponse { + const parsedEncounter = normalizeEncounterResult( + (raw as Record | null)?.encounter, + context, + ); + const fallbackOptions = + requestOptions.availableOptions?.map(cloneStoryOption) ?? + requestOptions.optionCatalog?.map(cloneStoryOption) ?? + getFallbackOptions(context, parsedEncounter); + + if (!raw || typeof raw !== 'object') { + return { + storyText: + context.inBattle === true + ? '前方敌意仍在持续逼近,局势只允许继续交锋或抽身脱离。' + : '周围暂时平静下来,你可以继续探索或前往别处。', + options: fallbackOptions, + encounter: parsedEncounter, + }; + } + + const data = raw as Record; + const rawOptions = Array.isArray(data.options) ? data.options : []; + const optionItems = rawOptions + .map((option) => { + if (!option || typeof option !== 'object') { + return null; + } + const item = option as Record; + const functionId = readString(item.functionId); + if (!functionId) { + return null; + } + return { + functionId, + actionText: readString(item.actionText) || undefined, + } satisfies RawOptionItem; + }) + .filter(Boolean) as RawOptionItem[]; + + const options = requestOptions.availableOptions + ? resolveOptionsFromProvidedOptions(optionItems, requestOptions.availableOptions) + : requestOptions.optionCatalog + ? resolveOptionsFromOptionCatalog( + optionItems, + requestOptions.optionCatalog, + context, + parsedEncounter, + ) + : optionItems.length > 0 + ? optionItems.map((item) => + createGenericOption({ + functionId: item.functionId, + actionText: item.actionText, + context, + encounter: parsedEncounter, + }), + ) + : fallbackOptions; + + return { + storyText: + readString(data.storyText) || + (context.inBattle === true + ? '敌人仍在前方压迫而来,战斗还没有结束。' + : '前路重新安静下来,可以继续决定接下来的探索方向。'), + options: options.length > 0 ? options : fallbackOptions, + encounter: parsedEncounter, + }; +} + +async function repairStoryNarrativeLanguage( + llmClient: UpstreamLlmClient, + response: AIResponse, + context: PromptContext, + requestOptions: StoryRequestOptions, +) { + if (!needsStoryLanguageRepair(response)) { + return finalizeStoryNarrativeLanguage(response, context); + } + + try { + const repairedContent = await llmClient.requestMessageContent({ + systemPrompt: STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT, + userPrompt: buildStoryLanguageRepairPrompt(response), + }); + const repairedResponse = normalizeResponse( + parseJsonResponseText(repairedContent), + context, + requestOptions, + ); + return finalizeStoryNarrativeLanguage(repairedResponse, context); + } catch (error) { + llmClient.logger.warn( + { + err: error, + }, + 'story narrative language repair failed', + ); + return finalizeStoryNarrativeLanguage(response, context); + } +} + +async function requestStoryCompletion( + llmClient: UpstreamLlmClient, + params: { + worldType: PromptWorldType; + character: PromptCharacter; + monsters: PromptMonsters; + history: PromptHistory; + choice?: string; + context: PromptContext; + requestOptions?: StoryRequestOptions; + }, +) { + const content = await llmClient.requestMessageContent({ + systemPrompt: SYSTEM_PROMPT, + userPrompt: buildUserPrompt({ + worldType: params.worldType, + character: params.character, + monsters: params.monsters, + history: params.history, + context: params.context, + choice: params.choice, + requestOptions: params.requestOptions, + }), + }); + const response = normalizeResponse( + parseJsonResponseText(content), + params.context, + params.requestOptions, + ); + + return repairStoryNarrativeLanguage( + llmClient, + response, + params.context, + params.requestOptions ?? {}, + ); +} + +export async function generateInitialStoryFromOrchestrator( + llmClient: UpstreamLlmClient, + worldType: PromptWorldType, + character: PromptCharacter, + monsters: PromptMonsters, + context: PromptContext, + requestOptions: StoryRequestOptions = {}, +) { + return requestStoryCompletion(llmClient, { + worldType, + character, + monsters, + history: [], + context, + requestOptions, + }); +} + +export async function generateNextStoryFromOrchestrator( + llmClient: UpstreamLlmClient, + worldType: PromptWorldType, + character: PromptCharacter, + monsters: PromptMonsters, + history: PromptHistory, + choice: string, + context: PromptContext, + requestOptions: StoryRequestOptions = {}, +) { + return requestStoryCompletion(llmClient, { + worldType, + character, + monsters, + history, + choice, + context, + requestOptions, + }); +} diff --git a/server-node/src/modules/ai/storyPromptBuilders.ts b/server-node/src/modules/ai/storyPromptBuilders.ts new file mode 100644 index 00000000..484e3f6e --- /dev/null +++ b/server-node/src/modules/ai/storyPromptBuilders.ts @@ -0,0 +1,163 @@ +type JsonRecord = Record; + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function readNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function describeWorld(worldType: string) { + switch (worldType) { + case 'WUXIA': + return '武侠'; + case 'XIANXIA': + return '仙侠'; + case 'CUSTOM': + return '自定义世界'; + default: + return worldType || '未知世界'; + } +} + +function describeCharacter(character: JsonRecord) { + return [ + `主角:${readString(character.name) ?? '未知角色'}`, + `称号:${readString(character.title) ?? '未知称号'}`, + `描述:${readString(character.description) ?? '暂无'}`, + `性格:${readString(character.personality) ?? '未显式提供'}`, + ].join('\n'); +} + +function describeMonsters(monsters: JsonRecord[]) { + if (monsters.length <= 0) { + return '当前敌对目标:无。'; + } + + return [ + '当前敌对目标:', + ...monsters.slice(0, 4).map((monster) => { + const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标'; + const hp = readNumber(monster.hp); + const maxHp = Math.max(1, readNumber(monster.maxHp, hp)); + return `- ${name}(生命 ${hp}/${maxHp})`; + }), + ].join('\n'); +} + +function describeStoryHistory(history: JsonRecord[]) { + if (history.length <= 0) { + return '近期剧情:暂无。'; + } + + return [ + '近期剧情:', + ...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`), + ].join('\n'); +} + +function describeRequestOptions(options: { + availableOptions?: Array>; + optionCatalog?: Array>; +}) { + const available = options.availableOptions ?? []; + const catalog = options.optionCatalog ?? []; + + if (available.length > 0) { + return [ + '固定可选项列表:', + ...available.map((option, index) => { + const functionId = readString(option.functionId) ?? 'unknown'; + const actionText = + readString(option.actionText) ?? + readString(option.text) ?? + '未提供文案'; + return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`; + }), + '必须保持数量不变,functionId 不变,可以重写 actionText。'.trim(), + ].join('\n'); + } + + if (catalog.length > 0) { + return [ + '当前局面可调用的交互选项目录:', + ...catalog.map((option, index) => { + const functionId = readString(option.functionId) ?? 'unknown'; + const actionText = + readString(option.actionText) ?? + readString(option.text) ?? + '未提供文案'; + return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`; + }), + 'functionId 只能从上面目录里选择。'.trim(), + ].join('\n'); + } + + return '当前没有固定目录,请根据局势生成合理选项。'; +} + +export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。 +输出格式必须严格符合: +{ + "storyText": "剧情文本", + "encounter": null, + "options": [ + { + "functionId": "预定义功能ID", + "actionText": "选项显示文本" + } + ] +} + +严格规则: +- 所有文本必须是中文。 +- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。 +- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。 +- options 只允许输出 functionId 和 actionText。 +- 如果当前不是“继续推进后下一刻会遇到什么”的场景,encounter 必须保持为 null。`; + +export function buildUserPrompt(params: { + worldType: string; + character: JsonRecord; + monsters: JsonRecord[]; + history: JsonRecord[]; + context: JsonRecord; + choice?: string; + requestOptions?: { + availableOptions?: Array>; + optionCatalog?: Array>; + }; +}) { + const sceneName = readString(params.context.sceneName) ?? '当前区域'; + const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。'; + const encounterName = readString(params.context.encounterName); + const playerHp = readNumber(params.context.playerHp); + const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp)); + const playerMana = readNumber(params.context.playerMana); + const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana)); + const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗'; + const pendingSceneEncounter = + params.context.pendingSceneEncounter === true ? '是' : '否'; + + return [ + `世界:${describeWorld(params.worldType)}`, + `场景:${sceneName}`, + `场景描述:${sceneDescription}`, + encounterName ? `当前面前对象:${encounterName}` : null, + `当前状态:${inBattle}`, + `玩家生命:${playerHp}/${playerMaxHp}`, + `玩家灵力:${playerMana}/${playerMaxMana}`, + `是否需要判断下一刻遭遇:${pendingSceneEncounter}`, + describeCharacter(params.character), + describeMonsters(params.monsters), + describeStoryHistory(params.history), + params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。', + describeRequestOptions(params.requestOptions ?? {}), + params.context.pendingSceneEncounter === true + ? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter;否则 encounter 必须为 null。' + : '当前这一步不是新的遭遇生成流程,encounter 必须为 null。', + ] + .filter(Boolean) + .join('\n\n'); +} diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts new file mode 100644 index 00000000..76acdc8c --- /dev/null +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -0,0 +1,2505 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import http, { + type IncomingMessage, + type RequestOptions, + type ServerResponse, +} from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; + +import { Router, type NextFunction, type Request, type Response } from 'express'; + +import { + buildMasterPrompt, + buildVideoActionPrompt, + getActionTemplateById, +} from '../../../../packages/shared/src/assets/qwenSprite.js'; +import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js'; +import type { AppConfig } from '../../config.js'; + +const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate'; +const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish'; +const CHARACTER_VISUAL_JOBS_PATH = '/api/assets/character-visual/jobs/'; +const CHARACTER_ANIMATION_GENERATE_PATH = '/api/assets/character-animation/generate'; +const CHARACTER_ANIMATION_PUBLISH_PATH = '/api/assets/character-animation/publish'; +const CHARACTER_ANIMATION_JOBS_PATH = '/api/assets/character-animation/jobs/'; +const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = '/api/assets/character-animation/import-video'; +const CHARACTER_ANIMATION_TEMPLATES_PATH = '/api/assets/character-animation/templates'; +const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; +const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro'; +const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.2-kf2v-flash'; +const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v'; +const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move'; +const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500; +const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000; +const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000; +const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000; + +const BUILT_IN_MOTION_TEMPLATES = [ + { + id: 'idle_loop', + label: '待机循环', + animation: 'idle', + promptSuffix: '保持呼吸感和轻微重心起伏。', + notes: '适合方案三的默认待机模板。', + }, + { + id: 'run_side', + label: '奔跑侧移', + animation: 'run', + promptSuffix: '保持平稳横向移动,脚步连续。', + notes: '适合横版角色的标准奔跑模板。', + }, + { + id: 'attack_slash', + label: '横斩攻击', + animation: 'attack', + promptSuffix: '短促前踏后横斩,收招干净。', + notes: '适合近战角色的基础攻击模板。', + }, + { + id: 'hurt_back', + label: '受击后仰', + animation: 'hurt', + promptSuffix: '身体后仰,重心短暂失衡后稳住。', + notes: '适合方案三的受击模板。', + }, + { + id: 'die_fall', + label: '倒地死亡', + animation: 'die', + promptSuffix: '失衡倒地,动作完整结束。', + notes: '适合终结动作模板。', + }, +] as const; + +type RequestResponse = { + statusCode: number; + headers: Record; + body: Buffer; +}; + +type DecodedMediaPayload = { + buffer: Buffer; + mimeType: string; + extension: string; +}; + +function readJsonBody(req: IncomingMessage & { body?: unknown }) { + const parsedBody = req.body; + if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) { + return Promise.resolve(parsedBody as Record); + } + + return new Promise>((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8') || '{}'; + resolve(JSON.parse(raw)); + } catch (error) { + reject(error); + } + }); + req.on('error', reject); + }); +} + +function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +function isRecordValue(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.every((item) => typeof item === 'string' && item.trim().length > 0) + ); +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function normalizeDashScopeBaseUrl(value: string) { + return value.replace(/\/$/u, ''); +} + +function resolveRuntimeEnv(config: AppConfig) { + return config.rawEnv; +} + +function extractApiErrorMessage(responseText: string, fallbackMessage: string) { + return parseApiErrorMessage(responseText, fallbackMessage); +} + +function sanitizePathSegment(value: string) { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-_]+/gu, '-') + .replace(/-+/gu, '-') + .replace(/^-|-$/gu, ''); + + return normalized || 'asset'; +} + +function createTimestampId(prefix: string) { + return `${prefix}-${Date.now()}`; +} + +function getJobRecordPath( + rootDir: string, + kind: 'visual' | 'animation', + taskId: string, +) { + return path.resolve( + rootDir, + 'public', + 'generated-character-drafts', + '_jobs', + kind, + `${sanitizePathSegment(taskId)}.json`, + ); +} + +async function writeJobRecord( + rootDir: string, + kind: 'visual' | 'animation', + taskId: string, + payload: Record, +) { + const filePath = getJobRecordPath(rootDir, kind, taskId); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); +} + +async function readJobRecord( + rootDir: string, + kind: 'visual' | 'animation', + taskId: string, +) { + const filePath = getJobRecordPath(rootDir, kind, taskId); + const raw = await readFile(filePath, 'utf8'); + return JSON.parse(raw) as Record; +} + +async function readJsonObjectFile(filePath: string) { + try { + const content = await readFile(filePath, 'utf8'); + const parsed = JSON.parse(content); + return isRecordValue(parsed) ? parsed : {}; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw error; + } +} + +async function writeJsonObjectFile( + filePath: string, + payload: Record, +) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); +} + +function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload { + const matched = /^data:([^;]+);base64,(.+)$/u.exec(dataUrl); + if (!matched) { + throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。'); + } + + const mimeType = matched[1]; + const base64Payload = matched[2]; + const extension = (() => { + switch (mimeType) { + case 'image/jpeg': + return 'jpg'; + case 'image/png': + return 'png'; + case 'image/webp': + return 'webp'; + case 'video/mp4': + return 'mp4'; + case 'video/quicktime': + return 'mov'; + case 'video/x-msvideo': + return 'avi'; + default: + return mimeType.split('/')[1] ?? 'bin'; + } + })(); + + return { + buffer: Buffer.from(base64Payload, 'base64'), + mimeType, + extension, + }; +} + +async function resolveMediaSourcePayload( + rootDir: string, + source: string, +): Promise { + const dataUrlMatch = /^data:/u.test(source); + if (dataUrlMatch) { + return decodeMediaDataUrl(source); + } + + if (!source.startsWith('/')) { + throw new Error('媒体来源必须是 Data URL 或 public 目录下的 URL。'); + } + + const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, ''); + const absolutePath = path.resolve( + rootDir, + 'public', + ...normalizedSource.split('/'), + ); + const publicRoot = path.resolve(rootDir, 'public'); + if (!absolutePath.startsWith(publicRoot)) { + throw new Error('媒体来源路径越界。'); + } + + const buffer = await readFile(absolutePath); + const extension = path + .extname(absolutePath) + .replace(/^\./u, '') + .toLowerCase(); + const mimeType = (() => { + switch (extension) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'webp': + return 'image/webp'; + case 'mp4': + return 'video/mp4'; + case 'mov': + return 'video/quicktime'; + case 'avi': + return 'video/x-msvideo'; + default: + return 'application/octet-stream'; + } + })(); + + return { + buffer, + mimeType, + extension: extension || 'bin', + }; +} + +async function resolveMediaSourceAsDataUrl( + rootDir: string, + source: string, +) { + if (/^data:/u.test(source)) { + return source; + } + + const payload = await resolveMediaSourcePayload(rootDir, source); + return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`; +} + +function requestResponse( + urlString: string, + options: { + method?: string; + headers?: Record; + body?: Buffer | string; + } = {}, +) { + return new Promise((resolve, reject) => { + const url = new URL(urlString); + const transport = url.protocol === 'https:' ? https : http; + const payload = + typeof options.body === 'string' + ? Buffer.from(options.body) + : options.body; + const requestOptions: RequestOptions = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port ? Number(url.port) : undefined, + path: `${url.pathname}${url.search}`, + method: options.method ?? 'GET', + headers: { + ...(options.headers ?? {}), + ...(payload ? { 'Content-Length': String(payload.byteLength) } : {}), + }, + }; + + const request = transport.request(requestOptions, (upstreamRes) => { + const chunks: Buffer[] = []; + upstreamRes.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + upstreamRes.on('end', () => { + resolve({ + statusCode: upstreamRes.statusCode ?? 502, + headers: upstreamRes.headers, + body: Buffer.concat(chunks), + }); + }); + upstreamRes.on('error', reject); + }); + + request.on('error', reject); + if (payload) { + request.write(payload); + } + request.end(); + }); +} + +function getRequestPathname( + req: IncomingMessage & { originalUrl?: string }, +) { + return new URL(req.originalUrl || req.url || '/', 'http://localhost').pathname; +} + +function requestTextResponse( + urlString: string, + options: { + method?: string; + headers?: Record; + body?: Buffer | string; + } = {}, +) { + return requestResponse(urlString, options).then((response) => ({ + ...response, + bodyText: response.body.toString('utf8'), + })); +} + +function requestBinaryResponse( + urlString: string, + options: { + method?: string; + headers?: Record; + } = {}, +) { + return requestResponse(urlString, options); +} + +function proxyJsonRequest( + urlString: string, + apiKey: string, + body: Record, + extraHeaders: Record = {}, +) { + return requestTextResponse(urlString, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + ...extraHeaders, + }, + body: JSON.stringify(body), + }); +} + +function buildMultipartBody( + fields: Array<{ name: string; value: string }>, + file: { + fieldName: string; + fileName: string; + contentType: string; + buffer: Buffer; + }, +) { + const boundary = `----GenarrativeBoundary${Date.now().toString(16)}`; + const chunks: Buffer[] = []; + + fields.forEach((field) => { + chunks.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`, + ), + ); + }); + + chunks.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, + ), + ); + chunks.push(file.buffer); + chunks.push(Buffer.from(`\r\n--${boundary}--\r\n`)); + + return { + boundary, + body: Buffer.concat(chunks), + }; +} + +async function uploadFileToDashScope( + baseUrl: string, + apiKey: string, + model: string, + fileName: string, + payload: DecodedMediaPayload, +) { + const policyResponse = await requestTextResponse( + `${baseUrl}/uploads?action=getPolicy&model=${encodeURIComponent(model)}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + ); + + if (policyResponse.statusCode < 200 || policyResponse.statusCode >= 300) { + throw new Error( + extractApiErrorMessage( + policyResponse.bodyText, + '获取阿里云临时上传策略失败。', + ), + ); + } + + const policyResponsePayload = JSON.parse(policyResponse.bodyText) as { + data?: { + upload_host?: string; + upload_dir?: string; + policy?: string; + signature?: string; + oss_access_key_id?: string; + x_oss_object_acl?: string; + x_oss_content_type?: string; + x_oss_forbid_overwrite?: string; + 'x-oss-object-acl'?: string; + 'x-oss-content-type'?: string; + 'x-oss-forbid-overwrite'?: string; + }; + }; + const policyPayload = policyResponsePayload.data ?? {}; + + if ( + !policyPayload.upload_host || + !policyPayload.upload_dir || + !policyPayload.policy || + !policyPayload.signature || + !policyPayload.oss_access_key_id + ) { + throw new Error('阿里云临时上传策略返回不完整。'); + } + + const objectKey = `${policyPayload.upload_dir.replace(/\/+$/u, '')}/${sanitizePathSegment(fileName)}.${payload.extension}`; + const multipart = buildMultipartBody( + [ + { name: 'key', value: objectKey }, + { name: 'OSSAccessKeyId', value: policyPayload.oss_access_key_id }, + { name: 'policy', value: policyPayload.policy }, + { name: 'Signature', value: policyPayload.signature }, + { name: 'success_action_status', value: '200' }, + ...(policyPayload.x_oss_object_acl || policyPayload['x-oss-object-acl'] + ? [ + { + name: 'x-oss-object-acl', + value: + policyPayload.x_oss_object_acl || + policyPayload['x-oss-object-acl'] || + '', + }, + ] + : []), + ...(policyPayload.x_oss_forbid_overwrite || + policyPayload['x-oss-forbid-overwrite'] + ? [ + { + name: 'x-oss-forbid-overwrite', + value: + policyPayload.x_oss_forbid_overwrite || + policyPayload['x-oss-forbid-overwrite'] || + '', + }, + ] + : []), + ...(policyPayload.x_oss_content_type || + policyPayload['x-oss-content-type'] + ? [ + { + name: 'x-oss-content-type', + value: + policyPayload.x_oss_content_type || + policyPayload['x-oss-content-type'] || + '', + }, + ] + : []), + ], + { + fieldName: 'file', + fileName, + contentType: payload.mimeType, + buffer: payload.buffer, + }, + ); + + const uploadResponse = await requestTextResponse(policyPayload.upload_host, { + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`, + }, + body: multipart.body, + }); + + if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) { + throw new Error( + extractApiErrorMessage(uploadResponse.bodyText, '上传媒体文件失败。'), + ); + } + + return `oss://${objectKey}`; +} + +async function waitForDashScopeTask( + baseUrl: string, + apiKey: string, + taskId: string, + options: { + timeoutMs: number; + intervalMs: number; + }, +) { + const deadline = Date.now() + options.timeoutMs; + + while (Date.now() < deadline) { + const response = await requestTextResponse(`${baseUrl}/tasks/${taskId}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error( + extractApiErrorMessage( + response.bodyText, + `查询任务失败(${response.statusCode})。`, + ), + ); + } + + const parsed = JSON.parse(response.bodyText) as Record; + const output = isRecordValue(parsed.output) ? parsed.output : null; + const taskStatus = + output && typeof output.task_status === 'string' + ? output.task_status + : ''; + + if (taskStatus === 'SUCCEEDED') { + return parsed; + } + + if (taskStatus === 'FAILED' || taskStatus === 'CANCELED') { + throw new Error( + extractApiErrorMessage(response.bodyText, '任务执行失败。'), + ); + } + + if (taskStatus === 'UNKNOWN') { + throw new Error('任务状态未知,可能已过期。'); + } + + await sleep(options.intervalMs); + } + + throw new Error('任务执行超时,请稍后重试。'); +} + +function findFirstStringByKey( + value: unknown, + targetKey: string, +): string | null { + if (Array.isArray(value)) { + for (const item of value) { + const candidate = findFirstStringByKey(item, targetKey); + if (candidate) { + return candidate; + } + } + return null; + } + + if (!isRecordValue(value)) { + return null; + } + + const directValue = value[targetKey]; + if (typeof directValue === 'string' && directValue.trim()) { + return directValue.trim(); + } + + for (const nestedValue of Object.values(value)) { + const candidate = findFirstStringByKey(nestedValue, targetKey); + if (candidate) { + return candidate; + } + } + + return null; +} + +function collectStringsByKey( + value: unknown, + targetKey: string, + results: string[], +) { + if (Array.isArray(value)) { + value.forEach((item) => collectStringsByKey(item, targetKey, results)); + return; + } + + if (!isRecordValue(value)) { + return; + } + + const directValue = value[targetKey]; + if (typeof directValue === 'string' && directValue.trim()) { + results.push(directValue.trim()); + } + + Object.values(value).forEach((nestedValue) => + collectStringsByKey(nestedValue, targetKey, results), + ); +} + +function extractTaskId(payload: Record) { + return findFirstStringByKey(payload, 'task_id') ?? ''; +} + +function extractVideoUrl(payload: Record) { + return ( + findFirstStringByKey(payload, 'video_url') ?? + findFirstStringByKey(payload, 'url') ?? + '' + ); +} + +function extractImageUrls(payload: Record) { + const urls: string[] = []; + collectStringsByKey(payload, 'image', urls); + collectStringsByKey(payload, 'url', urls); + return [...new Set(urls)]; +} + +function buildNpcVisualPrompt( + promptText: string, + characterBriefText = '', +) { + const mergedBrief = [characterBriefText.trim(), promptText.trim()] + .filter(Boolean) + .join('\n'); + + return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。'); +} + +function buildImageSequencePrompt( + animation: string, + promptText: string, + frameCount: number, + useChromaKey: boolean, +) { + return [ + `同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}。`, + '固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。', + '帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。', + useChromaKey + ? '纯绿色背景,无地面装饰,方便后期抠像。' + : '背景尽量纯净,避免复杂场景。', + promptText.trim(), + ] + .filter(Boolean) + .join(' '); +} + +function buildNpcAnimationPrompt(options: { + animation: string; + promptText: string; + useChromaKey: boolean; + characterBriefText?: string; + actionTemplateId?: string; +}) { + if (options.actionTemplateId) { + return buildVideoActionPrompt({ + actionTemplate: getActionTemplateById( + options.actionTemplateId as Parameters[0], + ), + actionDetailText: options.promptText, + useChromaKey: options.useChromaKey, + characterBrief: + options.characterBriefText?.trim() || `${options.animation} 动作角色`, + }); + } + + return [ + `单人 NPC 全身动作视频,动作主题是 ${options.animation}。`, + '角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', + '动作连贯,避免服装、发型、面部、武器随机漂移。', + options.useChromaKey + ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' + : '背景简洁纯净,无复杂场景。', + options.characterBriefText?.trim() + ? `角色设定:${options.characterBriefText.trim()}` + : '', + options.promptText.trim(), + ] + .filter(Boolean) + .join(' '); +} + +async function writeDraftBinaryFile( + rootDir: string, + relativePath: string, + buffer: Buffer, +) { + const absolutePath = path.resolve( + rootDir, + 'public', + ...relativePath.split('/'), + ); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, buffer); + return `/${relativePath}`; +} + +async function handleGenerateCharacterVisuals( + config: AppConfig, + req: IncomingMessage & { body?: unknown }, + res: ServerResponse, +) { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + const rootDir = config.projectRoot; + const runtimeEnv = resolveRuntimeEnv(config); + const baseUrl = normalizeDashScopeBaseUrl( + runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, + ); + const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; + const timeoutMs = Number( + runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || + DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, + ); + + if (!apiKey) { + sendJson(res, 500, { + error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色主形象。' }, + }); + return; + } + + let body: Record; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); + return; + } + + const characterId = + typeof body.characterId === 'string' + ? body.characterId.trim() + : 'character'; + const sourceMode = + typeof body.sourceMode === 'string' ? body.sourceMode.trim() : ''; + const promptText = + typeof body.promptText === 'string' ? body.promptText.trim() : ''; + const characterBriefText = + typeof body.characterBriefText === 'string' + ? body.characterBriefText.trim() + : ''; + const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) + ? body.referenceImageDataUrls.slice(0, 4) + : []; + const candidateCountRaw = + typeof body.candidateCount === 'number' ? body.candidateCount : 3; + const candidateCount = Math.max( + 1, + Math.min(4, Math.round(candidateCountRaw)), + ); + const model = + typeof body.imageModel === 'string' && body.imageModel.trim() + ? body.imageModel.trim() + : runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || + runtimeEnv.DASHSCOPE_IMAGE_MODEL || + DEFAULT_CHARACTER_VISUAL_MODEL; + const size = + typeof body.size === 'string' && body.size.trim() + ? body.size.trim() + : '1024*1536'; + + if (sourceMode === 'image-to-image' && referenceImageDataUrls.length === 0) { + sendJson(res, 400, { + error: { message: '图生主形象至少需要一张参考图。' }, + }); + return; + } + + if (!promptText && !characterBriefText && sourceMode === 'text-to-image') { + sendJson(res, 400, { + error: { message: '文生主形象需要填写角色设定。' }, + }); + return; + } + + let activeTaskId = ''; + let activePrompt = ''; + try { + const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText); + activePrompt = finalPrompt; + const content = [ + { text: finalPrompt }, + ...referenceImageDataUrls.map((image) => ({ image })), + ]; + const createTaskResponse = await proxyJsonRequest( + `${baseUrl}/services/aigc/image-generation/generation`, + apiKey, + { + model, + input: { + messages: [ + { + role: 'user', + content, + }, + ], + }, + parameters: { + n: candidateCount, + size, + prompt_extend: true, + watermark: false, + }, + }, + { + 'X-DashScope-Async': 'enable', + }, + ); + + if ( + createTaskResponse.statusCode < 200 || + createTaskResponse.statusCode >= 300 + ) { + sendJson(res, createTaskResponse.statusCode, { + error: { + message: extractApiErrorMessage( + createTaskResponse.bodyText, + '创建角色主形象任务失败。', + ), + }, + }); + return; + } + + const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< + string, + unknown + >; + const taskId = extractTaskId(taskPayload); + activeTaskId = taskId; + + if (!taskId) { + throw new Error('角色主形象任务未返回 task_id。'); + } + + const createdAt = new Date().toISOString(); + await writeJobRecord(rootDir, 'visual', taskId, { + taskId, + kind: 'visual', + status: 'running', + characterId, + model, + prompt: finalPrompt, + createdAt, + updatedAt: createdAt, + }); + + const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { + timeoutMs: + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, + intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, + }); + const imageUrls = extractImageUrls(taskResult).slice(0, candidateCount); + + if (imageUrls.length === 0) { + throw new Error('角色主形象生成成功,但没有返回可下载图片。'); + } + + const jobId = createTimestampId('visual-draft'); + const draftRelativeDir = path.posix.join( + 'generated-character-drafts', + sanitizePathSegment(characterId), + 'visual', + jobId, + ); + const drafts = await Promise.all( + imageUrls.map(async (imageUrl, index) => { + const imageResponse = await requestBinaryResponse(imageUrl); + + if (imageResponse.statusCode < 200 || imageResponse.statusCode >= 300) { + throw new Error( + `下载主形象候选失败(${imageResponse.statusCode})。`, + ); + } + + const fileName = `candidate-${String(index + 1).padStart(2, '0')}.png`; + const imageSrc = await writeDraftBinaryFile( + rootDir, + path.posix.join(draftRelativeDir, fileName), + imageResponse.body, + ); + + return { + id: `candidate-${index + 1}`, + label: `候选 ${index + 1}`, + imageSrc, + width: 1024, + height: 1536, + }; + }), + ); + + await writeFile( + path.resolve( + rootDir, + 'public', + ...draftRelativeDir.split('/'), + 'job.json', + ), + JSON.stringify( + { + taskId, + model, + prompt: finalPrompt, + sourceMode, + createdAt: new Date().toISOString(), + imageUrls, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + await writeJobRecord(rootDir, 'visual', taskId, { + taskId, + kind: 'visual', + status: 'completed', + characterId, + model, + prompt: finalPrompt, + createdAt, + updatedAt: new Date().toISOString(), + result: { + drafts, + draftRelativeDir, + }, + }); + + sendJson(res, 200, { + ok: true, + taskId, + model, + prompt: finalPrompt, + drafts, + }); + } catch (error) { + if (activeTaskId) { + await writeJobRecord(rootDir, 'visual', activeTaskId, { + taskId: activeTaskId, + kind: 'visual', + status: 'failed', + characterId, + model, + prompt: activePrompt, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + errorMessage: error instanceof Error ? error.message : '生成角色主形象失败。', + }); + } + sendJson(res, 500, { + error: { + message: + error instanceof Error ? error.message : '生成角色主形象候选失败。', + }, + }); + } +} + +async function handleGenerateCharacterAnimation( + config: AppConfig, + req: IncomingMessage & { body?: unknown }, + res: ServerResponse, +) { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + const rootDir = config.projectRoot; + const runtimeEnv = resolveRuntimeEnv(config); + const baseUrl = normalizeDashScopeBaseUrl( + runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, + ); + const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; + const timeoutMs = Number( + runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || + runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || + DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, + ); + + if (!apiKey) { + sendJson(res, 500, { + error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色动作。' }, + }); + return; + } + + let body: Record; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); + return; + } + + const characterId = + typeof body.characterId === 'string' + ? body.characterId.trim() + : 'character'; + const strategy = + typeof body.strategy === 'string' ? body.strategy.trim() : ''; + const animation = + typeof body.animation === 'string' ? body.animation.trim() : 'idle'; + const promptText = + typeof body.promptText === 'string' ? body.promptText.trim() : ''; + const characterBriefText = + typeof body.characterBriefText === 'string' + ? body.characterBriefText.trim() + : ''; + const actionTemplateId = + typeof body.actionTemplateId === 'string' + ? body.actionTemplateId.trim() + : ''; + const visualSource = + typeof body.visualSource === 'string' ? body.visualSource.trim() : ''; + const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) + ? body.referenceImageDataUrls.slice(0, 6) + : []; + const referenceVideoDataUrls = isStringArray(body.referenceVideoDataUrls) + ? body.referenceVideoDataUrls.slice(0, 2) + : []; + const lastFrameImageDataUrl = + typeof body.lastFrameImageDataUrl === 'string' && + body.lastFrameImageDataUrl.trim() + ? body.lastFrameImageDataUrl.trim() + : ''; + const frameCount = + typeof body.frameCount === 'number' && Number.isFinite(body.frameCount) + ? Math.max(2, Math.min(16, Math.round(body.frameCount))) + : 8; + const requestedDurationSeconds = + typeof body.durationSeconds === 'number' && + Number.isFinite(body.durationSeconds) + ? Math.max(1, Math.min(8, Math.round(body.durationSeconds))) + : 4; + const useChromaKey = body.useChromaKey !== false; + const resolution = + typeof body.resolution === 'string' && body.resolution.trim() + ? body.resolution.trim() + : '720P'; + const imageSequenceModel = + typeof body.imageSequenceModel === 'string' && + body.imageSequenceModel.trim() + ? body.imageSequenceModel.trim() + : runtimeEnv.DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL || + runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || + DEFAULT_CHARACTER_VISUAL_MODEL; + const videoModel = + typeof body.videoModel === 'string' && body.videoModel.trim() + ? body.videoModel.trim() + : runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL || + DEFAULT_CHARACTER_VIDEO_MODEL; + const durationSeconds = + videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds; + const normalizedResolution = + videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution; + const referenceVideoModel = + typeof body.referenceVideoModel === 'string' && + body.referenceVideoModel.trim() + ? body.referenceVideoModel.trim() + : runtimeEnv.DASHSCOPE_CHARACTER_REFERENCE_VIDEO_MODEL || + DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL; + const motionTransferModel = + typeof body.motionTransferModel === 'string' && + body.motionTransferModel.trim() + ? body.motionTransferModel.trim() + : runtimeEnv.DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL || + DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL; + + if (!visualSource) { + sendJson(res, 400, { + error: { message: '请先准备主形象,再生成动作。' }, + }); + return; + } + + let activeTaskId = ''; + let activePrompt = ''; + let activeModel = ''; + try { + if (strategy === 'image-sequence') { + const finalPrompt = buildImageSequencePrompt( + animation, + promptText, + frameCount, + useChromaKey, + ); + activePrompt = finalPrompt; + activeModel = imageSequenceModel; + const createTaskResponse = await proxyJsonRequest( + `${baseUrl}/services/aigc/image-generation/generation`, + apiKey, + { + model: imageSequenceModel, + input: { + messages: [ + { + role: 'user', + content: [ + { text: finalPrompt }, + { image: visualSource }, + ...referenceImageDataUrls.map((image) => ({ image })), + ], + }, + ], + }, + parameters: { + n: frameCount, + size: '768*1024', + enable_sequential: true, + prompt_extend: true, + watermark: false, + }, + }, + { + 'X-DashScope-Async': 'enable', + }, + ); + + if ( + createTaskResponse.statusCode < 200 || + createTaskResponse.statusCode >= 300 + ) { + sendJson(res, createTaskResponse.statusCode, { + error: { + message: extractApiErrorMessage( + createTaskResponse.bodyText, + '创建动作序列帧任务失败。', + ), + }, + }); + return; + } + + const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< + string, + unknown + >; + const taskId = extractTaskId(taskPayload); + activeTaskId = taskId; + + if (!taskId) { + throw new Error('动作序列帧任务未返回 task_id。'); + } + + const createdAt = new Date().toISOString(); + await writeJobRecord(rootDir, 'animation', taskId, { + taskId, + kind: 'animation', + status: 'running', + characterId, + animation, + strategy, + model: imageSequenceModel, + prompt: finalPrompt, + createdAt, + updatedAt: createdAt, + }); + + const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { + timeoutMs: + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, + intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, + }); + const imageUrls = extractImageUrls(taskResult).slice(0, frameCount); + + if (imageUrls.length === 0) { + throw new Error('动作序列帧生成成功,但没有返回图片。'); + } + + const jobId = createTimestampId('animation-seq'); + const draftRelativeDir = path.posix.join( + 'generated-character-drafts', + sanitizePathSegment(characterId), + 'animation', + sanitizePathSegment(animation), + jobId, + ); + const imageSources = await Promise.all( + imageUrls.map(async (imageUrl, index) => { + const imageResponse = await requestBinaryResponse(imageUrl); + + if ( + imageResponse.statusCode < 200 || + imageResponse.statusCode >= 300 + ) { + throw new Error(`下载动作帧失败(${imageResponse.statusCode})。`); + } + + return writeDraftBinaryFile( + rootDir, + path.posix.join( + draftRelativeDir, + `frame-${String(index + 1).padStart(2, '0')}.png`, + ), + imageResponse.body, + ); + }), + ); + + await writeFile( + path.resolve( + rootDir, + 'public', + ...draftRelativeDir.split('/'), + 'job.json', + ), + JSON.stringify( + { + taskId, + model: imageSequenceModel, + strategy, + animation, + prompt: finalPrompt, + createdAt: new Date().toISOString(), + imageUrls, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + await writeJobRecord(rootDir, 'animation', taskId, { + taskId, + kind: 'animation', + status: 'completed', + characterId, + animation, + strategy, + model: imageSequenceModel, + prompt: finalPrompt, + createdAt, + updatedAt: new Date().toISOString(), + result: { + imageSources, + draftRelativeDir, + }, + }); + + sendJson(res, 200, { + ok: true, + taskId, + strategy: 'image-sequence', + model: imageSequenceModel, + prompt: finalPrompt, + imageSources, + }); + return; + } + + if (strategy === 'image-to-video') { + const finalPrompt = buildNpcAnimationPrompt({ + animation, + promptText, + useChromaKey, + characterBriefText, + actionTemplateId, + }); + activePrompt = finalPrompt; + activeModel = videoModel; + const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash'; + const visualInputRef = isKf2vFlash + ? await resolveMediaSourceAsDataUrl(rootDir, visualSource) + : await uploadFileToDashScope( + baseUrl, + apiKey, + videoModel, + `${characterId}-${animation}-visual`, + await resolveMediaSourcePayload(rootDir, visualSource), + ); + const lastFrameRef = lastFrameImageDataUrl + ? isKf2vFlash + ? await resolveMediaSourceAsDataUrl(rootDir, lastFrameImageDataUrl) + : await uploadFileToDashScope( + baseUrl, + apiKey, + videoModel, + `${characterId}-${animation}-last-frame`, + await resolveMediaSourcePayload( + rootDir, + lastFrameImageDataUrl, + ), + ) + : ''; + const inputPayload = + isKf2vFlash + ? { + prompt: finalPrompt, + first_frame_url: visualInputRef, + ...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}), + } + : { + prompt: finalPrompt, + media: [ + { type: 'first_frame', url: visualInputRef }, + ...(lastFrameRef + ? [{ type: 'last_frame', url: lastFrameRef }] + : []), + ], + }; + const videoSynthesisEndpoint = isKf2vFlash + ? `${baseUrl}/services/aigc/image2video/video-synthesis` + : `${baseUrl}/services/aigc/video-generation/video-synthesis`; + + const createTaskResponse = await proxyJsonRequest( + videoSynthesisEndpoint, + apiKey, + { + model: videoModel, + input: inputPayload, + parameters: { + duration: durationSeconds, + resolution: normalizedResolution, + ...(isKf2vFlash ? { prompt_extend: true, watermark: false } : {}), + }, + }, + { + 'X-DashScope-Async': 'enable', + 'X-DashScope-OssResourceResolve': 'enable', + }, + ); + + if ( + createTaskResponse.statusCode < 200 || + createTaskResponse.statusCode >= 300 + ) { + sendJson(res, createTaskResponse.statusCode, { + error: { + message: extractApiErrorMessage( + createTaskResponse.bodyText, + '创建图生视频任务失败。', + ), + }, + }); + return; + } + + const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< + string, + unknown + >; + const taskId = extractTaskId(taskPayload); + activeTaskId = taskId; + + if (!taskId) { + throw new Error('图生视频任务未返回 task_id。'); + } + + const createdAt = new Date().toISOString(); + await writeJobRecord(rootDir, 'animation', taskId, { + taskId, + kind: 'animation', + status: 'running', + characterId, + animation, + strategy, + model: videoModel, + prompt: finalPrompt, + createdAt, + updatedAt: createdAt, + }); + + const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { + timeoutMs: + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, + intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, + }); + const videoUrl = extractVideoUrl(taskResult); + + if (!videoUrl) { + throw new Error('图生视频成功,但没有返回视频链接。'); + } + + const videoResponse = await requestBinaryResponse(videoUrl); + if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { + throw new Error(`下载图生视频失败(${videoResponse.statusCode})。`); + } + + const jobId = createTimestampId('animation-video'); + const draftRelativeDir = path.posix.join( + 'generated-character-drafts', + sanitizePathSegment(characterId), + 'animation', + sanitizePathSegment(animation), + jobId, + ); + const previewVideoPath = await writeDraftBinaryFile( + rootDir, + path.posix.join(draftRelativeDir, 'preview.mp4'), + videoResponse.body, + ); + + await writeFile( + path.resolve( + rootDir, + 'public', + ...draftRelativeDir.split('/'), + 'job.json', + ), + JSON.stringify( + { + taskId, + model: videoModel, + strategy, + animation, + prompt: finalPrompt, + createdAt: new Date().toISOString(), + videoUrl, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + await writeJobRecord(rootDir, 'animation', taskId, { + taskId, + kind: 'animation', + status: 'completed', + characterId, + animation, + strategy, + model: videoModel, + prompt: finalPrompt, + createdAt, + updatedAt: new Date().toISOString(), + result: { + previewVideoPath, + draftRelativeDir, + }, + }); + + sendJson(res, 200, { + ok: true, + taskId, + strategy: 'image-to-video', + model: videoModel, + prompt: finalPrompt, + previewVideoPath, + }); + return; + } + + const modelForVisualUpload = + strategy === 'reference-to-video' + ? referenceVideoModel + : strategy === 'motion-transfer' + ? motionTransferModel + : videoModel; + const visualUrl = await uploadFileToDashScope( + baseUrl, + apiKey, + modelForVisualUpload, + `${characterId}-${animation}-visual`, + await resolveMediaSourcePayload(rootDir, visualSource), + ); + + if (strategy === 'motion-transfer') { + if (referenceVideoDataUrls.length === 0) { + sendJson(res, 400, { + error: { message: '动作模板驱动至少需要一段参考视频。' }, + }); + return; + } + + const finalPrompt = buildNpcAnimationPrompt({ + animation, + promptText, + useChromaKey, + characterBriefText, + }); + activePrompt = finalPrompt; + activeModel = motionTransferModel; + const referenceVideoUrl = await uploadFileToDashScope( + baseUrl, + apiKey, + motionTransferModel, + `${characterId}-${animation}-reference-video`, + await resolveMediaSourcePayload(rootDir, referenceVideoDataUrls[0]), + ); + const createTaskResponse = await proxyJsonRequest( + `${baseUrl}/services/aigc/image2video/video-synthesis`, + apiKey, + { + model: motionTransferModel, + input: { + prompt: finalPrompt, + image_url: visualUrl, + video_url: referenceVideoUrl, + watermark: false, + }, + parameters: { + mode: 'wan-std', + }, + }, + { + 'X-DashScope-Async': 'enable', + 'X-DashScope-OssResourceResolve': 'enable', + }, + ); + + if ( + createTaskResponse.statusCode < 200 || + createTaskResponse.statusCode >= 300 + ) { + sendJson(res, createTaskResponse.statusCode, { + error: { + message: extractApiErrorMessage( + createTaskResponse.bodyText, + '创建动作模板迁移任务失败。', + ), + }, + }); + return; + } + + const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< + string, + unknown + >; + const taskId = extractTaskId(taskPayload); + activeTaskId = taskId; + + if (!taskId) { + throw new Error('动作模板迁移任务未返回 task_id。'); + } + + const createdAt = new Date().toISOString(); + await writeJobRecord(rootDir, 'animation', taskId, { + taskId, + kind: 'animation', + status: 'running', + characterId, + animation, + strategy, + model: motionTransferModel, + prompt: finalPrompt, + createdAt, + updatedAt: createdAt, + }); + + const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { + timeoutMs: + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, + intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, + }); + const videoUrl = extractVideoUrl(taskResult); + + if (!videoUrl) { + throw new Error('动作模板迁移成功,但没有返回视频链接。'); + } + + const videoResponse = await requestBinaryResponse(videoUrl); + if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { + throw new Error( + `下载动作模板视频失败(${videoResponse.statusCode})。`, + ); + } + + const jobId = createTimestampId('animation-motion'); + const draftRelativeDir = path.posix.join( + 'generated-character-drafts', + sanitizePathSegment(characterId), + 'animation', + sanitizePathSegment(animation), + jobId, + ); + const previewVideoPath = await writeDraftBinaryFile( + rootDir, + path.posix.join(draftRelativeDir, 'preview.mp4'), + videoResponse.body, + ); + + await writeFile( + path.resolve( + rootDir, + 'public', + ...draftRelativeDir.split('/'), + 'job.json', + ), + JSON.stringify( + { + taskId, + model: motionTransferModel, + strategy, + animation, + prompt: finalPrompt, + createdAt: new Date().toISOString(), + videoUrl, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + await writeJobRecord(rootDir, 'animation', taskId, { + taskId, + kind: 'animation', + status: 'completed', + characterId, + animation, + strategy, + model: motionTransferModel, + prompt: finalPrompt, + createdAt, + updatedAt: new Date().toISOString(), + result: { + previewVideoPath, + draftRelativeDir, + }, + }); + + sendJson(res, 200, { + ok: true, + taskId, + strategy: 'motion-transfer', + model: motionTransferModel, + prompt: finalPrompt, + previewVideoPath, + }); + return; + } + + if (strategy === 'reference-to-video') { + const uploadedReferenceUrls = await Promise.all([ + ...referenceImageDataUrls.map(async (source, index) => + uploadFileToDashScope( + baseUrl, + apiKey, + referenceVideoModel, + `${characterId}-${animation}-reference-image-${index + 1}`, + await resolveMediaSourcePayload(rootDir, source), + ), + ), + ...referenceVideoDataUrls.map(async (source, index) => + uploadFileToDashScope( + baseUrl, + apiKey, + referenceVideoModel, + `${characterId}-${animation}-reference-video-${index + 1}`, + await resolveMediaSourcePayload(rootDir, source), + ), + ), + ]); + + if (uploadedReferenceUrls.length === 0) { + sendJson(res, 400, { + error: { message: '参考生视频至少需要一张参考图或一段参考视频。' }, + }); + return; + } + + const finalPrompt = buildNpcAnimationPrompt({ + animation, + promptText, + useChromaKey, + characterBriefText, + }); + activePrompt = finalPrompt; + activeModel = referenceVideoModel; + const createTaskResponse = await proxyJsonRequest( + `${baseUrl}/services/aigc/video-generation/video-synthesis`, + apiKey, + { + model: referenceVideoModel, + input: { + prompt: finalPrompt, + reference_urls: [visualUrl, ...uploadedReferenceUrls], + }, + parameters: { + duration: durationSeconds, + resolution, + prompt_optimizer: true, + }, + }, + { + 'X-DashScope-Async': 'enable', + 'X-DashScope-OssResourceResolve': 'enable', + }, + ); + + if ( + createTaskResponse.statusCode < 200 || + createTaskResponse.statusCode >= 300 + ) { + sendJson(res, createTaskResponse.statusCode, { + error: { + message: extractApiErrorMessage( + createTaskResponse.bodyText, + '创建参考生视频任务失败。', + ), + }, + }); + return; + } + + const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< + string, + unknown + >; + const taskId = extractTaskId(taskPayload); + activeTaskId = taskId; + + if (!taskId) { + throw new Error('参考生视频任务未返回 task_id。'); + } + + const createdAt = new Date().toISOString(); + await writeJobRecord(rootDir, 'animation', taskId, { + taskId, + kind: 'animation', + status: 'running', + characterId, + animation, + strategy, + model: referenceVideoModel, + prompt: finalPrompt, + createdAt, + updatedAt: createdAt, + }); + + const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { + timeoutMs: + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, + intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, + }); + const videoUrl = extractVideoUrl(taskResult); + + if (!videoUrl) { + throw new Error('参考生视频成功,但没有返回视频链接。'); + } + + const videoResponse = await requestBinaryResponse(videoUrl); + if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { + throw new Error(`下载参考生视频失败(${videoResponse.statusCode})。`); + } + + const jobId = createTimestampId('animation-reference'); + const draftRelativeDir = path.posix.join( + 'generated-character-drafts', + sanitizePathSegment(characterId), + 'animation', + sanitizePathSegment(animation), + jobId, + ); + const previewVideoPath = await writeDraftBinaryFile( + rootDir, + path.posix.join(draftRelativeDir, 'preview.mp4'), + videoResponse.body, + ); + + await writeFile( + path.resolve( + rootDir, + 'public', + ...draftRelativeDir.split('/'), + 'job.json', + ), + JSON.stringify( + { + taskId, + model: referenceVideoModel, + strategy, + animation, + prompt: finalPrompt, + createdAt: new Date().toISOString(), + videoUrl, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + await writeJobRecord(rootDir, 'animation', taskId, { + taskId, + kind: 'animation', + status: 'completed', + characterId, + animation, + strategy, + model: referenceVideoModel, + prompt: finalPrompt, + createdAt, + updatedAt: new Date().toISOString(), + result: { + previewVideoPath, + draftRelativeDir, + }, + }); + + sendJson(res, 200, { + ok: true, + taskId, + strategy: 'reference-to-video', + model: referenceVideoModel, + prompt: finalPrompt, + previewVideoPath, + }); + return; + } + + sendJson(res, 400, { + error: { message: `不支持的动作生成策略:${strategy || 'unknown'}` }, + }); + } catch (error) { + if (activeTaskId) { + await writeJobRecord(rootDir, 'animation', activeTaskId, { + taskId: activeTaskId, + kind: 'animation', + status: 'failed', + characterId, + animation, + strategy, + model: activeModel, + prompt: activePrompt, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + errorMessage: error instanceof Error ? error.message : '生成角色动作失败。', + }); + } + sendJson(res, 500, { + error: { + message: error instanceof Error ? error.message : '生成角色动作失败。', + }, + }); + } +} + +async function handleReadCharacterJobStatus( + rootDir: string, + req: IncomingMessage & { originalUrl?: string }, + res: ServerResponse, + kind: 'visual' | 'animation', +) { + if (req.method !== 'GET') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + const pathname = getRequestPathname(req); + const prefix = + kind === 'visual' ? CHARACTER_VISUAL_JOBS_PATH : CHARACTER_ANIMATION_JOBS_PATH; + const taskId = decodeURIComponent(pathname.slice(prefix.length)).trim(); + + if (!taskId) { + sendJson(res, 400, { error: { message: 'taskId is required.' } }); + return; + } + + try { + const record = await readJobRecord(rootDir, kind, taskId); + sendJson(res, 200, record); + } catch (error) { + sendJson(res, 404, { + error: { + message: + error instanceof Error + ? error.message + : '未找到对应的任务记录。', + }, + }); + } +} + +async function handleImportCharacterAnimationVideo( + rootDir: string, + req: IncomingMessage & { body?: unknown }, + res: ServerResponse, +) { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + let body: Record; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); + return; + } + + const characterId = + typeof body.characterId === 'string' + ? body.characterId.trim() + : 'character'; + const animation = + typeof body.animation === 'string' ? body.animation.trim() : 'clip'; + const videoSource = + typeof body.videoSource === 'string' ? body.videoSource.trim() : ''; + const sourceLabel = + typeof body.sourceLabel === 'string' && body.sourceLabel.trim() + ? body.sourceLabel.trim() + : 'imported-video'; + + if (!videoSource) { + sendJson(res, 400, { error: { message: 'videoSource is required.' } }); + return; + } + + try { + const payload = await resolveMediaSourcePayload(rootDir, videoSource); + const draftId = createTimestampId('animation-import'); + const relativeDir = path.posix.join( + 'generated-character-drafts', + sanitizePathSegment(characterId), + 'animation', + sanitizePathSegment(animation), + draftId, + ); + const fileName = `${sanitizePathSegment(sourceLabel)}.${payload.extension}`; + const importedVideoPath = await writeDraftBinaryFile( + rootDir, + path.posix.join(relativeDir, fileName), + payload.buffer, + ); + + await writeFile( + path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'import.json'), + JSON.stringify( + { + characterId, + animation, + sourceLabel, + importedVideoPath, + createdAt: new Date().toISOString(), + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + sendJson(res, 200, { + ok: true, + importedVideoPath, + draftId, + saveMessage: '参考视频已导入到本地草稿目录。', + }); + } catch (error) { + sendJson(res, 500, { + error: { + message: + error instanceof Error ? error.message : '导入动作视频失败。', + }, + }); + } +} + +function handleListAnimationTemplates( + _config: AppConfig, + req: IncomingMessage, + res: ServerResponse, +) { + if (req.method !== 'GET') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + sendJson(res, 200, { + ok: true, + templates: BUILT_IN_MOTION_TEMPLATES, + }); +} + +async function handlePublishCharacterVisual( + config: AppConfig, + req: IncomingMessage & { body?: unknown }, + res: ServerResponse, +) { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + let body: Record; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); + return; + } + + const rootDir = config.projectRoot; + const characterOverridesFilePath = path.resolve( + rootDir, + 'src/data/characterOverrides.json', + ); + const characterId = + typeof body.characterId === 'string' ? body.characterId.trim() : ''; + const sourceMode = + typeof body.sourceMode === 'string' ? body.sourceMode.trim() : 'upload'; + const promptText = + typeof body.promptText === 'string' && body.promptText.trim() + ? body.promptText.trim() + : undefined; + const selectedPreviewSource = + typeof body.selectedPreviewSource === 'string' + ? body.selectedPreviewSource + : ''; + const previewSources = isStringArray(body.previewSources) + ? body.previewSources + : []; + const width = + typeof body.width === 'number' && Number.isFinite(body.width) + ? body.width + : 1024; + const height = + typeof body.height === 'number' && Number.isFinite(body.height) + ? body.height + : 1536; + const updateCharacterOverride = body.updateCharacterOverride !== false; + + if (!characterId) { + sendJson(res, 400, { error: { message: 'characterId is required.' } }); + return; + } + + if (!selectedPreviewSource) { + sendJson(res, 400, { + error: { message: 'selectedPreviewSource is required.' }, + }); + return; + } + + try { + const assetId = createTimestampId('visual'); + const visualDir = path.resolve( + rootDir, + 'public/generated-characters', + sanitizePathSegment(characterId), + 'visual', + assetId, + ); + await mkdir(visualDir, { recursive: true }); + + const masterPayload = await resolveMediaSourcePayload( + rootDir, + selectedPreviewSource, + ); + const masterFileName = `master.${masterPayload.extension}`; + await writeFile(path.join(visualDir, masterFileName), masterPayload.buffer); + + const previewImagePaths: string[] = []; + for (let index = 0; index < previewSources.length; index += 1) { + const previewPayload = await resolveMediaSourcePayload( + rootDir, + previewSources[index] ?? '', + ); + const previewFileName = `preview-${index + 1}.${previewPayload.extension}`; + await writeFile( + path.join(visualDir, previewFileName), + previewPayload.buffer, + ); + previewImagePaths.push( + `/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${previewFileName}`, + ); + } + + const masterImagePath = `/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${masterFileName}`; + const manifest: PublishedVisualManifest = { + id: assetId, + characterId, + sourceMode, + promptText, + masterImagePath, + previewImagePaths, + width, + height, + facing: 'right', + locked: true, + }; + + await writeFile( + path.join(visualDir, 'visual-manifest.json'), + JSON.stringify(manifest, null, 2) + '\n', + 'utf8', + ); + + let overrideMap: Record = {}; + if (updateCharacterOverride) { + overrideMap = await readJsonObjectFile(characterOverridesFilePath); + const existingOverride = overrideMap[characterId]; + const nextOverride = isRecordValue(existingOverride) + ? { ...existingOverride } + : {}; + nextOverride.generatedVisualAssetId = assetId; + nextOverride.portrait = masterImagePath; + overrideMap[characterId] = nextOverride; + await writeJsonObjectFile(characterOverridesFilePath, overrideMap); + } + + sendJson(res, 200, { + ok: true, + assetId, + portraitPath: masterImagePath, + overrideMap, + saveMessage: updateCharacterOverride + ? '主形象已发布到 public/generated-characters,并更新角色覆盖。' + : '主形象已保存到 public/generated-characters,可直接写回当前自定义世界角色。', + }); + } catch (error) { + sendJson(res, 500, { + error: { + message: + error instanceof Error + ? error.message + : '发布角色主形象失败。', + }, + }); + } +} + +async function handlePublishCharacterAnimation( + config: AppConfig, + req: IncomingMessage & { body?: unknown }, + res: ServerResponse, +) { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + let body: Record; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); + return; + } + + const rootDir = config.projectRoot; + const characterOverridesFilePath = path.resolve( + rootDir, + 'src/data/characterOverrides.json', + ); + const characterId = + typeof body.characterId === 'string' ? body.characterId.trim() : ''; + const visualAssetId = + typeof body.visualAssetId === 'string' ? body.visualAssetId.trim() : ''; + const animations = isRecordValue(body.animations) ? body.animations : null; + const updateCharacterOverride = body.updateCharacterOverride !== false; + + if (!characterId) { + sendJson(res, 400, { error: { message: 'characterId is required.' } }); + return; + } + + if (!visualAssetId) { + sendJson(res, 400, { error: { message: 'visualAssetId is required.' } }); + return; + } + + if (!animations || Object.keys(animations).length === 0) { + sendJson(res, 400, { error: { message: 'animations is required.' } }); + return; + } + + try { + const animationSetId = createTimestampId('animation-set'); + const baseAnimationDir = path.resolve( + rootDir, + 'public/generated-animations', + sanitizePathSegment(characterId), + animationSetId, + ); + await mkdir(baseAnimationDir, { recursive: true }); + + const actionManifests: PublishedAnimationManifest[] = []; + const nextAnimationMap: Record> = {}; + + for (const [action, rawAnimation] of Object.entries(animations)) { + if (!isRecordValue(rawAnimation)) { + continue; + } + + const framesDataUrls = isStringArray(rawAnimation.framesDataUrls) + ? rawAnimation.framesDataUrls + : []; + if (framesDataUrls.length === 0) { + continue; + } + + const fps = + typeof rawAnimation.fps === 'number' && Number.isFinite(rawAnimation.fps) + ? rawAnimation.fps + : 8; + const loop = rawAnimation.loop === true; + const frameWidth = + typeof rawAnimation.frameWidth === 'number' && + Number.isFinite(rawAnimation.frameWidth) + ? rawAnimation.frameWidth + : 192; + const frameHeight = + typeof rawAnimation.frameHeight === 'number' && + Number.isFinite(rawAnimation.frameHeight) + ? rawAnimation.frameHeight + : 256; + const actionKey = sanitizePathSegment(action); + const actionDir = path.join(baseAnimationDir, actionKey); + await mkdir(actionDir, { recursive: true }); + + const framePaths: string[] = []; + let frameExtension = 'png'; + for (let index = 0; index < framesDataUrls.length; index += 1) { + const framePayload = await resolveMediaSourcePayload( + rootDir, + framesDataUrls[index] ?? '', + ); + frameExtension = framePayload.extension; + const frameFileName = `frame${String(index + 1).padStart(2, '0')}.${framePayload.extension}`; + await writeFile(path.join(actionDir, frameFileName), framePayload.buffer); + framePaths.push( + `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}/${frameFileName}`, + ); + } + + const basePath = `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}`; + const previewVideoPath = + typeof rawAnimation.previewVideoPath === 'string' && + rawAnimation.previewVideoPath.trim() + ? rawAnimation.previewVideoPath.trim() + : undefined; + const manifest: PublishedAnimationManifest = { + id: `${animationSetId}-${actionKey}`, + animationSetId, + characterId, + visualAssetId, + action, + frameCount: framePaths.length, + fps, + loop, + frameWidth, + frameHeight, + previewVideoPath, + framePaths, + }; + + await writeFile( + path.join(actionDir, 'manifest.json'), + JSON.stringify(manifest, null, 2) + '\n', + 'utf8', + ); + + actionManifests.push(manifest); + nextAnimationMap[action] = { + folder: action, + prefix: 'frame', + frames: framePaths.length, + startFrame: 1, + extension: frameExtension, + basePath, + }; + } + + await writeFile( + path.join(baseAnimationDir, 'manifest.json'), + JSON.stringify( + { + animationSetId, + characterId, + visualAssetId, + actions: actionManifests, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + let overrideMap: Record = {}; + if (updateCharacterOverride) { + overrideMap = await readJsonObjectFile(characterOverridesFilePath); + const existingOverride = overrideMap[characterId]; + const nextOverride = isRecordValue(existingOverride) + ? { ...existingOverride } + : {}; + const existingAnimationMap = isRecordValue(nextOverride.animationMap) + ? nextOverride.animationMap + : {}; + nextOverride.generatedAnimationSetId = animationSetId; + nextOverride.generatedVisualAssetId = visualAssetId; + nextOverride.animationMap = { + ...existingAnimationMap, + ...nextAnimationMap, + }; + overrideMap[characterId] = nextOverride; + await writeJsonObjectFile(characterOverridesFilePath, overrideMap); + } + + sendJson(res, 200, { + ok: true, + animationSetId, + overrideMap, + animationMap: nextAnimationMap, + saveMessage: updateCharacterOverride + ? '基础动作资源已发布到 public/generated-animations,并更新角色覆盖。' + : '基础动作资源已保存到 public/generated-animations,可直接写回当前自定义世界角色。', + }); + } catch (error) { + sendJson(res, 500, { + error: { + message: + error instanceof Error + ? error.message + : '发布角色基础动作失败。', + }, + }); + } +} + +function toExpressHandler( + handler: ( + request: IncomingMessage & { body?: unknown; originalUrl?: string }, + response: ServerResponse, + ) => Promise | void, +) { + return (request: Request, response: Response, next: NextFunction) => { + Promise.resolve( + handler( + request as Request & IncomingMessage & { body?: unknown; originalUrl?: string }, + response as Response & ServerResponse, + ), + ).catch(next); + }; +} + +export function createCharacterAssetRoutes(config: AppConfig) { + const router = Router(); + + router.use((request, response, next) => { + if ( + request.path !== '/api/assets' && + !request.path.startsWith('/api/assets/') + ) { + next(); + return; + } + + if (!config.assetsApiEnabled) { + response.status(403).json({ + error: { + message: '资产工具接口当前未启用。', + }, + }); + return; + } + next(); + }); + + router.use( + CHARACTER_VISUAL_GENERATE_PATH, + toExpressHandler((request, response) => + handleGenerateCharacterVisuals(config, request, response), + ), + ); + router.use( + CHARACTER_VISUAL_PUBLISH_PATH, + toExpressHandler((request, response) => + handlePublishCharacterVisual(config, request, response), + ), + ); + router.use( + CHARACTER_VISUAL_JOBS_PATH, + toExpressHandler((request, response) => + handleReadCharacterJobStatus(config.projectRoot, request, response, 'visual'), + ), + ); + router.use( + CHARACTER_ANIMATION_GENERATE_PATH, + toExpressHandler((request, response) => + handleGenerateCharacterAnimation(config, request, response), + ), + ); + router.use( + CHARACTER_ANIMATION_PUBLISH_PATH, + toExpressHandler((request, response) => + handlePublishCharacterAnimation(config, request, response), + ), + ); + router.use( + CHARACTER_ANIMATION_JOBS_PATH, + toExpressHandler((request, response) => + handleReadCharacterJobStatus(config.projectRoot, request, response, 'animation'), + ), + ); + router.use( + CHARACTER_ANIMATION_IMPORT_VIDEO_PATH, + toExpressHandler((request, response) => + handleImportCharacterAnimationVideo(config.projectRoot, request, response), + ), + ); + router.use( + CHARACTER_ANIMATION_TEMPLATES_PATH, + toExpressHandler((request, response) => + handleListAnimationTemplates(config, request, response), + ), + ); + + return router; +} diff --git a/server-node/src/modules/assets/qwenSpriteRoutes.ts b/server-node/src/modules/assets/qwenSpriteRoutes.ts new file mode 100644 index 00000000..4053e78d --- /dev/null +++ b/server-node/src/modules/assets/qwenSpriteRoutes.ts @@ -0,0 +1,907 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import http, { + type IncomingMessage, + type RequestOptions, + type ServerResponse, +} from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; + +import { Router, type NextFunction, type Request, type Response } from 'express'; +import type { AppConfig } from '../../config.js'; + +const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/assets/qwen-sprite/master'; +const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/assets/qwen-sprite/sheet'; +const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/assets/qwen-sprite/frame-repair'; +const QWEN_SPRITE_SAVE_PATH = '/api/assets/qwen-sprite/save'; +const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; +const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0'; + +function readJsonBody(req: IncomingMessage & { body?: unknown }) { + const parsedBody = req.body; + if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) { + return Promise.resolve(parsedBody as Record); + } + + return new Promise>((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on('end', () => { + try { + const raw = + Buffer.concat(chunks) + .toString('utf8') + .replace(/^\uFEFF/u, '') || '{}'; + resolve(JSON.parse(raw)); + } catch (error) { + reject(error); + } + }); + req.on('error', reject); + }); +} + +function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +function isRecordValue(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.every((item) => typeof item === 'string' && item.trim().length > 0) + ); +} + +function resolveRuntimeEnv(config: AppConfig) { + return config.rawEnv; +} + +function normalizeDashScopeBaseUrl(value: string) { + return value.replace(/\/$/u, ''); +} + +function extractApiErrorMessage(responseText: string, fallbackMessage: string) { + if (!responseText.trim()) { + return fallbackMessage; + } + + try { + const parsed = JSON.parse(responseText) as { + code?: string; + message?: string; + error?: { message?: string }; + }; + if ( + typeof parsed.error?.message === 'string' && + parsed.error.message.trim() + ) { + return parsed.error.message; + } + if (typeof parsed.message === 'string' && parsed.message.trim()) { + return parsed.message; + } + if (typeof parsed.code === 'string' && parsed.code.trim()) { + return `${fallbackMessage} (${parsed.code})`; + } + } catch { + // Fall through to raw text. + } + + return responseText; +} + +function sanitizePathSegment(value: string) { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-_]+/gu, '-') + .replace(/-+/gu, '-') + .replace(/^-|-$/gu, ''); + + return normalized || 'asset'; +} + +function createTimestampId(prefix: string) { + return `${prefix}-${Date.now()}`; +} + +function requestTextResponse( + urlString: string, + options: { + method?: string; + headers?: Record; + bodyText?: string; + } = {}, +) { + return new Promise<{ + statusCode: number; + headers: Record; + bodyText: string; + }>((resolve, reject) => { + const url = new URL(urlString); + const transport = url.protocol === 'https:' ? https : http; + const payload = options.bodyText; + const requestOptions: RequestOptions = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port ? Number(url.port) : undefined, + path: `${url.pathname}${url.search}`, + method: options.method ?? 'GET', + headers: { + ...(options.headers ?? {}), + ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + }; + + const request = transport.request(requestOptions, (upstreamRes) => { + const chunks: Buffer[] = []; + upstreamRes.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + upstreamRes.on('end', () => { + resolve({ + statusCode: upstreamRes.statusCode ?? 502, + headers: upstreamRes.headers, + bodyText: Buffer.concat(chunks).toString('utf8'), + }); + }); + upstreamRes.on('error', reject); + }); + + request.on('error', reject); + if (payload) { + request.write(payload); + } + request.end(); + }); +} + +function requestBinaryResponse( + urlString: string, + options: { + method?: string; + headers?: Record; + } = {}, +) { + return new Promise<{ + statusCode: number; + headers: Record; + body: Buffer; + }>((resolve, reject) => { + const url = new URL(urlString); + const transport = url.protocol === 'https:' ? https : http; + const requestOptions: RequestOptions = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port ? Number(url.port) : undefined, + path: `${url.pathname}${url.search}`, + method: options.method ?? 'GET', + headers: options.headers ?? {}, + }; + + const request = transport.request(requestOptions, (upstreamRes) => { + const chunks: Buffer[] = []; + upstreamRes.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + upstreamRes.on('end', () => { + resolve({ + statusCode: upstreamRes.statusCode ?? 502, + headers: upstreamRes.headers, + body: Buffer.concat(chunks), + }); + }); + upstreamRes.on('error', reject); + }); + + request.on('error', reject); + request.end(); + }); +} + +function proxyJsonRequest( + urlString: string, + apiKey: string, + body: Record, +) { + return requestTextResponse(urlString, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + bodyText: JSON.stringify(body), + }); +} + +function collectStringsByKey( + value: unknown, + targetKey: string, + results: string[], +) { + if (Array.isArray(value)) { + value.forEach((item) => collectStringsByKey(item, targetKey, results)); + return; + } + + if (!isRecordValue(value)) { + return; + } + + const directValue = value[targetKey]; + if (typeof directValue === 'string' && directValue.trim()) { + results.push(directValue.trim()); + } + + Object.values(value).forEach((nestedValue) => + collectStringsByKey(nestedValue, targetKey, results), + ); +} + +function extractImageUrls(payload: Record) { + const results: string[] = []; + collectStringsByKey(payload.output, 'image', results); + collectStringsByKey(payload.output, 'url', results); + return [...new Set(results)]; +} + +function parseDataUrl(source: string) { + const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); + if (!matched) { + return null; + } + + const mimeType = matched[1]; + const base64Payload = matched[2]; + const extension = (() => { + switch (mimeType) { + case 'image/jpeg': + return 'jpg'; + case 'image/webp': + return 'webp'; + default: + return 'png'; + } + })(); + + return { + buffer: Buffer.from(base64Payload, 'base64'), + extension, + }; +} + +async function resolveImageSourcePayload(rootDir: string, source: string) { + const parsedDataUrl = parseDataUrl(source); + if (parsedDataUrl) { + return parsedDataUrl; + } + + if (!source.startsWith('/')) { + throw new Error('图像来源必须是 Data URL 或 public 目录 URL。'); + } + + const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, ''); + const absolutePath = path.resolve( + rootDir, + 'public', + ...normalizedSource.split('/'), + ); + const publicRoot = path.resolve(rootDir, 'public'); + + if (!absolutePath.startsWith(publicRoot)) { + throw new Error('图像来源路径越界。'); + } + + const buffer = await readFile(absolutePath); + const extension = path.extname(absolutePath).replace(/^\./u, '') || 'png'; + + return { + buffer, + extension, + }; +} + +async function resolveImageSourceAsDataUrl(rootDir: string, source: string) { + if (/^data:image\/[^;]+;base64,/u.test(source)) { + return source; + } + + const payload = await resolveImageSourcePayload(rootDir, source); + const mimeType = (() => { + switch (payload.extension) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'webp': + return 'image/webp'; + default: + return 'image/png'; + } + })(); + + return `data:${mimeType};base64,${payload.buffer.toString('base64')}`; +} + +async function writeDraftImageFile( + rootDir: string, + relativePath: string, + buffer: Buffer, +) { + const absolutePath = path.resolve(rootDir, 'public', ...relativePath.split('/')); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, buffer); + return `/${relativePath}`; +} + +async function generateQwenImages( + config: AppConfig, + input: { + kind: 'master' | 'sheet' | 'repair'; + promptText: string; + negativePrompt: string; + model: string; + size: string; + promptExtend: boolean; + seed?: number; + candidateCount: number; + referenceImages: string[]; + }, +) { + const rootDir = config.projectRoot; + const runtimeEnv = resolveRuntimeEnv(config); + const baseUrl = normalizeDashScopeBaseUrl( + runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, + ); + const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; + + if (!apiKey) { + throw new Error('服务端缺少 DASHSCOPE_API_KEY,无法调用 Qwen-Image。'); + } + + const content = [ + ...(await Promise.all( + input.referenceImages + .slice(0, 3) + .map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })), + )), + { text: input.promptText }, + ]; + + const requestPayload: Record = { + model: input.model || DEFAULT_QWEN_IMAGE_MODEL, + input: { + messages: [ + { + role: 'user', + content, + }, + ], + }, + parameters: { + n: Math.max(1, Math.min(6, input.candidateCount)), + negative_prompt: input.negativePrompt, + prompt_extend: input.promptExtend, + watermark: false, + size: input.size, + ...(typeof input.seed === 'number' && Number.isFinite(input.seed) + ? { seed: input.seed } + : {}), + }, + }; + + const response = await proxyJsonRequest( + `${baseUrl}/services/aigc/multimodal-generation/generation`, + apiKey, + requestPayload, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error( + extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'), + ); + } + + const parsed = JSON.parse(response.bodyText) as Record; + const imageUrls = extractImageUrls(parsed); + + if (imageUrls.length === 0) { + throw new Error('Qwen-Image 未返回可下载的图片结果。'); + } + + const draftId = createTimestampId(`qwen-${input.kind}`); + const relativeDir = path.posix.join( + 'generated-qwen-sprites', + '_drafts', + input.kind, + draftId, + ); + + const drafts = await Promise.all( + imageUrls.map(async (imageUrl, index) => { + const binaryResponse = await requestBinaryResponse(imageUrl); + if ( + binaryResponse.statusCode < 200 || + binaryResponse.statusCode >= 300 + ) { + throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`); + } + + const imageSrc = await writeDraftImageFile( + rootDir, + path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`), + binaryResponse.body, + ); + + return { + id: `${draftId}-${index + 1}`, + label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`, + imageSrc, + remoteUrl: imageUrl, + }; + }), + ); + + await writeFile( + path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'), + JSON.stringify( + { + draftId, + kind: input.kind, + model: input.model, + size: input.size, + promptText: input.promptText, + negativePrompt: input.negativePrompt, + promptExtend: input.promptExtend, + seed: input.seed, + candidateCount: input.candidateCount, + referenceImageCount: input.referenceImages.length, + drafts, + createdAt: new Date().toISOString(), + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + return { + draftId, + drafts, + model: input.model, + size: input.size, + promptText: input.promptText, + negativePrompt: input.negativePrompt, + }; +} + +async function handleGenerateMaster( + config: AppConfig, + req: IncomingMessage & { body?: unknown }, + res: ServerResponse, +) { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + let body: Record; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); + return; + } + + const promptText = + typeof body.promptText === 'string' ? body.promptText.trim() : ''; + const negativePrompt = + typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; + const model = + typeof body.model === 'string' && body.model.trim() + ? body.model.trim() + : DEFAULT_QWEN_IMAGE_MODEL; + const size = + typeof body.size === 'string' && body.size.trim() + ? body.size.trim() + : '1024*1024'; + const promptExtend = body.promptExtend !== false; + const candidateCount = + typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount) + ? body.candidateCount + : 1; + const seed = + typeof body.seed === 'number' && Number.isFinite(body.seed) + ? body.seed + : undefined; + const referenceImages = isStringArray(body.referenceImages) + ? body.referenceImages + : []; + + if (!promptText) { + sendJson(res, 400, { error: { message: 'promptText is required.' } }); + return; + } + + try { + const result = await generateQwenImages(config, { + kind: 'master', + promptText, + negativePrompt, + model, + size, + promptExtend, + seed, + candidateCount, + referenceImages, + }); + + sendJson(res, 200, { + ok: true, + ...result, + }); + } catch (error) { + sendJson(res, 500, { + error: { + message: error instanceof Error ? error.message : '生成主图失败。', + }, + }); + } +} + +async function handleGenerateSheet( + config: AppConfig, + req: IncomingMessage & { body?: unknown }, + res: ServerResponse, +) { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + let body: Record; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); + return; + } + + const promptText = + typeof body.promptText === 'string' ? body.promptText.trim() : ''; + const negativePrompt = + typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; + const model = + typeof body.model === 'string' && body.model.trim() + ? body.model.trim() + : DEFAULT_QWEN_IMAGE_MODEL; + const size = + typeof body.size === 'string' && body.size.trim() + ? body.size.trim() + : '1024*1024'; + const promptExtend = body.promptExtend !== false; + const candidateCount = + typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount) + ? body.candidateCount + : 1; + const seed = + typeof body.seed === 'number' && Number.isFinite(body.seed) + ? body.seed + : undefined; + const referenceImages = isStringArray(body.referenceImages) + ? body.referenceImages + : []; + + if (!promptText) { + sendJson(res, 400, { error: { message: 'promptText is required.' } }); + return; + } + + try { + const result = await generateQwenImages(config, { + kind: 'sheet', + promptText, + negativePrompt, + model, + size, + promptExtend, + seed, + candidateCount, + referenceImages, + }); + + sendJson(res, 200, { + ok: true, + ...result, + }); + } catch (error) { + sendJson(res, 500, { + error: { + message: error instanceof Error ? error.message : '生成精灵表失败。', + }, + }); + } +} + +async function handleRepairFrame( + config: AppConfig, + req: IncomingMessage & { body?: unknown }, + res: ServerResponse, +) { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + let body: Record; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); + return; + } + + const promptText = + typeof body.promptText === 'string' ? body.promptText.trim() : ''; + const negativePrompt = + typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; + const model = + typeof body.model === 'string' && body.model.trim() + ? body.model.trim() + : DEFAULT_QWEN_IMAGE_MODEL; + const size = + typeof body.size === 'string' && body.size.trim() + ? body.size.trim() + : '512*512'; + const promptExtend = body.promptExtend !== false; + const seed = + typeof body.seed === 'number' && Number.isFinite(body.seed) + ? body.seed + : undefined; + const referenceImages = isStringArray(body.referenceImages) + ? body.referenceImages + : []; + + if (!promptText) { + sendJson(res, 400, { error: { message: 'promptText is required.' } }); + return; + } + + if (referenceImages.length === 0) { + sendJson(res, 400, { + error: { message: '至少需要一张参考图来修复帧。' }, + }); + return; + } + + try { + const result = await generateQwenImages(config, { + kind: 'repair', + promptText, + negativePrompt, + model, + size, + promptExtend, + seed, + candidateCount: 1, + referenceImages, + }); + + sendJson(res, 200, { + ok: true, + ...result, + repairedFrame: result.drafts[0] ?? null, + }); + } catch (error) { + sendJson(res, 500, { + error: { + message: error instanceof Error ? error.message : '修帧失败。', + }, + }); + } +} + +async function handleSaveAsset( + rootDir: string, + req: IncomingMessage & { body?: unknown }, + res: ServerResponse, +) { + if (req.method !== 'POST') { + sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); + return; + } + + let body: Record; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); + return; + } + + const assetKey = + typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : ''; + const actionKey = + typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : ''; + const masterSource = + typeof body.masterSource === 'string' ? body.masterSource.trim() : ''; + const sheetSource = + typeof body.sheetSource === 'string' ? body.sheetSource.trim() : ''; + const framesDataUrls = isStringArray(body.framesDataUrls) + ? body.framesDataUrls + : []; + const metadata = isRecordValue(body.metadata) ? body.metadata : {}; + const prompts = isRecordValue(body.prompts) ? body.prompts : {}; + + if (!assetKey) { + sendJson(res, 400, { error: { message: 'assetKey is required.' } }); + return; + } + + if (!actionKey) { + sendJson(res, 400, { error: { message: 'actionKey is required.' } }); + return; + } + + if (!sheetSource) { + sendJson(res, 400, { error: { message: 'sheetSource is required.' } }); + return; + } + + try { + const assetId = createTimestampId('qwen-sprite'); + const relativeDir = path.posix.join( + 'generated-qwen-sprites', + assetKey, + actionKey, + assetId, + ); + const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/')); + await mkdir(path.join(absoluteDir, 'frames'), { recursive: true }); + + let masterImagePath: string | null = null; + if (masterSource) { + const payload = await resolveImageSourcePayload(rootDir, masterSource); + masterImagePath = await writeDraftImageFile( + rootDir, + path.posix.join(relativeDir, `master.${payload.extension}`), + payload.buffer, + ); + } + + const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource); + const sheetImagePath = await writeDraftImageFile( + rootDir, + path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`), + sheetPayload.buffer, + ); + + const framePaths: string[] = []; + for (let index = 0; index < framesDataUrls.length; index += 1) { + const framePayload = await resolveImageSourcePayload( + rootDir, + framesDataUrls[index] ?? '', + ); + const framePath = await writeDraftImageFile( + rootDir, + path.posix.join( + relativeDir, + 'frames', + `frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`, + ), + framePayload.buffer, + ); + framePaths.push(framePath); + } + + await writeFile( + path.join(absoluteDir, 'metadata.json'), + JSON.stringify( + { + assetId, + assetKey, + actionKey, + masterImagePath, + sheetImagePath, + framePaths, + metadata, + prompts, + createdAt: new Date().toISOString(), + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + sendJson(res, 200, { + ok: true, + assetId, + assetDir: `/${relativeDir}`, + masterImagePath, + sheetImagePath, + framePaths, + saveMessage: '已保存到 public/generated-qwen-sprites。', + }); + } catch (error) { + sendJson(res, 500, { + error: { + message: error instanceof Error ? error.message : '保存精灵表资产失败。', + }, + }); + } +} + +function toExpressHandler( + handler: ( + request: IncomingMessage & { body?: unknown }, + response: ServerResponse, + ) => Promise | void, +) { + return (request: Request, response: Response, next: NextFunction) => { + Promise.resolve( + handler( + request as Request & IncomingMessage & { body?: unknown }, + response as Response & ServerResponse, + ), + ).catch(next); + }; +} + +export function createQwenSpriteRoutes(config: AppConfig) { + const router = Router(); + + router.use((request, response, next) => { + if ( + request.path !== '/api/assets' && + !request.path.startsWith('/api/assets/') + ) { + next(); + return; + } + + if (!config.assetsApiEnabled) { + response.status(403).json({ + error: { + message: '资产工具接口当前未启用。', + }, + }); + return; + } + next(); + }); + + router.use( + QWEN_SPRITE_MASTER_GENERATE_PATH, + toExpressHandler((request, response) => + handleGenerateMaster(config, request, response), + ), + ); + router.use( + QWEN_SPRITE_SHEET_GENERATE_PATH, + toExpressHandler((request, response) => + handleGenerateSheet(config, request, response), + ), + ); + router.use( + QWEN_SPRITE_FRAME_REPAIR_PATH, + toExpressHandler((request, response) => + handleRepairFrame(config, request, response), + ), + ); + router.use( + QWEN_SPRITE_SAVE_PATH, + toExpressHandler((request, response) => + handleSaveAsset(config.projectRoot, request, response), + ), + ); + + return router; +} diff --git a/server-node/src/modules/combat/combatResolutionService.ts b/server-node/src/modules/combat/combatResolutionService.ts new file mode 100644 index 00000000..e31a5d00 --- /dev/null +++ b/server-node/src/modules/combat/combatResolutionService.ts @@ -0,0 +1,272 @@ +import type { + RuntimeBattlePresentation, + RuntimeStoryPatch, +} from '../../../../packages/shared/src/contracts/story.js'; +import { conflict } from '../../errors.js'; +import { + getEncounterNpcState, + setEncounterNpcState, + type RuntimeSession, +} from '../story/runtimeSession.js'; + +type CombatActionConfig = { + actionText: string; + manaCost: number; + baseDamage: number; + counterMultiplier: number; + heal?: number; + manaRestore?: number; +}; + +export type CombatResolution = { + actionText: string; + resultText: string; + battle: RuntimeBattlePresentation; + patches: RuntimeStoryPatch[]; + storyText?: string; +}; + +const COMBAT_ACTIONS: Record = { + battle_all_in_crush: { + actionText: '正面强压', + manaCost: 14, + baseDamage: 22, + counterMultiplier: 1.25, + }, + battle_feint_step: { + actionText: '虚晃切步', + manaCost: 8, + baseDamage: 16, + counterMultiplier: 0.7, + }, + battle_finisher_window: { + actionText: '抓破绽终结', + manaCost: 10, + baseDamage: 18, + counterMultiplier: 0.9, + }, + battle_guard_break: { + actionText: '破架重击', + manaCost: 9, + baseDamage: 17, + counterMultiplier: 0.95, + }, + battle_probe_pressure: { + actionText: '稳步试探', + manaCost: 5, + baseDamage: 12, + counterMultiplier: 0.8, + }, + battle_recover_breath: { + actionText: '边守边调息', + manaCost: 0, + baseDamage: 0, + counterMultiplier: 0.55, + heal: 12, + manaRestore: 9, + }, +}; + +function getAliveTarget(session: RuntimeSession) { + return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null; +} + +function applySparAffinityReward(session: RuntimeSession) { + const npcState = getEncounterNpcState(session); + const encounter = session.currentEncounter; + if (!npcState || !encounter || encounter.kind !== 'npc') { + return null; + } + + const nextAffinity = npcState.affinity + 3; + setEncounterNpcState(session, { + ...npcState, + affinity: nextAffinity, + }); + + return { + npcId: encounter.id, + previousAffinity: npcState.affinity, + nextAffinity, + } satisfies Extract; +} + +function clampPlayerVitals(session: RuntimeSession) { + session.playerHp = Math.max(0, Math.min(session.playerHp, session.playerMaxHp)); + session.playerMana = Math.max( + 0, + Math.min(session.playerMana, session.playerMaxMana), + ); +} + +function finishBattle( + session: RuntimeSession, + outcome: RuntimeBattlePresentation['outcome'], +) { + session.inBattle = false; + session.sceneHostileNpcs = []; + session.currentNpcBattleMode = null; + session.currentNpcBattleOutcome = + outcome === 'spar_complete' + ? 'spar_complete' + : outcome === 'victory' + ? 'fight_victory' + : null; + + if (outcome === 'victory' || outcome === 'escaped') { + session.currentEncounter = null; + session.npcInteractionActive = false; + return; + } + + if (session.currentEncounter?.kind === 'npc') { + session.npcInteractionActive = true; + } +} + +export function resolveCombatAction( + session: RuntimeSession, + functionId: string, +): CombatResolution { + const target = getAliveTarget(session); + if (!session.inBattle || !target) { + throw conflict('当前不在可结算战斗态,不能执行该战斗动作'); + } + + if (functionId === 'battle_escape_breakout') { + finishBattle(session, 'escaped'); + return { + actionText: '强行脱离战斗', + resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`, + battle: { + targetId: target.id, + targetName: target.name, + outcome: 'escaped', + }, + patches: [ + { + type: 'battle_resolved', + functionId, + targetId: target.id, + outcome: 'escaped', + }, + { + type: 'status_changed', + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + }, + { + type: 'encounter_changed', + encounterId: session.currentEncounter?.id ?? null, + }, + ], + }; + } + + const action = COMBAT_ACTIONS[functionId]; + if (!action) { + throw conflict(`暂不支持的战斗动作:${functionId}`); + } + + if (action.manaCost > session.playerMana) { + throw conflict('当前灵力不足,无法执行这个战斗动作'); + } + + const isSpar = session.currentNpcBattleMode === 'spar'; + const targetHpRatio = target.hp / Math.max(target.maxHp, 1); + const damageBonus = + functionId === 'battle_finisher_window' && targetHpRatio <= 0.4 ? 8 : 0; + const damageDealt = isSpar ? 1 : action.baseDamage + damageBonus; + + session.playerMana -= action.manaCost; + session.playerHp += action.heal ?? 0; + session.playerMana += action.manaRestore ?? 0; + clampPlayerVitals(session); + + target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt); + + const patches: RuntimeStoryPatch[] = []; + let resultText = ''; + let outcome: RuntimeBattlePresentation['outcome'] = 'ongoing'; + let damageTaken = 0; + + if ((isSpar && target.hp <= 1) || (!isSpar && target.hp <= 0)) { + if (isSpar) { + const affinityPatch = applySparAffinityReward(session); + finishBattle(session, 'spar_complete'); + if (affinityPatch) { + patches.push(affinityPatch); + } + outcome = 'spar_complete'; + resultText = `你和${target.name}这轮过招已经分出高下,对方也承认了你的身手。`; + } else { + finishBattle(session, 'victory'); + outcome = 'victory'; + resultText = `你彻底压垮了${target.name}的节奏,这场战斗已经收口。`; + } + } else { + const baseCounter = isSpar + ? 1 + : Math.max(4, Math.round(target.maxHp * 0.16 * action.counterMultiplier)); + damageTaken = baseCounter; + session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken); + + if (isSpar && session.playerHp <= 1) { + const affinityPatch = applySparAffinityReward(session); + finishBattle(session, 'spar_complete'); + if (affinityPatch) { + patches.push(affinityPatch); + } + outcome = 'spar_complete'; + resultText = `${target.name}也逼到了你的极限,这场切磋点到为止,双方都默认收手。`; + } else if (!isSpar && session.playerHp <= 0) { + session.playerHp = 0; + session.inBattle = false; + session.sceneHostileNpcs = []; + session.currentNpcBattleMode = null; + session.npcInteractionActive = false; + session.currentEncounter = null; + outcome = 'escaped'; + resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`; + } else { + resultText = `${action.actionText}命中了${target.name},但对方仍然顶住并回敬了一轮压力。`; + } + } + + patches.push( + { + type: 'battle_resolved', + functionId, + targetId: target.id, + damageDealt, + damageTaken, + outcome, + }, + { + type: 'status_changed', + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + }, + { + type: 'encounter_changed', + encounterId: session.currentEncounter?.id ?? null, + }, + ); + + return { + actionText: action.actionText, + resultText, + battle: { + targetId: target.id, + targetName: target.name, + damageDealt, + damageTaken, + outcome, + }, + patches, + }; +} diff --git a/server-node/src/modules/editor/editorRoutes.ts b/server-node/src/modules/editor/editorRoutes.ts new file mode 100644 index 00000000..c1a4ccab --- /dev/null +++ b/server-node/src/modules/editor/editorRoutes.ts @@ -0,0 +1,141 @@ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { Router } from 'express'; + +import type { AppConfig } from '../../config.js'; +import { badRequest, notFound } from '../../errors.js'; +import { asyncHandler } from '../../http.js'; + +const EDITOR_JSON_RESOURCE_FILES = { + 'item-overrides': 'src/data/itemOverrides.json', + 'npc-visual-overrides': 'src/data/npcVisualOverrides.json', + 'npc-layout-config': 'src/data/npcLayoutConfig.json', + 'character-overrides': 'src/data/characterOverrides.json', + 'monster-overrides': 'src/data/monsterOverrides.json', + 'scene-overrides': 'src/data/sceneOverrides.json', + 'scene-npc-overrides': 'src/data/sceneNpcOverrides.json', + 'state-function-overrides': 'src/data/stateFunctionOverrides.json', +} as const; + +type EditorJsonResourceId = keyof typeof EDITOR_JSON_RESOURCE_FILES; + +function isEditorJsonPayload(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function resolveEditorJsonFile( + config: AppConfig, + resourceId: string, +) { + const relativePath = + EDITOR_JSON_RESOURCE_FILES[ + resourceId as EditorJsonResourceId + ]; + if (!relativePath) { + throw notFound('未知的编辑器资源。'); + } + + return path.resolve(config.projectRoot, relativePath); +} + +async function readEditorJsonFile(filePath: string) { + try { + const content = await readFile(filePath, 'utf8'); + return JSON.parse(content) as Record; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw error; + } +} + +async function collectPngAssetPaths( + rootDir: string, + relativeDir = 'Icons', +): Promise { + const entries = await readdir(rootDir, { withFileTypes: true }); + const collected: string[] = []; + + for (const entry of entries) { + const absolutePath = path.join(rootDir, entry.name); + const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/'); + + if (entry.isDirectory()) { + collected.push( + ...(await collectPngAssetPaths(absolutePath, relativePath)), + ); + continue; + } + + if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) { + collected.push(relativePath); + } + } + + return collected.sort((left, right) => left.localeCompare(right)); +} + +export function createEditorRoutes(config: AppConfig) { + const router = Router(); + + router.use((request, response, next) => { + if ( + request.path !== '/api/editor' && + !request.path.startsWith('/api/editor/') + ) { + next(); + return; + } + + if (!config.editorApiEnabled) { + response.status(403).json({ + error: { + message: '编辑器接口当前未启用。', + }, + }); + return; + } + next(); + }); + + router.get( + '/api/editor/catalog/items', + asyncHandler(async (_request, response) => { + response.json({ + assetPaths: await collectPngAssetPaths( + path.resolve(config.projectRoot, 'public/Icons'), + ), + }); + }), + ); + + router.get( + '/api/editor/json/:resourceId', + asyncHandler(async (request, response) => { + const filePath = resolveEditorJsonFile(config, request.params.resourceId); + response.json(await readEditorJsonFile(filePath)); + }), + ); + + router.post( + '/api/editor/json/:resourceId', + asyncHandler(async (request, response) => { + if (!isEditorJsonPayload(request.body)) { + throw badRequest('编辑器保存请求必须是 JSON 对象。'); + } + + const filePath = resolveEditorJsonFile(config, request.params.resourceId); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile( + filePath, + JSON.stringify(request.body, null, 2) + '\n', + 'utf8', + ); + response.json({ ok: true }); + }), + ); + + return router; +} diff --git a/server-node/src/modules/inventory/index.ts b/server-node/src/modules/inventory/index.ts new file mode 100644 index 00000000..4db0479b --- /dev/null +++ b/server-node/src/modules/inventory/index.ts @@ -0,0 +1 @@ +export * from './inventoryMutationService.js'; diff --git a/server-node/src/modules/inventory/inventoryMutationService.test.ts b/server-node/src/modules/inventory/inventoryMutationService.test.ts new file mode 100644 index 00000000..442d0120 --- /dev/null +++ b/server-node/src/modules/inventory/inventoryMutationService.test.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; +import { + craftForgeRecipe, + equipInventoryItem, + useInventoryItem, + type RuntimeGameState, + type RuntimeInventoryItem, +} from './inventoryMutationService.js'; + +const TEST_WORLD = 'WUXIA' as RuntimeGameState['worldType']; +const TEST_IDLE_ANIMATION = 'idle' as RuntimeGameState['animationState']; + +function requireCharacter() { + return createTestPlayerCharacter< + NonNullable + >(); +} + +function buildItem( + overrides: Partial & + Pick, +): RuntimeInventoryItem { + return { + quantity: 1, + rarity: 'common', + tags: [], + ...overrides, + }; +} + +function createState(overrides: Partial = {}): RuntimeGameState { + return { + worldType: TEST_WORLD, + customWorldProfile: null, + playerCharacter: requireCharacter(), + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'test-scene', + storyHistory: [], + characterChats: {}, + animationState: TEST_IDLE_ANIMATION, + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'melee', + scrollWorld: false, + inBattle: false, + playerHp: 64, + playerMaxHp: 100, + playerMana: 18, + playerMaxMana: 60, + playerSkillCooldowns: { + slash: 2, + }, + activeBuildBuffs: [], + activeCombatEffects: [], + playerCurrency: 120, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: {}, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + ...overrides, + } satisfies RuntimeGameState; +} + +test('useInventoryItem applies recovery, cooldown推进 and buff mutation', () => { + const state = createState({ + playerInventory: [ + buildItem({ + id: 'focus-tonic', + category: '消耗品', + name: '凝神灵液', + rarity: 'rare', + tags: ['healing', 'mana'], + useProfile: { + hpRestore: 22, + manaRestore: 16, + cooldownReduction: 1, + buildBuffs: [ + { + id: 'focus-tonic:buff', + sourceType: 'item', + sourceId: 'focus-tonic', + name: '凝神增益', + tags: ['快剑'], + durationTurns: 2, + }, + ], + }, + }), + ], + }); + + const result = useInventoryItem(state, 'focus-tonic'); + assert.equal(result.ok, true); + if (!result.ok) { + return; + } + + assert.equal(result.mutation, 'use'); + assert.equal(result.nextState.playerHp, 86); + assert.equal(result.nextState.playerMana, 34); + assert.equal(result.nextState.playerSkillCooldowns.slash, 1); + assert.equal(result.nextState.playerInventory.length, 0); + assert.equal(result.nextState.runtimeStats.itemsUsed, 1); + assert.equal(result.nextState.activeBuildBuffs[0]?.id, 'focus-tonic:buff'); +}); + +test('equipInventoryItem swaps loadout and returns replaced gear to inventory', () => { + const oldWeapon = buildItem({ + id: 'starter-blade', + category: '武器', + name: '旧佩剑', + rarity: 'common', + tags: ['weapon', '快剑'], + equipmentSlotId: 'weapon', + statProfile: { + outgoingDamageBonus: 0.04, + }, + buildProfile: { + role: '快剑', + tags: ['快剑'], + synergy: ['快剑'], + forgeRank: 0, + }, + }); + const nextWeapon = buildItem({ + id: 'storm-blade', + category: '武器', + name: '逐风短剑', + rarity: 'rare', + tags: ['weapon', '快剑', '突进'], + equipmentSlotId: 'weapon', + statProfile: { + outgoingDamageBonus: 0.12, + }, + buildProfile: { + role: '快剑', + tags: ['快剑', '突进'], + synergy: ['快剑', '突进'], + forgeRank: 0, + }, + }); + const state = createState({ + playerInventory: [nextWeapon], + playerEquipment: { + weapon: oldWeapon, + armor: null, + relic: null, + }, + }); + + const result = equipInventoryItem(state, 'storm-blade'); + assert.equal(result.ok, true); + if (!result.ok) { + return; + } + + assert.equal(result.mutation, 'equip'); + assert.equal(result.slot, 'weapon'); + assert.equal(result.nextState.playerEquipment.weapon?.name, '逐风短剑'); + assert.equal( + result.nextState.playerInventory.some((item) => item.id === 'starter-blade'), + true, + ); + assert.equal( + result.nextState.playerInventory.some((item) => item.id === 'storm-blade'), + false, + ); +}); + +test('craftForgeRecipe consumes materials and produces forged output on the server side', () => { + const state = createState({ + playerCurrency: 40, + playerInventory: [ + buildItem({ + id: 'scrap-iron', + category: '材料', + name: '残铁碎片', + quantity: 3, + rarity: 'common', + tags: ['material'], + }), + ], + }); + + const result = craftForgeRecipe(state, 'synthesis-refined-ingot'); + assert.equal(result.ok, true); + if (!result.ok) { + return; + } + + assert.equal(result.mutation, 'craft'); + assert.equal(result.nextState.playerCurrency, 22); + assert.equal(result.createdItem?.name, '精炼锭材'); + assert.equal( + result.nextState.playerInventory.some((item) => item.name === '精炼锭材'), + true, + ); + assert.equal( + result.nextState.playerInventory.some((item) => item.id === 'scrap-iron'), + false, + ); +}); diff --git a/server-node/src/modules/inventory/inventoryMutationService.ts b/server-node/src/modules/inventory/inventoryMutationService.ts new file mode 100644 index 00000000..2afd452e --- /dev/null +++ b/server-node/src/modules/inventory/inventoryMutationService.ts @@ -0,0 +1,458 @@ +import { + addInventoryItems, + appendBuildBuffs, + applyEquipmentLoadoutToState, + buildForgeSuccessText, + buildInventoryUseResultText, + executeDismantleItem, + executeForgeRecipe, + executeReforgeItem, + getEquipmentSlotFromItem, + getEquipmentSlotLabel, + getForgeRecipeViews, + getReforgeCostView, + incrementGameRuntimeStats, + isInventoryItemUsable, + removeInventoryItem, + resolveInventoryItemUseEffect, +} from '../../bridges/legacyInventoryRuntimeBridge.js'; + +export type RuntimeGameState = Parameters< + typeof applyEquipmentLoadoutToState +>[0]; +export type RuntimeInventoryItem = Parameters< + typeof getEquipmentSlotFromItem +>[0]; +export type RuntimeEquipmentSlotId = Exclude< + ReturnType, + null +>; +export type RuntimeInventoryUseEffect = Exclude< + ReturnType, + null +>; +export type RuntimeForgeRecipeView = ReturnType< + typeof getForgeRecipeViews +>[number]; +export type RuntimeReforgeCostView = ReturnType; + +type InventoryMutationKind = + | 'use' + | 'equip' + | 'unequip' + | 'craft' + | 'dismantle' + | 'reforge'; + +type InventoryMutationFailureCode = + | 'missing_player_character' + | 'battle_locked' + | 'item_not_found' + | 'item_not_usable' + | 'item_not_equippable' + | 'slot_empty' + | 'recipe_not_available' + | 'mutation_not_available'; + +export type InventoryMutationFailure = { + ok: false; + code: InventoryMutationFailureCode; + message: string; +}; + +export type InventoryMutationSuccess = { + ok: true; + mutation: InventoryMutationKind; + nextState: RuntimeGameState; + actionText: string; + detailText: string; + item?: RuntimeInventoryItem; + slot?: RuntimeEquipmentSlotId; + replacedItem?: RuntimeInventoryItem | null; + createdItem?: RuntimeInventoryItem | null; + outputs?: RuntimeInventoryItem[]; + effect?: RuntimeInventoryUseEffect; + reforgeCost?: RuntimeReforgeCostView; +}; + +export type InventoryMutationResult = + | InventoryMutationFailure + | InventoryMutationSuccess; + +function createFailure( + code: InventoryMutationFailureCode, + message: string, +): InventoryMutationFailure { + return { + ok: false, + code, + message, + }; +} + +function tickCooldownMap( + cooldowns: RuntimeGameState['playerSkillCooldowns'], + turns: number, +) { + let nextCooldowns = cooldowns; + const totalTurns = Math.max(0, Math.floor(turns)); + + for (let index = 0; index < totalTurns; index += 1) { + nextCooldowns = Object.fromEntries( + Object.entries(nextCooldowns).map(([skillId, value]) => [ + skillId, + Math.max(0, Math.floor(value) - 1), + ]), + ); + } + + return nextCooldowns; +} + +function normalizeEquippedItem(item: RuntimeInventoryItem): RuntimeInventoryItem { + return { + ...item, + quantity: 1, + }; +} + +function buildEquipResultText( + item: RuntimeInventoryItem, + slot: RuntimeEquipmentSlotId, + replacedItem?: RuntimeInventoryItem | null, +) { + return replacedItem + ? `你将${replacedItem.name}从${getEquipmentSlotLabel(slot)}位上换下,改为装备${item.name}。` + : `你将${item.name}装备在${getEquipmentSlotLabel(slot)}位上。`; +} + +function buildUnequipResultText(item: RuntimeInventoryItem) { + return `你卸下了${item.name},暂时收回背包。`; +} + +export function getForgeRecipeCatalog( + state: RuntimeGameState, +): RuntimeForgeRecipeView[] { + return getForgeRecipeViews( + state.playerInventory, + state.playerCurrency, + state.worldType, + ); +} + +export function useInventoryItem( + state: RuntimeGameState, + itemId: string, +): InventoryMutationResult { + const playerCharacter = state.playerCharacter; + if (!playerCharacter) { + return createFailure( + 'missing_player_character', + '缺少玩家角色,无法使用背包物品。', + ); + } + + const item = state.playerInventory.find((candidate) => candidate.id === itemId); + if (!item || item.quantity <= 0) { + return createFailure('item_not_found', '未找到可使用的背包物品。'); + } + + if (!isInventoryItemUsable(item)) { + return createFailure('item_not_usable', `${item.name} 当前不可直接使用。`); + } + + const effect = resolveInventoryItemUseEffect(item, playerCharacter); + if ( + !effect || + (effect.hpRestore ?? 0) <= 0 && + (effect.manaRestore ?? 0) <= 0 && + (effect.cooldownReduction ?? 0) <= 0 && + (effect.buildBuffs?.length ?? 0) <= 0 + ) { + return createFailure( + 'item_not_usable', + `${item.name} 当前没有可结算的使用效果。`, + ); + } + + const nextState = { + ...state, + playerHp: Math.min(state.playerMaxHp, state.playerHp + effect.hpRestore), + playerMana: Math.min( + state.playerMaxMana, + state.playerMana + effect.manaRestore, + ), + playerSkillCooldowns: tickCooldownMap( + state.playerSkillCooldowns, + effect.cooldownReduction, + ), + activeBuildBuffs: appendBuildBuffs( + state.activeBuildBuffs, + effect.buildBuffs, + ), + playerInventory: removeInventoryItem(state.playerInventory, item.id, 1), + runtimeStats: incrementGameRuntimeStats(state.runtimeStats, { + itemsUsed: 1, + }), + } satisfies RuntimeGameState; + + return { + ok: true, + mutation: 'use', + nextState, + actionText: `使用${item.name}`, + detailText: buildInventoryUseResultText(item, effect), + item, + effect, + }; +} + +export function equipInventoryItem( + state: RuntimeGameState, + itemId: string, +): InventoryMutationResult { + if (!state.playerCharacter) { + return createFailure( + 'missing_player_character', + '缺少玩家角色,无法调整装备。', + ); + } + + if (state.inBattle) { + return createFailure('battle_locked', '战斗中无法调整装备。'); + } + + const item = state.playerInventory.find((candidate) => candidate.id === itemId); + if (!item || item.quantity <= 0) { + return createFailure('item_not_found', '背包里没有这件装备。'); + } + + const slot = getEquipmentSlotFromItem(item); + if (!slot) { + return createFailure('item_not_equippable', `${item.name} 不是可装备物品。`); + } + + const replacedItem = state.playerEquipment[slot]; + const nextEquipment = { + ...state.playerEquipment, + [slot]: normalizeEquippedItem(item), + }; + + let nextInventory = removeInventoryItem(state.playerInventory, item.id, 1); + if (replacedItem) { + nextInventory = addInventoryItems(nextInventory, [replacedItem]); + } + + const nextState = applyEquipmentLoadoutToState( + { + ...state, + playerInventory: nextInventory, + }, + nextEquipment, + ); + + return { + ok: true, + mutation: 'equip', + nextState, + actionText: `装备${item.name}`, + detailText: buildEquipResultText(item, slot, replacedItem), + item, + slot, + replacedItem, + }; +} + +export function unequipInventoryItem( + state: RuntimeGameState, + slot: RuntimeEquipmentSlotId, +): InventoryMutationResult { + if (!state.playerCharacter) { + return createFailure( + 'missing_player_character', + '缺少玩家角色,无法卸下装备。', + ); + } + + if (state.inBattle) { + return createFailure('battle_locked', '战斗中无法卸下装备。'); + } + + const equippedItem = state.playerEquipment[slot]; + if (!equippedItem) { + return createFailure('slot_empty', `${getEquipmentSlotLabel(slot)}位当前没有装备。`); + } + + const nextEquipment = { + ...state.playerEquipment, + [slot]: null, + }; + const nextState = applyEquipmentLoadoutToState( + { + ...state, + playerInventory: addInventoryItems(state.playerInventory, [equippedItem]), + }, + nextEquipment, + ); + + return { + ok: true, + mutation: 'unequip', + nextState, + actionText: `卸下${equippedItem.name}`, + detailText: buildUnequipResultText(equippedItem), + item: equippedItem, + slot, + }; +} + +export function craftForgeRecipe( + state: RuntimeGameState, + recipeId: string, +): InventoryMutationResult { + if (!state.playerCharacter) { + return createFailure( + 'missing_player_character', + '缺少玩家角色,无法执行锻造配方。', + ); + } + + if (state.inBattle) { + return createFailure('battle_locked', '战斗中无法使用工坊。'); + } + + const recipe = getForgeRecipeCatalog(state).find( + (candidate) => candidate.id === recipeId, + ); + if (!recipe) { + return createFailure('recipe_not_available', '未找到目标锻造配方。'); + } + + const result = executeForgeRecipe( + state.playerInventory, + recipeId, + state.worldType, + state.playerCurrency, + ); + if (!result) { + return createFailure( + 'mutation_not_available', + `${recipe.name} 当前材料或货币不足。`, + ); + } + + return { + ok: true, + mutation: 'craft', + nextState: { + ...state, + playerCurrency: result.currency, + playerInventory: result.inventory, + }, + actionText: `制作${result.createdItem.name}`, + detailText: buildForgeSuccessText('craft', { + recipeName: recipe.name, + createdItemName: result.createdItem.name, + currencyText: recipe.currencyText, + }), + createdItem: result.createdItem, + }; +} + +export function dismantleInventoryItem( + state: RuntimeGameState, + itemId: string, +): InventoryMutationResult { + if (!state.playerCharacter) { + return createFailure( + 'missing_player_character', + '缺少玩家角色,无法执行拆解。', + ); + } + + if (state.inBattle) { + return createFailure('battle_locked', '战斗中无法执行拆解。'); + } + + const item = state.playerInventory.find((candidate) => candidate.id === itemId); + if (!item || item.quantity <= 0) { + return createFailure('item_not_found', '未找到可拆解的物品。'); + } + + const result = executeDismantleItem(state.playerInventory, itemId); + if (!result) { + return createFailure( + 'mutation_not_available', + `${item.name} 当前不支持拆解。`, + ); + } + + return { + ok: true, + mutation: 'dismantle', + nextState: { + ...state, + playerInventory: result.inventory, + }, + actionText: `拆解${item.name}`, + detailText: buildForgeSuccessText('dismantle', { + sourceItemName: item.name, + outputNames: result.outputs.map((output) => output.name), + }), + item, + outputs: result.outputs, + }; +} + +export function reforgeInventoryItem( + state: RuntimeGameState, + itemId: string, +): InventoryMutationResult { + if (!state.playerCharacter) { + return createFailure( + 'missing_player_character', + '缺少玩家角色,无法执行重铸。', + ); + } + + if (state.inBattle) { + return createFailure('battle_locked', '战斗中无法执行重铸。'); + } + + const item = state.playerInventory.find((candidate) => candidate.id === itemId); + if (!item || item.quantity <= 0) { + return createFailure('item_not_found', '未找到可重铸的物品。'); + } + + const reforgeCost = getReforgeCostView(item, state.worldType); + const result = executeReforgeItem( + state.playerInventory, + itemId, + state.playerCurrency, + ); + if (!result) { + return createFailure( + 'mutation_not_available', + `${item.name} 当前不满足重铸条件。`, + ); + } + + return { + ok: true, + mutation: 'reforge', + nextState: { + ...state, + playerCurrency: Math.max(0, state.playerCurrency - result.currencyCost), + playerInventory: result.inventory, + }, + actionText: `重铸${item.name}`, + detailText: buildForgeSuccessText('reforge', { + sourceItemName: item.name, + createdItemName: result.reforgedItem.name, + currencyText: reforgeCost.currencyText, + }), + item, + createdItem: result.reforgedItem, + reforgeCost, + }; +} diff --git a/server-node/src/modules/inventory/inventoryStoryActionService.ts b/server-node/src/modules/inventory/inventoryStoryActionService.ts new file mode 100644 index 00000000..bfea1371 --- /dev/null +++ b/server-node/src/modules/inventory/inventoryStoryActionService.ts @@ -0,0 +1,197 @@ +import type { + RuntimeStoryActionRequest, + RuntimeStoryPatch, +} from '../../../../packages/shared/src/contracts/story.js'; +import { conflict, invalidRequest } from '../../errors.js'; +import { + calculatePlayerBuildSnapshot, + type RuntimeGameState as BuildRuntimeGameState, +} from '../build/buildCalculationService.js'; +import { + craftForgeRecipe, + dismantleInventoryItem, + equipInventoryItem, + reforgeInventoryItem, + unequipInventoryItem, + useInventoryItem, + type InventoryMutationFailure, + type InventoryMutationSuccess, + type RuntimeGameState as InventoryRuntimeGameState, +} from './inventoryMutationService.js'; +import { + replaceRuntimeSessionRawGameState, + type RuntimeSession, +} from '../story/runtimeSession.js'; + +const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set([ + 'equipment_equip', + 'equipment_unequip', + 'forge_craft', + 'forge_dismantle', + 'forge_reforge', + 'inventory_use', +]); + +type InventoryStoryResolution = { + actionText: string; + resultText: string; + patches: RuntimeStoryPatch[]; + toast?: string | null; +}; + +type JsonRecord = Record; + +function isObject(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readPayload(request: RuntimeStoryActionRequest) { + return isObject(request.action.payload) ? request.action.payload : {}; +} + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function readItemId(request: RuntimeStoryActionRequest) { + const payload = readPayload(request); + return ( + readString(payload.itemId) || + readString(payload.targetId) || + readString(request.action.targetId) + ); +} + +function readRecipeId(request: RuntimeStoryActionRequest) { + const payload = readPayload(request); + return ( + readString(payload.recipeId) || + readString(payload.targetId) || + readString(request.action.targetId) + ); +} + +function readEquipmentSlotId(request: RuntimeStoryActionRequest) { + const payload = readPayload(request); + const slotId = + readString(payload.slotId) || readString(request.action.targetId); + + if (slotId === 'weapon' || slotId === 'armor' || slotId === 'relic') { + return slotId; + } + + return ''; +} + +function refreshSessionFromGameState( + session: RuntimeSession, + nextGameState: InventoryMutationSuccess['nextState'], +) { + replaceRuntimeSessionRawGameState( + session, + nextGameState as unknown as JsonRecord, + ); +} + +export function buildBuildToast( + nextState: InventoryMutationSuccess['nextState'], +) { + const snapshot = calculatePlayerBuildSnapshot( + nextState as BuildRuntimeGameState, + ); + if (!snapshot.ok) { + return null; + } + + const buildMultiplier = + snapshot.value.buildBreakdown.buildDamageMultiplier.toFixed(2); + return `当前 Build 倍率 x${buildMultiplier}`; +} + +function throwMutationFailure(error: InventoryMutationFailure): never { + switch (error.code) { + case 'item_not_equippable': + case 'recipe_not_available': + throw invalidRequest(error.message); + default: + throw conflict(error.message); + } +} + +function resolveMutation( + request: RuntimeStoryActionRequest, + state: InventoryRuntimeGameState, +) { + switch (request.action.functionId) { + case 'inventory_use': { + const itemId = readItemId(request); + if (!itemId) { + throw invalidRequest('inventory_use 缺少 itemId'); + } + return useInventoryItem(state, itemId); + } + case 'equipment_equip': { + const itemId = readItemId(request); + if (!itemId) { + throw invalidRequest('equipment_equip 缺少 itemId'); + } + return equipInventoryItem(state, itemId); + } + case 'equipment_unequip': { + const slotId = readEquipmentSlotId(request); + if (!slotId) { + throw invalidRequest('equipment_unequip 缺少合法 slotId'); + } + return unequipInventoryItem(state, slotId); + } + case 'forge_craft': { + const recipeId = readRecipeId(request); + if (!recipeId) { + throw invalidRequest('forge_craft 缺少 recipeId'); + } + return craftForgeRecipe(state, recipeId); + } + case 'forge_dismantle': { + const itemId = readItemId(request); + if (!itemId) { + throw invalidRequest('forge_dismantle 缺少 itemId'); + } + return dismantleInventoryItem(state, itemId); + } + case 'forge_reforge': { + const itemId = readItemId(request); + if (!itemId) { + throw invalidRequest('forge_reforge 缺少 itemId'); + } + return reforgeInventoryItem(state, itemId); + } + default: + throw invalidRequest(`暂不支持的 Inventory 动作:${request.action.functionId}`); + } +} + +export function isSupportedInventoryStoryFunctionId(functionId: string) { + return SUPPORTED_INVENTORY_STORY_FUNCTION_IDS.has(functionId); +} + +export function resolveInventoryStoryAction( + session: RuntimeSession, + request: RuntimeStoryActionRequest, +): InventoryStoryResolution { + const mutation = resolveMutation( + request, + session.rawGameState as InventoryRuntimeGameState, + ); + if (!mutation.ok) { + throwMutationFailure(mutation); + } + + refreshSessionFromGameState(session, mutation.nextState); + + return { + actionText: mutation.actionText, + resultText: mutation.detailText, + patches: [], + toast: buildBuildToast(mutation.nextState), + }; +} diff --git a/server-node/src/modules/inventory/npcInventoryStoryActionService.ts b/server-node/src/modules/inventory/npcInventoryStoryActionService.ts new file mode 100644 index 00000000..86f87d18 --- /dev/null +++ b/server-node/src/modules/inventory/npcInventoryStoryActionService.ts @@ -0,0 +1,386 @@ +import type { + RuntimeStoryActionRequest, + RuntimeStoryPatch, +} from '../../../../packages/shared/src/contracts/story.js'; +import { conflict, invalidRequest } from '../../errors.js'; +import { + addInventoryItems, + appendStoryEngineCarrierMemory, + applyStoryChoiceToStanceProfile, + buildInitialNpcState, + buildNpcGiftCommitActionText, + buildNpcGiftResultText, + buildNpcTradeTransactionActionText, + buildNpcTradeTransactionResultText, + buildRelationState, + getGiftCandidates, + getNpcBuybackPrice, + getNpcPurchasePrice, + markNpcFirstMeaningfulContactResolved, + normalizeNpcPersistentState, + removeInventoryItem, + syncNpcTradeInventory, +} from '../../bridges/legacyNpcTask6Bridge.js'; +import { + replaceRuntimeSessionRawGameState, + type RuntimeSession, +} from '../story/runtimeSession.js'; + +const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set([ + 'npc_gift', + 'npc_trade', +]); + +type NpcInventoryStoryResolution = { + actionText: string; + resultText: string; + patches: RuntimeStoryPatch[]; +}; + +type JsonRecord = Record; +type RuntimeInventoryItem = Parameters[1][number]; +type RuntimeGameState = Parameters[0]; +type RuntimeEncounter = Parameters[0]; + +function isObject(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readPayload(request: RuntimeStoryActionRequest) { + return isObject(request.action.payload) ? request.action.payload : {}; +} + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function readPositiveInteger(value: unknown, fallback = 1) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(1, Math.floor(value)); +} + +function cloneInventoryItemForOwner( + item: RuntimeInventoryItem, + owner: 'player' | 'npc', + quantity = 1, +): RuntimeInventoryItem { + const preserveIdentity = Boolean( + item.runtimeMetadata || + item.buildProfile || + item.equipmentSlotId || + item.statProfile || + item.attributeResonance, + ); + + return { + ...item, + id: preserveIdentity + ? `${owner}:${item.id}:${quantity}` + : `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`, + quantity, + runtimeMetadata: item.runtimeMetadata + ? { + ...item.runtimeMetadata, + seedKey: `${item.runtimeMetadata.seedKey}:${owner}`, + } + : item.runtimeMetadata, + }; +} + +function getNpcEncounterKey(encounter: RuntimeEncounter) { + return encounter.id?.trim() || encounter.npcName; +} + +function getNpcEncounter( + session: RuntimeSession, + state: RuntimeGameState, +): RuntimeEncounter | null { + const rawEncounter = state.currentEncounter; + if (!rawEncounter || rawEncounter.kind !== 'npc') { + return null; + } + + return { + npcAvatar: '', + hostile: false, + ...rawEncounter, + id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName, + } satisfies RuntimeEncounter; +} + +export function ensureNpcInventorySessionState(session: RuntimeSession) { + const state = session.rawGameState as unknown as RuntimeGameState; + const encounter = getNpcEncounter(session, state); + if (!encounter) { + return; + } + + const npcKey = getNpcEncounterKey(encounter); + const baseNpcState = + state.npcStates?.[npcKey] ?? + buildInitialNpcState(encounter, state.worldType, state); + const normalizedNpcState = normalizeNpcPersistentState(baseNpcState); + const syncedNpcState = syncNpcTradeInventory(state, encounter, normalizedNpcState); + + const nextState = { + ...state, + npcStates: { + ...(state.npcStates ?? {}), + [npcKey]: syncedNpcState, + }, + } satisfies RuntimeGameState; + + replaceRuntimeSessionRawGameState( + session, + nextState as unknown as JsonRecord, + ); +} + +function getCurrentNpcState(session: RuntimeSession) { + const state = session.rawGameState as unknown as RuntimeGameState; + const encounter = getNpcEncounter(session, state); + if (!encounter) { + throw conflict('当前不在可结算的 NPC 交互态,无法执行交易或赠礼。'); + } + + const npcKey = getNpcEncounterKey(encounter); + const npcState = state.npcStates?.[npcKey]; + if (!npcState) { + throw conflict('当前 NPC 状态不存在,无法继续结算。'); + } + + return { + state, + encounter, + npcKey, + npcState, + }; +} + +function resolveTradeMode(request: RuntimeStoryActionRequest) { + const mode = readString(readPayload(request).mode); + if (mode === 'buy' || mode === 'sell') { + return mode; + } + + throw invalidRequest('npc_trade 缺少合法 mode,需为 buy 或 sell'); +} + +function readTradeItemId(request: RuntimeStoryActionRequest) { + const payload = readPayload(request); + return ( + readString(payload.itemId) || + readString(payload.selectedNpcItemId) || + readString(payload.selectedPlayerItemId) || + readString(request.action.targetId) + ); +} + +function readTradeQuantity(request: RuntimeStoryActionRequest) { + return readPositiveInteger(readPayload(request).quantity, 1); +} + +function resolveNpcTradeAction( + session: RuntimeSession, + request: RuntimeStoryActionRequest, +): NpcInventoryStoryResolution { + ensureNpcInventorySessionState(session); + const { state, encounter, npcKey, npcState } = getCurrentNpcState(session); + const mode = resolveTradeMode(request); + const itemId = readTradeItemId(request); + const quantity = readTradeQuantity(request); + + if (!itemId) { + throw invalidRequest('npc_trade 缺少 itemId'); + } + + if (mode === 'buy') { + const npcItem = npcState.inventory.find((item) => item.id === itemId); + if (!npcItem || npcItem.quantity < quantity) { + throw conflict('目标商品不存在或库存不足。'); + } + + const totalPrice = getNpcPurchasePrice(npcItem, npcState.affinity) * quantity; + if (state.playerCurrency < totalPrice) { + throw conflict('当前钱币不足,无法完成购买。'); + } + + const acquiredItem = cloneInventoryItemForOwner(npcItem, 'player', quantity); + let nextState = { + ...state, + playerCurrency: state.playerCurrency - totalPrice, + playerInventory: addInventoryItems(state.playerInventory, [acquiredItem]), + npcStates: { + ...state.npcStates, + [npcKey]: { + ...markNpcFirstMeaningfulContactResolved(npcState), + inventory: removeInventoryItem(npcState.inventory, npcItem.id, quantity), + }, + }, + } satisfies RuntimeGameState; + nextState = appendStoryEngineCarrierMemory(nextState, [acquiredItem]); + + replaceRuntimeSessionRawGameState( + session, + nextState as unknown as JsonRecord, + ); + + return { + actionText: buildNpcTradeTransactionActionText({ + encounter, + mode: 'buy', + item: npcItem, + quantity, + }), + resultText: buildNpcTradeTransactionResultText({ + encounter, + mode: 'buy', + item: npcItem, + quantity, + totalPrice, + worldType: state.worldType, + }), + patches: [], + }; + } + + const playerItem = state.playerInventory.find((item) => item.id === itemId); + if (!playerItem || playerItem.quantity < quantity) { + throw conflict('背包里没有足够数量的目标物品。'); + } + + const totalPrice = getNpcBuybackPrice(playerItem, npcState.affinity) * quantity; + const soldItem = cloneInventoryItemForOwner(playerItem, 'npc', quantity); + const nextState = { + ...state, + playerCurrency: state.playerCurrency + totalPrice, + playerInventory: removeInventoryItem(state.playerInventory, playerItem.id, quantity), + npcStates: { + ...state.npcStates, + [npcKey]: { + ...markNpcFirstMeaningfulContactResolved(npcState), + inventory: addInventoryItems(npcState.inventory, [soldItem]), + }, + }, + } satisfies RuntimeGameState; + + replaceRuntimeSessionRawGameState( + session, + nextState as unknown as JsonRecord, + ); + + return { + actionText: buildNpcTradeTransactionActionText({ + encounter, + mode: 'sell', + item: playerItem, + quantity, + }), + resultText: buildNpcTradeTransactionResultText({ + encounter, + mode: 'sell', + item: playerItem, + quantity, + totalPrice, + worldType: state.worldType, + }), + patches: [], + }; +} + +function resolveNpcGiftAction( + session: RuntimeSession, + request: RuntimeStoryActionRequest, +): NpcInventoryStoryResolution { + ensureNpcInventorySessionState(session); + const { state, encounter, npcKey, npcState } = getCurrentNpcState(session); + const itemId = + readString(readPayload(request).itemId) || readString(request.action.targetId); + + if (!itemId) { + throw invalidRequest('npc_gift 缺少 itemId'); + } + + const giftItem = state.playerInventory.find((item) => item.id === itemId); + if (!giftItem || giftItem.quantity <= 0) { + throw conflict('背包里没有这件可赠送的物品。'); + } + + const giftCandidate = getGiftCandidates(state.playerInventory, encounter, { + worldType: state.worldType, + customWorldProfile: state.customWorldProfile, + }).find((candidate) => candidate.item.id === giftItem.id); + const affinityGain = giftCandidate?.affinityGain ?? 0; + const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? undefined; + const nextAffinity = npcState.affinity + affinityGain; + const nextNpcState = { + ...markNpcFirstMeaningfulContactResolved(npcState), + affinity: nextAffinity, + relationState: buildRelationState(nextAffinity), + giftsGiven: (npcState.giftsGiven ?? 0) + 1, + stanceProfile: applyStoryChoiceToStanceProfile( + npcState.stanceProfile, + 'npc_gift', + { affinityGain }, + ), + inventory: addInventoryItems(npcState.inventory, [ + cloneInventoryItemForOwner(giftItem, 'npc'), + ]), + }; + + const nextState = { + ...state, + playerInventory: removeInventoryItem(state.playerInventory, giftItem.id, 1), + npcStates: { + ...state.npcStates, + [npcKey]: nextNpcState, + }, + } satisfies RuntimeGameState; + + replaceRuntimeSessionRawGameState( + session, + nextState as unknown as JsonRecord, + ); + + return { + actionText: buildNpcGiftCommitActionText(encounter, giftItem), + resultText: buildNpcGiftResultText( + encounter, + giftItem, + affinityGain, + nextAffinity, + attributeSummary, + ), + patches: [ + { + type: 'npc_affinity_changed', + npcId: npcKey, + previousAffinity: npcState.affinity, + nextAffinity, + }, + ], + }; +} + +export function isSupportedNpcInventoryStoryFunctionId(functionId: string) { + return SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS.has(functionId); +} + +export function resolveNpcInventoryStoryAction( + session: RuntimeSession, + request: RuntimeStoryActionRequest, +): NpcInventoryStoryResolution { + switch (request.action.functionId) { + case 'npc_trade': + return resolveNpcTradeAction(session, request); + case 'npc_gift': + return resolveNpcGiftAction(session, request); + default: + throw invalidRequest( + `暂不支持的 NPC Inventory 动作:${request.action.functionId}`, + ); + } +} diff --git a/server-node/src/modules/npc/npcInteractionService.ts b/server-node/src/modules/npc/npcInteractionService.ts new file mode 100644 index 00000000..b5401555 --- /dev/null +++ b/server-node/src/modules/npc/npcInteractionService.ts @@ -0,0 +1,261 @@ +import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js'; +import { conflict } from '../../errors.js'; +import { + MAX_TASK5_COMPANIONS, + getEncounterNpcState, + setEncounterNpcState, + type RuntimeEncounter, + type RuntimeNpcState, + type RuntimeSession, +} from '../story/runtimeSession.js'; + +export type NpcInteractionResolution = { + actionText: string; + resultText: string; + patches: RuntimeStoryPatch[]; + storyText?: string; + toast?: string | null; +}; + +function requireNpcEncounter(session: RuntimeSession) { + if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { + throw conflict('当前没有可结算的 NPC 交互对象'); + } + + return session.currentEncounter; +} + +function requireNpcState( + session: RuntimeSession, + encounter: RuntimeEncounter, +): RuntimeNpcState { + const npcState = getEncounterNpcState(session); + if (!npcState) { + throw conflict(`未找到 ${encounter.npcName} 的运行时关系状态`); + } + + return npcState; +} + +function buildAffinityPatch( + encounter: RuntimeEncounter, + previousAffinity: number, + nextAffinity: number, +) { + return { + type: 'npc_affinity_changed', + npcId: encounter.id, + previousAffinity, + nextAffinity, + } satisfies RuntimeStoryPatch; +} + +function buildBattleTarget( + encounter: RuntimeEncounter, + npcState: RuntimeNpcState, + mode: 'fight' | 'spar', +) { + const maxHp = + mode === 'spar' + ? 8 + : Math.max(32, 24 + Math.max(0, Math.round(npcState.affinity * 0.35))); + + return { + id: encounter.id, + name: encounter.npcName, + hp: maxHp, + maxHp, + description: encounter.npcDescription, + }; +} + +export function resolveNpcInteraction( + session: RuntimeSession, + functionId: string, +): NpcInteractionResolution { + const encounter = requireNpcEncounter(session); + const npcState = requireNpcState(session, encounter); + + switch (functionId) { + case 'npc_preview_talk': { + session.npcInteractionActive = true; + return { + actionText: `转向${encounter.npcName}`, + resultText: `你把注意力真正收回到${encounter.npcName}身上,接下来可以围绕这名角色做正式交互了。`, + patches: [ + { + type: 'status_changed', + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + }, + ], + }; + } + case 'npc_chat': { + session.npcInteractionActive = true; + const affinityGain = Math.max(2, 6 - npcState.chattedCount); + const nextAffinity = npcState.affinity + affinityGain; + setEncounterNpcState(session, { + ...npcState, + affinity: nextAffinity, + chattedCount: npcState.chattedCount + 1, + firstMeaningfulContactResolved: true, + }); + + return { + actionText: `继续和${encounter.npcName}交谈`, + resultText: `${encounter.npcName}愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 ${affinityGain} 点。`, + patches: [ + buildAffinityPatch(encounter, npcState.affinity, nextAffinity), + { + type: 'status_changed', + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + }, + ], + }; + } + case 'npc_help': { + if (npcState.helpUsed) { + throw conflict('当前 NPC 的一次性援手已经用完了'); + } + + const previousAffinity = npcState.affinity; + const nextAffinity = previousAffinity + 4; + session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 10); + session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 8); + setEncounterNpcState(session, { + ...npcState, + affinity: nextAffinity, + helpUsed: true, + }); + + return { + actionText: `向${encounter.npcName}请求援手`, + resultText: `${encounter.npcName}给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。`, + patches: [ + buildAffinityPatch(encounter, previousAffinity, nextAffinity), + { + type: 'status_changed', + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + }, + ], + }; + } + case 'npc_recruit': { + if (npcState.recruited) { + throw conflict('当前 NPC 已经处于已招募状态'); + } + if (npcState.affinity < 60) { + throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队'); + } + if (session.companions.length >= MAX_TASK5_COMPANIONS) { + throw conflict('队伍已满,任务5首轮后端接口暂不处理换队逻辑'); + } + + setEncounterNpcState(session, { + ...npcState, + recruited: true, + firstMeaningfulContactResolved: true, + }); + session.companions.push({ + npcId: encounter.id, + characterId: encounter.characterId ?? '', + joinedAtAffinity: npcState.affinity, + }); + session.currentEncounter = null; + session.npcInteractionActive = false; + session.currentNpcBattleMode = null; + session.currentNpcBattleOutcome = null; + session.inBattle = false; + session.sceneHostileNpcs = []; + + return { + actionText: `邀请${encounter.npcName}加入队伍`, + resultText: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`, + patches: [ + { + type: 'status_changed', + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + }, + { + type: 'encounter_changed', + encounterId: null, + }, + ], + }; + } + case 'npc_fight': + case 'npc_spar': { + session.npcInteractionActive = false; + session.inBattle = true; + session.currentNpcBattleMode = functionId === 'npc_spar' ? 'spar' : 'fight'; + session.currentNpcBattleOutcome = null; + session.sceneHostileNpcs = [ + buildBattleTarget( + encounter, + npcState, + functionId === 'npc_spar' ? 'spar' : 'fight', + ), + ]; + + return { + actionText: + functionId === 'npc_spar' + ? `与${encounter.npcName}点到为止切磋` + : `与${encounter.npcName}正面开战`, + resultText: + functionId === 'npc_spar' + ? `${encounter.npcName}摆开架势,准备和你来一场点到为止的切磋。` + : `${encounter.npcName}已经不再保留余地,当前冲突正式转入战斗结算。`, + patches: [ + { + type: 'status_changed', + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + }, + ], + }; + } + case 'npc_leave': { + session.currentEncounter = null; + session.npcInteractionActive = false; + session.currentNpcBattleMode = null; + session.currentNpcBattleOutcome = null; + session.sceneHostileNpcs = []; + session.inBattle = false; + + return { + actionText: `离开${encounter.npcName}`, + resultText: `你暂时没有继续和${encounter.npcName}纠缠,把注意力重新拉回了前路。`, + patches: [ + { + type: 'status_changed', + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + }, + { + type: 'encounter_changed', + encounterId: null, + }, + ], + }; + } + default: + throw conflict(`暂不支持的 NPC 动作:${functionId}`); + } +} diff --git a/server-node/src/modules/npc/npcTask6Primitives.test.ts b/server-node/src/modules/npc/npcTask6Primitives.test.ts new file mode 100644 index 00000000..3ec857df --- /dev/null +++ b/server-node/src/modules/npc/npcTask6Primitives.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; +import { + buildInitialNpcState, + getGiftCandidates, + syncNpcTradeInventory, +} from './npcTask6Primitives.js'; + +function createState(overrides: Record = {}) { + return { + worldType: 'WUXIA', + customWorldProfile: null, + currentScenePreset: { + id: 'market-street', + name: '桥市', + description: '桥下的临时市集还没有散。', + treasureHints: [], + }, + storyHistory: [ + { + text: '你刚从桥口撤下来,正准备补足补给。', + }, + ], + playerCharacter: createTestPlayerCharacter<{ id: string }>(), + playerEquipment: { + weapon: { + tags: ['weapon', '快剑'], + buildProfile: { + role: '快剑', + tags: ['快剑', '突进'], + }, + }, + armor: null, + relic: null, + }, + activeBuildBuffs: [ + { + tags: ['续战'], + }, + ], + ...overrides, + }; +} + +test('buildInitialNpcState generates deterministic trade stock for runtime role npc', () => { + const state = createState(); + const npcState = buildInitialNpcState( + { + id: 'npc_vendor_01', + npcName: '桥市货郎', + npcDescription: '背着木箱沿街兜售补给的行脚货郎。', + context: '沿街商贩', + }, + 'WUXIA', + state, + ); + + assert.equal(npcState.affinity, 6); + assert.equal(typeof npcState.tradeStockSignature, 'string'); + assert.ok((npcState.tradeStockSignature ?? '').includes('npc_vendor_01')); + assert.ok(npcState.inventory.length > 0); + assert.ok( + npcState.inventory.every( + (item) => item.runtimeMetadata?.generationChannel === 'npc_trade', + ), + ); +}); + +test('syncNpcTradeInventory keeps non-trade items while refreshing generated stock', () => { + const state = createState({ + activeBuildBuffs: [{ tags: ['爆发'] }], + }); + const nextState = syncNpcTradeInventory( + state, + { + id: 'npc_vendor_02', + npcName: '药铺掌柜', + npcDescription: '一边记账一边看着药炉火候。', + context: '药商', + }, + { + affinity: 14, + helpUsed: false, + chattedCount: 0, + giftsGiven: 1, + inventory: [ + { + id: 'gift-token', + category: '信物', + name: '旧铜铃', + quantity: 1, + rarity: 'rare', + tags: ['relic'], + runtimeMetadata: { + generationChannel: 'npc_gift', + }, + }, + ], + recruited: false, + tradeStockSignature: 'outdated-signature', + firstMeaningfulContactResolved: true, + knownAttributeRumors: [], + revealedFacts: [], + seenBackstoryChapterIds: [], + }, + ); + + assert.ok(nextState.inventory.some((item) => item.id === 'gift-token')); + assert.ok( + nextState.inventory.some( + (item) => item.runtimeMetadata?.generationChannel === 'npc_trade', + ), + ); + assert.notEqual(nextState.tradeStockSignature, 'outdated-signature'); +}); + +test('getGiftCandidates prefers gifts that match npc role tags', () => { + const candidates = getGiftCandidates( + [ + { + id: 'mana-herb', + category: '材料', + name: '暖息草', + quantity: 1, + rarity: 'rare', + tags: ['material', 'mana'], + }, + { + id: 'plain-stone', + category: '材料', + name: '碎石', + quantity: 1, + rarity: 'common', + tags: ['material'], + }, + ], + { + id: 'npc_vendor_03', + npcName: '药行掌柜', + npcDescription: '对药性和回气补给都很熟。', + context: '药商', + }, + ); + + assert.equal(candidates[0]?.item.id, 'mana-herb'); + assert.ok((candidates[0]?.affinityGain ?? 0) > (candidates[1]?.affinityGain ?? 0)); + assert.match(candidates[0]?.attributeInsight?.reasonText ?? '', /回气|补给/u); +}); diff --git a/server-node/src/modules/npc/npcTask6Primitives.ts b/server-node/src/modules/npc/npcTask6Primitives.ts new file mode 100644 index 00000000..0218bdfd --- /dev/null +++ b/server-node/src/modules/npc/npcTask6Primitives.ts @@ -0,0 +1,411 @@ +import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js'; +import { buildRelationState, sortInventoryItems } from '../runtime/runtimeStatePrimitives.js'; +import { + buildLooseRuntimeItemGenerationContext, + buildRuntimeInventoryStock, +} from '../runtime-item/runtimeItemModule.js'; +import { normalizeNpcPersistentState } from '../runtime/runtimeNpcStatePrimitives.js'; + +type RuntimeInventoryItem = { + id: string; + category: string; + name: string; + quantity: number; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + tags: string[]; + runtimeMetadata?: { + generationChannel?: string; + } | null; +}; + +type RuntimeEncounter = { + id?: string; + npcName: string; + context: string; + characterId?: string | null; + monsterPresetId?: string | null; + initialAffinity?: number; +}; + +type RuntimeNpcState = { + affinity: number; + helpUsed: boolean; + chattedCount: number; + giftsGiven: number; + inventory: RuntimeInventoryItem[]; + recruited: boolean; + relationState?: ReturnType; + revealedFacts?: string[]; + knownAttributeRumors?: string[]; + firstMeaningfulContactResolved?: boolean; + seenBackstoryChapterIds?: string[]; + tradeStockSignature?: string | null; + stanceProfile?: { + trust?: number; + warmth?: number; + ideologicalFit?: number; + fearOrGuard?: number; + loyalty?: number; + currentConflictTag?: string | null; + recentApprovals?: string[]; + recentDisapprovals?: string[]; + } | null; +}; + +function clampStanceMetric(value: number) { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function buildInitialStanceProfile( + affinity: number, + options: { + recruited?: boolean; + hostile?: boolean; + roleText?: string | null; + } = {}, +) { + const recruitedBonus = options.recruited ? 14 : 0; + const hostilePenalty = options.hostile ? 18 : 0; + const roleText = options.roleText ?? ''; + const currentConflictTag = + /旧案|调查|追查/u.test(roleText) + ? '旧案' + : /守|卫|巡/u.test(roleText) + ? '守线' + : /商|摊|军需/u.test(roleText) + ? '交易' + : null; + + return { + trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus - hostilePenalty), + warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus), + ideologicalFit: clampStanceMetric(48 + affinity * 0.25), + fearOrGuard: clampStanceMetric(62 - affinity * 0.55 + hostilePenalty), + loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)), + currentConflictTag, + recentApprovals: [], + recentDisapprovals: [], + }; +} + +function getRarityScore(rarity: RuntimeInventoryItem['rarity']) { + switch (rarity) { + case 'legendary': + return 5; + case 'epic': + return 4; + case 'rare': + return 3; + case 'uncommon': + return 2; + default: + return 1; + } +} + +function describeAffinityShift(affinityGain: number) { + if (affinityGain >= 12) return '态度一下子软化了许多'; + if (affinityGain >= 8) return '态度明显和缓下来'; + if (affinityGain >= 5) return '态度比先前亲近了一些'; + return '态度略微放松了些'; +} + +function describeNpcAffinityInWords( + affinity: number, + options: { recruited?: boolean } = {}, +) { + if (options.recruited) { + return '已经把你视为并肩而行的同伴,交流时天然站在你这一边。'; + } + if (affinity >= 90) return '对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。'; + if (affinity >= 60) return '对你已经建立起稳固信任,愿意进一步合作。'; + if (affinity >= 30) return '对你的态度明显友善了许多,也更愿意正常交流。'; + if (affinity >= 15) return '戒备开始松动,愿意试探性地配合你的节奏。'; + if (affinity >= 0) return '仍保持明显距离,只会给出谨慎而有限的回应。'; + return '关系已经降到冰点,对你几乎不再保留善意。'; +} + +function isRuntimeTradeDrivenRoleNpc(encounter: RuntimeEncounter) { + return !encounter.characterId && !encounter.monsterPresetId; +} + +export function applyStoryChoiceToStanceProfile( + stanceProfile: RuntimeNpcState['stanceProfile'], + action: 'npc_chat' | 'npc_help' | 'npc_gift' | 'npc_recruit' | 'npc_quest_accept', + options: { + affinityGain?: number; + recruited?: boolean; + } = {}, +) { + const base = + stanceProfile ?? + buildInitialStanceProfile(0, { + recruited: options.recruited, + }); + const affinityGain = options.affinityGain ?? 0; + const approvalNotes = [...(base.recentApprovals ?? [])]; + const disapprovalNotes = [...(base.recentDisapprovals ?? [])]; + + const applyApproval = (note: string) => { + approvalNotes.push(note); + while (approvalNotes.length > 3) approvalNotes.shift(); + }; + const applyDisapproval = (note: string) => { + disapprovalNotes.push(note); + while (disapprovalNotes.length > 3) disapprovalNotes.shift(); + }; + + const next = { + ...base, + trust: base.trust ?? 40, + warmth: base.warmth ?? 35, + ideologicalFit: base.ideologicalFit ?? 45, + fearOrGuard: base.fearOrGuard ?? 55, + loyalty: base.loyalty ?? 20, + }; + + switch (action) { + case 'npc_chat': + next.trust += 6 + affinityGain * 2; + next.warmth += 4 + affinityGain * 2; + next.fearOrGuard -= 5 + affinityGain; + if (affinityGain >= 0) { + applyApproval('你愿意先从眼前局势和试探开始说话。'); + } else { + applyDisapproval('这轮交流没能真正对上节奏。'); + } + break; + case 'npc_help': + next.trust += 12; + next.warmth += 6; + next.fearOrGuard -= 8; + applyApproval('你在对方需要的时候搭了手。'); + break; + case 'npc_gift': + next.trust += 6 + affinityGain; + next.warmth += 10 + affinityGain * 2; + next.fearOrGuard -= 4; + applyApproval('你给出的东西回应了对方眼下的处境。'); + break; + case 'npc_recruit': + next.trust += 8; + next.warmth += 6; + next.loyalty += 18; + next.fearOrGuard -= 10; + applyApproval('你正式把对方纳入了同行关系。'); + break; + case 'npc_quest_accept': + next.trust += 7; + next.ideologicalFit += 5; + next.loyalty += 4; + applyApproval('你接住了对方主动交出来的事。'); + break; + } + + return { + ...next, + trust: clampStanceMetric(next.trust), + warmth: clampStanceMetric(next.warmth), + ideologicalFit: clampStanceMetric(next.ideologicalFit), + fearOrGuard: clampStanceMetric(next.fearOrGuard), + loyalty: clampStanceMetric(next.loyalty), + recentApprovals: approvalNotes, + recentDisapprovals: disapprovalNotes, + }; +} + +export function buildInitialNpcState( + encounter: RuntimeEncounter, + worldType: string | null | undefined, + state?: { + currentScenePreset?: { + id: string; + name: string; + description?: string; + treasureHints?: string[]; + } | null; + playerCharacter?: { + id: string; + } | null; + } | null, +) { + const initialAffinity = + encounter.initialAffinity ?? + (encounter.monsterPresetId ? -40 : encounter.characterId ? 18 : 6); + const baseState = normalizeNpcPersistentState({ + affinity: initialAffinity, + relationState: buildRelationState(initialAffinity), + helpUsed: false, + chattedCount: 0, + giftsGiven: 0, + inventory: [] as RuntimeInventoryItem[], + tradeStockSignature: null, + recruited: false, + revealedFacts: [], + knownAttributeRumors: [], + firstMeaningfulContactResolved: false, + seenBackstoryChapterIds: [], + stanceProfile: buildInitialStanceProfile(initialAffinity, { + recruited: false, + hostile: Boolean(encounter.monsterPresetId) || initialAffinity < 0, + roleText: encounter.context, + }), + }); + + if (state && isRuntimeTradeDrivenRoleNpc(encounter)) { + return syncNpcTradeInventory( + { + worldType, + currentScenePreset: state.currentScenePreset ?? null, + playerCharacter: state.playerCharacter ?? null, + }, + encounter, + baseState, + ); + } + + return baseState; +} + +export function getGiftCandidates( + playerInventory: RuntimeInventoryItem[], + _encounter: RuntimeEncounter, +) { + return [...playerInventory] + .filter((item) => item.quantity > 0) + .map((item) => ({ + item, + affinityGain: + Math.min( + 24, + 4 + + getRarityScore(item.rarity) * 3 + + (item.tags.includes('mana') ? 3 : 0) + + (item.tags.includes('healing') ? 3 : 0), + ), + attributeInsight: { + reasonText: item.tags.includes('mana') + ? '这份礼物明显更适合对方当前的回气与补给需求。' + : item.tags.includes('healing') + ? '这份礼物更像是在照顾对方眼下的补给处境。' + : '这份礼物至少表达了你愿意先拿出诚意。', + }, + })) + .sort((left, right) => { + const diff = right.affinityGain - left.affinityGain; + if (diff !== 0) return diff; + return getRarityScore(right.item.rarity) - getRarityScore(left.item.rarity); + }); +} + +export function buildNpcGiftResultText( + encounter: RuntimeEncounter, + item: RuntimeInventoryItem, + affinityGain: number, + nextAffinity: number, + attributeSummary?: string, +) { + const summaryText = attributeSummary ? `你感到:${attributeSummary}` : ''; + return `${encounter.npcName}收下了${item.name},${describeAffinityShift(affinityGain)}。${describeNpcAffinityInWords(nextAffinity)}${summaryText}`; +} + +export function buildNpcGiftCommitActionText( + encounter: RuntimeEncounter, + item: RuntimeInventoryItem, +) { + return `把${item.name}赠给${encounter.npcName}`; +} + +export function buildNpcTradeTransactionResultText(params: { + encounter: RuntimeEncounter; + mode: 'buy' | 'sell'; + item: RuntimeInventoryItem; + quantity: number; + totalPrice: number; + worldType: string | null | undefined; +}) { + const quantityText = + params.quantity > 1 ? `${params.item.name} x${params.quantity}` : params.item.name; + + if (params.mode === 'sell') { + return `${params.encounter.npcName}收下了${quantityText},付给你${formatCurrency(params.totalPrice, params.worldType)}。`; + } + + return `${params.encounter.npcName}收下了${formatCurrency(params.totalPrice, params.worldType)},把${quantityText}卖给了你。`; +} + +export function buildNpcTradeTransactionActionText(params: { + encounter: RuntimeEncounter; + mode: 'buy' | 'sell'; + item: RuntimeInventoryItem; + quantity: number; +}) { + const quantityText = + params.quantity > 1 ? `${params.item.name} x${params.quantity}` : params.item.name; + + if (params.mode === 'sell') { + return `把${quantityText}卖给${params.encounter.npcName}`; + } + + return `从${params.encounter.npcName}手里买下${quantityText}`; +} + +export function syncNpcTradeInventory( + state: { + worldType: string | null | undefined; + currentScenePreset?: { + id: string; + name: string; + description?: string; + treasureHints?: string[]; + } | null; + playerCharacter?: { + id: string; + } | null; + }, + encounter: RuntimeEncounter, + npcState: RuntimeNpcState, +) { + if (!isRuntimeTradeDrivenRoleNpc(encounter)) { + return npcState; + } + + const tradeStockSignature = `${encounter.id ?? encounter.npcName}:${state.currentScenePreset?.id ?? 'scene'}:${state.worldType ?? 'world'}`; + if (npcState.tradeStockSignature === tradeStockSignature) { + return npcState; + } + + const runtimeStock = buildRuntimeInventoryStock( + buildLooseRuntimeItemGenerationContext({ + worldType: state.worldType, + scene: state.currentScenePreset ?? null, + encounter: { + ...encounter, + kind: 'npc', + npcDescription: encounter.context, + npcAvatar: '', + context: encounter.context, + }, + playerCharacterId: state.playerCharacter?.id ?? 'npc-trade-preview', + generationChannel: 'npc_trade', + }), + { + seedKey: `npc-trade:${encounter.id ?? encounter.npcName}`, + itemCount: 4, + fixedKinds: ['consumable', 'material', 'relic', 'equipment'], + fixedPermanence: ['timed', 'resource', 'permanent', 'permanent'], + } as Parameters[1], + ); + + const preservedInventory = npcState.tradeStockSignature + ? npcState.inventory.filter( + (item) => item.runtimeMetadata?.generationChannel !== 'npc_trade', + ) + : []; + + return normalizeNpcPersistentState({ + ...npcState, + inventory: sortInventoryItems([...preservedInventory, ...runtimeStock]), + tradeStockSignature, + }); +} diff --git a/server-node/src/modules/quest/index.ts b/server-node/src/modules/quest/index.ts new file mode 100644 index 00000000..b06b5a3f --- /dev/null +++ b/server-node/src/modules/quest/index.ts @@ -0,0 +1,2 @@ +export * from './questProgressionService.js'; +export { generateQuestForNpcEncounter } from '../../services/questService.js'; diff --git a/server-node/src/modules/quest/questProgressionService.test.ts b/server-node/src/modules/quest/questProgressionService.test.ts new file mode 100644 index 00000000..83d2ab28 --- /dev/null +++ b/server-node/src/modules/quest/questProgressionService.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js'; +import { + acknowledgeQuestCompletion, + applyQuestSignal, + turnInQuest, +} from './questProgressionService.js'; + +const TEST_WORLD = 'WUXIA' as Parameters[0]['worldType']; + +const TEST_SCENE = { + id: 'forest_path', + name: 'Forest Path', + description: 'A narrow trail with fresh claw marks.', + npcs: [ + { + id: 'hostile-wolf-alpha', + name: '狼王', + description: 'A hostile wolf alpha.', + avatar: '狼', + role: '敌对角色', + monsterPresetId: 'wolf_alpha', + initialAffinity: -40, + hostile: true, + }, + ], + treasureHints: [], +}; + +function createQuest() { + const quest = buildQuestForEncounter({ + issuerNpcId: 'npc_scout', + issuerNpcName: 'Scout Lin', + roleText: 'tracker', + scene: TEST_SCENE, + worldType: TEST_WORLD, + currentQuests: [], + }); + assert.ok(quest); + return quest; +} + +test('applyQuestSignal advances quest steps on the server side', () => { + const quest = createQuest(); + const result = applyQuestSignal([quest], { + kind: 'hostile_npc_defeated', + sceneId: TEST_SCENE.id, + hostileNpcId: 'wolf_alpha', + }); + + assert.equal(result.updatedQuestIds.length, 1); + assert.equal(result.updatedQuestIds[0], quest.id); + assert.equal(result.updatedQuests[0]?.objective.kind, 'talk_to_npc'); + assert.equal(result.updatedQuests[0]?.status, 'active'); +}); + +test('turnInQuest rejects unfinished quests before reward-ready state', () => { + const quest = createQuest(); + const result = turnInQuest([quest], quest.id); + + assert.equal(result.ok, false); + if (result.ok) { + return; + } + + assert.equal(result.code, 'quest_not_ready_to_turn_in'); +}); + +test('turnInQuest marks ready quests as turned in after signal progression', () => { + const quest = createQuest(); + const afterBattle = applyQuestSignal([quest], { + kind: 'hostile_npc_defeated', + sceneId: TEST_SCENE.id, + hostileNpcId: 'wolf_alpha', + }); + const afterTalk = applyQuestSignal(afterBattle.nextQuests, { + kind: 'npc_talk_completed', + npcId: 'npc_scout', + }); + const turnInResult = turnInQuest(afterTalk.nextQuests, quest.id); + + assert.equal(turnInResult.ok, true); + if (!turnInResult.ok) { + return; + } + + assert.equal(turnInResult.updatedQuests[0]?.status, 'turned_in'); + assert.equal(turnInResult.updatedQuests[0]?.completionNotified, true); +}); + +test('acknowledgeQuestCompletion updates completion notification flag independently', () => { + const quest = createQuest(); + const result = acknowledgeQuestCompletion([quest], quest.id); + + assert.equal(result.ok, true); + if (!result.ok) { + return; + } + + assert.equal(result.updatedQuests[0]?.completionNotified, true); +}); diff --git a/server-node/src/modules/quest/questProgressionService.ts b/server-node/src/modules/quest/questProgressionService.ts new file mode 100644 index 00000000..ae190f43 --- /dev/null +++ b/server-node/src/modules/quest/questProgressionService.ts @@ -0,0 +1,213 @@ +import { + applyQuestProgressSignal, + normalizeQuestLogEntries, +} from '../../bridges/legacyQuestProgressBridge.js'; + +export type QuestLogEntry = Parameters[0][number]; +export type QuestProgressSignal = Parameters[1]; + +type QuestMutationFailureCode = 'quest_not_found' | 'quest_not_ready_to_turn_in'; + +export type QuestMutationFailure = { + ok: false; + code: QuestMutationFailureCode; + message: string; +}; + +export type QuestMutationSuccess = { + ok: true; + nextQuests: QuestLogEntry[]; + updatedQuestIds: string[]; + updatedQuests: QuestLogEntry[]; +}; + +export type QuestMutationResult = QuestMutationFailure | QuestMutationSuccess; + +function createFailure( + code: QuestMutationFailureCode, + message: string, +): QuestMutationFailure { + return { + ok: false, + code, + message, + }; +} + +function collectUpdatedQuestIds( + previous: QuestLogEntry[], + next: QuestLogEntry[], +): string[] { + const previousById = new Map(previous.map((quest) => [quest.id, quest])); + + return next + .filter((quest) => { + const previousQuest = previousById.get(quest.id); + return JSON.stringify(previousQuest) !== JSON.stringify(quest); + }) + .map((quest) => quest.id); +} + +function buildSuccess( + previous: QuestLogEntry[], + next: QuestLogEntry[], +): QuestMutationSuccess { + const updatedQuestIds = collectUpdatedQuestIds(previous, next); + return { + ok: true, + nextQuests: next, + updatedQuestIds, + updatedQuests: next.filter((quest) => updatedQuestIds.includes(quest.id)), + }; +} + +export function normalizeQuestEntries(quests: QuestLogEntry[]): QuestLogEntry[] { + return normalizeQuestLogEntries(quests); +} + +function getQuestActiveStep(quest: QuestLogEntry) { + if (!quest.steps?.length) { + return null; + } + + if (quest.activeStepId) { + return quest.steps.find((step) => step.id === quest.activeStepId) ?? null; + } + + return quest.steps.find((step) => step.progress < step.requiredCount) ?? null; +} + +export function applyQuestSignal( + quests: QuestLogEntry[], + signal: QuestProgressSignal, +): QuestMutationSuccess { + const normalizedQuests = normalizeQuestEntries(quests); + const nextQuests = applyQuestProgressSignal(normalizedQuests, signal); + return buildSuccess(normalizedQuests, nextQuests); +} + +export function acknowledgeQuestCompletion( + quests: QuestLogEntry[], + questId: string, +): QuestMutationResult { + const normalizedQuests = normalizeQuestEntries(quests); + const quest = findQuestById(normalizedQuests, questId); + if (!quest) { + return createFailure('quest_not_found', '未找到目标委托。'); + } + + const nextQuests = markQuestCompletionNotified(normalizedQuests, questId); + return buildSuccess(normalizedQuests, nextQuests); +} + +export function findQuestById(quests: QuestLogEntry[], questId: string) { + return quests.find((quest) => quest.id === questId) ?? null; +} + +export function getQuestForIssuer( + quests: QuestLogEntry[], + issuerNpcId: string, +) { + return ( + normalizeQuestEntries(quests).find( + (quest) => + quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', + ) ?? null + ); +} + +export function acceptQuest( + quests: QuestLogEntry[], + quest: QuestLogEntry, +) { + const normalizedQuests = normalizeQuestEntries(quests); + if (findQuestById(normalizedQuests, quest.id)) { + return normalizedQuests; + } + + return [...normalizedQuests, normalizeQuestEntries([quest])[0]!]; +} + +export function buildQuestAcceptResultText(quest: QuestLogEntry) { + const normalizedQuest = normalizeQuestEntries([quest])[0]!; + const activeStep = getQuestActiveStep(normalizedQuest); + return `${normalizedQuest.issuerNpcName} 正式把委托交到了你手上。${ + activeStep?.revealText ?? normalizedQuest.summary + }`; +} + +export function buildQuestTurnInResultText(quest: QuestLogEntry) { + const normalizedQuest = normalizeQuestEntries([quest])[0]!; + const itemText = normalizedQuest.reward.items.map((item) => item.name).join('、'); + const intelText = normalizedQuest.reward.intel?.rumorText + ? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}` + : ''; + const storyHintText = normalizedQuest.reward.storyHint + ? ` ${normalizedQuest.reward.storyHint}` + : ''; + + return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和 ${itemText}${intelText}。${storyHintText}`; +} + +export function isQuestReadyToClaim(quest: QuestLogEntry) { + const status = normalizeQuestEntries([quest])[0]!.status; + return status === 'ready_to_turn_in' || status === 'completed'; +} + +export function markQuestTurnedIn( + quests: QuestLogEntry[], + questId: string, +) { + return quests.map((quest) => + quest.id === questId + ? normalizeQuestEntries([ + { + ...quest, + status: 'turned_in', + completionNotified: true, + steps: quest.steps?.map((step) => ({ + ...step, + progress: step.requiredCount, + })), + }, + ])[0]! + : normalizeQuestEntries([quest])[0]!, + ); +} + +export function markQuestCompletionNotified( + quests: QuestLogEntry[], + questId: string, +) { + return quests.map((quest) => + quest.id === questId + ? normalizeQuestEntries([ + { + ...quest, + completionNotified: true, + }, + ])[0]! + : normalizeQuestEntries([quest])[0]!, + ); +} + +export function turnInQuest( + quests: QuestLogEntry[], + questId: string, +): QuestMutationResult { + const normalizedQuests = normalizeQuestEntries(quests); + const quest = findQuestById(normalizedQuests, questId); + if (!quest) { + return createFailure('quest_not_found', '未找到目标委托。'); + } + + if (!isQuestReadyToClaim(quest)) { + return createFailure( + 'quest_not_ready_to_turn_in', + `${quest.title} 当前还不能交付结算。`, + ); + } + + const nextQuests = markQuestTurnedIn(normalizedQuests, questId); + return buildSuccess(normalizedQuests, nextQuests); +} diff --git a/server-node/src/modules/quest/questRuntimeSignalService.ts b/server-node/src/modules/quest/questRuntimeSignalService.ts new file mode 100644 index 00000000..7fb86601 --- /dev/null +++ b/server-node/src/modules/quest/questRuntimeSignalService.ts @@ -0,0 +1,84 @@ +import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/story.js'; +import { + applyQuestSignal, + normalizeQuestEntries, +} from './questProgressionService.js'; +import { + replaceRuntimeSessionRawGameState, + type RuntimeSession, +} from '../story/runtimeSession.js'; + +type JsonRecord = Record; +type RuntimeGameState = { + currentScenePreset?: { + id?: string | null; + } | null; + quests?: unknown[]; +}; + +function readSceneId(state: RuntimeGameState) { + return state.currentScenePreset?.id ?? null; +} + +export function applyQuestSignalsForResolvedAction(params: { + session: RuntimeSession; + functionId: string; + previousEncounter: RuntimeSession['currentEncounter']; + battle?: RuntimeBattlePresentation | null; +}) { + const state = params.session.rawGameState as unknown as RuntimeGameState; + const quests = normalizeQuestEntries(Array.isArray(state.quests) ? state.quests : []); + if (quests.length <= 0) { + return; + } + + let mutation = null; + + if ( + params.functionId === 'npc_chat' && + params.previousEncounter?.kind === 'npc' + ) { + mutation = applyQuestSignal(quests, { + kind: 'npc_talk_completed', + npcId: params.previousEncounter.id, + }); + } else if ( + params.battle?.outcome === 'victory' && + typeof params.battle.targetId === 'string' && + params.battle.targetId.trim() + ) { + mutation = applyQuestSignal(quests, { + kind: 'hostile_npc_defeated', + sceneId: readSceneId(state), + hostileNpcId: params.battle.targetId, + }); + } else if ( + params.battle?.outcome === 'spar_complete' && + params.previousEncounter?.kind === 'npc' + ) { + mutation = applyQuestSignal(quests, { + kind: 'npc_spar_completed', + npcId: params.previousEncounter.id, + }); + } else if ( + params.functionId === 'treasure_inspect' || + params.functionId === 'treasure_secure' + ) { + mutation = applyQuestSignal(quests, { + kind: 'treasure_inspected', + sceneId: readSceneId(state), + }); + } + + if (!mutation || mutation.updatedQuestIds.length <= 0) { + return; + } + + replaceRuntimeSessionRawGameState( + params.session, + { + ...state, + quests: mutation.nextQuests, + } as unknown as JsonRecord, + ); +} diff --git a/server-node/src/modules/quest/questStoryActionService.ts b/server-node/src/modules/quest/questStoryActionService.ts new file mode 100644 index 00000000..23e1f447 --- /dev/null +++ b/server-node/src/modules/quest/questStoryActionService.ts @@ -0,0 +1,242 @@ +import type { + RuntimeStoryActionRequest, + RuntimeStoryPatch, +} from '../../../../packages/shared/src/contracts/story.js'; +import { conflict, invalidRequest } from '../../errors.js'; +import { + appendStoryEngineCarrierMemory, + markNpcFirstMeaningfulContactResolved, +} from '../../bridges/legacyNpcTask6Bridge.js'; +import { + acceptQuest, + addInventoryItems, + buildQuestAcceptResultText, + buildQuestForEncounter, + buildQuestTurnInResultText, + buildRelationState, + getQuestForIssuer, + incrementGameRuntimeStats, + isQuestReadyToClaim, + turnInQuest, +} from './questTask6Bridge.js'; +import { + replaceRuntimeSessionRawGameState, + type RuntimeSession, +} from '../story/runtimeSession.js'; + +const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set([ + 'npc_quest_accept', + 'npc_quest_turn_in', +]); + +type QuestStoryResolution = { + actionText: string; + resultText: string; + patches: RuntimeStoryPatch[]; +}; + +type JsonRecord = Record; +type RuntimeGameState = Parameters[0]; +type RuntimeNpcState = Parameters< + typeof markNpcFirstMeaningfulContactResolved +>[0]; +type RuntimeEncounter = { + id?: string; + kind?: 'npc' | 'treasure'; + npcAvatar?: string; + npcName: string; + npcDescription: string; + context: string; + hostile?: boolean; + characterId?: string | null; + monsterPresetId?: string | null; +}; + +function getNpcEncounter( + session: RuntimeSession, + state: RuntimeGameState, +): RuntimeEncounter | null { + const rawEncounter = state.currentEncounter; + if (!rawEncounter || rawEncounter.kind !== 'npc') { + return null; + } + + return { + npcAvatar: '', + hostile: false, + ...rawEncounter, + id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName, + } satisfies RuntimeEncounter; +} + +function getNpcEncounterKey(encounter: RuntimeEncounter) { + return encounter.id?.trim() || encounter.npcName; +} + +function readPayload(request: RuntimeStoryActionRequest) { + return typeof request.action.payload === 'object' && request.action.payload + ? (request.action.payload as JsonRecord) + : {}; +} + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function readQuestId(request: RuntimeStoryActionRequest) { + const payload = readPayload(request); + return readString(payload.questId) || readString(request.action.targetId); +} + +function ensureEncounterQuestContext(session: RuntimeSession) { + const state = session.rawGameState as unknown as RuntimeGameState; + const encounter = getNpcEncounter(session, state); + if (!encounter) { + throw conflict('当前不在可结算的 NPC 委托态。'); + } + + const npcKey = getNpcEncounterKey(encounter); + const npcState = state.npcStates?.[npcKey]; + if (!npcState) { + throw conflict('当前 NPC 状态不存在,无法处理委托。'); + } + + return { + state, + encounter, + npcKey, + npcState, + }; +} + +function resolveQuestAcceptAction( + session: RuntimeSession, +): QuestStoryResolution { + const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session); + const quests = Array.isArray(state.quests) ? state.quests : []; + const existingQuest = getQuestForIssuer(quests, npcKey); + if (existingQuest) { + throw conflict('当前角色已经有未结清的委托。'); + } + + const quest = buildQuestForEncounter({ + issuerNpcId: npcKey, + issuerNpcName: encounter.npcName, + roleText: encounter.context, + scene: state.currentScenePreset, + worldType: state.worldType, + currentQuests: quests.map((item) => ({ + id: item.id, + issuerNpcId: item.issuerNpcId, + status: item.status, + })), + }); + if (!quest) { + throw conflict('当前场景缺少可落地的委托抓手,暂时无法接取任务。'); + } + + const nextState = { + ...state, + quests: acceptQuest(quests, quest), + runtimeStats: incrementGameRuntimeStats(state.runtimeStats, { + questsAccepted: 1, + }), + npcStates: { + ...state.npcStates, + [npcKey]: { + ...markNpcFirstMeaningfulContactResolved(npcState), + }, + }, + } satisfies RuntimeGameState; + + replaceRuntimeSessionRawGameState( + session, + nextState as unknown as JsonRecord, + ); + + return { + actionText: `接下${encounter.npcName}的委托`, + resultText: buildQuestAcceptResultText(quest), + patches: [], + }; +} + +function resolveQuestTurnInAction( + session: RuntimeSession, + request: RuntimeStoryActionRequest, +): QuestStoryResolution { + const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session); + const quests = Array.isArray(state.quests) ? state.quests : []; + const questId = readQuestId(request); + const quest = + (questId ? quests.find((item) => item.id === questId) : null) ?? + getQuestForIssuer(quests, npcKey); + + if (!quest) { + throw conflict('当前没有可交付的委托。'); + } + + if (!isQuestReadyToClaim(quest)) { + throw conflict('这份委托还没有达到可交付状态。'); + } + + const turnInResult = turnInQuest(quests, quest.id); + if (!turnInResult.ok) { + throw conflict(turnInResult.message); + } + + const nextAffinity = npcState.affinity + quest.reward.affinityBonus; + let nextState = { + ...state, + quests: turnInResult.nextQuests, + playerCurrency: state.playerCurrency + quest.reward.currency, + playerInventory: addInventoryItems(state.playerInventory, quest.reward.items), + npcStates: { + ...state.npcStates, + [npcKey]: { + ...markNpcFirstMeaningfulContactResolved(npcState), + affinity: nextAffinity, + relationState: buildRelationState(nextAffinity), + }, + }, + } satisfies RuntimeGameState; + nextState = appendStoryEngineCarrierMemory(nextState, quest.reward.items); + + replaceRuntimeSessionRawGameState( + session, + nextState as unknown as JsonRecord, + ); + + return { + actionText: `向${encounter.npcName}交付委托`, + resultText: buildQuestTurnInResultText(quest), + patches: [ + { + type: 'npc_affinity_changed', + npcId: npcKey, + previousAffinity: npcState.affinity, + nextAffinity, + }, + ], + }; +} + +export function isSupportedQuestStoryFunctionId(functionId: string) { + return SUPPORTED_QUEST_STORY_FUNCTION_IDS.has(functionId); +} + +export function resolveQuestStoryAction( + session: RuntimeSession, + request: RuntimeStoryActionRequest, +): QuestStoryResolution { + switch (request.action.functionId) { + case 'npc_quest_accept': + return resolveQuestAcceptAction(session); + case 'npc_quest_turn_in': + return resolveQuestTurnInAction(session, request); + default: + throw invalidRequest( + `暂不支持的 Quest 动作:${request.action.functionId}`, + ); + } +} diff --git a/server-node/src/modules/quest/questTask6Bridge.ts b/server-node/src/modules/quest/questTask6Bridge.ts new file mode 100644 index 00000000..94f2196e --- /dev/null +++ b/server-node/src/modules/quest/questTask6Bridge.ts @@ -0,0 +1,17 @@ +// Temporary bridge for legacy pure quest task6 action logic from src/**. +export { + addInventoryItems, + buildRelationState, + incrementGameRuntimeStats, +} from '../runtime/runtimeStatePrimitives.js'; +export { + buildQuestForEncounter, +} from '../../bridges/legacyQuestProgressBridge.js'; +export { + acceptQuest, + buildQuestAcceptResultText, + buildQuestTurnInResultText, + getQuestForIssuer, + isQuestReadyToClaim, + turnInQuest, +} from './questProgressionService.js'; diff --git a/server-node/src/modules/quest/runtimeQuestModule.ts b/server-node/src/modules/quest/runtimeQuestModule.ts new file mode 100644 index 00000000..e0c447a1 --- /dev/null +++ b/server-node/src/modules/quest/runtimeQuestModule.ts @@ -0,0 +1,1246 @@ +import { + QUEST_INTIMACY_LEVELS, + QUEST_NARRATIVE_TYPES, + QUEST_OBJECTIVE_KINDS, + QUEST_REWARD_THEMES, + QUEST_URGENCY_LEVELS, +} from '../../../../packages/shared/src/contracts/story.js'; +import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js'; + +export type QuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; +export type QuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number]; +export type QuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number]; +export type QuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number]; +export type QuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number]; +export type QuestStatus = + | 'active' + | 'ready_to_turn_in' + | 'completed' + | 'turned_in' + | 'failed' + | 'expired'; + +export type QuestRewardItem = { + id: string; + category: string; + name: string; + quantity: number; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + tags: string[]; +}; + +export type QuestReward = { + affinityBonus: number; + currency: number; + items: QuestRewardItem[]; + intel?: { + rumorText: string; + unlockedSceneId?: string | null; + }; + storyHint?: string; +}; + +export type QuestStep = { + id: string; + kind: QuestObjectiveKind; + targetHostileNpcId?: string; + targetNpcId?: string; + targetSceneId?: string; + targetItemId?: string; + requiredCount: number; + progress: number; + title: string; + revealText: string; + completeText: string; +}; + +export type QuestObjective = { + kind: QuestObjectiveKind; + targetHostileNpcId?: string; + targetNpcId?: string; + targetSceneId?: string; + targetItemId?: string; + requiredCount?: number; +}; + +export type QuestNarrativeBinding = { + origin: 'ai_compiled' | 'fallback_builder'; + narrativeType: QuestNarrativeType; + dramaticNeed: string; + issuerGoal: string; + playerHook: string; + worldReason: string; + followupHooks: string[]; +}; + +export type QuestLogEntry = { + id: string; + issuerNpcId: string; + issuerNpcName: string; + sceneId: string | null; + chapterId?: string | null; + actId?: string | null; + threadId?: string | null; + contractId?: string | null; + title: string; + description: string; + summary: string; + objective: QuestObjective; + progress: number; + status: QuestStatus; + completionNotified: boolean; + reward: QuestReward; + rewardText: string; + narrativeBinding: QuestNarrativeBinding; + steps?: QuestStep[]; + activeStepId?: string | null; + visibleStage?: number; + hiddenFlags?: string[]; + discoveredFactIds?: string[]; + relatedCarrierIds?: string[]; + consequenceIds?: string[]; +}; + +export type QuestSceneNpcSnapshot = { + id: string; + name: string; + description?: string; + avatar?: string; + role?: string; + monsterPresetId?: string | null; + initialAffinity?: number; + hostile?: boolean; +}; + +export type QuestSceneSnapshot = { + id: string; + name: string; + description?: string; + npcs: QuestSceneNpcSnapshot[]; + treasureHints: string[]; +}; + +export type QuestIntent = { + title: string; + description: string; + summary: string; + narrativeType: QuestNarrativeType; + dramaticNeed: string; + issuerGoal: string; + playerHook: string; + worldReason: string; + recommendedObjectiveKinds: QuestObjectiveKind[]; + urgency: QuestUrgency; + intimacy: QuestIntimacy; + rewardTheme: QuestRewardTheme; + followupHooks: string[]; +}; + +export type QuestOpportunity = { + shouldOffer: boolean; + reason: string; + suggestedIssuerNpcId?: string; + suggestedThreatType?: 'hostile_npc' | 'treasure' | 'relationship' | 'travel'; +}; + +export type QuestProgressSignal = + | { kind: 'hostile_npc_defeated'; sceneId?: string | null; hostileNpcId: string } + | { kind: 'treasure_inspected'; sceneId?: string | null } + | { kind: 'npc_spar_completed'; npcId: string } + | { kind: 'npc_talk_completed'; npcId: string } + | { kind: 'scene_reached'; sceneId: string } + | { kind: 'item_delivered'; npcId: string; itemId: string; quantity: number }; + +export type QuestGenerationContext = { + worldType: string | null | undefined; + customWorldProfile?: { + name?: string; + summary?: string; + } | null; + actState?: { id?: string | null } | null; + currentSceneId?: string | null; + currentSceneName?: string | null; + currentSceneDescription?: string | null; + issuerNpcId: string; + issuerNpcName: string; + issuerNpcContext: string; + issuerAffinity: number; + issuerNarrativeProfile?: { + publicMask?: string; + visibleLine?: string; + immediatePressure?: string; + reactionHooks?: string[]; + } | null; + issuerDisclosureStage?: string | null; + issuerWarmthStage?: string | null; + activeThreadIds: string[]; + encounterKind?: string | null; + currentSceneTreasureHintCount: number; + currentSceneHostileNpcIds: string[]; + recentStoryMoments: Array<{ text: string }>; + playerCharacter?: { + id: string; + name?: string; + title?: string; + } | null; + playerHp?: number; + playerMaxHp?: number; + playerMana?: number; + playerMaxMana?: number; + playerInventory?: Array<{ name: string }>; + playerEquipment?: unknown; + activeCompanions?: Array<{ characterId: string }>; + rosterCompanions?: Array<{ characterId: string }>; + currentQuestSummary?: Array<{ + id: string; + title: string; + status: QuestStatus; + issuerNpcId: string; + }>; +}; + +export type QuestCompilationRequest = { + issuerNpcId: string; + issuerNpcName: string; + roleText: string; + scene: QuestSceneSnapshot | null; + worldType: string | null | undefined; + context?: QuestGenerationContext; + origin?: QuestNarrativeBinding['origin']; +}; + +export type QuestPreviewRequest = QuestCompilationRequest & { + currentQuests?: Array<{ + id: string; + issuerNpcId: string; + status: QuestStatus; + }>; +}; + +type RuntimeEncounterLike = { + id?: string; + kind?: string; + npcName: string; + context: string; + narrativeProfile?: QuestGenerationContext['issuerNarrativeProfile']; +}; + +type RuntimeNpcStateLike = { + affinity?: number; + recruited?: boolean; +}; + +type RuntimeSceneLike = { + id: string; + name: string; + description?: string; + npcs?: QuestSceneNpcSnapshot[]; + treasureHints?: string[]; +}; + +type RuntimeStateLike = { + worldType: string | null | undefined; + customWorldProfile?: QuestGenerationContext['customWorldProfile']; + storyEngineMemory?: { + actState?: { id?: string | null } | null; + activeThreadIds?: string[]; + } | null; + currentScenePreset?: RuntimeSceneLike | null; + storyHistory: Array<{ text: string }>; + playerCharacter?: QuestGenerationContext['playerCharacter']; + playerHp?: number; + playerMaxHp?: number; + playerMana?: number; + playerMaxMana?: number; + playerInventory?: Array<{ name: string }>; + playerEquipment?: unknown; + companions?: Array<{ characterId: string }>; + roster?: Array<{ characterId: string }>; + quests: QuestLogEntry[]; + npcStates: Record; +}; + +const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed']; +const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired']; + +function clampProgress(progress: number | undefined, requiredCount: number) { + return Math.max(0, Math.min(requiredCount, Math.round(progress ?? 0))); +} + +function compactQuestLabel(label: string, maxLength = 6) { + const trimmed = label.trim(); + return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed; +} + +function buildQuestId( + issuerNpcId: string, + kind: QuestObjectiveKind, + targetKey: string, +) { + return `quest:${issuerNpcId}:${kind}:${targetKey}`; +} + +function isRewardReadyStatus(status: QuestStatus) { + return REWARD_READY_STATUSES.includes(status); +} + +function isTerminalStatus(status: QuestStatus) { + return TERMINAL_QUEST_STATUSES.includes(status); +} + +function getNpcDisclosureStage( + affinity: number, + options: { recruited?: boolean } = {}, +) { + if (options.recruited || affinity >= 50) return 'deep'; + if (affinity >= 30) return 'honest'; + if (affinity >= 15) return 'partial'; + return 'guarded'; +} + +function getNpcWarmthStage( + affinity: number, + options: { recruited?: boolean } = {}, +) { + if (options.recruited || affinity >= 50) return 'warm'; + if (affinity >= 30) return 'cooperative'; + if (affinity >= 15) return 'neutral'; + return 'distant'; +} + +type SceneQuestThreat = + | { + kind: 'defeat_hostile_npc'; + targetHostileNpcId: string; + targetHostileNpcName: string; + targetSceneId: string; + suggestedThreatType: 'hostile_npc'; + } + | { + kind: 'inspect_treasure'; + targetSceneId: string; + targetSceneName: string; + suggestedThreatType: 'treasure'; + } + | { + kind: 'spar_with_npc'; + suggestedThreatType: 'relationship'; + }; + +function getScenePrimaryThreat( + scene: QuestSceneSnapshot | null, +): SceneQuestThreat | null { + if (!scene) { + return null; + } + + const hostileNpc = + scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ?? null; + if (hostileNpc) { + const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id; + return { + kind: 'defeat_hostile_npc', + targetHostileNpcId, + targetHostileNpcName: hostileNpc.name || targetHostileNpcId, + targetSceneId: scene.id, + suggestedThreatType: 'hostile_npc', + }; + } + + if ((scene.treasureHints?.length ?? 0) > 0) { + return { + kind: 'inspect_treasure', + targetSceneId: scene.id, + targetSceneName: scene.name, + suggestedThreatType: 'treasure', + }; + } + + return { + kind: 'spar_with_npc', + suggestedThreatType: 'relationship', + }; +} + +function buildRewardItems(params: { + issuerNpcId: string; + worldType: string | null | undefined; + rewardTheme: QuestRewardTheme; +}) { + const prefix = `quest-reward:${params.issuerNpcId}`; + + switch (params.rewardTheme) { + case 'intel': + return [ + { + id: `${prefix}:intel-record`, + category: '线索', + name: '旧案残页', + quantity: 1, + rarity: 'rare', + tags: ['relic', 'intel'], + }, + ] satisfies QuestRewardItem[]; + case 'relationship': + return [ + { + id: `${prefix}:bond-token`, + category: '信物', + name: '同行信符', + quantity: 1, + rarity: 'rare', + tags: ['relic', 'bond'], + }, + ] satisfies QuestRewardItem[]; + case 'rare_item': + return [ + { + id: `${prefix}:rare-gear`, + category: '装备', + name: params.worldType === 'XIANXIA' ? '灵纹佩' : '断桥佩刃', + quantity: 1, + rarity: 'epic', + tags: ['equipment', 'relic'], + }, + ] satisfies QuestRewardItem[]; + case 'resource': + return [ + { + id: `${prefix}:supply-pack`, + category: '补给', + name: params.worldType === 'XIANXIA' ? '回灵散' : '疗伤丹', + quantity: 2, + rarity: 'uncommon', + tags: ['healing', 'mana'], + }, + ] satisfies QuestRewardItem[]; + default: + return [ + { + id: `${prefix}:field-kit`, + category: '补给', + name: params.worldType === 'XIANXIA' ? '护心符' : '常备药包', + quantity: 1, + rarity: 'rare', + tags: ['healing', 'material'], + }, + ] satisfies QuestRewardItem[]; + } +} + +function buildQuestReward(params: { + issuerNpcId: string; + issuerNpcName: string; + worldType: string | null | undefined; + rewardTheme: QuestRewardTheme; + narrativeType: QuestNarrativeType; + scene: QuestSceneSnapshot | null; +}) { + const baseCurrency = + params.rewardTheme === 'intel' + ? params.worldType === 'XIANXIA' + ? 40 + : 58 + : params.worldType === 'XIANXIA' + ? 54 + : 72; + + const reward: QuestReward = { + affinityBonus: + params.narrativeType === 'relationship' || params.narrativeType === 'trial' + ? 14 + : 12, + currency: baseCurrency, + items: buildRewardItems(params), + storyHint: `${params.issuerNpcName}把和眼前局势最相关的收获留给了你。`, + }; + + if (params.rewardTheme === 'intel') { + reward.intel = { + rumorText: params.scene + ? `${params.scene.name} 一带还压着更深一层没说透的旧线索。` + : '对方愿意把一条尚未外传的消息托付给你。', + unlockedSceneId: params.scene?.id ?? null, + }; + } + + return reward; +} + +function buildRewardText( + reward: QuestReward, + worldType: string | null | undefined, +) { + const itemText = + reward.items.map((item) => item.name).join('、') || '当前局势相关的补给'; + const intelText = reward.intel?.rumorText + ? `,以及情报“${reward.intel.rumorText}”` + : ''; + return `完成后可获得好感 +${reward.affinityBonus}、${formatCurrency( + reward.currency, + worldType, + )}、${itemText}${intelText}。`; +} + +function buildTalkBackStep( + issuerNpcId: string, + issuerNpcName: string, +): QuestStep { + return { + id: 'step_report_back', + kind: 'talk_to_npc', + targetNpcId: issuerNpcId, + requiredCount: 1, + progress: 0, + title: `回去找${issuerNpcName}`, + revealText: `回去和 ${issuerNpcName} 把这次委托的结果说明白。`, + completeText: `你已经和 ${issuerNpcName} 交代清楚,现在可以正式领取报酬。`, + }; +} + +function buildPrimaryQuestStep(params: { + issuerNpcId: string; + issuerNpcName: string; + scene: QuestSceneSnapshot | null; + intent: QuestIntent; +}): QuestStep | null { + const { issuerNpcId, issuerNpcName, scene, intent } = params; + const threat = getScenePrimaryThreat(scene); + if (!threat) { + return null; + } + + const preferredKinds = + intent.recommendedObjectiveKinds.length > 0 + ? intent.recommendedObjectiveKinds + : [threat.kind]; + const chosenKind = preferredKinds.includes(threat.kind) + ? threat.kind + : preferredKinds[0] ?? threat.kind; + + if (chosenKind === 'inspect_treasure' && scene) { + return { + id: 'step_primary', + kind: 'inspect_treasure', + targetSceneId: scene.id, + requiredCount: 1, + progress: 0, + title: `调查${compactQuestLabel(scene.name, 8)}`, + revealText: `${issuerNpcName} 想确认 ${scene.name} 一带留下的异常究竟是真是假。`, + completeText: `${scene.name} 的情况已经查明,可以回去和 ${issuerNpcName} 对情报了。`, + }; + } + + if (chosenKind === 'spar_with_npc') { + return { + id: 'step_primary', + kind: 'spar_with_npc', + targetNpcId: issuerNpcId, + requiredCount: 1, + progress: 0, + title: `与${issuerNpcName}切磋`, + revealText: `${issuerNpcName} 想先亲自试一试你的成色,再决定后续是否继续合作。`, + completeText: `这场切磋已经结束,${issuerNpcName} 对你的判断也有了变化。`, + }; + } + + if (threat.kind === 'defeat_hostile_npc') { + return { + id: 'step_primary', + kind: 'defeat_hostile_npc', + targetHostileNpcId: threat.targetHostileNpcId, + targetSceneId: threat.targetSceneId, + requiredCount: 1, + progress: 0, + title: `压制${compactQuestLabel(threat.targetHostileNpcName, 8)}`, + revealText: `${issuerNpcName} 希望你先压制 ${threat.targetHostileNpcName},再回来说明局势。`, + completeText: `${threat.targetHostileNpcName} 已被压制,回去向 ${issuerNpcName} 汇报吧。`, + }; + } + + return null; +} + +function deriveObjectiveFromStep(step: QuestStep | null): QuestObjective { + if (!step) { + return { + kind: 'talk_to_npc', + }; + } + + return { + kind: step.kind, + targetHostileNpcId: step.targetHostileNpcId, + targetNpcId: step.targetNpcId, + targetSceneId: step.targetSceneId, + targetItemId: step.targetItemId, + requiredCount: step.requiredCount, + }; +} + +function getQuestActiveStep(quest: QuestLogEntry) { + if (!quest.steps?.length) { + return null; + } + + if (quest.activeStepId) { + return quest.steps.find((step) => step.id === quest.activeStepId) ?? null; + } + + return quest.steps.find((step) => step.progress < step.requiredCount) ?? null; +} + +function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) { + const title = rawTitle + .replace(/[《》「」“”"']/gu, '') + .replace(/[,。!?;:,.!?;:].*$/u, '') + .trim(); + + if (title && title.length <= 12) { + return title; + } + + return fallbackTitle.length <= 12 ? fallbackTitle : fallbackTitle.slice(0, 10); +} + +function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { + const steps = (quest.steps ?? []).map((step) => ({ + ...step, + requiredCount: Math.max(1, Math.round(step.requiredCount ?? 1)), + progress: clampProgress( + step.progress, + Math.max(1, Math.round(step.requiredCount ?? 1)), + ), + })); + const activeStep = steps.find((step) => step.progress < step.requiredCount) ?? null; + const terminal = isTerminalStatus(quest.status); + const rewardReady = !terminal && !activeStep ? 'completed' : quest.status; + + return { + ...quest, + title: normalizeQuestTitle(quest.title, quest.title), + summary: quest.summary.trim() || quest.description.trim(), + progress: activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0, + objective: deriveObjectiveFromStep(activeStep ?? steps[steps.length - 1] ?? null), + status: terminal ? quest.status : rewardReady, + completionNotified: quest.completionNotified ?? false, + rewardText: quest.rewardText.trim(), + steps, + activeStepId: activeStep?.id ?? null, + visibleStage: quest.visibleStage ?? 0, + hiddenFlags: quest.hiddenFlags ?? [], + discoveredFactIds: quest.discoveredFactIds ?? [], + relatedCarrierIds: quest.relatedCarrierIds ?? [], + consequenceIds: quest.consequenceIds ?? [], + }; +} + +function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) { + switch (signal.kind) { + case 'hostile_npc_defeated': + return ( + step.kind === 'defeat_hostile_npc' && + (!step.targetSceneId || step.targetSceneId === signal.sceneId) && + step.targetHostileNpcId === signal.hostileNpcId + ); + case 'treasure_inspected': + return ( + step.kind === 'inspect_treasure' && + (!step.targetSceneId || step.targetSceneId === signal.sceneId) + ); + case 'npc_spar_completed': + return step.kind === 'spar_with_npc' && step.targetNpcId === signal.npcId; + case 'npc_talk_completed': + return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId; + case 'scene_reached': + return step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId; + case 'item_delivered': + return ( + step.kind === 'deliver_item' && + step.targetNpcId === signal.npcId && + step.targetItemId === signal.itemId + ); + default: + return false; + } +} + +function getSignalProgressIncrement(signal: QuestProgressSignal) { + return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1; +} + +function summarizeRecentStoryMoments(context: QuestGenerationContext) { + const moments = context.recentStoryMoments + .slice(-4) + .map((moment) => `- ${moment.text}`) + .join('\n'); + + return moments || '- 暂无近期剧情记录'; +} + +function summarizeCurrentQuests(context: QuestGenerationContext) { + const summary = context.currentQuestSummary + ?.map( + (quest) => + `- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`, + ) + .join('\n'); + + return summary || '- 当前没有进行中的任务'; +} + +function summarizeCompanions(context: QuestGenerationContext) { + const active = + context.activeCompanions?.map((companion) => companion.characterId).join('、') || + '无'; + const roster = + context.rosterCompanions?.map((companion) => companion.characterId).join('、') || + '无'; + return `当前同行角色:${active}\n队伍名册:${roster}`; +} + +function summarizePlayerState(context: QuestGenerationContext) { + const playerName = context.playerCharacter?.name ?? '未知角色'; + const playerTitle = context.playerCharacter?.title ?? '未知称号'; + const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`; + const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`; + const inventory = + context.playerInventory?.slice(0, 8).map((item) => item.name).join('、') || '无'; + + return [ + `玩家:${playerName}(${playerTitle})`, + `生命:${hp}`, + `灵力:${mana}`, + `背包快照:${inventory}`, + ].join('\n'); +} + +function summarizeScene( + scene: QuestSceneSnapshot | null, + context: QuestGenerationContext, +) { + const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无'; + const treasureHintCount = context.currentSceneTreasureHintCount ?? 0; + + return [ + `场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`, + `场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`, + `敌对角色 ID:${hostileNpcIds}`, + `宝藏线索数量:${treasureHintCount}`, + ].join('\n'); +} + +function summarizeActiveThreads(context: QuestGenerationContext) { + return context.activeThreadIds?.length + ? context.activeThreadIds.join('、') + : '暂无明确激活线程'; +} + +function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) { + const profile = context.issuerNarrativeProfile; + if (!profile) { + return '暂无额外叙事档案'; + } + + return [ + `公开面:${profile.publicMask ?? '暂无'}`, + `表层线:${profile.visibleLine ?? '暂无'}`, + `当前压力:${profile.immediatePressure ?? '暂无'}`, + profile.reactionHooks?.length + ? `反应钩子:${profile.reactionHooks.join('、')}` + : null, + ] + .filter(Boolean) + .join('\n'); +} + +function describeWorld(worldType: QuestGenerationContext['worldType']) { + switch (worldType) { + case 'WUXIA': + return '武侠'; + case 'XIANXIA': + return '仙侠'; + case 'CUSTOM': + return '自定义世界'; + default: + return '未知世界'; + } +} + +export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。 +只返回 JSON,不要输出 Markdown。 + +输出结构: +{ + "intent": { + "title": "中文任务标题", + "description": "中文任务描述", + "summary": "中文短摘要", + "narrativeType": "bounty|escort|investigation|retrieval|relationship|trial", + "dramaticNeed": "string", + "issuerGoal": "string", + "playerHook": "string", + "worldReason": "string", + "recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"], + "urgency": "low|medium|high", + "intimacy": "transactional|cooperative|trust_based", + "rewardTheme": "currency|resource|relationship|intel|rare_item", + "followupHooks": ["string"] + } +} + +规则: +- 所有自然语言字段都必须使用中文。 +- 任务必须扎根于当前场景、发布者和近期剧情。 +- 不要编造奖励、ID、数量、状态或不受支持的规则变化。 +- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。 +- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。 +- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。 +- description 解释任务为什么在当前剧情里成立,避免纯规则说明。 +- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`; + +export function buildQuestIntentPrompt(params: { + context: QuestGenerationContext; + scene: QuestSceneSnapshot | null; + opportunity: QuestOpportunity; +}) { + const { context, scene, opportunity } = params; + const customWorldSummary = context.customWorldProfile + ? `${context.customWorldProfile.name ?? '自定义世界'}: ${ + context.customWorldProfile.summary ?? '暂无摘要' + }` + : '无'; + + return [ + `世界:${describeWorld(context.worldType)}`, + `自定义世界摘要:${customWorldSummary}`, + `发布角色:${context.issuerNpcName}(${context.issuerNpcId})`, + `发布者身份:${context.issuerNpcContext || '暂无'}`, + `发布者好感:${context.issuerAffinity ?? 0}`, + `发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`, + `发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`, + `当前激活线程:${summarizeActiveThreads(context)}`, + `发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`, + `当前遭遇类型:${context.encounterKind ?? '无'}`, + summarizeScene(scene, context), + summarizePlayerState(context), + summarizeCompanions(context), + `当前任务机会:${opportunity.reason}`, + `当前任务列表:\n${summarizeCurrentQuests(context)}`, + `近期剧情片段:\n${summarizeRecentStoryMoments(context)}`, + '现在请基于这次具体局势,生成一个自然生长出来的任务意图。', + ].join('\n\n'); +} + +export function buildQuestGenerationContextFromState(params: { + state: RuntimeStateLike; + encounter: RuntimeEncounterLike; +}) { + const { state, encounter } = params; + const issuerNpcId = encounter.id ?? encounter.npcName; + const issuerState = state.npcStates[issuerNpcId]; + + return { + worldType: state.worldType, + customWorldProfile: state.customWorldProfile ?? null, + actState: state.storyEngineMemory?.actState ?? null, + currentSceneId: state.currentScenePreset?.id ?? null, + currentSceneName: state.currentScenePreset?.name ?? null, + currentSceneDescription: state.currentScenePreset?.description ?? null, + issuerNpcId, + issuerNpcName: encounter.npcName, + issuerNpcContext: encounter.context, + issuerAffinity: issuerState?.affinity ?? 0, + issuerNarrativeProfile: encounter.narrativeProfile ?? null, + issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0, { + recruited: issuerState?.recruited, + }), + issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0, { + recruited: issuerState?.recruited, + }), + activeThreadIds: state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [], + encounterKind: encounter.kind ?? 'npc', + currentSceneTreasureHintCount: + state.currentScenePreset?.treasureHints?.length ?? 0, + currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? []) + .filter((npc) => Boolean(npc.hostile || npc.monsterPresetId)) + .map((npc) => npc.monsterPresetId ?? npc.id), + recentStoryMoments: state.storyHistory.slice(-6), + playerCharacter: state.playerCharacter ?? null, + playerHp: state.playerHp, + playerMaxHp: state.playerMaxHp, + playerMana: state.playerMana, + playerMaxMana: state.playerMaxMana, + playerInventory: state.playerInventory ?? [], + playerEquipment: state.playerEquipment, + activeCompanions: state.companions ?? [], + rosterCompanions: state.roster ?? [], + currentQuestSummary: state.quests.map((quest) => ({ + id: quest.id, + title: quest.title, + status: quest.status, + issuerNpcId: quest.issuerNpcId, + })), + } satisfies QuestGenerationContext; +} + +export function findQuestById(quests: QuestLogEntry[], questId: string) { + return quests.find((quest) => quest.id === questId) ?? null; +} + +export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string) { + return ( + normalizeQuestLogEntries(quests).find( + (quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', + ) ?? null + ); +} + +export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity { + const { issuerNpcId, scene, currentQuests = [] } = params; + if (!scene) { + return { + shouldOffer: false, + reason: '当前缺少可落地的场景信息,暂时不适合生成委托。', + }; + } + + if ( + currentQuests.some( + (quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', + ) + ) { + return { + shouldOffer: false, + reason: '这名角色还有尚未结清的委托。', + suggestedIssuerNpcId: issuerNpcId, + }; + } + + const liveQuestCount = currentQuests.filter( + (quest) => !isTerminalStatus(quest.status), + ).length; + if (liveQuestCount >= 4) { + return { + shouldOffer: false, + reason: '当前未完成委托已经偏多,不再继续塞入新的任务机会。', + suggestedIssuerNpcId: issuerNpcId, + }; + } + + const threat = getScenePrimaryThreat(scene); + if (!threat) { + return { + shouldOffer: false, + reason: '当前场景里缺少足够明确的任务抓手。', + suggestedIssuerNpcId: issuerNpcId, + }; + } + + return { + shouldOffer: true, + reason: + threat.kind === 'inspect_treasure' + ? `${scene.name} 附近出现了值得调查的异常。` + : threat.kind === 'spar_with_npc' + ? `${params.issuerNpcName} 更适合给出一份关系驱动的试炼型委托。` + : `${scene.name} 附近存在可以被明确指向的敌对角色威胁。`, + suggestedIssuerNpcId: issuerNpcId, + suggestedThreatType: threat.suggestedThreatType, + }; +} + +export function buildFallbackQuestIntent( + params: QuestCompilationRequest, +): QuestIntent { + const { issuerNpcName, scene } = params; + const threat = getScenePrimaryThreat(scene); + + if (threat?.kind === 'defeat_hostile_npc') { + return { + title: `压制${compactQuestLabel(threat.targetHostileNpcName, 8)}`, + description: `${issuerNpcName} 希望你先处理掉 ${ + scene?.name ?? '前方区域' + } 徘徊的 ${threat.targetHostileNpcName},再回来交换后续情报。`, + summary: `击退 ${threat.targetHostileNpcName},然后回去和 ${issuerNpcName} 交谈`, + narrativeType: 'bounty', + dramaticNeed: `${scene?.name ?? '前方区域'} 的危险已经影响到 ${issuerNpcName} 的下一步行动。`, + issuerGoal: `先压下 ${threat.targetHostileNpcName} 带来的威胁,再确认局势是否稳定。`, + playerHook: '你正好位于现场,也最适合先去验证这一层风险。', + worldReason: `${scene?.name ?? '这一区域'} 的局势还没有真正安定下来。`, + recommendedObjectiveKinds: ['defeat_hostile_npc', 'talk_to_npc'], + urgency: 'medium', + intimacy: 'cooperative', + rewardTheme: 'resource', + followupHooks: [ + `${issuerNpcName} 手里还握着没完全说开的后续线索。`, + '这份委托背后还有更深一层的局势变化。', + ], + }; + } + + if (threat?.kind === 'inspect_treasure' && scene) { + return { + title: `${compactQuestLabel(scene.name)}异动`, + description: `${issuerNpcName} 不确定 ${scene.name} 一带出现的异动是真是假,想让你先去看清楚,再回来对一遍情报。`, + summary: `调查 ${scene.name} 的异常,然后回去向 ${issuerNpcName} 汇报`, + narrativeType: 'investigation', + dramaticNeed: `${issuerNpcName} 想知道这条线索值不值得继续深挖。`, + issuerGoal: `确认 ${scene.name} 一带究竟藏着什么。`, + playerHook: '你已经身在局中,最适合把这层异常先摸清。', + worldReason: `${scene.name} 周围留下了还没有被说清的痕迹。`, + recommendedObjectiveKinds: ['inspect_treasure', 'talk_to_npc'], + urgency: 'medium', + intimacy: 'cooperative', + rewardTheme: 'intel', + followupHooks: [ + `${scene.name} 的异常可能还连着另一处更深的地点。`, + `${issuerNpcName} 对这里并不是完全陌生。`, + ], + }; + } + + return { + title: `${compactQuestLabel(issuerNpcName)}试炼`, + description: `${issuerNpcName} 想先亲自试一试你的成色,再决定要不要把更关键的事继续交给你。`, + summary: `和 ${issuerNpcName} 切磋一场,然后回来把话说透`, + narrativeType: 'trial', + dramaticNeed: `${issuerNpcName} 还没完全确认你值不值得信任。`, + issuerGoal: '通过切磋判断你的实力和态度。', + playerHook: '你只需要接住这场试探,就能让关系往前推一步。', + worldReason: '在这种局势里,口头承诺往往不如当面试一试来得直接。', + recommendedObjectiveKinds: ['spar_with_npc', 'talk_to_npc'], + urgency: 'low', + intimacy: 'trust_based', + rewardTheme: 'relationship', + followupHooks: [ + `${issuerNpcName} 会根据这次试探重新判断和你的距离。`, + '这次切磋很可能会牵出下一轮更正式的合作。', + ], + }; +} + +export function compileQuestIntentToQuest( + params: QuestCompilationRequest, + intent: QuestIntent, +): QuestLogEntry | null { + const fallbackIntent = buildFallbackQuestIntent(params); + const primaryStep = buildPrimaryQuestStep({ + issuerNpcId: params.issuerNpcId, + issuerNpcName: params.issuerNpcName, + scene: params.scene, + intent, + }); + if (!primaryStep) { + return null; + } + + const steps = [ + primaryStep, + buildTalkBackStep(params.issuerNpcId, params.issuerNpcName), + ]; + const reward = buildQuestReward({ + issuerNpcId: params.issuerNpcId, + issuerNpcName: params.issuerNpcName, + worldType: params.worldType, + rewardTheme: intent.rewardTheme, + narrativeType: intent.narrativeType, + scene: params.scene, + }); + const rewardText = buildRewardText(reward, params.worldType); + + return normalizeQuestLogEntry({ + id: buildQuestId( + params.issuerNpcId, + primaryStep.kind, + primaryStep.targetHostileNpcId ?? + primaryStep.targetSceneId ?? + primaryStep.targetNpcId ?? + params.scene?.id ?? + primaryStep.id, + ), + issuerNpcId: params.issuerNpcId, + issuerNpcName: params.issuerNpcName, + sceneId: params.scene?.id ?? null, + chapterId: null, + actId: params.context?.actState?.id ?? null, + threadId: params.context?.activeThreadIds?.[0] ?? null, + contractId: null, + title: normalizeQuestTitle(intent.title, fallbackIntent.title), + description: intent.description.trim() || fallbackIntent.description, + summary: intent.summary.trim() || fallbackIntent.summary, + objective: deriveObjectiveFromStep(primaryStep), + progress: 0, + status: 'active', + completionNotified: false, + reward, + rewardText, + narrativeBinding: { + origin: params.origin ?? 'fallback_builder', + narrativeType: intent.narrativeType, + dramaticNeed: intent.dramaticNeed, + issuerGoal: intent.issuerGoal, + playerHook: intent.playerHook, + worldReason: intent.worldReason, + followupHooks: intent.followupHooks, + }, + steps, + activeStepId: steps[0]?.id ?? null, + visibleStage: 0, + hiddenFlags: [], + discoveredFactIds: [], + relatedCarrierIds: [], + consequenceIds: [], + }); +} + +export function buildQuestForEncounter(params: QuestPreviewRequest) { + const opportunity = evaluateQuestOpportunity(params); + if (!opportunity.shouldOffer) { + return null; + } + + return compileQuestIntentToQuest( + { + ...params, + origin: 'fallback_builder', + }, + buildFallbackQuestIntent(params), + ); +} + +export function buildChapterQuestForScene(params: { + scene: QuestSceneSnapshot | null; + worldType: string | null | undefined; + context?: QuestGenerationContext; +}) { + const { scene, worldType, context } = params; + if (!scene) { + return null; + } + + const guideNpc = + scene.npcs.find((npc) => !npc.hostile && !npc.monsterPresetId) ?? null; + return buildQuestForEncounter({ + issuerNpcId: guideNpc?.id ?? `scene-guide:${scene.id}`, + issuerNpcName: guideNpc?.name ?? `${compactQuestLabel(scene.name)}引路人`, + roleText: guideNpc?.role ?? scene.description ?? scene.name, + scene, + worldType, + currentQuests: [], + context, + origin: 'fallback_builder', + }); +} + +export function normalizeQuestLogEntries(quests: QuestLogEntry[]) { + return quests.map((quest) => normalizeQuestLogEntry(quest)); +} + +export function buildQuestAcceptResultText(quest: QuestLogEntry) { + const normalizedQuest = normalizeQuestLogEntry(quest); + const activeStep = getQuestActiveStep(normalizedQuest); + return `${normalizedQuest.issuerNpcName} 正式把委托交到了你手上。${ + activeStep?.revealText ?? normalizedQuest.summary + }`; +} + +export function buildQuestTurnInResultText(quest: QuestLogEntry) { + const normalizedQuest = normalizeQuestLogEntry(quest); + const itemText = + normalizedQuest.reward.items.map((item) => item.name).join('、') || '补给'; + const intelText = normalizedQuest.reward.intel?.rumorText + ? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}` + : ''; + const storyHintText = normalizedQuest.reward.storyHint + ? ` ${normalizedQuest.reward.storyHint}` + : ''; + + return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${ + normalizedQuest.reward.currency + } 赏金和 ${itemText}${intelText}。${storyHintText}`; +} + +export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) { + const normalizedQuests = normalizeQuestLogEntries(quests); + if (findQuestById(normalizedQuests, quest.id)) { + return normalizedQuests; + } + + return [...normalizedQuests, normalizeQuestLogEntries([quest])[0]!]; +} + +export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) { + return quests.map((quest) => + quest.id === questId + ? normalizeQuestLogEntries([ + { + ...quest, + status: 'turned_in', + completionNotified: true, + steps: quest.steps?.map((step) => ({ + ...step, + progress: step.requiredCount, + })), + }, + ])[0]! + : normalizeQuestLogEntries([quest])[0]!, + ); +} + +export function markQuestCompletionNotified( + quests: QuestLogEntry[], + questId: string, +) { + return quests.map((quest) => + quest.id === questId + ? normalizeQuestLogEntries([ + { + ...quest, + completionNotified: true, + }, + ])[0]! + : normalizeQuestLogEntries([quest])[0]!, + ); +} + +export function isQuestReadyToClaim(quest: QuestLogEntry) { + const status = normalizeQuestLogEntries([quest])[0]!.status; + return status === 'ready_to_turn_in' || status === 'completed'; +} + +export function applyQuestProgressSignal( + quests: QuestLogEntry[], + signal: QuestProgressSignal, +) { + return quests.map((quest) => { + const normalizedQuest = normalizeQuestLogEntry(quest); + if ( + isTerminalStatus(normalizedQuest.status) || + isRewardReadyStatus(normalizedQuest.status) + ) { + return normalizedQuest; + } + + const activeStep = getQuestActiveStep(normalizedQuest); + if (!activeStep || !stepMatchesSignal(activeStep, signal)) { + return normalizedQuest; + } + + const increment = getSignalProgressIncrement(signal); + const nextSteps = normalizedQuest.steps!.map((step) => + step.id === activeStep.id + ? { + ...step, + progress: Math.min(step.requiredCount, step.progress + increment), + } + : step, + ); + + return normalizeQuestLogEntry({ + ...normalizedQuest, + steps: nextSteps, + completionNotified: false, + }); + }); +} diff --git a/server-node/src/modules/runtime-item/index.ts b/server-node/src/modules/runtime-item/index.ts new file mode 100644 index 00000000..5d4b46ca --- /dev/null +++ b/server-node/src/modules/runtime-item/index.ts @@ -0,0 +1,2 @@ +export * from './runtimeItemResolutionService.js'; +export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js'; diff --git a/server-node/src/modules/runtime-item/runtimeItemModule.ts b/server-node/src/modules/runtime-item/runtimeItemModule.ts new file mode 100644 index 00000000..9c24e2d0 --- /dev/null +++ b/server-node/src/modules/runtime-item/runtimeItemModule.ts @@ -0,0 +1,784 @@ +import { + RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, + RUNTIME_ITEM_TONE_VALUES, +} from '../../../../packages/shared/src/contracts/story.js'; + +export type RuntimeItemFunctionalBias = + (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; +export type RuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number]; + +export type RuntimeRelationAnchor = + | { type: 'npc'; npcName: string } + | { type: 'scene'; sceneName: string } + | { type: 'monster'; monsterName: string } + | { type: 'quest'; questName: string } + | { type: 'faction'; factionName: string } + | { type: 'landmark'; landmarkName: string }; + +export type RuntimeItemPlan = { + slot: string; + itemKind: 'equipment' | 'consumable' | 'material' | 'relic' | 'quest'; + permanence: 'permanent' | 'timed' | 'resource'; + relationAnchor: RuntimeRelationAnchor; + targetBuildDirection: string[]; +}; + +export type RuntimeItemAiPromptInput = { + worldSummary: string; + sceneSummary: string; + encounterSummary: string; + relatedNpcSummary: string; + recentStorySummary: string; + activeThreadSummary: string; + generationChannel: string; + playerBuildDirection: string[]; + playerBuildGaps: string[]; + desiredItemKind: RuntimeItemPlan['itemKind']; + permanence: RuntimeItemPlan['permanence']; +}; + +export type RuntimeItemAiIntent = { + shortNameSeed: string; + sourcePhrase: string; + reasonToAppear: string; + relationHooks: string[]; + desiredBuildTags: string[]; + desiredFunctionalBias: RuntimeItemFunctionalBias[]; + tone: RuntimeItemTone; + visibleClue: string; + witnessMark: string; + unfinishedBusiness: string; + hiddenHook: string; + reactionHooks: string[]; + namingPattern: string; +}; + +export type RuntimeItemStoryFingerprint = { + relatedScarIds: string[]; + relatedThreadIds: string[]; + visibleClue: string; + witnessMark: string; + unresolvedQuestion: string; +}; + +export type RuntimeItemInventory = { + id: string; + category: string; + name: string; + description: string; + quantity: number; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + tags: string[]; + equipmentSlotId?: string; + buildProfile?: { + role: string; + tags: string[]; + synergy: string[]; + forgeRank: number; + }; + statProfile?: { + maxHpBonus?: number; + outgoingDamageBonus?: number; + incomingDamageMultiplier?: number; + }; + useProfile?: { + hpRestore: number; + manaRestore: number; + cooldownReduction: number; + buildBuffs: Array<{ + id: string; + sourceType: 'item'; + sourceId: string; + name: string; + tags: string[]; + durationTurns: number; + }>; + }; + runtimeMetadata?: { + origin: 'ai_compiled' | 'procedural'; + generationChannel: string; + seedKey: string; + sourceReason: string; + storyFingerprint: RuntimeItemStoryFingerprint; + }; +}; + +export type DirectedRuntimeReward = { + primaryItem: RuntimeItemInventory | null; + supportItems: RuntimeItemInventory[]; + hp?: number; + mana?: number; + currency?: number; + storyHint?: string; +}; + +export type RuntimeItemGenerationContext = { + worldType: string | null | undefined; + customWorldProfile?: { + name?: string; + summary?: string; + } | null; + sceneId: string | null; + sceneName: string | null; + sceneDescription: string | null; + treasureHints: string[]; + encounter: { + id?: string; + kind?: string; + npcName: string; + npcDescription?: string; + npcAvatar?: string; + context?: string; + } | null; + encounterNpcId: string | null; + encounterNpcName: string | null; + encounterContextText: string | null; + relatedNpcState: { + affinity?: number; + } | null; + relatedNpcNarrativeProfile: { + publicMask?: string; + visibleLine?: string; + immediatePressure?: string; + debtOrBurden?: string; + contradiction?: string; + taboo?: string; + reactionHooks?: string[]; + relatedThreadIds?: string[]; + } | null; + relatedScene: { + id: string; + name: string; + description?: string; + treasureHints?: string[]; + } | null; + recentStorySummary: string; + recentActions: string[]; + activeThreadIds: string[]; + playerCharacterId: string; + playerBuildTags: string[]; + playerBuildGaps: string[]; + playerEquipmentTags: string[]; + generationChannel: string; +}; + +type LooseContextInput = { + worldType: string | null | undefined; + customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile']; + scene?: RuntimeItemGenerationContext['relatedScene']; + encounter?: RuntimeItemGenerationContext['encounter']; + relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState']; + storyHistory?: Array<{ text: string }>; + playerCharacterId?: string; + playerBuildTags?: string[]; + playerEquipmentTags?: string[]; + generationChannel: string; +}; + +function dedupeStrings(values: Array) { + return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]; +} + +function sanitizeFragment(value: string | null | undefined, maxLength = 4) { + return (value ?? '') + .replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '') + .slice(0, maxLength); +} + +function resolveAnchorLabel(anchor: RuntimeRelationAnchor) { + switch (anchor.type) { + case 'npc': + return anchor.npcName; + case 'scene': + return anchor.sceneName; + case 'monster': + return anchor.monsterName; + case 'quest': + return anchor.questName; + case 'faction': + return anchor.factionName; + default: + return anchor.landmarkName; + } +} + +function buildRecentStoryLines(storyHistory: Array<{ text: string }> = []) { + return storyHistory + .slice(-4) + .map((moment) => moment.text.trim()) + .filter(Boolean) + .slice(-3); +} + +function buildRecentStorySummary(lines: string[]) { + return lines.length > 0 ? lines.join(' / ') : '最近没有形成稳定的事件线索。'; +} + +function derivePlayerBuildGaps(playerBuildTags: string[]) { + const gapChecks = [ + { id: 'survival_gap', tags: ['守御', '护体', '回复', '续战'] }, + { id: 'mana_gap', tags: ['法力', '冷却', '护持', '过载'] }, + { id: 'finisher_gap', tags: ['爆发', '重击', '追击', '压制'] }, + ]; + + const tagSet = new Set(playerBuildTags); + return gapChecks + .filter((definition) => definition.tags.every((tag) => !tagSet.has(tag))) + .map((definition) => definition.id) + .slice(0, 3); +} + +function buildRuntimeItemStoryFingerprint(params: { + context: RuntimeItemGenerationContext; + plan: RuntimeItemPlan; + intent: RuntimeItemAiIntent; +}) { + const anchorKey = sanitizeFragment(resolveAnchorLabel(params.plan.relationAnchor), 6) || '旧痕'; + return { + relatedScarIds: [`scar:${params.context.generationChannel}:${anchorKey}`], + relatedThreadIds: params.context.activeThreadIds.slice(0, 2), + visibleClue: params.intent.visibleClue, + witnessMark: params.intent.witnessMark, + unresolvedQuestion: params.intent.hiddenHook || params.intent.unfinishedBusiness, + } satisfies RuntimeItemStoryFingerprint; +} + +function buildNarrativeName( + plan: RuntimeItemPlan, + intent: RuntimeItemAiIntent, + index: number, +) { + const seed = intent.shortNameSeed || '旧痕'; + switch (plan.itemKind) { + case 'equipment': + return `${seed}${index === 0 ? '战符' : '护具'}`; + case 'consumable': + return `${seed}${intent.desiredFunctionalBias.includes('mana') ? '回息散' : '疗伤散'}`; + case 'material': + return `${seed}残材`; + case 'quest': + return `${seed}凭证`; + default: + return `${seed}遗物`; + } +} + +function buildNarrativeDescription(params: { + context: RuntimeItemGenerationContext; + plan: RuntimeItemPlan; + intent: RuntimeItemAiIntent; +}) { + const buildText = params.context.playerBuildTags.join('、') || '当前构筑'; + const anchorText = resolveAnchorLabel(params.plan.relationAnchor); + return `${anchorText}把这件物件推到了你面前。它会围绕你的构筑 ${buildText} 发挥作用,原因是:${params.intent.reasonToAppear}`; +} + +function createRelationAnchor( + context: RuntimeItemGenerationContext, + index = 0, +): RuntimeRelationAnchor { + if (context.encounterNpcName) { + return { + type: 'npc', + npcName: context.encounterNpcName, + }; + } + + if (context.sceneName) { + return { + type: 'scene', + sceneName: context.sceneName, + }; + } + + return { + type: 'landmark', + landmarkName: `遗址${index + 1}`, + }; +} + +function buildPlanFromOptions(params: { + context: RuntimeItemGenerationContext; + index: number; + fixedKinds?: RuntimeItemPlan['itemKind'][]; + fixedPermanence?: RuntimeItemPlan['permanence'][]; +}) { + return { + slot: `slot_${params.index + 1}`, + itemKind: params.fixedKinds?.[params.index] ?? 'relic', + permanence: params.fixedPermanence?.[params.index] ?? 'permanent', + relationAnchor: createRelationAnchor(params.context, params.index), + targetBuildDirection: params.context.playerBuildTags.slice(0, 3), + } satisfies RuntimeItemPlan; +} + +function buildItemRarity(plan: RuntimeItemPlan) { + if (plan.itemKind === 'equipment' || plan.itemKind === 'relic') { + return 'rare' as const; + } + if (plan.itemKind === 'quest') { + return 'epic' as const; + } + return 'uncommon' as const; +} + +function buildItemTags( + plan: RuntimeItemPlan, + intent: RuntimeItemAiIntent, + context: RuntimeItemGenerationContext, +) { + return dedupeStrings([ + plan.itemKind, + ...intent.desiredBuildTags, + ...context.playerBuildTags.slice(0, 2), + ...intent.desiredFunctionalBias, + ]); +} + +function buildItemProfiles( + itemId: string, + plan: RuntimeItemPlan, + intent: RuntimeItemAiIntent, + context: RuntimeItemGenerationContext, +) { + if (plan.itemKind === 'equipment') { + return { + equipmentSlotId: 'weapon', + buildProfile: { + role: context.playerBuildTags[0] ?? '均衡', + tags: buildItemTags(plan, intent, context).slice(0, 3), + synergy: buildItemTags(plan, intent, context).slice(0, 3), + forgeRank: 0, + }, + statProfile: { + maxHpBonus: intent.desiredFunctionalBias.includes('guard') ? 16 : 8, + outgoingDamageBonus: intent.desiredFunctionalBias.includes('damage') + ? 0.12 + : 0.05, + incomingDamageMultiplier: intent.desiredFunctionalBias.includes('guard') + ? 0.9 + : 0.96, + }, + }; + } + + if (plan.itemKind === 'consumable') { + return { + useProfile: { + hpRestore: intent.desiredFunctionalBias.includes('heal') ? 12 : 0, + manaRestore: intent.desiredFunctionalBias.includes('mana') ? 10 : 0, + cooldownReduction: intent.desiredFunctionalBias.includes('cooldown') ? 1 : 0, + buildBuffs: [ + { + id: `${itemId}:buff`, + sourceType: 'item' as const, + sourceId: itemId, + name: `${intent.shortNameSeed || '旧痕'}增益`, + tags: buildItemTags(plan, intent, context).slice(0, 2), + durationTurns: 2, + }, + ], + }, + }; + } + + return {}; +} + +function buildRuntimeInventoryItem(params: { + context: RuntimeItemGenerationContext; + plan: RuntimeItemPlan; + intent: RuntimeItemAiIntent; + seedKey: string; + index: number; +}) { + const itemId = `${params.seedKey}:${params.index + 1}`; + const storyFingerprint = buildRuntimeItemStoryFingerprint(params); + const name = buildNarrativeName(params.plan, params.intent, params.index); + + return { + id: itemId, + category: + params.plan.itemKind === 'equipment' + ? '装备' + : params.plan.itemKind === 'consumable' + ? '消耗品' + : params.plan.itemKind === 'material' + ? '材料' + : params.plan.itemKind === 'quest' + ? '凭证' + : '遗物', + name, + description: buildNarrativeDescription(params), + quantity: 1, + rarity: buildItemRarity(params.plan), + tags: buildItemTags(params.plan, params.intent, params.context), + runtimeMetadata: { + origin: 'ai_compiled' as const, + generationChannel: params.context.generationChannel, + seedKey: itemId, + sourceReason: params.intent.reasonToAppear, + storyFingerprint, + }, + ...buildItemProfiles(itemId, params.plan, params.intent, params.context), + } satisfies RuntimeItemInventory; +} + +export function buildRuntimeItemAiPromptInput( + context: RuntimeItemGenerationContext, + plan: RuntimeItemPlan, +) { + return { + worldSummary: + context.customWorldProfile?.summary ?? context.worldType ?? '未知世界', + sceneSummary: [context.sceneName, context.sceneDescription].filter(Boolean).join(' / '), + encounterSummary: [context.encounterNpcName, context.encounterContextText] + .filter(Boolean) + .join(' / '), + relatedNpcSummary: context.relatedNpcNarrativeProfile + ? `${context.encounterNpcName ?? '相关人物'}:公开面 ${ + context.relatedNpcNarrativeProfile.publicMask ?? '暂无' + };当前压力 ${context.relatedNpcNarrativeProfile.immediatePressure ?? '暂无'}` + : context.relatedNpcState + ? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity ?? 0}` + : '暂无明确人物关系', + recentStorySummary: context.recentStorySummary, + activeThreadSummary: context.activeThreadIds.join('、'), + generationChannel: context.generationChannel, + playerBuildDirection: context.playerBuildTags, + playerBuildGaps: context.playerBuildGaps, + desiredItemKind: plan.itemKind, + permanence: plan.permanence, + } satisfies RuntimeItemAiPromptInput; +} + +export function buildRuntimeItemAiIntent( + context: RuntimeItemGenerationContext, + plan: RuntimeItemPlan, +) { + const anchorLabel = resolveAnchorLabel(plan.relationAnchor); + const sourceSeed = + sanitizeFragment(context.sceneName, 4) || + sanitizeFragment(context.customWorldProfile?.name, 4) || + sanitizeFragment(anchorLabel, 4) || + '旧誓'; + const functionalBias: RuntimeItemFunctionalBias[] = []; + + if (plan.permanence === 'timed') { + functionalBias.push( + context.playerBuildGaps.includes('survival_gap') ? 'heal' : 'cooldown', + ); + } + if (context.playerBuildGaps.includes('mana_gap')) functionalBias.push('mana'); + if (context.playerBuildGaps.includes('survival_gap')) functionalBias.push('guard'); + if ( + functionalBias.length <= 0 || + context.playerBuildGaps.includes('finisher_gap') || + plan.itemKind === 'equipment' + ) { + functionalBias.push('damage'); + } + + return { + shortNameSeed: sourceSeed, + sourcePhrase: anchorLabel, + reasonToAppear: + context.generationChannel === 'monster_drop' + ? `${anchorLabel}倒下后,${context.sceneName ?? '这片战场'}里最值得带走的残留被翻了出来。` + : `${anchorLabel}与最近局势把它推到了你面前。`, + relationHooks: [context.encounterContextText ?? context.sceneName ?? anchorLabel, ...context.recentActions] + .filter(Boolean) + .slice(0, 2) as string[], + desiredBuildTags: dedupeStrings([ + ...plan.targetBuildDirection, + ...context.playerBuildTags.slice(0, 2), + ]).slice(0, 3), + desiredFunctionalBias: [...new Set(functionalBias)].slice(0, 2), + tone: + context.generationChannel === 'monster_drop' + ? 'grim' + : context.generationChannel === 'quest_reward' + ? 'ritual' + : context.playerBuildGaps.includes('survival_gap') + ? 'survival' + : 'martial', + visibleClue: + context.relatedNpcNarrativeProfile?.visibleLine ?? + `${anchorLabel}身上留下的旧痕`, + witnessMark: + context.relatedNpcNarrativeProfile?.debtOrBurden ?? + `${anchorLabel}尚未散尽的使用痕`, + unfinishedBusiness: + context.relatedNpcNarrativeProfile?.contradiction ?? + `${anchorLabel}背后还有没说完的问题`, + hiddenHook: + context.relatedNpcNarrativeProfile?.taboo ?? + `${anchorLabel}为什么会在此刻重新出现`, + reactionHooks: [ + ...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []), + ...(context.activeThreadIds ?? []), + ].slice(0, 4), + namingPattern: + plan.itemKind === 'quest' + ? 'quest_evidence' + : plan.itemKind === 'material' + ? 'scene_relic' + : plan.relationAnchor.type === 'monster' + ? 'monster_trophy' + : plan.relationAnchor.type === 'npc' + ? 'npc_relic' + : 'faction_issue', + } satisfies RuntimeItemAiIntent; +} + +function describeRelationAnchor(anchor: RuntimeRelationAnchor) { + switch (anchor.type) { + case 'npc': + return `NPC:${anchor.npcName}`; + case 'scene': + return `场景:${anchor.sceneName}`; + case 'monster': + return `怪物:${anchor.monsterName}`; + case 'quest': + return `任务:${anchor.questName}`; + case 'faction': + return `势力:${anchor.factionName}`; + default: + return `地标:${anchor.landmarkName}`; + } +} + +function describePlan( + context: RuntimeItemGenerationContext, + plan: RuntimeItemPlan, + index: number, +) { + const promptInput = buildRuntimeItemAiPromptInput(context, plan); + + return [ + `物品 ${index + 1}`, + `- slot: ${plan.slot}`, + `- 物品类型: ${promptInput.desiredItemKind}`, + `- 持续性: ${promptInput.permanence}`, + `- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`, + `- 世界摘要: ${promptInput.worldSummary}`, + `- 场景摘要: ${promptInput.sceneSummary || '无'}`, + `- 遭遇摘要: ${promptInput.encounterSummary || '无'}`, + `- 相关人物: ${promptInput.relatedNpcSummary}`, + `- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`, + `- 近期剧情: ${promptInput.recentStorySummary}`, + `- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`, + `- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`, + `- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`, + ].join('\n'); +} + +export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。 +你只返回 JSON,不要输出 Markdown、解释或代码块。 + +输出结构: +{ + "intents": [ + { + "shortNameSeed": "中文短种子", + "sourcePhrase": "中文来源短语", + "reasonToAppear": "中文出现理由", + "relationHooks": ["中文关系钩子"], + "desiredBuildTags": ["中文 build 标签"], + "desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"], + "tone": "grim|mysterious|martial|ritual|survival", + "visibleClue": "玩家第一眼能抓到的痕迹", + "witnessMark": "它见证过什么的使用痕", + "unfinishedBusiness": "背后仍未结清的问题", + "hiddenHook": "更深一层但别直接讲穿的钩子", + "reactionHooks": ["以后谁会对它起反应"], + "namingPattern": "命名范式建议" + } + ] +} + +规则: +- intents 数量必须与输入物品数量完全一致,顺序也必须一致。 +- 所有自然语言字段都必须使用中文。 +- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。 +- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。 +- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。 +- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`; + +export function buildRuntimeItemIntentPrompt(params: { + context: RuntimeItemGenerationContext; + plans: RuntimeItemPlan[]; +}) { + return [ + `生成渠道:${params.context.generationChannel}`, + `以下每个物品都需要给出一条可编译的运行时物品意图。`, + ...params.plans.map((plan, index) => describePlan(params.context, plan, index)), + '请严格返回 JSON。', + ].join('\n\n'); +} + +function buildBaseRuntimeContext(params: { + worldType: string | null | undefined; + customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile']; + scene?: RuntimeItemGenerationContext['relatedScene']; + encounter?: RuntimeItemGenerationContext['encounter']; + relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState']; + storyHistory?: Array<{ text: string }>; + playerCharacterId?: string; + playerBuildTags?: string[]; + playerEquipmentTags?: string[]; + generationChannel: string; +}) { + const recentStoryLines = buildRecentStoryLines(params.storyHistory); + const activeThreadIds = dedupeStrings( + params.encounter?.kind === 'npc' && params.encounter?.id + ? [`thread:${params.encounter.id}`] + : params.scene?.id + ? [`thread:${params.scene.id}`] + : [], + ).slice(0, 3); + + return { + worldType: params.worldType, + customWorldProfile: params.customWorldProfile ?? null, + sceneId: params.scene?.id ?? null, + sceneName: params.scene?.name ?? null, + sceneDescription: params.scene?.description ?? null, + treasureHints: [...(params.scene?.treasureHints ?? [])], + encounter: params.encounter ?? null, + encounterNpcId: + params.encounter?.id ?? params.encounter?.npcName ?? null, + encounterNpcName: params.encounter?.npcName ?? null, + encounterContextText: params.encounter?.context ?? null, + relatedNpcState: params.relatedNpcState ?? null, + relatedNpcNarrativeProfile: null, + relatedScene: params.scene ?? null, + recentStorySummary: buildRecentStorySummary(recentStoryLines), + recentActions: recentStoryLines, + activeThreadIds, + playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player', + playerBuildTags: params.playerBuildTags ?? [], + playerBuildGaps: derivePlayerBuildGaps(params.playerBuildTags ?? []), + playerEquipmentTags: params.playerEquipmentTags ?? [], + generationChannel: params.generationChannel, + } satisfies RuntimeItemGenerationContext; +} + +export function buildLooseRuntimeItemGenerationContext(params: LooseContextInput) { + return buildBaseRuntimeContext(params); +} + +export function buildQuestRuntimeItemGenerationContext(params: { + context: { + worldType?: string | null; + customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile']; + currentSceneId?: string | null; + currentSceneName?: string | null; + currentSceneDescription?: string | null; + issuerAffinity?: number | null; + recentStoryMoments?: Array<{ text: string }>; + playerCharacter?: { id: string } | null; + }; + generationChannel?: string; + issuerNpcId: string; + issuerNpcName: string; + roleText: string; + scene?: RuntimeItemGenerationContext['relatedScene']; +}) { + const { context, issuerNpcId, issuerNpcName, roleText } = params; + + return buildBaseRuntimeContext({ + worldType: context.worldType ?? null, + customWorldProfile: context.customWorldProfile ?? null, + scene: + params.scene ?? + (context.currentSceneName + ? { + id: context.currentSceneId ?? '', + name: context.currentSceneName, + description: context.currentSceneDescription ?? '', + treasureHints: [], + } + : null), + encounter: { + id: issuerNpcId, + kind: 'npc', + npcName: issuerNpcName, + npcDescription: roleText, + npcAvatar: '', + context: roleText, + }, + relatedNpcState: + context.issuerAffinity == null + ? null + : { + affinity: context.issuerAffinity, + }, + storyHistory: context.recentStoryMoments ?? [], + playerCharacterId: context.playerCharacter?.id ?? 'quest-player', + generationChannel: params.generationChannel ?? 'quest_reward', + }); +} + +export function buildDirectedRuntimeReward( + context: RuntimeItemGenerationContext, + options: { + seedKey: string; + itemCount?: number; + fixedKinds?: RuntimeItemPlan['itemKind'][]; + fixedPermanence?: RuntimeItemPlan['permanence'][]; + baseHp?: number; + baseMana?: number; + baseCurrency?: number; + storyHint?: string; + }, +) { + const itemCount = Math.max(1, options.itemCount ?? 2); + const items = Array.from({ length: itemCount }, (_, index) => { + const plan = buildPlanFromOptions({ + context, + index, + fixedKinds: options.fixedKinds, + fixedPermanence: options.fixedPermanence, + }); + const intent = buildRuntimeItemAiIntent(context, plan); + return buildRuntimeInventoryItem({ + context, + plan, + intent, + seedKey: options.seedKey, + index, + }); + }); + + return { + primaryItem: items[0] ?? null, + supportItems: items.slice(1), + hp: options.baseHp ?? 0, + mana: options.baseMana ?? 0, + currency: options.baseCurrency ?? 0, + storyHint: + options.storyHint ?? + (items[0] + ? `${items[0].name} 先露出的是“${ + items[0].runtimeMetadata?.storyFingerprint.visibleClue ?? '旧痕' + }”。` + : '你得到了一件与当前局势相关的物品。'), + } satisfies DirectedRuntimeReward; +} + +export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward) { + return [ + ...(reward.primaryItem ? [reward.primaryItem] : []), + ...reward.supportItems, + ]; +} + +export function buildRuntimeInventoryStock( + context: RuntimeItemGenerationContext, + options: Parameters[1], +) { + return flattenDirectedRuntimeRewardItems( + buildDirectedRuntimeReward(context, options), + ); +} diff --git a/server-node/src/modules/runtime-item/runtimeItemResolutionService.test.ts b/server-node/src/modules/runtime-item/runtimeItemResolutionService.test.ts new file mode 100644 index 00000000..43dc64c1 --- /dev/null +++ b/server-node/src/modules/runtime-item/runtimeItemResolutionService.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildLooseRuntimeItemGenerationContext, + buildQuestRuntimeItemGenerationContext, +} from '../../bridges/legacyRuntimeItemResolutionBridge.js'; +import { + resolveDirectedReward, + resolveRuntimeInventoryStock, +} from './runtimeItemResolutionService.js'; + +const TEST_WUXIA_WORLD = 'WUXIA' as Parameters< + typeof buildLooseRuntimeItemGenerationContext +>[0]['worldType']; +const TEST_XIANXIA_WORLD = 'XIANXIA' as NonNullable< + Parameters[0]['context']['worldType'] +>; + +test('resolveDirectedReward returns flattened runtime reward items on the server side', () => { + const context = buildLooseRuntimeItemGenerationContext({ + worldType: TEST_WUXIA_WORLD, + scene: { + id: 'scene-ruins', + name: '断碑古道', + description: '碎碑与旧誓散落在路旁。', + treasureHints: ['残匣', '旧祭火'], + }, + encounter: { + id: 'treasure-altar', + kind: 'treasure', + npcName: '断誓秘匣', + npcDescription: '匣盖上留着未熄的旧印。', + npcAvatar: '', + context: '古道祭坛', + }, + playerCharacterId: 'hero', + playerBuildTags: ['快剑', '追击'], + generationChannel: 'treasure', + }); + + const result = resolveDirectedReward(context, { + seedKey: 'task6:treasure', + fixedKinds: ['relic', 'consumable'], + fixedPermanence: ['permanent', 'timed'], + itemCount: 2, + }); + + assert.equal(result.items.length, 2); + assert.equal( + result.reward.primaryItem?.runtimeMetadata?.generationChannel, + 'treasure', + ); + assert.equal(result.items[0]?.id, result.reward.primaryItem?.id); + assert.ok(result.reward.primaryItem?.description?.includes('构筑')); +}); + +test('resolveRuntimeInventoryStock builds quest-flavored stock without browser fallback', () => { + const context = buildQuestRuntimeItemGenerationContext({ + context: { + worldType: TEST_XIANXIA_WORLD, + currentSceneId: 'scene-cloud', + currentSceneName: '云阙旧渡', + currentSceneDescription: '旧渡口残留着灵潮和巡守痕迹。', + issuerNpcId: 'npc-issuer', + issuerNpcName: '巡守使', + issuerNpcContext: '巡守', + issuerAffinity: 24, + recentStoryMoments: [], + playerCharacter: null, + }, + issuerNpcId: 'npc-issuer', + issuerNpcName: '巡守使', + roleText: '巡守', + scene: { + id: 'scene-cloud', + name: '云阙旧渡', + description: '旧渡口残留着灵潮和巡守痕迹。', + treasureHints: ['旧印'], + }, + }); + + const items = resolveRuntimeInventoryStock(context, { + seedKey: 'task6:quest', + fixedKinds: ['equipment', 'consumable'], + fixedPermanence: ['permanent', 'timed'], + itemCount: 2, + }); + + assert.equal(items.length, 2); + assert.equal( + items.every((item) => item.runtimeMetadata?.generationChannel === 'quest_reward'), + true, + ); + assert.equal(items.some((item) => Boolean(item.buildProfile || item.useProfile)), true); +}); diff --git a/server-node/src/modules/runtime-item/runtimeItemResolutionService.ts b/server-node/src/modules/runtime-item/runtimeItemResolutionService.ts new file mode 100644 index 00000000..0f43c088 --- /dev/null +++ b/server-node/src/modules/runtime-item/runtimeItemResolutionService.ts @@ -0,0 +1,39 @@ +import { + buildDirectedRuntimeReward, + buildRuntimeInventoryStock, + flattenDirectedRuntimeRewardItems, +} from '../../bridges/legacyRuntimeItemResolutionBridge.js'; + +export type RuntimeItemGenerationContext = Parameters< + typeof buildDirectedRuntimeReward +>[0]; +export type RuntimeRewardOptions = Parameters< + typeof buildDirectedRuntimeReward +>[1]; +export type DirectedRuntimeReward = ReturnType; +export type ResolvedRuntimeRewardItem = ReturnType< + typeof buildRuntimeInventoryStock +>[number]; + +export type RuntimeRewardResolution = { + reward: DirectedRuntimeReward; + items: ResolvedRuntimeRewardItem[]; +}; + +export function resolveDirectedReward( + context: RuntimeItemGenerationContext, + options: RuntimeRewardOptions, +): RuntimeRewardResolution { + const reward = buildDirectedRuntimeReward(context, options); + return { + reward, + items: flattenDirectedRuntimeRewardItems(reward), + }; +} + +export function resolveRuntimeInventoryStock( + context: RuntimeItemGenerationContext, + options: RuntimeRewardOptions, +): ResolvedRuntimeRewardItem[] { + return buildRuntimeInventoryStock(context, options); +} diff --git a/server-node/src/modules/runtime-item/runtimeTreasureModule.ts b/server-node/src/modules/runtime-item/runtimeTreasureModule.ts new file mode 100644 index 00000000..aab1ddfe --- /dev/null +++ b/server-node/src/modules/runtime-item/runtimeTreasureModule.ts @@ -0,0 +1,80 @@ +import { + buildDirectedRuntimeReward, + buildLooseRuntimeItemGenerationContext, + flattenDirectedRuntimeRewardItems, +} from './runtimeItemModule.js'; + +type TreasureInteractionAction = 'inspect' | 'leave' | 'secure'; + +type RuntimeStateLike = { + worldType: string | null | undefined; + currentScenePreset?: { + id: string; + name: string; + description?: string; + treasureHints?: string[]; + } | null; + currentEncounter?: { + id?: string; + kind?: string; + npcName: string; + npcDescription?: string; + npcAvatar?: string; + context?: string; + } | null; + playerCharacter?: { + id: string; + } | null; +}; + +type RuntimeEncounterLike = NonNullable; + +export type TreasureReward = { + items: ReturnType; + hp: number; + mana: number; + currency: number; + storyHint?: string; +}; + +export function resolveTreasureReward( + state: RuntimeStateLike, + encounter: RuntimeEncounterLike, + action: TreasureInteractionAction, +) { + const context = buildLooseRuntimeItemGenerationContext({ + worldType: state.worldType, + scene: state.currentScenePreset ?? null, + encounter, + playerCharacterId: state.playerCharacter?.id ?? 'treasure-player', + generationChannel: 'treasure', + }); + const directed = buildDirectedRuntimeReward(context, { + seedKey: `treasure:${encounter.id ?? encounter.npcName}:${action}`, + variant: action, + itemCount: 2, + fixedKinds: + action === 'inspect' ? ['relic', 'consumable'] : ['relic', 'material'], + fixedPermanence: + action === 'inspect' ? ['permanent', 'timed'] : ['permanent', 'resource'], + baseHp: action === 'inspect' ? 10 : 0, + baseMana: action === 'inspect' ? 12 : 0, + baseCurrency: + action === 'inspect' + ? state.worldType === 'XIANXIA' + ? 34 + : 48 + : state.worldType === 'XIANXIA' + ? 22 + : 30, + storyHint: `${encounter.npcName}里藏着与你当前构筑和现场线索贴合的战利品。`, + } as Parameters[1]); + + return { + items: flattenDirectedRuntimeRewardItems(directed), + hp: directed.hp ?? 0, + mana: directed.mana ?? 0, + currency: directed.currency ?? 0, + storyHint: directed.storyHint, + } satisfies TreasureReward; +} diff --git a/server-node/src/modules/runtime-item/treasureStoryActionService.ts b/server-node/src/modules/runtime-item/treasureStoryActionService.ts new file mode 100644 index 00000000..82b797ca --- /dev/null +++ b/server-node/src/modules/runtime-item/treasureStoryActionService.ts @@ -0,0 +1,140 @@ +import type { + RuntimeStoryActionRequest, + RuntimeStoryPatch, +} from '../../../../packages/shared/src/contracts/story.js'; +import { conflict, invalidRequest } from '../../errors.js'; +import { + addInventoryItems, + appendStoryEngineCarrierMemory, +} from '../../bridges/legacyNpcTask6Bridge.js'; +import { + buildTreasureResultText, + resolveTreasureReward, +} from '../../bridges/legacyTreasureRuntimeBridge.js'; +import { buildBuildToast } from '../inventory/inventoryStoryActionService.js'; +import { + replaceRuntimeSessionRawGameState, + type RuntimeSession, +} from '../story/runtimeSession.js'; + +const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set([ + 'treasure_inspect', + 'treasure_leave', + 'treasure_secure', +]); + +type TreasureStoryResolution = { + actionText: string; + resultText: string; + patches: RuntimeStoryPatch[]; + toast?: string | null; +}; + +type JsonRecord = Record; +type RuntimeGameState = Parameters[0]; +type RuntimeEncounter = Parameters[1]; + +function resolveTreasureAction(functionId: string) { + switch (functionId) { + case 'treasure_secure': + return 'secure'; + case 'treasure_inspect': + return 'inspect'; + case 'treasure_leave': + return 'leave'; + default: + throw invalidRequest(`暂不支持的 Treasure 动作:${functionId}`); + } +} + +function getTreasureEncounter( + session: RuntimeSession, + state: RuntimeGameState, +): RuntimeEncounter | null { + const rawEncounter = state.currentEncounter; + if (!rawEncounter || rawEncounter.kind !== 'treasure') { + return null; + } + + return { + npcAvatar: '', + hostile: false, + ...rawEncounter, + id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName, + } satisfies RuntimeEncounter; +} + +export function isSupportedTreasureStoryFunctionId(functionId: string) { + return SUPPORTED_TREASURE_STORY_FUNCTION_IDS.has(functionId); +} + +export function resolveTreasureStoryAction( + session: RuntimeSession, + request: RuntimeStoryActionRequest, +): TreasureStoryResolution { + const state = session.rawGameState as unknown as RuntimeGameState; + const encounter = getTreasureEncounter(session, state); + if (!encounter) { + throw conflict('当前没有可结算的宝藏遭遇。'); + } + + const action = resolveTreasureAction(request.action.functionId); + const reward = + action === 'leave' ? null : resolveTreasureReward(state, encounter, action); + + let nextState = { + ...state, + currentEncounter: null, + npcInteractionActive: false, + sceneHostileNpcs: [], + playerX: 0, + playerFacing: 'right' as const, + animationState: state.animationState, + scrollWorld: false, + inBattle: false, + playerHp: reward + ? Math.min(state.playerMaxHp, state.playerHp + reward.hp) + : state.playerHp, + playerMana: reward + ? Math.min(state.playerMaxMana, state.playerMana + reward.mana) + : state.playerMana, + playerCurrency: reward + ? state.playerCurrency + reward.currency + : state.playerCurrency, + playerInventory: reward + ? addInventoryItems(state.playerInventory, reward.items) + : state.playerInventory, + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + } satisfies RuntimeGameState; + if (reward) { + nextState = appendStoryEngineCarrierMemory(nextState, reward.items); + } + + replaceRuntimeSessionRawGameState( + session, + nextState as unknown as JsonRecord, + ); + + return { + actionText: + action === 'leave' + ? '先记下位置' + : action === 'inspect' + ? '仔细检查' + : '直接收取', + resultText: buildTreasureResultText( + encounter, + action, + reward ?? undefined, + state.worldType, + ), + patches: [], + toast: reward ? buildBuildToast(nextState) : null, + }; +} diff --git a/server-node/src/modules/runtime/runtimeBuildModule.ts b/server-node/src/modules/runtime/runtimeBuildModule.ts new file mode 100644 index 00000000..fbc1ee33 --- /dev/null +++ b/server-node/src/modules/runtime/runtimeBuildModule.ts @@ -0,0 +1,211 @@ +import { + getEquipmentBonuses, + type RuntimeEquipmentLoadout, +} from './runtimeEquipmentModule.js'; + +type RuntimeCharacterLike = { + attributes: { + strength: number; + agility: number; + intelligence: number; + spirit: number; + }; +}; + +type RuntimeBuildBuff = { + id: string; + name: string; + tags: string[]; + durationTurns: number; +}; + +type RuntimeInventoryItemLike = { + buildProfile?: { + role: string; + tags: string[]; + synergy: string[]; + forgeRank: number; + }; +}; + +type RuntimeGameStateLike = { + playerEquipment: RuntimeEquipmentLoadout; + activeBuildBuffs?: RuntimeBuildBuff[]; + playerCharacter?: RuntimeCharacterLike | null; +}; + +export type BuildContributionRow = { + label: string; + source: 'buff' | 'weapon' | 'armor' | 'relic' | 'character'; + fitScore: number; + sourceCoefficient: number; + bonusDelta: number; + attributeSimilarities: Record; + attributeWeights: Record; + attributeContributions: Record; + attributeModifierDeltas: Record; +}; + +export type BuildDamageBreakdown = { + tags: string[]; + baseTagCount: number; + buildDamageBonus: number; + buildDamageMultiplier: number; + rows: BuildContributionRow[]; +}; + +export type OutgoingDamageResult = { + damage: number; + isCritical: boolean; + critChance: number; + critDamageMultiplier: number; + attackPowerMultiplier: number; +}; + +function roundNumber(value: number, digits = 4) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function hashSeed(seed: string) { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + return hash; +} + +export function appendBuildBuffs( + baseBuffs: TBuff[] | null | undefined, + additions: TBuff[] | null | undefined, +) { + const merged = new Map(); + + [...(baseBuffs ?? []), ...(additions ?? [])].forEach((buff) => { + const existing = merged.get(buff.id); + if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) { + merged.set(buff.id, { + ...buff, + tags: [...new Set(buff.tags.map((tag) => tag.trim()).filter(Boolean))], + }); + } + }); + + return [...merged.values()].filter( + (buff) => buff.tags.length > 0 && buff.durationTurns > 0, + ); +} + +function collectBuildTags( + state: RuntimeGameStateLike, + character: RuntimeCharacterLike, +) { + const tags = new Set(); + state.activeBuildBuffs + ?.filter((buff) => (buff.durationTurns ?? 0) > 0) + .forEach((buff) => buff.tags.forEach((tag) => tags.add(tag))); + + (['weapon', 'armor', 'relic'] as const).forEach((slot) => { + const item = state.playerEquipment[slot]; + item?.buildProfile?.tags?.forEach((tag) => tags.add(tag)); + if (item?.buildProfile?.role) { + tags.add(item.buildProfile.role); + } + }); + + if (character.attributes.agility >= 10) tags.add('快剑'); + if (character.attributes.strength >= 10) tags.add('重击'); + if (character.attributes.spirit >= 10) tags.add('续战'); + if (character.attributes.intelligence >= 8) tags.add('法力'); + + return [...tags].filter(Boolean).slice(0, 8); +} + +export function getPlayerBuildDamageBreakdown< + TState extends RuntimeGameStateLike, + TItem extends RuntimeInventoryItemLike, +>(state: TState, character: RuntimeCharacterLike) { + const tags = collectBuildTags(state, character); + const rows = tags.map((tag, index) => { + const bonusDelta = roundNumber(0.03 + Math.min(index, 3) * 0.01, 4); + return { + label: tag, + source: index === 0 ? 'buff' : 'weapon', + fitScore: roundNumber(0.6 + Math.max(0, 3 - index) * 0.08, 4), + sourceCoefficient: 1, + bonusDelta, + attributeSimilarities: {}, + attributeWeights: {}, + attributeContributions: {}, + attributeModifierDeltas: {}, + } satisfies BuildContributionRow; + }); + + const buildDamageBonus = roundNumber( + clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, 0.6), + 4, + ); + + return { + tags, + baseTagCount: tags.length, + buildDamageBonus, + buildDamageMultiplier: roundNumber(1 + buildDamageBonus, 4), + rows, + } satisfies BuildDamageBreakdown; +} + +export function resolvePlayerOutgoingDamageResult< + TState extends RuntimeGameStateLike, + TItem extends RuntimeInventoryItemLike, +>( + state: TState, + character: RuntimeCharacterLike, + baseDamage: number, + functionMultiplier = 1, + critRollSeed?: string, +) { + const buildBreakdown = getPlayerBuildDamageBreakdown(state, character); + const equipmentBonuses = getEquipmentBonuses(state.playerEquipment); + const attackPowerMultiplier = roundNumber( + 1 + + (character.attributes.strength * 0.01 + + character.attributes.agility * 0.006 + + character.attributes.spirit * 0.004), + 4, + ); + const critChance = roundNumber( + clamp(0.08 + character.attributes.agility * 0.01, 0.08, 0.45), + 4, + ); + const critDamageMultiplier = roundNumber( + 1.45 + character.attributes.strength * 0.01, + 4, + ); + const roll = critRollSeed ? (hashSeed(critRollSeed) % 1000) / 1000 : 1; + const isCritical = roll < critChance; + + const damage = Math.max( + 1, + Math.round( + baseDamage * + functionMultiplier * + equipmentBonuses.outgoingDamageMultiplier * + buildBreakdown.buildDamageMultiplier * + attackPowerMultiplier * + (isCritical ? critDamageMultiplier : 1), + ), + ); + + return { + damage, + isCritical, + critChance, + critDamageMultiplier, + attackPowerMultiplier, + } satisfies OutgoingDamageResult; +} diff --git a/server-node/src/modules/runtime/runtimeEconomyPrimitives.test.ts b/server-node/src/modules/runtime/runtimeEconomyPrimitives.test.ts new file mode 100644 index 00000000..b9d69388 --- /dev/null +++ b/server-node/src/modules/runtime/runtimeEconomyPrimitives.test.ts @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + formatCurrency, + getCurrencyName, + getInventoryItemValue, + getNpcBuybackPrice, + getNpcPurchasePrice, +} from './runtimeEconomyPrimitives.js'; +import { buildTreasureResultText } from './runtimeTreasureTexts.js'; + +test('runtime economy primitives calculate trade prices on the server without src/data/economy', () => { + const item = { + category: '专属物品', + name: '青铜令牌', + rarity: 'epic' as const, + tags: ['relic'], + }; + + assert.equal(getCurrencyName('WUXIA'), '铜钱'); + assert.equal(getCurrencyName('XIANXIA'), '灵石'); + assert.equal(formatCurrency(48, 'WUXIA'), '48 铜钱'); + assert.equal(getInventoryItemValue(item), 118); + assert.equal(getNpcPurchasePrice(item, 0), 118); + assert.equal(getNpcPurchasePrice(item, 65), 99); + assert.equal(getNpcBuybackPrice(item, 95), 68); +}); + +test('runtime treasure text uses server-side currency formatting and reward summaries', () => { + const text = buildTreasureResultText( + { + npcName: '古旧木匣', + }, + 'inspect', + { + items: [{ name: '残卷' }, { name: '灵药' }], + hp: 10, + mana: 12, + currency: 34, + storyHint: '你察觉这批东西与当前线索彼此呼应。', + }, + 'XIANXIA', + ); + + assert.match(text, /34 灵石/); + assert.match(text, /残卷、灵药/); + assert.match(text, /气血 \+10/); + assert.match(text, /灵力 \+12/); +}); diff --git a/server-node/src/modules/runtime/runtimeEconomyPrimitives.ts b/server-node/src/modules/runtime/runtimeEconomyPrimitives.ts new file mode 100644 index 00000000..01c3a3de --- /dev/null +++ b/server-node/src/modules/runtime/runtimeEconomyPrimitives.ts @@ -0,0 +1,75 @@ +type RuntimeInventoryItemLike = { + category: string; + name: string; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + tags: string[]; + value?: number; +}; + +const RARITY_BASE_VALUES: Record = { + common: 12, + uncommon: 24, + rare: 48, + epic: 92, + legendary: 168, +}; + +export function getCurrencyName(worldType: string | null | undefined) { + if (worldType === 'XIANXIA') { + return '灵石'; + } + if (worldType === 'WUXIA') { + return '铜钱'; + } + return '钱币'; +} + +export function formatCurrency( + value: number, + worldType: string | null | undefined, +) { + return `${value} ${getCurrencyName(worldType)}`; +} + +export function getDiscountTierForAffinity(affinity: number) { + if (affinity >= 90) return 3; + if (affinity >= 60) return 2; + if (affinity >= 30) return 1; + return 0; +} + +export function getInventoryItemValue(item: RuntimeInventoryItemLike) { + if (typeof item.value === 'number' && Number.isFinite(item.value)) { + return Math.max(8, Math.round(item.value)); + } + + let value = RARITY_BASE_VALUES[item.rarity]; + + if (item.tags.includes('weapon')) value += 14; + if (item.tags.includes('armor')) value += 12; + if (item.tags.includes('relic')) value += 16; + if (item.tags.includes('mana')) value += 8; + if (item.tags.includes('healing')) value += 8; + if (item.tags.includes('material')) value += 4; + if (item.category.includes('专属')) value += 10; + + return Math.max(8, value); +} + +export function getNpcPurchasePrice( + item: RuntimeInventoryItemLike, + affinity: number, +) { + const discountTier = getDiscountTierForAffinity(affinity); + const discountMultiplier = 1 - discountTier * 0.08; + return Math.max(6, Math.round(getInventoryItemValue(item) * discountMultiplier)); +} + +export function getNpcBuybackPrice( + item: RuntimeInventoryItemLike, + affinity: number, +) { + const discountTier = getDiscountTierForAffinity(affinity); + const buybackMultiplier = 0.4 + discountTier * 0.06; + return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier)); +} diff --git a/server-node/src/modules/runtime/runtimeEquipmentModule.ts b/server-node/src/modules/runtime/runtimeEquipmentModule.ts new file mode 100644 index 00000000..f683ec63 --- /dev/null +++ b/server-node/src/modules/runtime/runtimeEquipmentModule.ts @@ -0,0 +1,211 @@ +type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + +type RuntimeInventoryItemLike = { + id: string; + category: string; + name: string; + quantity: number; + rarity: ItemRarity; + tags: string[]; + equipmentSlotId?: RuntimeEquipmentSlotId; + statProfile?: { + maxHpBonus?: number; + maxManaBonus?: number; + outgoingDamageBonus?: number; + incomingDamageMultiplier?: number; + }; + buildProfile?: { + role: string; + tags: string[]; + synergy: string[]; + forgeRank: number; + }; +}; + +export type RuntimeEquipmentSlotId = 'weapon' | 'armor' | 'relic'; + +export type RuntimeEquipmentLoadout = { + weapon: TItem | null; + armor: TItem | null; + relic: TItem | null; +}; + +export type EquipmentBonuses = { + maxHpBonus: number; + maxManaBonus: number; + outgoingDamageMultiplier: number; + incomingDamageMultiplier: number; +}; + +const EQUIPMENT_SLOTS: RuntimeEquipmentSlotId[] = ['weapon', 'armor', 'relic']; + +const WEAPON_DAMAGE_BONUS: Record = { + common: 0.06, + uncommon: 0.1, + rare: 0.14, + epic: 0.2, + legendary: 0.28, +}; + +const ARMOR_HP_BONUS: Record = { + common: 14, + uncommon: 22, + rare: 32, + epic: 44, + legendary: 58, +}; + +const ARMOR_DAMAGE_MULTIPLIER: Record = { + common: 0.97, + uncommon: 0.94, + rare: 0.9, + epic: 0.86, + legendary: 0.8, +}; + +const RELIC_MANA_BONUS: Record = { + common: 10, + uncommon: 18, + rare: 28, + epic: 40, + legendary: 54, +}; + +const RELIC_DAMAGE_BONUS: Record = { + common: 0.02, + uncommon: 0.04, + rare: 0.06, + epic: 0.09, + legendary: 0.12, +}; + +export function createEmptyEquipmentLoadout(): RuntimeEquipmentLoadout { + return { + weapon: null, + armor: null, + relic: null, + }; +} + +export function getEquipmentSlotLabel(slot: RuntimeEquipmentSlotId) { + return { + weapon: '武器', + armor: '护甲', + relic: '饰品', + }[slot]; +} + +function inferSlotFromText(value: string) { + if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) return 'weapon' as const; + if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) return 'armor' as const; + if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) return 'relic' as const; + return null; +} + +export function getEquipmentSlotFromItem( + item: RuntimeInventoryItemLike, +): RuntimeEquipmentSlotId | null { + if (item.equipmentSlotId) return item.equipmentSlotId; + if (item.tags.includes('weapon')) return 'weapon'; + if (item.tags.includes('armor')) return 'armor'; + if (item.tags.includes('relic')) return 'relic'; + + return inferSlotFromText(`${item.category} ${item.name}`); +} + +function getFallbackBonusesForItem(slot: RuntimeEquipmentSlotId, rarity: ItemRarity) { + if (slot === 'weapon') { + return { + maxHpBonus: 0, + maxManaBonus: 0, + outgoingDamageBonus: WEAPON_DAMAGE_BONUS[rarity], + incomingDamageMultiplier: 1, + }; + } + + if (slot === 'armor') { + return { + maxHpBonus: ARMOR_HP_BONUS[rarity], + maxManaBonus: 0, + outgoingDamageBonus: 0, + incomingDamageMultiplier: ARMOR_DAMAGE_MULTIPLIER[rarity], + }; + } + + return { + maxHpBonus: 0, + maxManaBonus: RELIC_MANA_BONUS[rarity], + outgoingDamageBonus: RELIC_DAMAGE_BONUS[rarity], + incomingDamageMultiplier: 1, + }; +} + +function getItemEquipmentBonuses( + item: RuntimeInventoryItemLike, + slot: RuntimeEquipmentSlotId, +) { + const fallback = getFallbackBonusesForItem(slot, item.rarity); + + return { + maxHpBonus: item.statProfile?.maxHpBonus ?? fallback.maxHpBonus, + maxManaBonus: item.statProfile?.maxManaBonus ?? fallback.maxManaBonus, + outgoingDamageBonus: + item.statProfile?.outgoingDamageBonus ?? fallback.outgoingDamageBonus, + incomingDamageMultiplier: + item.statProfile?.incomingDamageMultiplier ?? fallback.incomingDamageMultiplier, + }; +} + +export function getEquipmentBonuses( + loadout: RuntimeEquipmentLoadout, +): EquipmentBonuses { + let maxHpBonus = 0; + let maxManaBonus = 0; + let outgoingDamageBonus = 0; + let incomingDamageMultiplier = 1; + + EQUIPMENT_SLOTS.forEach((slot) => { + const item = loadout[slot]; + if (!item) return; + + const itemBonuses = getItemEquipmentBonuses(item, slot); + maxHpBonus += itemBonuses.maxHpBonus; + maxManaBonus += itemBonuses.maxManaBonus; + outgoingDamageBonus += itemBonuses.outgoingDamageBonus; + incomingDamageMultiplier *= itemBonuses.incomingDamageMultiplier; + }); + + return { + maxHpBonus, + maxManaBonus, + outgoingDamageMultiplier: Number((1 + outgoingDamageBonus).toFixed(4)), + incomingDamageMultiplier: Number(incomingDamageMultiplier.toFixed(4)), + }; +} + +export function applyEquipmentLoadoutToState< + TState extends { + playerMaxHp: number; + playerHp: number; + playerMaxMana: number; + playerMana: number; + playerEquipment: RuntimeEquipmentLoadout; + }, + TItem extends RuntimeInventoryItemLike, +>(state: TState, nextEquipment: RuntimeEquipmentLoadout) { + const previousBonuses = getEquipmentBonuses(state.playerEquipment); + const nextBonuses = getEquipmentBonuses(nextEquipment); + const baseMaxHp = Math.max(1, state.playerMaxHp - previousBonuses.maxHpBonus); + const baseMaxMana = Math.max(1, state.playerMaxMana - previousBonuses.maxManaBonus); + const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus; + const nextMaxMana = baseMaxMana + nextBonuses.maxManaBonus; + + return { + ...state, + playerMaxHp: nextMaxHp, + playerHp: Math.min(nextMaxHp, state.playerHp), + playerMaxMana: nextMaxMana, + playerMana: nextMaxMana, + playerEquipment: nextEquipment, + }; +} diff --git a/server-node/src/modules/runtime/runtimeForgeModule.ts b/server-node/src/modules/runtime/runtimeForgeModule.ts new file mode 100644 index 00000000..a8817994 --- /dev/null +++ b/server-node/src/modules/runtime/runtimeForgeModule.ts @@ -0,0 +1,468 @@ +import { formatCurrency } from './runtimeEconomyPrimitives.js'; +import { + addInventoryItems, + removeInventoryItem, +} from './runtimeStatePrimitives.js'; +import { + getEquipmentSlotFromItem, + getEquipmentSlotLabel, + type RuntimeEquipmentSlotId, +} from './runtimeEquipmentModule.js'; + +type RuntimeInventoryItemLike = { + id: string; + category: string; + name: string; + quantity: number; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + tags: string[]; + equipmentSlotId?: RuntimeEquipmentSlotId; + statProfile?: { + maxHpBonus?: number; + maxManaBonus?: number; + outgoingDamageBonus?: number; + incomingDamageMultiplier?: number; + }; + buildProfile?: { + role: string; + tags: string[]; + synergy: string[]; + forgeRank: number; + }; +}; + +type ForgeRequirement = { + id: string; + label: string; + quantity: number; + matches: (item: TItem) => boolean; +}; + +type ForgeRecipeDefinition = { + id: string; + name: string; + kind: 'synthesis' | 'forge'; + description: string; + resultLabel: string; + currencyCost: number; + requirements: ForgeRequirement[]; + createResult: (worldType: string | null | undefined) => TItem; +}; + +function createItemId(prefix: string) { + return `${prefix}:${Date.now().toString(36)}:${Math.random() + .toString(36) + .slice(2, 8)}`; +} + +function normalizeBuildTags(tags: string[]) { + return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; +} + +function buildMaterialItem( + name: string, + quantity: number, + tags: string[], + rarity: RuntimeInventoryItemLike['rarity'] = 'uncommon', + description?: string, +) { + return { + id: createItemId(`forge-material:${name}`), + category: '材料', + name, + quantity: Math.max(1, Math.floor(quantity)), + rarity, + tags: ['material', ...normalizeBuildTags(tags)], + description, + buildProfile: { + role: '工巧', + tags: normalizeBuildTags(tags), + synergy: normalizeBuildTags(tags), + forgeRank: 0, + }, + } satisfies RuntimeInventoryItemLike; +} + +function buildEquipmentItem(params: { + name: string; + slot: RuntimeEquipmentSlotId; + rarity: RuntimeInventoryItemLike['rarity']; + description: string; + role: string; + tags: string[]; + synergy: string[]; + statProfile: NonNullable; +}) { + return { + id: createItemId(`forge-equip:${params.name}`), + category: getEquipmentSlotLabel(params.slot), + name: params.name, + quantity: 1, + rarity: params.rarity, + tags: [ + params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic', + ...normalizeBuildTags(params.tags), + ], + description: params.description, + equipmentSlotId: params.slot, + statProfile: params.statProfile, + buildProfile: { + role: params.role, + tags: normalizeBuildTags(params.tags), + synergy: normalizeBuildTags(params.synergy), + forgeRank: 1, + }, + } satisfies RuntimeInventoryItemLike; +} + +function buildNamedMaterialRequirement( + name: string, + quantity: number, +): ForgeRequirement { + return { + id: `name:${name}`, + label: name, + quantity, + matches: (item) => item.name === name, + }; +} + +function buildAnyMaterialRequirement( + id: string, + label: string, + quantity: number, +): ForgeRequirement { + return { + id, + label, + quantity, + matches: (item) => item.tags.includes('material') || item.category.includes('材料'), + }; +} + +function buildForgeRecipes() { + return [ + { + id: 'synthesis-refined-ingot', + name: '压炼锭材', + kind: 'synthesis', + description: '把零散残片和基础材料压成稳定可用的金属锭材。', + resultLabel: '精炼锭材', + currencyCost: 18, + requirements: [buildAnyMaterialRequirement('material:any', '任意材料', 3)], + createResult: () => + buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare') as TItem, + }, + { + id: 'forge-duelist-blade', + name: '锻造 百炼追风剑', + kind: 'forge', + description: '围绕快剑、突进、追击构筑的轻灵主武器。', + resultLabel: '百炼追风剑', + currencyCost: 72, + requirements: [ + buildNamedMaterialRequirement('精炼锭材', 2), + buildNamedMaterialRequirement('快剑精粹', 1), + ], + createResult: () => + buildEquipmentItem({ + name: '百炼追风剑', + slot: 'weapon', + rarity: 'epic', + description: '为快剑与追身构筑准备的锻造兵刃。', + role: '快剑', + tags: ['快剑', '突进', '追击'], + synergy: ['快剑', '突进', '追击'], + statProfile: { + maxManaBonus: 10, + outgoingDamageBonus: 0.2, + }, + }) as TItem, + }, + ] satisfies ForgeRecipeDefinition[]; +} + +type ForgeRecipeView = { + id: string; + name: string; + kind: 'synthesis' | 'forge'; + description: string; + resultLabel: string; + currencyCost: number; + currencyText: string; + requirements: Array<{ + id: string; + label: string; + quantity: number; + owned: number; + }>; + canCraft: boolean; +}; + +function countMatchingItems( + inventory: TItem[], + requirement: ForgeRequirement, +) { + return inventory + .filter((item) => requirement.matches(item)) + .reduce((sum, item) => sum + item.quantity, 0); +} + +function consumeRequirement( + inventory: TItem[], + requirement: ForgeRequirement, +) { + let remaining = requirement.quantity; + let nextInventory = [...inventory]; + + for (const item of inventory) { + if (remaining <= 0) break; + if (!requirement.matches(item)) continue; + + const consumed = Math.min(item.quantity, remaining); + nextInventory = removeInventoryItem(nextInventory, item.id, consumed); + remaining -= consumed; + } + + return remaining === 0 ? nextInventory : null; +} + +function applyRequirementsIfPossible( + inventory: TItem[], + requirements: ForgeRequirement[], +) { + let nextInventory = [...inventory]; + for (const requirement of requirements) { + const consumedInventory = consumeRequirement(nextInventory, requirement); + if (!consumedInventory) return null; + nextInventory = consumedInventory; + } + return nextInventory; +} + +function buildTagEssence(tag: string) { + return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare'); +} + +function buildDismantleBaseMaterials( + item: RuntimeInventoryItemLike, + slot: RuntimeEquipmentSlotId | null, +) { + const rarityScale: Record = { + common: 1, + uncommon: 2, + rare: 3, + epic: 4, + legendary: 5, + }; + + const amount = rarityScale[item.rarity]; + if (slot === 'weapon') { + return [buildMaterialItem('武器残片', amount, ['工巧', '重击'])]; + } + if (slot === 'armor') { + return [buildMaterialItem('甲片', amount, ['工巧', '守御'])]; + } + if (slot === 'relic') { + return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'])]; + } + + return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'])]; +} + +function buildDismantleEssences(item: RuntimeInventoryItemLike) { + const buildTags = normalizeBuildTags([ + ...(item.buildProfile?.tags ?? []), + item.buildProfile?.role ?? '', + ]).slice(0, item.rarity === 'legendary' ? 3 : 2); + + return buildTags.map((tag) => buildTagEssence(tag)); +} + +function getReforgeCost( + slot: RuntimeEquipmentSlotId | null, +) { + if (slot === 'relic') { + return { + requirements: [buildNamedMaterialRequirement('凝光纱', 1)], + currencyCost: 52, + }; + } + + return { + requirements: [buildNamedMaterialRequirement('精炼锭材', 1)], + currencyCost: 46, + }; +} + +function buildReforgedItem(item: RuntimeInventoryItemLike) { + const slot = getEquipmentSlotFromItem(item); + if (!slot || !item.buildProfile) return null; + + const nextTags = normalizeBuildTags([ + ...item.buildProfile.tags, + slot === 'weapon' ? '追击' : slot === 'armor' ? '护体' : '法力', + ]).slice(0, 3); + + return { + ...item, + id: createItemId(`reforge:${item.name}`), + name: item.name.includes('重铸') ? item.name : `${item.name}·重铸`, + statProfile: { + ...item.statProfile, + maxHpBonus: (item.statProfile?.maxHpBonus ?? 0) + (slot === 'armor' ? 10 : 4), + maxManaBonus: (item.statProfile?.maxManaBonus ?? 0) + (slot === 'relic' ? 10 : 4), + outgoingDamageBonus: Number( + (((item.statProfile?.outgoingDamageBonus ?? 0) + 0.03)).toFixed(3), + ), + incomingDamageMultiplier: + typeof item.statProfile?.incomingDamageMultiplier === 'number' + ? Number(Math.max(0.72, item.statProfile.incomingDamageMultiplier - 0.03).toFixed(3)) + : slot === 'armor' + ? 0.94 + : 0.97, + }, + buildProfile: { + ...item.buildProfile, + tags: nextTags, + synergy: nextTags, + forgeRank: (item.buildProfile.forgeRank ?? 0) + 1, + }, + } satisfies RuntimeInventoryItemLike; +} + +export function getForgeRecipeViews( + inventory: TItem[], + playerCurrency = 0, + worldType: string | null | undefined = null, +) { + return buildForgeRecipes().map((recipe) => ({ + id: recipe.id, + name: recipe.name, + kind: recipe.kind, + description: recipe.description, + resultLabel: recipe.resultLabel, + currencyCost: recipe.currencyCost, + currencyText: formatCurrency(recipe.currencyCost, worldType), + requirements: recipe.requirements.map((requirement) => ({ + id: requirement.id, + label: requirement.label, + quantity: requirement.quantity, + owned: countMatchingItems(inventory, requirement), + })), + canCraft: + playerCurrency >= recipe.currencyCost && + recipe.requirements.every( + (requirement) => countMatchingItems(inventory, requirement) >= requirement.quantity, + ), + })) satisfies ForgeRecipeView[]; +} + +export function executeForgeRecipe( + inventory: TItem[], + recipeId: string, + worldType: string | null | undefined, + playerCurrency: number, +) { + const recipe = buildForgeRecipes().find((candidate) => candidate.id === recipeId); + if (!recipe || playerCurrency < recipe.currencyCost) return null; + + const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements); + if (!consumedInventory) return null; + + const createdItem = recipe.createResult(worldType); + return { + inventory: addInventoryItems(consumedInventory, [createdItem]), + currency: playerCurrency - recipe.currencyCost, + createdItem, + }; +} + +export function executeDismantleItem( + inventory: TItem[], + itemId: string, +) { + const targetItem = inventory.find((item) => item.id === itemId); + if (!targetItem || targetItem.quantity <= 0) return null; + + const slot = getEquipmentSlotFromItem(targetItem); + if (!slot && !targetItem.buildProfile) return null; + + const outputs = [ + ...buildDismantleBaseMaterials(targetItem, slot), + ...buildDismantleEssences(targetItem), + ] as TItem[]; + + return { + inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs), + outputs, + }; +} + +export function executeReforgeItem( + inventory: TItem[], + itemId: string, + playerCurrency: number, +) { + const targetItem = inventory.find((item) => item.id === itemId); + if (!targetItem || targetItem.quantity <= 0) return null; + + const slot = getEquipmentSlotFromItem(targetItem); + const reforgedItem = buildReforgedItem(targetItem) as TItem | null; + const reforgeCost = getReforgeCost(slot); + if (!reforgedItem || playerCurrency < reforgeCost.currencyCost) return null; + + const consumedInventory = applyRequirementsIfPossible( + removeInventoryItem(inventory, itemId, 1), + reforgeCost.requirements, + ); + if (!consumedInventory) return null; + + return { + inventory: addInventoryItems(consumedInventory, [reforgedItem]), + reforgedItem, + currencyCost: reforgeCost.currencyCost, + }; +} + +export function getReforgeCostView( + item: TItem, + worldType: string | null | undefined, +) { + const slot = getEquipmentSlotFromItem(item); + const cost = getReforgeCost(slot); + return { + currencyCost: cost.currencyCost, + currencyText: formatCurrency(cost.currencyCost, worldType), + requirements: cost.requirements.map((requirement) => ({ + id: requirement.id, + label: requirement.label, + quantity: requirement.quantity, + })), + }; +} + +export function buildForgeSuccessText( + action: 'craft' | 'dismantle' | 'reforge', + params: { + sourceItemName?: string; + recipeName?: string; + createdItemName?: string; + outputNames?: string[]; + currencyText?: string; + }, +) { + if (action === 'craft') { + return `你在工坊中完成了${params.recipeName},获得了${params.createdItemName}${ + params.currencyText ? `,并支付了${params.currencyText}` : '' + }。`; + } + + if (action === 'reforge') { + return `你消耗材料重新淬炼了${params.sourceItemName},最终得到${params.createdItemName}${ + params.currencyText ? `,并支付了${params.currencyText}` : '' + }。`; + } + + return `你拆解了${params.sourceItemName},回收出${(params.outputNames ?? []).join('、')}。`; +} diff --git a/server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts b/server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts new file mode 100644 index 00000000..d39a048a --- /dev/null +++ b/server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts @@ -0,0 +1,130 @@ +type RuntimeCharacterLike = { + attributes: { + strength: number; + agility: number; + intelligence: number; + spirit: number; + }; +}; + +type RuntimeBuildBuff = { + id: string; + sourceType: 'item'; + sourceId: string; + name: string; + tags: string[]; + durationTurns: number; +}; + +type RuntimeInventoryItemLike = { + name: string; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + tags: string[]; + useProfile?: { + hpRestore?: number; + manaRestore?: number; + cooldownReduction?: number; + buildBuffs?: RuntimeBuildBuff[]; + }; +}; + +export type InventoryUseEffect = { + hpRestore: number; + manaRestore: number; + cooldownReduction: number; + buildBuffs: RuntimeBuildBuff[]; +}; + +function getRarityMultiplier(rarity: RuntimeInventoryItemLike['rarity']) { + switch (rarity) { + case 'legendary': + return 2.4; + case 'epic': + return 1.9; + case 'rare': + return 1.55; + case 'uncommon': + return 1.2; + default: + return 1; + } +} + +export function isInventoryItemUsable(item: RuntimeInventoryItemLike) { + return ( + Boolean(item.useProfile) || + item.tags.includes('healing') || + item.tags.includes('mana') + ); +} + +export function resolveInventoryItemUseEffect( + item: RuntimeInventoryItemLike, + character: RuntimeCharacterLike, +): InventoryUseEffect | null { + if (!isInventoryItemUsable(item)) return null; + + if (item.useProfile) { + return { + hpRestore: item.useProfile.hpRestore ?? 0, + manaRestore: item.useProfile.manaRestore ?? 0, + cooldownReduction: item.useProfile.cooldownReduction ?? 0, + buildBuffs: item.useProfile.buildBuffs ?? [], + }; + } + + const rarityMultiplier = getRarityMultiplier(item.rarity); + const hasHealing = + item.tags.includes('healing') || + /药|包|补给|恢复|疗伤|meat|apple|mushroom|water/i.test(item.name); + const hasMana = + item.tags.includes('mana') || + /灵液|法力|mana|crystal|essence|spirit/i.test(item.name); + + const hpRestore = hasHealing + ? Math.max( + 10, + Math.round((14 + character.attributes.spirit * 1.4) * rarityMultiplier), + ) + : 0; + const manaRestore = hasMana + ? Math.max( + 8, + Math.round( + (12 + character.attributes.intelligence * 1.4) * rarityMultiplier, + ), + ) + : 0; + const cooldownReduction = /凝神|回气|醒神|booster|essence/i.test(item.name) + ? 1 + : 0; + + if (hpRestore <= 0 && manaRestore <= 0 && cooldownReduction <= 0) { + return null; + } + + return { + hpRestore, + manaRestore, + cooldownReduction, + buildBuffs: [], + }; +} + +export function buildInventoryUseResultText( + item: RuntimeInventoryItemLike, + effect: InventoryUseEffect, +) { + const parts = [ + effect.hpRestore > 0 ? `恢复 ${effect.hpRestore} 点气血` : null, + effect.manaRestore > 0 ? `恢复 ${effect.manaRestore} 点灵力` : null, + effect.cooldownReduction > 0 + ? `额外推进 ${effect.cooldownReduction} 回合冷却` + : null, + effect.buildBuffs.length > 0 + ? `获得 ${effect.buildBuffs.map((buff) => buff.name).join('、')}` + : null, + ].filter(Boolean); + + return `你取出${item.name}立刻使用,${parts.join(',')}。`; +} diff --git a/server-node/src/modules/runtime/runtimeNarrativeMemory.ts b/server-node/src/modules/runtime/runtimeNarrativeMemory.ts new file mode 100644 index 00000000..c7560d0a --- /dev/null +++ b/server-node/src/modules/runtime/runtimeNarrativeMemory.ts @@ -0,0 +1,88 @@ +function dedupeStrings(values: Array, limit = 16) { + return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))] + .slice(-limit); +} + +type RuntimeStoryFingerprint = { + relatedScarIds?: string[]; + relatedThreadIds?: string[]; + visibleClue?: string | null; +}; + +type RuntimeInventoryItemLike = { + id: string; + runtimeMetadata?: { + storyFingerprint?: RuntimeStoryFingerprint | null; + } | null; +}; + +type RuntimeStoryEngineMemoryLike = { + discoveredFactIds: string[]; + inferredFactIds?: string[]; + activeThreadIds: string[]; + resolvedScarIds: string[]; + recentCarrierIds: string[]; +}; + +type RuntimeGameStateLike = { + storyEngineMemory?: RuntimeStoryEngineMemoryLike | null; +}; + +function createEmptyStoryEngineMemoryState(): RuntimeStoryEngineMemoryLike { + return { + discoveredFactIds: [], + activeThreadIds: [], + resolvedScarIds: [], + recentCarrierIds: [], + }; +} + +export function appendStoryEngineCarrierMemory< + TState extends RuntimeGameStateLike, + TItem extends RuntimeInventoryItemLike, +>(state: TState, items: TItem[]) { + const storyEngineMemory = + state.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); + const carriers = items.filter((item) => item.runtimeMetadata?.storyFingerprint); + if (carriers.length <= 0) { + return { + ...state, + storyEngineMemory, + }; + } + + const recentCarrierIds = dedupeStrings( + [...storyEngineMemory.recentCarrierIds, ...carriers.map((item) => item.id)], + 8, + ); + const scarIds = carriers.flatMap( + (item) => item.runtimeMetadata?.storyFingerprint?.relatedScarIds ?? [], + ); + const threadIds = carriers.flatMap( + (item) => item.runtimeMetadata?.storyFingerprint?.relatedThreadIds ?? [], + ); + const visibleClues = carriers.flatMap((item) => { + const clue = item.runtimeMetadata?.storyFingerprint?.visibleClue; + return clue ? [clue] : []; + }); + + return { + ...state, + storyEngineMemory: { + ...storyEngineMemory, + recentCarrierIds, + resolvedScarIds: dedupeStrings( + [...storyEngineMemory.resolvedScarIds, ...scarIds], + 10, + ), + activeThreadIds: dedupeStrings( + [...storyEngineMemory.activeThreadIds, ...threadIds], + 8, + ), + discoveredFactIds: dedupeStrings( + [...storyEngineMemory.discoveredFactIds, ...visibleClues], + 24, + ), + }, + }; +} diff --git a/server-node/src/modules/runtime/runtimeNpcStatePrimitives.test.ts b/server-node/src/modules/runtime/runtimeNpcStatePrimitives.test.ts new file mode 100644 index 00000000..d0e22424 --- /dev/null +++ b/server-node/src/modules/runtime/runtimeNpcStatePrimitives.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + markNpcFirstMeaningfulContactResolved, + normalizeNpcPersistentState, +} from './runtimeNpcStatePrimitives.js'; +import { appendStoryEngineCarrierMemory } from './runtimeNarrativeMemory.js'; + +test('runtime npc state primitives normalize arrays, relation state and stance defaults on the server', () => { + const normalized = normalizeNpcPersistentState({ + affinity: 18, + recruited: false, + revealedFacts: ['thread:a', 1, null], + knownAttributeRumors: ['力量偏盛', false], + seenBackstoryChapterIds: ['past-1', 2], + stanceProfile: null, + }); + + assert.equal(normalized.relationState.stance, 'neutral'); + assert.deepEqual(normalized.revealedFacts, ['thread:a']); + assert.deepEqual(normalized.knownAttributeRumors, ['力量偏盛']); + assert.deepEqual(normalized.seenBackstoryChapterIds, ['past-1']); + assert.equal(normalized.firstMeaningfulContactResolved, false); + assert.equal(normalized.stanceProfile.currentConflictTag, null); +}); + +test('runtime npc state primitives can mark first meaningful contact as resolved locally on the server', () => { + const nextState = markNpcFirstMeaningfulContactResolved({ + affinity: 64, + recruited: false, + revealedFacts: [], + knownAttributeRumors: [], + seenBackstoryChapterIds: [], + firstMeaningfulContactResolved: false, + }); + + assert.equal(nextState.firstMeaningfulContactResolved, true); + assert.equal(nextState.relationState.stance, 'bonded'); +}); + +test('runtime narrative memory appends carrier facts without depending on src/services/storyEngine/echoMemory', () => { + const nextState = appendStoryEngineCarrierMemory( + { + storyEngineMemory: { + discoveredFactIds: ['clue:old'], + activeThreadIds: ['thread:old'], + resolvedScarIds: [], + recentCarrierIds: [], + }, + }, + [ + { + id: 'carrier-1', + runtimeMetadata: { + storyFingerprint: { + relatedScarIds: ['scar:one'], + relatedThreadIds: ['thread:new'], + visibleClue: 'clue:new', + }, + }, + }, + ], + ); + + assert.deepEqual(nextState.storyEngineMemory.recentCarrierIds, ['carrier-1']); + assert.deepEqual(nextState.storyEngineMemory.resolvedScarIds, ['scar:one']); + assert.deepEqual(nextState.storyEngineMemory.activeThreadIds, [ + 'thread:old', + 'thread:new', + ]); + assert.deepEqual(nextState.storyEngineMemory.discoveredFactIds, [ + 'clue:old', + 'clue:new', + ]); +}); diff --git a/server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts b/server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts new file mode 100644 index 00000000..26ccbb2b --- /dev/null +++ b/server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts @@ -0,0 +1,117 @@ +import { buildRelationState } from './runtimeStatePrimitives.js'; + +type RuntimeNpcStanceProfile = { + trust?: number; + warmth?: number; + ideologicalFit?: number; + fearOrGuard?: number; + loyalty?: number; + currentConflictTag?: string | null; + recentApprovals?: unknown; + recentDisapprovals?: unknown; +}; + +type RuntimeNpcPersistentStateLike = { + affinity: number; + recruited?: boolean; + relationState?: unknown; + revealedFacts?: unknown; + knownAttributeRumors?: unknown; + tradeStockSignature?: string | null; + firstMeaningfulContactResolved?: boolean; + seenBackstoryChapterIds?: unknown; + stanceProfile?: RuntimeNpcStanceProfile | null; +}; + +function clampStanceMetric(value: number) { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function normalizeRecentStanceNotes(value: unknown) { + return Array.isArray(value) + ? value + .filter( + (item): item is string => typeof item === 'string' && item.trim().length > 0, + ) + .slice(-3) + : []; +} + +function buildInitialStanceProfile( + affinity: number, + options: { + recruited?: boolean; + } = {}, +) { + const recruitedBonus = options.recruited ? 14 : 0; + + return { + trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus), + warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus), + ideologicalFit: clampStanceMetric(48 + affinity * 0.25), + fearOrGuard: clampStanceMetric(62 - affinity * 0.55), + loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)), + currentConflictTag: null, + recentApprovals: [], + recentDisapprovals: [], + }; +} + +function normalizeStanceProfile( + stanceProfile: RuntimeNpcPersistentStateLike['stanceProfile'], + npcState: RuntimeNpcPersistentStateLike, +) { + if (!stanceProfile) { + return buildInitialStanceProfile(npcState.affinity, { + recruited: npcState.recruited, + }); + } + + return { + trust: clampStanceMetric(stanceProfile.trust ?? 40), + warmth: clampStanceMetric(stanceProfile.warmth ?? 35), + ideologicalFit: clampStanceMetric(stanceProfile.ideologicalFit ?? 45), + fearOrGuard: clampStanceMetric(stanceProfile.fearOrGuard ?? 55), + loyalty: clampStanceMetric(stanceProfile.loyalty ?? 20), + currentConflictTag: stanceProfile.currentConflictTag ?? null, + recentApprovals: normalizeRecentStanceNotes(stanceProfile.recentApprovals), + recentDisapprovals: normalizeRecentStanceNotes(stanceProfile.recentDisapprovals), + }; +} + +export function normalizeNpcPersistentState< + TNpcState extends RuntimeNpcPersistentStateLike, +>(npcState: TNpcState) { + return { + ...npcState, + relationState: buildRelationState(npcState.affinity), + revealedFacts: Array.isArray(npcState.revealedFacts) + ? npcState.revealedFacts.filter( + (fact): fact is string => typeof fact === 'string', + ) + : [], + knownAttributeRumors: Array.isArray(npcState.knownAttributeRumors) + ? npcState.knownAttributeRumors.filter( + (fact): fact is string => typeof fact === 'string', + ) + : [], + tradeStockSignature: npcState.tradeStockSignature ?? null, + firstMeaningfulContactResolved: + npcState.firstMeaningfulContactResolved ?? false, + seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds) + ? npcState.seenBackstoryChapterIds.filter( + (fact): fact is string => typeof fact === 'string', + ) + : [], + stanceProfile: normalizeStanceProfile(npcState.stanceProfile, npcState), + }; +} + +export function markNpcFirstMeaningfulContactResolved< + TNpcState extends RuntimeNpcPersistentStateLike, +>(npcState: TNpcState) { + return normalizeNpcPersistentState({ + ...npcState, + firstMeaningfulContactResolved: true, + }); +} diff --git a/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts b/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts new file mode 100644 index 00000000..23fb56f8 --- /dev/null +++ b/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { hydrateSavedSnapshot } from './runtimeSnapshotHydration.js'; + +test('runtime snapshot hydration normalizes server snapshots for frontend restore flows', () => { + const snapshot = hydrateSavedSnapshot({ + version: 2, + savedAt: '2026-04-09T00:00:00.000Z', + bottomTab: 'unknown-tab', + gameState: { + currentScene: 'Story', + worldType: 'WUXIA', + playerCharacter: { + id: 'hero', + title: '试剑客', + description: '在风里试探局势的人。', + personality: '谨慎而果断', + attributes: { + strength: 8, + spirit: 6, + }, + skills: [{ id: 'skill-1' }], + resourceProfile: { + maxHp: 150, + maxMana: 80, + }, + }, + playerHp: 180, + playerMaxHp: 120, + playerMana: 22, + playerMaxMana: 18, + playerEquipment: { + weapon: null, + armor: { + id: 'armor-1', + category: '护甲', + name: '试炼轻甲', + quantity: 1, + rarity: 'rare', + tags: ['armor'], + statProfile: { + maxHpBonus: 20, + }, + }, + relic: { + id: 'relic-1', + category: '饰品', + name: '回气坠', + quantity: 1, + rarity: 'rare', + tags: ['relic'], + statProfile: { + maxManaBonus: 15, + }, + }, + }, + quests: [ + { + id: 'quest-1', + title: '试炼委托', + summary: '完成一轮测试', + description: '完成一轮测试', + issuerNpcId: 'npc-1', + issuerNpcName: '引路人', + status: 'active', + rewardText: '完成后可领取测试奖励。', + reward: { + currency: 10, + items: [], + }, + steps: [ + { + id: 'quest-1-step-1', + title: '完成一轮测试', + detail: '推进这条测试委托。', + kind: 'reach_scene', + targetSceneId: 'test-scene', + requiredCount: 1, + progress: 0, + }, + ], + }, + ], + roster: [ + { + npcId: 'npc-companion', + characterId: 'companion-a', + joinedAtAffinity: 8, + }, + ], + companions: [ + { + npcId: 'npc-companion', + characterId: 'companion-a', + joinedAtAffinity: 8, + }, + ], + npcStates: { + npc_guard: { + affinity: 12, + revealedFacts: ['fact:a', 1], + }, + }, + characterChats: { + companion_a: { + history: [ + { speaker: 'player', text: '最近风声不对。' }, + { speaker: 'npc', text: '这条不该留下。' }, + ], + summary: '已经建立起初步信任。', + }, + }, + }, + currentStory: { + text: '恢复中的故事', + options: [], + streaming: true, + }, + }); + + assert.ok(snapshot); + assert.equal(snapshot.bottomTab, 'adventure'); + assert.equal(snapshot.currentStory?.streaming, false); + assert.equal(snapshot.gameState.runtimeActionVersion, 0); + assert.equal(snapshot.gameState.playerMaxHp, 170); + assert.equal(snapshot.gameState.playerHp, 170); + assert.equal(snapshot.gameState.playerMaxMana, 95); + assert.equal(snapshot.gameState.playerMana, 22); + assert.equal(snapshot.gameState.playerCurrency, 160); + assert.deepEqual(snapshot.gameState.roster, []); + assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []); + assert.equal( + snapshot.gameState.storyEngineMemory.saveMigrationManifest?.version, + 'story-engine-v5', + ); + assert.deepEqual(snapshot.gameState.npcStates.npc_guard.revealedFacts, [ + 'fact:a', + ]); + assert.deepEqual(snapshot.gameState.characterChats.companion_a.history, [ + { + speaker: 'player', + text: '最近风声不对。', + }, + ]); +}); + +test('runtime snapshot hydration keeps custom world economy defaults on the server', () => { + const snapshot = hydrateSavedSnapshot({ + version: 2, + savedAt: '2026-04-09T00:00:00.000Z', + bottomTab: 'inventory', + gameState: { + worldType: 'CUSTOM', + customWorldProfile: { + ownedSettingLayers: { + ruleProfile: { + economyProfile: { + initialCurrency: 228, + }, + }, + }, + }, + }, + currentStory: null, + }); + + assert.ok(snapshot); + assert.equal(snapshot.bottomTab, 'inventory'); + assert.equal(snapshot.gameState.playerCurrency, 228); +}); + +test('runtime snapshot hydration backfills starter loadout when legacy saves omitted playerEquipment', () => { + const snapshot = hydrateSavedSnapshot({ + version: 2, + savedAt: '2026-04-09T00:00:00.000Z', + bottomTab: 'adventure', + gameState: { + currentScene: 'Story', + worldType: 'WUXIA', + playerCharacter: { + id: 'hero', + title: '试剑客', + description: '在风里试探局势的人。', + personality: '谨慎而果断', + attributes: { + strength: 8, + spirit: 6, + }, + skills: [], + }, + playerHp: 140, + playerMaxHp: 140, + playerMana: 60, + playerMaxMana: 60, + }, + currentStory: null, + }); + + assert.ok(snapshot); + assert.equal(snapshot.gameState.playerMaxHp, 208); + assert.equal(snapshot.gameState.playerMaxMana, 1009); + assert.equal(snapshot.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon'); + assert.equal(snapshot.gameState.playerEquipment.armor?.id, 'starter:hero:armor'); + assert.equal(snapshot.gameState.playerEquipment.relic?.id, 'starter:hero:relic'); +}); diff --git a/server-node/src/modules/runtime/runtimeSnapshotHydration.ts b/server-node/src/modules/runtime/runtimeSnapshotHydration.ts new file mode 100644 index 00000000..583ec99d --- /dev/null +++ b/server-node/src/modules/runtime/runtimeSnapshotHydration.ts @@ -0,0 +1,643 @@ +import { jsonClone } from '../../http.js'; +import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; +import { normalizeQuestEntries } from '../quest/questProgressionService.js'; +import { + createEmptyEquipmentLoadout, + getEquipmentBonuses, + getEquipmentSlotLabel, +} from './runtimeEquipmentModule.js'; +import { normalizeNpcPersistentState } from './runtimeNpcStatePrimitives.js'; + +type JsonRecord = Record; +type SnapshotShape = { + savedAt: string; + bottomTab: unknown; + gameState: unknown; + currentStory: unknown; +}; + +const STORY_ENGINE_MIGRATION_VERSION = 'story-engine-v5'; +const STORY_ENGINE_REQUIRED_TRANSFORMS = [ + 'ensure_story_engine_memory', + 'ensure_campaign_state', + 'ensure_player_style_profile', +]; +const UNIVERSAL_MAX_MANA = 999; +const EQUIPMENT_SLOTS = ['weapon', 'armor', 'relic'] as const; + +type RuntimeEquipmentSlotId = (typeof EQUIPMENT_SLOTS)[number]; +type LegacyCharacterEquipmentItem = { + slot: string; + item: string; + rarity?: string; +}; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown, fallback = '') { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function readNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function readBoolean(value: unknown, fallback = false) { + return typeof value === 'boolean' ? value : fallback; +} + +function readArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function clampNonNegativeInteger(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + + return Math.max(0, Math.floor(value)); +} + +function normalizeBottomTab(value: unknown) { + return value === 'character' || value === 'inventory' + ? value + : 'adventure'; +} + +function buildSaveMigrationManifest() { + return { + version: 'story-engine-v5', + requiredTransforms: [ + 'ensure_story_engine_memory', + 'ensure_campaign_state', + 'ensure_player_style_profile', + ], + backwardCompatible: true, + }; +} + +function createEmptyStoryEngineMemoryState() { + return { + discoveredFactIds: [], + inferredFactIds: [], + activeThreadIds: [], + resolvedScarIds: [], + recentCarrierIds: [], + openedSceneChapterIds: [], + recentSignalIds: [], + recentCompanionReactions: [], + currentChapter: null, + currentJourneyBeatId: null, + currentJourneyBeat: null, + companionArcStates: [], + worldMutations: [], + chronicle: [], + factionTensionStates: [], + currentCampEvent: null, + currentSetpieceDirective: null, + continueGameDigest: null, + campaignState: null, + actState: null, + consequenceLedger: [], + companionResolutions: [], + endingState: null, + authorialConstraintPack: null, + branchBudgetStatus: null, + narrativeQaReport: null, + narrativeCodex: [], + playerStyleProfile: null, + simulationRunResults: [], + releaseGateReport: null, + saveMigrationManifest: { + version: STORY_ENGINE_MIGRATION_VERSION, + requiredTransforms: STORY_ENGINE_REQUIRED_TRANSFORMS, + backwardCompatible: true, + }, + }; +} + +function normalizeRuntimeStats( + stats: unknown, + options: { + isActiveRun?: boolean; + now?: number; + } = {}, +) { + const now = options.now ?? Date.now(); + const rawStats = isRecord(stats) ? stats : {}; + + return { + playTimeMs: + typeof rawStats.playTimeMs === 'number' && + Number.isFinite(rawStats.playTimeMs) + ? Math.max(0, rawStats.playTimeMs) + : 0, + lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null, + hostileNpcsDefeated: clampNonNegativeInteger( + rawStats.hostileNpcsDefeated, + ), + questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted), + itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed), + scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled), + }; +} + +function normalizeCharacterChats(value: unknown) { + return Object.fromEntries( + Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => { + const rawRecord = isRecord(record) ? record : {}; + + return [ + characterId, + { + history: readArray(rawRecord.history) + .filter( + (turn) => + isRecord(turn) && + typeof turn.text === 'string' && + (turn.speaker === 'player' || turn.speaker === 'character'), + ) + .map((turn) => ({ + speaker: turn.speaker, + text: turn.text, + })), + summary: readString(rawRecord.summary), + updatedAt: readString(rawRecord.updatedAt) || null, + }, + ]; + }), + ); +} + +function normalizeCompanionState(value: unknown) { + if (!isRecord(value)) { + return null; + } + + const npcId = readString(value.npcId); + if (!npcId) { + return null; + } + + return { + ...jsonClone(value), + npcId, + characterId: readString(value.characterId), + joinedAtAffinity: Math.round(readNumber(value.joinedAtAffinity, 0)), + }; +} + +function dedupeCompanions(value: unknown) { + const seenNpcIds = new Set(); + + return readArray(value) + .map((entry) => normalizeCompanionState(entry)) + .filter((entry): entry is NonNullable> => { + if (!entry || seenNpcIds.has(entry.npcId)) { + return false; + } + + seenNpcIds.add(entry.npcId); + return true; + }); +} + +function normalizeRoster( + roster: ReturnType, + companions: ReturnType, +) { + const activeNpcIds = new Set(companions.map((companion) => companion.npcId)); + + return roster.filter((companion) => !activeNpcIds.has(companion.npcId)); +} + +function normalizeNpcStates(value: unknown) { + return Object.fromEntries( + Object.entries(isRecord(value) ? value : {}).map(([npcId, npcState]) => { + const rawState = isRecord(npcState) ? npcState : {}; + + return [ + npcId, + normalizeNpcPersistentState({ + ...jsonClone(rawState), + affinity: Math.round(readNumber(rawState.affinity, 0)), + chattedCount: Math.max( + 0, + Math.round(readNumber(rawState.chattedCount, 0)), + ), + helpUsed: readBoolean(rawState.helpUsed), + giftsGiven: Math.max( + 0, + Math.round(readNumber(rawState.giftsGiven, 0)), + ), + inventory: jsonClone(readArray(rawState.inventory)), + recruited: readBoolean(rawState.recruited), + }), + ]; + }), + ); +} + +function resolveInitialPlayerCurrency(gameState: JsonRecord) { + const customWorldProfile = isRecord(gameState.customWorldProfile) + ? gameState.customWorldProfile + : null; + const customWorldInitialCurrency = readNumber( + (customWorldProfile?.ownedSettingLayers as JsonRecord | undefined) + ?.ruleProfile && + isRecord( + (customWorldProfile.ownedSettingLayers as JsonRecord).ruleProfile, + ) && + isRecord( + ( + (customWorldProfile.ownedSettingLayers as JsonRecord) + .ruleProfile as JsonRecord + ).economyProfile, + ) + ? ( + ( + ( + customWorldProfile.ownedSettingLayers as JsonRecord + ).ruleProfile as JsonRecord + ).economyProfile as JsonRecord + ).initialCurrency + : undefined, + Number.NaN, + ); + if (Number.isFinite(customWorldInitialCurrency)) { + return Math.max(0, Math.round(customWorldInitialCurrency)); + } + + return readString(gameState.worldType).toUpperCase() === 'XIANXIA' ? 140 : 160; +} + +function normalizeEquipmentLoadout(value: unknown) { + if (!isRecord(value)) { + return null; + } + + return { + weapon: isRecord(value.weapon) ? jsonClone(value.weapon) : null, + armor: isRecord(value.armor) ? jsonClone(value.armor) : null, + relic: isRecord(value.relic) ? jsonClone(value.relic) : null, + }; +} + +function normalizePresetRarity(rarityText: string | undefined) { + if (!rarityText) return 'common' as const; + if (/传说|legendary/iu.test(rarityText)) return 'legendary' as const; + if (/史诗|epic/iu.test(rarityText)) return 'epic' as const; + if (/稀有|rare/iu.test(rarityText)) return 'rare' as const; + if (/优秀|uncommon/iu.test(rarityText)) return 'uncommon' as const; + return 'common' as const; +} + +function inferEquipmentSlot(value: string) { + if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) { + return 'weapon' as const; + } + if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) { + return 'armor' as const; + } + if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) { + return 'relic' as const; + } + return null; +} + +function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) { + const tags = new Set([slot]); + + if (/灵|气|符|珠|盘|玉/u.test(name)) tags.add('mana'); + if (/护|守|甲|铠/u.test(name)) tags.add('armor'); + if (/刃|剑|弓|刀|拳/u.test(name)) tags.add('weapon'); + if (/徽章|护符|坠|铃|盘|令/u.test(name)) tags.add('relic'); + if (/疗|愈|血/u.test(name)) tags.add('healing'); + + return [...tags]; +} + +function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] { + const equipmentById: Record = { + 'sword-princess': [ + { slot: '武器', item: '王庭剑', rarity: '稀有' }, + { slot: '护甲', item: '王庭轻甲', rarity: '稀有' }, + { slot: '饰品', item: '皇室徽章', rarity: '史诗' }, + ], + 'archer-hero': [ + { slot: '武器', item: '流风弓', rarity: '稀有' }, + { slot: '护甲', item: '风行者皮甲', rarity: '稀有' }, + { slot: '饰品', item: '鹰眼石', rarity: '史诗' }, + ], + 'girl-hero': [ + { slot: '武器', item: '双影刃', rarity: '稀有' }, + { slot: '护甲', item: '疾影轻甲', rarity: '稀有' }, + { slot: '饰品', item: '敏捷徽章', rarity: '史诗' }, + ], + 'punch-hero': [ + { slot: '武器', item: '破军拳套', rarity: '稀有' }, + { slot: '护甲', item: '刚岩护甲', rarity: '稀有' }, + { slot: '饰品', item: '力量护符', rarity: '史诗' }, + ], + 'fighter-4': [ + { slot: '武器', item: '玄甲战刃', rarity: '稀有' }, + { slot: '护甲', item: '玄铁甲', rarity: '稀有' }, + { slot: '饰品', item: '守护徽章', rarity: '史诗' }, + ], + }; + + const characterId = readString(character.id); + if (equipmentById[characterId]) { + return equipmentById[characterId]; + } + + const characterName = readString(character.name, '旅人'); + return EQUIPMENT_SLOTS.map((slot) => ({ + slot: getEquipmentSlotLabel(slot), + item: { + weapon: `${characterName}的主手器`, + armor: `${characterName}的护身装`, + relic: `${characterName}的随身符`, + }[slot], + rarity: '普通', + })); +} + +function buildLegacyStarterEquipmentLoadout(character: JsonRecord) { + const characterId = readString(character.id, 'unknown'); + const loadout = createEmptyEquipmentLoadout(); + const starterEquipment = getLegacyCharacterEquipment(character); + + starterEquipment.forEach((equipmentItem, index) => { + const slot = + inferEquipmentSlot(`${equipmentItem.slot} ${equipmentItem.item}`) ?? + EQUIPMENT_SLOTS[index] ?? + null; + if (!slot || loadout[slot]) { + return; + } + + loadout[slot] = { + id: `starter:${characterId}:${slot}`, + category: getEquipmentSlotLabel(slot), + name: equipmentItem.item, + quantity: 1, + rarity: normalizePresetRarity(equipmentItem.rarity), + tags: inferEquipmentTags(slot, equipmentItem.item), + equipmentSlotId: slot, + }; + }); + + return loadout; +} + +function hasEquippedItems( + equipment: ReturnType, +) { + return Boolean(equipment?.weapon || equipment?.armor || equipment?.relic); +} + +function readCharacterAttributes(character: JsonRecord) { + return isRecord(character.attributes) ? character.attributes : {}; +} + +function getLegacyCharacterBaseMaxHp(character: JsonRecord) { + const attributes = readCharacterAttributes(character); + + return Math.max( + 120, + 90 + + readNumber(attributes.strength, 0) * 10 + + readNumber(attributes.spirit, 0) * 4, + ); +} + +function buildCharacterResourceProfile(character: JsonRecord) { + const resourceProfile = isRecord(character.resourceProfile) + ? character.resourceProfile + : null; + if ( + resourceProfile && + Number.isFinite(resourceProfile.maxHp) && + Number.isFinite(resourceProfile.maxMana) + ) { + return { + maxHp: Math.max(1, Math.round(readNumber(resourceProfile.maxHp, 1))), + maxMana: Math.max( + 1, + Math.round(readNumber(resourceProfile.maxMana, UNIVERSAL_MAX_MANA)), + ), + }; + } + + const source = [ + readString(character.title), + readString(character.description), + readString(character.personality), + ...readArray(character.combatTags).filter( + (tag): tag is string => typeof tag === 'string' && tag.trim().length > 0, + ), + ].join(' '); + const skills = readArray(character.skills); + const baseHp = /守|甲|拳|先锋|重击|护体/u.test(source) + ? 210 + : /远射|机动|快袭|游击/u.test(source) + ? 168 + : /法|符|阵|灵|术/u.test(source) + ? 176 + : 188; + + return { + maxHp: Math.max( + getLegacyCharacterBaseMaxHp(character), + baseHp + Math.min(18, skills.length * 4), + ), + maxMana: UNIVERSAL_MAX_MANA, + }; +} + +function normalizeSavedStory(currentStory: unknown) { + if (!isRecord(currentStory)) { + return null; + } + + return { + ...jsonClone(currentStory), + streaming: false, + }; +} + +function normalizeGameState(gameState: unknown) { + const rawState = isRecord(gameState) ? jsonClone(gameState) : {}; + const { playerEquipment: _rawPlayerEquipment, ...rawStateWithoutEquipment } = + rawState; + const playerCharacter = isRecord(rawState.playerCharacter) + ? rawState.playerCharacter + : null; + const companions = dedupeCompanions(rawState.companions); + const roster = normalizeRoster(dedupeCompanions(rawState.roster), companions); + const storyEngineMemory = { + ...createEmptyStoryEngineMemoryState(), + ...(isRecord(rawState.storyEngineMemory) + ? jsonClone(rawState.storyEngineMemory) + : {}), + saveMigrationManifest: buildSaveMigrationManifest(), + }; + const savedPlayerMaxHp = Math.max( + 1, + Math.round(readNumber(rawState.playerMaxHp, 1)), + ); + const savedPlayerMaxMana = Math.max( + 1, + Math.round(readNumber(rawState.playerMaxMana, 1)), + ); + const resolvedEquipment = + normalizeEquipmentLoadout(rawState.playerEquipment) ?? + (playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null); + const baseResourceProfile = playerCharacter + ? buildCharacterResourceProfile(playerCharacter) + : null; + const basePlayerMaxHp = baseResourceProfile + ? hasEquippedItems(resolvedEquipment) + ? baseResourceProfile.maxHp + : Math.max(baseResourceProfile.maxHp, savedPlayerMaxHp) + : savedPlayerMaxHp; + const basePlayerMaxMana = baseResourceProfile + ? hasEquippedItems(resolvedEquipment) + ? baseResourceProfile.maxMana + : Math.max(baseResourceProfile.maxMana, savedPlayerMaxMana) + : savedPlayerMaxMana; + const normalizedCommonState = { + ...rawStateWithoutEquipment, + customWorldProfile: + isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null + ? rawState.customWorldProfile ?? null + : null, + runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, { + isActiveRun: Boolean( + rawState.playerCharacter && rawState.currentScene === 'Story', + ), + }), + storyEngineMemory, + chapterState: + rawState.chapterState ?? + (isRecord(storyEngineMemory.currentChapter) + ? storyEngineMemory.currentChapter + : null), + campaignState: + rawState.campaignState ?? + (isRecord(storyEngineMemory.campaignState) + ? storyEngineMemory.campaignState + : storyEngineMemory.campaignState ?? null), + activeScenarioPackId: + readString(rawState.activeScenarioPackId) || + readString( + (rawState.customWorldProfile as JsonRecord | null)?.scenarioPackId, + ) || + null, + activeCampaignPackId: + readString(rawState.activeCampaignPackId) || + readString( + (rawState.customWorldProfile as JsonRecord | null)?.campaignPackId, + ) || + null, + npcInteractionActive: readBoolean(rawState.npcInteractionActive), + playerCurrency: + typeof rawState.playerCurrency === 'number' && + Number.isFinite(rawState.playerCurrency) + ? Math.round(rawState.playerCurrency) + : resolveInitialPlayerCurrency(rawState), + quests: normalizeQuestEntries( + jsonClone(readArray(rawState.quests)) as Parameters< + typeof normalizeQuestEntries + >[0], + ), + roster, + companions, + npcStates: normalizeNpcStates(rawState.npcStates), + characterChats: normalizeCharacterChats(rawState.characterChats), + activeBuildBuffs: jsonClone(readArray(rawState.activeBuildBuffs)), + runtimeSessionId: readString(rawState.runtimeSessionId) || null, + runtimeActionVersion: + typeof rawState.runtimeActionVersion === 'number' && + Number.isFinite(rawState.runtimeActionVersion) + ? Math.round(rawState.runtimeActionVersion) + : 0, + }; + + if (!playerCharacter) { + return { + ...normalizedCommonState, + playerEquipment: createEmptyEquipmentLoadout(), + playerMaxHp: savedPlayerMaxHp, + playerHp: Math.max( + 0, + Math.min( + savedPlayerMaxHp, + Math.round(readNumber(rawState.playerHp, savedPlayerMaxHp)), + ), + ), + playerMaxMana: savedPlayerMaxMana, + playerMana: Math.max( + 0, + Math.min( + savedPlayerMaxMana, + Math.round(readNumber(rawState.playerMana, savedPlayerMaxMana)), + ), + ), + }; + } + + const stateWithResourceCaps = { + ...normalizedCommonState, + playerCharacter, + playerMaxHp: basePlayerMaxHp, + playerHp: Math.max( + 0, + Math.round(readNumber(rawState.playerHp, basePlayerMaxHp)), + ), + playerMaxMana: basePlayerMaxMana, + playerMana: Math.max( + 0, + Math.round(readNumber(rawState.playerMana, basePlayerMaxMana)), + ), + }; + + if (!resolvedEquipment) { + return stateWithResourceCaps; + } + + const equipmentBonuses = getEquipmentBonuses(resolvedEquipment); + const nextPlayerMaxHp = basePlayerMaxHp + equipmentBonuses.maxHpBonus; + const nextPlayerMaxMana = basePlayerMaxMana + equipmentBonuses.maxManaBonus; + + return { + ...stateWithResourceCaps, + playerEquipment: resolvedEquipment, + playerMaxHp: nextPlayerMaxHp, + playerHp: Math.min(nextPlayerMaxHp, stateWithResourceCaps.playerHp), + playerMaxMana: nextPlayerMaxMana, + playerMana: Math.min(nextPlayerMaxMana, stateWithResourceCaps.playerMana), + }; +} + +export function normalizeSavedSnapshotPayload(snapshot: T) { + return { + ...snapshot, + bottomTab: normalizeBottomTab(snapshot.bottomTab), + gameState: normalizeGameState(snapshot.gameState), + currentStory: normalizeSavedStory(snapshot.currentStory), + }; +} + +export function hydrateSavedSnapshot( + snapshot: SavedSnapshot | null, +): SavedSnapshot | null { + if (!snapshot) { + return null; + } + + return normalizeSavedSnapshotPayload(snapshot); +} diff --git a/server-node/src/modules/runtime/runtimeStatePrimitives.test.ts b/server-node/src/modules/runtime/runtimeStatePrimitives.test.ts new file mode 100644 index 00000000..29a01472 --- /dev/null +++ b/server-node/src/modules/runtime/runtimeStatePrimitives.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + addInventoryItems, + buildRelationState, + incrementGameRuntimeStats, + removeInventoryItem, +} from './runtimeStatePrimitives.js'; + +test('runtime state primitives merge stackable inventory items but preserve identity-sensitive items', () => { + const merged = addInventoryItems( + [ + { + id: 'potion-1', + category: '消耗品', + name: '疗伤丹', + quantity: 1, + rarity: 'uncommon', + tags: ['healing'], + }, + { + id: 'relic-1', + category: '专属物品', + name: '青铜令牌', + quantity: 1, + rarity: 'epic', + tags: ['relic'], + }, + ], + [ + { + id: 'potion-2', + category: '消耗品', + name: '疗伤丹', + quantity: 2, + rarity: 'uncommon', + tags: ['healing'], + }, + { + id: 'relic-2', + category: '专属物品', + name: '青铜令牌', + quantity: 1, + rarity: 'epic', + tags: ['relic'], + }, + ], + ); + + assert.equal( + merged.find((item) => item.name === '疗伤丹')?.quantity, + 3, + ); + assert.equal( + merged.filter((item) => item.name === '青铜令牌').length, + 2, + ); +}); + +test('runtime state primitives remove inventory quantity without leaving zero-count entries', () => { + const nextInventory = removeInventoryItem( + [ + { + id: 'potion-1', + category: '消耗品', + name: '疗伤丹', + quantity: 2, + rarity: 'uncommon', + tags: ['healing'], + }, + ], + 'potion-1', + 2, + ); + + assert.deepEqual(nextInventory, []); +}); + +test('runtime state primitives increment stats and resolve relation stances locally on the server', () => { + const nextStats = incrementGameRuntimeStats( + { + hostileNpcsDefeated: 1, + questsAccepted: 0, + itemsUsed: 2, + scenesTraveled: 3, + }, + { + questsAccepted: 2, + itemsUsed: -1, + scenesTraveled: 4, + }, + ); + + assert.deepEqual(nextStats, { + hostileNpcsDefeated: 1, + questsAccepted: 2, + itemsUsed: 2, + scenesTraveled: 7, + }); + assert.deepEqual(buildRelationState(-5), { + affinity: -5, + stance: 'hostile', + }); + assert.deepEqual(buildRelationState(18), { + affinity: 18, + stance: 'neutral', + }); + assert.deepEqual(buildRelationState(72), { + affinity: 72, + stance: 'bonded', + }); +}); diff --git a/server-node/src/modules/runtime/runtimeStatePrimitives.ts b/server-node/src/modules/runtime/runtimeStatePrimitives.ts new file mode 100644 index 00000000..4da90fef --- /dev/null +++ b/server-node/src/modules/runtime/runtimeStatePrimitives.ts @@ -0,0 +1,221 @@ +type RuntimeInventoryBuildBuff = { + name: string; + durationTurns: number; + tags: string[]; +}; + +type RuntimeInventoryUseProfile = { + hpRestore?: number; + manaRestore?: number; + cooldownReduction?: number; + buildBuffs?: RuntimeInventoryBuildBuff[]; +}; + +type RuntimeInventoryItemLike = { + id: string; + category: string; + name: string; + quantity: number; + rarity?: string | null; + tags: string[]; + runtimeMetadata?: unknown; + equipmentSlotId?: unknown; + buildProfile?: unknown; + statProfile?: unknown; + attributeResonance?: unknown; + useProfile?: RuntimeInventoryUseProfile | null; +}; + +type RuntimeStatsLike = { + hostileNpcsDefeated: number; + questsAccepted: number; + itemsUsed: number; + scenesTraveled: number; +}; + +type RuntimeRelationState = { + affinity: number; + stance: 'hostile' | 'guarded' | 'neutral' | 'cooperative' | 'bonded'; +}; + +const RARITY_SCORES: Record = { + common: 1, + uncommon: 2, + rare: 3, + epic: 4, + legendary: 5, +}; + +function clampNonNegativeInteger(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + + return Math.max(0, Math.floor(value)); +} + +function getRarityScore(rarity: string | null | undefined) { + if (!rarity) { + return 0; + } + + return RARITY_SCORES[rarity] ?? 0; +} + +function isIdentitySensitiveInventoryItem(item: RuntimeInventoryItemLike) { + return Boolean( + item.runtimeMetadata || + item.equipmentSlotId || + item.buildProfile || + item.statProfile || + item.attributeResonance || + item.category.includes('专属') || + item.rarity === 'epic' || + item.rarity === 'legendary', + ); +} + +function buildInventoryMergeKey(item: RuntimeInventoryItemLike) { + if (isIdentitySensitiveInventoryItem(item)) { + return `identity:${item.id}`; + } + + const buildBuffKey = (item.useProfile?.buildBuffs ?? []) + .map( + (buff) => + `${buff.name}:${buff.durationTurns}:${(buff.tags ?? []).join('|')}`, + ) + .join(','); + + return [ + item.category, + item.name, + item.rarity ?? '', + [...(item.tags ?? [])].sort().join('|'), + item.useProfile?.hpRestore ?? 0, + item.useProfile?.manaRestore ?? 0, + item.useProfile?.cooldownReduction ?? 0, + buildBuffKey, + ].join('::'); +} + +function mergeInventory(items: TItem[]) { + const merged = new Map(); + + for (const item of items) { + const key = buildInventoryMergeKey(item); + const existing = merged.get(key); + if (existing) { + merged.set(key, { + ...existing, + quantity: existing.quantity + item.quantity, + tags: [...new Set([...(existing.tags ?? []), ...(item.tags ?? [])])], + runtimeMetadata: + existing.runtimeMetadata ?? item.runtimeMetadata ?? null, + }); + continue; + } + + merged.set(key, { + ...item, + tags: [...new Set(item.tags ?? [])], + }); + } + + return [...merged.values()]; +} + +export function sortInventoryItems( + items: TItem[], +) { + return [...items].sort((left, right) => { + const rarityDiff = getRarityScore(right.rarity) - getRarityScore(left.rarity); + if (rarityDiff !== 0) { + return rarityDiff; + } + + const categoryDiff = left.category.localeCompare( + right.category, + 'zh-Hans-CN', + ); + if (categoryDiff !== 0) { + return categoryDiff; + } + + return left.name.localeCompare(right.name, 'zh-Hans-CN'); + }); +} + +export function addInventoryItems( + base: TItem[], + additions: TItem[], +) { + return sortInventoryItems(mergeInventory([...base, ...additions])); +} + +export function removeInventoryItem( + base: TItem[], + itemId: string, + quantity = 1, +) { + return sortInventoryItems( + base + .map((item) => + item.id === itemId + ? { + ...item, + quantity: Math.max(0, item.quantity - quantity), + } + : item, + ) + .filter((item) => item.quantity > 0), + ); +} + +export function incrementGameRuntimeStats( + stats: TStats, + increments: Partial< + Pick< + RuntimeStatsLike, + 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled' + > + >, +) { + return { + ...stats, + hostileNpcsDefeated: + stats.hostileNpcsDefeated + + clampNonNegativeInteger(increments.hostileNpcsDefeated), + questsAccepted: + stats.questsAccepted + clampNonNegativeInteger(increments.questsAccepted), + itemsUsed: stats.itemsUsed + clampNonNegativeInteger(increments.itemsUsed), + scenesTraveled: + stats.scenesTraveled + + clampNonNegativeInteger(increments.scenesTraveled), + }; +} + +export function resolveRelationStance( + affinity: number, +): RuntimeRelationState['stance'] { + if (affinity < 0) { + return 'hostile'; + } + if (affinity < 15) { + return 'guarded'; + } + if (affinity < 30) { + return 'neutral'; + } + if (affinity < 60) { + return 'cooperative'; + } + return 'bonded'; +} + +export function buildRelationState(affinity: number): RuntimeRelationState { + return { + affinity, + stance: resolveRelationStance(affinity), + }; +} diff --git a/server-node/src/modules/runtime/runtimeTreasureTexts.ts b/server-node/src/modules/runtime/runtimeTreasureTexts.ts new file mode 100644 index 00000000..3486a50b --- /dev/null +++ b/server-node/src/modules/runtime/runtimeTreasureTexts.ts @@ -0,0 +1,49 @@ +import { formatCurrency } from './runtimeEconomyPrimitives.js'; + +type TreasureRewardItem = { + name: string; +}; + +type TreasureRewardLike = { + items: TreasureRewardItem[]; + hp: number; + mana: number; + currency: number; + storyHint?: string; +}; + +type TreasureEncounterLike = { + npcName: string; +}; + +type TreasureInteractionAction = 'inspect' | 'leave' | 'secure'; + +export function buildTreasureResultText( + encounter: TreasureEncounterLike, + action: TreasureInteractionAction, + reward?: TreasureRewardLike, + worldType?: string | null, +) { + if (action === 'leave') { + return `你暂时没有触碰 ${encounter.npcName},只是把它的异常位置和痕迹牢牢记下。`; + } + + const itemText = + reward?.items.length ? reward.items.map((item) => item.name).join('、') : '零散战利品'; + const restoreParts = [ + (reward?.hp ?? 0) > 0 ? `气血 +${reward?.hp ?? 0}` : null, + (reward?.mana ?? 0) > 0 ? `灵力 +${reward?.mana ?? 0}` : null, + ].filter(Boolean); + const restoreText = + restoreParts.length > 0 ? `,并恢复 ${restoreParts.join('、')}` : ''; + const currencyText = reward + ? `,另得 ${formatCurrency(reward.currency, worldType ?? null)}` + : ''; + const storyHint = reward?.storyHint ? ` ${reward.storyHint}` : ''; + + if (action === 'inspect') { + return `你仔细检查了 ${encounter.npcName},顺着现场痕迹拆开机关与伪装,最终收回 ${itemText}${currencyText}${restoreText}。${storyHint}`; + } + + return `你迅速收下了 ${encounter.npcName} 中最关键的收获:${itemText}${currencyText}。${storyHint}`; +} diff --git a/server-node/src/modules/story/runtimeSession.ts b/server-node/src/modules/story/runtimeSession.ts new file mode 100644 index 00000000..02bfb3d7 --- /dev/null +++ b/server-node/src/modules/story/runtimeSession.ts @@ -0,0 +1,854 @@ +import type { + RuntimeStoryEncounterViewModel, + RuntimeStoryOptionView, + RuntimeStoryViewModel, + Task5RuntimeOptionScope, +} from '../../../../packages/shared/src/contracts/story.js'; +import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js'; +import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; + +type JsonRecord = Record; +type StoryHistoryRole = 'action' | 'result'; + +type FunctionDefinition = { + actionText: string; + detailText: string; + scope: Task5RuntimeOptionScope; +}; + +export type RuntimeStoryHistoryEntry = { + text: string; + historyRole: StoryHistoryRole; +}; + +export type RuntimeNpcState = { + affinity: number; + chattedCount: number; + helpUsed: boolean; + giftsGiven: number; + inventory: unknown[]; + recruited: boolean; + firstMeaningfulContactResolved: boolean; + relationState: JsonRecord | null; + stanceProfile: JsonRecord | null; + tradeStockSignature?: string | null; + revealedFacts?: string[]; + knownAttributeRumors?: string[]; + seenBackstoryChapterIds?: string[]; +}; + +export type RuntimeEncounter = { + id: string; + kind: 'npc' | 'treasure'; + npcName: string; + npcDescription: string; + context: string; + hostile: boolean; + characterId: string | null; + monsterPresetId: string | null; +}; + +export type RuntimeHostileNpc = { + id: string; + name: string; + hp: number; + maxHp: number; + description: string; +}; + +export type RuntimeCompanion = { + npcId: string; + characterId: string; + joinedAtAffinity: number; +}; + +export type RuntimeSession = { + sessionId: string; + runtimeVersion: number; + snapshotBottomTab: string; + rawGameState: JsonRecord; + worldType: string | null; + storyHistory: RuntimeStoryHistoryEntry[]; + currentEncounter: RuntimeEncounter | null; + npcInteractionActive: boolean; + sceneHostileNpcs: RuntimeHostileNpc[]; + inBattle: boolean; + playerHp: number; + playerMaxHp: number; + playerMana: number; + playerMaxMana: number; + npcStates: Record; + companions: RuntimeCompanion[]; + currentNpcBattleMode: 'fight' | 'spar' | null; + currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; +}; + +export const MAX_TASK5_COMPANIONS = 2; + +const STORY_FUNCTION_IDS = new Set([ + 'story_continue_adventure', + 'story_opening_camp_dialogue', + 'camp_travel_home_scene', + 'idle_call_out', + 'idle_explore_forward', + 'idle_observe_signs', + 'idle_rest_focus', + 'idle_travel_next_scene', +]); + +const COMBAT_FUNCTION_IDS = new Set([ + 'battle_all_in_crush', + 'battle_escape_breakout', + 'battle_feint_step', + 'battle_finisher_window', + 'battle_guard_break', + 'battle_probe_pressure', + 'battle_recover_breath', +]); + +const NPC_FUNCTION_IDS = new Set([ + 'npc_chat', + 'npc_fight', + 'npc_help', + 'npc_leave', + 'npc_preview_talk', + 'npc_recruit', + 'npc_spar', +]); + +const TASK6_RUNTIME_FUNCTION_ID_SET = new Set( + TASK6_RUNTIME_FUNCTION_IDS, +); + +export const TASK6_DEFERRED_FUNCTION_IDS = new Set([ +]); + +const FUNCTION_DEFINITIONS: Record = { + story_continue_adventure: { + actionText: '继续推进冒险', + detailText: '让后端基于当前快照继续推进当前故事状态。', + scope: 'story', + }, + story_opening_camp_dialogue: { + actionText: '交换开场判断', + detailText: '把当前营地里的第一次正式对话切进服务端交互态。', + scope: 'story', + }, + camp_travel_home_scene: { + actionText: '返回营地', + detailText: '结束当前遭遇,把流程带回安全的营地状态。', + scope: 'story', + }, + idle_call_out: { + actionText: '主动出声试探', + detailText: '对前路喊话,逼迫附近的动静更快浮出水面。', + scope: 'story', + }, + idle_explore_forward: { + actionText: '继续向前探索', + detailText: '继续沿当前路径深入,把新遭遇交给后端推进。', + scope: 'story', + }, + idle_observe_signs: { + actionText: '观察周围迹象', + detailText: '先读环境,再决定下一轮要不要靠近或出手。', + scope: 'story', + }, + idle_rest_focus: { + actionText: '原地调息', + detailText: '恢复少量生命与灵力,稳住下一轮节奏。', + scope: 'story', + }, + idle_travel_next_scene: { + actionText: '前往相邻场景', + detailText: '收束当前遭遇并切往下一段场景流程。', + scope: 'story', + }, + battle_all_in_crush: { + actionText: '正面强压', + detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。', + scope: 'combat', + }, + battle_escape_breakout: { + actionText: '强行脱离战斗', + detailText: '打断当前战斗,把状态切回探索或脱身结果。', + scope: 'combat', + }, + battle_feint_step: { + actionText: '虚晃切步', + detailText: '用更轻的代价制造伤害,同时压低敌方反击力度。', + scope: 'combat', + }, + battle_finisher_window: { + actionText: '抓破绽终结', + detailText: '对残血目标有额外收益,适合收尾。', + scope: 'combat', + }, + battle_guard_break: { + actionText: '破架重击', + detailText: '偏稳定的伤害动作,能打断对方的站稳节奏。', + scope: 'combat', + }, + battle_probe_pressure: { + actionText: '稳步试探', + detailText: '低风险压迫,兼顾伤害和节奏控制。', + scope: 'combat', + }, + battle_recover_breath: { + actionText: '边守边调息', + detailText: '优先回稳资源,但仍可能吃到轻量反击。', + scope: 'combat', + }, + npc_chat: { + actionText: '继续交谈', + detailText: '围绕当前话题延续对话,推进好感与关系判断。', + scope: 'npc', + }, + npc_fight: { + actionText: '与对方战斗', + detailText: '把当前 NPC 交互直接切进正式战斗结算。', + scope: 'npc', + }, + npc_help: { + actionText: '请求援手', + detailText: '向当前 NPC 请求一次性支援,恢复部分状态。', + scope: 'npc', + }, + npc_leave: { + actionText: '离开当前角色', + detailText: '结束当前 NPC 交互,重新回到探索态。', + scope: 'npc', + }, + npc_preview_talk: { + actionText: '转向眼前角色', + detailText: '从遭遇预览切进正式 NPC 互动菜单。', + scope: 'npc', + }, + npc_recruit: { + actionText: '邀请加入队伍', + detailText: '关系达标后可以直接把当前 NPC 收进同行队伍。', + scope: 'npc', + }, + npc_spar: { + actionText: '点到为止切磋', + detailText: '用 spar 模式进入轻量战斗,结果会回流到关系状态。', + scope: 'npc', + }, + npc_trade: { + actionText: '交易', + detailText: '查看库存并执行买入或卖出。', + scope: 'npc', + }, + npc_gift: { + actionText: '赠送礼物', + detailText: '把背包里的物品正式交给当前角色。', + scope: 'npc', + }, + npc_quest_accept: { + actionText: '接下委托', + detailText: '把当前角色的委托正式收进任务日志。', + scope: 'npc', + }, + npc_quest_turn_in: { + actionText: '交付委托', + detailText: '向当前角色结算已经完成的委托奖励。', + scope: 'npc', + }, + treasure_secure: { + actionText: '直接收取', + detailText: '不再拖延,直接把眼前最关键的收获带走。', + scope: 'story', + }, + treasure_inspect: { + actionText: '仔细检查', + detailText: '多花些时间拆开机关、痕迹和伪装。', + scope: 'story', + }, + treasure_leave: { + actionText: '先记下位置', + detailText: '暂时不碰它,只把异常位置和痕迹记住。', + scope: 'story', + }, +}; + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function isObject(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown, fallback = '') { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function readNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function readBoolean(value: unknown, fallback = false) { + return typeof value === 'boolean' ? value : fallback; +} + +function readArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function normalizeStoryHistory(value: unknown) { + return readArray(value) + .map((entry) => { + const rawEntry = isObject(entry) ? entry : {}; + const historyRole = + rawEntry.historyRole === 'action' ? 'action' : 'result'; + + return { + text: readString(rawEntry.text), + historyRole, + } satisfies RuntimeStoryHistoryEntry; + }) + .filter((entry) => entry.text); +} + +function normalizeNpcState(value: unknown): RuntimeNpcState { + const rawState = isObject(value) ? value : {}; + + return { + affinity: Math.round(readNumber(rawState.affinity, 0)), + chattedCount: Math.max(0, Math.round(readNumber(rawState.chattedCount, 0))), + helpUsed: readBoolean(rawState.helpUsed), + giftsGiven: Math.max(0, Math.round(readNumber(rawState.giftsGiven, 0))), + inventory: cloneJson(readArray(rawState.inventory)), + recruited: readBoolean(rawState.recruited), + firstMeaningfulContactResolved: readBoolean( + rawState.firstMeaningfulContactResolved, + ), + tradeStockSignature: readString(rawState.tradeStockSignature) || null, + relationState: isObject(rawState.relationState) + ? cloneJson(rawState.relationState) + : null, + stanceProfile: isObject(rawState.stanceProfile) + ? cloneJson(rawState.stanceProfile) + : null, + revealedFacts: readArray(rawState.revealedFacts).filter( + (item): item is string => typeof item === 'string' && item.trim().length > 0, + ), + knownAttributeRumors: readArray(rawState.knownAttributeRumors).filter( + (item): item is string => typeof item === 'string' && item.trim().length > 0, + ), + seenBackstoryChapterIds: readArray(rawState.seenBackstoryChapterIds).filter( + (item): item is string => typeof item === 'string' && item.trim().length > 0, + ), + }; +} + +function normalizeEncounter(value: unknown): RuntimeEncounter | null { + const rawEncounter = isObject(value) ? value : null; + if (!rawEncounter) { + return null; + } + + const kind = rawEncounter.kind === 'treasure' ? 'treasure' : 'npc'; + const npcName = readString(rawEncounter.npcName); + if (!npcName) { + return null; + } + + return { + id: readString(rawEncounter.id, npcName), + kind, + npcName, + npcDescription: readString(rawEncounter.npcDescription), + context: readString(rawEncounter.context), + hostile: + readBoolean(rawEncounter.hostile) || + Boolean(readString(rawEncounter.monsterPresetId)), + characterId: readString(rawEncounter.characterId) || null, + monsterPresetId: readString(rawEncounter.monsterPresetId) || null, + }; +} + +function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null { + const rawNpc = isObject(value) ? value : null; + if (!rawNpc) { + return null; + } + + const id = readString(rawNpc.id); + const name = readString(rawNpc.name, id); + if (!id || !name) { + return null; + } + + const maxHp = Math.max(1, Math.round(readNumber(rawNpc.maxHp, 1))); + const hp = Math.max(0, Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp)))); + + return { + id, + name, + hp, + maxHp, + description: readString(rawNpc.description), + }; +} + +function normalizeCompanion(value: unknown): RuntimeCompanion | null { + const rawCompanion = isObject(value) ? value : null; + if (!rawCompanion) { + return null; + } + + const npcId = readString(rawCompanion.npcId); + if (!npcId) { + return null; + } + + return { + npcId, + characterId: readString(rawCompanion.characterId), + joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)), + }; +} + +function normalizeNpcStates(value: unknown) { + const rawStates = isObject(value) ? value : {}; + + return Object.fromEntries( + Object.entries(rawStates).map(([key, state]) => [key, normalizeNpcState(state)]), + ) as Record; +} + +function normalizeCompanions(value: unknown) { + return readArray(value) + .map((entry) => normalizeCompanion(entry)) + .filter((entry): entry is RuntimeCompanion => Boolean(entry)); +} + +function normalizeHostileNpcs(value: unknown) { + return readArray(value) + .map((entry) => normalizeHostileNpc(entry)) + .filter((entry): entry is RuntimeHostileNpc => Boolean(entry)); +} + +export function getEncounterKey(encounter: RuntimeEncounter) { + return encounter.id || encounter.npcName; +} + +export function loadRuntimeSession( + snapshot: SavedSnapshot, + requestedSessionId: string, +): RuntimeSession { + const rawGameState = isObject(snapshot.gameState) + ? cloneJson(snapshot.gameState) + : {}; + const currentEncounter = normalizeEncounter(rawGameState.currentEncounter); + const sceneHostileNpcs = normalizeHostileNpcs(rawGameState.sceneHostileNpcs); + const inBattle = + readBoolean(rawGameState.inBattle) && + sceneHostileNpcs.some((npc) => npc.hp > 0); + + return { + sessionId: readString(rawGameState.runtimeSessionId, requestedSessionId), + runtimeVersion: Math.max( + 0, + Math.round(readNumber(rawGameState.runtimeActionVersion, 0)), + ), + snapshotBottomTab: readString(snapshot.bottomTab, 'adventure'), + rawGameState, + worldType: readString(rawGameState.worldType) || null, + storyHistory: normalizeStoryHistory(rawGameState.storyHistory), + currentEncounter, + npcInteractionActive: readBoolean(rawGameState.npcInteractionActive), + sceneHostileNpcs, + inBattle, + playerHp: Math.max(0, Math.round(readNumber(rawGameState.playerHp, 0))), + playerMaxHp: Math.max(1, Math.round(readNumber(rawGameState.playerMaxHp, 1))), + playerMana: Math.max(0, Math.round(readNumber(rawGameState.playerMana, 0))), + playerMaxMana: Math.max( + 1, + Math.round(readNumber(rawGameState.playerMaxMana, 1)), + ), + npcStates: normalizeNpcStates(rawGameState.npcStates), + companions: normalizeCompanions(rawGameState.companions), + currentNpcBattleMode: + rawGameState.currentNpcBattleMode === 'fight' || + rawGameState.currentNpcBattleMode === 'spar' + ? rawGameState.currentNpcBattleMode + : null, + currentNpcBattleOutcome: + rawGameState.currentNpcBattleOutcome === 'fight_victory' || + rawGameState.currentNpcBattleOutcome === 'spar_complete' + ? rawGameState.currentNpcBattleOutcome + : null, + }; +} + +export function isStoryFunctionId(functionId: string) { + return STORY_FUNCTION_IDS.has(functionId); +} + +export function isCombatFunctionId(functionId: string) { + return COMBAT_FUNCTION_IDS.has(functionId); +} + +export function isNpcFunctionId(functionId: string) { + return NPC_FUNCTION_IDS.has(functionId); +} + +export function isTask5FunctionId(functionId: string) { + return ( + isStoryFunctionId(functionId) || + isCombatFunctionId(functionId) || + isNpcFunctionId(functionId) + ); +} + +export function isTask6RuntimeFunctionId(functionId: string) { + return TASK6_RUNTIME_FUNCTION_ID_SET.has(functionId); +} + +export function getEncounterNpcState(session: RuntimeSession) { + if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { + return null; + } + + const key = getEncounterKey(session.currentEncounter); + return ( + session.npcStates[key] ?? { + affinity: 0, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + firstMeaningfulContactResolved: false, + relationState: null, + stanceProfile: null, + } + ); +} + +export function setEncounterNpcState( + session: RuntimeSession, + npcState: RuntimeNpcState, +) { + if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { + return; + } + + session.npcStates[getEncounterKey(session.currentEncounter)] = npcState; +} + +function buildOptionView( + functionId: string, + overrides: Partial = {}, +): RuntimeStoryOptionView { + const definition = FUNCTION_DEFINITIONS[functionId]; + if (!definition) { + return { + functionId, + actionText: functionId, + detailText: '', + scope: 'story', + ...overrides, + }; + } + + return { + functionId, + actionText: definition.actionText, + detailText: definition.detailText, + scope: definition.scope, + ...overrides, + }; +} + +type RuntimeQuestPreview = { + id: string; + issuerNpcId: string; + status: string; +}; + +function readQuestPreviews(session: RuntimeSession): RuntimeQuestPreview[] { + return readArray(session.rawGameState.quests) + .map((quest) => { + const rawQuest = isObject(quest) ? quest : {}; + const id = readString(rawQuest.id); + const issuerNpcId = readString(rawQuest.issuerNpcId); + const status = readString(rawQuest.status); + + if (!id || !issuerNpcId || !status) { + return null; + } + + return { + id, + issuerNpcId, + status, + } satisfies RuntimeQuestPreview; + }) + .filter((quest): quest is RuntimeQuestPreview => Boolean(quest)); +} + +function getActiveEncounterQuest(session: RuntimeSession) { + if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { + return null; + } + + return ( + readQuestPreviews(session).find( + (quest) => + quest.issuerNpcId === session.currentEncounter?.id && + quest.status !== 'turned_in', + ) ?? null + ); +} + +function hasGiftablePlayerInventory(session: RuntimeSession) { + return readArray(session.rawGameState.playerInventory).some((item) => { + const rawItem = isObject(item) ? item : {}; + return readNumber(rawItem.quantity, 0) > 0; + }); +} + +export function buildAvailableOptions(session: RuntimeSession) { + if (session.inBattle) { + return [ + 'battle_probe_pressure', + 'battle_guard_break', + 'battle_feint_step', + 'battle_finisher_window', + 'battle_all_in_crush', + 'battle_recover_breath', + 'battle_escape_breakout', + ].map((functionId) => buildOptionView(functionId)); + } + + if (session.currentEncounter?.kind === 'npc') { + const npcState = getEncounterNpcState(session); + if (session.currentEncounter.hostile) { + return [ + buildOptionView('npc_fight'), + buildOptionView('npc_leave'), + ]; + } + + if (!session.npcInteractionActive) { + return [ + buildOptionView('npc_preview_talk'), + buildOptionView('npc_fight'), + buildOptionView('npc_leave'), + ]; + } + + const activeQuest = getActiveEncounterQuest(session); + const options = [ + buildOptionView('npc_chat'), + buildOptionView('npc_help', npcState?.helpUsed + ? { + disabled: true, + reason: '当前 NPC 的一次性援手已经用完了。', + } + : {}), + buildOptionView('npc_spar'), + buildOptionView('npc_fight'), + ]; + + if ((npcState?.inventory?.length ?? 0) > 0) { + options.push(buildOptionView('npc_trade')); + } + + if (hasGiftablePlayerInventory(session)) { + options.push(buildOptionView('npc_gift')); + } + + if ( + activeQuest && + (activeQuest.status === 'completed' || + activeQuest.status === 'ready_to_turn_in') + ) { + options.push(buildOptionView('npc_quest_turn_in')); + } else if (!activeQuest) { + options.push(buildOptionView('npc_quest_accept')); + } + + if (npcState && !npcState.recruited && npcState.affinity >= 60) { + options.push( + buildOptionView( + 'npc_recruit', + session.companions.length >= MAX_TASK5_COMPANIONS + ? { + disabled: true, + reason: '队伍已满,任务5首轮后端接口暂不处理换队逻辑。', + } + : {}, + ), + ); + } + + options.push(buildOptionView('npc_leave')); + return options; + } + + if (session.currentEncounter?.kind === 'treasure') { + return [ + buildOptionView('treasure_secure'), + buildOptionView('treasure_inspect'), + buildOptionView('treasure_leave'), + ]; + } + + return [ + 'idle_observe_signs', + 'idle_call_out', + 'idle_rest_focus', + 'idle_explore_forward', + 'idle_travel_next_scene', + 'story_continue_adventure', + ].map((functionId) => buildOptionView(functionId)); +} + +function buildEncounterViewModel( + session: RuntimeSession, +): RuntimeStoryEncounterViewModel | null { + if (!session.currentEncounter) { + return null; + } + + const npcState = getEncounterNpcState(session); + return { + id: session.currentEncounter.id, + kind: session.currentEncounter.kind, + npcName: session.currentEncounter.npcName, + hostile: session.currentEncounter.hostile, + affinity: npcState?.affinity, + recruited: npcState?.recruited, + interactionActive: session.npcInteractionActive, + battleMode: session.currentNpcBattleMode, + }; +} + +export function buildRuntimeViewModel( + session: RuntimeSession, + options = buildAvailableOptions(session), +): RuntimeStoryViewModel { + return { + player: { + hp: session.playerHp, + maxHp: session.playerMaxHp, + mana: session.playerMana, + maxMana: session.playerMaxMana, + }, + encounter: buildEncounterViewModel(session), + companions: session.companions.map((companion) => ({ + npcId: companion.npcId, + characterId: companion.characterId || undefined, + joinedAtAffinity: companion.joinedAtAffinity, + })), + availableOptions: options, + status: { + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + }, + }; +} + +export function appendStoryHistory( + session: RuntimeSession, + actionText: string, + resultText: string, +) { + session.storyHistory.push( + { + text: actionText, + historyRole: 'action', + }, + { + text: resultText, + historyRole: 'result', + }, + ); +} + +export function buildLegacyCurrentStory( + storyText: string, + options: RuntimeStoryOptionView[], +) { + return { + text: storyText, + options: options.map((option) => ({ + functionId: option.functionId, + actionText: option.actionText, + text: option.actionText, + detailText: option.detailText, + priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + })), + }; +} + +export function syncRawGameState(session: RuntimeSession) { + session.rawGameState.runtimeSessionId = session.sessionId; + session.rawGameState.runtimeActionVersion = session.runtimeVersion; + session.rawGameState.storyHistory = cloneJson(session.storyHistory); + session.rawGameState.currentEncounter = session.currentEncounter + ? cloneJson(session.currentEncounter) + : null; + session.rawGameState.npcInteractionActive = session.npcInteractionActive; + session.rawGameState.sceneHostileNpcs = cloneJson(session.sceneHostileNpcs); + session.rawGameState.inBattle = session.inBattle; + session.rawGameState.playerHp = session.playerHp; + session.rawGameState.playerMaxHp = session.playerMaxHp; + session.rawGameState.playerMana = session.playerMana; + session.rawGameState.playerMaxMana = session.playerMaxMana; + session.rawGameState.npcStates = cloneJson(session.npcStates); + session.rawGameState.companions = cloneJson(session.companions); + session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode; + session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome; + session.rawGameState.currentBattleNpcId = session.currentEncounter?.id ?? null; + session.rawGameState.activeCombatEffects = []; + session.rawGameState.playerActionMode = 'idle'; + session.rawGameState.scrollWorld = false; + session.rawGameState.animationState = 'idle'; +} + +export function replaceRuntimeSessionRawGameState( + session: RuntimeSession, + nextGameState: JsonRecord, +) { + session.rawGameState = cloneJson(nextGameState); + const refreshed = loadRuntimeSession( + { + version: 2, + savedAt: '', + bottomTab: session.snapshotBottomTab, + gameState: session.rawGameState, + currentStory: null, + }, + session.sessionId, + ); + + session.worldType = refreshed.worldType; + session.storyHistory = refreshed.storyHistory; + session.currentEncounter = refreshed.currentEncounter; + session.npcInteractionActive = refreshed.npcInteractionActive; + session.sceneHostileNpcs = refreshed.sceneHostileNpcs; + session.inBattle = refreshed.inBattle; + session.playerHp = refreshed.playerHp; + session.playerMaxHp = refreshed.playerMaxHp; + session.playerMana = refreshed.playerMana; + session.playerMaxMana = refreshed.playerMaxMana; + session.npcStates = refreshed.npcStates; + session.companions = refreshed.companions; + session.currentNpcBattleMode = refreshed.currentNpcBattleMode; + session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome; +} diff --git a/server-node/src/modules/story/storyActionRoutes.test.ts b/server-node/src/modules/story/storyActionRoutes.test.ts new file mode 100644 index 00000000..97409ce8 --- /dev/null +++ b/server-node/src/modules/story/storyActionRoutes.test.ts @@ -0,0 +1,1123 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createApp } from '../../app.ts'; +import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js'; +import type { AppConfig } from '../../config.ts'; +import { createAppContext } from '../../server.ts'; +import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.ts'; +import { httpRequest, type TestRequestInit } from '../../testHttp.ts'; +import { applyQuestSignal } from '../quest/questProgressionService.ts'; + +function createTestConfig(testName: string): AppConfig { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), `genarrative-story-actions-${testName}-`), + ); + + return { + nodeEnv: 'test', + projectRoot: tempRoot, + publicDir: path.join(tempRoot, 'public'), + logsDir: path.join(tempRoot, 'logs'), + dataDir: path.join(tempRoot, 'data'), + rawEnv: {}, + databaseUrl: `pg-mem://genarrative-story-actions-${testName}`, + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test-secret', + jwtExpiresIn: '7d', + jwtIssuer: 'genarrative-story-actions-test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: true, + provider: 'mock', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: '', + accessKeySecret: '', + signName: 'Test Sign', + templateCode: '100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: true, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', + accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', + userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', + callbackPath: '/api/auth/wechat/callback', + defaultRedirectPath: '/', + mockUserId: 'mock_wechat_user', + mockUnionId: 'mock_wechat_union', + mockDisplayName: '微信旅人', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'genarrative_refresh_session', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/api/auth', + }, + }; +} + +async function withTestServer( + testName: string, + run: (options: { baseUrl: string }) => Promise, +) { + const context = await createAppContext(createTestConfig(testName)); + const app = createApp(context); + const server = await new Promise((resolve) => { + const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); + }); + + try { + const address = server.address() as AddressInfo; + return await run({ + baseUrl: `http://127.0.0.1:${address.port}`, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await context.db.close(); + } +} + +async function authEntry(baseUrl: string, username: string, password: string) { + const response = await httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + }), + }); + const payload = (await response.json()) as { + token: string; + }; + + assert.equal(response.status, 200); + assert.ok(payload.token); + return payload; +} + +function withBearer(token: string, init: TestRequestInit = {}) { + return { + ...init, + headers: { + ...(init.headers ?? {}), + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } satisfies TestRequestInit; +} + +async function putSnapshot(baseUrl: string, token: string, gameState: unknown) { + const response = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(token, { + method: 'PUT', + body: JSON.stringify({ + gameState, + bottomTab: 'adventure', + currentStory: { + text: '初始化剧情', + options: [], + }, + }), + }), + ); + + assert.equal(response.status, 200); +} + +function requirePlayerCharacter() { + return createTestPlayerCharacter(); +} + +function createTask6GameState(overrides: Record = {}) { + return { + worldType: 'WUXIA', + playerCharacter: requirePlayerCharacter(), + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'test-scene', + storyHistory: [], + characterChats: {}, + animationState: 'idle', + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 32, + playerMaxHp: 40, + playerMana: 9, + playerMaxMana: 16, + playerSkillCooldowns: { + slash: 2, + }, + activeBuildBuffs: [], + activeCombatEffects: [], + playerCurrency: 90, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: {}, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + ...overrides, + }; +} + +const QUEST_BATTLE_SCENE = { + id: 'quest-bridge', + name: '断桥口', + description: '桥口被匪首和刀痕压得极紧。', + npcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + description: '手提短刀的拦路匪徒', + avatar: '匪', + role: '敌对角色', + monsterPresetId: 'npc_bandit_01', + initialAffinity: -40, + hostile: true, + }, + ], + treasureHints: [], +}; + +const QUEST_TREASURE_SCENE = { + id: 'quest-ruins', + name: '残碑古道', + description: '路旁散着断碑和旧匣。', + npcs: [], + treasureHints: ['残匣', '旧印'], +}; + +test('runtime story actions resolve npc chat on the server and persist updated affinity', async () => { + await withTestServer('npc-chat', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_npc_chat', 'secret123'); + + await putSnapshot(baseUrl, entry.token, { + worldType: 'WUXIA', + storyHistory: [], + currentEncounter: { + kind: 'npc', + id: 'npc_merchant_01', + npcName: '沈七', + npcDescription: '腰间挂着药囊的行商', + context: '受伤行商', + }, + npcInteractionActive: true, + sceneHostileNpcs: [], + inBattle: false, + playerHp: 31, + playerMaxHp: 40, + playerMana: 9, + playerMaxMana: 16, + npcStates: { + npc_merchant_01: { + affinity: 46, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + companions: [], + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + }); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_chat', + }, + }), + }), + ); + const payload = (await response.json()) as { + serverVersion: number; + viewModel: { + encounter: { + affinity: number; + } | null; + availableOptions: Array<{ + functionId: string; + }>; + }; + presentation: { + storyText: string; + }; + patches: Array<{ + type: string; + }>; + }; + + assert.equal(response.status, 200); + assert.equal(payload.serverVersion, 1); + assert.equal(payload.viewModel.encounter?.affinity, 52); + assert.match(payload.presentation.storyText, /沈七/u); + assert.ok( + payload.viewModel.availableOptions.some( + (option) => option.functionId === 'npc_help', + ), + ); + assert.ok( + payload.patches.some((patch) => patch.type === 'npc_affinity_changed'), + ); + + const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const snapshotPayload = (await snapshotResponse.json()) as { + gameState: { + runtimeActionVersion: number; + npcStates: { + npc_merchant_01: { + affinity: number; + }; + }; + }; + currentStory: { + text: string; + }; + }; + + assert.equal(snapshotResponse.status, 200); + assert.equal(snapshotPayload.gameState.runtimeActionVersion, 1); + assert.equal(snapshotPayload.gameState.npcStates.npc_merchant_01.affinity, 52); + assert.match(snapshotPayload.currentStory.text, /沈七/u); + }); +}); + +test('runtime story actions resolve combat finishers on the server and collapse the battle state', async () => { + await withTestServer('combat-finisher', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123'); + + await putSnapshot(baseUrl, entry.token, { + worldType: 'WUXIA', + storyHistory: [], + currentEncounter: { + kind: 'npc', + id: 'npc_bandit_01', + npcName: '断桥匪首', + npcDescription: '手提短刀的拦路匪徒', + context: '桥口劫匪', + hostile: true, + }, + npcInteractionActive: false, + sceneHostileNpcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + hp: 12, + maxHp: 28, + description: '桥口劫匪', + }, + ], + inBattle: true, + playerHp: 42, + playerMaxHp: 50, + playerMana: 20, + playerMaxMana: 20, + npcStates: { + npc_bandit_01: { + affinity: -12, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + companions: [], + currentNpcBattleMode: 'fight', + currentNpcBattleOutcome: null, + }); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'battle_finisher_window', + }, + }), + }), + ); + const payload = (await response.json()) as { + viewModel: { + encounter: null; + status: { + inBattle: boolean; + currentNpcBattleOutcome: string | null; + }; + availableOptions: Array<{ + functionId: string; + }>; + }; + presentation: { + battle: { + outcome: string; + damageDealt: number; + } | null; + }; + }; + + assert.equal(response.status, 200); + assert.equal(payload.viewModel.encounter, null); + assert.equal(payload.viewModel.status.inBattle, false); + assert.equal(payload.viewModel.status.currentNpcBattleOutcome, 'fight_victory'); + assert.equal(payload.presentation.battle?.outcome, 'victory'); + assert.ok((payload.presentation.battle?.damageDealt ?? 0) >= 12); + assert.ok( + payload.viewModel.availableOptions.some( + (option) => option.functionId === 'idle_observe_signs', + ), + ); + + const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }); + const snapshotPayload = (await snapshotResponse.json()) as { + gameState: { + inBattle: boolean; + currentEncounter: unknown; + sceneHostileNpcs: unknown[]; + currentNpcBattleOutcome: string | null; + }; + }; + + assert.equal(snapshotResponse.status, 200); + assert.equal(snapshotPayload.gameState.inBattle, false); + assert.equal(snapshotPayload.gameState.currentEncounter, null); + assert.deepEqual(snapshotPayload.gameState.sceneHostileNpcs, []); + assert.equal(snapshotPayload.gameState.currentNpcBattleOutcome, 'fight_victory'); + }); +}); + +test('runtime story actions resolve inventory_use and persist updated resources', async () => { + await withTestServer('task6-inventory-use', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_task6_inventory', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + playerInventory: [ + { + id: 'focus-tonic', + category: '消耗品', + name: '凝神灵液', + quantity: 1, + rarity: 'rare', + tags: ['healing', 'mana'], + useProfile: { + hpRestore: 12, + manaRestore: 6, + cooldownReduction: 1, + buildBuffs: [ + { + id: 'focus-tonic:buff', + sourceType: 'item', + sourceId: 'focus-tonic', + name: '凝神增益', + tags: ['快剑'], + durationTurns: 2, + }, + ], + }, + }, + ], + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'inventory_use', + payload: { + itemId: 'focus-tonic', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + serverVersion: number; + viewModel: { + player: { + hp: number; + mana: number; + }; + }; + presentation: { + storyText: string; + toast: string | null; + }; + snapshot: { + gameState: { + runtimeStats: { + itemsUsed: number; + }; + playerInventory: unknown[]; + }; + }; + }; + + assert.equal(response.status, 200); + assert.equal(payload.serverVersion, 1); + assert.equal(payload.viewModel.player.hp, 44); + assert.equal(payload.viewModel.player.mana, 15); + assert.match(payload.presentation.storyText, /凝神灵液/u); + assert.match(payload.presentation.toast ?? '', /Build/u); + assert.equal(payload.snapshot.gameState.runtimeStats.itemsUsed, 1); + assert.deepEqual(payload.snapshot.gameState.playerInventory, []); + }); +}); + +test('runtime story actions resolve equipment_equip and persist updated loadout', async () => { + await withTestServer('task6-equipment-equip', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_task6_equip', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + playerInventory: [ + { + id: 'ward-mail', + category: '护甲', + name: '镇岳甲', + quantity: 1, + rarity: 'rare', + tags: ['armor', '守御', '护体'], + equipmentSlotId: 'armor', + statProfile: { + maxHpBonus: 24, + outgoingDamageBonus: 0.04, + incomingDamageMultiplier: 0.92, + }, + buildProfile: { + role: '守御', + tags: ['守御', '护体'], + synergy: ['守御', '护体'], + forgeRank: 0, + }, + }, + ], + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'equipment_equip', + payload: { + itemId: 'ward-mail', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + viewModel: { + player: { + maxHp: number; + }; + }; + presentation: { + storyText: string; + }; + snapshot: { + gameState: { + playerInventory: unknown[]; + playerEquipment: { + armor: { + id: string; + name: string; + } | null; + }; + }; + }; + }; + + assert.equal(response.status, 200); + assert.ok(payload.viewModel.player.maxHp > 40); + assert.match(payload.presentation.storyText, /镇岳甲/u); + assert.equal(payload.snapshot.gameState.playerInventory.length, 0); + assert.equal(payload.snapshot.gameState.playerEquipment.armor?.id, 'ward-mail'); + assert.equal(payload.snapshot.gameState.playerEquipment.armor?.name, '镇岳甲'); + }); +}); + +test('runtime story actions resolve npc_trade buy transactions on the server', async () => { + await withTestServer('task6-trade-buy', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_task6_trade_buy', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_merchant_02', + npcName: '梁伯', + npcDescription: '携带杂货箱的老人', + context: '沿街商贩', + characterId: 'merchant-test', + }, + npcInteractionActive: true, + playerCurrency: 90, + npcStates: { + npc_merchant_02: { + affinity: 58, + chattedCount: 1, + helpUsed: false, + giftsGiven: 0, + inventory: [ + { + id: 'merchant-essence', + category: '消耗品', + name: '回气散', + quantity: 3, + rarity: 'uncommon', + tags: ['mana'], + }, + ], + recruited: false, + }, + }, + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_trade', + payload: { + mode: 'buy', + itemId: 'merchant-essence', + quantity: 2, + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + presentation: { + storyText: string; + }; + snapshot: { + gameState: { + playerCurrency: number; + playerInventory: Array<{ name: string; quantity: number }>; + npcStates: { + npc_merchant_02: { + inventory: Array<{ id: string; quantity: number }>; + }; + }; + }; + }; + }; + + assert.equal(response.status, 200); + assert.match(payload.presentation.storyText, /回气散/u); + assert.ok(payload.snapshot.gameState.playerCurrency < 90); + assert.equal(payload.snapshot.gameState.playerInventory[0]?.name, '回气散'); + assert.equal(payload.snapshot.gameState.playerInventory[0]?.quantity, 2); + assert.equal( + payload.snapshot.gameState.npcStates.npc_merchant_02.inventory[0]?.quantity, + 1, + ); + }); +}); + +test('runtime story actions resolve npc_gift and persist affinity changes', async () => { + await withTestServer('task6-gift', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_task6_gift', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_merchant_03', + npcName: '沈娘', + npcDescription: '对药性很敏感的行脚商', + context: '药商', + characterId: 'merchant-gift', + }, + npcInteractionActive: true, + playerInventory: [ + { + id: 'gift-herb', + category: '材料', + name: '暖息草', + quantity: 1, + rarity: 'rare', + tags: ['material', 'mana'], + }, + ], + npcStates: { + npc_merchant_03: { + affinity: 22, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_gift', + payload: { + itemId: 'gift-herb', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + snapshot: { + gameState: { + playerInventory: unknown[]; + npcStates: { + npc_merchant_03: { + affinity: number; + giftsGiven: number; + }; + }; + }; + }; + patches: Array<{ type: string }>; + }; + + assert.equal(response.status, 200); + assert.equal(payload.snapshot.gameState.playerInventory.length, 0); + assert.ok(payload.snapshot.gameState.npcStates.npc_merchant_03.affinity > 22); + assert.equal(payload.snapshot.gameState.npcStates.npc_merchant_03.giftsGiven, 1); + assert.ok(payload.patches.some((patch) => patch.type === 'npc_affinity_changed')); + }); +}); + +test('runtime story actions resolve npc_quest_accept and persist accepted quests', async () => { + await withTestServer('task6-quest-accept', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_task6_quest_accept', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_scout_01', + npcName: '巡路人', + npcDescription: '熟悉桥口风向的探子', + context: '巡路人', + characterId: 'scout-quest', + }, + currentScenePreset: QUEST_BATTLE_SCENE, + npcInteractionActive: true, + npcStates: { + npc_scout_01: { + affinity: 16, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_quest_accept', + }, + }), + }), + ); + const payload = (await response.json()) as { + snapshot: { + gameState: { + quests: Array<{ issuerNpcId: string; status: string }>; + runtimeStats: { + questsAccepted: number; + }; + }; + }; + presentation: { + storyText: string; + }; + }; + + assert.equal(response.status, 200); + assert.equal(payload.snapshot.gameState.quests.length, 1); + assert.equal(payload.snapshot.gameState.quests[0]?.issuerNpcId, 'npc_scout_01'); + assert.equal(payload.snapshot.gameState.quests[0]?.status, 'active'); + assert.equal(payload.snapshot.gameState.runtimeStats.questsAccepted, 1); + assert.match(payload.presentation.storyText, /正式把委托交到了你手上/u); + }); +}); + +test('runtime story actions progress quests from combat victories and npc turn-ins', async () => { + await withTestServer('task6-quest-progress-turnin', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_qp_turnin', 'secret123'); + const quest = buildQuestForEncounter({ + issuerNpcId: 'npc_bandit_01', + issuerNpcName: '断桥匪首', + roleText: '桥口劫匪', + scene: QUEST_BATTLE_SCENE, + worldType: 'WUXIA', + currentQuests: [], + }); + assert.ok(quest); + + await putSnapshot(baseUrl, entry.token, { + ...createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_bandit_01', + npcName: '断桥匪首', + npcDescription: '手提短刀的拦路匪徒', + context: '桥口劫匪', + characterId: 'bandit-quest', + hostile: true, + }, + currentScenePreset: QUEST_BATTLE_SCENE, + sceneHostileNpcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + hp: 8, + maxHp: 28, + description: '桥口劫匪', + }, + ], + inBattle: true, + playerMana: 20, + playerMaxMana: 20, + currentNpcBattleMode: 'fight', + npcInteractionActive: false, + quests: [quest], + npcStates: { + npc_bandit_01: { + affinity: -12, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + }), + }); + + const battleResponse = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'battle_finisher_window', + }, + }), + }), + ); + const battlePayload = (await battleResponse.json()) as { + snapshot: { + gameState: { + quests: Array<{ + status: string; + objective: { kind: string }; + }>; + }; + }; + }; + + assert.equal(battleResponse.status, 200); + assert.equal(battlePayload.snapshot.gameState.quests[0]?.status, 'active'); + assert.equal( + battlePayload.snapshot.gameState.quests[0]?.objective.kind, + 'talk_to_npc', + ); + + const afterHostile = applyQuestSignal([quest], { + kind: 'hostile_npc_defeated', + sceneId: QUEST_BATTLE_SCENE.id, + hostileNpcId: 'npc_bandit_01', + }).nextQuests[0]; + assert.ok(afterHostile); + const readyQuest = applyQuestSignal([afterHostile], { + kind: 'npc_talk_completed', + npcId: 'npc_bandit_01', + }).nextQuests[0]; + assert.ok(readyQuest); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_bandit_01', + npcName: '断桥匪首', + npcDescription: '手提短刀的拦路匪徒', + context: '桥口劫匪', + characterId: 'bandit-quest', + }, + currentScenePreset: QUEST_BATTLE_SCENE, + npcInteractionActive: true, + playerCurrency: 12, + quests: [readyQuest], + npcStates: { + npc_bandit_01: { + affinity: 6, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + }), + ); + + const turnInResponse = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_quest_turn_in', + payload: { + questId: readyQuest.id, + }, + }, + }), + }), + ); + const turnInPayload = (await turnInResponse.json()) as { + snapshot: { + gameState: { + quests: Array<{ status: string }>; + playerCurrency: number; + playerInventory: Array<{ name: string }>; + npcStates: { + npc_bandit_01: { + affinity: number; + }; + }; + }; + }; + }; + + assert.equal(turnInResponse.status, 200); + assert.equal(turnInPayload.snapshot.gameState.quests[0]?.status, 'turned_in'); + assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12); + assert.ok(turnInPayload.snapshot.gameState.playerInventory.length > 0); + assert.ok(turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6); + }); +}); + +test('runtime story actions resolve treasure_inspect and advance treasure quests on the server', async () => { + await withTestServer('task6-treasure', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_task6_treasure', 'secret123'); + const quest = buildQuestForEncounter({ + issuerNpcId: 'npc_researcher_01', + issuerNpcName: '碑下学人', + roleText: '考据学人', + scene: QUEST_TREASURE_SCENE, + worldType: 'WUXIA', + currentQuests: [], + }); + assert.ok(quest); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'treasure', + id: 'treasure-stone-box', + npcName: '残匣', + npcDescription: '匣盖和断碑之间卡着旧印。', + context: '残碑古道', + }, + currentScenePreset: QUEST_TREASURE_SCENE, + quests: [quest], + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'treasure_inspect', + }, + }), + }), + ); + const payload = (await response.json()) as { + snapshot: { + gameState: { + currentEncounter: unknown; + playerInventory: Array<{ name: string }>; + quests: Array<{ objective: { kind: string } }>; + }; + }; + presentation: { + storyText: string; + }; + }; + + assert.equal(response.status, 200); + assert.equal(payload.snapshot.gameState.currentEncounter, null); + assert.ok(payload.snapshot.gameState.playerInventory.length > 0); + assert.equal( + payload.snapshot.gameState.quests[0]?.objective.kind, + 'talk_to_npc', + ); + assert.match(payload.presentation.storyText, /仔细检查了\s*残匣/u); + }); +}); diff --git a/server-node/src/modules/story/storyActionRoutes.ts b/server-node/src/modules/story/storyActionRoutes.ts new file mode 100644 index 00000000..57c24ed9 --- /dev/null +++ b/server-node/src/modules/story/storyActionRoutes.ts @@ -0,0 +1,73 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { RuntimeStoryActionRequest } from '../../../../packages/shared/src/contracts/story.js'; +import type { AppContext } from '../../context.js'; +import { badRequest } from '../../errors.js'; +import { asyncHandler, sendApiResponse } from '../../http.js'; +import { requireJwtAuth } from '../../middleware/auth.js'; +import { routeMeta } from '../../middleware/routeMeta.js'; +import { + getRuntimeStoryState, + resolveRuntimeStoryAction, +} from './storyActionService.js'; + +const actionPayloadSchema = z.record(z.string(), z.unknown()); + +const runtimeStoryActionSchema = z.object({ + sessionId: z.string().trim().min(1), + clientVersion: z.number().int().min(0).optional(), + action: z.object({ + type: z.literal('story_choice'), + functionId: z.string().trim().min(1), + targetId: z.string().trim().optional(), + payload: actionPayloadSchema.optional().default({}), + }), +}); + +export function createStoryActionRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + + router.use(requireAuth); + + router.post( + '/actions/resolve', + routeMeta({ operation: 'runtime.story.actions.resolve' }), + asyncHandler(async (request, response) => { + const payload = runtimeStoryActionSchema.parse( + request.body, + ) as RuntimeStoryActionRequest; + sendApiResponse( + response, + await resolveRuntimeStoryAction({ + runtimeRepository: context.runtimeRepository, + userId: request.userId!, + request: payload, + }), + ); + }), + ); + + router.get( + '/state/:sessionId', + routeMeta({ operation: 'runtime.story.state.get' }), + asyncHandler(async (request, response) => { + const sessionId = request.params.sessionId?.trim() || ''; + if (!sessionId) { + throw badRequest('sessionId is required'); + } + + sendApiResponse( + response, + await getRuntimeStoryState({ + runtimeRepository: context.runtimeRepository, + userId: request.userId!, + sessionId, + }), + ); + }), + ); + + return router; +} diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/story/storyActionService.ts new file mode 100644 index 00000000..7bc4b906 --- /dev/null +++ b/server-node/src/modules/story/storyActionService.ts @@ -0,0 +1,377 @@ +import type { + RuntimeBattlePresentation, + RuntimeStoryActionRequest, + RuntimeStoryActionResponse, + RuntimeStoryPatch, +} from '../../../../packages/shared/src/contracts/story.js'; +import { conflict, invalidRequest } from '../../errors.js'; +import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js'; +import { resolveCombatAction } from '../combat/combatResolutionService.js'; +import { resolveInventoryStoryAction, isSupportedInventoryStoryFunctionId } from '../inventory/inventoryStoryActionService.js'; +import { + ensureNpcInventorySessionState, + isSupportedNpcInventoryStoryFunctionId, + resolveNpcInventoryStoryAction, +} from '../inventory/npcInventoryStoryActionService.js'; +import { resolveNpcInteraction } from '../npc/npcInteractionService.js'; +import { + applyQuestSignalsForResolvedAction, +} from '../quest/questRuntimeSignalService.js'; +import { + isSupportedQuestStoryFunctionId, + resolveQuestStoryAction, +} from '../quest/questStoryActionService.js'; +import { + isSupportedTreasureStoryFunctionId, + resolveTreasureStoryAction, +} from '../runtime-item/treasureStoryActionService.js'; +import { + TASK6_DEFERRED_FUNCTION_IDS, + appendStoryHistory, + buildAvailableOptions, + buildLegacyCurrentStory, + buildRuntimeViewModel, + getEncounterNpcState, + isCombatFunctionId, + isNpcFunctionId, + isStoryFunctionId, + isTask5FunctionId, + loadRuntimeSession, + setEncounterNpcState, + syncRawGameState, + type RuntimeSession, +} from './runtimeSession.js'; +import { + hydrateSavedSnapshot, + normalizeSavedSnapshotPayload, +} from '../runtime/runtimeSnapshotHydration.js'; + +type StoryResolution = { + actionText: string; + resultText: string; + patches: RuntimeStoryPatch[]; + storyText?: string; + battle?: RuntimeBattlePresentation | null; + toast?: string | null; +}; + +function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) { + const payload = request.action.payload; + const optionText = + payload && typeof payload.optionText === 'string' + ? payload.optionText.trim() + : ''; + + return optionText || defaultText; +} + +function normalizeStatusPatch(session: RuntimeSession) { + return { + type: 'status_changed', + inBattle: session.inBattle, + npcInteractionActive: session.npcInteractionActive, + currentNpcBattleMode: session.currentNpcBattleMode, + currentNpcBattleOutcome: session.currentNpcBattleOutcome, + } satisfies RuntimeStoryPatch; +} + +function clearEncounterState(session: RuntimeSession) { + session.currentEncounter = null; + session.npcInteractionActive = false; + session.sceneHostileNpcs = []; + session.inBattle = false; + session.currentNpcBattleMode = null; +} + +function readSavedStoryText(currentStory: unknown) { + if ( + currentStory && + typeof currentStory === 'object' && + 'text' in currentStory && + typeof currentStory.text === 'string' && + currentStory.text.trim() + ) { + return currentStory.text.trim(); + } + + return ''; +} + +function buildFallbackStoryText(session: RuntimeSession) { + if (session.inBattle && session.sceneHostileNpcs.length > 0) { + return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`; + } + + if (session.currentEncounter?.kind === 'npc') { + return `${session.currentEncounter.npcName}正在等你表态,接下来这一轮该怎么回应,由服务端规则来继续收口。`; + } + + return '当前故事状态已经同步到后端,接下来可以继续推进这一轮运行时动作。'; +} + +function resolveStoryFlowAction( + session: RuntimeSession, + functionId: string, +): StoryResolution { + switch (functionId) { + case 'story_continue_adventure': + return { + actionText: '继续推进冒险', + resultText: '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。', + patches: [normalizeStatusPatch(session)], + }; + case 'story_opening_camp_dialogue': { + const encounter = session.currentEncounter; + const npcState = getEncounterNpcState(session); + if (encounter && npcState) { + const nextAffinity = npcState.affinity + 2; + setEncounterNpcState(session, { + ...npcState, + affinity: nextAffinity, + firstMeaningfulContactResolved: true, + }); + session.npcInteractionActive = true; + + return { + actionText: `与${encounter.npcName}交换开场判断`, + resultText: `${encounter.npcName}终于愿意把营地里的第一轮判断说出口,彼此的警惕也略微放下了一点。`, + patches: [ + { + type: 'npc_affinity_changed', + npcId: encounter.id, + previousAffinity: npcState.affinity, + nextAffinity, + }, + normalizeStatusPatch(session), + ], + }; + } + + return { + actionText: '交换开场判断', + resultText: '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。', + patches: [normalizeStatusPatch(session)], + }; + } + case 'camp_travel_home_scene': + clearEncounterState(session); + return { + actionText: '返回营地', + resultText: '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。', + patches: [ + normalizeStatusPatch(session), + { + type: 'encounter_changed', + encounterId: null, + }, + ], + }; + case 'idle_call_out': + return { + actionText: '主动出声试探', + resultText: '你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。', + patches: [normalizeStatusPatch(session)], + }; + case 'idle_explore_forward': + return { + actionText: '继续向前探索', + resultText: '你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。', + patches: [normalizeStatusPatch(session)], + }; + case 'idle_observe_signs': + return { + actionText: '观察周围迹象', + resultText: '你先压住动作,把风向、脚印和气味这些细节重新读了一遍。', + patches: [normalizeStatusPatch(session)], + }; + case 'idle_rest_focus': + session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 8); + session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 6); + return { + actionText: '原地调息', + resultText: '你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。', + patches: [normalizeStatusPatch(session)], + }; + case 'idle_travel_next_scene': + clearEncounterState(session); + return { + actionText: '前往相邻场景', + resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。', + patches: [ + normalizeStatusPatch(session), + { + type: 'encounter_changed', + encounterId: null, + }, + ], + }; + default: + throw invalidRequest(`暂不支持的 story action:${functionId}`); + } +} + +export async function resolveRuntimeStoryAction(params: { + runtimeRepository: RuntimeRepositoryPort; + userId: string; + request: RuntimeStoryActionRequest; +}) { + const snapshot = await params.runtimeRepository.getSnapshot(params.userId); + if (!snapshot) { + throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); + } + const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!; + + const functionId = + typeof params.request.action.functionId === 'string' + ? params.request.action.functionId.trim() + : ''; + if (!functionId) { + throw invalidRequest('functionId 不能为空'); + } + + if ( + !isSupportedInventoryStoryFunctionId(functionId) && + !isSupportedNpcInventoryStoryFunctionId(functionId) && + !isSupportedQuestStoryFunctionId(functionId) && + !isSupportedTreasureStoryFunctionId(functionId) && + TASK6_DEFERRED_FUNCTION_IDS.has(functionId) + ) { + throw conflict( + `动作 ${functionId} 属于任务6的 Inventory / Quest / Build 范围,本轮任务5接口暂未承接`, + ); + } + + if ( + !isSupportedInventoryStoryFunctionId(functionId) && + !isSupportedNpcInventoryStoryFunctionId(functionId) && + !isSupportedQuestStoryFunctionId(functionId) && + !isSupportedTreasureStoryFunctionId(functionId) && + !isTask5FunctionId(functionId) + ) { + throw invalidRequest(`暂不支持的 runtime action:${functionId}`); + } + + const session = loadRuntimeSession( + hydratedSnapshot, + params.request.sessionId, + ); + if ( + typeof params.request.clientVersion === 'number' && + params.request.clientVersion !== session.runtimeVersion + ) { + throw conflict('运行时版本已变化,请先同步最新快照后再提交动作', { + clientVersion: params.request.clientVersion, + serverVersion: session.runtimeVersion, + }); + } + + let resolution: StoryResolution; + const previousEncounter = session.currentEncounter + ? { ...session.currentEncounter } + : null; + if (isCombatFunctionId(functionId)) { + resolution = resolveCombatAction(session, functionId); + } else if (isNpcFunctionId(functionId)) { + resolution = resolveNpcInteraction(session, functionId); + } else if (isSupportedInventoryStoryFunctionId(functionId)) { + resolution = resolveInventoryStoryAction(session, params.request); + } else if (isSupportedNpcInventoryStoryFunctionId(functionId)) { + resolution = resolveNpcInventoryStoryAction(session, params.request); + } else if (isSupportedQuestStoryFunctionId(functionId)) { + resolution = resolveQuestStoryAction(session, params.request); + } else if (isSupportedTreasureStoryFunctionId(functionId)) { + resolution = resolveTreasureStoryAction(session, params.request); + } else if (isStoryFunctionId(functionId)) { + resolution = resolveStoryFlowAction(session, functionId); + } else { + throw invalidRequest(`当前动作没有可用的后端执行器:${functionId}`); + } + + syncRawGameState(session); + applyQuestSignalsForResolvedAction({ + session, + functionId, + previousEncounter, + battle: resolution.battle ?? null, + }); + + const actionText = resolveActionText(resolution.actionText, params.request); + const storyText = resolution.storyText ?? resolution.resultText; + + appendStoryHistory(session, actionText, resolution.resultText); + session.runtimeVersion += 1; + session.sessionId = params.request.sessionId; + + syncRawGameState(session); + ensureNpcInventorySessionState(session); + const options = buildAvailableOptions(session); + syncRawGameState(session); + + const persistedSnapshot = await params.runtimeRepository.putSnapshot( + params.userId, + normalizeSavedSnapshotPayload({ + savedAt: new Date().toISOString(), + bottomTab: session.snapshotBottomTab, + gameState: session.rawGameState, + currentStory: buildLegacyCurrentStory(storyText, options), + }), + ); + + return { + sessionId: session.sessionId, + serverVersion: session.runtimeVersion, + viewModel: buildRuntimeViewModel(session, options), + presentation: { + actionText, + resultText: resolution.resultText, + storyText, + options, + toast: resolution.toast ?? null, + battle: resolution.battle ?? null, + }, + patches: [ + { + type: 'story_history_append', + actionText, + resultText: resolution.resultText, + }, + ...resolution.patches, + ], + snapshot: hydrateSavedSnapshot(persistedSnapshot)!, + } satisfies RuntimeStoryActionResponse; +} + +export async function getRuntimeStoryState(params: { + runtimeRepository: RuntimeRepositoryPort; + userId: string; + sessionId: string; +}) { + const snapshot = await params.runtimeRepository.getSnapshot(params.userId); + if (!snapshot) { + throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); + } + const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!; + + const session = loadRuntimeSession(hydratedSnapshot, params.sessionId); + ensureNpcInventorySessionState(session); + const options = buildAvailableOptions(session); + const storyText = + readSavedStoryText(hydratedSnapshot.currentStory) || + buildFallbackStoryText(session); + + return { + sessionId: session.sessionId, + serverVersion: session.runtimeVersion, + viewModel: buildRuntimeViewModel(session, options), + presentation: { + actionText: '', + resultText: '', + storyText, + options, + toast: null, + battle: null, + }, + patches: [], + snapshot: hydratedSnapshot, + } satisfies RuntimeStoryActionResponse; +} diff --git a/server-node/src/observability.test.ts b/server-node/src/observability.test.ts new file mode 100644 index 00000000..e571bda7 --- /dev/null +++ b/server-node/src/observability.test.ts @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { Writable } from 'node:stream'; +import test from 'node:test'; + +import pino, { type Logger } from 'pino'; + +import { createApp } from './app.ts'; +import type { AppConfig } from './config.ts'; +import { createAppContext } from './server.ts'; +import { httpRequest } from './testHttp.ts'; + +type LogRecord = Record; + +function createTestConfig(testName: string): AppConfig { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), `genarrative-server-node-${testName}-`), + ); + + return { + nodeEnv: 'test', + projectRoot: tempRoot, + publicDir: path.join(tempRoot, 'public'), + logsDir: path.join(tempRoot, 'logs'), + dataDir: path.join(tempRoot, 'data'), + rawEnv: {}, + databaseUrl: `pg-mem://genarrative-${testName}`, + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test-secret', + jwtExpiresIn: '7d', + jwtIssuer: 'genarrative-server-node-test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: true, + provider: 'mock', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: '', + accessKeySecret: '', + signName: 'Test Sign', + templateCode: '100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: true, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', + accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', + userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', + callbackPath: '/api/auth/wechat/callback', + defaultRedirectPath: '/', + mockUserId: 'mock_wechat_user', + mockUnionId: 'mock_wechat_union', + mockDisplayName: '微信旅人', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'genarrative_refresh_session', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/api/auth', + }, + }; +} + +function createLogCollector() { + const records: LogRecord[] = []; + let buffer = ''; + + const destination = new Writable({ + write(chunk, _encoding, callback) { + buffer += chunk.toString('utf8'); + + let newlineIndex = buffer.indexOf('\n'); + while (newlineIndex >= 0) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (line) { + records.push(JSON.parse(line) as LogRecord); + } + + newlineIndex = buffer.indexOf('\n'); + } + + callback(); + }, + }); + + return { + logger: pino( + { + level: 'info', + base: undefined, + timestamp: false, + }, + destination, + ) as Logger, + records, + }; +} + +async function withTestServer( + testName: string, + logger: Logger, + run: (options: { baseUrl: string }) => Promise, +) { + const context = await createAppContext(createTestConfig(testName)); + context.logger = logger; + const app = createApp(context); + const server = await new Promise((resolve) => { + const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); + }); + + try { + const address = server.address() as AddressInfo; + return await run({ + baseUrl: `http://127.0.0.1:${address.port}`, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await context.db.close(); + } +} + +async function waitForRecord( + records: LogRecord[], + predicate: (record: LogRecord) => boolean, + timeoutMs = 2000, +) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const match = records.find(predicate); + if (match) { + return match; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + assert.fail('Timed out waiting for log record'); +} + +test('healthz echoes x-request-id and writes access log fields', async () => { + const { logger, records } = createLogCollector(); + + await withTestServer('observability-healthz', logger, async ({ baseUrl }) => { + const requestId = 'obs-healthz-request'; + const response = await httpRequest(`${baseUrl}/healthz`, { + headers: { + 'X-Request-Id': requestId, + }, + }); + const payload = (await response.json()) as { + ok: boolean; + service: string; + }; + + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-request-id'), requestId); + assert.equal(payload.ok, true); + assert.equal(payload.service, 'genarrative-node-server'); + + const accessLog = await waitForRecord( + records, + (record) => + record.request_id === requestId && + record.path === '/healthz' && + record.status === 200, + ); + + assert.equal(accessLog.method, 'GET'); + assert.equal(accessLog.user_id, null); + assert.equal(accessLog.api_version, '2026-04-08'); + assert.equal(accessLog.route_version, '2026-04-08'); + assert.equal(accessLog.operation, 'health.check'); + assert.equal(typeof accessLog.latency_ms, 'number'); + }); +}); + +test('unauthorized request keeps request trace in error log and response header', async () => { + const { logger, records } = createLogCollector(); + + await withTestServer( + 'observability-unauthorized', + logger, + async ({ baseUrl }) => { + const requestId = 'obs-unauthorized-request'; + const response = await httpRequest(`${baseUrl}/api/auth/me`, { + headers: { + 'X-Request-Id': requestId, + }, + }); + const payload = (await response.json()) as { + error: { + message: string; + }; + }; + + assert.equal(response.status, 401); + assert.equal(response.headers.get('x-request-id'), requestId); + assert.equal(payload.error.message, '缺少 Authorization Bearer Token'); + + const errorLog = await waitForRecord( + records, + (record) => + record.msg === 'request failed' && record.request_id === requestId, + ); + + assert.equal(errorLog.user_id, null); + assert.equal( + (errorLog.err as { message?: string } | undefined)?.message, + '缺少 Authorization Bearer Token', + ); + assert.equal(errorLog.api_version, '2026-04-08'); + assert.equal(errorLog.route_version, '2026-04-08'); + assert.equal(errorLog.operation, 'auth.me'); + + const accessLog = await waitForRecord( + records, + (record) => + record.request_id === requestId && + record.path === '/api/auth/me' && + record.status === 401, + ); + + assert.equal(accessLog.method, 'GET'); + assert.equal(accessLog.api_version, '2026-04-08'); + assert.equal(accessLog.route_version, '2026-04-08'); + assert.equal(accessLog.operation, 'auth.me'); + assert.equal(typeof accessLog.latency_ms, 'number'); + }, + ); +}); diff --git a/server-node/src/repositories/authAuditLogRepository.ts b/server-node/src/repositories/authAuditLogRepository.ts new file mode 100644 index 00000000..5fffe14d --- /dev/null +++ b/server-node/src/repositories/authAuditLogRepository.ts @@ -0,0 +1,105 @@ +import crypto from 'node:crypto'; + +import type { QueryResultRow } from 'pg'; + +import type { AuthAuditLogEventType } from '../../../packages/shared/src/contracts/auth.js'; +import type { AppDatabase } from '../db.js'; + +export type AuthAuditLogRecord = { + id: string; + userId: string; + eventType: AuthAuditLogEventType; + detail: string; + ip: string | null; + userAgent: string | null; + metaJson: Record | null; + createdAt: string; +}; + +type AuthAuditLogRow = QueryResultRow & { + id: string; + user_id: string; + event_type: AuthAuditLogEventType; + detail: string; + ip: string | null; + user_agent: string | null; + meta_json: Record | null; + created_at: string; +}; + +function toAuthAuditLogRecord( + row: AuthAuditLogRow | undefined, +): AuthAuditLogRecord | null { + if (!row) { + return null; + } + + return { + id: row.id, + userId: row.user_id, + eventType: row.event_type, + detail: row.detail, + ip: row.ip, + userAgent: row.user_agent, + metaJson: row.meta_json, + createdAt: row.created_at, + }; +} + +export class AuthAuditLogRepository { + constructor(private readonly db: AppDatabase) {} + + async create(input: { + userId: string; + eventType: AuthAuditLogEventType; + detail: string; + ip: string | null; + userAgent: string | null; + metaJson?: Record | null; + }) { + const id = `audit_${crypto.randomBytes(16).toString('hex')}`; + const createdAt = new Date().toISOString(); + + const result = await this.db.query( + `INSERT INTO auth_audit_logs ( + id, + user_id, + event_type, + detail, + ip, + user_agent, + meta_json, + created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, user_id, event_type, detail, ip, user_agent, meta_json, created_at`, + [ + id, + input.userId, + input.eventType, + input.detail, + input.ip, + input.userAgent, + input.metaJson ?? null, + createdAt, + ], + ); + + return toAuthAuditLogRecord(result.rows[0]); + } + + async listRecentByUserId(userId: string, limit = 20) { + const result = await this.db.query( + `SELECT id, user_id, event_type, detail, ip, user_agent, meta_json, created_at + FROM auth_audit_logs + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2`, + [userId, limit], + ); + + return result.rows + .map((row) => toAuthAuditLogRecord(row)) + .filter((row): row is AuthAuditLogRecord => Boolean(row)); + } +} diff --git a/server-node/src/repositories/authIdentityRepository.ts b/server-node/src/repositories/authIdentityRepository.ts new file mode 100644 index 00000000..1ced36e5 --- /dev/null +++ b/server-node/src/repositories/authIdentityRepository.ts @@ -0,0 +1,156 @@ +import crypto from 'node:crypto'; + +import type { QueryResultRow } from 'pg'; + +import type { AppDatabase } from '../db.js'; + +export type AuthIdentityProvider = 'wechat'; + +export type AuthIdentityRecord = { + id: string; + userId: string; + provider: AuthIdentityProvider; + providerUid: string; + providerUnionId: string | null; + displayName: string | null; + avatarUrl: string | null; + isVerified: boolean; + metaJson: Record | null; + createdAt: string; + updatedAt: string; +}; + +type AuthIdentityRow = QueryResultRow & { + id: string; + user_id: string; + provider: AuthIdentityProvider; + provider_uid: string; + provider_unionid: string | null; + display_name: string | null; + avatar_url: string | null; + is_verified: boolean; + meta_json: Record | null; + created_at: string; + updated_at: string; +}; + +function toAuthIdentityRecord( + row: AuthIdentityRow | undefined, +): AuthIdentityRecord | null { + if (!row) { + return null; + } + + return { + id: row.id, + userId: row.user_id, + provider: row.provider, + providerUid: row.provider_uid, + providerUnionId: row.provider_unionid, + displayName: row.display_name, + avatarUrl: row.avatar_url, + isVerified: row.is_verified, + metaJson: row.meta_json, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export type CreateWechatIdentityInput = { + userId: string; + providerUid: string; + providerUnionId: string | null; + displayName: string | null; + avatarUrl: string | null; + metaJson?: Record | null; +}; + +export class AuthIdentityRepository { + constructor(private readonly db: AppDatabase) {} + + async findWechatIdentityByProfile(params: { + providerUid: string; + providerUnionId: string | null; + }) { + const result = params.providerUnionId + ? await this.db.query( + `SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at + FROM auth_identities + WHERE provider = 'wechat' + AND (provider_unionid = $1 OR provider_uid = $2) + ORDER BY + CASE WHEN provider_unionid = $1 THEN 0 ELSE 1 END + LIMIT 1`, + [params.providerUnionId, params.providerUid], + ) + : await this.db.query( + `SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at + FROM auth_identities + WHERE provider = 'wechat' + AND provider_uid = $1 + LIMIT 1`, + [params.providerUid], + ); + + return toAuthIdentityRecord(result.rows[0]); + } + + async listByUserId(userId: string) { + const result = await this.db.query( + `SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at + FROM auth_identities + WHERE user_id = $1 + ORDER BY provider, created_at`, + [userId], + ); + + return result.rows + .map((row) => toAuthIdentityRecord(row)) + .filter((row): row is AuthIdentityRecord => Boolean(row)); + } + + async createWechatIdentity(input: CreateWechatIdentityInput) { + const now = new Date().toISOString(); + const identityId = `authi_${crypto.randomBytes(16).toString('hex')}`; + const result = await this.db.query( + `INSERT INTO auth_identities ( + id, + user_id, + provider, + provider_uid, + provider_unionid, + display_name, + avatar_url, + is_verified, + meta_json, + created_at, + updated_at + ) + VALUES ($1, $2, 'wechat', $3, $4, $5, $6, TRUE, $7, $8, $9) + RETURNING id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at`, + [ + identityId, + input.userId, + input.providerUid, + input.providerUnionId, + input.displayName, + input.avatarUrl, + input.metaJson ?? null, + now, + now, + ], + ); + + return toAuthIdentityRecord(result.rows[0]); + } + + async moveWechatIdentitiesToUser(sourceUserId: string, targetUserId: string) { + await this.db.query( + `UPDATE auth_identities + SET user_id = $1, updated_at = $2 + WHERE user_id = $3 + AND provider = 'wechat'`, + [targetUserId, new Date().toISOString(), sourceUserId], + ); + } +} diff --git a/server-node/src/repositories/authRiskBlockRepository.ts b/server-node/src/repositories/authRiskBlockRepository.ts new file mode 100644 index 00000000..45ccf928 --- /dev/null +++ b/server-node/src/repositories/authRiskBlockRepository.ts @@ -0,0 +1,128 @@ +import crypto from 'node:crypto'; + +import type { QueryResultRow } from 'pg'; + +import type { AppDatabase } from '../db.js'; + +export type AuthRiskBlockScopeType = 'phone' | 'ip'; + +export type AuthRiskBlockRecord = { + id: string; + scopeType: AuthRiskBlockScopeType; + scopeKey: string; + reason: string; + expiresAt: string; + liftedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +type AuthRiskBlockRow = QueryResultRow & { + id: string; + scope_type: AuthRiskBlockScopeType; + scope_key: string; + reason: string; + expires_at: string; + lifted_at: string | null; + created_at: string; + updated_at: string; +}; + +function toAuthRiskBlockRecord( + row: AuthRiskBlockRow | undefined, +): AuthRiskBlockRecord | null { + if (!row) { + return null; + } + + return { + id: row.id, + scopeType: row.scope_type, + scopeKey: row.scope_key, + reason: row.reason, + expiresAt: row.expires_at, + liftedAt: row.lifted_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class AuthRiskBlockRepository { + constructor(private readonly db: AppDatabase) {} + + async findActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) { + const result = await this.db.query( + `SELECT id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at + FROM auth_risk_blocks + WHERE scope_type = $1 + AND scope_key = $2 + AND lifted_at IS NULL + AND expires_at > $3 + ORDER BY expires_at DESC + LIMIT 1`, + [scopeType, scopeKey, new Date().toISOString()], + ); + + return toAuthRiskBlockRecord(result.rows[0]); + } + + async createOrRefresh(input: { + scopeType: AuthRiskBlockScopeType; + scopeKey: string; + reason: string; + expiresAt: string; + }) { + const existing = await this.findActive(input.scopeType, input.scopeKey); + if (existing) { + const result = await this.db.query( + `UPDATE auth_risk_blocks + SET reason = $1, + expires_at = $2, + updated_at = $3 + WHERE id = $4 + RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`, + [input.reason, input.expiresAt, new Date().toISOString(), existing.id], + ); + return toAuthRiskBlockRecord(result.rows[0]); + } + + const id = `risk_${crypto.randomBytes(16).toString('hex')}`; + const now = new Date().toISOString(); + const result = await this.db.query( + `INSERT INTO auth_risk_blocks ( + id, + scope_type, + scope_key, + reason, + expires_at, + lifted_at, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, NULL, $6, $7) + RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`, + [id, input.scopeType, input.scopeKey, input.reason, input.expiresAt, now, now], + ); + + return toAuthRiskBlockRecord(result.rows[0]); + } + + async liftActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) { + const now = new Date().toISOString(); + const result = await this.db.query( + `UPDATE auth_risk_blocks + SET lifted_at = $1, + updated_at = $2 + WHERE scope_type = $3 + AND scope_key = $4 + AND lifted_at IS NULL + AND expires_at > $5 + RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`, + [now, now, scopeType, scopeKey, now], + ); + + return result.rows + .map((row) => toAuthRiskBlockRecord(row)) + .filter((row): row is AuthRiskBlockRecord => Boolean(row)); + } +} diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index 70a32d82..ec0e6fb4 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -1,10 +1,21 @@ +import type { QueryResultRow } from 'pg'; + +import { + DEFAULT_MUSIC_VOLUME, + SAVE_SNAPSHOT_VERSION, +} from '../../../packages/shared/src/contracts/runtime.js'; +import type { + CustomWorldProfileRecord, + RuntimeSettings, + SavedGameSnapshot, +} from '../../../packages/shared/src/contracts/runtime.js'; import type { AppDatabase } from '../db.js'; -const SAVE_SNAPSHOT_VERSION = 2; -const DEFAULT_MUSIC_VOLUME = 0.42; const MAX_CUSTOM_WORLD_PROFILES = 12; -export type SavedSnapshot = { +export type SavedSnapshot = SavedGameSnapshot; + +type SnapshotRow = QueryResultRow & { version: number; savedAt: string; gameState: unknown; @@ -12,37 +23,53 @@ export type SavedSnapshot = { currentStory: unknown; }; -export type RuntimeSettings = { +type SettingsRow = QueryResultRow & { musicVolume: number; }; -function parseJson(value: string): T { - return JSON.parse(value) as T; -} +type ProfileRow = QueryResultRow & { + payload: CustomWorldProfileRecord; +}; -function toJson(value: unknown) { - return JSON.stringify(value ?? null); -} +export type RuntimeRepositoryPort = { + getSnapshot(userId: string): Promise; + putSnapshot( + userId: string, + payload: Omit, + ): Promise; + deleteSnapshot(userId: string): Promise; + getSettings(userId: string): Promise; + putSettings( + userId: string, + settings: RuntimeSettings, + ): Promise; + listCustomWorldProfiles(userId: string): Promise; + upsertCustomWorldProfile( + userId: string, + profileId: string, + profile: Record, + ): Promise; + deleteCustomWorldProfile( + userId: string, + profileId: string, + ): Promise; +}; -export class RuntimeRepository { +export class RuntimeRepository implements RuntimeRepositoryPort { constructor(private readonly db: AppDatabase) {} - getSnapshot(userId: string) { - const row = this.db - .prepare( - `SELECT version, saved_at, game_state_json, bottom_tab, current_story_json - FROM save_snapshots - WHERE user_id = ?`, - ) - .get(userId) as - | { - version: number; - saved_at: string; - game_state_json: string; - bottom_tab: string; - current_story_json: string; - } - | undefined; + async getSnapshot(userId: string) { + const result = await this.db.query( + `SELECT version, + saved_at AS "savedAt", + game_state_json AS "gameState", + bottom_tab AS "bottomTab", + current_story_json AS "currentStory" + FROM save_snapshots + WHERE user_id = $1`, + [userId], + ); + const row = result.rows[0]; if (!row) { return null; @@ -50,14 +77,14 @@ export class RuntimeRepository { return { version: row.version, - savedAt: row.saved_at, - gameState: parseJson(row.game_state_json), - bottomTab: row.bottom_tab, - currentStory: parseJson(row.current_story_json), + savedAt: row.savedAt, + gameState: row.gameState, + bottomTab: row.bottomTab, + currentStory: row.currentStory, } satisfies SavedSnapshot; } - putSnapshot(userId: string, payload: Omit) { + async putSnapshot(userId: string, payload: Omit) { const snapshot = { version: SAVE_SNAPSHOT_VERSION, savedAt: payload.savedAt, @@ -67,115 +94,126 @@ export class RuntimeRepository { } satisfies SavedSnapshot; const now = new Date().toISOString(); - this.db - .prepare( - `INSERT INTO save_snapshots ( + const result = await this.db.query( + `INSERT INTO save_snapshots ( user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - version = excluded.version, - saved_at = excluded.saved_at, - bottom_tab = excluded.bottom_tab, - game_state_json = excluded.game_state_json, - current_story_json = excluded.current_story_json, - updated_at = excluded.updated_at`, - ) - .run( + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_id) DO UPDATE SET + version = EXCLUDED.version, + saved_at = EXCLUDED.saved_at, + bottom_tab = EXCLUDED.bottom_tab, + game_state_json = EXCLUDED.game_state_json, + current_story_json = EXCLUDED.current_story_json, + updated_at = EXCLUDED.updated_at + RETURNING version, + saved_at AS "savedAt", + game_state_json AS "gameState", + bottom_tab AS "bottomTab", + current_story_json AS "currentStory"`, + [ userId, snapshot.version, snapshot.savedAt, snapshot.bottomTab, - toJson(snapshot.gameState), - toJson(snapshot.currentStory), + snapshot.gameState, + snapshot.currentStory, now, - ); + ], + ); - return snapshot; + const row = result.rows[0]; + + return { + version: row.version, + savedAt: row.savedAt, + gameState: row.gameState, + bottomTab: row.bottomTab, + currentStory: row.currentStory, + } satisfies SavedSnapshot; } - deleteSnapshot(userId: string) { - this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId); + async deleteSnapshot(userId: string) { + await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [userId]); } - getSettings(userId: string) { - const row = this.db - .prepare( - `SELECT music_volume - FROM runtime_settings - WHERE user_id = ?`, - ) - .get(userId) as { music_volume: number } | undefined; + async getSettings(userId: string) { + const result = await this.db.query( + `SELECT music_volume AS "musicVolume" + FROM runtime_settings + WHERE user_id = $1`, + [userId], + ); + const row = result.rows[0]; return { musicVolume: - typeof row?.music_volume === 'number' - ? row.music_volume + typeof row?.musicVolume === 'number' + ? row.musicVolume : DEFAULT_MUSIC_VOLUME, } satisfies RuntimeSettings; } - putSettings(userId: string, settings: RuntimeSettings) { + async putSettings(userId: string, settings: RuntimeSettings) { const nextSettings = { musicVolume: Math.max(0, Math.min(1, settings.musicVolume)), } satisfies RuntimeSettings; - this.db - .prepare( - `INSERT INTO runtime_settings (user_id, music_volume, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - music_volume = excluded.music_volume, - updated_at = excluded.updated_at`, - ) - .run(userId, nextSettings.musicVolume, new Date().toISOString()); + const result = await this.db.query( + `INSERT INTO runtime_settings (user_id, music_volume, updated_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE SET + music_volume = EXCLUDED.music_volume, + updated_at = EXCLUDED.updated_at + RETURNING music_volume AS "musicVolume"`, + [userId, nextSettings.musicVolume, new Date().toISOString()], + ); - return nextSettings; + return { + musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume, + } satisfies RuntimeSettings; } - listCustomWorldProfiles(userId: string) { - const rows = this.db - .prepare( - `SELECT payload_json - FROM custom_world_profiles - WHERE user_id = ? - ORDER BY updated_at DESC - LIMIT ?`, - ) - .all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>; + async listCustomWorldProfiles(userId: string) { + const result = await this.db.query( + `SELECT payload_json AS payload + FROM custom_world_profiles + WHERE user_id = $1 + ORDER BY updated_at DESC + LIMIT $2`, + [userId, MAX_CUSTOM_WORLD_PROFILES], + ); - return rows.map((row) => parseJson>(row.payload_json)); + return result.rows.map((row: ProfileRow) => row.payload); } - upsertCustomWorldProfile( + async upsertCustomWorldProfile( userId: string, profileId: string, - profile: Record, + profile: CustomWorldProfileRecord, ) { const payload = { ...profile, id: profileId, }; - this.db - .prepare( - `INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(user_id, profile_id) DO UPDATE SET - payload_json = excluded.payload_json, - updated_at = excluded.updated_at`, - ) - .run(userId, profileId, JSON.stringify(payload), new Date().toISOString()); + await this.db.query( + `INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, profile_id) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = EXCLUDED.updated_at`, + [userId, profileId, payload, new Date().toISOString()], + ); return this.listCustomWorldProfiles(userId); } - deleteCustomWorldProfile(userId: string, profileId: string) { - this.db - .prepare( - `DELETE FROM custom_world_profiles - WHERE user_id = ? AND profile_id = ?`, - ) - .run(userId, profileId); + async deleteCustomWorldProfile(userId: string, profileId: string) { + await this.db.query( + `DELETE FROM custom_world_profiles + WHERE user_id = $1 AND profile_id = $2`, + [userId, profileId], + ); return this.listCustomWorldProfiles(userId); } diff --git a/server-node/src/repositories/smsAuthEventRepository.ts b/server-node/src/repositories/smsAuthEventRepository.ts new file mode 100644 index 00000000..beb4587c --- /dev/null +++ b/server-node/src/repositories/smsAuthEventRepository.ts @@ -0,0 +1,102 @@ +import crypto from 'node:crypto'; + +import type { QueryResultRow } from 'pg'; + +import type { AppDatabase } from '../db.js'; + +export type SmsAuthScene = 'login' | 'bind_phone' | 'change_phone'; +export type SmsAuthAction = 'send_code' | 'verify_code'; + +type SmsAuthEventRow = QueryResultRow & { + total: number; +}; + +export class SmsAuthEventRepository { + constructor(private readonly db: AppDatabase) {} + + async create(input: { + phoneNumber: string; + scene: SmsAuthScene; + action: SmsAuthAction; + success: boolean; + ip: string | null; + userAgent: string | null; + }) { + const id = `smsev_${crypto.randomBytes(16).toString('hex')}`; + await this.db.query( + `INSERT INTO sms_auth_events ( + id, + phone_number, + scene, + action, + success, + ip, + user_agent, + created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + id, + input.phoneNumber, + input.scene, + input.action, + input.success, + input.ip, + input.userAgent, + new Date().toISOString(), + ], + ); + } + + async countSinceByPhone(params: { + phoneNumber: string; + action: SmsAuthAction; + success?: boolean; + since: string; + }) { + const result = await this.db.query( + `SELECT COUNT(*)::int AS total + FROM sms_auth_events + WHERE phone_number = $1 + AND action = $2 + AND ($3::boolean IS NULL OR success = $3) + AND created_at >= $4`, + [ + params.phoneNumber, + params.action, + params.success ?? null, + params.since, + ], + ); + + return result.rows[0]?.total ?? 0; + } + + async countSinceByIp(params: { + ip: string | null; + action: SmsAuthAction; + success?: boolean; + since: string; + }) { + if (!params.ip) { + return 0; + } + + const result = await this.db.query( + `SELECT COUNT(*)::int AS total + FROM sms_auth_events + WHERE ip = $1 + AND action = $2 + AND ($3::boolean IS NULL OR success = $3) + AND created_at >= $4`, + [ + params.ip, + params.action, + params.success ?? null, + params.since, + ], + ); + + return result.rows[0]?.total ?? 0; + } +} diff --git a/server-node/src/repositories/userRepository.ts b/server-node/src/repositories/userRepository.ts index 95da9992..9d10fa21 100644 --- a/server-node/src/repositories/userRepository.ts +++ b/server-node/src/repositories/userRepository.ts @@ -1,21 +1,33 @@ import crypto from 'node:crypto'; +import type { QueryResultRow } from 'pg'; + import type { AppDatabase } from '../db.js'; export type UserRecord = { id: string; - username: string; + username: string | null; passwordHash: string; tokenVersion: number; + displayName: string; + loginProvider: 'password' | 'phone' | 'wechat'; + accountStatus: 'active' | 'pending_bind_phone' | 'disabled'; + phoneNumber: string | null; + phoneVerifiedAt: string | null; createdAt: string; updatedAt: string; }; -type UserRow = { +type UserRow = QueryResultRow & { id: string; - username: string; + username: string | null; password_hash: string; token_version: number; + display_name: string; + login_provider: 'password' | 'phone' | 'wechat'; + account_status: 'active' | 'pending_bind_phone' | 'disabled'; + phone_number: string | null; + phone_verified_at: string | null; created_at: string; updated_at: string; }; @@ -30,59 +42,249 @@ function toUserRecord(row: UserRow | undefined): UserRecord | null { username: row.username, passwordHash: row.password_hash, tokenVersion: row.token_version, + displayName: row.display_name, + loginProvider: row.login_provider, + accountStatus: row.account_status, + phoneNumber: row.phone_number, + phoneVerifiedAt: row.phone_verified_at, createdAt: row.created_at, updatedAt: row.updated_at, }; } -export class UserRepository { +export type CreatePhoneUserInput = { + username: string; + passwordHash: string; + displayName: string; + phoneNumber: string; + phoneVerifiedAt: string; +}; + +export type CreateWechatPendingUserInput = { + username: string; + passwordHash: string; + displayName: string; +}; + +export type UserRepositoryPort = { + findByUsername(username: string): Promise; + findByPhoneNumber(phoneNumber: string): Promise; + findById(userId: string): Promise; + create(username: string, passwordHash: string): Promise; + createPhoneUser(input: CreatePhoneUserInput): Promise; + createWechatPendingUser( + input: CreateWechatPendingUserInput, + ): Promise; + activatePendingWechatUser( + userId: string, + params: { + displayName: string; + phoneNumber: string; + phoneVerifiedAt: string; + }, + ): Promise; + updatePhoneInfo( + userId: string, + params: { + phoneNumber: string; + phoneVerifiedAt: string; + displayName?: string; + }, + ): Promise; + deleteUser(userId: string): Promise; + incrementTokenVersion(userId: string): Promise; +}; + +export class UserRepository implements UserRepositoryPort { constructor(private readonly db: AppDatabase) {} - findByUsername(username: string) { - const row = this.db - .prepare( - `SELECT id, username, password_hash, token_version, created_at, updated_at - FROM users - WHERE username = ?`, - ) - .get(username) as UserRow | undefined; - return toUserRecord(row); + async findByUsername(username: string) { + const result = await this.db.query( + `SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at + FROM users + WHERE username = $1`, + [username], + ); + return toUserRecord(result.rows[0]); } - findById(userId: string) { - const row = this.db - .prepare( - `SELECT id, username, password_hash, token_version, created_at, updated_at - FROM users - WHERE id = ?`, - ) - .get(userId) as UserRow | undefined; - return toUserRecord(row); + async findByPhoneNumber(phoneNumber: string) { + const result = await this.db.query( + `SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at + FROM users + WHERE phone_number = $1`, + [phoneNumber], + ); + return toUserRecord(result.rows[0]); } - create(username: string, passwordHash: string) { + async findById(userId: string) { + const result = await this.db.query( + `SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at + FROM users + WHERE id = $1`, + [userId], + ); + return toUserRecord(result.rows[0]); + } + + async create(username: string, passwordHash: string) { const now = new Date().toISOString(); const id = `user_${crypto.randomBytes(16).toString('hex')}`; - this.db - .prepare( - `INSERT INTO users (id, username, password_hash, token_version, created_at, updated_at) - VALUES (?, ?, ?, 1, ?, ?)`, - ) - .run(id, username, passwordHash, now, now); + const result = await this.db.query( + `INSERT INTO users ( + id, + username, + password_hash, + token_version, + display_name, + login_provider, + account_status, + created_at, + updated_at + ) + VALUES ($1, $2, $3, 1, $4, 'password', 'active', $5, $6) + RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, + [id, username, passwordHash, username, now, now], + ); - return this.findById(id); + return toUserRecord(result.rows[0]); } - incrementTokenVersion(userId: string) { - this.db - .prepare( - `UPDATE users - SET token_version = token_version + 1, updated_at = ? - WHERE id = ?`, - ) - .run(new Date().toISOString(), userId); + async createPhoneUser(input: CreatePhoneUserInput) { + const now = new Date().toISOString(); + const id = `user_${crypto.randomBytes(16).toString('hex')}`; - return this.findById(userId); + const result = await this.db.query( + `INSERT INTO users ( + id, + username, + password_hash, + token_version, + display_name, + login_provider, + account_status, + phone_number, + phone_verified_at, + created_at, + updated_at + ) + VALUES ($1, $2, $3, 1, $4, 'phone', 'active', $5, $6, $7, $8) + RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, + [ + id, + input.username, + input.passwordHash, + input.displayName, + input.phoneNumber, + input.phoneVerifiedAt, + now, + now, + ], + ); + + return toUserRecord(result.rows[0]); + } + + async createWechatPendingUser(input: CreateWechatPendingUserInput) { + const now = new Date().toISOString(); + const id = `user_${crypto.randomBytes(16).toString('hex')}`; + + const result = await this.db.query( + `INSERT INTO users ( + id, + username, + password_hash, + token_version, + display_name, + login_provider, + account_status, + created_at, + updated_at + ) + VALUES ($1, $2, $3, 1, $4, 'wechat', 'pending_bind_phone', $5, $6) + RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, + [id, input.username, input.passwordHash, input.displayName, now, now], + ); + + return toUserRecord(result.rows[0]); + } + + async activatePendingWechatUser( + userId: string, + params: { + displayName: string; + phoneNumber: string; + phoneVerifiedAt: string; + }, + ) { + const result = await this.db.query( + `UPDATE users + SET account_status = 'active', + phone_number = $1, + phone_verified_at = $2, + display_name = $3, + updated_at = $4 + WHERE id = $5 + RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, + [ + params.phoneNumber, + params.phoneVerifiedAt, + params.displayName, + new Date().toISOString(), + userId, + ], + ); + + return toUserRecord(result.rows[0]); + } + + async updatePhoneInfo( + userId: string, + params: { + phoneNumber: string; + phoneVerifiedAt: string; + displayName?: string; + }, + ) { + const result = await this.db.query( + `UPDATE users + SET phone_number = $1, + phone_verified_at = $2, + display_name = COALESCE($3, display_name), + updated_at = $4 + WHERE id = $5 + RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, + [ + params.phoneNumber, + params.phoneVerifiedAt, + params.displayName ?? null, + new Date().toISOString(), + userId, + ], + ); + + return toUserRecord(result.rows[0]); + } + + async deleteUser(userId: string) { + await this.db.query( + `DELETE FROM users + WHERE id = $1`, + [userId], + ); + } + + async incrementTokenVersion(userId: string) { + const result = await this.db.query( + `UPDATE users + SET token_version = token_version + 1, updated_at = $1 + WHERE id = $2 + RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, + [new Date().toISOString(), userId], + ); + + return toUserRecord(result.rows[0]); } } diff --git a/server-node/src/repositories/userSessionRepository.ts b/server-node/src/repositories/userSessionRepository.ts new file mode 100644 index 00000000..50d4a46e --- /dev/null +++ b/server-node/src/repositories/userSessionRepository.ts @@ -0,0 +1,214 @@ +import crypto from 'node:crypto'; + +import type { QueryResultRow } from 'pg'; + +import type { AppDatabase } from '../db.js'; + +export type UserSessionRecord = { + id: string; + userId: string; + refreshTokenHash: string; + clientType: string; + userAgent: string | null; + ip: string | null; + expiresAt: string; + revokedAt: string | null; + createdAt: string; + updatedAt: string; + lastSeenAt: string; +}; + +type UserSessionRow = QueryResultRow & { + id: string; + user_id: string; + refresh_token_hash: string; + client_type: string; + user_agent: string | null; + ip: string | null; + expires_at: string; + revoked_at: string | null; + created_at: string; + updated_at: string; + last_seen_at: string; +}; + +function toUserSessionRecord( + row: UserSessionRow | undefined, +): UserSessionRecord | null { + if (!row) { + return null; + } + + return { + id: row.id, + userId: row.user_id, + refreshTokenHash: row.refresh_token_hash, + clientType: row.client_type, + userAgent: row.user_agent, + ip: row.ip, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + lastSeenAt: row.last_seen_at, + }; +} + +export type CreateUserSessionInput = { + userId: string; + refreshTokenHash: string; + clientType: string; + userAgent: string | null; + ip: string | null; + expiresAt: string; +}; + +export class UserSessionRepository { + constructor(private readonly db: AppDatabase) {} + + async create(input: CreateUserSessionInput) { + const now = new Date().toISOString(); + const sessionId = `usess_${crypto.randomBytes(16).toString('hex')}`; + + const result = await this.db.query( + `INSERT INTO user_sessions ( + id, + user_id, + refresh_token_hash, + client_type, + user_agent, + ip, + expires_at, + revoked_at, + created_at, + updated_at, + last_seen_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9, $10) + RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, + [ + sessionId, + input.userId, + input.refreshTokenHash, + input.clientType, + input.userAgent, + input.ip, + input.expiresAt, + now, + now, + now, + ], + ); + + return toUserSessionRecord(result.rows[0]); + } + + async findActiveByRefreshTokenHash(refreshTokenHash: string) { + const result = await this.db.query( + `SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at + FROM user_sessions + WHERE refresh_token_hash = $1 + LIMIT 1`, + [refreshTokenHash], + ); + + return toUserSessionRecord(result.rows[0]); + } + + async rotate( + sessionId: string, + input: { + refreshTokenHash: string; + expiresAt: string; + lastSeenAt: string; + }, + ) { + const result = await this.db.query( + `UPDATE user_sessions + SET refresh_token_hash = $1, + expires_at = $2, + last_seen_at = $3, + updated_at = $4 + WHERE id = $5 + RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, + [ + input.refreshTokenHash, + input.expiresAt, + input.lastSeenAt, + new Date().toISOString(), + sessionId, + ], + ); + + return toUserSessionRecord(result.rows[0]); + } + + async revoke(sessionId: string) { + const now = new Date().toISOString(); + const result = await this.db.query( + `UPDATE user_sessions + SET revoked_at = $1, + updated_at = $2 + WHERE id = $3 + RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, + [now, now, sessionId], + ); + + return toUserSessionRecord(result.rows[0]); + } + + async findById(sessionId: string) { + const result = await this.db.query( + `SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at + FROM user_sessions + WHERE id = $1 + LIMIT 1`, + [sessionId], + ); + + return toUserSessionRecord(result.rows[0]); + } + + async listActiveByUserId(userId: string) { + const result = await this.db.query( + `SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at + FROM user_sessions + WHERE user_id = $1 + AND revoked_at IS NULL + ORDER BY last_seen_at DESC, created_at DESC`, + [userId], + ); + + return result.rows + .map((row) => toUserSessionRecord(row)) + .filter((row): row is UserSessionRecord => Boolean(row)); + } + + async revokeAllByUserId(userId: string) { + const now = new Date().toISOString(); + await this.db.query( + `UPDATE user_sessions + SET revoked_at = $1, + updated_at = $2 + WHERE user_id = $3 + AND revoked_at IS NULL`, + [now, now, userId], + ); + } + + async revokeByUserIdAndSessionId(userId: string, sessionId: string) { + const now = new Date().toISOString(); + const result = await this.db.query( + `UPDATE user_sessions + SET revoked_at = $1, + updated_at = $2 + WHERE user_id = $3 + AND id = $4 + AND revoked_at IS NULL + RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, + [now, now, userId, sessionId], + ); + + return toUserSessionRecord(result.rows[0]); + } +} diff --git a/server-node/src/routes/authRoutes.ts b/server-node/src/routes/authRoutes.ts index 55c06ab2..3aa6b62c 100644 --- a/server-node/src/routes/authRoutes.ts +++ b/server-node/src/routes/authRoutes.ts @@ -1,51 +1,497 @@ -import { Router } from 'express'; +import { type Request, Router } from 'express'; import { z } from 'zod'; -import { entryWithPassword, logoutUser } from '../auth/authService.js'; +import type { + AuthEntryRequest, + AuthPhoneChangeRequest, + AuthPhoneLoginRequest, + AuthPhoneSendCodeRequest, + AuthWechatBindPhoneRequest, +} from '../../../packages/shared/src/contracts/auth.js'; +import { buildAuthRequestContext } from '../auth/authRequestContext.js'; +import { + bindWechatPhone, + buildAuthMeResponse, + changeUserPhone, + createRefreshSession, + entryWithPassword, + entryWithPhoneCode, + liftRiskBlock, + listActiveRiskBlocks, + listAuthAuditLogs, + listUserSessions, + logoutAllUserSessions, + logoutUser, + refreshAuthSession, + resolveWechatCallback, + revokeRefreshSession, + revokeUserSession, + sendPhoneLoginCode, + startWechatLogin, +} from '../auth/authService.js'; +import { + clearRefreshSessionCookie, + readRefreshSessionToken, + setRefreshSessionCookie, +} from '../auth/refreshSessionCookie.js'; import type { AppContext } from '../context.js'; -import { asyncHandler } from '../http.js'; +import { asyncHandler, sendApiResponse } from '../http.js'; import { requireJwtAuth } from '../middleware/auth.js'; +import { routeMeta } from '../middleware/routeMeta.js'; const authEntrySchema = z.object({ username: z.string(), password: z.string(), }); +const authPhoneSendCodeSchema = z.object({ + phone: z.string(), + scene: z.enum(['login', 'bind_phone', 'change_phone']).optional(), + captchaChallengeId: z.string().optional(), + captchaAnswer: z.string().optional(), +}); + +const authPhoneLoginSchema = z.object({ + phone: z.string(), + code: z.string(), +}); + +const authPhoneChangeSchema = z.object({ + phone: z.string(), + code: z.string(), +}); + +const authWechatBindPhoneSchema = z.object({ + phone: z.string(), + code: z.string(), +}); + +function resolveRequestOrigin(request: Request) { + const forwardedProto = request.header('x-forwarded-proto')?.split(',')[0]?.trim(); + const forwardedHost = request.header('x-forwarded-host')?.split(',')[0]?.trim(); + const protocol = forwardedProto || request.protocol || 'http'; + const host = forwardedHost || request.header('host') || '127.0.0.1:8081'; + return `${protocol}://${host}`; +} + +function normalizeRedirectPath(rawValue: unknown, fallback: string) { + if (typeof rawValue !== 'string' || !rawValue.trim()) { + return fallback; + } + + const value = rawValue.trim(); + if (value.startsWith('/')) { + return value; + } + + try { + const url = new URL(value); + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return fallback; + } +} + +function buildAuthResultRedirectUrl( + redirectPath: string, + params: Record, +) { + const hash = new URLSearchParams(params).toString(); + const [pathWithoutHash] = redirectPath.split('#'); + return `${pathWithoutHash || '/'}#${hash}`; +} + +function buildRefreshCookieLifetimeSeconds( + context: AppContext, + expiresAt: string, +) { + return Math.max( + 0, + Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000), + ); +} + export function createAuthRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); router.post( '/entry', + routeMeta({ operation: 'auth.entry' }), asyncHandler(async (request, response) => { - const payload = authEntrySchema.parse(request.body); - response.json( - await entryWithPassword(context, payload.username, payload.password), + const payload = authEntrySchema.parse(request.body) as AuthEntryRequest; + const requestContext = buildAuthRequestContext(request); + const result = await entryWithPassword( + context, + payload.username, + payload.password, + requestContext, + ); + const user = await context.userRepository.findById(result.user.id); + if (!user) { + throw new Error('failed to resolve auth user after password entry'); + } + const refreshSession = await createRefreshSession( + context, + user, + requestContext, + ); + setRefreshSessionCookie( + response, + context.config, + refreshSession.refreshToken, + buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), + ); + sendApiResponse(response, result); + }), + ); + + router.post( + '/phone/send-code', + routeMeta({ operation: 'auth.phone.send_code' }), + asyncHandler(async (request, response) => { + const payload = authPhoneSendCodeSchema.parse( + request.body, + ) as AuthPhoneSendCodeRequest; + sendApiResponse( + response, + await sendPhoneLoginCode( + context, + payload.phone, + payload.scene, + buildAuthRequestContext(request), + { + captchaChallengeId: payload.captchaChallengeId, + captchaAnswer: payload.captchaAnswer, + }, + ), + ); + }), + ); + + router.post( + '/phone/change', + routeMeta({ operation: 'auth.phone.change' }), + requireAuth, + asyncHandler(async (request, response) => { + const payload = authPhoneChangeSchema.parse( + request.body, + ) as AuthPhoneChangeRequest; + const requestContext = buildAuthRequestContext(request); + sendApiResponse( + response, + await changeUserPhone( + context, + request.userId!, + payload.phone, + payload.code, + requestContext, + ), + ); + }), + ); + + router.post( + '/phone/login', + routeMeta({ operation: 'auth.phone.login' }), + asyncHandler(async (request, response) => { + const payload = authPhoneLoginSchema.parse( + request.body, + ) as AuthPhoneLoginRequest; + const requestContext = buildAuthRequestContext(request); + const result = await entryWithPhoneCode( + context, + payload.phone, + payload.code, + requestContext, + ); + const user = await context.userRepository.findById(result.user.id); + if (!user) { + throw new Error('failed to resolve auth user after phone entry'); + } + const refreshSession = await createRefreshSession( + context, + user, + requestContext, + ); + setRefreshSessionCookie( + response, + context.config, + refreshSession.refreshToken, + buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), + ); + sendApiResponse(response, result); + }), + ); + + router.get( + '/wechat/start', + routeMeta({ operation: 'auth.wechat.start' }), + asyncHandler(async (request, response) => { + const redirectPath = normalizeRedirectPath( + request.query.redirectPath, + context.config.wechatAuth.defaultRedirectPath, + ); + const callbackUrl = new URL( + context.config.wechatAuth.callbackPath, + resolveRequestOrigin(request), + ).toString(); + + sendApiResponse( + response, + await startWechatLogin(context, callbackUrl, redirectPath), + ); + }), + ); + + router.get( + '/wechat/callback', + routeMeta({ operation: 'auth.wechat.callback' }), + asyncHandler(async (request, response) => { + const state = + typeof request.query.state === 'string' ? request.query.state.trim() : ''; + const stateRecord = context.wechatAuthStates.consume(state); + const redirectPath = + stateRecord?.redirectPath ?? context.config.wechatAuth.defaultRedirectPath; + + if (!stateRecord) { + response.redirect( + 302, + buildAuthResultRedirectUrl(redirectPath, { + auth_provider: 'wechat', + auth_error: '微信登录状态已失效,请重新发起登录。', + }), + ); + return; + } + + try { + const requestContext = buildAuthRequestContext(request); + const result = await resolveWechatCallback(context, { + code: typeof request.query.code === 'string' ? request.query.code : null, + mockCode: + typeof request.query.mock_code === 'string' + ? request.query.mock_code + : null, + }, requestContext); + const user = await context.userRepository.findById(result.user.id); + if (!user) { + throw new Error('failed to resolve auth user after wechat callback'); + } + const refreshSession = await createRefreshSession( + context, + user, + requestContext, + ); + setRefreshSessionCookie( + response, + context.config, + refreshSession.refreshToken, + buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), + ); + + response.redirect( + 302, + buildAuthResultRedirectUrl(redirectPath, { + auth_provider: 'wechat', + auth_token: result.token, + auth_binding_status: result.user.bindingStatus, + }), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : '微信登录失败,请稍后再试。'; + response.redirect( + 302, + buildAuthResultRedirectUrl(redirectPath, { + auth_provider: 'wechat', + auth_error: message, + }), + ); + } + }), + ); + + router.post( + '/wechat/bind-phone', + routeMeta({ operation: 'auth.wechat.bind_phone' }), + requireAuth, + asyncHandler(async (request, response) => { + const payload = authWechatBindPhoneSchema.parse( + request.body, + ) as AuthWechatBindPhoneRequest; + const requestContext = buildAuthRequestContext(request); + const result = await bindWechatPhone( + context, + request.userId!, + payload.phone, + payload.code, + requestContext, + ); + const user = await context.userRepository.findById(result.user.id); + if (!user) { + throw new Error('failed to resolve auth user after wechat bind'); + } + const refreshSession = await createRefreshSession( + context, + user, + requestContext, + ); + setRefreshSessionCookie( + response, + context.config, + refreshSession.refreshToken, + buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), + ); + sendApiResponse(response, result); + }), + ); + + router.post( + '/refresh', + routeMeta({ operation: 'auth.refresh' }), + asyncHandler(async (request, response) => { + const refreshToken = readRefreshSessionToken(request, context.config); + try { + const result = await refreshAuthSession(context, refreshToken); + setRefreshSessionCookie( + response, + context.config, + result.refreshToken, + buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt), + ); + sendApiResponse(response, { + token: result.token, + }); + } catch (error) { + clearRefreshSessionCookie(response, context.config); + throw error; + } + }), + ); + + router.get( + '/risk-blocks', + routeMeta({ operation: 'auth.risk_blocks' }), + requireAuth, + asyncHandler(async (request, response) => { + const user = await context.userRepository.findById(request.userId!); + sendApiResponse( + response, + await listActiveRiskBlocks( + context, + user!, + buildAuthRequestContext(request), + ), + ); + }), + ); + + router.post( + '/risk-blocks/:scopeType/lift', + routeMeta({ operation: 'auth.risk_blocks.lift' }), + requireAuth, + asyncHandler(async (request, response) => { + const user = await context.userRepository.findById(request.userId!); + sendApiResponse( + response, + await liftRiskBlock( + context, + user!, + buildAuthRequestContext(request), + request.params.scopeType === 'phone' ? 'phone' : 'ip', + ), + ); + }), + ); + + router.get( + '/sessions', + routeMeta({ operation: 'auth.sessions' }), + requireAuth, + asyncHandler(async (request, response) => { + const refreshToken = readRefreshSessionToken(request, context.config); + sendApiResponse( + response, + await listUserSessions(context, request.userId!, refreshToken), + ); + }), + ); + + router.post( + '/sessions/:sessionId/revoke', + routeMeta({ operation: 'auth.sessions.revoke' }), + requireAuth, + asyncHandler(async (request, response) => { + const refreshToken = readRefreshSessionToken(request, context.config); + sendApiResponse( + response, + await revokeUserSession( + context, + request.userId!, + request.params.sessionId, + refreshToken, + buildAuthRequestContext(request), + ), + ); + }), + ); + + router.get( + '/audit-logs', + routeMeta({ operation: 'auth.audit_logs' }), + requireAuth, + asyncHandler(async (request, response) => { + sendApiResponse( + response, + await listAuthAuditLogs(context, request.userId!), ); }), ); router.get( '/me', + routeMeta({ operation: 'auth.me' }), requireAuth, asyncHandler(async (request, response) => { - const user = context.userRepository.findById(request.userId!); - response.json({ - user: user - ? { - id: user.id, - username: user.username, - } - : null, - }); + const user = await context.userRepository.findById(request.userId!); + sendApiResponse(response, await buildAuthMeResponse(context, user)); + }), + ); + + router.post( + '/logout-all', + routeMeta({ operation: 'auth.logout_all' }), + requireAuth, + asyncHandler(async (request, response) => { + clearRefreshSessionCookie(response, context.config); + sendApiResponse( + response, + await logoutAllUserSessions( + context, + request.userId!, + buildAuthRequestContext(request), + ), + ); }), ); router.post( '/logout', + routeMeta({ operation: 'auth.logout' }), requireAuth, asyncHandler(async (request, response) => { - response.json(await logoutUser(context, request.userId!)); + const refreshToken = readRefreshSessionToken(request, context.config); + await revokeRefreshSession(context, refreshToken); + clearRefreshSessionCookie(response, context.config); + sendApiResponse( + response, + await logoutUser( + context, + request.userId!, + buildAuthRequestContext(request), + ), + ); }), ); diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index c5f21437..71bf17bc 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -1,27 +1,67 @@ import { Router } from 'express'; import { z } from 'zod'; -import type { GameState } from '../../../src/types/game.js'; import type { - RuntimeItemGenerationContext, - RuntimeItemPlan, -} from '../../../src/types/runtimeItem.js'; -import type { Encounter } from '../../../src/types/scene.js'; + AnswerCustomWorldSessionQuestionRequest, + CreateCustomWorldSessionRequest, + RuntimeSettings, + SavedGameSnapshotInput, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { CUSTOM_WORLD_GENERATION_MODES } from '../../../packages/shared/src/contracts/runtime.js'; +import type { + QuestGenerationRequest, + RuntimeItemIntentRequest, +} from '../../../packages/shared/src/contracts/story.js'; +import type { + CharacterChatReplyRequest, + CharacterChatSuggestionsRequest, + CharacterChatSummaryRequest, + NpcChatDialogueRequest, + NpcRecruitDialogueRequest, +} from '../../../packages/shared/src/contracts/story.js'; import type { AppContext } from '../context.js'; import { badRequest, notFound } from '../errors.js'; -import { asyncHandler, jsonClone } from '../http.js'; +import { + asyncHandler, + jsonClone, + prepareEventStreamResponse, + sendApiResponse, +} from '../http.js'; +import { + generateCharacterChatSuggestionsFromOrchestrator, + generateCharacterChatSummaryFromOrchestrator, + streamCharacterChatReplyFromOrchestrator, + streamNpcChatDialogueFromOrchestrator, + streamNpcRecruitDialogueFromOrchestrator, +} from '../modules/ai/chatOrchestrator.js'; import { requireJwtAuth } from '../middleware/auth.js'; -import { plainTextRequestSchema } from '../services/chatService.js'; +import { routeMeta } from '../middleware/routeMeta.js'; +import { + hydrateSavedSnapshot, + normalizeSavedSnapshotPayload, +} from '../modules/runtime/runtimeSnapshotHydration.js'; +import { + characterChatReplyRequestSchema, + characterChatSuggestionsRequestSchema, + characterChatSummaryRequestSchema, + npcChatDialogueRequestSchema, + npcRecruitDialogueRequestSchema, +} from '../services/chatService.js'; import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js'; import { generateQuestForNpcEncounter } from '../services/questService.js'; import { generateRuntimeItemIntents } from '../services/runtimeItemService.js'; -import { generateSceneImage, sceneImageSchema } from '../services/sceneImageService.js'; +import { + generateSceneImage, + sceneImageSchema, +} from '../services/sceneImageService.js'; import { generateHighQualityInitialStory, generateHighQualityNextStory, parseStoryRequest, } from '../services/storyService.js'; +const jsonObjectSchema = z.record(z.string(), z.unknown()); + const saveSnapshotSchema = z.object({ gameState: z.unknown(), bottomTab: z.string().trim().min(1), @@ -34,13 +74,13 @@ const settingsSchema = z.object({ }); const customWorldProfileSchema = z.object({ - profile: z.record(z.string(), z.unknown()), + profile: jsonObjectSchema, }); const customWorldSessionSchema = z.object({ settingText: z.string().trim().min(1), - creatorIntent: z.record(z.string(), z.unknown()).nullable().optional().default(null), - generationMode: z.enum(['fast', 'full']).default('fast'), + creatorIntent: jsonObjectSchema.nullable().optional().default(null), + generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'), }); const customWorldAnswerSchema = z.object({ @@ -49,16 +89,16 @@ const customWorldAnswerSchema = z.object({ }); const runtimeItemIntentSchema = z.object({ - context: z.custom(), - plans: z.array(z.custom()), + context: jsonObjectSchema, + plans: z.array(jsonObjectSchema), }); const questGenerationSchema = z.object({ - state: z.custom(), - encounter: z.custom(), + state: jsonObjectSchema, + encounter: jsonObjectSchema, }); -const llmProxySchema = z.record(z.string(), z.unknown()); +const llmProxySchema = jsonObjectSchema; function readParam(param: string | string[] | undefined) { return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; @@ -72,84 +112,115 @@ export function createRuntimeRoutes(context: AppContext) { router.post( '/llm/chat/completions', + routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }), asyncHandler(async (request, response) => { const body = llmProxySchema.parse(request.body); - await context.llmClient.forwardCompletion(body, response); + await context.llmClient.forwardCompletion(request, body, response); }), ); router.post( '/custom-world/scene-image', + routeMeta({ operation: 'runtime.customWorld.sceneImage' }), asyncHandler(async (request, response) => { const payload = sceneImageSchema.parse(request.body); - response.json(await generateSceneImage(context, payload)); + sendApiResponse(response, await generateSceneImage(context, payload)); }), ); router.get( '/runtime/save/snapshot', + routeMeta({ operation: 'runtime.snapshot.get' }), asyncHandler(async (request, response) => { - response.json(context.runtimeRepository.getSnapshot(request.userId!) ?? null); + sendApiResponse( + response, + hydrateSavedSnapshot( + await context.runtimeRepository.getSnapshot(request.userId!), + ), + ); }), ); router.put( '/runtime/save/snapshot', + routeMeta({ operation: 'runtime.snapshot.put' }), asyncHandler(async (request, response) => { - const payload = saveSnapshotSchema.parse(request.body); - response.json( - context.runtimeRepository.putSnapshot(request.userId!, { - savedAt: payload.savedAt || new Date().toISOString(), - gameState: payload.gameState, - bottomTab: payload.bottomTab, - currentStory: payload.currentStory ?? null, - }), + const payload = saveSnapshotSchema.parse( + request.body, + ) as SavedGameSnapshotInput; + const normalizedSnapshot = normalizeSavedSnapshotPayload({ + savedAt: payload.savedAt || new Date().toISOString(), + gameState: payload.gameState, + bottomTab: payload.bottomTab, + currentStory: payload.currentStory ?? null, + }); + sendApiResponse( + response, + hydrateSavedSnapshot( + await context.runtimeRepository.putSnapshot( + request.userId!, + normalizedSnapshot, + ), + ), ); }), ); router.delete( '/runtime/save/snapshot', + routeMeta({ operation: 'runtime.snapshot.delete' }), asyncHandler(async (request, response) => { - context.runtimeRepository.deleteSnapshot(request.userId!); - response.json({ ok: true }); + await context.runtimeRepository.deleteSnapshot(request.userId!); + sendApiResponse(response, { ok: true }); }), ); router.get( '/runtime/settings', + routeMeta({ operation: 'runtime.settings.get' }), asyncHandler(async (request, response) => { - response.json(context.runtimeRepository.getSettings(request.userId!)); + sendApiResponse( + response, + await context.runtimeRepository.getSettings(request.userId!), + ); }), ); router.put( '/runtime/settings', + routeMeta({ operation: 'runtime.settings.put' }), asyncHandler(async (request, response) => { - const payload = settingsSchema.parse(request.body); - response.json(context.runtimeRepository.putSettings(request.userId!, payload)); + const payload = settingsSchema.parse(request.body) as RuntimeSettings; + sendApiResponse( + response, + await context.runtimeRepository.putSettings(request.userId!, payload), + ); }), ); router.get( '/runtime/custom-world-library', + routeMeta({ operation: 'runtime.customWorldLibrary.list' }), asyncHandler(async (request, response) => { - response.json({ - profiles: context.runtimeRepository.listCustomWorldProfiles(request.userId!), + sendApiResponse(response, { + profiles: await context.runtimeRepository.listCustomWorldProfiles( + request.userId!, + ), }); }), ); router.put( '/runtime/custom-world-library/:profileId', + routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }), asyncHandler(async (request, response) => { const profileId = readParam(request.params.profileId); if (!profileId) { throw badRequest('profileId is required'); } const payload = customWorldProfileSchema.parse(request.body); - response.json({ - profiles: context.runtimeRepository.upsertCustomWorldProfile( + sendApiResponse(response, { + profiles: await context.runtimeRepository.upsertCustomWorldProfile( request.userId!, profileId, jsonClone(payload.profile), @@ -160,13 +231,14 @@ export function createRuntimeRoutes(context: AppContext) { router.delete( '/runtime/custom-world-library/:profileId', + routeMeta({ operation: 'runtime.customWorldLibrary.delete' }), asyncHandler(async (request, response) => { const profileId = readParam(request.params.profileId); if (!profileId) { throw badRequest('profileId is required'); } - response.json({ - profiles: context.runtimeRepository.deleteCustomWorldProfile( + sendApiResponse(response, { + profiles: await context.runtimeRepository.deleteCustomWorldProfile( request.userId!, profileId, ), @@ -176,78 +248,114 @@ export function createRuntimeRoutes(context: AppContext) { router.post( '/runtime/story/initial', + routeMeta({ operation: 'runtime.story.initial' }), asyncHandler(async (request, response) => { const payload = parseStoryRequest(request.body); - response.json(await generateHighQualityInitialStory(payload)); + sendApiResponse( + response, + await generateHighQualityInitialStory(context.llmClient, payload), + ); }), ); router.post( '/runtime/story/continue', + routeMeta({ operation: 'runtime.story.continue' }), asyncHandler(async (request, response) => { const payload = parseStoryRequest(request.body); - response.json(await generateHighQualityNextStory(payload)); + sendApiResponse( + response, + await generateHighQualityNextStory(context.llmClient, payload), + ); }), ); router.post( '/runtime/chat/character/suggestions', + routeMeta({ operation: 'runtime.chat.character.suggestions' }), asyncHandler(async (request, response) => { - const payload = plainTextRequestSchema.parse(request.body); - response.json({ - text: await context.llmClient.requestMessageContent(payload), + const payload = characterChatSuggestionsRequestSchema.parse( + request.body, + ) as CharacterChatSuggestionsRequest; + sendApiResponse(response, { + text: await generateCharacterChatSuggestionsFromOrchestrator( + context.llmClient, + payload, + ), }); }), ); router.post( '/runtime/chat/character/summary', + routeMeta({ operation: 'runtime.chat.character.summary' }), asyncHandler(async (request, response) => { - const payload = plainTextRequestSchema.parse(request.body); - response.json({ - text: await context.llmClient.requestMessageContent(payload), + const payload = characterChatSummaryRequestSchema.parse( + request.body, + ) as CharacterChatSummaryRequest; + sendApiResponse(response, { + text: await generateCharacterChatSummaryFromOrchestrator( + context.llmClient, + payload, + ), }); }), ); router.post( '/runtime/chat/character/reply/stream', + routeMeta({ operation: 'runtime.chat.character.replyStream' }), asyncHandler(async (request, response) => { - const payload = plainTextRequestSchema.parse(request.body); - await context.llmClient.forwardSseText({ - ...payload, + const payload = characterChatReplyRequestSchema.parse( + request.body, + ) as CharacterChatReplyRequest; + await streamCharacterChatReplyFromOrchestrator(context.llmClient, { + request, response, + payload, }); }), ); router.post( '/runtime/chat/npc/dialogue/stream', + routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }), asyncHandler(async (request, response) => { - const payload = plainTextRequestSchema.parse(request.body); - await context.llmClient.forwardSseText({ - ...payload, + const payload = npcChatDialogueRequestSchema.parse( + request.body, + ) as NpcChatDialogueRequest; + await streamNpcChatDialogueFromOrchestrator(context.llmClient, { + request, response, + payload, }); }), ); router.post( '/runtime/chat/npc/recruit/stream', + routeMeta({ operation: 'runtime.chat.npc.recruitStream' }), asyncHandler(async (request, response) => { - const payload = plainTextRequestSchema.parse(request.body); - await context.llmClient.forwardSseText({ - ...payload, + const payload = npcRecruitDialogueRequestSchema.parse( + request.body, + ) as NpcRecruitDialogueRequest; + await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, { + request, response, + payload, }); }), ); router.post( '/runtime/custom-world/sessions', + routeMeta({ operation: 'runtime.customWorldSession.create' }), asyncHandler(async (request, response) => { - const payload = customWorldSessionSchema.parse(request.body); - response.json( + const payload = customWorldSessionSchema.parse( + request.body, + ) as CreateCustomWorldSessionRequest; + sendApiResponse( + response, context.customWorldSessions.create( request.userId!, payload.settingText, @@ -260,6 +368,7 @@ export function createRuntimeRoutes(context: AppContext) { router.get( '/runtime/custom-world/sessions/:sessionId', + routeMeta({ operation: 'runtime.customWorldSession.get' }), asyncHandler(async (request, response) => { const session = context.customWorldSessions.get( request.userId!, @@ -268,14 +377,17 @@ export function createRuntimeRoutes(context: AppContext) { if (!session) { throw notFound('custom world session not found'); } - response.json(session); + sendApiResponse(response, session); }), ); router.post( '/runtime/custom-world/sessions/:sessionId/answers', + routeMeta({ operation: 'runtime.customWorldSession.answer' }), asyncHandler(async (request, response) => { - const payload = customWorldAnswerSchema.parse(request.body); + const payload = customWorldAnswerSchema.parse( + request.body, + ) as AnswerCustomWorldSessionQuestionRequest; const session = context.customWorldSessions.answer( request.userId!, readParam(request.params.sessionId), @@ -285,12 +397,13 @@ export function createRuntimeRoutes(context: AppContext) { if (!session) { throw notFound('custom world session not found'); } - response.json(session); + sendApiResponse(response, session); }), ); router.get( '/runtime/custom-world/sessions/:sessionId/generate/stream', + routeMeta({ operation: 'runtime.customWorldSession.generateStream' }), asyncHandler(async (request, response) => { const session = context.customWorldSessions.get( request.userId!, @@ -300,11 +413,7 @@ export function createRuntimeRoutes(context: AppContext) { throw notFound('custom world session not found'); } - response.status(200); - response.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); - response.setHeader('Cache-Control', 'no-cache'); - response.setHeader('Connection', 'keep-alive'); - response.setHeader('X-Accel-Buffering', 'no'); + prepareEventStreamResponse(request, response); const controller = new AbortController(); request.on('close', () => { @@ -328,7 +437,10 @@ export function createRuntimeRoutes(context: AppContext) { const profile = await generateCustomWorldProfile(context, session, { signal: controller.signal, onProgress: (progress) => { - writeEvent('progress', progress as unknown as Record); + writeEvent( + 'progress', + progress as unknown as Record, + ); }, }); context.customWorldSessions.setResult( @@ -341,7 +453,9 @@ export function createRuntimeRoutes(context: AppContext) { writeEvent('done', { ok: true }); } catch (error) { const message = - error instanceof Error ? error.message : 'custom world generation failed'; + error instanceof Error + ? error.message + : 'custom world generation failed'; context.customWorldSessions.updateStatus( request.userId!, readParam(request.params.sessionId), @@ -357,9 +471,12 @@ export function createRuntimeRoutes(context: AppContext) { router.post( '/runtime/items/runtime-intent', + routeMeta({ operation: 'runtime.items.intent' }), asyncHandler(async (request, response) => { - const payload = runtimeItemIntentSchema.parse(request.body); - response.json({ + const payload = runtimeItemIntentSchema.parse( + request.body, + ) as RuntimeItemIntentRequest; + sendApiResponse(response, { intents: await generateRuntimeItemIntents(context.llmClient, payload), }); }), @@ -367,20 +484,28 @@ export function createRuntimeRoutes(context: AppContext) { router.post( '/runtime/quests/generate', + routeMeta({ operation: 'runtime.quests.generate' }), asyncHandler(async (request, response) => { - const payload = questGenerationSchema.parse(request.body); - response.json( + const payload = questGenerationSchema.parse( + request.body, + ) as QuestGenerationRequest; + sendApiResponse( + response, await generateQuestForNpcEncounter(context.llmClient, payload), ); }), ); - router.get('/ws/health', (_request, response) => { - response.json({ - ok: true, - message: 'websocket routes reserved for future real-time support', - }); - }); + router.get( + '/ws/health', + routeMeta({ operation: 'runtime.ws.health' }), + (_request, response) => { + sendApiResponse(response, { + ok: true, + message: 'websocket routes reserved for future real-time support', + }); + }, + ); return router; } diff --git a/server-node/src/server.ts b/server-node/src/server.ts index 6a2c14fd..73a98f72 100644 --- a/server-node/src/server.ts +++ b/server-node/src/server.ts @@ -1,14 +1,23 @@ import { pathToFileURL } from 'node:url'; import { createApp } from './app.js'; -import { type AppConfig,loadConfig } from './config.js'; +import { type AppConfig, loadConfig } from './config.js'; import type { AppContext } from './context.js'; import { createDatabase } from './db.js'; import { createLogger } from './logging.js'; +import { AuthIdentityRepository } from './repositories/authIdentityRepository.js'; +import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js'; +import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js'; import { RuntimeRepository } from './repositories/runtimeRepository.js'; +import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js'; import { UserRepository } from './repositories/userRepository.js'; +import { UserSessionRepository } from './repositories/userSessionRepository.js'; import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; import { UpstreamLlmClient } from './services/llmClient.js'; +import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; +import { createSmsVerificationService } from './services/smsVerificationService.js'; +import { createWechatAuthService } from './services/wechatAuthService.js'; +import { WechatAuthStateStore } from './services/wechatAuthStateStore.js'; function resolveListenTarget(serverAddr: string) { const trimmed = serverAddr.trim(); @@ -41,24 +50,57 @@ function resolveListenTarget(serverAddr: string) { }; } -export function createAppContext(config: AppConfig = loadConfig()) { +function describeDatabase(databaseUrl: string) { + if (databaseUrl.startsWith('pg-mem://')) { + return { + database_engine: 'pg-mem', + database_name: databaseUrl.slice('pg-mem://'.length) || 'memory', + }; + } + + try { + const url = new URL(databaseUrl); + return { + database_engine: url.protocol.replace(/:$/u, ''), + database_host: url.hostname, + database_port: Number(url.port || 5432), + database_name: url.pathname.replace(/^\/+/u, '') || 'postgres', + }; + } catch { + return { + database_engine: 'postgresql', + database_target: 'configured', + }; + } +} + +export async function createAppContext(config: AppConfig = loadConfig()) { const logger = createLogger(config); - const db = createDatabase(config); + const db = await createDatabase(config); const context: AppContext = { config, logger, db, userRepository: new UserRepository(db), + authIdentityRepository: new AuthIdentityRepository(db), + authAuditLogRepository: new AuthAuditLogRepository(db), + authRiskBlockRepository: new AuthRiskBlockRepository(db), + smsAuthEventRepository: new SmsAuthEventRepository(db), + userSessionRepository: new UserSessionRepository(db), runtimeRepository: new RuntimeRepository(db), llmClient: new UpstreamLlmClient(config, logger), customWorldSessions: new CustomWorldSessionStore(), + smsVerificationService: createSmsVerificationService(config, logger), + wechatAuthService: createWechatAuthService(config, logger), + wechatAuthStates: new WechatAuthStateStore(), + captchaChallenges: new CaptchaChallengeStore(), }; return context; } async function main() { - const context = createAppContext(); + const context = await createAppContext(); const app = createApp(context); const { host, port } = resolveListenTarget(context.config.serverAddr); const server = app.listen(port, host, () => { @@ -66,17 +108,29 @@ async function main() { { host, port, - sqlite_path: context.config.sqlitePath, + ...describeDatabase(context.config.databaseUrl), }, 'server-node started', ); }); + let shuttingDown = false; const shutdown = () => { + if (shuttingDown) { + return; + } + shuttingDown = true; context.logger.info('server-node shutting down'); server.close(() => { - context.db.close(); - process.exit(0); + void context.db + .close() + .then(() => { + process.exit(0); + }) + .catch((error) => { + context.logger.error({ err: error }, 'failed to close database'); + process.exit(1); + }); }); }; @@ -89,5 +143,8 @@ const isEntryPoint = import.meta.url === pathToFileURL(process.argv[1]).href; if (isEntryPoint) { - void main(); + void main().catch((error) => { + console.error(error); + process.exit(1); + }); } diff --git a/server-node/src/services/captchaChallengeStore.ts b/server-node/src/services/captchaChallengeStore.ts new file mode 100644 index 00000000..1db4ffc7 --- /dev/null +++ b/server-node/src/services/captchaChallengeStore.ts @@ -0,0 +1,97 @@ +import crypto from 'node:crypto'; + +import type { AuthCaptchaChallenge } from '../../../packages/shared/src/contracts/auth.js'; + +type CaptchaChallengeRecord = { + challengeId: string; + scopeKey: string; + answer: string; + createdAt: string; + expiresAt: string; + imageDataUrl: string; +}; + +function buildCaptchaSvgDataUrl(text: string) { + const lines = Array.from({ length: 4 }, (_, index) => { + const x1 = 8 + index * 18; + const x2 = 150 - index * 16; + const y1 = 12 + index * 8; + const y2 = 46 - index * 6; + return ``; + }).join(''); + + const noise = Array.from(text).map((char, index) => { + const x = 24 + index * 24; + const y = 30 + ((index % 2) * 6 - 3); + const rotate = index % 2 === 0 ? -8 : 7; + return `${char}`; + }).join(''); + + const svg = ` + + +${lines} +${noise} +`; + + return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`; +} + +function normalizeCaptchaAnswer(answer: string) { + return answer.trim().toLowerCase(); +} + +function buildCaptchaText() { + return crypto.randomBytes(3).toString('hex').slice(0, 5).toUpperCase(); +} + +export class CaptchaChallengeStore { + private readonly challenges = new Map(); + + create(scopeKey: string, expiresInSeconds: number): AuthCaptchaChallenge { + const text = buildCaptchaText(); + const challengeId = `captcha_${crypto.randomBytes(16).toString('hex')}`; + const createdAt = new Date(); + const expiresAt = new Date(createdAt.getTime() + expiresInSeconds * 1000); + + this.challenges.set(challengeId, { + challengeId, + scopeKey, + answer: normalizeCaptchaAnswer(text), + createdAt: createdAt.toISOString(), + expiresAt: expiresAt.toISOString(), + imageDataUrl: buildCaptchaSvgDataUrl(text), + }); + + return { + challengeId, + promptText: '请输入图中的验证码后再获取短信验证码', + imageDataUrl: buildCaptchaSvgDataUrl(text), + expiresInSeconds, + }; + } + + verify(params: { + challengeId: string; + scopeKey: string; + answer: string; + }) { + const record = this.challenges.get(params.challengeId); + if (!record) { + return false; + } + if (record.scopeKey !== params.scopeKey) { + this.challenges.delete(params.challengeId); + return false; + } + if (new Date(record.expiresAt).getTime() <= Date.now()) { + this.challenges.delete(params.challengeId); + return false; + } + + const isValid = + record.answer === normalizeCaptchaAnswer(params.answer); + this.challenges.delete(params.challengeId); + return isValid; + } +} diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts index e7f4332a..bf6f9ae2 100644 --- a/server-node/src/services/chatService.ts +++ b/server-node/src/services/chatService.ts @@ -1,6 +1,56 @@ import { z } from 'zod'; -export const plainTextRequestSchema = z.object({ - systemPrompt: z.string().trim().min(1), - userPrompt: z.string().trim().min(1), +import type { + CharacterChatReplyRequest, + CharacterChatSuggestionsRequest, + CharacterChatSummaryRequest, + NpcChatDialogueRequest, + NpcRecruitDialogueRequest, +} from '../../../packages/shared/src/contracts/story.js'; + +const jsonObjectSchema = z.record(z.string(), z.unknown()); + +const baseCharacterChatSchema = z.object({ + worldType: z.string().trim().min(1), + playerCharacter: jsonObjectSchema, + targetCharacter: jsonObjectSchema, + storyHistory: z.array(jsonObjectSchema).default([]), + context: jsonObjectSchema, + conversationHistory: z.array(jsonObjectSchema).default([]), + targetStatus: jsonObjectSchema, }); + +const baseNpcChatSchema = z.object({ + worldType: z.string().trim().min(1), + character: jsonObjectSchema, + encounter: jsonObjectSchema, + monsters: z.array(jsonObjectSchema).default([]), + history: z.array(jsonObjectSchema).default([]), + context: jsonObjectSchema, +}); + +export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({ + conversationSummary: z.string().optional().default(''), + playerMessage: z.string().trim().min(1), +}) satisfies z.ZodType; + +export const characterChatSuggestionsRequestSchema = + baseCharacterChatSchema.extend({ + conversationSummary: z.string().optional().default(''), + }) satisfies z.ZodType; + +export const characterChatSummaryRequestSchema = baseCharacterChatSchema.extend( + { + previousSummary: z.string().optional().default(''), + }, +) satisfies z.ZodType; + +export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({ + topic: z.string().trim().min(1), + resultSummary: z.string().optional().default(''), +}) satisfies z.ZodType; + +export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({ + invitationText: z.string().trim().min(1), + recruitSummary: z.string().optional().default(''), +}) satisfies z.ZodType; diff --git a/server-node/src/services/customWorldGenerationService.ts b/server-node/src/services/customWorldGenerationService.ts index 240c27cc..3d9e38ef 100644 --- a/server-node/src/services/customWorldGenerationService.ts +++ b/server-node/src/services/customWorldGenerationService.ts @@ -1,8 +1,8 @@ import { type CustomWorldGenerationProgress, - generateCustomWorldProfile as generateCustomWorldProfileFromAi, type GenerateCustomWorldProfileInput, -} from '../../../src/services/ai.js'; + generateCustomWorldProfileFromOrchestrator, +} from '../modules/ai/customWorldOrchestrator.js'; import type { AppContext } from '../context.js'; import type { CustomWorldSession } from './customWorldSessionStore.js'; @@ -20,7 +20,7 @@ export async function generateCustomWorldProfile( generationMode: session.generationMode, } satisfies GenerateCustomWorldProfileInput; - const profile = await generateCustomWorldProfileFromAi(input, { + const profile = await generateCustomWorldProfileFromOrchestrator(input, { onProgress: options.onProgress, signal: options.signal, }); diff --git a/server-node/src/services/customWorldSessionStore.ts b/server-node/src/services/customWorldSessionStore.ts index 7f356cc8..05c5d704 100644 --- a/server-node/src/services/customWorldSessionStore.ts +++ b/server-node/src/services/customWorldSessionStore.ts @@ -1,28 +1,21 @@ import crypto from 'node:crypto'; -export type CustomWorldSessionStatus = - | 'clarifying' - | 'ready_to_generate' - | 'generating' - | 'completed' - | 'generation_error'; - -export type CustomWorldQuestion = { - id: string; - label: string; - question: string; - answer?: string; -}; +import type { JsonObject } from '../../../packages/shared/src/contracts/common.js'; +import type { + CustomWorldGenerationMode, + CustomWorldQuestion, + CustomWorldSessionStatus, +} from '../../../packages/shared/src/contracts/runtime.js'; export type CustomWorldSession = { sessionId: string; userId: string; status: CustomWorldSessionStatus; settingText: string; - creatorIntent: Record | null; - generationMode: 'fast' | 'full'; + creatorIntent: JsonObject | null; + generationMode: CustomWorldGenerationMode; questions: CustomWorldQuestion[]; - result?: Record; + result?: JsonObject; lastError?: string; createdAt: string; updatedAt: string; @@ -38,7 +31,7 @@ function hasPendingQuestion(questions: CustomWorldQuestion[]) { function buildClarificationQuestions( settingText: string, - creatorIntent: Record | null, + creatorIntent: JsonObject | null, ) { const questions: CustomWorldQuestion[] = []; const worldHook = @@ -91,8 +84,8 @@ export class CustomWorldSessionStore { create( userId: string, settingText: string, - creatorIntent: Record | null, - generationMode: 'fast' | 'full', + creatorIntent: JsonObject | null, + generationMode: CustomWorldGenerationMode, ) { const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`; const now = new Date().toISOString(); @@ -159,7 +152,7 @@ export class CustomWorldSessionStore { return cloneSession(session); } - setResult(userId: string, sessionId: string, result: Record) { + setResult(userId: string, sessionId: string, result: JsonObject) { const session = this.sessions.get(userId)?.get(sessionId); if (!session) { return null; @@ -167,7 +160,7 @@ export class CustomWorldSessionStore { session.status = 'completed'; session.lastError = undefined; - session.result = JSON.parse(JSON.stringify(result)) as Record; + session.result = JSON.parse(JSON.stringify(result)) as JsonObject; session.updatedAt = new Date().toISOString(); return cloneSession(session); } diff --git a/server-node/src/services/llmClient.ts b/server-node/src/services/llmClient.ts index d03162cc..159e3fba 100644 --- a/server-node/src/services/llmClient.ts +++ b/server-node/src/services/llmClient.ts @@ -1,11 +1,18 @@ import { Readable } from 'node:stream'; -import type { Response as ExpressResponse } from 'express'; +import type { + Request as ExpressRequest, + Response as ExpressResponse, +} from 'express'; import type { Logger } from 'pino'; import type { AppConfig } from '../config.js'; -import { upstreamError } from '../errors.js'; -import { extractApiErrorMessage } from '../http.js'; +import { HttpError, upstreamError } from '../errors.js'; +import { + extractApiErrorMessage, + prepareApiResponse, + prepareEventStreamResponse, +} from '../http.js'; export type ChatMessage = { role: 'system' | 'user' | 'assistant'; @@ -18,6 +25,14 @@ type CompletionRequest = { messages: ChatMessage[]; }; +type RequestExecutionOptions = { + signal?: AbortSignal; + timeoutMs?: number; + debugLabel?: string; +}; + +const DEFAULT_LLM_REQUEST_TIMEOUT_MS = 30000; + function normalizeBaseUrl(baseUrl: string) { return baseUrl.replace(/\/+$/u, ''); } @@ -26,11 +41,69 @@ function buildCompletionUrl(baseUrl: string) { return `${normalizeBaseUrl(baseUrl)}/chat/completions`; } +function isAbortLikeError(error: unknown) { + return ( + (typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} + +function readTimeoutMs(config: AppConfig) { + const parsed = Number(config.rawEnv.LLM_REQUEST_TIMEOUT_MS); + return Number.isFinite(parsed) && parsed > 0 + ? Math.round(parsed) + : DEFAULT_LLM_REQUEST_TIMEOUT_MS; +} + +export class UpstreamLlmTimeoutError extends HttpError { + constructor(message = 'LLM 上游请求超时') { + super(502, message, { + code: 'UPSTREAM_TIMEOUT', + }); + this.name = 'UpstreamLlmTimeoutError'; + } +} + +export class UpstreamLlmConnectivityError extends HttpError { + constructor(message = '无法连接 LLM 上游服务') { + super(502, message, { + code: 'UPSTREAM_CONNECTIVITY', + }); + this.name = 'UpstreamLlmConnectivityError'; + } +} + +export function isUpstreamLlmTimeoutError( + error: unknown, +): error is UpstreamLlmTimeoutError { + return ( + error instanceof UpstreamLlmTimeoutError || + (error instanceof HttpError && error.code === 'UPSTREAM_TIMEOUT') + ); +} + +export function isUpstreamLlmConnectivityError( + error: unknown, +): error is UpstreamLlmConnectivityError { + return ( + error instanceof UpstreamLlmConnectivityError || + (error instanceof HttpError && error.code === 'UPSTREAM_CONNECTIVITY') + ); +} + export class UpstreamLlmClient { + readonly logger: Logger; + private readonly requestTimeoutMs: number; + constructor( private readonly config: AppConfig, - private readonly logger: Logger, - ) {} + logger: Logger, + ) { + this.logger = logger; + this.requestTimeoutMs = readTimeoutMs(config); + } private resolveModel(model?: string) { return model?.trim() || this.config.llm.model; @@ -47,24 +120,128 @@ export class UpstreamLlmClient { }; } - async requestCompletion(body: CompletionRequest, signal?: AbortSignal) { - const response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), { - method: 'POST', - headers: this.buildHeaders(), - body: JSON.stringify({ - ...body, - model: this.resolveModel(body.model), - }), - signal, - }); + private createRequestSignal( + externalSignal?: AbortSignal, + timeoutMs = this.requestTimeoutMs, + ) { + const controller = new AbortController(); + let timedOut = false; + const handleAbort = () => controller.abort(externalSignal?.reason); + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + if (externalSignal) { + if (externalSignal.aborted) { + handleAbort(); + } else { + externalSignal.addEventListener('abort', handleAbort, { + once: true, + }); + } + } + + return { + signal: controller.signal, + didTimeout() { + return timedOut; + }, + cleanup() { + clearTimeout(timeout); + externalSignal?.removeEventListener('abort', handleAbort); + }, + }; + } + + private attachRequestAbort(request: ExpressRequest) { + const controller = new AbortController(); + const handleClose = () => controller.abort(); + request.on('close', handleClose); + + return { + signal: controller.signal, + cleanup() { + request.removeListener('close', handleClose); + }, + }; + } + + async requestCompletion( + body: CompletionRequest, + options: RequestExecutionOptions = {}, + ) { + const timeoutMs = + typeof options.timeoutMs === 'number' && options.timeoutMs > 0 + ? Math.round(options.timeoutMs) + : this.requestTimeoutMs; + const requestSignal = this.createRequestSignal(options.signal, timeoutMs); + const model = this.resolveModel(body.model); + const debugLabel = + typeof options.debugLabel === 'string' && options.debugLabel.trim() + ? options.debugLabel.trim() + : undefined; + + this.logger.debug( + { + llm_model: model, + llm_stream: body.stream === true, + llm_timeout_ms: timeoutMs, + llm_debug_label: debugLabel, + }, + 'llm upstream request started', + ); + + let response: globalThis.Response; + try { + response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify({ + ...body, + model, + }), + signal: requestSignal.signal, + }); + } catch (error) { + requestSignal.cleanup(); + if (requestSignal.didTimeout() && isAbortLikeError(error)) { + throw new UpstreamLlmTimeoutError(); + } + + if (error instanceof TypeError) { + throw new UpstreamLlmConnectivityError(); + } + + this.logger.warn( + { + err: error, + llm_model: model, + llm_stream: body.stream === true, + llm_debug_label: debugLabel, + }, + 'llm upstream request failed', + ); + throw error; + } + + requestSignal.cleanup(); if (!response.ok) { const rawText = await response.text(); - throw upstreamError( - extractApiErrorMessage(rawText, 'LLM 上游请求失败'), - ); + throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败')); } + this.logger.debug( + { + llm_model: model, + llm_stream: body.stream === true, + llm_status: response.status, + llm_debug_label: debugLabel, + }, + 'llm upstream request succeeded', + ); + return response; } @@ -73,6 +250,8 @@ export class UpstreamLlmClient { userPrompt: string; model?: string; signal?: AbortSignal; + timeoutMs?: number; + debugLabel?: string; }) { const response = await this.requestCompletion( { @@ -82,7 +261,11 @@ export class UpstreamLlmClient { { role: 'user', content: params.userPrompt }, ], }, - params.signal, + { + signal: params.signal, + timeoutMs: params.timeoutMs, + debugLabel: params.debugLabel, + }, ); const rawText = await response.text(); const parsed = JSON.parse(rawText) as { @@ -101,69 +284,116 @@ export class UpstreamLlmClient { return content; } - async forwardCompletion(body: Record, response: ExpressResponse) { - const upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), { - method: 'POST', - headers: this.buildHeaders(), - body: JSON.stringify({ - ...body, - model: - typeof body.model === 'string' && body.model.trim() - ? body.model - : this.config.llm.model, - }), - }); + async forwardCompletion( + request: ExpressRequest, + body: Record, + response: ExpressResponse, + ) { + const requestAbort = this.attachRequestAbort(request); + let upstreamResponse: globalThis.Response; - if (!upstreamResponse.ok) { - const rawText = await upstreamResponse.text(); - throw upstreamError( - extractApiErrorMessage(rawText, 'LLM 上游请求失败'), - ); + try { + upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify({ + ...body, + model: + typeof body.model === 'string' && body.model.trim() + ? body.model + : this.config.llm.model, + }), + signal: requestAbort.signal, + }); + } catch (error) { + requestAbort.cleanup(); + if (requestAbort.signal.aborted && response.writableEnded) { + return; + } + throw error; } - response.status(upstreamResponse.status); - response.setHeader( - 'Content-Type', - upstreamResponse.headers.get('content-type') || 'application/json; charset=utf-8', - ); + if (!upstreamResponse.ok) { + requestAbort.cleanup(); + const rawText = await upstreamResponse.text(); + throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败')); + } + + prepareApiResponse(request, response, { + statusCode: upstreamResponse.status, + headers: { + 'Content-Type': + upstreamResponse.headers.get('content-type') || + 'application/json; charset=utf-8', + }, + }); if (!upstreamResponse.body) { + requestAbort.cleanup(); response.end(); return; } - await Readable.fromWeb(upstreamResponse.body as never).pipe(response); + try { + await Readable.fromWeb(upstreamResponse.body as never).pipe(response); + } finally { + requestAbort.cleanup(); + } } async forwardSseText(params: { + request: ExpressRequest; systemPrompt: string; userPrompt: string; response: ExpressResponse; model?: string; }) { - const upstreamResponse = await this.requestCompletion({ - model: params.model, - stream: true, - messages: [ - { role: 'system', content: params.systemPrompt }, - { role: 'user', content: params.userPrompt }, - ], + const requestAbort = this.attachRequestAbort(params.request); + let upstreamResponse: globalThis.Response; + + try { + upstreamResponse = await this.requestCompletion( + { + model: params.model, + stream: true, + messages: [ + { role: 'system', content: params.systemPrompt }, + { role: 'user', content: params.userPrompt }, + ], + }, + { + signal: requestAbort.signal, + }, + ); + } catch (error) { + requestAbort.cleanup(); + if (requestAbort.signal.aborted && params.response.writableEnded) { + return; + } + throw error; + } + + prepareEventStreamResponse(params.request, params.response, { + statusCode: upstreamResponse.status, + headers: { + 'Content-Type': + upstreamResponse.headers.get('content-type') || + 'text/event-stream; charset=utf-8', + }, }); - params.response.status(upstreamResponse.status); - params.response.setHeader( - 'Content-Type', - upstreamResponse.headers.get('content-type') || 'text/event-stream; charset=utf-8', - ); - params.response.setHeader('Cache-Control', 'no-cache'); - params.response.setHeader('Connection', 'keep-alive'); - params.response.setHeader('X-Accel-Buffering', 'no'); - if (!upstreamResponse.body) { + requestAbort.cleanup(); params.response.end(); return; } - await Readable.fromWeb(upstreamResponse.body as never).pipe(params.response); + try { + await Readable.fromWeb(upstreamResponse.body as never).pipe( + params.response, + ); + } finally { + requestAbort.cleanup(); + } } } diff --git a/server-node/src/services/questService.ts b/server-node/src/services/questService.ts index 587b8f4c..e3774f73 100644 --- a/server-node/src/services/questService.ts +++ b/server-node/src/services/questService.ts @@ -1,17 +1,29 @@ +import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/story.js'; +import { + QUEST_INTIMACY_LEVELS, + QUEST_NARRATIVE_TYPES, + QUEST_OBJECTIVE_KINDS, + QUEST_REWARD_THEMES, + QUEST_URGENCY_LEVELS, +} from '../../../packages/shared/src/contracts/story.js'; +import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { buildFallbackQuestIntent, compileQuestIntentToQuest, evaluateQuestOpportunity, -} from '../../../src/data/questFlow.js'; -import { parseJsonResponseText } from '../../../src/services/llmParsers.js'; -import { buildQuestGenerationContextFromState } from '../../../src/services/questDirector.js'; -import { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT } from '../../../src/services/questPrompt.js'; -import type { QuestIntent, QuestPreviewRequest } from '../../../src/services/questTypes.js'; -import type { GameState } from '../../../src/types/game.js'; -import type { Encounter } from '../../../src/types/scene.js'; -import type { QuestLogEntry } from '../../../src/types/story.js'; + buildQuestGenerationContextFromState, + buildQuestIntentPrompt, + QUEST_INTENT_SYSTEM_PROMPT, +} from '../bridges/legacyQuestRuntimeBridge.js'; import type { UpstreamLlmClient } from './llmClient.js'; +type QuestPreviewRequest = Parameters[0]; +type QuestIntent = ReturnType; +type QuestGenerationInput = Parameters[0]; +type QuestGenerationState = QuestGenerationInput['state']; +type QuestGenerationEncounter = QuestGenerationInput['encounter']; +type QuestLogEntry = ReturnType; + function coerceString(value: unknown, fallback: string) { return typeof value === 'string' && value.trim() ? value.trim() : fallback; } @@ -41,7 +53,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn summary: coerceString(intent.summary, fallback.summary), narrativeType: typeof intent.narrativeType === 'string' && - ['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType) + QUEST_NARRATIVE_TYPES.includes(intent.narrativeType) ? (intent.narrativeType as QuestIntent['narrativeType']) : fallback.narrativeType, dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed), @@ -51,29 +63,20 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn recommendedObjectiveKinds: coerceStringArray( intent.recommendedObjectiveKinds, fallback.recommendedObjectiveKinds, - ).filter((kind) => - [ - 'defeat_hostile_npc', - 'inspect_treasure', - 'spar_with_npc', - 'talk_to_npc', - 'reach_scene', - 'deliver_item', - ].includes(kind), - ) as QuestIntent['recommendedObjectiveKinds'], + ).filter((kind) => QUEST_OBJECTIVE_KINDS.includes(kind)) as QuestIntent['recommendedObjectiveKinds'], urgency: typeof intent.urgency === 'string' && - ['low', 'medium', 'high'].includes(intent.urgency) + QUEST_URGENCY_LEVELS.includes(intent.urgency) ? (intent.urgency as QuestIntent['urgency']) : fallback.urgency, intimacy: typeof intent.intimacy === 'string' && - ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy) + QUEST_INTIMACY_LEVELS.includes(intent.intimacy) ? (intent.intimacy as QuestIntent['intimacy']) : fallback.intimacy, rewardTheme: typeof intent.rewardTheme === 'string' && - ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme) + QUEST_REWARD_THEMES.includes(intent.rewardTheme) ? (intent.rewardTheme as QuestIntent['rewardTheme']) : fallback.rewardTheme, followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks), @@ -82,10 +85,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn export async function generateQuestForNpcEncounter( llmClient: UpstreamLlmClient, - params: { - state: GameState; - encounter: Encounter; - }, + params: QuestGenerationRequest, ): Promise { const { state, encounter } = params; const issuerNpcId = encounter.id ?? encounter.npcName; @@ -95,7 +95,7 @@ export async function generateQuestForNpcEncounter( roleText: encounter.context, scene: state.currentScenePreset, worldType: state.worldType, - currentQuests: state.quests.map((quest: QuestLogEntry) => ({ + currentQuests: state.quests.map((quest) => ({ id: quest.id, issuerNpcId: quest.issuerNpcId, status: quest.status, diff --git a/server-node/src/services/runtimeItemService.ts b/server-node/src/services/runtimeItemService.ts index 3b97f7d9..20b9bdfd 100644 --- a/server-node/src/services/runtimeItemService.ts +++ b/server-node/src/services/runtimeItemService.ts @@ -1,16 +1,22 @@ -import { buildRuntimeItemAiIntent } from '../../../src/data/runtimeItemNarrative.js'; -import { parseJsonResponseText } from '../../../src/services/llmParsers.js'; import { + RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, + RUNTIME_ITEM_TONE_VALUES, +} from '../../../packages/shared/src/contracts/story.js'; +import type { + RuntimeItemIntentRequest, +} from '../../../packages/shared/src/contracts/story.js'; +import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; +import { + buildRuntimeItemAiIntent, buildRuntimeItemIntentPrompt, RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, -} from '../../../src/services/runtimeItemAiPrompt.js'; -import type { - RuntimeItemAiIntent, - RuntimeItemGenerationContext, - RuntimeItemPlan, -} from '../../../src/types/runtimeItem.js'; +} from '../bridges/legacyRuntimeItemBridge.js'; import type { UpstreamLlmClient } from './llmClient.js'; +type RuntimeItemGenerationContext = Parameters[0]; +type RuntimeItemPlan = Parameters[1]; +type RuntimeItemAiIntent = ReturnType; + function coerceString(value: unknown, fallback: string) { return typeof value === 'string' && value.trim() ? value.trim() : fallback; } @@ -45,7 +51,7 @@ function sanitizeRuntimeItemAiIntent( ( item, ): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] => - ['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item), + RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES.includes(item), ); const tone = coerceString(intent.tone, fallback.tone); @@ -59,7 +65,7 @@ function sanitizeRuntimeItemAiIntent( desiredFunctionalBias.length > 0 ? desiredFunctionalBias : fallback.desiredFunctionalBias, - tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone) + tone: RUNTIME_ITEM_TONE_VALUES.includes(tone) ? (tone as RuntimeItemAiIntent['tone']) : fallback.tone, visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''), @@ -80,10 +86,7 @@ function sanitizeRuntimeItemAiIntent( export async function generateRuntimeItemIntents( llmClient: UpstreamLlmClient, - params: { - context: RuntimeItemGenerationContext; - plans: RuntimeItemPlan[]; - }, + params: RuntimeItemIntentRequest, ) { const fallbackIntents = params.plans.map((plan) => buildRuntimeItemAiIntent(params.context, plan), diff --git a/server-node/src/services/smsVerificationService.ts b/server-node/src/services/smsVerificationService.ts new file mode 100644 index 00000000..6548cec3 --- /dev/null +++ b/server-node/src/services/smsVerificationService.ts @@ -0,0 +1,239 @@ +import crypto from 'node:crypto'; + +import DypnsClient, { + CheckSmsVerifyCodeRequest, + SendSmsVerifyCodeRequest, +} from '@alicloud/dypnsapi20170525'; +import OpenApiClient from '@alicloud/openapi-client'; +import type { Logger } from 'pino'; + +import type { NormalizedPhoneNumber } from '../auth/phoneNumber.js'; +import type { AppConfig } from '../config.js'; +import { + badRequest, + unauthorized, + upstreamError, +} from '../errors.js'; + +export type SendLoginCodeResult = { + cooldownSeconds: number; + expiresInSeconds: number; + providerRequestId: string | null; +}; + +export type SmsVerificationService = { + sendLoginCode(phoneNumber: NormalizedPhoneNumber): Promise; + verifyLoginCode( + phoneNumber: NormalizedPhoneNumber, + verifyCode: string, + ): Promise; +}; + +function isAliyunConfigMissing(config: AppConfig['smsAuth']) { + return !config.accessKeyId || !config.accessKeySecret; +} + +function buildProviderErrorMessage(prefix: string, message: string) { + const normalizedMessage = message.trim(); + return normalizedMessage ? `${prefix}:${normalizedMessage}` : prefix; +} + +class AliyunSmsVerificationService implements SmsVerificationService { + private readonly client: DypnsClient; + + constructor( + private readonly config: AppConfig['smsAuth'], + private readonly logger: Logger, + ) { + if (isAliyunConfigMissing(config)) { + throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置'); + } + + const clientConfig = new OpenApiClient.Config({ + accessKeyId: config.accessKeyId, + accessKeySecret: config.accessKeySecret, + endpoint: config.endpoint, + protocol: 'HTTPS', + }); + this.client = new DypnsClient(clientConfig); + } + + async sendLoginCode(phoneNumber: NormalizedPhoneNumber) { + const templateParam = JSON.stringify({ + [this.config.templateParamKey]: '##code##', + }); + const request = new SendSmsVerifyCodeRequest({ + phoneNumber: phoneNumber.nationalNumber, + countryCode: this.config.countryCode, + signName: this.config.signName, + templateCode: this.config.templateCode, + templateParam, + codeLength: this.config.codeLength, + codeType: this.config.codeType, + validTime: this.config.validTimeSeconds, + interval: this.config.intervalSeconds, + duplicatePolicy: this.config.duplicatePolicy, + returnVerifyCode: this.config.returnVerifyCode, + schemeName: this.config.schemeName || undefined, + outId: `login_${crypto.randomBytes(12).toString('hex')}`, + }); + + try { + const response = await this.client.sendSmsVerifyCode(request); + const body = response.body; + if (!body?.success || body.code !== 'OK') { + throw this.resolveAliyunRequestError( + '短信验证码发送失败', + body?.message ?? '', + body?.code ?? '', + ); + } + + return { + cooldownSeconds: this.config.intervalSeconds, + expiresInSeconds: this.config.validTimeSeconds, + providerRequestId: body.requestId ?? body.model?.requestId ?? null, + } satisfies SendLoginCodeResult; + } catch (error) { + if (error instanceof Error && error.name === 'HttpError') { + throw error; + } + + this.logger.error( + { + err: error, + phone_suffix: phoneNumber.nationalNumber.slice(-4), + }, + 'aliyun sms send failed', + ); + throw upstreamError( + buildProviderErrorMessage( + '短信验证码发送失败', + error instanceof Error ? error.message : 'unknown error', + ), + ); + } + } + + async verifyLoginCode( + phoneNumber: NormalizedPhoneNumber, + verifyCode: string, + ) { + const request = new CheckSmsVerifyCodeRequest({ + phoneNumber: phoneNumber.nationalNumber, + countryCode: this.config.countryCode, + verifyCode, + caseAuthPolicy: this.config.caseAuthPolicy, + schemeName: this.config.schemeName || undefined, + }); + + try { + const response = await this.client.checkSmsVerifyCode(request); + const body = response.body; + if (!body?.success || body.code !== 'OK') { + throw this.resolveAliyunRequestError( + '验证码校验失败', + body?.message ?? '', + body?.code ?? '', + ); + } + + if (body.model?.verifyResult !== 'PASS') { + throw unauthorized('验证码错误或已失效'); + } + } catch (error) { + if (error instanceof Error && error.name === 'HttpError') { + throw error; + } + + this.logger.error( + { + err: error, + phone_suffix: phoneNumber.nationalNumber.slice(-4), + }, + 'aliyun sms verify failed', + ); + throw upstreamError( + buildProviderErrorMessage( + '验证码校验失败', + error instanceof Error ? error.message : 'unknown error', + ), + ); + } + } + + private resolveAliyunRequestError( + fallbackMessage: string, + providerMessage: string, + providerCode: string, + ) { + const normalizedCode = providerCode.trim().toUpperCase(); + if ( + normalizedCode.includes('MOBILE') || + normalizedCode.includes('PHONE') || + normalizedCode.includes('TEMPLATE') || + normalizedCode.includes('SIGN') + ) { + return badRequest( + buildProviderErrorMessage(fallbackMessage, providerMessage), + { + providerCode, + }, + ); + } + + return upstreamError( + buildProviderErrorMessage(fallbackMessage, providerMessage), + { + providerCode, + }, + ); + } +} + +class MockSmsVerificationService implements SmsVerificationService { + private readonly sentCodes = new Map(); + + constructor(private readonly config: AppConfig['smsAuth']) {} + + async sendLoginCode(phoneNumber: NormalizedPhoneNumber) { + this.sentCodes.set(phoneNumber.e164, this.config.mockVerifyCode); + return { + cooldownSeconds: this.config.intervalSeconds, + expiresInSeconds: this.config.validTimeSeconds, + providerRequestId: 'mock-request-id', + } satisfies SendLoginCodeResult; + } + + async verifyLoginCode( + phoneNumber: NormalizedPhoneNumber, + verifyCode: string, + ) { + const expectedCode = this.sentCodes.get(phoneNumber.e164); + if (!expectedCode || expectedCode !== verifyCode) { + throw unauthorized('验证码错误或已失效'); + } + } +} + +export function createSmsVerificationService( + config: AppConfig, + logger: Logger, +): SmsVerificationService { + if (!config.smsAuth.enabled) { + return { + async sendLoginCode() { + throw badRequest('短信验证码登录未启用'); + }, + async verifyLoginCode() { + throw badRequest('短信验证码登录未启用'); + }, + }; + } + + if (config.smsAuth.provider === 'mock') { + return new MockSmsVerificationService(config.smsAuth); + } + + return new AliyunSmsVerificationService(config.smsAuth, logger); +} diff --git a/server-node/src/services/storyService.ts b/server-node/src/services/storyService.ts index 81a5d63e..fed1348e 100644 --- a/server-node/src/services/storyService.ts +++ b/server-node/src/services/storyService.ts @@ -1,26 +1,24 @@ import { z } from 'zod'; +import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/story.js'; import { - generateInitialStoryStrict as generateInitialStoryFromAi, - generateNextStepStrict as generateNextStepFromAi, - type StoryGenerationContext, - type StoryRequestOptions, -} from '../../../src/services/ai.js'; -import type { Character } from '../../../src/types/characters.js'; -import type { WorldType } from '../../../src/types/core.js'; -import type { SceneHostileNpc } from '../../../src/types/scene.js'; -import type { StoryMoment } from '../../../src/types/story.js'; + generateInitialStoryFromOrchestrator, + generateNextStoryFromOrchestrator, +} from '../modules/ai/storyOrchestrator.js'; +import type { UpstreamLlmClient } from './llmClient.js'; + +const jsonObjectSchema = z.record(z.string(), z.unknown()); const storyRequestSchema = z.object({ worldType: z.string().trim().min(1), - character: z.record(z.string(), z.unknown()), - monsters: z.array(z.record(z.string(), z.unknown())).default([]), - history: z.array(z.record(z.string(), z.unknown())).default([]), + character: jsonObjectSchema, + monsters: z.array(jsonObjectSchema).default([]), + history: z.array(jsonObjectSchema).default([]), choice: z.string().optional().default(''), - context: z.record(z.string(), z.unknown()), + context: jsonObjectSchema, requestOptions: z.object({ - availableOptions: z.array(z.record(z.string(), z.unknown())).optional().default([]), - optionCatalog: z.array(z.record(z.string(), z.unknown())).optional().default([]), + availableOptions: z.array(jsonObjectSchema).optional().default([]), + optionCatalog: z.array(jsonObjectSchema).optional().default([]), }).optional().default({ availableOptions: [], optionCatalog: [], @@ -28,28 +26,30 @@ const storyRequestSchema = z.object({ }); export function parseStoryRequest(body: unknown) { - return storyRequestSchema.parse(body); + return storyRequestSchema.parse(body) as StoryRequestPayload; } function toTypedStoryParams( request: ReturnType, ) { return { - worldType: request.worldType as WorldType, - character: request.character as unknown as Character, - monsters: request.monsters as unknown as SceneHostileNpc[], - history: request.history as unknown as StoryMoment[], + worldType: request.worldType, + character: request.character, + monsters: request.monsters, + history: request.history, choice: request.choice.trim(), - context: request.context as unknown as StoryGenerationContext, - requestOptions: request.requestOptions as unknown as StoryRequestOptions, + context: request.context, + requestOptions: request.requestOptions, }; } export async function generateHighQualityInitialStory( + llmClient: UpstreamLlmClient, request: ReturnType, ) { const params = toTypedStoryParams(request); - return generateInitialStoryFromAi( + return generateInitialStoryFromOrchestrator( + llmClient, params.worldType, params.character, params.monsters, @@ -59,10 +59,12 @@ export async function generateHighQualityInitialStory( } export async function generateHighQualityNextStory( + llmClient: UpstreamLlmClient, request: ReturnType, ) { const params = toTypedStoryParams(request); - return generateNextStepFromAi( + return generateNextStoryFromOrchestrator( + llmClient, params.worldType, params.character, params.monsters, diff --git a/server-node/src/services/wechatAuthService.ts b/server-node/src/services/wechatAuthService.ts new file mode 100644 index 00000000..7501a153 --- /dev/null +++ b/server-node/src/services/wechatAuthService.ts @@ -0,0 +1,182 @@ +import type { Logger } from 'pino'; + +import type { AppConfig } from '../config.js'; +import { badRequest, upstreamError } from '../errors.js'; + +export type WechatIdentityProfile = { + providerUid: string; + providerUnionId: string | null; + displayName: string | null; + avatarUrl: string | null; + metaJson: Record | null; +}; + +export type WechatAuthService = { + buildAuthorizationUrl(params: { + callbackUrl: string; + state: string; + }): string; + resolveCallbackProfile(params: { + code?: string | null; + mockCode?: string | null; + }): Promise; +}; + +class MockWechatAuthService implements WechatAuthService { + constructor(private readonly config: AppConfig['wechatAuth']) {} + + buildAuthorizationUrl(params: { + callbackUrl: string; + state: string; + }) { + const callbackUrl = new URL(params.callbackUrl); + callbackUrl.searchParams.set('mock_code', this.config.mockUserId); + callbackUrl.searchParams.set('state', params.state); + return callbackUrl.toString(); + } + + async resolveCallbackProfile(params: { + mockCode?: string | null; + }) { + const mockCode = params.mockCode?.trim() || this.config.mockUserId; + return { + providerUid: mockCode, + providerUnionId: this.config.mockUnionId || null, + displayName: this.config.mockDisplayName || '微信旅人', + avatarUrl: this.config.mockAvatarUrl || null, + metaJson: { + mockCode, + }, + } satisfies WechatIdentityProfile; + } +} + +class RealWechatAuthService implements WechatAuthService { + constructor( + private readonly config: AppConfig['wechatAuth'], + private readonly logger: Logger, + ) { + if (!config.appId || !config.appSecret) { + throw new Error('WECHAT_APP_ID 或 WECHAT_APP_SECRET 未配置'); + } + } + + buildAuthorizationUrl(params: { + callbackUrl: string; + state: string; + }) { + const url = new URL(this.config.authorizeEndpoint); + url.searchParams.set('appid', this.config.appId); + url.searchParams.set('redirect_uri', params.callbackUrl); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'snsapi_login'); + url.searchParams.set('state', params.state); + return `${url.toString()}#wechat_redirect`; + } + + async resolveCallbackProfile(params: { + code?: string | null; + }) { + const code = params.code?.trim(); + if (!code) { + throw badRequest('缺少微信授权 code'); + } + + try { + const accessTokenUrl = new URL(this.config.accessTokenEndpoint); + accessTokenUrl.searchParams.set('appid', this.config.appId); + accessTokenUrl.searchParams.set('secret', this.config.appSecret); + accessTokenUrl.searchParams.set('code', code); + accessTokenUrl.searchParams.set('grant_type', 'authorization_code'); + + const accessTokenResponse = await fetch(accessTokenUrl.toString()); + const accessTokenPayload = + (await accessTokenResponse.json()) as Record; + + if (!accessTokenResponse.ok || typeof accessTokenPayload.openid !== 'string') { + throw new Error( + typeof accessTokenPayload.errmsg === 'string' + ? accessTokenPayload.errmsg + : 'failed to exchange code', + ); + } + + const accessToken = + typeof accessTokenPayload.access_token === 'string' + ? accessTokenPayload.access_token + : ''; + const openId = accessTokenPayload.openid; + const fallbackUnionId = + typeof accessTokenPayload.unionid === 'string' + ? accessTokenPayload.unionid + : null; + + if (!accessToken) { + throw new Error('missing access_token'); + } + + const userInfoUrl = new URL(this.config.userInfoEndpoint); + userInfoUrl.searchParams.set('access_token', accessToken); + userInfoUrl.searchParams.set('openid', openId); + userInfoUrl.searchParams.set('lang', 'zh_CN'); + + const userInfoResponse = await fetch(userInfoUrl.toString()); + const userInfoPayload = + (await userInfoResponse.json()) as Record; + + if (!userInfoResponse.ok || typeof userInfoPayload.openid !== 'string') { + throw new Error( + typeof userInfoPayload.errmsg === 'string' + ? userInfoPayload.errmsg + : 'failed to fetch user info', + ); + } + + return { + providerUid: userInfoPayload.openid, + providerUnionId: + typeof userInfoPayload.unionid === 'string' + ? userInfoPayload.unionid + : fallbackUnionId, + displayName: + typeof userInfoPayload.nickname === 'string' + ? userInfoPayload.nickname + : null, + avatarUrl: + typeof userInfoPayload.headimgurl === 'string' + ? userInfoPayload.headimgurl + : null, + metaJson: userInfoPayload, + } satisfies WechatIdentityProfile; + } catch (error) { + this.logger.error({ err: error }, 'wechat auth callback failed'); + throw upstreamError( + error instanceof Error + ? `微信登录失败:${error.message}` + : '微信登录失败', + ); + } + } +} + +export function createWechatAuthService( + config: AppConfig, + logger: Logger, +): WechatAuthService { + if (!config.wechatAuth.enabled) { + return { + buildAuthorizationUrl() { + throw badRequest('微信登录暂未启用'); + }, + async resolveCallbackProfile() { + throw badRequest('微信登录暂未启用'); + }, + }; + } + + if (config.wechatAuth.provider === 'mock') { + return new MockWechatAuthService(config.wechatAuth); + } + + return new RealWechatAuthService(config.wechatAuth, logger); +} diff --git a/server-node/src/services/wechatAuthStateStore.ts b/server-node/src/services/wechatAuthStateStore.ts new file mode 100644 index 00000000..7312f62c --- /dev/null +++ b/server-node/src/services/wechatAuthStateStore.ts @@ -0,0 +1,32 @@ +import crypto from 'node:crypto'; + +export type WechatAuthStateRecord = { + state: string; + redirectPath: string; + createdAt: string; +}; + +export class WechatAuthStateStore { + private readonly states = new Map(); + + create(redirectPath: string) { + const state = crypto.randomBytes(18).toString('hex'); + const record: WechatAuthStateRecord = { + state, + redirectPath, + createdAt: new Date().toISOString(), + }; + this.states.set(state, record); + return record; + } + + consume(state: string) { + const record = this.states.get(state) ?? null; + if (!record) { + return null; + } + + this.states.delete(state); + return record; + } +} diff --git a/server-node/src/testFixtures/runtimeCharacter.ts b/server-node/src/testFixtures/runtimeCharacter.ts new file mode 100644 index 00000000..d55d484b --- /dev/null +++ b/server-node/src/testFixtures/runtimeCharacter.ts @@ -0,0 +1,34 @@ +export function createTestPlayerCharacter() { + return { + id: 'test-hero', + name: '测试主角', + title: '断桥行者', + description: '用于后端运行时测试的稳定角色夹具。', + backstory: '在断桥旧哨附近长期行动,熟悉近身交锋和临场判断。', + avatar: '/test-hero.png', + portrait: '/test-hero-portrait.png', + assetFolder: 'test-hero', + assetVariant: 'default', + gender: 'female', + attributes: { + strength: 12, + agility: 11, + intelligence: 8, + spirit: 10, + }, + personality: '沉稳果断', + skills: [ + { + id: 'slash', + name: '试锋斩', + animation: 'attack', + damage: 18, + manaCost: 4, + cooldownTurns: 1, + range: 1, + style: 'steady', + }, + ], + adventureOpenings: {}, + } as TCharacter; +} diff --git a/server-node/src/testHttp.ts b/server-node/src/testHttp.ts new file mode 100644 index 00000000..2bf60317 --- /dev/null +++ b/server-node/src/testHttp.ts @@ -0,0 +1,92 @@ +import http from 'node:http'; +import https from 'node:https'; + +type RequestHeaders = Record; + +export type TestRequestInit = { + method?: string; + headers?: RequestHeaders; + body?: string; +}; + +type TestHeaders = { + get(name: string): string | null; +}; + +export type TestResponse = { + status: number; + headers: TestHeaders; + text(): Promise; + json(): Promise; +}; + +function createHeadersMap(headers: http.IncomingHttpHeaders): TestHeaders { + const values = new Map(); + + for (const [key, value] of Object.entries(headers)) { + if (typeof value === 'string') { + values.set(key.toLowerCase(), value); + continue; + } + + if (Array.isArray(value)) { + values.set(key.toLowerCase(), value.join(', ')); + } + } + + return { + get(name: string) { + return values.get(name.toLowerCase()) ?? null; + }, + }; +} + +export async function httpRequest( + urlText: string, + init: TestRequestInit = {}, +): Promise { + const url = new URL(urlText); + const transport = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const request = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: `${url.pathname}${url.search}`, + method: init.method ?? 'GET', + headers: init.headers, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + + resolve({ + status: response.statusCode ?? 0, + headers: createHeadersMap(response.headers), + async text() { + return body; + }, + async json() { + return JSON.parse(body) as T; + }, + }); + }); + }, + ); + + request.on('error', reject); + + if (typeof init.body === 'string' && init.body.length > 0) { + request.write(init.body); + } + + request.end(); + }); +} diff --git a/server-node/src/types/express.d.ts b/server-node/src/types/express.d.ts index 67f10103..e3f1b32f 100644 --- a/server-node/src/types/express.d.ts +++ b/server-node/src/types/express.d.ts @@ -2,6 +2,7 @@ declare global { namespace Express { interface Request { requestId: string; + requestStartedAt: number; userId?: string; auth?: { userId: string; diff --git a/server-node/test.mjs b/server-node/test.mjs new file mode 100644 index 00000000..5fd18b38 --- /dev/null +++ b/server-node/test.mjs @@ -0,0 +1,54 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const serverRoot = path.dirname(scriptPath); +const repoRoot = path.resolve(serverRoot, '..'); +const bundledNodePath = path.join( + repoRoot, + '.tools', + 'node-v22.22.2-win-x64', + process.platform === 'win32' ? 'node.exe' : 'bin/node', +); +const runtimeNodePath = fs.existsSync(bundledNodePath) + ? bundledNodePath + : process.execPath; + +function collectTestFiles(dirPath) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const testFiles = []; + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + testFiles.push(...collectTestFiles(fullPath)); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.test.ts')) { + testFiles.push(path.relative(serverRoot, fullPath)); + } + } + + return testFiles.sort(); +} + +const testFiles = collectTestFiles(path.join(serverRoot, 'src')); + +if (testFiles.length === 0) { + console.error('No test files found under server-node/src'); + process.exit(1); +} + +const result = spawnSync( + runtimeNodePath, + ['--test', '--test-concurrency=1', '--import', 'tsx', ...testFiles], + { + cwd: serverRoot, + stdio: 'inherit', + }, +); + +process.exit(result.status ?? 1); diff --git a/src/App.tsx b/src/App.tsx index 89a6d39d..e780ec78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,180 +1,8 @@ -import { useEffect } from 'react'; - import { GameShellRuntime } from './components/game-shell/GameShellRuntime.tsx'; -import { activateRosterCompanion, benchActiveCompanion } from './data/companionRoster'; -import { syncGameStatePlayTime } from './data/runtimeStats'; -import { useBackgroundMusic } from './hooks/useBackgroundMusic'; -import { useCombatFlow } from './hooks/useCombatFlow'; -import { useGameFlow } from './hooks/useGameFlow'; -import { useGamePersistence } from './hooks/useGamePersistence'; -import { useGameSettings } from './hooks/useGameSettings'; -import { useNpcInteractionFlow } from './hooks/useNpcInteractionFlow'; -import { useStoryGeneration } from './hooks/useStoryGeneration'; +import { useGameShellRuntime } from './hooks/useGameShellRuntime'; export default function App() { - const { - gameState, - setGameState, - bottomTab, - setBottomTab, - isMapOpen, - setIsMapOpen, - resetGame, - handleCustomWorldSelect: selectCustomWorld, - handleBackToWorldSelect: backToWorldSelect, - handleCharacterSelect: selectCharacter, - } = useGameFlow(); + const gameShellProps = useGameShellRuntime(); - const combatFlow = useCombatFlow({ - setGameState, - }); - - const storyFlow = useStoryGeneration({ - gameState, - setGameState, - buildResolvedChoiceState: combatFlow.buildResolvedChoiceState, - playResolvedChoice: combatFlow.playResolvedChoice, - }); - - const { companionRenderStates, buildCompanionRenderStates } = useNpcInteractionFlow(gameState); - const settings = useGameSettings(); - - const persistence = useGamePersistence({ - gameState, - bottomTab, - currentStory: storyFlow.currentStory, - isLoading: storyFlow.isLoading, - setGameState, - setBottomTab, - hydrateStoryState: storyFlow.hydrateStoryState, - resetStoryState: storyFlow.resetStoryState, - }); - - useBackgroundMusic({ - active: Boolean(gameState.playerCharacter && gameState.currentScene === 'Story'), - volume: settings.musicVolume, - }); - - useEffect(() => { - if (!gameState.playerCharacter || gameState.currentScene !== 'Story') { - return; - } - - const intervalId = window.setInterval(() => { - setGameState(currentState => { - if (!currentState.playerCharacter || currentState.currentScene !== 'Story') { - return currentState; - } - - return syncGameStatePlayTime(currentState); - }); - }, 15000); - - return () => window.clearInterval(intervalId); - }, [gameState.currentScene, gameState.playerCharacter, setGameState]); - - const handleCustomWorldSelect = ( - customWorldProfile: Parameters[0], - ) => { - storyFlow.resetStoryState(); - selectCustomWorld(customWorldProfile); - }; - - const handleCharacterSelect = ( - character: Parameters[0], - ) => { - storyFlow.resetStoryState(); - selectCharacter(character); - }; - - const handleBackToWorldSelect = () => { - storyFlow.resetStoryState(); - backToWorldSelect(); - }; - - const handleContinueGame = () => { - void persistence.continueSavedGame(); - }; - - const handleStartNewGame = () => { - void persistence.clearSavedGame(); - storyFlow.resetStoryState(); - resetGame(); - }; - - const handleSaveAndExit = () => { - const syncedGameState = syncGameStatePlayTime(gameState); - void persistence.saveCurrentGame({ - gameState: syncedGameState, - bottomTab, - currentStory: storyFlow.currentStory, - }); - storyFlow.resetStoryState(); - resetGame(); - }; - - const handleBenchCompanion = (npcId: string) => { - setGameState(currentState => benchActiveCompanion(currentState, npcId)); - }; - - const handleActivateRosterCompanion = (npcId: string, swapNpcId?: string | null) => { - setGameState(currentState => activateRosterCompanion(currentState, npcId, swapNpcId)); - }; - - const gameShellSession = { - gameState, - currentStory: storyFlow.currentStory, - isLoading: storyFlow.isLoading, - aiError: storyFlow.aiError, - bottomTab, - setBottomTab, - isMapOpen, - setIsMapOpen, - }; - - const gameShellStory = { - displayedOptions: storyFlow.displayedOptions, - canRefreshOptions: storyFlow.canRefreshOptions, - handleRefreshOptions: storyFlow.handleRefreshOptions, - handleChoice: storyFlow.handleChoice, - handleMapTravelToScene: storyFlow.travelToSceneFromMap, - npcUi: storyFlow.npcUi, - characterChatUi: storyFlow.characterChatUi, - inventoryUi: storyFlow.inventoryUi, - battleRewardUi: storyFlow.battleRewardUi, - questUi: storyFlow.questUi, - goalUi: storyFlow.goalUi, - }; - - const gameShellEntry = { - hasSavedGame: persistence.hasSavedGame, - handleContinueGame, - handleStartNewGame, - handleSaveAndExit, - handleCustomWorldSelect, - handleBackToWorldSelect, - handleCharacterSelect, - }; - - const gameShellCompanions = { - companionRenderStates, - buildCompanionRenderStates, - onBenchCompanion: handleBenchCompanion, - onActivateRosterCompanion: handleActivateRosterCompanion, - }; - - const gameShellAudio = { - musicVolume: settings.musicVolume, - onMusicVolumeChange: settings.setMusicVolume, - }; - - return ( - - ); + return ; } diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index 2911405c..c37f8881 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -2,7 +2,7 @@ import { motion } from 'motion/react'; import type { CustomWorldGenerationProgress, -} from '../services/aiService'; +} from '../../packages/shared/src/contracts/runtime'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; interface CustomWorldGenerationViewProps { diff --git a/src/components/ItemCatalogEditor.tsx b/src/components/ItemCatalogEditor.tsx index 465e9f05..fdab86db 100644 --- a/src/components/ItemCatalogEditor.tsx +++ b/src/components/ItemCatalogEditor.tsx @@ -11,9 +11,13 @@ import { createInventoryItemFromCatalogEntry, ITEM_CATALOG_API_PATH, ITEM_CATEGORY_OPTIONS, - ITEM_OVERRIDES_API_PATH, } from '../data/itemCatalog'; -import { fetchJson, saveJsonObject } from '../editor/shared/jsonClient'; +import { + EDITOR_JSON_RESOURCE_IDS, + fetchEditorJsonResource, + saveEditorJsonResource, +} from '../editor/shared/editorApiClient'; +import { fetchJson } from '../editor/shared/jsonClient'; import { SectionCard as Section } from '../editor/shared/SectionCard'; import { type ItemCatalogOverride, type ItemRarity, type TimedBuildBuff,WorldType } from '../types'; import { PixelIcon } from './PixelIcon'; @@ -176,7 +180,9 @@ export function ItemCatalogEditor() { try { const [catalogResponse, overridesResponse] = await Promise.all([ fetchJson(ITEM_CATALOG_API_PATH), - fetchJson>(ITEM_OVERRIDES_API_PATH), + fetchEditorJsonResource>( + EDITOR_JSON_RESOURCE_IDS.itemOverrides, + ), ]); if (disposed) return; @@ -416,7 +422,11 @@ export function ItemCatalogEditor() { setSaveMessage(null); try { - await saveJsonObject(ITEM_OVERRIDES_API_PATH, overrideMap as Record); + await saveEditorJsonResource( + EDITOR_JSON_RESOURCE_IDS.itemOverrides, + overrideMap as Record, + '保存失败', + ); setSaveMessage('物品覆盖已保存到 src/data/itemOverrides.json。'); setTimeout(() => setSaveMessage(null), 5000); } catch (error) { diff --git a/src/components/StateFunctionEditor.tsx b/src/components/StateFunctionEditor.tsx index ea225baf..43575250 100644 --- a/src/components/StateFunctionEditor.tsx +++ b/src/components/StateFunctionEditor.tsx @@ -16,6 +16,10 @@ import { type ScenePreset, } from '../data/scenePresets'; import stateFunctionOverridesJson from '../data/stateFunctionOverrides.json'; +import { + EDITOR_JSON_RESOURCE_IDS, + saveEditorJsonResource, +} from '../editor/shared/editorApiClient'; import { buildStateFunctionDefinitions, type FunctionAvailabilityContext, @@ -30,7 +34,6 @@ import { type StateFunctionOverrideMap, } from '../data/stateFunctions'; import {cloneValue} from '../editor/shared/cloneValue'; -import {saveJsonObject} from '../editor/shared/jsonClient'; import {SectionCard} from '../editor/shared/SectionCard'; import {type ResolvedChoiceState,useCombatFlow} from '../hooks/useCombatFlow'; import { @@ -1134,8 +1137,8 @@ export function StateFunctionEditor() { setIsSaving(true); setSaveMessage(null); try { - await saveJsonObject( - '/api/state-function-overrides', + await saveEditorJsonResource( + EDITOR_JSON_RESOURCE_IDS.stateFunctionOverrides, overrideMap as Record, '保存选项行为覆盖失败', ); diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx new file mode 100644 index 00000000..c6ad028c --- /dev/null +++ b/src/components/auth/AccountModal.tsx @@ -0,0 +1,478 @@ +import { useEffect, useState } from 'react'; + +import type { + AuthAuditLogEntry, + AuthCaptchaChallenge, + AuthRiskBlockSummary, + AuthSessionSummary, + AuthUser, +} from '../../services/authService'; +import { CaptchaChallengeField } from './CaptchaChallengeField'; + +type AccountModalProps = { + user: AuthUser; + isOpen: boolean; + riskBlocks: AuthRiskBlockSummary[]; + sessions: AuthSessionSummary[]; + auditLogs: AuthAuditLogEntry[]; + loadingRiskBlocks: boolean; + loadingSessions: boolean; + loadingAuditLogs: boolean; + onClose: () => void; + onLogout: () => Promise; + onRefreshRiskBlocks: () => Promise; + onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise; + onRefreshSessions: () => Promise; + onLogoutAll: () => Promise; + onRefreshAuditLogs: () => Promise; + onRevokeSession: (sessionId: string) => Promise; + changePhoneCaptchaChallenge: AuthCaptchaChallenge | null; + onSendChangePhoneCode: ( + phone: string, + captcha?: { + challengeId?: string; + answer?: string; + }, + ) => Promise<{ + cooldownSeconds: number; + expiresInSeconds: number; + }>; + onChangePhone: (phone: string, code: string) => Promise; +}; + +function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) { + switch (loginMethod) { + case 'wechat': + return '微信登录'; + case 'phone': + return '手机号登录'; + default: + return '账号登录'; + } +} + +function formatSessionTime(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString('zh-CN', { + hour12: false, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function AccountModal({ + user, + isOpen, + riskBlocks, + sessions, + auditLogs, + loadingRiskBlocks, + loadingSessions, + loadingAuditLogs, + onClose, + onLogout, + onRefreshRiskBlocks, + onLiftRiskBlock, + onRefreshSessions, + onLogoutAll, + onRefreshAuditLogs, + onRevokeSession, + changePhoneCaptchaChallenge, + onSendChangePhoneCode, + onChangePhone, +}: AccountModalProps) { + const [editingPhone, setEditingPhone] = useState(false); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [captchaAnswer, setCaptchaAnswer] = useState(''); + const [changePhoneError, setChangePhoneError] = useState(''); + const [changePhoneHint, setChangePhoneHint] = useState(''); + const [sendingCode, setSendingCode] = useState(false); + const [changingPhone, setChangingPhone] = useState(false); + const [cooldownSeconds, setCooldownSeconds] = useState(0); + + useEffect(() => { + if (cooldownSeconds <= 0) { + return; + } + + const timeoutId = window.setTimeout(() => { + setCooldownSeconds((current) => Math.max(0, current - 1)); + }, 1000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [cooldownSeconds]); + + if (!isOpen) { + return null; + } + + return ( +
+
event.stopPropagation()} + > +
+
+
+ 账号信息 +
+
+ {user.displayName} +
+
+ +
+ +
+
+ 登录方式:{resolveLoginMethodLabel(user.loginMethod)} +
+
+ 手机号:{user.phoneNumberMasked || '未绑定'} +
+
+ 微信绑定:{user.wechatBound ? '已绑定' : '未绑定'} +
+
+ 账号状态: + {user.bindingStatus === 'pending_bind_phone' + ? ' 待绑定手机号' + : ' 已激活'} +
+
+ +
+
+
+ 当前安全状态 +
+ +
+
+ {loadingRiskBlocks ? ( +
+ 正在读取安全状态... +
+ ) : riskBlocks.length > 0 ? ( + riskBlocks.map((block) => ( +
+
+ {block.title} + + 剩余约 {Math.max(1, Math.ceil(block.remainingSeconds / 60))} 分钟 + +
+
+ {block.detail} +
+ +
+ )) + ) : ( +
+ 当前没有生效中的安全限制。 +
+ )} +
+
+ +
+
+
+ 登录设备 +
+ +
+
+ {loadingSessions ? ( +
+ 正在读取当前登录设备... +
+ ) : sessions.length > 0 ? ( + sessions.map((session) => ( +
+
+ {session.clientLabel} + + {session.isCurrent ? '当前设备' : '已登录'} + +
+
+ 最近活跃:{formatSessionTime(session.lastSeenAt)} +
+
+ 到期时间:{formatSessionTime(session.expiresAt)} +
+ {session.ipMasked ? ( +
+ IP:{session.ipMasked} +
+ ) : null} + {!session.isCurrent ? ( + + ) : null} +
+ )) + ) : ( +
+ 暂无可展示的登录设备。 +
+ )} +
+
+ +
+
+
+ 更换手机号 +
+ +
+ + {editingPhone ? ( +
+ + + {changePhoneHint ? ( +
+ {changePhoneHint} +
+ ) : null} + + {changePhoneError ? ( +
+ {changePhoneError} +
+ ) : null} + +
+ ) : null} +
+ +
+
+
+ 最近账号操作 +
+ +
+
+ {loadingAuditLogs ? ( +
+ 正在读取账号操作记录... +
+ ) : auditLogs.length > 0 ? ( + auditLogs.map((log) => ( +
+
+ {log.title} + + {formatSessionTime(log.createdAt)} + +
+
+ {log.detail} +
+ {log.ipMasked ? ( +
+ IP:{log.ipMasked} +
+ ) : null} +
+ )) + ) : ( +
+ 暂无账号操作记录。 +
+ )} +
+
+ + + + +
+
+ ); +} diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 36f18fa1..3697c9d3 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -5,22 +5,69 @@ import { getStoredAccessToken, } from '../../services/apiClient'; import { + type AuthAuditLogEntry, + type AuthCaptchaChallenge, + type AuthRiskBlockSummary, + type AuthSessionSummary, type AuthUser, + bindWechatPhone, + changePhoneNumber, + consumeAuthCallbackResult, ensureAutoAuthUser, + getAuthAuditLogs, + getAuthRiskBlocks, + getAuthSessions, + getCaptchaChallengeFromError, getCurrentAuthUser, + liftAuthRiskBlock, + loginWithPhoneCode, + logoutAllAuthSessions, logoutAuthUser, + revokeAuthSession, + sendPhoneLoginCode, + startWechatLogin, } from '../../services/authService'; +import { AccountModal } from './AccountModal'; +import { BindPhoneScreen } from './BindPhoneScreen'; +import { LoginScreen } from './LoginScreen'; type AuthGateProps = { children: ReactNode; }; -type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error'; +type AuthStatus = + | 'checking' + | 'recovering' + | 'unauthenticated' + | 'pending_bind_phone' + | 'ready' + | 'error'; + +const allowDevGuestAutoAuth = + import.meta.env.DEV && + import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true'; export function AuthGate({ children }: AuthGateProps) { const [status, setStatus] = useState('checking'); const [user, setUser] = useState(null); const [error, setError] = useState(''); + const [sendingCode, setSendingCode] = useState(false); + const [loggingIn, setLoggingIn] = useState(false); + const [bindingPhone, setBindingPhone] = useState(false); + const [wechatLoading, setWechatLoading] = useState(false); + const [showAccountModal, setShowAccountModal] = useState(false); + const [sessions, setSessions] = useState([]); + const [loadingSessions, setLoadingSessions] = useState(false); + const [auditLogs, setAuditLogs] = useState([]); + const [loadingAuditLogs, setLoadingAuditLogs] = useState(false); + const [riskBlocks, setRiskBlocks] = useState([]); + const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false); + const [loginCaptchaChallenge, setLoginCaptchaChallenge] = + useState(null); + const [bindCaptchaChallenge, setBindCaptchaChallenge] = + useState(null); + const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] = + useState(null); useEffect(() => { let isActive = true; @@ -57,31 +104,58 @@ export function AuthGate({ children }: AuthGateProps) { }; const hydrate = async () => { + const callbackResult = consumeAuthCallbackResult(); + if (callbackResult?.error && isActive) { + setError(callbackResult.error); + } + const token = getStoredAccessToken(); if (!token) { - await ensureAutoUser(); + if (allowDevGuestAutoAuth) { + await ensureAutoUser(); + return; + } + + if (!isActive) { + return; + } + + setUser(null); + setStatus('unauthenticated'); return; } try { - const nextUser = await getCurrentAuthUser(); + const nextSession = await getCurrentAuthUser(); if (!isActive) { return; } - if (nextUser) { - setUser(nextUser); - setStatus('ready'); - setError(''); + if (!nextSession.user) { + setUser(null); + setStatus('unauthenticated'); return; } - await ensureAutoUser(); + setUser(nextSession.user); + setStatus( + nextSession.user.bindingStatus === 'pending_bind_phone' + ? 'pending_bind_phone' + : 'ready', + ); + setError(callbackResult?.error ?? ''); } catch { if (!isActive) { return; } - await ensureAutoUser(); + + if (allowDevGuestAutoAuth) { + await ensureAutoUser(); + return; + } + + setUser(null); + setStatus('unauthenticated'); } }; @@ -100,6 +174,91 @@ export function AuthGate({ children }: AuthGateProps) { }; }, []); + useEffect(() => { + if (!showAccountModal || status !== 'ready') { + return; + } + + let isActive = true; + setLoadingRiskBlocks(true); + setLoadingSessions(true); + setLoadingAuditLogs(true); + void getAuthRiskBlocks() + .then((nextBlocks) => { + if (!isActive) { + return; + } + setRiskBlocks(nextBlocks); + }) + .catch((blockError) => { + if (!isActive) { + return; + } + setError( + blockError instanceof Error + ? blockError.message + : '读取安全状态失败,请稍后再试。', + ); + }) + .finally(() => { + if (!isActive) { + return; + } + setLoadingRiskBlocks(false); + }); + void getAuthSessions() + .then((nextSessions) => { + if (!isActive) { + return; + } + setSessions(nextSessions); + }) + .catch((sessionError) => { + if (!isActive) { + return; + } + setError( + sessionError instanceof Error + ? sessionError.message + : '读取登录设备失败,请稍后再试。', + ); + }) + .finally(() => { + if (!isActive) { + return; + } + setLoadingSessions(false); + }); + + void getAuthAuditLogs() + .then((nextLogs) => { + if (!isActive) { + return; + } + setAuditLogs(nextLogs); + }) + .catch((auditError) => { + if (!isActive) { + return; + } + setError( + auditError instanceof Error + ? auditError.message + : '读取账号操作记录失败,请稍后再试。', + ); + }) + .finally(() => { + if (!isActive) { + return; + } + setLoadingAuditLogs(false); + }); + + return () => { + isActive = false; + }; + }, [showAccountModal, status]); + if (status === 'checking') { return (
@@ -116,11 +275,135 @@ export function AuthGate({ children }: AuthGateProps) { ); } + if (status === 'unauthenticated') { + return ( + { + setSendingCode(true); + setError(''); + try { + const result = await sendPhoneLoginCode(phone, 'login', captcha); + setLoginCaptchaChallenge(null); + return result; + } catch (sendError) { + const captchaChallenge = getCaptchaChallengeFromError(sendError); + if (captchaChallenge) { + setLoginCaptchaChallenge(captchaChallenge); + } + setError( + sendError instanceof Error + ? sendError.message + : '发送验证码失败,请稍后再试。', + ); + throw sendError; + } finally { + setSendingCode(false); + } + }} + onSubmit={async (phone, code) => { + setLoggingIn(true); + setError(''); + try { + const nextUser = await loginWithPhoneCode(phone, code); + setLoginCaptchaChallenge(null); + setUser(nextUser); + setStatus('ready'); + } catch (loginError) { + setError( + loginError instanceof Error + ? loginError.message + : '登录失败,请稍后再试。', + ); + } finally { + setLoggingIn(false); + } + }} + onStartWechatLogin={async () => { + setWechatLoading(true); + setError(''); + try { + await startWechatLogin(); + } catch (wechatError) { + setError( + wechatError instanceof Error + ? wechatError.message + : '微信登录暂不可用,请稍后再试。', + ); + } finally { + setWechatLoading(false); + } + }} + /> + ); + } + + if (status === 'pending_bind_phone' && user) { + return ( + { + setSendingCode(true); + setError(''); + try { + const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha); + setBindCaptchaChallenge(null); + return result; + } catch (sendError) { + const captchaChallenge = getCaptchaChallengeFromError(sendError); + if (captchaChallenge) { + setBindCaptchaChallenge(captchaChallenge); + } + setError( + sendError instanceof Error + ? sendError.message + : '发送验证码失败,请稍后再试。', + ); + throw sendError; + } finally { + setSendingCode(false); + } + }} + onSubmit={async (phone, code) => { + setBindingPhone(true); + setError(''); + try { + const nextUser = await bindWechatPhone(phone, code); + setBindCaptchaChallenge(null); + setUser(nextUser); + setStatus('ready'); + } catch (bindError) { + setError( + bindError instanceof Error + ? bindError.message + : '绑定手机号失败,请稍后再试。', + ); + } finally { + setBindingPhone(false); + } + }} + onLogout={async () => { + await logoutAuthUser(); + setUser(null); + setStatus('unauthenticated'); + }} + /> + ); + } + if (status !== 'ready' || !user) { return (
-
自动登录失败
+
登录状态异常
{error || '账号恢复失败,请刷新页面后重试。'}
@@ -142,7 +425,13 @@ export function AuthGate({ children }: AuthGateProps) {
- {user.username} +
+ setShowAccountModal(false)} + onLogout={async () => { + await logoutAuthUser(); + setShowAccountModal(false); + }} + onRefreshRiskBlocks={async () => { + setLoadingRiskBlocks(true); + try { + setRiskBlocks(await getAuthRiskBlocks()); + } catch (blockError) { + setError( + blockError instanceof Error + ? blockError.message + : '读取安全状态失败,请稍后再试。', + ); + } finally { + setLoadingRiskBlocks(false); + } + }} + onLiftRiskBlock={async (scopeType) => { + try { + await liftAuthRiskBlock(scopeType); + setRiskBlocks(await getAuthRiskBlocks()); + setAuditLogs(await getAuthAuditLogs()); + } catch (liftError) { + setError( + liftError instanceof Error + ? liftError.message + : '解除保护失败,请稍后再试。', + ); + } + }} + onRefreshSessions={async () => { + setLoadingSessions(true); + try { + setSessions(await getAuthSessions()); + } catch (sessionError) { + setError( + sessionError instanceof Error + ? sessionError.message + : '读取登录设备失败,请稍后再试。', + ); + } finally { + setLoadingSessions(false); + } + }} + onRefreshAuditLogs={async () => { + setLoadingAuditLogs(true); + try { + setAuditLogs(await getAuthAuditLogs()); + } catch (auditError) { + setError( + auditError instanceof Error + ? auditError.message + : '读取账号操作记录失败,请稍后再试。', + ); + } finally { + setLoadingAuditLogs(false); + } + }} + onRevokeSession={async (sessionId) => { + try { + await revokeAuthSession(sessionId); + setSessions((current) => + current.filter((session) => session.sessionId !== sessionId), + ); + setAuditLogs(await getAuthAuditLogs()); + } catch (revokeError) { + setError( + revokeError instanceof Error + ? revokeError.message + : '移除登录设备失败,请稍后再试。', + ); + } + }} + onLogoutAll={async () => { + await logoutAllAuthSessions(); + setShowAccountModal(false); + }} + changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} + onSendChangePhoneCode={async (phone, captcha) => { + try { + const result = await sendPhoneLoginCode( + phone, + 'change_phone', + captcha, + ); + setChangePhoneCaptchaChallenge(null); + return result; + } catch (sendError) { + const captchaChallenge = getCaptchaChallengeFromError(sendError); + if (captchaChallenge) { + setChangePhoneCaptchaChallenge(captchaChallenge); + } + throw sendError; + } + }} + onChangePhone={async (phone, code) => { + const nextUser = await changePhoneNumber(phone, code); + setChangePhoneCaptchaChallenge(null); + setUser(nextUser); + }} + /> {children}
); diff --git a/src/components/auth/BindPhoneScreen.tsx b/src/components/auth/BindPhoneScreen.tsx new file mode 100644 index 00000000..374c4582 --- /dev/null +++ b/src/components/auth/BindPhoneScreen.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from 'react'; + +import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService'; +import { CaptchaChallengeField } from './CaptchaChallengeField'; + +type BindPhoneScreenProps = { + user: AuthUser; + sendingCode: boolean; + binding: boolean; + error: string; + captchaChallenge: AuthCaptchaChallenge | null; + onSendCode: ( + phone: string, + captcha?: { + challengeId?: string; + answer?: string; + }, + ) => Promise<{ + cooldownSeconds: number; + expiresInSeconds: number; + }>; + onSubmit: (phone: string, code: string) => Promise; + onLogout: () => Promise; +}; + +export function BindPhoneScreen({ + user, + sendingCode, + binding, + error, + captchaChallenge, + onSendCode, + onSubmit, + onLogout, +}: BindPhoneScreenProps) { + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [captchaAnswer, setCaptchaAnswer] = useState(''); + const [cooldownSeconds, setCooldownSeconds] = useState(0); + const [hint, setHint] = useState(''); + + useEffect(() => { + if (cooldownSeconds <= 0) { + return; + } + + const timeoutId = window.setTimeout(() => { + setCooldownSeconds((current) => Math.max(0, current - 1)); + }, 1000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [cooldownSeconds]); + + return ( +
+
+
+
+
+
叙世
+
视觉叙事 RPG
+
+

+ 账号激活 +

+

+ 绑定手机号 +

+

+ 微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。 +

+
+ 当前登录身份:{user.displayName} +
+
+ +
{ + event.preventDefault(); + void onSubmit(phone, code); + }} + > + + + + + {hint ? ( +
+ {hint} +
+ ) : null} + + + + {error ? ( +
+ {error} +
+ ) : null} + + + + + +
+
+
+ ); +} diff --git a/src/components/auth/CaptchaChallengeField.tsx b/src/components/auth/CaptchaChallengeField.tsx new file mode 100644 index 00000000..5fc79506 --- /dev/null +++ b/src/components/auth/CaptchaChallengeField.tsx @@ -0,0 +1,34 @@ +import type { AuthCaptchaChallenge } from '../../services/authService'; + +type CaptchaChallengeFieldProps = { + challenge: AuthCaptchaChallenge | null; + answer: string; + onAnswerChange: (value: string) => void; +}; + +export function CaptchaChallengeField({ + challenge, + answer, + onAnswerChange, +}: CaptchaChallengeFieldProps) { + if (!challenge) { + return null; + } + + return ( +
+
{challenge.promptText}
+ 图形验证码 + onAnswerChange(event.target.value)} + /> +
+ ); +} diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index aee88e8a..40f5b7ec 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -1,39 +1,82 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; + +import type { AuthCaptchaChallenge } from '../../services/authService'; +import { CaptchaChallengeField } from './CaptchaChallengeField'; type LoginScreenProps = { - loading: boolean; + sendingCode: boolean; + loggingIn: boolean; + wechatLoading: boolean; error: string; - onSubmit: (username: string, password: string) => Promise; + captchaChallenge: AuthCaptchaChallenge | null; + onSendCode: ( + phone: string, + captcha?: { + challengeId?: string; + answer?: string; + }, + ) => Promise<{ + cooldownSeconds: number; + expiresInSeconds: number; + }>; + onSubmit: (phone: string, code: string) => Promise; + onStartWechatLogin: () => Promise; }; export function LoginScreen({ - loading, + sendingCode, + loggingIn, + wechatLoading, error, + captchaChallenge, + onSendCode, onSubmit, + onStartWechatLogin, }: LoginScreenProps) { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [captchaAnswer, setCaptchaAnswer] = useState(''); + const [cooldownSeconds, setCooldownSeconds] = useState(0); + const [hint, setHint] = useState(''); + + useEffect(() => { + if (cooldownSeconds <= 0) { + return; + } + + const timeoutId = window.setTimeout(() => { + setCooldownSeconds((current) => Math.max(0, current - 1)); + }, 1000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [cooldownSeconds]); return ( -
-
-
+
+
+
-

- Genarrative +

+
叙世
+
视觉叙事 RPG
+
+

+ 账号系统

-

- 登录后进入冒险 +

+ 账号登录

- 当前版本已切到后端账号模式。输入用户名和密码即可直接进入,用户名不存在时会自动创建账号。 + 先登录账号,再同步你的冒险进度。本轮先开放手机号验证码登录,微信登录将在下一阶段接入。

- 用户名:3 到 24 位字母、数字、下划线 + 手机号登录后可在不同设备继续同一份存档
- 第一次提交会自动注册,后续同名即登录 + 验证码登录优先适配移动端,网页端也可直接使用
@@ -42,32 +85,78 @@ export function LoginScreen({ className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12" onSubmit={(event) => { event.preventDefault(); - void onSubmit(username, password); + void onSubmit(phone, code); }} > + {hint ? ( +
+ {hint} +
+ ) : null} + + + +
+ 手机验证码适合直接登录;如果你更习惯微信,也可以先走微信登录再绑定手机号。 +
+ {error ? (
{error} @@ -76,10 +165,21 @@ export function LoginScreen({ + +
diff --git a/src/components/game-shell/GameShellCanvasStage.tsx b/src/components/game-shell/GameShellCanvasStage.tsx index fe117e67..02075e31 100644 --- a/src/components/game-shell/GameShellCanvasStage.tsx +++ b/src/components/game-shell/GameShellCanvasStage.tsx @@ -3,6 +3,7 @@ import type { GameState, } from '../../types'; import type { GameCanvasEntitySelection } from '../GameCanvas'; +import type { GameShellDialogueIndicator } from './types'; import { GameCanvas } from '../GameCanvas'; export function GameShellCanvasStage({ @@ -21,11 +22,7 @@ export function GameShellCanvasStage({ visibleGameState: GameState; hideSelectionHero: boolean; canvasCompanionRenderStates: CompanionRenderState[]; - dialogueIndicator: { - showPlayer: boolean; - showEncounter: boolean; - activeSpeaker?: 'player' | 'npc' | null; - } | null; + dialogueIndicator: GameShellDialogueIndicator | null; sceneTransitionPhase: 'idle' | 'exiting' | 'entering'; sceneTransitionToken: number; setSelectedSceneEntity: (selection: GameCanvasEntitySelection | null) => void; @@ -36,9 +33,9 @@ export function GameShellCanvasStage({
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
-
-
叙世
-
GENARRATIVE
+
+
叙世
+
视觉叙事 RPG
) : ( diff --git a/src/components/game-shell/GameShellMainContent.tsx b/src/components/game-shell/GameShellMainContent.tsx index 954bb670..628da06e 100644 --- a/src/components/game-shell/GameShellMainContent.tsx +++ b/src/components/game-shell/GameShellMainContent.tsx @@ -20,22 +20,7 @@ import type { GameCanvasEntitySelection } from '../GameCanvas'; import { CharacterSelectionFlow } from './CharacterSelectionFlow'; import { GameShellStoryPanels } from './GameShellStoryPanels'; import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow'; - -type AdventureStatistics = { - playTimeMs: number; - hostileNpcsDefeated: number; - questsAccepted: number; - questsCompleted: number; - questsTurnedIn: number; - itemsUsed: number; - scenesTraveled: number; - currentSceneName: string; - playerCurrency: number; - inventoryItemCount: number; - inventoryStackCount: number; - activeCompanionCount: number; - rosterCompanionCount: number; -}; +import type { GameShellAdventureStatistics } from './types'; export function GameShellMainContent({ gameState, @@ -106,7 +91,7 @@ export function GameShellMainContent({ openOverlayPanel: (panel: 'character' | 'inventory') => void; openCampModal: () => void; openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void; - adventureStatistics: AdventureStatistics; + adventureStatistics: GameShellAdventureStatistics; musicVolume: number; onMusicVolumeChange: (value: number) => void; resetForSaveAndExit: () => void; diff --git a/src/components/game-shell/GameShellRuntime.tsx b/src/components/game-shell/GameShellRuntime.tsx index 4c0d8125..1584e595 100644 --- a/src/components/game-shell/GameShellRuntime.tsx +++ b/src/components/game-shell/GameShellRuntime.tsx @@ -1,20 +1,13 @@ -import {useCallback, useEffect, useMemo, useState} from 'react'; - -import {getLiveGamePlayTimeMs} from '../../data/runtimeStats'; -import {getWorldCampScenePreset} from '../../data/scenePresets'; -import type {StoryOption} from '../../types'; -import {UI_CHROME} from '../../uiAssets'; -import {GameShellCanvasStage} from './GameShellCanvasStage'; -import {GameShellMainContent} from './GameShellMainContent'; -import {GameShellOverlays} from './GameShellOverlays'; -import {type GameShellProps} from './types'; -import {useGameShellViewModel} from './useGameShellViewModel'; -import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './useSceneTransitionModel'; +import { UI_CHROME } from '../../uiAssets'; +import { GameShellCanvasStage } from './GameShellCanvasStage'; +import { GameShellMainContent } from './GameShellMainContent'; +import { GameShellOverlays } from './GameShellOverlays'; +import type { GameShellProps } from './types'; +import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel'; export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) { const { gameState, - currentStory, isLoading, aiError, bottomTab, @@ -26,7 +19,6 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam displayedOptions, canRefreshOptions, handleRefreshOptions, - handleChoice, handleMapTravelToScene, npcUi, characterChatUi, @@ -46,18 +38,10 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam } = entry; const { companionRenderStates, - buildCompanionRenderStates, onBenchCompanion, onActivateRosterCompanion, } = companions; const {musicVolume, onMusicVolumeChange} = audio; - - const [clockNow, setClockNow] = useState(() => Date.now()); - const openingCampSceneId = useMemo( - () => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null), - [gameState.worldType], - ); - const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal); const { selectionStage, setSelectionStage, @@ -77,120 +61,24 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam shouldMountMapModal, shouldMountCharacterChatModal, shouldMountNpcModals, - } = useGameShellViewModel({ - gameState, - isMapOpen, - characterChatModalOpen: Boolean(characterChatUi.modal), - hasNpcModalOpen, - }); - const { visibleGameState, visibleCurrentStory, sceneTransitionPhase, sceneTransitionToken, setSceneTransitionDurations, - beginSceneTransition, - } = useSceneTransitionModel({ - gameState, - currentStory, - openingCampSceneId, + isCharacterSelectionStage, + shouldHideStoryOptions, + hideSelectionHero, + dialogueIndicator, + characterChatSummaries, + canvasCompanionRenderStates, + adventureStatistics, + handleSceneTransitionChoice, + } = useGameShellRuntimeViewModel({ + session, + story, + companions, }); - const isCharacterSelectionStage = - gameState.currentScene === 'Selection' && - Boolean(gameState.worldType) && - !gameState.playerCharacter; - const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; - const hideSelectionHero = - gameState.currentScene === 'Selection' && - selectionStage !== 'start'; - - const dialogueIndicator = useMemo(() => { - if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') { - return null; - } - - const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null; - return { - showPlayer: true, - showEncounter: true, - activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null, - } as const; - }, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]); - - const characterChatSummaries = useMemo( - () => - Object.fromEntries( - Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]), - ), - [gameState.characterChats], - ); - - const visibleCompanionRenderStates = useMemo( - () => buildCompanionRenderStates(visibleGameState), - [buildCompanionRenderStates, visibleGameState], - ); - - const canvasCompanionRenderStates = useMemo(() => { - const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc' - ? visibleGameState.currentEncounter.id ?? null - : null; - if (!activeEncounterNpcId) return visibleCompanionRenderStates; - return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId); - }, [visibleCompanionRenderStates, visibleGameState.currentEncounter]); - - const livePlayTimeMs = useMemo( - () => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow), - [clockNow, gameState.runtimeStats], - ); - - const adventureStatistics = useMemo( - () => ({ - playTimeMs: livePlayTimeMs, - hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated, - questsAccepted: gameState.runtimeStats.questsAccepted, - questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length, - questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length, - itemsUsed: gameState.runtimeStats.itemsUsed, - scenesTraveled: gameState.runtimeStats.scenesTraveled, - currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域', - playerCurrency: visibleGameState.playerCurrency, - inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0), - inventoryStackCount: visibleGameState.playerInventory.length, - activeCompanionCount: visibleGameState.companions.length, - rosterCompanionCount: visibleGameState.roster.length, - }), - [ - gameState.runtimeStats.itemsUsed, - gameState.runtimeStats.hostileNpcsDefeated, - gameState.runtimeStats.questsAccepted, - gameState.runtimeStats.scenesTraveled, - livePlayTimeMs, - visibleGameState.companions.length, - visibleGameState.currentScenePreset?.name, - visibleGameState.playerCurrency, - visibleGameState.playerInventory, - visibleGameState.quests, - visibleGameState.roster.length, - ], - ); - - useEffect(() => { - if (!gameState.playerCharacter || gameState.currentScene !== 'Story') { - return; - } - - setClockNow(Date.now()); - const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000); - return () => window.clearInterval(intervalId); - }, [gameState.currentScene, gameState.playerCharacter]); - - const handleSceneTransitionChoice = useCallback((option: StoryOption) => { - const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId]; - if (transitionMode) { - beginSceneTransition(transitionMode); - } - handleChoice(option); - }, [beginSceneTransition, handleChoice]); return (
{ const module = await import('../AdventurePanel'); @@ -38,22 +39,6 @@ const InventoryPanel = lazy(async () => { }; }); -type AdventureStatistics = { - playTimeMs: number; - hostileNpcsDefeated: number; - questsAccepted: number; - questsCompleted: number; - questsTurnedIn: number; - itemsUsed: number; - scenesTraveled: number; - currentSceneName: string; - playerCurrency: number; - inventoryItemCount: number; - inventoryStackCount: number; - activeCompanionCount: number; - rosterCompanionCount: number; -}; - export function GameShellStoryPanels({ visibleGameState, visibleCurrentStory, @@ -102,7 +87,7 @@ export function GameShellStoryPanels({ openOverlayPanel: (panel: 'character' | 'inventory') => void; openCampModal: () => void; openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void; - adventureStatistics: AdventureStatistics; + adventureStatistics: GameShellAdventureStatistics; musicVolume: number; onMusicVolumeChange: (value: number) => void; onSaveAndExit: () => void; diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index f24c3f83..76a35f9e 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -4,6 +4,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { buildCustomWorldPlayableCharacters, } from '../../data/characterPresets'; +import type { + CustomWorldGenerationProgress, +} from '../../../packages/shared/src/contracts/runtime'; +import type { JsonObject } from '../../../packages/shared/src/contracts/common'; import { readSavedCustomWorldProfiles, upsertSavedCustomWorldProfile, @@ -11,7 +15,6 @@ import { import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; import { getScenePreset } from '../../data/scenePresets'; import { - type CustomWorldGenerationProgress, generateCustomWorldProfile, } from '../../services/aiService'; import { @@ -365,7 +368,9 @@ export function PreGameSelectionFlow({ settingText: generatedCustomWorldProfile.settingText.trim() || customWorldSettingPreview, - creatorIntent: generatedCustomWorldProfile.creatorIntent, + creatorIntent: + (generatedCustomWorldProfile.creatorIntent as JsonObject | null) ?? + null, generationMode: options.generationMode ?? generatedCustomWorldProfile.generationMode ?? @@ -453,7 +458,7 @@ export function PreGameSelectionFlow({ const profile = await generateCustomWorldProfile( { settingText, - creatorIntent: customWorldCreatorIntent, + creatorIntent: customWorldCreatorIntent as unknown as JsonObject, generationMode: customWorldGenerationMode, }, { diff --git a/src/components/game-shell/types.ts b/src/components/game-shell/types.ts index 9ed82eb7..395bbb44 100644 --- a/src/components/game-shell/types.ts +++ b/src/components/game-shell/types.ts @@ -63,6 +63,28 @@ export interface GameShellAudioProps { onMusicVolumeChange: (value: number) => void; } +export interface GameShellDialogueIndicator { + showPlayer: boolean; + showEncounter: boolean; + activeSpeaker?: 'player' | 'npc' | null; +} + +export interface GameShellAdventureStatistics { + playTimeMs: number; + hostileNpcsDefeated: number; + questsAccepted: number; + questsCompleted: number; + questsTurnedIn: number; + itemsUsed: number; + scenesTraveled: number; + currentSceneName: string; + playerCurrency: number; + inventoryItemCount: number; + inventoryStackCount: number; + activeCompanionCount: number; + rosterCompanionCount: number; +} + export interface GameShellProps { session: GameShellSessionProps; story: GameShellStoryProps; diff --git a/src/components/game-shell/useGameShellRuntimeViewModel.test.ts b/src/components/game-shell/useGameShellRuntimeViewModel.test.ts new file mode 100644 index 00000000..5542ec03 --- /dev/null +++ b/src/components/game-shell/useGameShellRuntimeViewModel.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest'; + +import type { CharacterChatRecord, CompanionRenderState, GameState, StoryMoment } from '../../types'; +import { AnimationState, WorldType } from '../../types'; +import { + buildAdventureStatistics, + buildCanvasCompanionRenderStates, + buildCharacterChatSummaries, + buildGameShellDialogueIndicator, +} from './useGameShellRuntimeViewModel'; + +function createBaseGameState(): GameState { + return { + worldType: WorldType.WUXIA, + customWorldProfile: null, + playerCharacter: null, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 3, + questsAccepted: 2, + itemsUsed: 4, + scenesTraveled: 5, + }, + currentScene: 'Story', + storyHistory: [], + characterChats: {}, + animationState: AnimationState.IDLE, + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: { + id: 'scene-1', + name: '断桥旧哨', + description: '测试场景', + imageSrc: '/scene.png', + treasureHints: [], + npcs: [], + }, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 100, + playerMaxHp: 100, + playerMana: 20, + playerMaxMana: 20, + playerSkillCooldowns: {}, + activeBuildBuffs: [], + activeCombatEffects: [], + playerCurrency: 18, + playerInventory: [ + { + id: 'item-1', + name: '药草', + description: '恢复道具', + quantity: 2, + category: 'consumable', + rarity: 'common', + tags: [], + value: 1, + }, + { + id: 'item-2', + name: '布料', + description: '材料', + quantity: 3, + category: 'material', + rarity: 'common', + tags: [], + value: 1, + }, + ], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: {}, + quests: [ + { + id: 'quest-1', + issuerNpcId: 'npc-1', + issuerNpcName: '老周', + sceneId: 'scene-1', + title: '寻回包裹', + description: '找回丢失的包裹', + summary: '一项测试任务', + objective: { + kind: 'deliver_item', + targetItemId: 'item-1', + requiredCount: 1, + }, + progress: 1, + status: 'completed', + reward: { + affinityBonus: 2, + currency: 10, + items: [], + }, + rewardText: '测试奖励', + }, + { + id: 'quest-2', + issuerNpcId: 'npc-2', + issuerNpcName: '阿青', + sceneId: 'scene-1', + title: '护送商队', + description: '保护商队通行', + summary: '另一项测试任务', + objective: { + kind: 'reach_scene', + targetSceneId: 'scene-2', + requiredCount: 1, + }, + progress: 1, + status: 'turned_in', + reward: { + affinityBonus: 3, + currency: 20, + items: [], + }, + rewardText: '测试奖励', + }, + ], + roster: [ + { + npcId: 'npc-roster', + characterId: 'char-roster', + joinedAtAffinity: 10, + hp: 90, + maxHp: 90, + mana: 12, + maxMana: 12, + skillCooldowns: {}, + }, + ], + companions: [ + { + npcId: 'npc-active', + characterId: 'char-active', + joinedAtAffinity: 18, + hp: 100, + maxHp: 100, + mana: 16, + maxMana: 16, + skillCooldowns: {}, + }, + { + npcId: 'npc-encounter', + characterId: 'char-encounter', + joinedAtAffinity: 22, + hp: 88, + maxHp: 88, + mana: 14, + maxMana: 14, + skillCooldowns: {}, + }, + ], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + }; +} + +describe('useGameShellRuntimeViewModel helpers', () => { + it('builds a dialogue indicator only for active npc dialogue playback', () => { + const state = { + ...createBaseGameState(), + currentEncounter: { + id: 'npc-encounter', + kind: 'npc' as const, + npcName: '山道客', + npcDescription: '拦路人', + npcAvatar: '/npc.png', + context: '山道相遇', + }, + }; + const story = { + text: '继续对话', + displayMode: 'dialogue' as const, + dialogue: [ + { + speaker: 'npc' as const, + text: '先别急着出手。', + }, + { + speaker: 'player' as const, + text: '那你想说什么?', + }, + ], + options: [], + } satisfies StoryMoment; + + expect( + buildGameShellDialogueIndicator({ + isLoading: true, + visibleGameState: state, + visibleCurrentStory: story, + }), + ).toEqual({ + showPlayer: true, + showEncounter: true, + activeSpeaker: 'player', + }); + + expect( + buildGameShellDialogueIndicator({ + isLoading: false, + visibleGameState: state, + visibleCurrentStory: story, + }), + ).toBeNull(); + }); + + it('derives compact chat summaries and hides the active encounter companion from canvas renders', () => { + const chatSummaries = buildCharacterChatSummaries({ + 'char-active': { + history: [], + summary: '已经建立起稳定默契。', + updatedAt: null, + }, + 'char-roster': { + history: [], + summary: '仍在营地观望局势。', + updatedAt: null, + }, + } satisfies Record); + + expect(chatSummaries).toEqual({ + 'char-active': '已经建立起稳定默契。', + 'char-roster': '仍在营地观望局势。', + }); + + const visibleCompanionRenderStates = [ + { npcId: 'npc-active' }, + { npcId: 'npc-encounter' }, + ] as CompanionRenderState[]; + const visibleGameState = { + ...createBaseGameState(), + currentEncounter: { + id: 'npc-encounter', + kind: 'npc' as const, + npcName: '山道客', + npcDescription: '拦路人', + npcAvatar: '/npc.png', + context: '山道相遇', + }, + }; + + expect( + buildCanvasCompanionRenderStates({ + visibleCompanionRenderStates, + visibleGameState, + }), + ).toEqual([{ npcId: 'npc-active' }]); + }); + + it('aggregates adventure statistics from runtime and visible state slices', () => { + const gameState = createBaseGameState(); + const statistics = buildAdventureStatistics({ + gameState, + visibleGameState: gameState, + livePlayTimeMs: 3210, + }); + + expect(statistics).toEqual({ + playTimeMs: 3210, + hostileNpcsDefeated: 3, + questsAccepted: 2, + questsCompleted: 2, + questsTurnedIn: 1, + itemsUsed: 4, + scenesTraveled: 5, + currentSceneName: '断桥旧哨', + playerCurrency: 18, + inventoryItemCount: 5, + inventoryStackCount: 2, + activeCompanionCount: 2, + rosterCompanionCount: 1, + }); + }); +}); diff --git a/src/components/game-shell/useGameShellRuntimeViewModel.ts b/src/components/game-shell/useGameShellRuntimeViewModel.ts new file mode 100644 index 00000000..cb18016c --- /dev/null +++ b/src/components/game-shell/useGameShellRuntimeViewModel.ts @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { getLiveGamePlayTimeMs } from '../../data/runtimeStats'; +import { getWorldCampScenePreset } from '../../data/scenePresets'; +import type { + CharacterChatRecord, + CompanionRenderState, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import type { + GameShellAdventureStatistics, + GameShellDialogueIndicator, + GameShellProps, +} from './types'; +import { useGameShellViewModel } from './useGameShellViewModel'; +import { + SCENE_TRANSITION_FUNCTION_MODES, + useSceneTransitionModel, +} from './useSceneTransitionModel'; + +export function buildGameShellDialogueIndicator(params: { + isLoading: boolean; + visibleGameState: GameState; + visibleCurrentStory: StoryMoment | null; +}): GameShellDialogueIndicator | null { + const { isLoading, visibleGameState, visibleCurrentStory } = params; + if ( + !isLoading || + visibleCurrentStory?.displayMode !== 'dialogue' || + visibleGameState.currentEncounter?.kind !== 'npc' + ) { + return null; + } + + const lastSpeaker = + visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1] + ?.speaker ?? null; + + return { + showPlayer: true, + showEncounter: true, + activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null, + }; +} + +export function buildCharacterChatSummaries( + characterChats: Record | undefined, +) { + return Object.fromEntries( + Object.entries(characterChats ?? {}).map(([characterId, record]) => [ + characterId, + record.summary, + ]), + ); +} + +export function buildCanvasCompanionRenderStates(params: { + visibleCompanionRenderStates: CompanionRenderState[]; + visibleGameState: GameState; +}) { + const activeEncounterNpcId = + params.visibleGameState.currentEncounter?.kind === 'npc' + ? params.visibleGameState.currentEncounter.id ?? null + : null; + if (!activeEncounterNpcId) { + return params.visibleCompanionRenderStates; + } + + return params.visibleCompanionRenderStates.filter( + (companion) => companion.npcId !== activeEncounterNpcId, + ); +} + +export function buildAdventureStatistics(params: { + gameState: GameState; + visibleGameState: GameState; + livePlayTimeMs: number; +}): GameShellAdventureStatistics { + const { gameState, visibleGameState, livePlayTimeMs } = params; + + return { + playTimeMs: livePlayTimeMs, + hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated, + questsAccepted: gameState.runtimeStats.questsAccepted, + questsCompleted: visibleGameState.quests.filter( + (quest) => quest.status === 'completed' || quest.status === 'turned_in', + ).length, + questsTurnedIn: visibleGameState.quests.filter( + (quest) => quest.status === 'turned_in', + ).length, + itemsUsed: gameState.runtimeStats.itemsUsed, + scenesTraveled: gameState.runtimeStats.scenesTraveled, + currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域', + playerCurrency: visibleGameState.playerCurrency, + inventoryItemCount: visibleGameState.playerInventory.reduce( + (sum, item) => sum + item.quantity, + 0, + ), + inventoryStackCount: visibleGameState.playerInventory.length, + activeCompanionCount: visibleGameState.companions.length, + rosterCompanionCount: visibleGameState.roster.length, + }; +} + +export function useGameShellRuntimeViewModel(params: Pick< + GameShellProps, + 'session' | 'story' | 'companions' +>) { + const { session, story, companions } = params; + const { + gameState, + currentStory, + isLoading, + isMapOpen, + } = session; + const { npcUi, characterChatUi, handleChoice } = story; + const { buildCompanionRenderStates } = companions; + + const [clockNow, setClockNow] = useState(() => Date.now()); + const openingCampSceneId = useMemo( + () => + gameState.worldType + ? getWorldCampScenePreset(gameState.worldType)?.id ?? null + : null, + [gameState.worldType], + ); + const hasNpcModalOpen = Boolean( + npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal, + ); + const shellViewModel = useGameShellViewModel({ + gameState, + isMapOpen, + characterChatModalOpen: Boolean(characterChatUi.modal), + hasNpcModalOpen, + }); + const sceneTransitionModel = useSceneTransitionModel({ + gameState, + currentStory, + openingCampSceneId, + }); + const { + visibleGameState, + visibleCurrentStory, + sceneTransitionPhase, + beginSceneTransition, + } = sceneTransitionModel; + const isCharacterSelectionStage = + gameState.currentScene === 'Selection' && + Boolean(gameState.worldType) && + !gameState.playerCharacter; + const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; + const hideSelectionHero = + gameState.currentScene === 'Selection' && + shellViewModel.selectionStage !== 'start'; + + const dialogueIndicator = useMemo( + () => + buildGameShellDialogueIndicator({ + isLoading, + visibleGameState, + visibleCurrentStory, + }), + [isLoading, visibleCurrentStory, visibleGameState], + ); + + const characterChatSummaries = useMemo( + () => buildCharacterChatSummaries(gameState.characterChats), + [gameState.characterChats], + ); + + const visibleCompanionRenderStates = useMemo( + () => buildCompanionRenderStates(visibleGameState), + [buildCompanionRenderStates, visibleGameState], + ); + + const canvasCompanionRenderStates = useMemo( + () => + buildCanvasCompanionRenderStates({ + visibleCompanionRenderStates, + visibleGameState, + }), + [visibleCompanionRenderStates, visibleGameState], + ); + + const livePlayTimeMs = useMemo( + () => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow), + [clockNow, gameState.runtimeStats], + ); + + const adventureStatistics = useMemo( + () => + buildAdventureStatistics({ + gameState, + visibleGameState, + livePlayTimeMs, + }), + [gameState, livePlayTimeMs, visibleGameState], + ); + + useEffect(() => { + if (!gameState.playerCharacter || gameState.currentScene !== 'Story') { + return; + } + + setClockNow(Date.now()); + const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000); + return () => window.clearInterval(intervalId); + }, [gameState.currentScene, gameState.playerCharacter]); + + const handleSceneTransitionChoice = useCallback( + (option: StoryOption) => { + const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId]; + if (transitionMode) { + beginSceneTransition(transitionMode); + } + handleChoice(option); + }, + [beginSceneTransition, handleChoice], + ); + + return { + ...shellViewModel, + ...sceneTransitionModel, + isCharacterSelectionStage, + shouldHideStoryOptions, + hideSelectionHero, + dialogueIndicator, + characterChatSummaries, + canvasCompanionRenderStates, + adventureStatistics, + handleSceneTransitionChoice, + }; +} diff --git a/src/components/npcVisualEditorPersistence.ts b/src/components/npcVisualEditorPersistence.ts index b6e7129f..7b9aecab 100644 --- a/src/components/npcVisualEditorPersistence.ts +++ b/src/components/npcVisualEditorPersistence.ts @@ -1,4 +1,8 @@ import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals'; +import { + buildEditorJsonApiPath, + EDITOR_JSON_RESOURCE_IDS, +} from '../editor/shared/editorApiClient'; import { saveJsonObject } from '../editor/shared/jsonClient'; import { buildNpcVisualSavePayload, @@ -6,18 +10,27 @@ import { } from './npcVisualEditorModel'; import { cloneNpcLayoutConfig, type NpcLayoutConfig } from './npcVisualShared'; -export const NPC_VISUAL_OVERRIDES_API_PATH = '/api/npc-visual-overrides'; -export const NPC_LAYOUT_CONFIG_API_PATH = '/api/npc-layout-config'; +export const NPC_VISUAL_OVERRIDES_API_PATH = buildEditorJsonApiPath( + EDITOR_JSON_RESOURCE_IDS.npcVisualOverrides, +); +export const NPC_LAYOUT_CONFIG_API_PATH = buildEditorJsonApiPath( + EDITOR_JSON_RESOURCE_IDS.npcLayoutConfig, +); -type SaveJsonObjectFn = typeof saveJsonObject; +type SaveEditorJsonFn = typeof saveJsonObject; export async function persistNpcVisualOverrides(params: { overrideMap: Record; npcId: string; editorState: EditableNpcVisualState; - saveJson?: SaveJsonObjectFn; + saveJson?: SaveEditorJsonFn; }) { - const { overrideMap, npcId, editorState, saveJson = saveJsonObject } = params; + const { + overrideMap, + npcId, + editorState, + saveJson = saveJsonObject, + } = params; const nextOverrideMap = buildNpcVisualSavePayload(overrideMap, npcId, editorState); await saveJson( @@ -34,7 +47,7 @@ export async function persistNpcVisualOverrides(params: { export async function persistNpcLayoutConfig(params: { layoutDraft: NpcLayoutConfig; - saveJson?: SaveJsonObjectFn; + saveJson?: SaveEditorJsonFn; }) { const { layoutDraft, saveJson = saveJsonObject } = params; const nextLayout = cloneNpcLayoutConfig(layoutDraft); diff --git a/src/components/preset-editor/CharacterPresetPanel.tsx b/src/components/preset-editor/CharacterPresetPanel.tsx index 4ce5de4c..f5b15dfa 100644 --- a/src/components/preset-editor/CharacterPresetPanel.tsx +++ b/src/components/preset-editor/CharacterPresetPanel.tsx @@ -13,6 +13,7 @@ import { validateCharacterOverrides } from '../../data/editorValidation'; import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets'; import { getScenePresetsByWorld } from '../../data/scenePresets'; import { cloneValue } from '../../editor/shared/cloneValue'; +import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient'; import { EditorEmptyState } from '../../editor/shared/EditorEmptyState'; import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard'; import { @@ -83,7 +84,7 @@ export function CharacterPresetPanel() { ) : null; const { isSaving, saveMessage, save } = useJsonSave({ - endpoint: '/api/character-overrides', + resourceId: EDITOR_JSON_RESOURCE_IDS.characterOverrides, payload: overrideMap as Record, validate: () => validateCharacterOverrides( diff --git a/src/components/preset-editor/MonsterPresetPanel.tsx b/src/components/preset-editor/MonsterPresetPanel.tsx index af828ac4..2c89a369 100644 --- a/src/components/preset-editor/MonsterPresetPanel.tsx +++ b/src/components/preset-editor/MonsterPresetPanel.tsx @@ -7,6 +7,7 @@ import { type MonsterPresetOverride, } from '../../data/hostileNpcPresets'; import monsterOverridesJson from '../../data/monsterOverrides.json'; +import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient'; import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard'; import { NumberField, @@ -43,7 +44,7 @@ export function MonsterPresetPanel() { const [previewAnimation, setPreviewAnimation] = useState<(typeof MONSTER_ANIMATION_OPTIONS)[number]>('idle'); const { isSaving, saveMessage, save } = useJsonSave({ - endpoint: '/api/monster-overrides', + resourceId: EDITOR_JSON_RESOURCE_IDS.monsterOverrides, payload: overrideMap as Record, validate: () => validateMonsterOverrides(overrideMap, allMonsters), successMessage: '敌人预设覆盖已保存到 src/data/monsterOverrides.json。', diff --git a/src/components/preset-editor/SceneNpcPresetPanel.tsx b/src/components/preset-editor/SceneNpcPresetPanel.tsx index 6820d8b1..96804bcf 100644 --- a/src/components/preset-editor/SceneNpcPresetPanel.tsx +++ b/src/components/preset-editor/SceneNpcPresetPanel.tsx @@ -7,6 +7,7 @@ import { import { validateSceneNpcOverrides } from '../../data/editorValidation'; import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets'; import sceneNpcOverridesJson from '../../data/sceneNpcOverrides.json'; +import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient'; import { getScenePresetsByWorld, type SceneNpcPresetOverride, @@ -116,7 +117,7 @@ export function SceneNpcPresetPanel() { (effectiveNpc?.initialAffinity ?? 0) < 0, ); const { isSaving, saveMessage, save } = useJsonSave({ - endpoint: '/api/scene-npc-overrides', + resourceId: EDITOR_JSON_RESOURCE_IDS.sceneNpcOverrides, payload: overrideMap as Record, validate: () => validateSceneNpcOverrides( diff --git a/src/components/preset-editor/ScenePresetPanel.tsx b/src/components/preset-editor/ScenePresetPanel.tsx index b425ca26..148ef27b 100644 --- a/src/components/preset-editor/ScenePresetPanel.tsx +++ b/src/components/preset-editor/ScenePresetPanel.tsx @@ -5,6 +5,7 @@ import { validateSceneOverrides } from '../../data/editorValidation'; import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets'; import { createSceneHostileNpcsFromIds } from '../../data/hostileNpcs'; import sceneOverridesJson from '../../data/sceneOverrides.json'; +import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient'; import { getSceneHostileNpcPresetIds, getSceneHostileNpcs, @@ -46,7 +47,7 @@ export function ScenePresetPanel() { ); const [previewMode, setPreviewMode] = useState('monster'); const { isSaving, saveMessage, save } = useJsonSave({ - endpoint: '/api/scene-overrides', + resourceId: EDITOR_JSON_RESOURCE_IDS.sceneOverrides, payload: overrideMap as Record, validate: () => validateSceneOverrides(overrideMap, allScenes, MONSTER_PRESETS_BY_WORLD), diff --git a/src/components/preset-editor/characterAssetStudioPersistence.ts b/src/components/preset-editor/characterAssetStudioPersistence.ts index fd0a6433..957bf817 100644 --- a/src/components/preset-editor/characterAssetStudioPersistence.ts +++ b/src/components/preset-editor/characterAssetStudioPersistence.ts @@ -1,20 +1,24 @@ import { - fetchJson, - parseApiErrorMessage, -} from '../../editor/shared/jsonClient'; + ASSET_API_PATHS, + postApiJson, +} from '../../editor/shared/editorApiClient'; +import { fetchJson } from '../../editor/shared/jsonClient'; export const CHARACTER_VISUAL_GENERATE_API_PATH = - '/api/character-visual/generate'; + ASSET_API_PATHS.characterVisualGenerate; export const CHARACTER_VISUAL_PUBLISH_API_PATH = - '/api/character-visual/publish'; -export const CHARACTER_VISUAL_JOB_API_PATH = '/api/character-visual/jobs'; -export const CHARACTER_ANIMATION_GENERATE_API_PATH = '/api/animation/generate'; -export const CHARACTER_ANIMATION_PUBLISH_API_PATH = '/api/animation/publish'; -export const CHARACTER_ANIMATION_JOB_API_PATH = '/api/animation/jobs'; + ASSET_API_PATHS.characterVisualPublish; +export const CHARACTER_VISUAL_JOB_API_PATH = ASSET_API_PATHS.characterVisualJobs; +export const CHARACTER_ANIMATION_GENERATE_API_PATH = + ASSET_API_PATHS.characterAnimationGenerate; +export const CHARACTER_ANIMATION_PUBLISH_API_PATH = + ASSET_API_PATHS.characterAnimationPublish; +export const CHARACTER_ANIMATION_JOB_API_PATH = + ASSET_API_PATHS.characterAnimationJobs; export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH = - '/api/animation/import-video'; + ASSET_API_PATHS.characterAnimationImportVideo; export const CHARACTER_ANIMATION_TEMPLATES_API_PATH = - '/api/animation/templates'; + ASSET_API_PATHS.characterAnimationTemplates; export type CharacterVisualSourceMode = | 'text-to-image' @@ -119,26 +123,13 @@ export type CharacterAssetJobStatus = { export async function generateCharacterVisualCandidates( payload: CharacterVisualGenerationPayload, ) { - const response = await fetch(CHARACTER_VISUAL_GENERATE_API_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const responseText = await response.text(); - - if (!response.ok) { - throw new Error( - parseApiErrorMessage(responseText, '生成角色主形象候选失败'), - ); - } - - return JSON.parse(responseText) as { + return postApiJson<{ ok: true; taskId: string; model: string; prompt: string; drafts: CharacterVisualDraft[]; - }; + }>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象候选失败'); } export async function fetchCharacterVisualJobStatus(taskId: string) { @@ -151,41 +142,19 @@ export async function fetchCharacterVisualJobStatus(taskId: string) { export async function publishCharacterVisualAsset( payload: CharacterVisualPublishPayload, ) { - const response = await fetch(CHARACTER_VISUAL_PUBLISH_API_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const responseText = await response.text(); - - if (!response.ok) { - throw new Error(parseApiErrorMessage(responseText, '发布角色主形象失败')); - } - - return JSON.parse(responseText) as { + return postApiJson<{ ok: true; assetId: string; portraitPath: string; overrideMap: Record; saveMessage: string; - }; + }>(CHARACTER_VISUAL_PUBLISH_API_PATH, payload, '发布角色主形象失败'); } export async function generateCharacterAnimationDraft( payload: CharacterAnimationGenerationPayload, ) { - const response = await fetch(CHARACTER_ANIMATION_GENERATE_API_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const responseText = await response.text(); - - if (!response.ok) { - throw new Error(parseApiErrorMessage(responseText, '生成角色动作草稿失败')); - } - - return JSON.parse(responseText) as + return postApiJson< | { ok: true; taskId: string; @@ -201,7 +170,8 @@ export async function generateCharacterAnimationDraft( model: string; prompt: string; previewVideoPath: string; - }; + } + >(CHARACTER_ANIMATION_GENERATE_API_PATH, payload, '生成角色动作草稿失败'); } export async function fetchCharacterAnimationJobStatus(taskId: string) { @@ -224,23 +194,12 @@ export async function importCharacterAnimationVideo(payload: { videoSource: string; sourceLabel?: string; }) { - const response = await fetch(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const responseText = await response.text(); - - if (!response.ok) { - throw new Error(parseApiErrorMessage(responseText, '导入动作视频失败')); - } - - return JSON.parse(responseText) as { + return postApiJson<{ ok: true; importedVideoPath: string; draftId: string; saveMessage: string; - }; + }>(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, payload, '导入动作视频失败'); } export async function publishCharacterAnimationAssets(payload: { @@ -249,22 +208,11 @@ export async function publishCharacterAnimationAssets(payload: { animations: Record; updateCharacterOverride?: boolean; }) { - const response = await fetch(CHARACTER_ANIMATION_PUBLISH_API_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const responseText = await response.text(); - - if (!response.ok) { - throw new Error(parseApiErrorMessage(responseText, '发布角色基础动作失败')); - } - - return JSON.parse(responseText) as { + return postApiJson<{ ok: true; animationSetId: string; overrideMap: Record; animationMap: Record; saveMessage: string; - }; + }>(CHARACTER_ANIMATION_PUBLISH_API_PATH, payload, '发布角色基础动作失败'); } diff --git a/src/data/itemCatalog.ts b/src/data/itemCatalog.ts index c365c052..f4e32fda 100644 --- a/src/data/itemCatalog.ts +++ b/src/data/itemCatalog.ts @@ -5,10 +5,12 @@ import type { ItemRarity, WorldType, } from '../types'; +import { + EDITOR_ITEM_CATALOG_API_PATH, +} from '../editor/shared/editorApiClient'; import { buildDesignedItemMetadata } from './itemDesign'; -export const ITEM_CATALOG_API_PATH = '/api/item-catalog'; -export const ITEM_OVERRIDES_API_PATH = '/api/item-overrides'; +export { EDITOR_ITEM_CATALOG_API_PATH as ITEM_CATALOG_API_PATH }; export const ITEM_CATEGORY_OPTIONS = [ '武器', diff --git a/src/editor/shared/editorApiClient.ts b/src/editor/shared/editorApiClient.ts new file mode 100644 index 00000000..9ab2637f --- /dev/null +++ b/src/editor/shared/editorApiClient.ts @@ -0,0 +1,78 @@ +import { fetchJson, parseApiErrorMessage, saveJsonObject } from './jsonClient'; + +export const EDITOR_API_BASE_PATH = '/api/editor'; +export const ASSETS_API_BASE_PATH = '/api/assets'; + +export const EDITOR_JSON_RESOURCE_IDS = { + itemOverrides: 'item-overrides', + npcVisualOverrides: 'npc-visual-overrides', + npcLayoutConfig: 'npc-layout-config', + characterOverrides: 'character-overrides', + monsterOverrides: 'monster-overrides', + sceneOverrides: 'scene-overrides', + sceneNpcOverrides: 'scene-npc-overrides', + stateFunctionOverrides: 'state-function-overrides', +} as const; + +export type EditorJsonResourceId = + (typeof EDITOR_JSON_RESOURCE_IDS)[keyof typeof EDITOR_JSON_RESOURCE_IDS]; + +export const ASSET_API_PATHS = { + characterVisualGenerate: `${ASSETS_API_BASE_PATH}/character-visual/generate`, + characterVisualPublish: `${ASSETS_API_BASE_PATH}/character-visual/publish`, + characterVisualJobs: `${ASSETS_API_BASE_PATH}/character-visual/jobs`, + characterAnimationGenerate: `${ASSETS_API_BASE_PATH}/character-animation/generate`, + characterAnimationPublish: `${ASSETS_API_BASE_PATH}/character-animation/publish`, + characterAnimationJobs: `${ASSETS_API_BASE_PATH}/character-animation/jobs`, + characterAnimationImportVideo: `${ASSETS_API_BASE_PATH}/character-animation/import-video`, + characterAnimationTemplates: `${ASSETS_API_BASE_PATH}/character-animation/templates`, + qwenSpriteMaster: `${ASSETS_API_BASE_PATH}/qwen-sprite/master`, + qwenSpriteSheet: `${ASSETS_API_BASE_PATH}/qwen-sprite/sheet`, + qwenSpriteFrameRepair: `${ASSETS_API_BASE_PATH}/qwen-sprite/frame-repair`, + qwenSpriteSave: `${ASSETS_API_BASE_PATH}/qwen-sprite/save`, +} as const; + +export const EDITOR_ITEM_CATALOG_API_PATH = + `${EDITOR_API_BASE_PATH}/catalog/items`; + +export function buildEditorJsonApiPath(resourceId: EditorJsonResourceId) { + return `${EDITOR_API_BASE_PATH}/json/${resourceId}`; +} + +export function fetchEditorJsonResource( + resourceId: EditorJsonResourceId, + fallbackMessage = '读取失败', +) { + return fetchJson(buildEditorJsonApiPath(resourceId), fallbackMessage); +} + +export function saveEditorJsonResource( + resourceId: EditorJsonResourceId, + payload: Record, + fallbackMessage = '保存失败', +) { + return saveJsonObject( + buildEditorJsonApiPath(resourceId), + payload, + fallbackMessage, + ); +} + +export async function postApiJson( + url: string, + payload: Record, + fallbackMessage: string, +) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const responseText = await response.text(); + + if (!response.ok) { + throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); + } + + return responseText ? (JSON.parse(responseText) as T) : ({} as T); +} diff --git a/src/editor/shared/jsonClient.ts b/src/editor/shared/jsonClient.ts index eeb32c18..1b279350 100644 --- a/src/editor/shared/jsonClient.ts +++ b/src/editor/shared/jsonClient.ts @@ -1,43 +1,28 @@ -type ApiErrorPayload = { - error?: { - message?: string; - }; - message?: string; -}; - -export function parseApiErrorMessage(responseText: string, fallbackMessage: string) { - if (!responseText) { - return fallbackMessage; - } - - try { - const parsed = JSON.parse(responseText) as ApiErrorPayload; - if (parsed.error?.message) { - return parsed.error.message; - } - - if (typeof parsed.message === 'string' && parsed.message.trim()) { - return parsed.message; - } - } catch { - // Fall through to the raw response text below. - } - - return responseText; -} +import { + API_RESPONSE_ENVELOPE_HEADER, + API_RESPONSE_ENVELOPE_VERSION, + parseApiErrorMessage, + unwrapApiResponse, +} from '../../../packages/shared/src/http'; export async function fetchJson( url: string, fallbackMessage = '请求失败', ): Promise { - const response = await fetch(url); + const response = await fetch(url, { + headers: { + [API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION, + }, + }); const responseText = await response.text(); if (!response.ok) { throw new Error(parseApiErrorMessage(responseText, `${fallbackMessage}: ${response.status}`)); } - return responseText ? (JSON.parse(responseText) as T) : ({} as T); + return responseText + ? unwrapApiResponse(JSON.parse(responseText) as T) + : ({} as T); } export async function saveJsonObject( @@ -47,7 +32,10 @@ export async function saveJsonObject( ) { const response = await fetch(url, { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + [API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION, + }, body: JSON.stringify(payload, null, 2), }); const responseText = await response.text(); @@ -56,3 +44,5 @@ export async function saveJsonObject( throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); } } + +export { parseApiErrorMessage }; diff --git a/src/editor/shared/useJsonSave.ts b/src/editor/shared/useJsonSave.ts index 3df09659..f304345e 100644 --- a/src/editor/shared/useJsonSave.ts +++ b/src/editor/shared/useJsonSave.ts @@ -1,9 +1,12 @@ import { useState } from 'react'; -import { saveJsonObject } from './jsonClient'; +import { + saveEditorJsonResource, + type EditorJsonResourceId, +} from './editorApiClient'; type UseJsonSaveOptions = { - endpoint: string; + resourceId: EditorJsonResourceId; payload: Record; validate?: () => string[]; successMessage: string; @@ -11,7 +14,7 @@ type UseJsonSaveOptions = { }; export function useJsonSave({ - endpoint, + resourceId, payload, validate, successMessage, @@ -32,7 +35,7 @@ export function useJsonSave({ } try { - await saveJsonObject(endpoint, payload); + await saveEditorJsonResource(resourceId, payload, errorMessage); setSaveMessage(successMessage); } catch (error) { setSaveMessage(error instanceof Error ? error.message : errorMessage); diff --git a/src/hooks/story/choiceActions.test.ts b/src/hooks/story/choiceActions.test.ts index e8baca25..2c5ea6d6 100644 --- a/src/hooks/story/choiceActions.test.ts +++ b/src/hooks/story/choiceActions.test.ts @@ -1,10 +1,26 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../services/ai', () => ({ +vi.mock('../../services/aiService', () => ({ generateNextStep: vi.fn(), })); -import { generateNextStep } from '../../services/ai'; +const { + isServerRuntimeFunctionIdMock, + resolveServerRuntimeChoiceMock, +} = vi.hoisted(() => ({ + isServerRuntimeFunctionIdMock: vi.fn(() => false), + resolveServerRuntimeChoiceMock: vi.fn(), +})); + +vi.mock('./runtimeStoryCoordinator', () => ({ + resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock, +})); + +vi.mock('../../services/runtimeStoryService', () => ({ + isServerRuntimeFunctionId: isServerRuntimeFunctionIdMock, +})); + +import { generateNextStep } from '../../services/aiService'; import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types'; import { createStoryChoiceActions } from './choiceActions'; @@ -133,6 +149,228 @@ const neverNpcEncounter = ( ): encounter is Encounter => false; describe('createStoryChoiceActions', () => { + beforeEach(() => { + isServerRuntimeFunctionIdMock.mockReset(); + isServerRuntimeFunctionIdMock.mockReturnValue(false); + resolveServerRuntimeChoiceMock.mockReset(); + }); + + it('routes task5 story choices through the server runtime action endpoint', async () => { + const state = createBaseState(); + const option = createBattleOption('npc_chat'); + const setGameState = vi.fn(); + const setCurrentStory = vi.fn(); + + isServerRuntimeFunctionIdMock.mockReturnValue(true); + resolveServerRuntimeChoiceMock.mockResolvedValue({ + hydratedSnapshot: { + gameState: { + ...state, + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 1, + npcStates: { + ...state.npcStates, + 'npc-opponent': { + ...state.npcStates['npc-opponent'], + affinity: 6, + chattedCount: 1, + }, + }, + }, + currentStory: { + text: '后端已结算关系变化', + options: [], + }, + bottomTab: 'adventure', + }, + nextStory: { + text: '后端已结算关系变化', + options: [ + { + functionId: 'npc_help', + actionText: '请求援手', + text: '请求援手', + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + ], + }, + }); + + const { handleChoice } = createStoryChoiceActions({ + gameState: { + ...state, + currentEncounter: { + id: 'npc-opponent', + kind: 'npc', + npcName: '山道客', + npcDescription: '拦路的陌生人', + npcAvatar: '/npc.png', + context: '山道相遇', + }, + npcInteractionActive: true, + inBattle: false, + sceneHostileNpcs: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + }, + currentStory: createFallbackStory('当前故事'), + isLoading: false, + setGameState, + setCurrentStory, + setAiError: vi.fn(), + setIsLoading: vi.fn(), + setBattleReward: vi.fn(), + buildResolvedChoiceState: vi.fn(), + playResolvedChoice: vi.fn(), + buildStoryContextFromState: vi.fn(), + buildStoryFromResponse: vi.fn((_, __, response) => response), + buildFallbackStoryForState: vi.fn(() => createFallbackStory()), + generateStoryForState: vi.fn(), + getAvailableOptionsForState: vi.fn(() => null), + getStoryGenerationHostileNpcs: vi.fn(() => []), + getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), + buildNpcStory: vi.fn(() => createFallbackStory()), + updateQuestLog: vi.fn((inputState: GameState) => inputState), + incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), + getCampCompanionTravelScene: vi.fn(() => null), + startOpeningAdventure: vi.fn(), + enterNpcInteraction: vi.fn(() => false), + handleNpcInteraction: vi.fn(() => false), + handleTreasureInteraction: vi.fn(() => false), + commitGeneratedStateWithEncounterEntry: vi.fn(), + finalizeNpcBattleResult: vi.fn(() => null), + isContinueAdventureOption: vi.fn(() => false), + isCampTravelHomeOption: vi.fn(() => false), + isInitialCompanionEncounter: neverNpcEncounter, + isRegularNpcEncounter: (encounter): encounter is Encounter => + Boolean(encounter?.kind === 'npc'), + isNpcEncounter: (encounter): encounter is Encounter => + Boolean(encounter?.kind === 'npc'), + npcPreviewTalkFunctionId: 'npc_preview_talk', + fallbackCompanionName: '同伴', + turnVisualMs: 820, + }); + + await handleChoice(option); + + expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith({ + gameState: expect.objectContaining({ + currentEncounter: expect.objectContaining({ + id: 'npc-opponent', + }), + }), + currentStory: createFallbackStory('当前故事'), + option, + }); + expect(setGameState).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeActionVersion: 1, + }), + ); + expect(setCurrentStory).toHaveBeenCalledWith( + expect.objectContaining({ + text: '后端已结算关系变化', + options: [ + expect.objectContaining({ + functionId: 'npc_help', + }), + ], + }), + ); + }); + + it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => { + const state: GameState = { + ...createBaseState(), + currentEncounter: { + id: 'npc-merchant', + kind: 'npc' as const, + npcName: '梁伯', + npcDescription: '沿街商贩', + npcAvatar: '/npc.png', + context: '沿街商贩', + }, + npcInteractionActive: true, + inBattle: false, + sceneHostileNpcs: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + }; + const option: StoryOption = { + functionId: 'npc_trade', + actionText: '交易', + text: '交易', + interaction: { + kind: 'npc' as const, + npcId: 'npc-merchant', + action: 'trade' as const, + }, + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right' as const, + scrollWorld: false, + monsterChanges: [], + }, + }; + const handleNpcInteraction = vi.fn(() => true); + + isServerRuntimeFunctionIdMock.mockReturnValue(true); + + const { handleChoice } = createStoryChoiceActions({ + gameState: state, + currentStory: createFallbackStory('当前故事'), + isLoading: false, + setGameState: vi.fn(), + setCurrentStory: vi.fn(), + setAiError: vi.fn(), + setIsLoading: vi.fn(), + setBattleReward: vi.fn(), + buildResolvedChoiceState: vi.fn(), + playResolvedChoice: vi.fn(), + buildStoryContextFromState: vi.fn(), + buildStoryFromResponse: vi.fn((_, __, response) => response), + buildFallbackStoryForState: vi.fn(() => createFallbackStory()), + generateStoryForState: vi.fn(), + getAvailableOptionsForState: vi.fn(() => null), + getStoryGenerationHostileNpcs: vi.fn(() => []), + getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs), + buildNpcStory: vi.fn(() => createFallbackStory()), + updateQuestLog: vi.fn((inputState: GameState) => inputState), + incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), + getCampCompanionTravelScene: vi.fn(() => null), + startOpeningAdventure: vi.fn(), + enterNpcInteraction: vi.fn(() => false), + handleNpcInteraction, + handleTreasureInteraction: vi.fn(() => false), + commitGeneratedStateWithEncounterEntry: vi.fn(), + finalizeNpcBattleResult: vi.fn(() => null), + isContinueAdventureOption: vi.fn(() => false), + isCampTravelHomeOption: vi.fn(() => false), + isInitialCompanionEncounter: neverNpcEncounter, + isRegularNpcEncounter: (encounter): encounter is Encounter => + Boolean(encounter?.kind === 'npc'), + isNpcEncounter: (encounter): encounter is Encounter => + Boolean(encounter?.kind === 'npc'), + npcPreviewTalkFunctionId: 'npc_preview_talk', + fallbackCompanionName: '同伴', + turnVisualMs: 820, + }); + + await handleChoice(option); + + expect(handleNpcInteraction).toHaveBeenCalledWith(option); + expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled(); + }); + it('keeps the finishing action in history before npc victory follow-up generation', async () => { const state = createBaseState(); const option = createBattleOption(); diff --git a/src/hooks/story/choiceActions.ts b/src/hooks/story/choiceActions.ts index b4059ace..de560d3b 100644 --- a/src/hooks/story/choiceActions.ts +++ b/src/hooks/story/choiceActions.ts @@ -3,25 +3,9 @@ import type { SetStateAction, } from 'react'; -import { - buildEncounterEntryState, - hasEncounterEntity, -} from '../../data/encounterTransition'; -import { rollHostileNpcLoot } from '../../data/hostileNpcPresets'; -import { addInventoryItems } from '../../data/npcInteractions'; -import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow'; -import { - CALL_OUT_ENTRY_X_METERS, - createSceneEncounterPreview, - resolveSceneEncounterPreview, -} from '../../data/sceneEncounterPreviews'; -import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; -import { generateNextStep } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; -import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory'; -import { createHistoryMoment } from '../../services/storyHistory'; +import { isServerRuntimeFunctionId } from '../../services/runtimeStoryService'; import { - AnimationState, type Character, type Encounter, type GameState, @@ -34,6 +18,12 @@ import type { CommitGeneratedStateWithEncounterEntry, GenerateStoryForState, } from './progressionActions'; +import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation'; +import { + runCampTravelHomeChoice, + runServerRuntimeChoiceAction, + shouldOpenLocalRuntimeNpcModal, +} from './storyChoiceRuntime'; import type { BattleRewardSummary } from './uiTypes'; type RuntimeStatsIncrements = Partial>; @@ -78,112 +68,6 @@ type IncrementRuntimeStats = ( increments: RuntimeStatsIncrements, ) => GameState; -function sleep(ms: number) { - return new Promise(resolve => window.setTimeout(resolve, ms)); -} - -function buildReasonedOptionCatalog(options: StoryOption[]) { - const seenFunctionIds = new Set(); - - return options.filter(option => { - if (seenFunctionIds.has(option.functionId)) { - return false; - } - - seenFunctionIds.add(option.functionId); - return true; - }); -} - -function buildCombatResolutionContextText(params: { - baseState: GameState; - afterSequence: GameState; - optionKind: ResolvedChoiceState['optionKind']; - projectedBattleReward: BattleRewardSummary | null; - getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs']; -}) { - const { - baseState, - afterSequence, - optionKind, - projectedBattleReward, - getResolvedSceneHostileNpcs, - } = params; - - if (optionKind === 'escape') { - const hostileNames = getResolvedSceneHostileNpcs(baseState) - .map((hostileNpc) => hostileNpc.name) - .join('、'); - return hostileNames - ? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。` - : '你已成功逃脱刚才的交战,当前不再处于战斗状态。'; - } - - if ( - !baseState.inBattle || - afterSequence.inBattle || - Boolean(baseState.currentBattleNpcId) - ) { - return null; - } - - const hostileNames = getResolvedSceneHostileNpcs(baseState) - .map((hostileNpc) => hostileNpc.name) - .join('、'); - const lootText = - projectedBattleReward?.items.length - ? `战利品:${projectedBattleReward.items.map((item) => item.name).join('、')}。` - : ''; - - return hostileNames - ? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}` - : `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`; -} - -async function buildHostileNpcBattleReward( - state: GameState, - afterSequence: GameState, - optionKind: ResolvedChoiceState['optionKind'], - getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'], -): Promise { - if ( - optionKind === 'escape' - || !state.worldType - || state.currentBattleNpcId - || !state.inBattle - || afterSequence.inBattle - ) { - return null; - } - - const activeHostileNpcs = getResolvedSceneHostileNpcs(state); - const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence); - const defeatedHostileNpcs = activeHostileNpcs.filter(hostileNpc => - !nextHostileNpcs.some(nextHostileNpc => nextHostileNpc.id === hostileNpc.id), - ); - - if (defeatedHostileNpcs.length === 0) { - return null; - } - - const rolledItems = await rollHostileNpcLoot( - state, - defeatedHostileNpcs.map(hostileNpc => ({ - id: hostileNpc.id, - name: hostileNpc.name, - })), - ); - - return { - id: `battle-reward-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - defeatedHostileNpcs: defeatedHostileNpcs.map(hostileNpc => ({ - id: hostileNpc.id, - name: hostileNpc.name, - })), - items: addInventoryItems([], rolledItems), - }; -} - export function createStoryChoiceActions({ gameState, currentStory, @@ -250,8 +134,10 @@ export function createStoryChoiceActions({ getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null; startOpeningAdventure: () => Promise; enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean; - handleNpcInteraction: (option: StoryOption) => boolean; - handleTreasureInteraction: (option: StoryOption) => void | Promise | boolean; + handleNpcInteraction: (option: StoryOption) => boolean | Promise; + handleTreasureInteraction: ( + option: StoryOption, + ) => void | Promise | boolean | Promise; commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry; finalizeNpcBattleResult: ( state: GameState, @@ -268,91 +154,6 @@ export function createStoryChoiceActions({ fallbackCompanionName: string; turnVisualMs: number; }) { - const handleCampTravelHome = async (option: StoryOption, character: Character) => { - const targetScene = getCampCompanionTravelScene(gameState, character); - if (!targetScene) { - return; - } - - setBattleReward(null); - setAiError(null); - - const companionName = isNpcEncounter(gameState.currentEncounter) - ? gameState.currentEncounter.npcName - : fallbackCompanionName; - const travelRunState: GameState = { - ...gameState, - ambientIdleMode: undefined, - currentEncounter: null, - npcInteractionActive: false, - sceneHostileNpcs: [], - playerX: 0, - playerFacing: 'right' as const, - animationState: AnimationState.RUN, - playerActionMode: 'idle' as const, - activeCombatEffects: [], - scrollWorld: true, - inBattle: false, - lastObserveSignsSceneId: null, - lastObserveSignsReport: null, - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - }; - const travelBaseState: GameState = incrementRuntimeStats({ - ...gameState, - ambientIdleMode: undefined, - currentScenePreset: targetScene, - currentEncounter: null, - npcInteractionActive: false, - sceneHostileNpcs: [], - playerX: 0, - playerFacing: 'right' as const, - animationState: AnimationState.IDLE, - playerActionMode: 'idle' as const, - activeCombatEffects: [], - scrollWorld: false, - inBattle: false, - lastObserveSignsSceneId: null, - lastObserveSignsReport: null, - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - }, { - scenesTraveled: 1, - }); - const travelPreviewState: GameState = { - ...travelBaseState, - ...createSceneEncounterPreview(travelBaseState), - }; - const resolvedState = hasEncounterEntity(travelPreviewState) - ? resolveSceneEncounterPreview(travelPreviewState) - : travelBaseState; - const entryState = buildEncounterEntryState(resolvedState, CALL_OUT_ENTRY_X_METERS); - - setIsLoading(true); - setGameState(travelRunState); - await sleep(turnVisualMs); - - await commitGeneratedStateWithEncounterEntry( - entryState, - resolvedState, - character, - option.actionText, - `You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`, - option.functionId, - ); - return; - }; - const handleChoice = async (option: StoryOption) => { const character = gameState.playerCharacter; if (!gameState.worldType || !character || isLoading) return; @@ -367,7 +168,43 @@ export function createStoryChoiceActions({ } if (isCampTravelHomeOption(option)) { - await handleCampTravelHome(option, character); + await runCampTravelHomeChoice({ + gameState, + option, + character, + setBattleReward, + setAiError, + setIsLoading, + setGameState, + incrementRuntimeStats, + getCampCompanionTravelScene, + commitGeneratedStateWithEncounterEntry, + isNpcEncounter, + fallbackCompanionName, + turnVisualMs, + }); + return; + } + + if (shouldOpenLocalRuntimeNpcModal(option)) { + setAiError(null); + await handleNpcInteraction(option); + return; + } + + if (isServerRuntimeFunctionId(option.functionId)) { + await runServerRuntimeChoiceAction({ + gameState, + currentStory, + option, + character, + setBattleReward, + setAiError, + setIsLoading, + setGameState, + setCurrentStory: (story) => setCurrentStory(story), + buildFallbackStoryForState, + }); return; } @@ -393,238 +230,41 @@ export function createStoryChoiceActions({ if (option.interaction?.kind === 'npc') { setAiError(null); - handleNpcInteraction(option); + await handleNpcInteraction(option); return; } if (option.interaction?.kind === 'treasure') { setAiError(null); - handleTreasureInteraction(option); + await handleTreasureInteraction(option); return; } - setBattleReward(null); - setAiError(null); - setIsLoading(true); - - const baseChoiceState = ( - isRegularNpcEncounter(gameState.currentEncounter) - && !gameState.npcInteractionActive - && !option.interaction - ) - ? { - ...gameState, - currentEncounter: null, - npcInteractionActive: false, - } - : gameState; - - let fallbackState = baseChoiceState; - - try { - const history = baseChoiceState.storyHistory; - const resolvedChoice = buildResolvedChoiceState(baseChoiceState, option, character); - const projectedState = resolvedChoice.afterSequence; - const shouldUseLocalNpcVictory = Boolean( - baseChoiceState.currentBattleNpcId && - resolvedChoice.optionKind === 'battle' && - ( - projectedState.currentNpcBattleOutcome || - (baseChoiceState.currentNpcBattleMode === 'fight' && !projectedState.inBattle) - ), - ); - const projectedBattleReward = shouldUseLocalNpcVictory - ? null - : await buildHostileNpcBattleReward( - baseChoiceState, - projectedState, - resolvedChoice.optionKind, - getResolvedSceneHostileNpcs, - ); - const projectedStateWithBattleReward = projectedBattleReward - ? appendStoryEngineCarrierMemory({ - ...projectedState, - playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items), - } as GameState, projectedBattleReward.items) - : projectedState; - fallbackState = projectedStateWithBattleReward; - const projectedAvailableOptions = getAvailableOptionsForState( - projectedStateWithBattleReward, - character, - ); - const combatResolutionContextText = buildCombatResolutionContextText({ - baseState: baseChoiceState, - afterSequence: projectedStateWithBattleReward, - optionKind: resolvedChoice.optionKind, - projectedBattleReward, - getResolvedSceneHostileNpcs, - }); - const historyForStoryGeneration = combatResolutionContextText - ? [ - ...history, - createHistoryMoment(option.actionText, 'action'), - createHistoryMoment(combatResolutionContextText, 'result'), - ] - : history; - - const responsePromise = shouldUseLocalNpcVictory - ? Promise.resolve(null) - : generateNextStep( - gameState.worldType, - character, - getStoryGenerationHostileNpcs(projectedStateWithBattleReward), - historyForStoryGeneration, - option.actionText, - buildStoryContextFromState(projectedStateWithBattleReward, { - lastFunctionId: option.functionId, - observeSignsRequested: option.functionId === 'idle_observe_signs', - recentActionResult: combatResolutionContextText, - }), - projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined, - ); - const responseSettledPromise = responsePromise.then(() => undefined, () => undefined); - const playbackSync: EscapePlaybackSync | undefined = resolvedChoice.optionKind === 'escape' - ? { waitForStoryResponse: responseSettledPromise } - : undefined; - const actionPromise = playResolvedChoice( - baseChoiceState, - option, - character, - resolvedChoice, - playbackSync, - ); - const [actionResult, responseResult] = await Promise.allSettled([actionPromise, responsePromise]); - - if (actionResult.status === 'rejected') { - throw actionResult.reason; - } - - let afterSequence = shouldUseLocalNpcVictory ? resolvedChoice.afterSequence : actionResult.value; - if (projectedBattleReward) { - afterSequence = appendStoryEngineCarrierMemory({ - ...afterSequence, - playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items), - } as GameState, projectedBattleReward.items); - } - fallbackState = afterSequence; - - if (shouldUseLocalNpcVictory) { - const victory = finalizeNpcBattleResult( - afterSequence, - character, - baseChoiceState.currentNpcBattleMode!, - afterSequence.currentNpcBattleOutcome, - ); - if (victory) { - const historyBase = baseChoiceState.currentNpcBattleMode === 'spar' - ? (afterSequence.sparStoryHistoryBefore ?? []) - : baseChoiceState.storyHistory; - const nextHistory = [ - ...historyBase, - createHistoryMoment(option.actionText, 'action'), - createHistoryMoment(victory.resultText, 'result'), - ]; - const nextState = { - ...victory.nextState, - storyHistory: nextHistory, - }; - const postBattleOptionCatalog = baseChoiceState.currentNpcBattleMode === 'spar' && nextState.currentEncounter - ? buildReasonedOptionCatalog( - buildNpcStory( - nextState, - character, - nextState.currentEncounter, - ).options, - ) - : null; - fallbackState = nextState; - setGameState(nextState); - try { - const nextStory = await generateStoryForState({ - state: nextState, - character, - history: nextHistory, - choice: option.actionText, - lastFunctionId: option.functionId, - optionCatalog: postBattleOptionCatalog, - }); - const recoveredState = applyStoryReasoningRecovery(nextState); - setGameState(recoveredState); - setCurrentStory(nextStory); - } catch (storyError) { - console.error('Failed to continue npc battle resolution story:', storyError); - setAiError(storyError instanceof Error ? storyError.message : '未知智能生成错误'); - setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText)); - } - return; - } - } - - if (responseResult.status === 'rejected') { - throw responseResult.reason; - } - - const response = responseResult.value!; - const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId || resolvedChoice.optionKind === 'escape' - ? [] - : getResolvedSceneHostileNpcs(baseChoiceState) - .map(hostileNpc => hostileNpc.id) - .filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId)); - const nextHistory = combatResolutionContextText - ? [ - ...historyForStoryGeneration, - createHistoryMoment(response.storyText, 'result', response.options), - ] - : [ - ...baseChoiceState.storyHistory, - createHistoryMoment(option.actionText, 'action'), - createHistoryMoment(response.storyText, 'result', response.options), - ]; - - const nextState = incrementRuntimeStats({ - ...updateQuestLog( - afterSequence, - quests => applyQuestProgressFromHostileNpcDefeat( - quests, - baseChoiceState.currentScenePreset?.id ?? null, - defeatedHostileNpcIds, - ), - ), - lastObserveSignsSceneId: option.functionId === 'idle_observe_signs' - ? (afterSequence.currentScenePreset?.id ?? null) - : afterSequence.lastObserveSignsSceneId ?? null, - lastObserveSignsReport: option.functionId === 'idle_observe_signs' - ? response.storyText - : afterSequence.lastObserveSignsReport ?? null, - storyHistory: nextHistory, - }, { - hostileNpcsDefeated: defeatedHostileNpcIds.length, - }); - - const recoveredState = applyStoryReasoningRecovery(nextState); - setGameState(recoveredState); - if (projectedBattleReward) { - setBattleReward(projectedBattleReward); - } - - setCurrentStory( - buildStoryFromResponse( - recoveredState, - character, - { - text: response.storyText, - options: response.options, - }, - projectedAvailableOptions, - ), - ); - } catch (error) { - console.error('Failed to get next step:', error); - setAiError(error instanceof Error ? error.message : '未知智能生成错误'); - setCurrentStory(buildFallbackStoryForState(fallbackState, character)); - } finally { - setIsLoading(false); - } + await runLocalStoryChoiceContinuation({ + gameState, + currentStory, + option, + character, + setGameState, + setCurrentStory: (story) => setCurrentStory(story), + setAiError, + setIsLoading, + setBattleReward, + buildResolvedChoiceState, + playResolvedChoice, + buildStoryContextFromState, + buildStoryFromResponse, + buildFallbackStoryForState, + generateStoryForState, + getAvailableOptionsForState, + getStoryGenerationHostileNpcs, + getResolvedSceneHostileNpcs, + buildNpcStory, + updateQuestLog, + incrementRuntimeStats, + finalizeNpcBattleResult, + isRegularNpcEncounter, + }); }; return { diff --git a/src/hooks/story/goalFlow.ts b/src/hooks/story/goalFlow.ts new file mode 100644 index 00000000..7c4c96d0 --- /dev/null +++ b/src/hooks/story/goalFlow.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + buildGoalStackState, + createGoalPulseSnapshot, + deriveGoalPulseEvent, +} from '../../services/storyEngine/goalDirector'; +import type { GameState } from '../../types'; +import type { GoalFlowUi } from './uiTypes'; + +export function useStoryGoalFlow(gameState: GameState) { + const [goalPulse, setGoalPulse] = useState(null); + const previousGoalPulseSnapshotRef = + useRef | null>(null); + + const runtimeGoalStack = useMemo( + () => + buildGoalStackState({ + quests: gameState.quests, + worldType: gameState.worldType, + currentSceneId: gameState.currentScenePreset?.id ?? null, + chapterState: + gameState.chapterState ?? + gameState.storyEngineMemory?.currentChapter ?? + null, + journeyBeat: gameState.storyEngineMemory?.currentJourneyBeat ?? null, + setpieceDirective: + gameState.storyEngineMemory?.currentSetpieceDirective ?? null, + currentCampEvent: + gameState.storyEngineMemory?.currentCampEvent ?? null, + currentSceneName: gameState.currentScenePreset?.name ?? null, + }), + [ + gameState.chapterState, + gameState.currentScenePreset?.id, + gameState.currentScenePreset?.name, + gameState.quests, + gameState.storyEngineMemory?.currentCampEvent, + gameState.storyEngineMemory?.currentChapter, + gameState.storyEngineMemory?.currentJourneyBeat, + gameState.storyEngineMemory?.currentSetpieceDirective, + gameState.worldType, + ], + ); + + useEffect(() => { + const currentSnapshot = createGoalPulseSnapshot( + gameState.quests, + runtimeGoalStack, + ); + const previousSnapshot = previousGoalPulseSnapshotRef.current; + + if (!previousSnapshot) { + previousGoalPulseSnapshotRef.current = currentSnapshot; + return; + } + + const nextPulse = deriveGoalPulseEvent({ + previous: previousSnapshot, + quests: gameState.quests, + goalStack: runtimeGoalStack, + }); + if (nextPulse) { + setGoalPulse(nextPulse); + } + + previousGoalPulseSnapshotRef.current = currentSnapshot; + }, [gameState.quests, runtimeGoalStack]); + + const dismissGoalPulse = useCallback(() => { + setGoalPulse(null); + }, []); + + const resetGoalPulseTracking = useCallback(() => { + previousGoalPulseSnapshotRef.current = null; + setGoalPulse(null); + }, []); + + return { + runtimeGoalStack, + goalUi: { + goalStack: runtimeGoalStack, + pulse: goalPulse, + dismissPulse: dismissGoalPulse, + } satisfies GoalFlowUi, + resetGoalPulseTracking, + }; +} diff --git a/src/hooks/story/inventoryActions.ts b/src/hooks/story/inventoryActions.ts index 6d1066f1..86d458d7 100644 --- a/src/hooks/story/inventoryActions.ts +++ b/src/hooks/story/inventoryActions.ts @@ -1,44 +1,196 @@ -import type { GameState } from '../../types'; -import type { CommitGeneratedState } from '../generatedState'; -import { useEquipmentFlow } from '../useEquipmentFlow'; -import { useForgeFlow } from '../useForgeFlow'; -import { useInventoryFlow } from '../useInventoryFlow'; +import { useMemo, type Dispatch, type SetStateAction } from 'react'; + +import { + EQUIPMENT_EQUIP_FUNCTION, + EQUIPMENT_UNEQUIP_FUNCTION, + FORGE_CRAFT_FUNCTION, + FORGE_DISMANTLE_FUNCTION, + FORGE_REFORGE_FUNCTION, + INVENTORY_USE_FUNCTION, +} from '../../data/functionCatalog'; +import { getForgeRecipeViews } from '../../data/forgeSystem'; +import type { Character, GameState, StoryMoment } from '../../types'; +import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator'; import type { InventoryFlowUi } from './uiTypes'; -type TickCooldowns = (cooldowns: Record) => Record; +type BuildFallbackStoryForState = ( + state: GameState, + character: Character, + fallbackText?: string, +) => StoryMoment; export function useStoryInventoryActions({ gameState, - commitGeneratedState, - tickCooldowns, + runtime, }: { gameState: GameState; - commitGeneratedState: CommitGeneratedState; - tickCooldowns: TickCooldowns; + runtime: { + currentStory: StoryMoment | null; + setGameState: Dispatch>; + setCurrentStory: Dispatch>; + setAiError: Dispatch>; + setIsLoading: Dispatch>; + buildFallbackStoryForState: BuildFallbackStoryForState; + }; }) { - const inventoryFlow = useInventoryFlow({ - gameState, - commitGeneratedState, - tickCooldowns, - }); - const equipmentFlow = useEquipmentFlow({ - gameState, - commitGeneratedState, - }); - const forgeFlow = useForgeFlow({ - gameState, - commitGeneratedState, - }); + const { + currentStory, + setGameState, + setCurrentStory, + setAiError, + setIsLoading, + buildFallbackStoryForState, + } = runtime; + const forgeRecipes = useMemo( + () => + getForgeRecipeViews( + gameState.playerInventory, + gameState.playerCurrency, + gameState.worldType, + ), + [gameState.playerCurrency, gameState.playerInventory, gameState.worldType], + ); + + const resolveServerInventoryAction = async (params: { + functionId: string; + actionText: string; + payload: Record; + }) => { + const character = gameState.playerCharacter; + if ( + !character || + !gameState.worldType || + gameState.currentScene !== 'Story' + ) { + return false; + } + + setAiError(null); + setIsLoading(true); + + try { + const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({ + gameState, + currentStory, + option: { + functionId: params.functionId, + actionText: params.actionText, + }, + payload: params.payload, + }); + + setGameState(hydratedSnapshot.gameState); + setCurrentStory(nextStory); + return true; + } catch (error) { + console.error('Failed to resolve inventory runtime action on the server:', error); + setAiError(error instanceof Error ? error.message : '背包动作执行失败'); + if (!currentStory) { + setCurrentStory(buildFallbackStoryForState(gameState, character)); + } + return false; + } finally { + setIsLoading(false); + } + }; + + const useInventoryItem = async (itemId: string) => { + const item = gameState.playerInventory.find( + (candidate) => candidate.id === itemId, + ); + if (!item) { + return false; + } + + return resolveServerInventoryAction({ + functionId: INVENTORY_USE_FUNCTION.id, + actionText: `使用${item.name}`, + payload: { itemId }, + }); + }; + + const equipInventoryItem = async (itemId: string) => { + const item = gameState.playerInventory.find( + (candidate) => candidate.id === itemId, + ); + if (!item) { + return false; + } + + return resolveServerInventoryAction({ + functionId: EQUIPMENT_EQUIP_FUNCTION.id, + actionText: `装备${item.name}`, + payload: { itemId }, + }); + }; + + const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => { + const equippedItem = gameState.playerEquipment[slot]; + if (!equippedItem) { + return false; + } + + return resolveServerInventoryAction({ + functionId: EQUIPMENT_UNEQUIP_FUNCTION.id, + actionText: `卸下${equippedItem.name}`, + payload: { slotId: slot }, + }); + }; + + const craftRecipe = async (recipeId: string) => { + const recipe = forgeRecipes.find( + (candidate) => candidate.id === recipeId, + ); + if (!recipe) { + return false; + } + + return resolveServerInventoryAction({ + functionId: FORGE_CRAFT_FUNCTION.id, + actionText: `制作${recipe.resultLabel}`, + payload: { recipeId }, + }); + }; + + const dismantleItem = async (itemId: string) => { + const item = gameState.playerInventory.find( + (candidate) => candidate.id === itemId, + ); + if (!item) { + return false; + } + + return resolveServerInventoryAction({ + functionId: FORGE_DISMANTLE_FUNCTION.id, + actionText: `拆解${item.name}`, + payload: { itemId }, + }); + }; + + const reforgeItem = async (itemId: string) => { + const item = gameState.playerInventory.find( + (candidate) => candidate.id === itemId, + ); + if (!item) { + return false; + } + + return resolveServerInventoryAction({ + functionId: FORGE_REFORGE_FUNCTION.id, + actionText: `重铸${item.name}`, + payload: { itemId }, + }); + }; return { inventoryUi: { - useInventoryItem: inventoryFlow.handleUseInventoryItem, - equipInventoryItem: equipmentFlow.handleEquipInventoryItem, - unequipItem: equipmentFlow.handleUnequipItem, - forgeRecipes: forgeFlow.forgeRecipes, - craftRecipe: forgeFlow.handleCraftRecipe, - dismantleItem: forgeFlow.handleDismantleItem, - reforgeItem: forgeFlow.handleReforgeItem, + useInventoryItem, + equipInventoryItem, + unequipItem, + forgeRecipes, + craftRecipe, + dismantleItem, + reforgeItem, } satisfies InventoryFlowUi, }; } diff --git a/src/hooks/story/npcEncounterActions.ts b/src/hooks/story/npcEncounterActions.ts index e5efe1fb..814b1c1e 100644 --- a/src/hooks/story/npcEncounterActions.ts +++ b/src/hooks/story/npcEncounterActions.ts @@ -60,6 +60,7 @@ import type { } from '../../types'; import { AnimationState } from '../../types'; import type { CommitGeneratedState } from '../generatedState'; +import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator'; type CommitGeneratedStateWithEncounterEntry = ( entryState: GameState, @@ -116,6 +117,7 @@ function isNpcEncounter( export function createStoryNpcEncounterActions({ gameState, + currentStory, setGameState, setCurrentStory, setAiError, @@ -142,6 +144,7 @@ export function createStoryNpcEncounterActions({ npcInteractionFlow, }: { gameState: GameState; + currentStory: StoryMoment | null; setGameState: Dispatch>; setCurrentStory: Dispatch>; setAiError: Dispatch>; @@ -588,6 +591,46 @@ export function createStoryNpcEncounterActions({ return true; }; + const resolveServerNpcStoryAction = async (params: { + option: StoryOption; + encounter: Encounter; + payload?: Record; + }) => { + const playerCharacter = gameState.playerCharacter; + if ( + !playerCharacter || + !gameState.worldType || + gameState.currentScene !== 'Story' + ) { + return false; + } + + setAiError(null); + setIsLoading(true); + + try { + const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({ + gameState, + currentStory, + option: params.option, + payload: params.payload, + }); + + setGameState(hydratedSnapshot.gameState); + setCurrentStory(nextStory); + return true; + } catch (error) { + console.error('Failed to resolve npc story action on the server:', error); + setAiError(error instanceof Error ? error.message : 'NPC 动作执行失败'); + if (!currentStory) { + setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter)); + } + return false; + } finally { + setIsLoading(false); + } + }; + const handleNpcInteraction = (option: StoryOption) => { const playerCharacter = gameState.playerCharacter; if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) { @@ -835,141 +878,23 @@ export function createStoryNpcEncounterActions({ return true; } case 'quest_accept': { - const existingQuest = getQuestForIssuer( - gameState.quests, - getNpcEncounterKey(encounter), - ); - if (existingQuest) return true; - setAiError(null); - setIsLoading(true); - void (async () => { - let committed = false; - - try { - const quest = - (await generateQuestForNpcEncounter({ - state: gameState, - encounter, - })) ?? - buildQuestForEncounter({ - issuerNpcId: getNpcEncounterKey(encounter), - issuerNpcName: encounter.npcName, - roleText: encounter.context, - scene: gameState.currentScenePreset, - worldType: gameState.worldType, - }); - if (!quest) { - return; - } - - const nextState = incrementRuntimeStats( - updateNpcState( - updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)), - encounter, - (currentNpcState) => ({ - ...markNpcFirstMeaningfulContactResolved(currentNpcState), - stanceProfile: applyStoryChoiceToStanceProfile( - currentNpcState.stanceProfile, - 'npc_quest_accept', - ), - }), - ), - {questsAccepted: 1}, - ); - await commitGeneratedState( - nextState, - playerCharacter, - option.actionText, - buildQuestAcceptResultText(quest), - option.functionId, - ); - committed = true; - } catch (error) { - console.error('Failed to accept npc quest:', error); - const fallbackQuest = buildQuestForEncounter({ - issuerNpcId: getNpcEncounterKey(encounter), - issuerNpcName: encounter.npcName, - roleText: encounter.context, - scene: gameState.currentScenePreset, - worldType: gameState.worldType, - }); - if (!fallbackQuest) { - return; - } - - const nextState = incrementRuntimeStats( - updateNpcState( - updateQuestLog(gameState, (quests) => - acceptQuest(quests, fallbackQuest), - ), - encounter, - (currentNpcState) => ({ - ...markNpcFirstMeaningfulContactResolved(currentNpcState), - stanceProfile: applyStoryChoiceToStanceProfile( - currentNpcState.stanceProfile, - 'npc_quest_accept', - ), - }), - ), - {questsAccepted: 1}, - ); - await commitGeneratedState( - nextState, - playerCharacter, - option.actionText, - buildQuestAcceptResultText(fallbackQuest), - option.functionId, - ); - committed = true; - } finally { - if (!committed) { - setIsLoading(false); - } - } - })(); + void resolveServerNpcStoryAction({ + option, + encounter, + }); return true; } case 'quest_turn_in': { const questId = option.interaction.questId; - const quest = questId ? findQuestById(gameState.quests, questId) : null; - if (!quest || quest.status !== 'completed') return true; - - const nextState = appendStoryEngineCarrierMemory({ - ...updateQuestLog(gameState, (quests) => - markQuestTurnedIn(quests, quest.id), - ), - npcStates: { - ...gameState.npcStates, - [getNpcEncounterKey(encounter)]: { - ...syncNpcNarrativeState({ - encounter, - npcState: { - ...npcState, - ...markNpcFirstMeaningfulContactResolved(npcState), - affinity: npcState.affinity + quest.reward.affinityBonus, - relationState: buildRelationState( - npcState.affinity + quest.reward.affinityBonus, - ), - }, - customWorldProfile: gameState.customWorldProfile, - storyEngineMemory: gameState.storyEngineMemory, - }), - }, - }, - playerCurrency: gameState.playerCurrency + quest.reward.currency, - playerInventory: addInventoryItems( - gameState.playerInventory, - quest.reward.items, - ), - } as GameState, quest.reward.items); - - void commitGeneratedState( - nextState, - playerCharacter, - option.actionText, - buildQuestTurnInResultText(quest), - option.functionId, - ); + void resolveServerNpcStoryAction({ + option, + encounter, + payload: questId + ? { + questId, + } + : undefined, + }); return true; } case 'leave': { diff --git a/src/hooks/story/npcInteraction.ts b/src/hooks/story/npcInteraction.ts index f0b1d644..bcc29b24 100644 --- a/src/hooks/story/npcInteraction.ts +++ b/src/hooks/story/npcInteraction.ts @@ -50,6 +50,7 @@ import type { StoryOption, } from '../../types'; import type { CommitGeneratedState } from '../generatedState'; +import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator'; import type { GiftModalState, RecruitModalState, @@ -67,6 +68,7 @@ type GenerateStoryForState = (params: { }) => Promise; type StoryNpcInteractionRuntime = { + currentStory: StoryMoment | null; setCurrentStory: Dispatch>; setAiError: Dispatch>; setIsLoading: Dispatch>; @@ -656,6 +658,60 @@ export function useStoryNpcInteractionFlow({ setRecruitModal(null); }; + const resolveServerNpcAction = async (params: { + encounter: Encounter; + actionText: string; + functionId: string; + action: 'trade' | 'gift' | 'quest_accept' | 'quest_turn_in'; + payload?: Record; + }) => { + const playerCharacter = gameState.playerCharacter; + if ( + !playerCharacter || + !gameState.worldType || + gameState.currentScene !== 'Story' + ) { + return false; + } + + runtime.setAiError(null); + runtime.setIsLoading(true); + + try { + const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({ + gameState, + currentStory: runtime.currentStory, + option: { + functionId: params.functionId, + actionText: params.actionText, + interaction: { + kind: 'npc', + npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter), + action: params.action, + }, + }, + payload: params.payload, + }); + + setGameState(hydratedSnapshot.gameState); + runtime.setCurrentStory(nextStory); + return true; + } catch (error) { + console.error('Failed to resolve npc runtime action on the server:', error); + runtime.setAiError( + error instanceof Error ? error.message : 'NPC 交互执行失败', + ); + if (!runtime.currentStory) { + runtime.setCurrentStory( + runtime.buildFallbackStoryForState(gameState, playerCharacter), + ); + } + return false; + } finally { + runtime.setIsLoading(false); + } + }; + const confirmTrade = () => { if (!tradeModal || !gameState.playerCharacter) return; @@ -669,27 +725,8 @@ export function useStoryNpcInteractionFlow({ if (!npcItem || quantity <= 0) return; if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return; - let nextState = updateNpcState( - gameState, - encounter, - currentNpcState => ({ - ...markNpcFirstMeaningfulContactResolved(currentNpcState), - inventory: removeInventoryItem(currentNpcState.inventory, npcItem.id, quantity), - }), - ); - - nextState = appendStoryEngineCarrierMemory({ - ...nextState, - playerCurrency: nextState.playerCurrency - totalPrice, - playerInventory: addInventoryItems( - nextState.playerInventory, - [cloneInventoryItemForOwner(npcItem, 'player', quantity)], - ), - } as GameState, [cloneInventoryItemForOwner(npcItem, 'player', quantity)]); - setTradeModal(null); - void commitNpcReactionAndGenerate({ - nextState, + void resolveServerNpcAction({ encounter, actionText: buildNpcTradeTransactionActionText({ encounter, @@ -697,16 +734,13 @@ export function useStoryNpcInteractionFlow({ item: npcItem, quantity, }), - resultText: buildNpcTradeTransactionResultText({ - encounter, + functionId: 'npc_trade', + action: 'trade', + payload: { mode: 'buy', - item: npcItem, + itemId: npcItem.id, quantity, - totalPrice, - worldType: gameState.worldType, - }), - lastFunctionId: 'npc_trade', - contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null, + }, }); return; } @@ -715,27 +749,8 @@ export function useStoryNpcInteractionFlow({ if (!playerItem || quantity <= 0) return; if (playerItem.quantity < quantity) return; - let nextState = updateNpcState( - gameState, - encounter, - currentNpcState => ({ - ...markNpcFirstMeaningfulContactResolved(currentNpcState), - inventory: addInventoryItems( - currentNpcState.inventory, - [cloneInventoryItemForOwner(playerItem, 'npc', quantity)], - ), - }), - ); - - nextState = { - ...nextState, - playerCurrency: nextState.playerCurrency + totalPrice, - playerInventory: removeInventoryItem(nextState.playerInventory, playerItem.id, quantity), - }; - setTradeModal(null); - void commitNpcReactionAndGenerate({ - nextState, + void resolveServerNpcAction({ encounter, actionText: buildNpcTradeTransactionActionText({ encounter, @@ -743,16 +758,13 @@ export function useStoryNpcInteractionFlow({ item: playerItem, quantity, }), - resultText: buildNpcTradeTransactionResultText({ - encounter, + functionId: 'npc_trade', + action: 'trade', + payload: { mode: 'sell', - item: playerItem, + itemId: playerItem.id, quantity, - totalPrice, - worldType: gameState.worldType, - }), - lastFunctionId: 'npc_trade', - contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null, + }, }); }; @@ -763,57 +775,15 @@ export function useStoryNpcInteractionFlow({ const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId); if (!giftItem) return; - const giftCandidate = getGiftCandidates(gameState.playerInventory, encounter, { - worldType: gameState.worldType, - customWorldProfile: gameState.customWorldProfile, - }) - .find(candidate => candidate.item.id === giftItem.id); - const affinityGain = giftCandidate?.affinityGain ?? 0; - const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? null; - let nextAffinity = 0; - - let nextState = updateNpcState( - gameState, - encounter, - currentNpcState => { - nextAffinity = currentNpcState.affinity + affinityGain; - return { - ...markNpcFirstMeaningfulContactResolved(currentNpcState), - affinity: nextAffinity, - relationState: buildRelationState(nextAffinity), - giftsGiven: currentNpcState.giftsGiven + 1, - stanceProfile: applyStoryChoiceToStanceProfile( - currentNpcState.stanceProfile, - 'npc_gift', - { affinityGain }, - ), - inventory: addInventoryItems( - currentNpcState.inventory, - [cloneInventoryItemForOwner(giftItem, 'npc')], - ), - }; - }, - ); - - nextState = { - ...nextState, - playerInventory: removeInventoryItem(nextState.playerInventory, giftItem.id, 1), - }; - setGiftModal(null); - void commitNpcReactionAndGenerate({ - nextState, + void resolveServerNpcAction({ encounter, actionText: buildNpcGiftCommitActionText(encounter, giftItem), - resultText: buildNpcGiftResultText( - encounter, - giftItem, - affinityGain, - nextAffinity, - attributeSummary ?? undefined, - ), - lastFunctionId: 'npc_gift', - contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null, + functionId: 'npc_gift', + action: 'gift', + payload: { + itemId: giftItem.id, + }, }); }; diff --git a/src/hooks/story/runtimeStoryCoordinator.test.ts b/src/hooks/story/runtimeStoryCoordinator.test.ts new file mode 100644 index 00000000..f55b5dfd --- /dev/null +++ b/src/hooks/story/runtimeStoryCoordinator.test.ts @@ -0,0 +1,366 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + putSaveSnapshotMock, + getRuntimeStoryStateMock, + resolveRuntimeStoryActionMock, + getRuntimeSessionIdMock, + getRuntimeClientVersionMock, +} = vi.hoisted(() => ({ + putSaveSnapshotMock: vi.fn(), + getRuntimeStoryStateMock: vi.fn(), + resolveRuntimeStoryActionMock: vi.fn(), + getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'), + getRuntimeClientVersionMock: vi.fn(() => 0), +})); + +vi.mock('../../services/storageService', () => ({ + putSaveSnapshot: putSaveSnapshotMock, +})); + +vi.mock('../../services/runtimeStoryService', async () => { + const actual = + await vi.importActual( + '../../services/runtimeStoryService', + ); + + return { + ...actual, + getRuntimeStoryState: getRuntimeStoryStateMock, + resolveRuntimeStoryAction: resolveRuntimeStoryActionMock, + getRuntimeSessionId: getRuntimeSessionIdMock, + getRuntimeClientVersion: getRuntimeClientVersionMock, + }; +}); + +import type { GameState, StoryMoment, StoryOption } from '../../types'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import { + loadServerRuntimeOptionCatalog, + resumeServerRuntimeStory, + resolveServerRuntimeChoice, +} from './runtimeStoryCoordinator'; + +function createStory(text: string): StoryMoment { + return { + text, + options: [], + }; +} + +function createGameState(): GameState { + return { + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 7, + } as GameState; +} + +describe('runtimeStoryCoordinator', () => { + beforeEach(() => { + putSaveSnapshotMock.mockReset(); + getRuntimeStoryStateMock.mockReset(); + resolveRuntimeStoryActionMock.mockReset(); + getRuntimeSessionIdMock.mockReset(); + getRuntimeSessionIdMock.mockReturnValue('runtime-main'); + getRuntimeClientVersionMock.mockReset(); + getRuntimeClientVersionMock.mockReturnValue(7); + }); + + it('loads runtime option catalogs through the persisted server snapshot flow', async () => { + const gameState = createGameState(); + const currentStory = createStory('当前故事'); + + getRuntimeStoryStateMock.mockResolvedValue({ + sessionId: 'runtime-main', + serverVersion: 3, + viewModel: { + player: { + hp: 100, + maxHp: 100, + mana: 20, + maxMana: 20, + }, + encounter: null, + companions: [], + availableOptions: [ + { + functionId: 'npc_chat', + actionText: '继续交谈', + scope: 'npc', + }, + ], + status: { + inBattle: false, + npcInteractionActive: true, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + }, + }, + presentation: { + actionText: '', + resultText: '', + storyText: '服务端故事', + options: [], + }, + patches: [], + snapshot: { + version: 2, + savedAt: '2026-04-08T00:00:00.000Z', + bottomTab: 'adventure', + gameState: {}, + currentStory: null, + }, + }); + + const options = await loadServerRuntimeOptionCatalog({ + gameState, + currentStory, + }); + + expect(putSaveSnapshotMock).toHaveBeenCalledWith({ + gameState, + bottomTab: 'adventure', + currentStory, + }); + expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main'); + expect(options).toEqual([ + expect.objectContaining({ + functionId: 'npc_chat', + actionText: '继续交谈', + }), + ]); + }); + + it('hydrates runtime choices into snapshot state and presentation-safe story data', async () => { + const gameState = createGameState(); + const currentStory = createStory('当前故事'); + const option = { + functionId: 'npc_chat', + actionText: '继续交谈', + text: '继续交谈', + interaction: { + kind: 'npc', + npcId: 'npc-opponent', + action: 'chat', + }, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + } as StoryOption; + const hydratedSnapshot = { + version: 8, + savedAt: '2026-04-08T00:00:00.000Z', + gameState: { + ...gameState, + runtimeActionVersion: 8, + runtimeSessionId: 'runtime-main', + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + } as GameState, + currentStory: createStory('快照中的故事'), + bottomTab: 'adventure', + } as HydratedSavedGameSnapshot; + + resolveRuntimeStoryActionMock.mockResolvedValue({ + sessionId: 'runtime-main', + serverVersion: 8, + viewModel: { + player: { + hp: 96, + maxHp: 100, + mana: 18, + maxMana: 20, + }, + encounter: null, + companions: [], + availableOptions: [ + { + functionId: 'npc_help', + actionText: '请求援手', + scope: 'npc', + }, + ], + status: { + inBattle: false, + npcInteractionActive: true, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + }, + }, + presentation: { + actionText: '继续交谈', + resultText: '关系已有变化', + storyText: '', + options: [], + }, + patches: [], + snapshot: hydratedSnapshot, + }); + + const result = await resolveServerRuntimeChoice({ + gameState, + currentStory, + option, + payload: { + note: 'server-runtime-test', + }, + }); + + expect(putSaveSnapshotMock).toHaveBeenCalledWith({ + gameState, + bottomTab: 'adventure', + currentStory, + }); + expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({ + sessionId: 'runtime-main', + clientVersion: 7, + option, + targetId: 'npc-opponent', + payload: { + note: 'server-runtime-test', + }, + }); + expect(result.hydratedSnapshot).toBe(hydratedSnapshot); + expect(result.nextStory).toEqual( + expect.objectContaining({ + text: '快照中的故事', + options: [ + expect.objectContaining({ + functionId: 'npc_help', + actionText: '请求援手', + }), + ], + }), + ); + }); + + it('refreshes resumable runtime stories from the server before hydrating the main flow', async () => { + const localHydratedSnapshot = { + version: 7, + savedAt: '2026-04-08T00:00:00.000Z', + gameState: { + currentScene: 'Story', + worldType: 'wuxia', + playerCharacter: { + id: 'hero', + }, + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + runtimeActionVersion: 7, + runtimeSessionId: 'runtime-main', + } as unknown as GameState, + currentStory: createStory('本地快照故事'), + bottomTab: 'inventory' as const, + } as HydratedSavedGameSnapshot; + const serverHydratedSnapshot = { + version: 8, + savedAt: '2026-04-08T00:00:00.000Z', + gameState: { + currentScene: 'Story', + worldType: 'wuxia', + playerCharacter: { + id: 'hero', + }, + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + runtimeActionVersion: 8, + runtimeSessionId: 'runtime-main', + } as unknown as GameState, + currentStory: createStory('服务端快照故事'), + bottomTab: 'character' as const, + } as HydratedSavedGameSnapshot; + + getRuntimeStoryStateMock.mockResolvedValue({ + sessionId: 'runtime-main', + serverVersion: 8, + viewModel: { + player: { + hp: 90, + maxHp: 100, + mana: 16, + maxMana: 20, + }, + encounter: null, + companions: [], + availableOptions: [ + { + functionId: 'npc_help', + actionText: '请求援手', + scope: 'npc', + }, + ], + status: { + inBattle: false, + npcInteractionActive: false, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + }, + }, + presentation: { + actionText: '', + resultText: '', + storyText: '服务端恢复后的故事', + options: [], + }, + patches: [], + snapshot: serverHydratedSnapshot, + }); + + const result = await resumeServerRuntimeStory(localHydratedSnapshot); + + expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main'); + expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot); + expect(result.nextStory).toEqual( + expect.objectContaining({ + text: '服务端恢复后的故事', + options: [ + expect.objectContaining({ + functionId: 'npc_help', + actionText: '请求援手', + }), + ], + }), + ); + }); + + it('keeps local snapshot hydration when the saved state is not an active runtime story', async () => { + const localHydratedSnapshot = { + version: 7, + savedAt: '2026-04-08T00:00:00.000Z', + gameState: { + currentScene: 'Home', + worldType: null, + playerCharacter: null, + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + runtimeActionVersion: 7, + runtimeSessionId: 'runtime-main', + } as unknown as GameState, + currentStory: createStory('本地快照故事'), + bottomTab: 'adventure' as const, + } as HydratedSavedGameSnapshot; + + const result = await resumeServerRuntimeStory(localHydratedSnapshot); + + expect(getRuntimeStoryStateMock).not.toHaveBeenCalled(); + expect(result.hydratedSnapshot).toBe(localHydratedSnapshot); + expect(result.nextStory).toBe(localHydratedSnapshot.currentStory); + }); +}); diff --git a/src/hooks/story/runtimeStoryCoordinator.ts b/src/hooks/story/runtimeStoryCoordinator.ts new file mode 100644 index 00000000..0f55546e --- /dev/null +++ b/src/hooks/story/runtimeStoryCoordinator.ts @@ -0,0 +1,122 @@ +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import { + buildStoryMomentFromRuntimeOptions, + getRuntimeClientVersion, + getRuntimeSessionId, + getRuntimeStoryState, + resolveRuntimeStoryAction, + type RuntimeStoryChoicePayload, + type RuntimeStoryResponse, +} from '../../services/runtimeStoryService'; +import { putSaveSnapshot } from '../../services/storageService'; +import type { GameState, StoryMoment, StoryOption } from '../../types'; + +function getRuntimeResponseOptions(response: RuntimeStoryResponse) { + return response.viewModel.availableOptions.length > 0 + ? response.viewModel.availableOptions + : response.presentation.options; +} + +async function syncRuntimeSnapshot( + gameState: GameState, + currentStory: StoryMoment | null, +) { + await putSaveSnapshot({ + gameState, + bottomTab: 'adventure', + currentStory, + }); +} + +export async function loadServerRuntimeOptionCatalog(params: { + gameState: GameState; + currentStory: StoryMoment | null; +}) { + await syncRuntimeSnapshot(params.gameState, params.currentStory); + + const response = await getRuntimeStoryState( + getRuntimeSessionId(params.gameState), + ); + const options = buildStoryMomentFromRuntimeOptions({ + storyText: response.presentation.storyText, + options: getRuntimeResponseOptions(response), + gameState: params.gameState, + }).options; + + return options.length > 0 ? options : null; +} + +export async function resumeServerRuntimeStory( + snapshot: HydratedSavedGameSnapshot, +) { + const hydratedSnapshot = snapshot; + const shouldRefreshFromServer = + hydratedSnapshot.gameState.currentScene === 'Story' && + Boolean(hydratedSnapshot.gameState.worldType) && + Boolean(hydratedSnapshot.gameState.playerCharacter); + + if (!shouldRefreshFromServer) { + return { + hydratedSnapshot, + nextStory: hydratedSnapshot.currentStory, + }; + } + + const response = await getRuntimeStoryState( + getRuntimeSessionId(hydratedSnapshot.gameState), + ); + const resumedSnapshot = response.snapshot; + const runtimeOptions = getRuntimeResponseOptions(response); + const nextStory = + response.presentation.storyText || runtimeOptions.length > 0 + ? buildStoryMomentFromRuntimeOptions({ + storyText: + response.presentation.storyText || + resumedSnapshot.currentStory?.text || + hydratedSnapshot.currentStory?.text || + '', + options: runtimeOptions, + gameState: resumedSnapshot.gameState, + }) + : resumedSnapshot.currentStory; + + return { + hydratedSnapshot: resumedSnapshot, + nextStory, + }; +} + +export async function resolveServerRuntimeChoice(params: { + gameState: GameState; + currentStory: StoryMoment | null; + option: Pick & + Partial>; + payload?: RuntimeStoryChoicePayload; +}) { + await syncRuntimeSnapshot(params.gameState, params.currentStory); + + const response = await resolveRuntimeStoryAction({ + sessionId: getRuntimeSessionId(params.gameState), + clientVersion: getRuntimeClientVersion(params.gameState), + option: params.option, + targetId: + params.option.interaction?.kind === 'npc' + ? params.option.interaction.npcId + : undefined, + payload: params.payload, + }); + const hydratedSnapshot = response.snapshot; + + return { + response, + hydratedSnapshot, + nextStory: buildStoryMomentFromRuntimeOptions({ + storyText: + response.presentation.storyText || + hydratedSnapshot.currentStory?.text || + params.option.actionText, + options: getRuntimeResponseOptions(response), + gameState: hydratedSnapshot.gameState, + }), + }; +} diff --git a/src/hooks/story/storyBootstrap.ts b/src/hooks/story/storyBootstrap.ts new file mode 100644 index 00000000..93fda4a8 --- /dev/null +++ b/src/hooks/story/storyBootstrap.ts @@ -0,0 +1,249 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +import type { StoryGenerationContext } from '../../services/aiTypes'; +import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; +import type { Character, Encounter, GameState, StoryMoment, StoryOption } from '../../types'; +import { + playOpeningAdventureSequence, + type PreparedOpeningAdventure, +} from './openingAdventure'; + +type BuildFallbackStoryForState = ( + state: GameState, + character: Character, + fallbackText?: string, +) => StoryMoment; + +type GenerateStoryForState = (params: { + state: GameState; + character: Character; + history: StoryMoment[]; + choice?: string; + lastFunctionId?: string | null; + optionCatalog?: StoryOption[] | null; +}) => Promise; + +type BuildDialogueStoryMoment = ( + npcName: string, + text: string, + options: StoryOption[], + streaming?: boolean, +) => StoryMoment; + +export function useStoryBootstrap(params: { + gameState: GameState; + currentStory: StoryMoment | null; + isLoading: boolean; + setGameState: Dispatch>; + setCurrentStory: Dispatch>; + setAiError: Dispatch>; + setIsLoading: Dispatch>; + prepareOpeningAdventure: ( + state: GameState, + character: Character, + ) => PreparedOpeningAdventure | null; + getNpcEncounterKey: (encounter: Encounter) => string; + buildFallbackStoryForState: BuildFallbackStoryForState; + generateStoryForState: GenerateStoryForState; + buildDialogueStoryMoment: BuildDialogueStoryMoment; + buildStoryContextFromState: ( + state: GameState, + extras?: { + lastFunctionId?: string | null; + openingCampBackground?: string | null; + openingCampDialogue?: string | null; + encounterNpcStateOverride?: GameState['npcStates'][string] | null; + }, + ) => StoryGenerationContext; + getStoryGenerationHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + hasRenderableDialogueTurns: (text: string, npcName: string) => boolean; + inferOpeningCampFollowupOptions: ( + state: GameState, + character: Character, + baseOptions: StoryOption[], + openingBackground: string, + openingDialogue: string, + ) => Promise; + getTypewriterDelay: (char: string) => number; + isNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isInitialCompanionEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; +}) { + const { + gameState, + currentStory, + isLoading, + setGameState, + setCurrentStory, + setAiError, + setIsLoading, + prepareOpeningAdventure, + getNpcEncounterKey, + buildFallbackStoryForState, + generateStoryForState, + buildDialogueStoryMoment, + buildStoryContextFromState, + getStoryGenerationHostileNpcs, + hasRenderableDialogueTurns, + inferOpeningCampFollowupOptions, + getTypewriterDelay, + isNpcEncounter, + isInitialCompanionEncounter, + } = params; + + const [preparedOpeningAdventure, setPreparedOpeningAdventure] = + useState(null); + + useEffect(() => { + if ( + gameState.currentScene !== 'Story' || + !gameState.playerCharacter || + gameState.storyHistory.length > 0 || + currentStory || + !isNpcEncounter(gameState.currentEncounter) || + gameState.currentEncounter.specialBehavior !== 'initial_companion' + ) { + setPreparedOpeningAdventure(null); + return; + } + + setPreparedOpeningAdventure( + prepareOpeningAdventure(gameState, gameState.playerCharacter), + ); + }, [ + currentStory, + gameState, + isNpcEncounter, + prepareOpeningAdventure, + ]); + + const startOpeningAdventure = useCallback(async () => { + if ( + !gameState.playerCharacter || + !isNpcEncounter(gameState.currentEncounter) + ) { + return; + } + + const encounter = gameState.currentEncounter; + if (encounter.specialBehavior !== 'initial_companion') { + return; + } + + const preparedStory = + preparedOpeningAdventure?.encounterKey === getNpcEncounterKey(encounter) + ? preparedOpeningAdventure + : prepareOpeningAdventure(gameState, gameState.playerCharacter); + + if (!preparedStory) { + return; + } + + await playOpeningAdventureSequence({ + gameState, + character: gameState.playerCharacter, + encounter, + preparedStory, + setGameState, + setCurrentStory, + setAiError, + setIsLoading, + buildDialogueStoryMoment, + buildStoryContextFromState, + getStoryGenerationHostileNpcs, + hasRenderableDialogueTurns, + inferOpeningCampFollowupOptions, + getTypewriterDelay, + }); + }, [ + buildDialogueStoryMoment, + buildStoryContextFromState, + gameState, + getNpcEncounterKey, + getStoryGenerationHostileNpcs, + getTypewriterDelay, + hasRenderableDialogueTurns, + inferOpeningCampFollowupOptions, + isNpcEncounter, + prepareOpeningAdventure, + preparedOpeningAdventure, + setAiError, + setCurrentStory, + setGameState, + setIsLoading, + ]); + + useEffect(() => { + const startStory = async () => { + if ( + gameState.currentScene !== 'Story' || + !gameState.worldType || + !gameState.playerCharacter || + currentStory || + isLoading + ) { + return; + } + + if ( + gameState.storyHistory.length === 0 && + isInitialCompanionEncounter(gameState.currentEncounter) && + !gameState.npcInteractionActive + ) { + setAiError(null); + void startOpeningAdventure(); + return; + } + + setIsLoading(true); + try { + setAiError(null); + const nextStory = await generateStoryForState({ + state: gameState, + character: gameState.playerCharacter, + history: [], + }); + setGameState(applyStoryReasoningRecovery(gameState)); + setCurrentStory(nextStory); + } catch (error) { + console.error('Failed to start story:', error); + setAiError(error instanceof Error ? error.message : '未知智能生成错误'); + setCurrentStory( + buildFallbackStoryForState(gameState, gameState.playerCharacter), + ); + } finally { + setIsLoading(false); + } + }; + + void startStory(); + }, [ + buildFallbackStoryForState, + currentStory, + gameState, + generateStoryForState, + isInitialCompanionEncounter, + isLoading, + setAiError, + setCurrentStory, + setGameState, + setIsLoading, + startOpeningAdventure, + ]); + + const resetPreparedOpeningAdventure = useCallback(() => { + setPreparedOpeningAdventure(null); + }, []); + + return { + preparedOpeningAdventure, + startOpeningAdventure, + resetPreparedOpeningAdventure, + }; +} diff --git a/src/hooks/story/storyCampCompanion.test.ts b/src/hooks/story/storyCampCompanion.test.ts new file mode 100644 index 00000000..78f6fdbc --- /dev/null +++ b/src/hooks/story/storyCampCompanion.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getWorldCampScenePreset } from '../../data/scenePresets'; +import { + AnimationState, + type Character, + type Encounter, + type GameState, + type StoryMoment, + type StoryOption, + WorldType, +} from '../../types'; +import { + buildCampCompanionOpeningResultText, + buildInitialCompanionDialogueText, + createCampCompanionStoryHelpers, +} from './storyCampCompanion'; + +function createCharacter(): Character { + return { + id: 'sword-princess', + name: '测试同伴', + title: '试剑公主', + description: '在营地观察局势的试炼者。', + backstory: '她在旅途中始终保留自己的真正目标。', + avatar: '/hero.png', + portrait: '/hero-portrait.png', + assetFolder: 'hero', + assetVariant: 'default', + attributes: { + strength: 12, + agility: 10, + intelligence: 8, + spirit: 9, + }, + personality: '谨慎冷静', + skills: [], + adventureOpenings: { + [WorldType.WUXIA]: { + reason: '调查旧路异动', + goal: '查清前方局势', + monologue: '风声里还藏着未说破的话。', + surfaceHook: '我来这里,是为了确认旧路尽头到底出了什么事。', + immediateConcern: '眼下的风向不对,我们不能直接把底牌亮出来。', + guardedMotive: '我真正要找的东西,还不能让更多人知道。', + }, + }, + }; +} + +function createOption( + functionId: string, + actionText = functionId, +): StoryOption { + return { + functionId, + actionText, + text: actionText, + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }; +} + +function createEncounter(overrides: Partial = {}): Encounter { + return { + id: 'camp-companion', + kind: 'npc', + npcName: '沈砺', + npcDescription: '正靠在营地灯火旁观察风向。', + npcAvatar: '/npc.png', + context: '营地夜谈', + specialBehavior: 'camp_companion', + ...overrides, + }; +} + +function createStory(text: string, options: StoryOption[] = []): StoryMoment { + return { + text, + options, + }; +} + +function createGameState(overrides: Partial = {}): GameState { + return { + worldType: WorldType.WUXIA, + customWorldProfile: null, + playerCharacter: createCharacter(), + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'Story', + storyHistory: [], + characterChats: {}, + animationState: AnimationState.IDLE, + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: getWorldCampScenePreset(WorldType.WUXIA), + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 100, + playerMaxHp: 100, + playerMana: 30, + playerMaxMana: 30, + playerSkillCooldowns: {}, + activeCombatEffects: [], + playerCurrency: 0, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: {}, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + ...overrides, + } as GameState; +} + +describe('storyCampCompanion', () => { + it('builds opening dialogue from the character adventure opening', () => { + const text = buildInitialCompanionDialogueText( + createCharacter(), + createEncounter(), + WorldType.WUXIA, + ); + + expect(text).toContain('我来这里,是为了确认旧路尽头到底出了什么事。'); + expect(text).toContain('沈砺:那就不要说得太快太多。'); + expect(text).toContain('我真正要找的东西,还不能让更多人知道。'); + }); + + it('summarizes the camp opening result with the current concern', () => { + const text = buildCampCompanionOpeningResultText( + createCharacter(), + createEncounter(), + WorldType.WUXIA, + ); + + expect(text).toContain('沈砺 在'); + expect(text).toContain('眼下的风向不对'); + }); + + it('keeps chat and recruit options while appending the travel action for camp openings', () => { + const buildNpcStory = vi.fn(() => + createStory('营地开场', [ + createOption('npc_chat', '继续交谈'), + createOption('npc_recruit', '邀请同行'), + createOption('npc_trade', '查看货物'), + ]), + ); + const helpers = createCampCompanionStoryHelpers({ + buildNpcStory, + buildStoryContextFromState: vi.fn(), + getStoryGenerationHostileNpcs: vi.fn(() => []), + getNpcEncounterKey: vi.fn(() => 'camp-companion'), + generateNextStep: vi.fn(), + }); + + const options = helpers.buildCampCompanionOpeningOptions( + createGameState(), + createCharacter(), + createEncounter(), + ); + + expect(options.map((option) => option.functionId)).toEqual([ + 'npc_chat', + 'npc_recruit', + 'camp_travel_home_scene', + ]); + }); + + it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => { + const baseOptions = [createOption('npc_chat', '继续交谈')]; + const generateNextStep = vi + .fn() + .mockResolvedValueOnce({ + storyText: '继续营地交谈', + options: [ + createOption('npc_trade', '先看对方带来的东西'), + createOption('npc_chat', '继续交谈'), + ], + }) + .mockRejectedValueOnce(new Error('llm failed')); + const buildStoryContextFromState = vi.fn(() => ({ + playerHp: 100, + playerMaxHp: 100, + playerMana: 30, + playerMaxMana: 30, + inBattle: false, + playerX: 0, + playerFacing: 'right' as const, + playerAnimation: AnimationState.IDLE, + skillCooldowns: {}, + sceneId: 'camp', + sceneName: '营地', + sceneDescription: '营火微亮。', + pendingSceneEncounter: false, + })); + const helpers = createCampCompanionStoryHelpers({ + buildNpcStory: vi.fn(), + buildStoryContextFromState, + getStoryGenerationHostileNpcs: vi.fn(() => []), + getNpcEncounterKey: vi.fn(() => 'camp-companion'), + generateNextStep, + }); + const state = createGameState(); + const character = createCharacter(); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + const resolvedOptions = await helpers.inferOpeningCampFollowupOptions( + state, + character, + baseOptions, + '营地里风声微沉。', + '你们刚交换完第一轮判断。', + ); + const fallbackOptions = await helpers.inferOpeningCampFollowupOptions( + state, + character, + baseOptions, + '营地里风声微沉。', + '你们刚交换完第一轮判断。', + ); + + expect(buildStoryContextFromState).toHaveBeenCalledWith( + state, + expect.objectContaining({ + openingCampBackground: '营地里风声微沉。', + openingCampDialogue: '你们刚交换完第一轮判断。', + }), + ); + expect(resolvedOptions.map((option) => option.functionId)).toEqual([ + 'npc_trade', + 'npc_chat', + ]); + expect(fallbackOptions).toBe(baseOptions); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('reconstructs the opening camp chat context from story history and filters idle camp options', () => { + const encounter = createEncounter(); + const buildNpcStory = vi.fn(() => + createStory('营地常态', [ + createOption('npc_chat', '继续交谈'), + createOption('npc_leave', '结束对话'), + createOption('npc_fight', '直接切磋'), + createOption('npc_trade', '查看货物'), + ]), + ); + const helpers = createCampCompanionStoryHelpers({ + buildNpcStory, + buildStoryContextFromState: vi.fn(), + getStoryGenerationHostileNpcs: vi.fn(() => []), + getNpcEncounterKey: vi.fn(() => 'camp-companion'), + generateNextStep: vi.fn(), + }); + const state = createGameState({ + currentEncounter: encounter, + npcStates: { + 'camp-companion': { + affinity: 16, + helpUsed: false, + chattedCount: 1, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + storyHistory: [ + { + text: `在营地与 ${encounter.npcName} 交换开场判断`, + options: [], + historyRole: 'action', + }, + { + text: '你们先对了一遍眼前局势。', + options: [], + historyRole: 'result', + }, + ], + }); + + const chatContext = helpers.buildOpeningCampChatContext( + state, + createCharacter(), + encounter, + ); + const idleStory = helpers.buildCampCompanionIdleStory( + state, + createCharacter(), + encounter, + ); + + expect(chatContext).toEqual( + expect.objectContaining({ + openingCampBackground: expect.stringContaining('沈砺 在'), + openingCampDialogue: '你们先对了一遍眼前局势。', + }), + ); + expect(idleStory.options.map((option) => option.functionId)).toEqual([ + 'npc_chat', + 'npc_trade', + 'camp_travel_home_scene', + ]); + }); +}); diff --git a/src/hooks/story/storyCampCompanion.ts b/src/hooks/story/storyCampCompanion.ts new file mode 100644 index 00000000..6bf9d3dd --- /dev/null +++ b/src/hooks/story/storyCampCompanion.ts @@ -0,0 +1,300 @@ +import { + getCharacterAdventureOpening, + getCharacterHomeSceneId, +} from '../../data/characterPresets'; +import { + buildCampTravelHomeOption, + NPC_CHAT_FUNCTION, + NPC_FIGHT_FUNCTION, + NPC_LEAVE_FUNCTION, + NPC_RECRUIT_FUNCTION, +} from '../../data/functionCatalog'; +import { buildInitialNpcState } from '../../data/npcInteractions'; +import { + getForwardScenePreset, + getScenePresetById, + getTravelScenePreset, + getWorldCampScenePreset, +} from '../../data/scenePresets'; +import { sortStoryOptionsByPriority } from '../../data/stateFunctions'; +import type { StoryGenerationContext } from '../../services/aiService'; +import type { + Character, + Encounter, + GameState, + StoryMoment, + StoryOption, + WorldType, +} from '../../types'; + +type BuildNpcStory = ( + state: GameState, + character: Character, + encounter: Encounter, + overrideText?: string, +) => StoryMoment; + +type BuildStoryContextFromState = ( + state: GameState, + extras?: { + openingCampBackground?: string | null; + openingCampDialogue?: string | null; + }, +) => StoryGenerationContext; + +type GetStoryGenerationHostileNpcs = ( + state: GameState, +) => GameState['sceneHostileNpcs']; + +type GetNpcEncounterKey = (encounter: Encounter) => string; + +type GenerateNextStep = + (typeof import('../../services/aiService'))['generateNextStep']; + +export function buildInitialCompanionDialogueText( + character: Character, + encounter: Encounter, + worldType: WorldType | null, +) { + const opening = getCharacterAdventureOpening(character, worldType); + const surfaceHook = + opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。'; + const immediateConcern = + opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。'; + const guardedMotive = + opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。'; + + return [ + `你:${surfaceHook}`, + `${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`, + `你:${immediateConcern}`, + `${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`, + ].join('\n'); +} + +export function buildCampCompanionOpeningResultText( + character: Character, + encounter: Encounter, + worldType: WorldType | null, +) { + const opening = getCharacterAdventureOpening(character, worldType); + const campSceneName = worldType + ? (getWorldCampScenePreset(worldType)?.name ?? '归处') + : '归处'; + if (!opening) { + return `${encounter.npcName} 已经来到你身边。在${campSceneName},你稍作停顿,决定下一步去向何方。`; + } + + return `${encounter.npcName} 在${campSceneName}来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`; +} + +function getCampCompanionHomeScene(state: GameState, character: Character) { + if (!state.worldType) return null; + const sceneId = getCharacterHomeSceneId(state.worldType, character.id); + return getScenePresetById(state.worldType, sceneId); +} + +export function createCampCompanionStoryHelpers(params: { + buildNpcStory: BuildNpcStory; + buildStoryContextFromState: BuildStoryContextFromState; + getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs; + getNpcEncounterKey: GetNpcEncounterKey; + generateNextStep: GenerateNextStep; +}) { + const getCampCompanionTravelScene = ( + state: GameState, + character: Character, + ) => { + if (!state.worldType) return null; + + const campScene = getWorldCampScenePreset(state.worldType); + const homeScene = getCampCompanionHomeScene(state, character); + if ( + homeScene && + homeScene.id !== campScene?.id && + homeScene.id !== state.currentScenePreset?.id + ) { + return homeScene; + } + + const fallbackSceneId = + campScene?.id ?? state.currentScenePreset?.id ?? null; + return ( + getForwardScenePreset(state.worldType, fallbackSceneId) ?? + getTravelScenePreset(state.worldType, fallbackSceneId) ?? + homeScene + ); + }; + + const buildCampCompanionOpeningOptions = ( + state: GameState, + character: Character, + encounter: Encounter, + ) => { + const targetScene = getCampCompanionTravelScene(state, character); + const baseOptions = params.buildNpcStory( + state, + character, + encounter, + ).options; + const chatOptions = baseOptions + .filter((option) => option.functionId === NPC_CHAT_FUNCTION.id) + .slice(0, 1); + const recruitOption = + baseOptions.find( + (option) => option.functionId === NPC_RECRUIT_FUNCTION.id, + ) ?? null; + const openingOptions = recruitOption + ? [...chatOptions, recruitOption] + : chatOptions; + + if (!targetScene) { + return openingOptions; + } + + return [...openingOptions, buildCampTravelHomeOption(targetScene.name)]; + }; + + const inferOpeningCampFollowupOptions = async ( + state: GameState, + character: Character, + baseOptions: StoryOption[], + openingBackground: string, + openingDialogue: string, + ) => { + if (!state.worldType || baseOptions.length === 0) { + return baseOptions; + } + + try { + const response = await params.generateNextStep( + state.worldType, + character, + params.getStoryGenerationHostileNpcs(state), + state.storyHistory, + '继续承接营地中的这段交谈,并整理出眼下最自然的后续行动。', + params.buildStoryContextFromState(state, { + openingCampBackground: openingBackground, + openingCampDialogue: openingDialogue, + }), + { + availableOptions: baseOptions, + }, + ); + + return sortStoryOptionsByPriority(response.options); + } catch (error) { + console.error('Failed to infer opening camp follow-up options:', error); + return baseOptions; + } + }; + + const buildOpeningCampChatContext = ( + state: GameState, + character: Character, + encounter: Encounter, + ) => { + if (encounter.specialBehavior !== 'camp_companion') { + return {}; + } + + const npcState = + state.npcStates[params.getNpcEncounterKey(encounter)] ?? + buildInitialNpcState(encounter, state.worldType, state); + if (npcState.chattedCount > 2) { + return {}; + } + + const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`; + let openingDialogue: string | null = null; + + for (let index = 0; index < state.storyHistory.length - 1; index += 1) { + const entry = state.storyHistory[index]; + if (!entry) { + continue; + } + if (entry.historyRole !== 'action' || entry.text !== openingActionText) { + continue; + } + + for ( + let nextIndex = index + 1; + nextIndex < state.storyHistory.length; + nextIndex += 1 + ) { + const nextEntry = state.storyHistory[nextIndex]; + if (!nextEntry) { + continue; + } + if (nextEntry.historyRole === 'action') { + break; + } + if (nextEntry.text.trim()) { + openingDialogue = nextEntry.text; + break; + } + } + + if (openingDialogue) { + break; + } + } + + if (!openingDialogue) { + return {}; + } + + return { + openingCampBackground: buildCampCompanionOpeningResultText( + character, + encounter, + state.worldType, + ), + openingCampDialogue: openingDialogue, + }; + }; + + const buildCampCompanionIdleStory = ( + state: GameState, + character: Character, + encounter: Encounter, + overrideText?: string, + ): StoryMoment => { + const targetScene = getCampCompanionTravelScene(state, character); + const baseStory = params.buildNpcStory( + state, + character, + encounter, + overrideText, + ); + const filteredOptions = baseStory.options.filter( + (option) => + option.functionId !== NPC_LEAVE_FUNCTION.id && + option.functionId !== NPC_FIGHT_FUNCTION.id, + ); + + if (!targetScene) { + return { + ...baseStory, + options: filteredOptions, + }; + } + + return { + ...baseStory, + options: [ + ...filteredOptions.slice(0, 2), + buildCampTravelHomeOption(targetScene.name), + ...filteredOptions.slice(2), + ], + }; + }; + + return { + getCampCompanionTravelScene, + buildCampCompanionOpeningOptions, + inferOpeningCampFollowupOptions, + buildOpeningCampChatContext, + buildCampCompanionIdleStory, + }; +} diff --git a/src/hooks/story/storyChoiceContinuation.ts b/src/hooks/story/storyChoiceContinuation.ts new file mode 100644 index 00000000..06c426cf --- /dev/null +++ b/src/hooks/story/storyChoiceContinuation.ts @@ -0,0 +1,407 @@ +import { addInventoryItems } from '../../data/npcInteractions'; +import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow'; +import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; +import { generateNextStep } from '../../services/aiService'; +import type { StoryGenerationContext } from '../../services/aiTypes'; +import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory'; +import { createHistoryMoment } from '../../services/storyHistory'; +import type { + Character, + Encounter, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import type { EscapePlaybackSync } from '../combat/escapeFlow'; +import type { ResolvedChoiceState } from '../combat/resolvedChoice'; +import { + buildCombatResolutionContextText, + buildHostileNpcBattleReward, + buildReasonedOptionCatalog, +} from './storyChoiceRuntime'; +import type { BattleRewardSummary } from './uiTypes'; + +type RuntimeStatsIncrements = Partial< + Pick< + GameState['runtimeStats'], + 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled' + > +>; + +type BuildFallbackStoryForState = ( + state: GameState, + character: Character, + fallbackText?: string, +) => StoryMoment; + +type BuildStoryFromResponse = ( + state: GameState, + character: Character, + response: StoryMoment, + availableOptions: StoryOption[] | null, + optionCatalog?: StoryOption[] | null, +) => StoryMoment; + +type BuildNpcStory = ( + state: GameState, + character: Character, + encounter: Encounter, + overrideText?: string, +) => StoryMoment; + +type BuildStoryContextFromState = ( + state: GameState, + extras?: { + lastFunctionId?: string | null; + observeSignsRequested?: boolean; + recentActionResult?: string | null; + }, +) => StoryGenerationContext; + +type UpdateQuestLog = ( + state: GameState, + updater: (quests: GameState['quests']) => GameState['quests'], +) => GameState; + +type IncrementRuntimeStats = ( + state: GameState, + increments: RuntimeStatsIncrements, +) => GameState; + +export async function runLocalStoryChoiceContinuation(params: { + gameState: GameState; + currentStory: StoryMoment | null; + option: StoryOption; + character: Character; + setGameState: (state: GameState) => void; + setCurrentStory: (story: StoryMoment) => void; + setAiError: (message: string | null) => void; + setIsLoading: (loading: boolean) => void; + setBattleReward: (reward: BattleRewardSummary | null) => void; + buildResolvedChoiceState: ( + state: GameState, + option: StoryOption, + character: Character, + ) => ResolvedChoiceState; + playResolvedChoice: ( + state: GameState, + option: StoryOption, + character: Character, + resolvedChoice: ResolvedChoiceState, + sync?: EscapePlaybackSync, + ) => Promise; + buildStoryContextFromState: BuildStoryContextFromState; + buildStoryFromResponse: BuildStoryFromResponse; + buildFallbackStoryForState: BuildFallbackStoryForState; + generateStoryForState: (params: { + state: GameState; + character: Character; + history: StoryMoment[]; + choice?: string; + lastFunctionId?: string | null; + optionCatalog?: StoryOption[] | null; + }) => Promise; + getAvailableOptionsForState: ( + state: GameState, + character: Character, + ) => StoryOption[] | null; + getStoryGenerationHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + getResolvedSceneHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + buildNpcStory: BuildNpcStory; + updateQuestLog: UpdateQuestLog; + incrementRuntimeStats: IncrementRuntimeStats; + finalizeNpcBattleResult: ( + state: GameState, + character: Character, + battleMode: NonNullable, + battleOutcome: GameState['currentNpcBattleOutcome'], + ) => { nextState: GameState; resultText: string } | null; + isRegularNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; +}) { + params.setBattleReward(null); + params.setAiError(null); + params.setIsLoading(true); + + const baseChoiceState = + params.isRegularNpcEncounter(params.gameState.currentEncounter) && + !params.gameState.npcInteractionActive && + !params.option.interaction + ? { + ...params.gameState, + currentEncounter: null, + npcInteractionActive: false, + } + : params.gameState; + + let fallbackState = baseChoiceState; + + try { + const history = baseChoiceState.storyHistory; + const resolvedChoice = params.buildResolvedChoiceState( + baseChoiceState, + params.option, + params.character, + ); + const projectedState = resolvedChoice.afterSequence; + const shouldUseLocalNpcVictory = Boolean( + baseChoiceState.currentBattleNpcId && + resolvedChoice.optionKind === 'battle' && + (projectedState.currentNpcBattleOutcome || + (baseChoiceState.currentNpcBattleMode === 'fight' && + !projectedState.inBattle)), + ); + const projectedBattleReward = shouldUseLocalNpcVictory + ? null + : await buildHostileNpcBattleReward( + baseChoiceState, + projectedState, + resolvedChoice.optionKind, + params.getResolvedSceneHostileNpcs, + ); + const projectedStateWithBattleReward = projectedBattleReward + ? appendStoryEngineCarrierMemory( + { + ...projectedState, + playerInventory: addInventoryItems( + projectedState.playerInventory, + projectedBattleReward.items, + ), + } as GameState, + projectedBattleReward.items, + ) + : projectedState; + fallbackState = projectedStateWithBattleReward; + const projectedAvailableOptions = params.getAvailableOptionsForState( + projectedStateWithBattleReward, + params.character, + ); + const combatResolutionContextText = buildCombatResolutionContextText({ + baseState: baseChoiceState, + afterSequence: projectedStateWithBattleReward, + optionKind: resolvedChoice.optionKind, + projectedBattleReward, + getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs, + }); + const historyForStoryGeneration = combatResolutionContextText + ? [ + ...history, + createHistoryMoment(params.option.actionText, 'action'), + createHistoryMoment(combatResolutionContextText, 'result'), + ] + : history; + + const responsePromise = shouldUseLocalNpcVictory + ? Promise.resolve(null) + : generateNextStep( + params.gameState.worldType!, + params.character, + params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward), + historyForStoryGeneration, + params.option.actionText, + params.buildStoryContextFromState(projectedStateWithBattleReward, { + lastFunctionId: params.option.functionId, + observeSignsRequested: + params.option.functionId === 'idle_observe_signs', + recentActionResult: combatResolutionContextText, + }), + projectedAvailableOptions + ? { availableOptions: projectedAvailableOptions } + : undefined, + ); + const responseSettledPromise = responsePromise.then( + () => undefined, + () => undefined, + ); + const playbackSync: EscapePlaybackSync | undefined = + resolvedChoice.optionKind === 'escape' + ? { waitForStoryResponse: responseSettledPromise } + : undefined; + const actionPromise = params.playResolvedChoice( + baseChoiceState, + params.option, + params.character, + resolvedChoice, + playbackSync, + ); + const [actionResult, responseResult] = await Promise.allSettled([ + actionPromise, + responsePromise, + ]); + + if (actionResult.status === 'rejected') { + throw actionResult.reason; + } + + let afterSequence = shouldUseLocalNpcVictory + ? resolvedChoice.afterSequence + : actionResult.value; + if (projectedBattleReward) { + afterSequence = appendStoryEngineCarrierMemory( + { + ...afterSequence, + playerInventory: addInventoryItems( + afterSequence.playerInventory, + projectedBattleReward.items, + ), + } as GameState, + projectedBattleReward.items, + ); + } + fallbackState = afterSequence; + + if (shouldUseLocalNpcVictory) { + const victory = params.finalizeNpcBattleResult( + afterSequence, + params.character, + baseChoiceState.currentNpcBattleMode!, + afterSequence.currentNpcBattleOutcome, + ); + if (victory) { + const historyBase = + baseChoiceState.currentNpcBattleMode === 'spar' + ? (afterSequence.sparStoryHistoryBefore ?? []) + : baseChoiceState.storyHistory; + const nextHistory = [ + ...historyBase, + createHistoryMoment(params.option.actionText, 'action'), + createHistoryMoment(victory.resultText, 'result'), + ]; + const nextState = { + ...victory.nextState, + storyHistory: nextHistory, + }; + const postBattleOptionCatalog = + baseChoiceState.currentNpcBattleMode === 'spar' && + nextState.currentEncounter + ? buildReasonedOptionCatalog( + params.buildNpcStory( + nextState, + params.character, + nextState.currentEncounter, + ).options, + ) + : null; + fallbackState = nextState; + params.setGameState(nextState); + try { + const nextStory = await params.generateStoryForState({ + state: nextState, + character: params.character, + history: nextHistory, + choice: params.option.actionText, + lastFunctionId: params.option.functionId, + optionCatalog: postBattleOptionCatalog, + }); + const recoveredState = applyStoryReasoningRecovery(nextState); + params.setGameState(recoveredState); + params.setCurrentStory(nextStory); + } catch (storyError) { + console.error( + 'Failed to continue npc battle resolution story:', + storyError, + ); + params.setAiError( + storyError instanceof Error + ? storyError.message + : '未知智能生成错误', + ); + params.setCurrentStory( + params.buildFallbackStoryForState( + nextState, + params.character, + victory.resultText, + ), + ); + } + return; + } + } + + if (responseResult.status === 'rejected') { + throw responseResult.reason; + } + + const response = responseResult.value!; + const defeatedHostileNpcIds = + baseChoiceState.currentBattleNpcId || + resolvedChoice.optionKind === 'escape' + ? [] + : params + .getResolvedSceneHostileNpcs(baseChoiceState) + .map((hostileNpc) => hostileNpc.id) + .filter( + (hostileNpcId) => + !params + .getResolvedSceneHostileNpcs(afterSequence) + .some((hostileNpc) => hostileNpc.id === hostileNpcId), + ); + const nextHistory = combatResolutionContextText + ? [ + ...historyForStoryGeneration, + createHistoryMoment(response.storyText, 'result', response.options), + ] + : [ + ...baseChoiceState.storyHistory, + createHistoryMoment(params.option.actionText, 'action'), + createHistoryMoment(response.storyText, 'result', response.options), + ]; + + const nextState = params.incrementRuntimeStats( + { + ...params.updateQuestLog(afterSequence, (quests) => + applyQuestProgressFromHostileNpcDefeat( + quests, + baseChoiceState.currentScenePreset?.id ?? null, + defeatedHostileNpcIds, + ), + ), + lastObserveSignsSceneId: + params.option.functionId === 'idle_observe_signs' + ? (afterSequence.currentScenePreset?.id ?? null) + : afterSequence.lastObserveSignsSceneId ?? null, + lastObserveSignsReport: + params.option.functionId === 'idle_observe_signs' + ? response.storyText + : afterSequence.lastObserveSignsReport ?? null, + storyHistory: nextHistory, + }, + { + hostileNpcsDefeated: defeatedHostileNpcIds.length, + }, + ); + + const recoveredState = applyStoryReasoningRecovery(nextState); + params.setGameState(recoveredState); + if (projectedBattleReward) { + params.setBattleReward(projectedBattleReward); + } + + params.setCurrentStory( + params.buildStoryFromResponse( + recoveredState, + params.character, + { + text: response.storyText, + options: response.options, + }, + projectedAvailableOptions, + ), + ); + } catch (error) { + console.error('Failed to get next step:', error); + params.setAiError( + error instanceof Error ? error.message : '未知智能生成错误', + ); + params.setCurrentStory( + params.buildFallbackStoryForState(fallbackState, params.character), + ); + } finally { + params.setIsLoading(false); + } +} diff --git a/src/hooks/story/storyChoiceCoordinator.test.ts b/src/hooks/story/storyChoiceCoordinator.test.ts new file mode 100644 index 00000000..5371dcda --- /dev/null +++ b/src/hooks/story/storyChoiceCoordinator.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + Character, + Encounter, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import { createStoryChoiceCoordinatorConfig } from './storyChoiceCoordinator'; + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '测试角色', + backstory: '测试背景', + avatar: '/hero.png', + portrait: '/hero.png', + assetFolder: 'hero', + assetVariant: 'default', + attributes: { + strength: 10, + agility: 10, + intelligence: 8, + spirit: 9, + }, + personality: '谨慎', + skills: [], + adventureOpenings: {}, + } as unknown as Character; +} + +function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption { + return { + functionId, + actionText, + text: actionText, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + } as StoryOption; +} + +function createStory(text: string): StoryMoment { + return { + text, + options: [], + }; +} + +function createState(): GameState { + return { + worldType: 'WUXIA', + currentScene: 'Story', + } as GameState; +} + +const neverNpcEncounter = ( + _encounter: GameState['currentEncounter'], +): _encounter is Encounter => false; + +describe('storyChoiceCoordinator', () => { + it('builds one config object for createStoryChoiceActions from runtime controller and support', () => { + const runtimeController = { + buildStoryContextFromState: vi.fn(), + buildStoryFromResponse: vi.fn(), + buildFallbackStoryForState: vi.fn(), + generateStoryForState: vi.fn(), + getAvailableOptionsForState: vi.fn(), + getCampCompanionTravelScene: vi.fn(), + startOpeningAdventure: vi.fn(), + commitGeneratedStateWithEncounterEntry: vi.fn(), + }; + const runtimeSupport = { + buildNpcStory: vi.fn(), + updateQuestLog: vi.fn(), + updateRuntimeStats: vi.fn(), + }; + + const config = createStoryChoiceCoordinatorConfig({ + gameState: createState(), + currentStory: createStory('当前故事'), + isLoading: false, + setGameState: vi.fn(), + setCurrentStory: vi.fn(), + setAiError: vi.fn(), + setIsLoading: vi.fn(), + setBattleReward: vi.fn(), + buildResolvedChoiceState: vi.fn(), + playResolvedChoice: vi.fn(), + getStoryGenerationHostileNpcs: vi.fn(() => []), + getResolvedSceneHostileNpcs: vi.fn(() => []), + runtimeController: runtimeController as never, + runtimeSupport: runtimeSupport as never, + enterNpcInteraction: vi.fn(), + handleNpcInteraction: vi.fn(), + handleTreasureInteraction: vi.fn(), + finalizeNpcBattleResult: vi.fn(), + sortOptions: vi.fn((options: StoryOption[]) => options), + buildContinueAdventureOption: vi.fn(() => createOption('continue')), + isContinueAdventureOption: vi.fn(() => false), + isCampTravelHomeOption: vi.fn(() => false), + isInitialCompanionEncounter: neverNpcEncounter, + isRegularNpcEncounter: neverNpcEncounter, + isNpcEncounter: neverNpcEncounter, + npcPreviewTalkFunctionId: 'npc_preview_talk', + fallbackCompanionName: '同伴', + turnVisualMs: 820, + }); + + expect(config).toEqual( + expect.objectContaining({ + buildStoryContextFromState: runtimeController.buildStoryContextFromState, + buildStoryFromResponse: runtimeController.buildStoryFromResponse, + buildFallbackStoryForState: runtimeController.buildFallbackStoryForState, + generateStoryForState: runtimeController.generateStoryForState, + getAvailableOptionsForState: runtimeController.getAvailableOptionsForState, + buildNpcStory: runtimeSupport.buildNpcStory, + updateQuestLog: runtimeSupport.updateQuestLog, + incrementRuntimeStats: runtimeSupport.updateRuntimeStats, + getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene, + startOpeningAdventure: runtimeController.startOpeningAdventure, + commitGeneratedStateWithEncounterEntry: + runtimeController.commitGeneratedStateWithEncounterEntry, + }), + ); + + void createCharacter(); + }); +}); diff --git a/src/hooks/story/storyChoiceCoordinator.ts b/src/hooks/story/storyChoiceCoordinator.ts new file mode 100644 index 00000000..79a2911a --- /dev/null +++ b/src/hooks/story/storyChoiceCoordinator.ts @@ -0,0 +1,175 @@ +import type { Dispatch, SetStateAction } from 'react'; + +import type { + Character, + Encounter, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow'; +import type { ResolvedChoiceState } from '../combat/resolvedChoice'; +import type { BattleRewardSummary } from './uiTypes'; +import type { StoryRuntimeSupport } from './storyRuntimeSupport'; +import type { StoryGenerationContext } from '../../services/aiTypes'; + +export type ChoiceRuntimeController = { + buildStoryContextFromState: ( + state: GameState, + extras?: { + lastFunctionId?: string | null; + observeSignsRequested?: boolean; + recentActionResult?: string | null; + openingCampBackground?: string | null; + openingCampDialogue?: string | null; + encounterNpcStateOverride?: GameState['npcStates'][string] | null; + }, + ) => StoryGenerationContext; + buildStoryFromResponse: ( + state: GameState, + character: Character, + response: StoryMoment, + availableOptions: StoryOption[] | null, + optionCatalog?: StoryOption[] | null, + ) => StoryMoment; + buildFallbackStoryForState: ( + state: GameState, + character: Character, + fallbackText?: string, + ) => StoryMoment; + generateStoryForState: (params: { + state: GameState; + character: Character; + history: StoryMoment[]; + choice?: string; + lastFunctionId?: string | null; + optionCatalog?: StoryOption[] | null; + }) => Promise; + getAvailableOptionsForState: ( + state: GameState, + character: Character, + ) => StoryOption[] | null; + getCampCompanionTravelScene: ( + state: GameState, + character: Character, + ) => GameState['currentScenePreset'] | null; + startOpeningAdventure: () => Promise; + commitGeneratedStateWithEncounterEntry: ( + entryState: GameState, + resolvedState: GameState, + character: Character, + actionText: string, + resultText: string, + lastFunctionId?: string, + ) => Promise; +}; + +export type ChoiceRuntimeSupport = Pick< + StoryRuntimeSupport, + 'buildNpcStory' | 'updateQuestLog' | 'updateRuntimeStats' +>; + +export type StoryChoiceCoordinatorParams = { + gameState: GameState; + currentStory: StoryMoment | null; + isLoading: boolean; + setGameState: Dispatch>; + setCurrentStory: Dispatch>; + setAiError: Dispatch>; + setIsLoading: Dispatch>; + setBattleReward: Dispatch>; + buildResolvedChoiceState: ( + state: GameState, + option: StoryOption, + character: Character, + ) => ResolvedChoiceState; + playResolvedChoice: ( + state: GameState, + option: StoryOption, + character: Character, + resolvedChoice: ResolvedChoiceState, + sync?: ResolvedChoicePlaybackSync, + ) => Promise; + getStoryGenerationHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + getResolvedSceneHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + runtimeController: ChoiceRuntimeController; + runtimeSupport: ChoiceRuntimeSupport; + enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean; + handleNpcInteraction: (option: StoryOption) => boolean | Promise; + handleTreasureInteraction: ( + option: StoryOption, + ) => void | Promise | boolean | Promise; + finalizeNpcBattleResult: ( + state: GameState, + character: Character, + battleMode: NonNullable, + battleOutcome: GameState['currentNpcBattleOutcome'], + ) => { nextState: GameState; resultText: string } | null; + sortOptions: (options: StoryOption[]) => StoryOption[]; + buildContinueAdventureOption: () => StoryOption; + isContinueAdventureOption: (option: StoryOption) => boolean; + isCampTravelHomeOption: (option: StoryOption) => boolean; + isInitialCompanionEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isRegularNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + npcPreviewTalkFunctionId: string; + fallbackCompanionName: string; + turnVisualMs: number; +}; + +export function createStoryChoiceCoordinatorConfig( + params: StoryChoiceCoordinatorParams, +) { + return { + gameState: params.gameState, + currentStory: params.currentStory, + isLoading: params.isLoading, + setGameState: params.setGameState, + setCurrentStory: params.setCurrentStory, + setAiError: params.setAiError, + setIsLoading: params.setIsLoading, + setBattleReward: params.setBattleReward, + buildResolvedChoiceState: params.buildResolvedChoiceState, + playResolvedChoice: params.playResolvedChoice, + buildStoryContextFromState: + params.runtimeController.buildStoryContextFromState, + buildStoryFromResponse: params.runtimeController.buildStoryFromResponse, + buildFallbackStoryForState: + params.runtimeController.buildFallbackStoryForState, + generateStoryForState: params.runtimeController.generateStoryForState, + getAvailableOptionsForState: + params.runtimeController.getAvailableOptionsForState, + getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs, + getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs, + buildNpcStory: params.runtimeSupport.buildNpcStory, + updateQuestLog: params.runtimeSupport.updateQuestLog, + incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats, + getCampCompanionTravelScene: + params.runtimeController.getCampCompanionTravelScene, + startOpeningAdventure: params.runtimeController.startOpeningAdventure, + enterNpcInteraction: params.enterNpcInteraction, + handleNpcInteraction: params.handleNpcInteraction, + handleTreasureInteraction: params.handleTreasureInteraction, + commitGeneratedStateWithEncounterEntry: + params.runtimeController.commitGeneratedStateWithEncounterEntry, + finalizeNpcBattleResult: params.finalizeNpcBattleResult, + isContinueAdventureOption: params.isContinueAdventureOption, + isCampTravelHomeOption: params.isCampTravelHomeOption, + isInitialCompanionEncounter: params.isInitialCompanionEncounter, + isRegularNpcEncounter: params.isRegularNpcEncounter, + isNpcEncounter: params.isNpcEncounter, + npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId, + fallbackCompanionName: params.fallbackCompanionName, + turnVisualMs: params.turnVisualMs, + }; +} diff --git a/src/hooks/story/storyChoiceRuntime.test.ts b/src/hooks/story/storyChoiceRuntime.test.ts new file mode 100644 index 00000000..e004ea1f --- /dev/null +++ b/src/hooks/story/storyChoiceRuntime.test.ts @@ -0,0 +1,338 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + rollHostileNpcLootMock, + resolveServerRuntimeChoiceMock, +} = vi.hoisted(() => ({ + rollHostileNpcLootMock: vi.fn(), + resolveServerRuntimeChoiceMock: vi.fn(), +})); + +vi.mock('../../data/hostileNpcPresets', async () => { + const actual = + await vi.importActual( + '../../data/hostileNpcPresets', + ); + + return { + ...actual, + rollHostileNpcLoot: rollHostileNpcLootMock, + }; +}); + +vi.mock('./runtimeStoryCoordinator', () => ({ + resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock, +})); + +import type { Character, GameState, StoryMoment, StoryOption } from '../../types'; +import { + buildCombatResolutionContextText, + buildHostileNpcBattleReward, + buildReasonedOptionCatalog, + runServerRuntimeChoiceAction, + shouldOpenLocalRuntimeNpcModal, +} from './storyChoiceRuntime'; + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '测试角色', + backstory: '测试背景', + avatar: '/hero.png', + portrait: '/hero.png', + assetFolder: 'hero', + assetVariant: 'default', + attributes: { + strength: 10, + agility: 10, + intelligence: 10, + spirit: 10, + }, + personality: 'calm', + skills: [], + adventureOpenings: {}, + } as unknown as Character; +} + +function createStory(text: string): StoryMoment { + return { + text, + options: [], + }; +} + +function createOption( + functionId: string, + interaction?: StoryOption['interaction'], +): StoryOption { + return { + functionId, + actionText: functionId, + text: functionId, + interaction, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + } as StoryOption; +} + +function createState(overrides: Partial = {}): GameState { + return { + worldType: 'WUXIA', + customWorldProfile: null, + playerCharacter: createCharacter(), + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'Story', + storyHistory: [], + characterChats: {}, + animationState: 'idle', + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 100, + playerMaxHp: 100, + playerMana: 20, + playerMaxMana: 20, + playerSkillCooldowns: {}, + activeCombatEffects: [], + playerCurrency: 0, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: {}, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + ...overrides, + } as GameState; +} + +describe('storyChoiceRuntime', () => { + beforeEach(() => { + rollHostileNpcLootMock.mockReset(); + resolveServerRuntimeChoiceMock.mockReset(); + }); + + it('deduplicates option catalogs by function id for post-battle recovery', () => { + const options = buildReasonedOptionCatalog([ + createOption('npc_chat'), + createOption('npc_chat'), + createOption('npc_help'), + ]); + + expect(options.map((option) => option.functionId)).toEqual([ + 'npc_chat', + 'npc_help', + ]); + }); + + it('identifies npc trade and gift as local runtime modal actions', () => { + expect( + shouldOpenLocalRuntimeNpcModal( + createOption('npc_trade', { + kind: 'npc', + npcId: 'npc-merchant', + action: 'trade', + }), + ), + ).toBe(true); + expect( + shouldOpenLocalRuntimeNpcModal( + createOption('npc_gift', { + kind: 'npc', + npcId: 'npc-friend', + action: 'gift', + }), + ), + ).toBe(true); + expect( + shouldOpenLocalRuntimeNpcModal(createOption('npc_chat')), + ).toBe(false); + }); + + it('builds escape and victory context text for local battle resolution', () => { + const baseState = createState({ + inBattle: true, + sceneHostileNpcs: [ + { id: 'wolf', name: '山狼' }, + ] as GameState['sceneHostileNpcs'], + }); + + expect( + buildCombatResolutionContextText({ + baseState, + afterSequence: { + ...baseState, + inBattle: false, + sceneHostileNpcs: [], + }, + optionKind: 'escape', + projectedBattleReward: null, + getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs, + }), + ).toContain('你已成功逃脱'); + + expect( + buildCombatResolutionContextText({ + baseState: { + ...baseState, + currentBattleNpcId: null, + }, + afterSequence: { + ...baseState, + inBattle: false, + sceneHostileNpcs: [], + }, + optionKind: 'battle', + projectedBattleReward: { + id: 'reward-1', + defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }], + items: [ + { id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] }, + ], + }, + getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs, + }), + ).toContain('战利品:狼牙。'); + }); + + it('builds defeated hostile rewards from locally resolved battle states', async () => { + rollHostileNpcLootMock.mockResolvedValue([ + { + id: 'loot-1', + category: '材料', + name: '狼牙', + quantity: 1, + rarity: 'common', + tags: [], + }, + ]); + + const reward = await buildHostileNpcBattleReward( + createState({ + inBattle: true, + sceneHostileNpcs: [ + { id: 'wolf', name: '山狼' }, + ] as GameState['sceneHostileNpcs'], + currentBattleNpcId: null, + }), + createState({ + inBattle: false, + sceneHostileNpcs: [], + }), + 'battle', + (state) => state.sceneHostileNpcs, + ); + + expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1); + expect(reward?.items[0]).toEqual( + expect.objectContaining({ + name: '狼牙', + }), + ); + }); + + it('applies server runtime responses and falls back locally when the request fails', async () => { + const gameState = createState(); + const currentStory = createStory('当前故事'); + const setBattleReward = vi.fn(); + const setAiError = vi.fn(); + const setIsLoading = vi.fn(); + const setGameState = vi.fn(); + const setCurrentStory = vi.fn(); + + resolveServerRuntimeChoiceMock.mockResolvedValueOnce({ + hydratedSnapshot: { + gameState: { + ...gameState, + runtimeActionVersion: 3, + }, + }, + nextStory: createStory('服务端故事'), + }); + + await runServerRuntimeChoiceAction({ + gameState, + currentStory, + option: createOption('npc_chat'), + character: createCharacter(), + setBattleReward, + setAiError, + setIsLoading, + setGameState, + setCurrentStory: setCurrentStory as (story: StoryMoment) => void, + buildFallbackStoryForState: () => createStory('fallback'), + }); + + expect(setGameState).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeActionVersion: 3, + }), + ); + expect(setCurrentStory).toHaveBeenCalledWith( + expect.objectContaining({ + text: '服务端故事', + }), + ); + + resolveServerRuntimeChoiceMock.mockRejectedValueOnce(new Error('boom')); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await runServerRuntimeChoiceAction({ + gameState, + currentStory: null, + option: createOption('npc_chat'), + character: createCharacter(), + setBattleReward, + setAiError, + setIsLoading, + setGameState, + setCurrentStory: setCurrentStory as (story: StoryMoment) => void, + buildFallbackStoryForState: () => createStory('fallback'), + }); + } finally { + consoleErrorSpy.mockRestore(); + } + + expect(setAiError).toHaveBeenCalledWith('boom'); + expect(setCurrentStory).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'fallback', + }), + ); + }); +}); diff --git a/src/hooks/story/storyChoiceRuntime.ts b/src/hooks/story/storyChoiceRuntime.ts new file mode 100644 index 00000000..65375534 --- /dev/null +++ b/src/hooks/story/storyChoiceRuntime.ts @@ -0,0 +1,322 @@ +import { + buildEncounterEntryState, + hasEncounterEntity, +} from '../../data/encounterTransition'; +import { rollHostileNpcLoot } from '../../data/hostileNpcPresets'; +import { addInventoryItems } from '../../data/npcInteractions'; +import { + CALL_OUT_ENTRY_X_METERS, + createSceneEncounterPreview, + resolveSceneEncounterPreview, +} from '../../data/sceneEncounterPreviews'; +import { + AnimationState, + Character, + Encounter, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator'; +import type { BattleRewardSummary } from './uiTypes'; + +type RuntimeStatsIncrements = Partial< + Pick< + GameState['runtimeStats'], + 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled' + > +>; + +type BuildFallbackStoryForState = ( + state: GameState, + character: Character, + fallbackText?: string, +) => StoryMoment; + +type IncrementRuntimeStats = ( + state: GameState, + increments: RuntimeStatsIncrements, +) => GameState; + +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +export function buildReasonedOptionCatalog(options: StoryOption[]) { + const seenFunctionIds = new Set(); + + return options.filter((option) => { + if (seenFunctionIds.has(option.functionId)) { + return false; + } + + seenFunctionIds.add(option.functionId); + return true; + }); +} + +export function buildCombatResolutionContextText(params: { + baseState: GameState; + afterSequence: GameState; + optionKind: 'battle' | 'escape' | 'idle'; + projectedBattleReward: BattleRewardSummary | null; + getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs']; +}) { + const { + baseState, + afterSequence, + optionKind, + projectedBattleReward, + getResolvedSceneHostileNpcs, + } = params; + + if (optionKind === 'escape') { + const hostileNames = getResolvedSceneHostileNpcs(baseState) + .map((hostileNpc) => hostileNpc.name) + .join('、'); + return hostileNames + ? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。` + : '你已成功逃脱刚才的交战,当前不再处于战斗状态。'; + } + + if ( + !baseState.inBattle || + afterSequence.inBattle || + Boolean(baseState.currentBattleNpcId) + ) { + return null; + } + + const hostileNames = getResolvedSceneHostileNpcs(baseState) + .map((hostileNpc) => hostileNpc.name) + .join('、'); + const lootText = + projectedBattleReward?.items.length + ? `战利品:${projectedBattleReward.items + .map((item) => item.name) + .join('、')}。` + : ''; + + return hostileNames + ? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}` + : `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`; +} + +export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) { + return ( + option.interaction?.kind === 'npc' && + (option.functionId === 'npc_trade' || option.functionId === 'npc_gift') + ); +} + +export async function buildHostileNpcBattleReward( + state: GameState, + afterSequence: GameState, + optionKind: 'battle' | 'escape' | 'idle', + getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'], +): Promise { + if ( + optionKind === 'escape' || + !state.worldType || + state.currentBattleNpcId || + !state.inBattle || + afterSequence.inBattle + ) { + return null; + } + + const activeHostileNpcs = getResolvedSceneHostileNpcs(state); + const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence); + const defeatedHostileNpcs = activeHostileNpcs.filter( + (hostileNpc) => + !nextHostileNpcs.some( + (nextHostileNpc) => nextHostileNpc.id === hostileNpc.id, + ), + ); + + if (defeatedHostileNpcs.length === 0) { + return null; + } + + const rolledItems = await rollHostileNpcLoot( + state, + defeatedHostileNpcs.map((hostileNpc) => ({ + id: hostileNpc.id, + name: hostileNpc.name, + })), + ); + + return { + id: `battle-reward-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`, + defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc) => ({ + id: hostileNpc.id, + name: hostileNpc.name, + })), + items: addInventoryItems([], rolledItems), + }; +} + +export async function runCampTravelHomeChoice(params: { + gameState: GameState; + option: StoryOption; + character: Character; + setBattleReward: (reward: BattleRewardSummary | null) => void; + setAiError: (message: string | null) => void; + setIsLoading: (loading: boolean) => void; + setGameState: (state: GameState) => void; + incrementRuntimeStats: IncrementRuntimeStats; + getCampCompanionTravelScene: ( + state: GameState, + character: Character, + ) => GameState['currentScenePreset'] | null; + commitGeneratedStateWithEncounterEntry: ( + entryState: GameState, + resolvedState: GameState, + character: Character, + actionText: string, + resultText: string, + lastFunctionId?: string, + ) => Promise | void; + isNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + fallbackCompanionName: string; + turnVisualMs: number; +}) { + const targetScene = params.getCampCompanionTravelScene( + params.gameState, + params.character, + ); + if (!targetScene) { + return false; + } + + params.setBattleReward(null); + params.setAiError(null); + + const companionName = params.isNpcEncounter(params.gameState.currentEncounter) + ? params.gameState.currentEncounter.npcName + : params.fallbackCompanionName; + const travelRunState: GameState = { + ...params.gameState, + ambientIdleMode: undefined, + currentEncounter: null, + npcInteractionActive: false, + sceneHostileNpcs: [], + playerX: 0, + playerFacing: 'right' as const, + animationState: AnimationState.RUN, + playerActionMode: 'idle' as const, + activeCombatEffects: [], + scrollWorld: true, + inBattle: false, + lastObserveSignsSceneId: null, + lastObserveSignsReport: null, + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + }; + const travelBaseState: GameState = params.incrementRuntimeStats( + { + ...params.gameState, + ambientIdleMode: undefined, + currentScenePreset: targetScene, + currentEncounter: null, + npcInteractionActive: false, + sceneHostileNpcs: [], + playerX: 0, + playerFacing: 'right' as const, + animationState: AnimationState.IDLE, + playerActionMode: 'idle' as const, + activeCombatEffects: [], + scrollWorld: false, + inBattle: false, + lastObserveSignsSceneId: null, + lastObserveSignsReport: null, + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + }, + { + scenesTraveled: 1, + }, + ); + const travelPreviewState: GameState = { + ...travelBaseState, + ...createSceneEncounterPreview(travelBaseState), + }; + const resolvedState = hasEncounterEntity(travelPreviewState) + ? resolveSceneEncounterPreview(travelPreviewState) + : travelBaseState; + const entryState = buildEncounterEntryState( + resolvedState, + CALL_OUT_ENTRY_X_METERS, + ); + + params.setIsLoading(true); + params.setGameState(travelRunState); + await sleep(params.turnVisualMs); + + await params.commitGeneratedStateWithEncounterEntry( + entryState, + resolvedState, + params.character, + params.option.actionText, + `You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`, + params.option.functionId, + ); + return true; +} + +export async function runServerRuntimeChoiceAction(params: { + gameState: GameState; + currentStory: StoryMoment | null; + option: StoryOption; + character: Character; + setBattleReward: (reward: BattleRewardSummary | null) => void; + setAiError: (message: string | null) => void; + setIsLoading: (loading: boolean) => void; + setGameState: (state: GameState) => void; + setCurrentStory: (story: StoryMoment) => void; + buildFallbackStoryForState: BuildFallbackStoryForState; +}) { + params.setBattleReward(null); + params.setAiError(null); + params.setIsLoading(true); + + try { + const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({ + gameState: params.gameState, + currentStory: params.currentStory, + option: params.option, + }); + + params.setGameState(hydratedSnapshot.gameState); + params.setCurrentStory(nextStory); + } catch (error) { + console.error('Failed to resolve runtime action on the server:', error); + params.setAiError( + error instanceof Error ? error.message : '运行时动作执行失败', + ); + if (!params.currentStory) { + params.setCurrentStory( + params.buildFallbackStoryForState( + params.gameState, + params.character, + ), + ); + } + } finally { + params.setIsLoading(false); + } +} diff --git a/src/hooks/story/storyContextBuilder.ts b/src/hooks/story/storyContextBuilder.ts new file mode 100644 index 00000000..5a5a9303 --- /dev/null +++ b/src/hooks/story/storyContextBuilder.ts @@ -0,0 +1,625 @@ +import { getCharacterById } from '../../data/characterPresets'; +import { + NPC_CHAT_FUNCTION, + STORY_OPENING_CAMP_DIALOGUE_FUNCTION, +} from '../../data/functionCatalog'; +import { + buildInitialNpcState, + describeNpcAffinityInWords, + getNpcConversationDirective, + isNpcFirstMeaningfulContact, +} from '../../data/npcInteractions'; +import { buildSceneEntityCatalogText } from '../../data/scenePresets'; +import { hasMixedNarrativeLanguage } from '../../services/narrativeLanguage'; +import type { StoryGenerationContext } from '../../services/aiTypes'; +import { + buildFallbackActorNarrativeProfile, + normalizeActorNarrativeProfile, +} from '../../services/storyEngine/actorNarrativeProfile'; +import { applyAdaptiveTuningToPromptContext } from '../../services/storyEngine/adaptiveNarrativeTuner'; +import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler'; +import { + buildCampEvent, + evaluateCampEventOpportunity, +} from '../../services/storyEngine/campEventDirector'; +import { + advanceChapterState, + resolveCurrentChapterState, +} from '../../services/storyEngine/chapterDirector'; +import { + advanceCompanionArc, + buildCompanionArcStates, +} from '../../services/storyEngine/companionArcDirector'; +import { buildGoalStackState } from '../../services/storyEngine/goalDirector'; +import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner'; +import { buildVisibilitySliceFromFacts } from '../../services/storyEngine/knowledgeContract'; +import { buildKnowledgeGraph } from '../../services/storyEngine/knowledgeGraph'; +import { buildRecentCarrierEchoes } from '../../services/storyEngine/narrativeCarrierCatalog'; +import { buildChapterRecap } from '../../services/storyEngine/recapDigest'; +import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry'; +import { buildSceneNarrativeDirective } from '../../services/storyEngine/sceneNarrativeDirector'; +import { + buildSetpieceDirective, + evaluateSetpieceOpportunity, +} from '../../services/storyEngine/setpieceDirector'; +import { buildChronicleSummary } from '../../services/storyEngine/storyChronicle'; +import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack'; +import { + buildEncounterVisibilitySlice, + createEmptyStoryEngineMemoryState, +} from '../../services/storyEngine/visibilityEngine'; +import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph'; +import type { GameState } from '../../types'; +import { getCharacterChatRecord } from './characterChat'; +import { getNpcEncounterKey } from './storyGenerationState'; + +const OPENING_CAMP_DIALOGUE_FUNCTION_ID = + STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id; + +export type StoryContextBuilderExtras = { + pendingSceneEncounter?: boolean; + lastFunctionId?: string | null; + observeSignsRequested?: boolean; + recentActionResult?: string | null; + openingCampBackground?: string | null; + openingCampDialogue?: string | null; + encounterNpcStateOverride?: GameState['npcStates'][string] | null; +}; + +function buildPartyRelationshipNotes(state: GameState) { + const lines: string[] = []; + const seenCharacterIds = new Set(); + + const appendNote = (characterId: string, roleLabel: string) => { + if (seenCharacterIds.has(characterId)) return; + const character = getCharacterById(characterId); + const summary = getCharacterChatRecord(state, characterId).summary.trim(); + if (hasMixedNarrativeLanguage(summary)) return; + if (!character || !summary) return; + + seenCharacterIds.add(characterId); + lines.push( + `- ${character.name} (${character.title} / ${roleLabel}): ${summary}`, + ); + }; + + state.companions.forEach((companion) => + appendNote(companion.characterId, '当前同行'), + ); + state.roster.forEach((companion) => + appendNote(companion.characterId, '营地待命'), + ); + + return lines.length > 0 ? lines.join('\n') : null; +} + +function describeScenePressureLevel( + pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined, +) { + switch (pressureLevel) { + case 'low': + return '低'; + case 'medium': + return '中'; + case 'high': + return '高'; + case 'extreme': + return '极高'; + default: + return null; + } +} + +function buildRecentConversationEventText(state: GameState) { + const recentText = state.storyHistory + .slice(-6) + .map((item) => item.text) + .join('\n'); + if ( + /击败|怪物|战斗|切磋|交手|脱身/u.test(recentText) + ) { + return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。'; + } + if (/携手|相助|帮你|并肩/u.test(recentText)) { + return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。'; + } + return null; +} + +function inferConversationSituation( + state: GameState, + extras: Pick< + StoryContextBuilderExtras, + 'lastFunctionId' | 'openingCampDialogue' + >, +) { + if (state.inBattle) return 'shared_danger_coordination' as const; + if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID) + return 'camp_first_contact' as const; + if ( + state.currentEncounter?.specialBehavior === 'camp_companion' && + extras.openingCampDialogue?.trim() + ) { + return 'camp_followup' as const; + } + const recentText = state.storyHistory + .slice(-6) + .map((item) => item.text) + .join('\n'); + if ( + /击败|怪物|战斗|切磋|交手|脱身/u.test(recentText) + ) { + return 'post_battle_breath' as const; + } + if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id) + return 'private_followup' as const; + return 'first_contact_cautious' as const; +} + +function inferConversationPressure( + state: GameState, + situation: ReturnType, +) { + const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1); + if (state.inBattle || hpRatio < 0.35) return 'high' as const; + if ( + situation === 'post_battle_breath' || + situation === 'shared_danger_coordination' + ) + return 'medium' as const; + if (situation === 'camp_first_contact' || situation === 'camp_followup') + return 'low' as const; + return 'medium' as const; +} + +function describeConversationSituation( + situation: ReturnType, +) { + switch (situation) { + case 'camp_first_contact': + return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。'; + case 'camp_followup': + return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。'; + case 'post_battle_breath': + return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。'; + case 'shared_danger_coordination': + return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。'; + case 'private_followup': + return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。'; + default: + return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。'; + } +} + +function describeConversationTalkPriority( + situation: ReturnType, +) { + switch (situation) { + case 'camp_first_contact': + return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。'; + case 'camp_followup': + return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。'; + case 'post_battle_breath': + return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。'; + case 'shared_danger_coordination': + return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。'; + case 'private_followup': + return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。'; + default: + return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。'; + } +} + +function resolveEncounterNarrativeProfile(state: GameState) { + const encounter = state.currentEncounter; + if (!encounter || encounter.kind !== 'npc') { + return null; + } + if (encounter.narrativeProfile) { + return encounter.narrativeProfile; + } + if (!state.customWorldProfile) { + return null; + } + + const role = + state.customWorldProfile.storyNpcs.find((npc) => + npc.id === encounter.id || npc.name === encounter.npcName, + ) + ?? state.customWorldProfile.playableNpcs.find((npc) => + npc.id === encounter.id || npc.name === encounter.npcName, + ); + if (!role) { + return null; + } + + const themePack = + state.customWorldProfile.themePack + ?? buildThemePackFromWorldProfile(state.customWorldProfile); + const storyGraph = + state.customWorldProfile.storyGraph + ?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); + + return normalizeActorNarrativeProfile( + role.narrativeProfile, + buildFallbackActorNarrativeProfile(role, storyGraph, themePack), + ); +} + +function resolveActiveThreadIds( + state: GameState, + encounterNarrativeProfile: ReturnType, +) { + if (state.storyEngineMemory?.activeThreadIds?.length) { + return state.storyEngineMemory.activeThreadIds.slice(0, 4); + } + if (encounterNarrativeProfile?.relatedThreadIds.length) { + return encounterNarrativeProfile.relatedThreadIds.slice(0, 4); + } + if (!state.customWorldProfile) { + return []; + } + + const themePack = + state.customWorldProfile.themePack + ?? buildThemePackFromWorldProfile(state.customWorldProfile); + const storyGraph = + state.customWorldProfile.storyGraph + ?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); + + return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id); +} + +export function buildStoryContextFromState( + state: GameState, + extras: StoryContextBuilderExtras = {}, +): StoryGenerationContext { + const conversationSituation = inferConversationSituation(state, extras); + const conversationPressure = inferConversationPressure( + state, + conversationSituation, + ); + const recentSharedEvent = buildRecentConversationEventText(state); + const encounterNpcState = + state.currentEncounter?.kind === 'npc' + ? (() => { + const encounter = state.currentEncounter; + return extras.encounterNpcStateOverride + ?? state.npcStates[getNpcEncounterKey(encounter)] + ?? buildInitialNpcState(encounter, state.worldType, state); + })() + : null; + const encounterDirective = + state.currentEncounter?.kind === 'npc' + ? (() => { + const encounter = state.currentEncounter; + return encounterNpcState + ? getNpcConversationDirective(encounter, encounterNpcState) + : null; + })() + : null; + const isFirstMeaningfulContact = + state.currentEncounter?.kind === 'npc' + ? (() => { + const encounter = state.currentEncounter; + return encounterNpcState + ? isNpcFirstMeaningfulContact(encounter, encounterNpcState) + : false; + })() + : false; + const firstContactRelationStance = (() => { + if ( + !isFirstMeaningfulContact || + !state.currentEncounter || + state.currentEncounter.kind !== 'npc' + ) { + return null; + } + + const stance = encounterNpcState?.relationState?.stance ?? null; + if ( + stance === 'guarded' || + stance === 'neutral' || + stance === 'cooperative' || + stance === 'bonded' + ) { + return stance; + } + return null; + })(); + const encounterAffinityText = + state.currentEncounter?.kind === 'npc' + ? (() => { + const encounter = state.currentEncounter; + return encounterNpcState + ? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, { + recruited: encounterNpcState.recruited, + }) + : null; + })() + : null; + const baseSceneDescription = state.currentScenePreset?.description ?? null; + const sceneMutationDescription = [ + state.currentScenePreset?.mutationStateText + ? `最新世界变化:${state.currentScenePreset.mutationStateText}` + : null, + describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel) + ? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}` + : null, + ] + .filter(Boolean) + .join('\n'); + const observeSignsSceneDescription = + extras.observeSignsRequested && state.worldType + ? [ + baseSceneDescription, + sceneMutationDescription, + '当前可观察实体池:', + buildSceneEntityCatalogText( + state.worldType, + state.currentScenePreset?.id ?? null, + ), + ] + .filter(Boolean) + .join('\n') + : [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n'); + const storyEngineMemory = + state.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); + const knowledgeFacts = + state.customWorldProfile?.knowledgeFacts + ?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []); + const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state); + const activeThreadIds = resolveActiveThreadIds( + { + ...state, + storyEngineMemory, + } as GameState, + encounterNarrativeProfile, + ); + const visibilitySlice = + state.currentEncounter?.kind === 'npc' + ? (() => { + const relevantFacts = knowledgeFacts.filter((fact) => + fact.ownerActorIds.includes(state.currentEncounter?.id ?? '') + || fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '') + || fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)), + ); + return relevantFacts.length > 0 + ? buildVisibilitySliceFromFacts({ + facts: relevantFacts, + discoveredFactIds: [ + ...storyEngineMemory.discoveredFactIds, + ...(encounterNpcState?.revealedFacts ?? []), + ...(encounterNpcState?.seenBackstoryChapterIds ?? []).map( + (chapterId) => + relevantFacts.find((fact) => + fact.aliases?.includes(chapterId) || fact.id.includes(chapterId), + )?.id ?? '', + ), + ], + activeThreadIds, + disclosureStage: encounterDirective?.disclosureStage ?? null, + isFirstMeaningfulContact, + }) + : buildEncounterVisibilitySlice({ + narrativeProfile: encounterNarrativeProfile, + backstoryReveal: state.currentEncounter.backstoryReveal ?? null, + disclosureStage: encounterDirective?.disclosureStage ?? null, + isFirstMeaningfulContact, + seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [], + storyEngineMemory, + activeThreadIds, + }); + })() + : null; + const sceneNarrativeDirective = buildSceneNarrativeDirective({ + sceneId: state.currentScenePreset?.id ?? null, + sceneName: state.currentScenePreset?.name ?? null, + encounterId: state.currentEncounter?.id ?? null, + encounterName: state.currentEncounter?.npcName ?? null, + recentActions: state.storyHistory.slice(-3).map((moment) => moment.text), + activeThreadIds, + visibilitySlice, + encounterNarrativeProfile, + disclosureStage: encounterDirective?.disclosureStage ?? null, + isFirstMeaningfulContact, + affinity: encounterNpcState?.affinity ?? null, + }); + const chapterState = advanceChapterState({ + previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null, + nextChapter: resolveCurrentChapterState({ + state: { + ...state, + storyEngineMemory, + }, + }), + }); + const journeyBeat = resolveCurrentJourneyBeat({ + state: { + ...state, + chapterState, + storyEngineMemory: { + ...storyEngineMemory, + currentChapter: chapterState, + }, + } as GameState, + chapterState, + }); + const companionArcStates = advanceCompanionArc({ + previous: storyEngineMemory.companionArcStates, + next: buildCompanionArcStates({ + state, + reactions: storyEngineMemory.recentCompanionReactions, + }), + }); + const currentCampEvent = evaluateCampEventOpportunity({ + state, + chapterState, + journeyBeat, + companionArcStates, + }) + ? buildCampEvent({ + state, + chapterState, + journeyBeat, + companionArcStates, + }) + : null; + const setpieceDirective = evaluateSetpieceOpportunity({ + state, + chapterState, + journeyBeat, + }) + ? buildSetpieceDirective({ + state, + chapterState, + journeyBeat, + }) + : null; + const recentWorldMutations = storyEngineMemory.worldMutations ?? []; + const recentChronicleSummary = buildChronicleSummary({ + ...state, + chapterState, + storyEngineMemory: { + ...storyEngineMemory, + currentChapter: chapterState, + companionArcStates, + }, + } as GameState); + const compiledPacks = state.customWorldProfile + ? compileCampaignFromWorldProfile({ profile: state.customWorldProfile }) + : null; + const goalStack = buildGoalStackState({ + quests: state.quests, + worldType: state.worldType, + currentSceneId: state.currentScenePreset?.id ?? null, + chapterState, + journeyBeat, + setpieceDirective, + currentCampEvent, + currentSceneName: state.currentScenePreset?.name ?? null, + }); + const activeScenarioPack = + resolveScenarioPack(state.activeScenarioPackId) + ?? compiledPacks?.scenarioPack + ?? null; + const activeCampaignPack = compiledPacks?.campaignPack ?? null; + + const fallbackChapterRecap = buildChapterRecap({ + state: { ...state, chapterState } as GameState, + }); + const safeEncounterRelationshipSummary = + state.currentEncounter?.characterId + ? getCharacterChatRecord(state, state.currentEncounter.characterId) + .summary + .trim() + : ''; + + return applyAdaptiveTuningToPromptContext({ + context: { + playerHp: state.playerHp, + playerMaxHp: state.playerMaxHp, + playerMana: state.playerMana, + playerMaxMana: state.playerMaxMana, + inBattle: state.inBattle, + playerX: state.playerX, + playerFacing: state.playerFacing, + playerAnimation: state.animationState, + skillCooldowns: state.playerSkillCooldowns, + sceneId: state.currentScenePreset?.id ?? null, + sceneName: state.currentScenePreset?.name ?? null, + sceneDescription: observeSignsSceneDescription, + pendingSceneEncounter: extras.pendingSceneEncounter ?? false, + lastFunctionId: extras.lastFunctionId ?? null, + observeSignsRequested: extras.observeSignsRequested ?? false, + recentActionResult: extras.recentActionResult ?? null, + lastObserveSignsReport: + state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null) + ? (state.lastObserveSignsReport ?? null) + : null, + encounterKind: state.currentEncounter?.kind ?? null, + encounterName: state.currentEncounter?.npcName ?? null, + encounterDescription: state.currentEncounter?.npcDescription ?? null, + encounterContext: state.currentEncounter?.context ?? null, + encounterId: state.currentEncounter?.id ?? null, + encounterCharacterId: state.currentEncounter?.characterId ?? null, + encounterGender: state.currentEncounter?.gender ?? null, + encounterCustomProfile: state.currentEncounter + ? { + title: state.currentEncounter.title ?? '', + description: state.currentEncounter.npcDescription ?? '', + backstory: state.currentEncounter.backstory ?? '', + personality: state.currentEncounter.personality ?? '', + motivation: state.currentEncounter.motivation ?? '', + combatStyle: state.currentEncounter.combatStyle ?? '', + relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])], + tags: [...(state.currentEncounter.tags ?? [])], + backstoryReveal: state.currentEncounter.backstoryReveal, + skills: [...(state.currentEncounter.skills ?? [])], + initialItems: [...(state.currentEncounter.initialItems ?? [])], + imageSrc: state.currentEncounter.imageSrc, + visual: state.currentEncounter.visual, + narrativeProfile: state.currentEncounter.narrativeProfile, + } + : null, + encounterAffinity: encounterDirective?.affinity ?? null, + encounterAffinityText, + encounterStanceProfile: encounterNpcState?.stanceProfile ?? null, + encounterConversationStyle: encounterDirective?.style ?? null, + encounterDisclosureStage: encounterDirective?.disclosureStage ?? null, + encounterWarmthStage: encounterDirective?.warmthStage ?? null, + encounterAnswerMode: encounterDirective?.answerMode ?? null, + encounterAllowedTopics: encounterDirective?.allowTopics ?? null, + encounterBlockedTopics: encounterDirective?.blockedTopics ?? null, + isFirstMeaningfulContact, + firstContactRelationStance, + conversationSituation, + conversationPressure, + recentSharedEvent: + recentSharedEvent ?? describeConversationSituation(conversationSituation), + talkPriority: describeConversationTalkPriority(conversationSituation), + visibilitySlice, + sceneNarrativeDirective, + campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null, + actState: storyEngineMemory.actState ?? null, + chapterState, + journeyBeat, + goalStack, + currentCampEvent, + setpieceDirective, + activeScenarioPack, + activeCampaignPack, + encounterNarrativeProfile, + knowledgeFacts, + activeThreadIds, + companionArcStates, + companionResolutions: storyEngineMemory.companionResolutions ?? [], + consequenceLedger: storyEngineMemory.consequenceLedger ?? [], + authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null, + playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null, + recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [], + recentCarrierEchoes: buildRecentCarrierEchoes(state), + recentWorldMutations, + recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [], + recentChronicleSummary: + recentChronicleSummary.trim() && + !hasMixedNarrativeLanguage(recentChronicleSummary) + ? recentChronicleSummary + : fallbackChapterRecap, + narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null, + releaseGateReport: storyEngineMemory.releaseGateReport ?? null, + simulationRunResults: storyEngineMemory.simulationRunResults ?? [], + branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null, + encounterRelationshipSummary: state.currentEncounter?.characterId + ? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary) + ? safeEncounterRelationshipSummary || null + : null + : null, + partyRelationshipNotes: buildPartyRelationshipNotes(state), + customWorldProfile: state.customWorldProfile ?? null, + openingCampBackground: extras.openingCampBackground ?? null, + openingCampDialogue: extras.openingCampDialogue ?? null, + }, + profile: storyEngineMemory.playerStyleProfile ?? null, + }); +} diff --git a/src/hooks/story/storyEncounterState.test.ts b/src/hooks/story/storyEncounterState.test.ts new file mode 100644 index 00000000..4ecd9b5e --- /dev/null +++ b/src/hooks/story/storyEncounterState.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + AnimationState, + type Character, + type Encounter, + type GameState, + type StoryMoment, +} from '../../types'; +import { createStoryStateResolvers } from './storyEncounterState'; + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '测试主角', + personality: 'calm', + skills: [], + } as unknown as Character; +} + +function createGameState(overrides: Partial = {}): GameState { + return { + worldType: null, + customWorldProfile: null, + playerCharacter: createCharacter(), + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'Story', + storyHistory: [], + characterChats: {}, + animationState: AnimationState.IDLE, + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 100, + playerMaxHp: 100, + playerMana: 20, + playerMaxMana: 20, + playerSkillCooldowns: {}, + activeCombatEffects: [], + playerCurrency: 0, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: {}, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + ...overrides, + } as GameState; +} + +function createNpcEncounter( + overrides: Partial = {}, +): Encounter { + return { + id: 'npc-guard', + kind: 'npc', + npcName: '山道客', + npcDescription: '守在路口的陌生人', + npcAvatar: '/npc.png', + context: '山道相遇', + ...overrides, + } as Encounter; +} + +describe('storyEncounterState', () => { + it('delegates camp companion option pools to the dedicated builder', () => { + const character = createCharacter(); + const state = createGameState({ + currentEncounter: createNpcEncounter({ + specialBehavior: 'camp_companion', + }), + }); + const campStory: StoryMoment = { + text: '营地同伴剧情', + options: [ + { + functionId: 'npc_chat', + actionText: '继续交谈', + text: '继续交谈', + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + ], + }; + const buildCampCompanionIdleOptions = vi.fn(() => campStory); + const buildNpcStory = vi.fn(); + + const { getAvailableOptionsForState } = createStoryStateResolvers({ + buildCampCompanionIdleOptions, + buildNpcStory, + }); + + expect(getAvailableOptionsForState(state, character)).toEqual( + campStory.options, + ); + expect(buildCampCompanionIdleOptions).toHaveBeenCalledWith( + state, + character, + state.currentEncounter, + undefined, + ); + expect(buildNpcStory).not.toHaveBeenCalled(); + }); + + it('uses preview talk options for initial companion encounters before formal interaction starts', () => { + const character = createCharacter(); + const state = createGameState({ + currentEncounter: createNpcEncounter({ + specialBehavior: 'initial_companion', + }), + }); + const { getAvailableOptionsForState } = createStoryStateResolvers({ + buildCampCompanionIdleOptions: vi.fn(), + buildNpcStory: vi.fn(), + }); + + const options = getAvailableOptionsForState(state, character); + + expect(options).toEqual([ + expect.objectContaining({ + functionId: 'npc_preview_talk', + }), + ]); + }); + + it('preserves explicit fallback text when the state falls back to the generic story moment', () => { + const state = createGameState(); + const character = createCharacter(); + const { buildFallbackStoryForState } = createStoryStateResolvers({ + buildCampCompanionIdleOptions: vi.fn(), + buildNpcStory: vi.fn(), + }); + + const story = buildFallbackStoryForState(state, character, '手动兜底文本'); + + expect(story.text).toBe('手动兜底文本'); + expect(story.options.length).toBeGreaterThan(0); + }); +}); diff --git a/src/hooks/story/storyEncounterState.ts b/src/hooks/story/storyEncounterState.ts new file mode 100644 index 00000000..9f97e85b --- /dev/null +++ b/src/hooks/story/storyEncounterState.ts @@ -0,0 +1,238 @@ +import { buildNpcPreviewTalkOption } from '../../data/functionCatalog'; +import { + getDefaultFunctionIdsForContext, + resolveFunctionOption, +} from '../../data/stateFunctions'; +import { buildTreasureEncounterStoryMoment } from '../../data/treasureInteractions'; +import type { + Character, + Encounter, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import { buildFallbackStoryMoment } from '../combatStoryUtils'; + +type CampCompanionEncounter = Encounter & { + specialBehavior: 'camp_companion'; +}; + +type EncounterStoryBuilder = ( + state: GameState, + character: Character, + encounter: Encounter, + overrideText?: string, +) => StoryMoment; + +export function buildNpcPreviewStory( + state: GameState, + character: Character, + encounter: Encounter, + overrideText?: string, +): StoryMoment { + if (!state.worldType) { + return { + text: + overrideText ?? + `${encounter.npcName}正停在前方,像是在等你先决定要不要真正把注意力落到他身上。`, + options: [buildNpcPreviewTalkOption(encounter)], + }; + } + + const functionContext = { + worldType: state.worldType, + playerCharacter: character, + inBattle: false, + currentSceneId: state.currentScenePreset?.id ?? null, + currentSceneName: state.currentScenePreset?.name ?? null, + monsters: [], + playerHp: state.playerHp, + playerMaxHp: state.playerMaxHp, + playerMana: state.playerMana, + playerMaxMana: state.playerMaxMana, + }; + + const locationOptions = getDefaultFunctionIdsForContext(functionContext) + .filter((functionId) => functionId !== 'idle_call_out') + .map((functionId) => resolveFunctionOption(functionId, functionContext)) + .filter((option): option is StoryOption => Boolean(option)); + + return { + text: + overrideText ?? + `${encounter.npcName}出现在${state.currentScenePreset?.name ?? '前方道路'}附近,但你还没有真正把全部注意力落到对方身上。`, + options: [buildNpcPreviewTalkOption(encounter), ...locationOptions], + }; +} + +export function getResolvedSceneHostileNpcs(state: GameState) { + return state.sceneHostileNpcs; +} + +export function getStoryGenerationHostileNpcs(state: GameState) { + return state.inBattle ? getResolvedSceneHostileNpcs(state) : []; +} + +export function isCampCompanionEncounter( + encounter: GameState['currentEncounter'], +): encounter is CampCompanionEncounter { + return Boolean( + encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion', + ); +} + +export function isInitialCompanionEncounter( + encounter: GameState['currentEncounter'], +): encounter is Encounter { + return Boolean( + encounter?.kind === 'npc' && + encounter.specialBehavior === 'initial_companion', + ); +} + +export function isNpcEncounter( + encounter: GameState['currentEncounter'], +): encounter is Encounter { + return Boolean(encounter?.kind === 'npc'); +} + +export function isRegularNpcEncounter( + encounter: GameState['currentEncounter'], +): encounter is Encounter { + return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior); +} + +export function isTreasureEncounter( + encounter: GameState['currentEncounter'], +): encounter is Encounter { + return Boolean(encounter?.kind === 'treasure'); +} + +export function buildTreasureStory( + state: GameState, + _character: Character, + encounter: Encounter, + overrideText?: string, +): StoryMoment { + return buildTreasureEncounterStoryMoment({ + state, + encounter, + overrideText, + }); +} + +function resolveEncounterStory(params: { + state: GameState; + character: Character; + buildCampCompanionIdleOptions: EncounterStoryBuilder; + buildNpcStory: EncounterStoryBuilder; + fallbackText?: string; +}) { + const { state, character, fallbackText } = params; + + if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) { + return params.buildCampCompanionIdleOptions( + state, + character, + state.currentEncounter, + fallbackText, + ); + } + + if ( + isInitialCompanionEncounter(state.currentEncounter) && + !state.inBattle && + !state.npcInteractionActive + ) { + return buildNpcPreviewStory( + state, + character, + state.currentEncounter, + fallbackText, + ); + } + + if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) { + if (!state.npcInteractionActive) { + return buildNpcPreviewStory( + state, + character, + state.currentEncounter, + fallbackText, + ); + } + + return params.buildNpcStory( + state, + character, + state.currentEncounter, + fallbackText, + ); + } + + if (isNpcEncounter(state.currentEncounter) && !state.inBattle) { + return params.buildNpcStory( + state, + character, + state.currentEncounter, + fallbackText, + ); + } + + if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) { + return buildTreasureStory( + state, + character, + state.currentEncounter, + fallbackText, + ); + } + + return null; +} + +export function createStoryStateResolvers(params: { + buildCampCompanionIdleOptions: EncounterStoryBuilder; + buildNpcStory: EncounterStoryBuilder; +}) { + const getAvailableOptionsForState = ( + state: GameState, + character: Character, + ) => + resolveEncounterStory({ + state, + character, + buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions, + buildNpcStory: params.buildNpcStory, + })?.options ?? null; + + const buildFallbackStoryForState = ( + state: GameState, + character: Character, + fallbackText?: string, + ) => { + const resolvedStory = resolveEncounterStory({ + state, + character, + fallbackText, + buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions, + buildNpcStory: params.buildNpcStory, + }); + if (resolvedStory) { + return resolvedStory; + } + + const fallback = buildFallbackStoryMoment(state, character); + return fallbackText + ? { + ...fallback, + text: fallbackText, + } + : fallback; + }; + + return { + getAvailableOptionsForState, + buildFallbackStoryForState, + }; +} diff --git a/src/hooks/story/storyInteractionCoordinator.test.ts b/src/hooks/story/storyInteractionCoordinator.test.ts new file mode 100644 index 00000000..16f22120 --- /dev/null +++ b/src/hooks/story/storyInteractionCoordinator.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameState, StoryMoment, StoryOption } from '../../types'; +import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator'; + +function createOption( + functionId = 'npc_chat', + actionText = '继续交谈', +): StoryOption { + return { + functionId, + actionText, + text: actionText, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + } as StoryOption; +} + +function createStory(text: string): StoryMoment { + return { + text, + options: [], + }; +} + +function createState(): GameState { + return { + worldType: 'WUXIA', + currentScene: 'Story', + } as GameState; +} + +describe('storyInteractionCoordinator', () => { + it('builds shared interaction configs for treasure, inventory and npc flows', () => { + const gameState = createState(); + const currentStory = createStory('当前故事'); + const setGameState = vi.fn(); + const setCurrentStory = vi.fn(); + const setAiError = vi.fn(); + const setIsLoading = vi.fn(); + const buildFallbackStoryForState = vi.fn(); + const buildStoryContextFromState = vi.fn(); + const buildDialogueStoryMoment = vi.fn(); + const generateStoryForState = vi.fn(); + const getStoryGenerationHostileNpcs = vi.fn(() => []); + const getAvailableOptionsForState = vi.fn(() => [createOption()]); + const getTypewriterDelay = (_char: string) => 90 as const; + const commitGeneratedState = vi.fn(); + const commitGeneratedStateWithEncounterEntry = vi.fn(); + const appendHistory = vi.fn(); + const buildOpeningCampChatContext = vi.fn(); + const sortOptions = vi.fn((options: StoryOption[]) => options); + const buildContinueAdventureOption = vi.fn(() => createOption('continue')); + const sanitizeOptions = vi.fn((options: StoryOption[]) => options); + const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' })); + const runtimeSupport = { + buildNpcStory: vi.fn(), + cloneInventoryItemForOwner: vi.fn(), + getNpcEncounterKey: vi.fn(), + getResolvedNpcState: vi.fn(), + updateNpcState: vi.fn(), + updateQuestLog: vi.fn(), + updateRuntimeStats: vi.fn(), + }; + + const config = createStoryInteractionCoordinatorConfig({ + gameState, + setGameState, + setCurrentStory, + setAiError, + setIsLoading, + currentStory, + buildStoryContextFromState, + buildFallbackStoryForState, + buildDialogueStoryMoment, + generateStoryForState, + getAvailableOptionsForState, + getStoryGenerationHostileNpcs, + getTypewriterDelay, + runtimeSupport, + commitGeneratedState, + commitGeneratedStateWithEncounterEntry, + appendHistory, + buildOpeningCampChatContext, + sortOptions, + buildContinueAdventureOption, + sanitizeOptions, + resolveNpcInteractionDecision, + }); + + expect(config.treasureFlow.runtime).toBe(config.inventoryFlow.runtime); + expect(config.treasureFlow).toEqual({ + gameState, + runtime: config.inventoryFlow.runtime, + }); + expect(config.npcInteractionFlow).toEqual( + expect.objectContaining({ + gameState, + setGameState, + commitGeneratedState, + getNpcEncounterKey: runtimeSupport.getNpcEncounterKey, + getResolvedNpcState: runtimeSupport.getResolvedNpcState, + updateNpcState: runtimeSupport.updateNpcState, + cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner, + runtime: expect.objectContaining({ + currentStory, + setCurrentStory, + setAiError, + setIsLoading, + buildStoryContextFromState, + buildFallbackStoryForState, + buildDialogueStoryMoment, + generateStoryForState, + getStoryGenerationHostileNpcs, + getTypewriterDelay, + }), + }), + ); + expect(config.npcEncounterActions).toEqual( + expect.objectContaining({ + gameState, + currentStory, + setGameState, + setCurrentStory, + setAiError, + setIsLoading, + commitGeneratedState, + commitGeneratedStateWithEncounterEntry, + appendHistory, + buildOpeningCampChatContext, + buildStoryContextFromState, + buildFallbackStoryForState, + buildDialogueStoryMoment, + generateStoryForState, + getStoryGenerationHostileNpcs, + getTypewriterDelay, + getAvailableOptionsForState, + sanitizeOptions, + sortOptions, + buildContinueAdventureOption, + getNpcEncounterKey: runtimeSupport.getNpcEncounterKey, + getResolvedNpcState: runtimeSupport.getResolvedNpcState, + updateNpcState: runtimeSupport.updateNpcState, + cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner, + resolveNpcInteractionDecision, + }), + ); + }); +}); diff --git a/src/hooks/story/storyInteractionCoordinator.ts b/src/hooks/story/storyInteractionCoordinator.ts new file mode 100644 index 00000000..515082bc --- /dev/null +++ b/src/hooks/story/storyInteractionCoordinator.ts @@ -0,0 +1,137 @@ +import type { Dispatch, SetStateAction } from 'react'; + +import type { StoryGenerationContext } from '../../services/aiTypes'; +import type { + Character, + Encounter, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import type { StoryRuntimeSupport } from './storyRuntimeSupport'; +import type { StoryRuntimeControllerResult } from './useStoryRuntimeController'; + +type StoryInteractionCoordinatorParams = { + gameState: GameState; + setGameState: Dispatch>; + setCurrentStory: Dispatch>; + setAiError: Dispatch>; + setIsLoading: Dispatch>; + currentStory: StoryMoment | null; + buildStoryContextFromState: ( + state: GameState, + extras?: { + lastFunctionId?: string | null; + openingCampBackground?: string | null; + openingCampDialogue?: string | null; + encounterNpcStateOverride?: GameState['npcStates'][string] | null; + }, + ) => StoryGenerationContext; + buildFallbackStoryForState: ( + state: GameState, + character: Character, + fallbackText?: string, + ) => StoryMoment; + buildDialogueStoryMoment: StoryRuntimeControllerResult['buildDialogueStoryMoment']; + generateStoryForState: StoryRuntimeControllerResult['generateStoryForState']; + getAvailableOptionsForState: StoryRuntimeControllerResult['getAvailableOptionsForState']; + getStoryGenerationHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + getTypewriterDelay: StoryRuntimeControllerResult['getTypewriterDelay']; + runtimeSupport: StoryRuntimeSupport; + commitGeneratedState: StoryRuntimeControllerResult['commitGeneratedState']; + commitGeneratedStateWithEncounterEntry: StoryRuntimeControllerResult['commitGeneratedStateWithEncounterEntry']; + appendHistory: StoryRuntimeControllerResult['appendHistory']; + buildOpeningCampChatContext: StoryRuntimeControllerResult['buildOpeningCampChatContext']; + sortOptions: (options: StoryOption[]) => StoryOption[]; + buildContinueAdventureOption: () => StoryOption; + sanitizeOptions: ( + options: StoryOption[], + character: Character, + state: GameState, + ) => StoryOption[]; + resolveNpcInteractionDecision: ( + state: GameState, + option: StoryOption, + ) => { kind: string }; +}; + +export function createStoryInteractionCoordinatorConfig( + params: StoryInteractionCoordinatorParams, +) { + const sharedRuntime = { + currentStory: params.currentStory, + setGameState: params.setGameState, + setCurrentStory: params.setCurrentStory, + setAiError: params.setAiError, + setIsLoading: params.setIsLoading, + buildFallbackStoryForState: params.buildFallbackStoryForState, + }; + + return { + treasureFlow: { + gameState: params.gameState, + runtime: sharedRuntime, + }, + inventoryFlow: { + gameState: params.gameState, + runtime: sharedRuntime, + }, + npcInteractionFlow: { + gameState: params.gameState, + setGameState: params.setGameState, + commitGeneratedState: params.commitGeneratedState, + getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey, + getResolvedNpcState: params.runtimeSupport.getResolvedNpcState, + updateNpcState: params.runtimeSupport.updateNpcState, + cloneInventoryItemForOwner: + params.runtimeSupport.cloneInventoryItemForOwner, + runtime: { + currentStory: params.currentStory, + setCurrentStory: params.setCurrentStory, + setAiError: params.setAiError, + setIsLoading: params.setIsLoading, + buildStoryContextFromState: params.buildStoryContextFromState, + buildFallbackStoryForState: params.buildFallbackStoryForState, + buildDialogueStoryMoment: params.buildDialogueStoryMoment, + generateStoryForState: params.generateStoryForState, + getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs, + getTypewriterDelay: params.getTypewriterDelay, + }, + }, + npcEncounterActions: { + gameState: params.gameState, + currentStory: params.currentStory, + setGameState: params.setGameState, + setCurrentStory: params.setCurrentStory, + setAiError: params.setAiError, + setIsLoading: params.setIsLoading, + commitGeneratedState: params.commitGeneratedState, + commitGeneratedStateWithEncounterEntry: + params.commitGeneratedStateWithEncounterEntry, + appendHistory: params.appendHistory, + buildOpeningCampChatContext: params.buildOpeningCampChatContext, + buildStoryContextFromState: params.buildStoryContextFromState, + buildFallbackStoryForState: params.buildFallbackStoryForState, + buildDialogueStoryMoment: params.buildDialogueStoryMoment, + generateStoryForState: params.generateStoryForState, + getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs, + getTypewriterDelay: params.getTypewriterDelay, + getAvailableOptionsForState: params.getAvailableOptionsForState, + sanitizeOptions: params.sanitizeOptions, + sortOptions: params.sortOptions, + buildContinueAdventureOption: params.buildContinueAdventureOption, + getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey, + getResolvedNpcState: params.runtimeSupport.getResolvedNpcState, + updateNpcState: params.runtimeSupport.updateNpcState, + cloneInventoryItemForOwner: + params.runtimeSupport.cloneInventoryItemForOwner, + resolveNpcInteractionDecision: params.resolveNpcInteractionDecision, + }, + }; +} + +export type StoryInteractionCoordinatorConfig = ReturnType< + typeof createStoryInteractionCoordinatorConfig +>; diff --git a/src/hooks/story/storyPresentation.test.ts b/src/hooks/story/storyPresentation.test.ts new file mode 100644 index 00000000..4b7c63fe --- /dev/null +++ b/src/hooks/story/storyPresentation.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { + AnimationState, + type Character, + type GameState, + type StoryMoment, + type StoryOption, +} from '../../types'; +import { buildStoryFromResponse } from './storyPresentation'; + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '测试主角', + personality: 'calm', + skills: [], + } as unknown as Character; +} + +function createGameState(overrides: Partial = {}): GameState { + return { + worldType: null, + customWorldProfile: null, + playerCharacter: createCharacter(), + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'Story', + storyHistory: [], + characterChats: {}, + animationState: AnimationState.IDLE, + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 100, + playerMaxHp: 100, + playerMana: 20, + playerMaxMana: 20, + playerSkillCooldowns: {}, + activeCombatEffects: [], + playerCurrency: 0, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: {}, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + ...overrides, + } as GameState; +} + +function createOption( + functionId = 'npc_chat', + actionText = '继续交谈', +): StoryOption { + return { + functionId, + actionText, + text: actionText, + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + } as StoryOption; +} + +function createStory( + text: string, + options: StoryOption[] = [], +): StoryMoment { + return { + text, + options, + }; +} + +describe('storyPresentation', () => { + it('keeps provided available options when the AI response omits them', () => { + const availableOptions = [createOption('npc_help', '请求援手')]; + + const story = buildStoryFromResponse({ + state: createGameState(), + character: createCharacter(), + response: createStory('服务端返回正文'), + availableOptions, + }); + + expect(story.text).toBe('服务端返回正文'); + expect(story.options).toEqual([ + expect.objectContaining({ + functionId: 'npc_help', + actionText: '请求援手', + }), + ]); + }); + + it('deduplicates repeated response options before padding local fallbacks', () => { + const duplicatedOptions = [ + createOption('npc_chat', '继续交谈'), + createOption('npc_chat', '继续交谈'), + ]; + + const story = buildStoryFromResponse({ + state: createGameState(), + character: createCharacter(), + response: createStory('需要本地归一化', duplicatedOptions), + availableOptions: null, + }); + + expect( + story.options.filter( + (option) => + option.functionId === 'npc_chat' && + option.actionText === '继续交谈', + ), + ).toHaveLength(1); + expect(story.options.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/hooks/story/storyPresentation.ts b/src/hooks/story/storyPresentation.ts new file mode 100644 index 00000000..a8fb06ef --- /dev/null +++ b/src/hooks/story/storyPresentation.ts @@ -0,0 +1,245 @@ +import { sortStoryOptionsByPriority } from '../../data/stateFunctions'; +import type { + Character, + GameState, + StoryDialogueTurn, + StoryMoment, + StoryOption, +} from '../../types'; +import { + buildFallbackStoryMoment, + normalizeSkillProbabilities, +} from '../combatStoryUtils'; +import { resolveStoryResponseOptions } from './storyResponseOptions'; + +const MIN_OPTION_POOL_SIZE = 6; + +function dedupeStoryOptions(options: StoryOption[]) { + const seen = new Set(); + + return options.filter((option) => { + const identity = `${option.functionId}::${option.actionText}::${option.text}`; + if (seen.has(identity)) { + return false; + } + seen.add(identity); + return true; + }); +} + +function escapeRegExp(value: string) { + const specialChars = [ + '\\', + '^', + '$', + '*', + '+', + '?', + '.', + '(', + ')', + '|', + '[', + ']', + '{', + '}', + ]; + return specialChars.reduce( + (escaped, char) => escaped.split(char).join('\\' + char), + value, + ); +} + +function normalizeDialogueSpeakerName(rawSpeakerName: string) { + return rawSpeakerName + .trim() + .replace( + /^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u, + '', + ) + .replace( + /[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u, + '', + ) + .replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '') + .trim(); +} + +export function sanitizeStoryOptions( + options: StoryOption[], + character: Character, + state: GameState, +) { + const normalizedOptions = dedupeStoryOptions( + options.map((option) => normalizeSkillProbabilities(option, character)), + ); + + if (normalizedOptions.length === 0) { + return buildFallbackStoryMoment(state, character).options; + } + + if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) { + return normalizedOptions; + } + + return sortStoryOptionsByPriority( + dedupeStoryOptions([ + ...normalizedOptions, + ...buildFallbackStoryMoment(state, character).options, + ]).slice(0, MIN_OPTION_POOL_SIZE), + ); +} + +export function buildStoryFromResponse(params: { + state: GameState; + character: Character; + response: StoryMoment; + availableOptions: StoryOption[] | null; + optionCatalog?: StoryOption[] | null; +}) { + return { + text: params.response.text, + options: resolveStoryResponseOptions({ + responseOptions: params.response.options, + availableOptions: params.availableOptions, + optionCatalog: params.optionCatalog ?? null, + getSanitizedOptions: () => + sanitizeStoryOptions( + params.response.options, + params.character, + params.state, + ), + }), + } satisfies StoryMoment; +} + +export function parseDialogueTurns( + text: string, + npcName: string, +): StoryDialogueTurn[] { + const turns: StoryDialogueTurn[] = []; + const dialogueColonPattern = '(?:\\uFF1A|:)'; + const playerPrefixPattern = new RegExp( + '^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' + + dialogueColonPattern + + '\\\\s*(.+)$', + 'u', + ); + const npcPrefixPattern = new RegExp( + '^' + + escapeRegExp(npcName) + + '\\\\s*' + + dialogueColonPattern + + '\\\\s*(.+)$', + 'u', + ); + const namedSpeakerPattern = new RegExp( + '^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', + 'u', + ); + const lines = text + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + for (const line of lines) { + const playerMatch = line.match(playerPrefixPattern); + const playerText = playerMatch?.[1]?.trim(); + if (playerText) { + turns.push({ speaker: 'player', text: playerText }); + continue; + } + + const npcMatch = line.match(npcPrefixPattern); + const npcText = npcMatch?.[1]?.trim(); + if (npcText) { + turns.push({ speaker: 'npc', speakerName: npcName, text: npcText }); + continue; + } + + const namedSpeakerMatch = line.match(namedSpeakerPattern); + if (namedSpeakerMatch) { + const rawSpeakerName = namedSpeakerMatch[1]; + const rawSpeakerText = namedSpeakerMatch[2]; + if (!rawSpeakerName || !rawSpeakerText) { + continue; + } + + const speakerName = normalizeDialogueSpeakerName(rawSpeakerName); + const speakerText = rawSpeakerText.trim(); + + if (speakerName && speakerText) { + turns.push({ + speaker: speakerName === npcName ? 'npc' : 'companion', + speakerName, + text: speakerText, + }); + continue; + } + } + + if (line.startsWith('你:') || line.startsWith('你:')) { + turns.push({ speaker: 'player', text: line.slice(2).trim() }); + continue; + } + + if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) { + turns.push({ + speaker: 'npc', + text: line.slice(npcName.length + 1).trim(), + }); + continue; + } + + if (line.startsWith('主角:') || line.startsWith('主角:')) { + turns.push({ speaker: 'player', text: line.slice(3).trim() }); + continue; + } + + if (turns.length > 0) { + const lastTurnIndex = turns.length - 1; + const lastTurn = turns[lastTurnIndex]; + if (lastTurn) { + turns[lastTurnIndex] = { + ...lastTurn, + text: lastTurn.text + line, + }; + } + } + } + + return turns.filter((turn) => turn.text.length > 0); +} + +export function buildDialogueStoryMoment( + npcName: string, + text: string, + options: StoryOption[], + streaming = false, +): StoryMoment { + return { + text, + options, + displayMode: 'dialogue', + dialogue: parseDialogueTurns(text, npcName), + streaming, + }; +} + +export function hasRenderableDialogueTurns(text: string, npcName: string) { + return parseDialogueTurns(text, npcName).length >= 2; +} + +export function getTypewriterDelay(char: string) { + if (/[。!?!?]/u.test(char)) { + return 240; + } + if (/[,、;;:]/u.test(char)) { + return 150; + } + if (/\s/u.test(char)) { + return 45; + } + return 90; +} diff --git a/src/hooks/story/storyRenderingHelpers.ts b/src/hooks/story/storyRenderingHelpers.ts new file mode 100644 index 00000000..4033d047 --- /dev/null +++ b/src/hooks/story/storyRenderingHelpers.ts @@ -0,0 +1,241 @@ +import type { + Character, + GameState, + StoryDialogueTurn, + StoryMoment, + StoryOption, +} from '../../types'; +import { + buildFallbackStoryMoment, + normalizeSkillProbabilities, +} from '../combatStoryUtils'; + +const MIN_OPTION_POOL_SIZE = 6; + +export function dedupeStoryOptions(options: StoryOption[]) { + const seen = new Set(); + + return options.filter((option) => { + const identity = `${option.functionId}::${option.actionText}::${option.text}`; + if (seen.has(identity)) return false; + seen.add(identity); + return true; + }); +} + +export function buildLocalCharacterChatSummary( + character: Character, + history: Array<{ speaker: 'player' | 'character'; text: string }>, + previousSummary: string, +) { + const latestTurns = history + .slice(-4) + .map( + (turn) => + `${turn.speaker === 'player' ? '玩家' : character.name}:${turn.text}`, + ) + .join(' '); + + const currentSummary = latestTurns + ? `${character.name}在私下交谈里变得更愿意坦率回应。最近交流:${latestTurns}` + : `${character.name}愿意继续私下交谈,对玩家的信任也在慢慢加深。`; + if (!previousSummary) { + return currentSummary.slice(0, 118); + } + + return `${previousSummary} ${currentSummary}`.slice(0, 118); +} + +export function buildLocalCharacterChatSuggestions(character: Character) { + return [ + '我想听你把这件事再说得更明白一点。', + `${character.name},你现在真正担心的是什么?`, + '先把外面的局势放一放,我想更了解你一些。', + ]; +} + +export function sanitizeOptions( + options: StoryOption[], + character: Character, + state: GameState, +) { + const normalizedOptions = dedupeStoryOptions( + options.map((option) => normalizeSkillProbabilities(option, character)), + ); + + if (normalizedOptions.length === 0) { + return buildFallbackStoryMoment(state, character).options; + } + + if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) { + return normalizedOptions; + } + + return dedupeStoryOptions([ + ...normalizedOptions, + ...buildFallbackStoryMoment(state, character).options, + ]).slice(0, MIN_OPTION_POOL_SIZE); +} + +function escapeRegExp(value: string) { + const specialChars = [ + '\\', + '^', + '$', + '*', + '+', + '?', + '.', + '(', + ')', + '|', + '[', + ']', + '{', + '}', + ]; + return specialChars.reduce( + (escaped, char) => escaped.split(char).join('\\' + char), + value, + ); +} + +function normalizeDialogueSpeakerName(rawSpeakerName: string) { + return rawSpeakerName + .trim() + .replace( + /^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u, + '', + ) + .replace( + /[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u, + '', + ) + .replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '') + .trim(); +} + +function parseDialogueTurns( + text: string, + npcName: string, +): StoryDialogueTurn[] { + const turns: StoryDialogueTurn[] = []; + const dialogueColonPattern = '(?:\\uFF1A|:)'; + const playerPrefixPattern = new RegExp( + '^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' + + dialogueColonPattern + + '\\\\s*(.+)$', + 'u', + ); + const npcPrefixPattern = new RegExp( + '^' + + escapeRegExp(npcName) + + '\\\\s*' + + dialogueColonPattern + + '\\\\s*(.+)$', + 'u', + ); + const namedSpeakerPattern = new RegExp( + '^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', + 'u', + ); + const lines = text + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + for (const line of lines) { + const playerMatch = line.match(playerPrefixPattern); + const playerText = playerMatch?.[1]?.trim(); + if (playerText) { + turns.push({ speaker: 'player', text: playerText }); + continue; + } + + const npcMatch = line.match(npcPrefixPattern); + const npcText = npcMatch?.[1]?.trim(); + if (npcText) { + turns.push({ speaker: 'npc', speakerName: npcName, text: npcText }); + continue; + } + + const namedSpeakerMatch = line.match(namedSpeakerPattern); + if (namedSpeakerMatch) { + const rawSpeakerName = namedSpeakerMatch[1]; + const rawSpeakerText = namedSpeakerMatch[2]; + if (!rawSpeakerName || !rawSpeakerText) { + continue; + } + + const speakerName = normalizeDialogueSpeakerName(rawSpeakerName); + const speakerText = rawSpeakerText.trim(); + + if (speakerName && speakerText) { + turns.push({ + speaker: speakerName === npcName ? 'npc' : 'companion', + speakerName, + text: speakerText, + }); + continue; + } + } + + if (line.startsWith('你:') || line.startsWith('你:')) { + turns.push({ speaker: 'player', text: line.slice(2).trim() }); + continue; + } + + if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) { + turns.push({ + speaker: 'npc', + text: line.slice(npcName.length + 1).trim(), + }); + continue; + } + + if (line.startsWith('主角:') || line.startsWith('主角:')) { + turns.push({ speaker: 'player', text: line.slice(3).trim() }); + continue; + } + + if (turns.length > 0) { + const lastTurnIndex = turns.length - 1; + const lastTurn = turns[lastTurnIndex]; + if (lastTurn) { + turns[lastTurnIndex] = { + ...lastTurn, + text: lastTurn.text + line, + }; + } + } + } + + return turns.filter((turn) => turn.text.length > 0); +} + +export function buildDialogueStoryMoment( + npcName: string, + text: string, + options: StoryOption[], + streaming = false, +): StoryMoment { + return { + text, + options, + displayMode: 'dialogue', + dialogue: parseDialogueTurns(text, npcName), + streaming, + }; +} + +export function hasRenderableDialogueTurns(text: string, npcName: string) { + return parseDialogueTurns(text, npcName).length >= 2; +} + +export function getTypewriterDelay(char: string) { + if (/[。!?!?]/u.test(char)) return 240; + if (/[,、;;:]/u.test(char)) return 150; + if (/\s/u.test(char)) return 45; + return 90; +} diff --git a/src/hooks/story/storyRequestCoordinator.test.ts b/src/hooks/story/storyRequestCoordinator.test.ts new file mode 100644 index 00000000..d5e1a898 --- /dev/null +++ b/src/hooks/story/storyRequestCoordinator.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { StoryGenerationContext } from '../../services/aiService'; +import type { Character, GameState, StoryMoment, StoryOption } from '../../types'; +import { + generateStoryForStateWithCoordinator, + resolveStoryRequestOptions, +} from './storyRequestCoordinator'; + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '在风声里辨认危险的旅人。', + personality: '谨慎而果断', + skills: [], + } as unknown as Character; +} + +function createGameState(): GameState { + return { + worldType: 'WUXIA', + runtimeSessionId: 'runtime-main', + runtimeActionVersion: 3, + } as GameState; +} + +function createStory(text: string): StoryMoment { + return { + text, + options: [], + }; +} + +function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption { + return { + functionId, + actionText, + text: actionText, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + } as StoryOption; +} + +describe('storyRequestCoordinator', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('switches to server runtime option catalogs when the local option pool is fully server-backed', async () => { + const state = createGameState(); + const character = createCharacter(); + const currentStory = createStory('当前故事'); + const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]); + const loadRuntimeOptionCatalog = vi + .fn() + .mockResolvedValue([createOption('npc_help', '请求援手')]); + + const result = await resolveStoryRequestOptions({ + state, + character, + currentStory, + getAvailableOptionsForState, + loadRuntimeOptionCatalog, + }); + + expect(loadRuntimeOptionCatalog).toHaveBeenCalledWith({ + gameState: state, + currentStory, + }); + expect(result.availableOptions).toBeNull(); + expect(result.optionCatalog).toEqual([ + expect.objectContaining({ + functionId: 'npc_help', + actionText: '请求援手', + }), + ]); + }); + + it('keeps explicit option catalogs without reloading server runtime options', async () => { + const state = createGameState(); + const character = createCharacter(); + const currentStory = createStory('当前故事'); + const optionCatalog = [createOption('npc_help', '请求援手')]; + const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]); + const loadRuntimeOptionCatalog = vi.fn(); + + const result = await resolveStoryRequestOptions({ + state, + character, + currentStory, + optionCatalog, + getAvailableOptionsForState, + loadRuntimeOptionCatalog, + }); + + expect(loadRuntimeOptionCatalog).not.toHaveBeenCalled(); + expect(getAvailableOptionsForState).not.toHaveBeenCalled(); + expect(result.optionCatalog).toBe(optionCatalog); + expect(result.availableOptions).toBeNull(); + }); + + it('falls back to local available options when server runtime catalog refresh fails during续写', async () => { + const state = createGameState(); + const character = createCharacter(); + const currentStory = createStory('当前故事'); + const history = [createStory('上一轮剧情')]; + const localOptions = [createOption('npc_chat', '继续交谈')]; + const getAvailableOptionsForState = vi.fn(() => localOptions); + const getStoryGenerationHostileNpcs = vi.fn(() => []); + const buildStoryContextFromState = vi.fn( + (_state, extras) => + ({ + playerHp: 100, + playerMaxHp: 100, + playerMana: 30, + playerMaxMana: 30, + inBattle: false, + playerX: 0, + playerFacing: 'right', + playerAnimation: 'idle', + skillCooldowns: {}, + sceneId: 'inn_room', + sceneName: '客栈内室', + sceneDescription: '屋里安静得只剩风声。', + pendingSceneEncounter: false, + lastFunctionId: extras?.lastFunctionId ?? null, + }) as StoryGenerationContext, + ); + const buildStoryFromResponse = vi.fn( + ( + _state: GameState, + _character: Character, + response: StoryMoment, + ) => response, + ); + const requestInitialStory = vi.fn(); + const requestNextStep = vi.fn().mockResolvedValue({ + storyText: '服务端续写完成', + options: [createOption('npc_help', '顺势追问')], + }); + const loadRuntimeOptionCatalog = vi + .fn() + .mockRejectedValue(new Error('server option catalog failed')); + const onServerOptionCatalogLoadError = vi.fn(); + + const result = await generateStoryForStateWithCoordinator({ + state, + character, + history, + currentStory, + choice: '继续交谈', + lastFunctionId: 'npc_chat', + getAvailableOptionsForState, + getStoryGenerationHostileNpcs, + buildStoryContextFromState, + buildStoryFromResponse, + requestInitialStory, + requestNextStep, + loadRuntimeOptionCatalog, + onServerOptionCatalogLoadError, + }); + + expect(onServerOptionCatalogLoadError).toHaveBeenCalledTimes(1); + expect(requestInitialStory).not.toHaveBeenCalled(); + expect(requestNextStep).toHaveBeenCalledWith( + 'WUXIA', + character, + [], + history, + '继续交谈', + expect.objectContaining({ + sceneId: 'inn_room', + lastFunctionId: 'npc_chat', + }), + { + availableOptions: localOptions, + }, + ); + expect(result).toEqual({ + text: '服务端续写完成', + options: [createOption('npc_help', '顺势追问')], + }); + }); +}); diff --git a/src/hooks/story/storyRequestCoordinator.ts b/src/hooks/story/storyRequestCoordinator.ts new file mode 100644 index 00000000..df841027 --- /dev/null +++ b/src/hooks/story/storyRequestCoordinator.ts @@ -0,0 +1,196 @@ +import type { + StoryGenerationContext, + StoryRequestOptions, +} from '../../services/aiService'; +import { shouldUseServerRuntimeOptions } from '../../services/runtimeStoryService'; +import type { + AIResponse, + Character, + GameState, + SceneHostileNpc, + StoryMoment, + StoryOption, + WorldType, +} from '../../types'; +import { loadServerRuntimeOptionCatalog } from './runtimeStoryCoordinator'; + +type BuildStoryContextFromState = ( + state: GameState, + extras?: { + lastFunctionId?: string | null; + }, +) => StoryGenerationContext; + +type BuildStoryFromResponse = ( + state: GameState, + character: Character, + response: StoryMoment, + availableOptions: StoryOption[] | null, + optionCatalog?: StoryOption[] | null, +) => StoryMoment; + +type GetAvailableOptionsForState = ( + state: GameState, + character: Character, +) => StoryOption[] | null; + +type GetStoryGenerationHostileNpcs = (state: GameState) => SceneHostileNpc[]; + +type RequestInitialStory = ( + worldType: WorldType, + character: Character, + monsters: SceneHostileNpc[], + context: StoryGenerationContext, + requestOptions?: StoryRequestOptions, +) => Promise; + +type RequestNextStep = ( + worldType: WorldType, + character: Character, + monsters: SceneHostileNpc[], + history: StoryMoment[], + choice: string, + context: StoryGenerationContext, + requestOptions?: StoryRequestOptions, +) => Promise; + +type LoadRuntimeOptionCatalog = typeof loadServerRuntimeOptionCatalog; + +export type ResolvedStoryRequestOptions = { + availableOptions: StoryOption[] | null; + optionCatalog: StoryOption[] | null; +}; + +export async function resolveStoryRequestOptions(params: { + state: GameState; + character: Character; + currentStory: StoryMoment | null; + optionCatalog?: StoryOption[] | null; + getAvailableOptionsForState: GetAvailableOptionsForState; + loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog; + onServerOptionCatalogLoadError?: (error: unknown) => void; +}) { + let optionCatalog = + params.optionCatalog && params.optionCatalog.length > 0 + ? params.optionCatalog + : null; + let availableOptions = optionCatalog + ? null + : params.getAvailableOptionsForState(params.state, params.character); + + if (optionCatalog || !shouldUseServerRuntimeOptions(availableOptions)) { + return { + availableOptions, + optionCatalog, + } satisfies ResolvedStoryRequestOptions; + } + + try { + const serverOptionCatalog = await ( + params.loadRuntimeOptionCatalog ?? loadServerRuntimeOptionCatalog + )({ + gameState: params.state, + currentStory: params.currentStory, + }); + + if (serverOptionCatalog && serverOptionCatalog.length > 0) { + optionCatalog = serverOptionCatalog; + availableOptions = null; + } + } catch (error) { + params.onServerOptionCatalogLoadError?.(error); + } + + return { + availableOptions, + optionCatalog, + } satisfies ResolvedStoryRequestOptions; +} + +export function buildAiStoryRequestOptions( + options: ResolvedStoryRequestOptions, +) { + if (options.availableOptions) { + return { + availableOptions: options.availableOptions, + }; + } + + if (options.optionCatalog) { + return { + optionCatalog: options.optionCatalog, + }; + } + + return undefined; +} + +export async function generateStoryForStateWithCoordinator(params: { + state: GameState; + character: Character; + history: StoryMoment[]; + currentStory: StoryMoment | null; + choice?: string; + lastFunctionId?: string | null; + optionCatalog?: StoryOption[] | null; + getAvailableOptionsForState: GetAvailableOptionsForState; + getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs; + buildStoryContextFromState: BuildStoryContextFromState; + buildStoryFromResponse: BuildStoryFromResponse; + requestInitialStory: RequestInitialStory; + requestNextStep: RequestNextStep; + loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog; + onServerOptionCatalogLoadError?: (error: unknown) => void; +}) { + if (!params.state.worldType) { + throw new Error( + 'The current world is not initialized, so story generation cannot continue.', + ); + } + const worldType = params.state.worldType; + + const resolvedOptions = await resolveStoryRequestOptions({ + state: params.state, + character: params.character, + currentStory: params.currentStory, + optionCatalog: params.optionCatalog, + getAvailableOptionsForState: params.getAvailableOptionsForState, + loadRuntimeOptionCatalog: params.loadRuntimeOptionCatalog, + onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError, + }); + const requestOptions = buildAiStoryRequestOptions(resolvedOptions); + const monsters = params.getStoryGenerationHostileNpcs(params.state); + const context = params.choice + ? params.buildStoryContextFromState(params.state, { + lastFunctionId: params.lastFunctionId, + }) + : params.buildStoryContextFromState(params.state); + const response = params.choice + ? await params.requestNextStep( + worldType, + params.character, + monsters, + params.history, + params.choice, + context, + requestOptions, + ) + : await params.requestInitialStory( + worldType, + params.character, + monsters, + context, + requestOptions, + ); + + return params.buildStoryFromResponse( + params.state, + params.character, + { + text: response.storyText, + options: response.options, + }, + resolvedOptions.availableOptions, + resolvedOptions.optionCatalog, + ); +} diff --git a/src/hooks/story/storyRequestRuntime.test.ts b/src/hooks/story/storyRequestRuntime.test.ts new file mode 100644 index 00000000..71683663 --- /dev/null +++ b/src/hooks/story/storyRequestRuntime.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + Character, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import { createGenerateStoryForState } from './storyRequestRuntime'; + +const { generateStoryForStateWithCoordinatorMock } = vi.hoisted(() => ({ + generateStoryForStateWithCoordinatorMock: vi.fn(), +})); + +vi.mock('./storyRequestCoordinator', () => ({ + generateStoryForStateWithCoordinator: + generateStoryForStateWithCoordinatorMock, +})); + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '在风声里辨认危险的旅人。', + backstory: '长年行走江湖。', + avatar: '/hero.png', + portrait: '/hero-portrait.png', + assetFolder: 'hero', + assetVariant: 'default', + attributes: { + strength: 10, + agility: 10, + intelligence: 8, + spirit: 9, + }, + personality: '谨慎而果断', + skills: [], + adventureOpenings: {}, + } as unknown as Character; +} + +function createGameState(): GameState { + return { + worldType: 'WUXIA', + currentScene: 'Story', + } as GameState; +} + +function createStory(text: string): StoryMoment { + return { + text, + options: [], + }; +} + +function createOption( + functionId = 'npc_chat', + actionText = '继续交谈', +): StoryOption { + return { + functionId, + actionText, + text: actionText, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + } as StoryOption; +} + +describe('storyRequestRuntime', () => { + it('forwards runtime request dependencies and currentStory into the coordinator', async () => { + const currentStory = createStory('当前故事'); + const getAvailableOptionsForState = vi.fn(() => [createOption()]); + const getStoryGenerationHostileNpcs = vi.fn(() => []); + const buildStoryContextFromState = vi.fn(); + const buildStoryFromResponse = vi.fn(); + const requestInitialStory = vi.fn(); + const requestNextStep = vi.fn(); + const onServerOptionCatalogLoadError = vi.fn(); + const state = createGameState(); + const character = createCharacter(); + const history = [createStory('上一轮剧情')]; + + generateStoryForStateWithCoordinatorMock.mockResolvedValue({ + text: '生成完成', + options: [], + }); + + const generateStoryForState = createGenerateStoryForState({ + currentStory, + getAvailableOptionsForState, + getStoryGenerationHostileNpcs, + buildStoryContextFromState, + buildStoryFromResponse, + requestInitialStory, + requestNextStep, + onServerOptionCatalogLoadError, + }); + + const result = await generateStoryForState({ + state, + character, + history, + choice: '继续交谈', + lastFunctionId: 'npc_chat', + optionCatalog: [createOption('npc_help', '请求援手')], + }); + + expect(generateStoryForStateWithCoordinatorMock).toHaveBeenCalledWith({ + state, + character, + history, + currentStory, + choice: '继续交谈', + lastFunctionId: 'npc_chat', + optionCatalog: [createOption('npc_help', '请求援手')], + getAvailableOptionsForState, + getStoryGenerationHostileNpcs, + buildStoryContextFromState, + buildStoryFromResponse, + requestInitialStory, + requestNextStep, + onServerOptionCatalogLoadError, + }); + expect(result).toEqual({ + text: '生成完成', + options: [], + }); + }); +}); diff --git a/src/hooks/story/storyRequestRuntime.ts b/src/hooks/story/storyRequestRuntime.ts new file mode 100644 index 00000000..247ae584 --- /dev/null +++ b/src/hooks/story/storyRequestRuntime.ts @@ -0,0 +1,69 @@ +import type { + Character, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import type { GenerateStoryForState } from './progressionActions'; +import { generateStoryForStateWithCoordinator } from './storyRequestCoordinator'; + +type GetAvailableOptionsForState = ( + state: GameState, + character: Character, +) => StoryOption[] | null; + +type BuildStoryContextFromState = Parameters< + typeof generateStoryForStateWithCoordinator +>[0]['buildStoryContextFromState']; + +type BuildStoryFromResponse = Parameters< + typeof generateStoryForStateWithCoordinator +>[0]['buildStoryFromResponse']; + +type GetStoryGenerationHostileNpcs = Parameters< + typeof generateStoryForStateWithCoordinator +>[0]['getStoryGenerationHostileNpcs']; + +type RequestInitialStory = Parameters< + typeof generateStoryForStateWithCoordinator +>[0]['requestInitialStory']; + +type RequestNextStep = Parameters< + typeof generateStoryForStateWithCoordinator +>[0]['requestNextStep']; + +export function createGenerateStoryForState(params: { + currentStory: StoryMoment | null; + getAvailableOptionsForState: GetAvailableOptionsForState; + getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs; + buildStoryContextFromState: BuildStoryContextFromState; + buildStoryFromResponse: BuildStoryFromResponse; + requestInitialStory: RequestInitialStory; + requestNextStep: RequestNextStep; + onServerOptionCatalogLoadError?: (error: unknown) => void; +}): GenerateStoryForState { + return async ({ + state, + character, + history, + choice, + lastFunctionId, + optionCatalog, + }) => + generateStoryForStateWithCoordinator({ + state, + character, + history, + currentStory: params.currentStory, + choice, + lastFunctionId, + optionCatalog, + getAvailableOptionsForState: params.getAvailableOptionsForState, + getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs, + buildStoryContextFromState: params.buildStoryContextFromState, + buildStoryFromResponse: params.buildStoryFromResponse, + requestInitialStory: params.requestInitialStory, + requestNextStep: params.requestNextStep, + onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError, + }); +} diff --git a/src/hooks/story/storyRuntimeSupport.test.ts b/src/hooks/story/storyRuntimeSupport.test.ts new file mode 100644 index 00000000..a1be568e --- /dev/null +++ b/src/hooks/story/storyRuntimeSupport.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import type { GameState, InventoryItem } from '../../types'; +import { + cloneInventoryItemForOwner, + updateQuestLog, + updateRuntimeStats, +} from './storyRuntimeSupport'; + +function createGameState(): GameState { + return { + worldType: 'WUXIA', + customWorldProfile: null, + playerCharacter: null, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'Story', + storyHistory: [], + characterChats: {}, + animationState: 'idle', + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 1, + playerMaxHp: 1, + playerMana: 1, + playerMaxMana: 1, + playerSkillCooldowns: {}, + activeCombatEffects: [], + playerCurrency: 0, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: {}, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + } as GameState; +} + +describe('storyRuntimeSupport', () => { + it('preserves identity-sensitive inventory items when cloning for another owner', () => { + const item = { + id: 'artifact-1', + category: '饰品', + name: '旧日秘匣', + quantity: 1, + rarity: 'epic', + tags: ['relic'], + runtimeMetadata: { + seedKey: 'artifact-seed', + }, + } as InventoryItem; + + expect(cloneInventoryItemForOwner(item, 'npc', 2)).toEqual( + expect.objectContaining({ + id: 'npc:artifact-1:2', + quantity: 2, + runtimeMetadata: expect.objectContaining({ + seedKey: 'artifact-seed:npc', + }), + }), + ); + }); + + it('uses synthetic ids for ordinary stackable items when cloning for another owner', () => { + const item = { + id: 'potion-1', + category: '消耗品', + name: '回气散', + quantity: 3, + rarity: 'common', + tags: ['healing'], + } as InventoryItem; + + expect(cloneInventoryItemForOwner(item, 'player')).toEqual( + expect.objectContaining({ + id: `player:${encodeURIComponent('消耗品-回气散')}`, + quantity: 1, + }), + ); + }); + + it('updates quest logs and runtime stats without keeping that logic in the main hook', () => { + const initialState = createGameState(); + const withQuest = updateQuestLog(initialState, () => [ + { + id: 'quest-1', + }, + ] as GameState['quests']); + const withStats = updateRuntimeStats(withQuest, { + itemsUsed: 2, + scenesTraveled: 1, + }); + + expect(withStats.quests).toEqual([{ id: 'quest-1' }]); + expect(withStats.runtimeStats.itemsUsed).toBe(2); + expect(withStats.runtimeStats.scenesTraveled).toBe(1); + }); +}); diff --git a/src/hooks/story/storyRuntimeSupport.ts b/src/hooks/story/storyRuntimeSupport.ts new file mode 100644 index 00000000..5e1dbb50 --- /dev/null +++ b/src/hooks/story/storyRuntimeSupport.ts @@ -0,0 +1,127 @@ +import { + buildInitialNpcState, + buildNpcEncounterStoryMoment, + normalizeNpcPersistentState, +} from '../../data/npcInteractions'; +import { incrementGameRuntimeStats } from '../../data/runtimeStats'; +import { syncNpcNarrativeState } from '../../services/storyEngine/echoMemory'; +import type { + Character, + Encounter, + GameState, + InventoryItem, +} from '../../types'; +import { getNpcEncounterKey } from './storyGenerationState'; + +export function cloneInventoryItemForOwner( + item: InventoryItem, + owner: 'player' | 'npc', + quantity = 1, +) { + const preserveIdentity = Boolean( + item.runtimeMetadata || + item.buildProfile || + item.equipmentSlotId || + item.statProfile || + item.attributeResonance, + ); + + return { + ...item, + id: preserveIdentity + ? `${owner}:${item.id}:${quantity}` + : `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`, + quantity, + runtimeMetadata: item.runtimeMetadata + ? { + ...item.runtimeMetadata, + seedKey: `${item.runtimeMetadata.seedKey}:${owner}`, + } + : item.runtimeMetadata, + }; +} + +export function getResolvedNpcState( + state: GameState, + encounter: Encounter, +) { + return ( + state.npcStates[getNpcEncounterKey(encounter)] ?? + buildInitialNpcState(encounter, state.worldType, state) + ); +} + +export function buildNpcStory( + state: GameState, + character: Character, + encounter: Encounter, + overrideText?: string, +) { + return buildNpcEncounterStoryMoment({ + state, + encounter, + npcState: getResolvedNpcState(state, encounter), + playerCharacter: character, + playerInventory: state.playerInventory, + activeQuests: state.quests, + scene: state.currentScenePreset, + partySize: state.companions.length, + overrideText, + worldType: state.worldType, + }); +} + +export function updateNpcState( + state: GameState, + encounter: Encounter, + updater: ( + npcState: ReturnType, + ) => ReturnType, +) { + return { + ...state, + npcStates: { + ...state.npcStates, + [getNpcEncounterKey(encounter)]: normalizeNpcPersistentState( + syncNpcNarrativeState({ + encounter, + npcState: updater(getResolvedNpcState(state, encounter)), + customWorldProfile: state.customWorldProfile, + storyEngineMemory: state.storyEngineMemory, + }), + ), + }, + }; +} + +export function updateQuestLog( + state: GameState, + updater: (quests: GameState['quests']) => GameState['quests'], +) { + return { + ...state, + quests: updater(state.quests), + }; +} + +export function updateRuntimeStats( + state: GameState, + increments: Parameters[1], +) { + return { + ...state, + runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments), + }; +} + +export const storyRuntimeSupport = { + cloneInventoryItemForOwner, + getNpcEncounterKey, + getResolvedNpcState, + buildNpcStory, + updateNpcState, + updateQuestLog, + updateRuntimeStats, +}; + +export type StoryRuntimeSupport = typeof storyRuntimeSupport; diff --git a/src/hooks/story/useStoryChoiceCoordinator.test.ts b/src/hooks/story/useStoryChoiceCoordinator.test.ts new file mode 100644 index 00000000..3bb6f49d --- /dev/null +++ b/src/hooks/story/useStoryChoiceCoordinator.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createClearStoryChoiceUi } from './useStoryChoiceCoordinator'; + +describe('useStoryChoiceCoordinator helpers', () => { + it('clears choice ui by dismissing battle reward', () => { + const calls: string[] = []; + const clearStoryChoiceUi = createClearStoryChoiceUi({ + clearBattleReward: vi.fn(() => calls.push('battle')), + }); + + clearStoryChoiceUi(); + + expect(calls).toEqual(['battle']); + }); +}); diff --git a/src/hooks/story/useStoryChoiceCoordinator.ts b/src/hooks/story/useStoryChoiceCoordinator.ts new file mode 100644 index 00000000..b53f38fa --- /dev/null +++ b/src/hooks/story/useStoryChoiceCoordinator.ts @@ -0,0 +1,140 @@ +import { useCallback, useState } from 'react'; + +import { createStoryChoiceActions } from './choiceActions'; +import { + createStoryChoiceCoordinatorConfig, + type ChoiceRuntimeController, + type ChoiceRuntimeSupport, +} from './storyChoiceCoordinator'; +import type { BattleRewardSummary } from './uiTypes'; +import type { + Character, + Encounter, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import type { ResolvedChoiceState } from '../combat/resolvedChoice'; +import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow'; + +type StoryChoiceCoordinatorParams = { + gameState: GameState; + isLoading: boolean; + setGameState: Parameters[0]['setGameState']; + setCurrentStory: Parameters< + typeof createStoryChoiceActions + >[0]['setCurrentStory']; + setAiError: Parameters[0]['setAiError']; + setIsLoading: Parameters[0]['setIsLoading']; + buildResolvedChoiceState: ( + state: GameState, + option: StoryOption, + character: Character, + ) => ResolvedChoiceState; + playResolvedChoice: ( + state: GameState, + option: StoryOption, + character: Character, + resolvedChoice: ResolvedChoiceState, + sync?: ResolvedChoicePlaybackSync, + ) => Promise; + getStoryGenerationHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + getResolvedSceneHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + runtimeController: ChoiceRuntimeController & { + currentStory: StoryMoment | null; + }; + runtimeSupport: ChoiceRuntimeSupport; + enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean; + handleNpcInteraction: (option: StoryOption) => boolean | Promise; + handleTreasureInteraction: ( + option: StoryOption, + ) => void | Promise | boolean | Promise; + finalizeNpcBattleResult: Parameters< + typeof createStoryChoiceCoordinatorConfig + >[0]['finalizeNpcBattleResult']; + sortOptions: (options: StoryOption[]) => StoryOption[]; + buildContinueAdventureOption: () => StoryOption; + isContinueAdventureOption: (option: StoryOption) => boolean; + isCampTravelHomeOption: (option: StoryOption) => boolean; + isInitialCompanionEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isRegularNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + npcPreviewTalkFunctionId: string; + fallbackCompanionName: string; + turnVisualMs: number; +}; + +export function createClearStoryChoiceUi(params: { + clearBattleReward: () => void; +}) { + return () => { + params.clearBattleReward(); + }; +} + +export function useStoryChoiceCoordinator( + params: StoryChoiceCoordinatorParams, +) { + const [battleReward, setBattleReward] = useState( + null, + ); + + const { handleChoice } = createStoryChoiceActions( + createStoryChoiceCoordinatorConfig({ + gameState: params.gameState, + currentStory: params.runtimeController.currentStory, + isLoading: params.isLoading, + setGameState: params.setGameState, + setCurrentStory: params.setCurrentStory, + setAiError: params.setAiError, + setIsLoading: params.setIsLoading, + setBattleReward, + buildResolvedChoiceState: params.buildResolvedChoiceState, + playResolvedChoice: params.playResolvedChoice, + getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs, + getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs, + runtimeController: params.runtimeController, + runtimeSupport: params.runtimeSupport, + enterNpcInteraction: params.enterNpcInteraction, + handleNpcInteraction: params.handleNpcInteraction, + handleTreasureInteraction: params.handleTreasureInteraction, + finalizeNpcBattleResult: params.finalizeNpcBattleResult, + sortOptions: params.sortOptions, + buildContinueAdventureOption: params.buildContinueAdventureOption, + isContinueAdventureOption: params.isContinueAdventureOption, + isCampTravelHomeOption: params.isCampTravelHomeOption, + isInitialCompanionEncounter: params.isInitialCompanionEncounter, + isRegularNpcEncounter: params.isRegularNpcEncounter, + isNpcEncounter: params.isNpcEncounter, + npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId, + fallbackCompanionName: params.fallbackCompanionName, + turnVisualMs: params.turnVisualMs, + }), + ); + + const clearStoryChoiceUi = useCallback( + createClearStoryChoiceUi({ + clearBattleReward: () => setBattleReward(null), + }), + [], + ); + + return { + handleChoice, + battleRewardUi: { + reward: battleReward, + dismiss: () => setBattleReward(null), + }, + clearStoryChoiceUi, + }; +} diff --git a/src/hooks/story/useStoryFlowCoordinator.ts b/src/hooks/story/useStoryFlowCoordinator.ts new file mode 100644 index 00000000..61f7c942 --- /dev/null +++ b/src/hooks/story/useStoryFlowCoordinator.ts @@ -0,0 +1,190 @@ +import type { Dispatch, SetStateAction } from 'react'; + +import type { Character, Encounter, GameState, StoryOption } from '../../types'; +import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow'; +import type { ResolvedChoiceState } from '../combat/resolvedChoice'; +import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator'; +import { sanitizeStoryOptions } from './storyPresentation'; +import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator'; +import type { StoryRuntimeSupport } from './storyRuntimeSupport'; +import { useStoryGoalSessionCoordinator } from './useStoryGoalSessionCoordinator'; +import { useStoryInteractionCoordinator } from './useStoryInteractionCoordinator'; +import type { StoryRuntimeControllerResult } from './useStoryRuntimeController'; + +type StoryFlowCoordinatorParams = { + gameState: GameState; + setGameState: Dispatch>; + buildResolvedChoiceState: ( + state: GameState, + option: StoryOption, + character: Character, + ) => ResolvedChoiceState; + playResolvedChoice: ( + state: GameState, + option: StoryOption, + character: Character, + resolvedChoice: ResolvedChoiceState, + sync?: ResolvedChoicePlaybackSync, + ) => Promise; + getStoryGenerationHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + getResolvedSceneHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + runtimeController: StoryRuntimeControllerResult; + runtimeSupport: StoryRuntimeSupport; + sortOptions: (options: StoryOption[]) => StoryOption[]; + buildContinueAdventureOption: () => StoryOption; + resolveNpcInteractionDecision: ( + state: GameState, + option: StoryOption, + ) => { kind: string }; + clearCharacterChatModal: () => void; + isContinueAdventureOption: (option: StoryOption) => boolean; + isCampTravelHomeOption: (option: StoryOption) => boolean; + isInitialCompanionEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isRegularNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + npcPreviewTalkFunctionId: string; + fallbackCompanionName: string; + turnVisualMs: number; +}; + +export function useStoryFlowCoordinator({ + gameState, + setGameState, + buildResolvedChoiceState, + playResolvedChoice, + getStoryGenerationHostileNpcs, + getResolvedSceneHostileNpcs, + runtimeController, + runtimeSupport, + sortOptions, + buildContinueAdventureOption, + resolveNpcInteractionDecision, + clearCharacterChatModal, + isContinueAdventureOption, + isCampTravelHomeOption, + isInitialCompanionEncounter, + isRegularNpcEncounter, + isNpcEncounter, + npcPreviewTalkFunctionId, + fallbackCompanionName, + turnVisualMs, +}: StoryFlowCoordinatorParams) { + const { + currentStory, + setCurrentStory, + setAiError, + setIsLoading, + isLoading, + buildStoryContextFromState, + buildFallbackStoryForState, + buildDialogueStoryMoment, + generateStoryForState, + getAvailableOptionsForState, + getTypewriterDelay, + commitGeneratedState, + commitGeneratedStateWithEncounterEntry, + appendHistory, + buildOpeningCampChatContext, + resetPreparedOpeningAdventure, + } = runtimeController; + const interactionConfig = createStoryInteractionCoordinatorConfig({ + gameState, + setGameState, + setCurrentStory, + setAiError, + setIsLoading, + currentStory, + buildStoryContextFromState, + buildFallbackStoryForState, + buildDialogueStoryMoment, + generateStoryForState, + getAvailableOptionsForState, + getStoryGenerationHostileNpcs, + getTypewriterDelay, + runtimeSupport, + commitGeneratedState, + commitGeneratedStateWithEncounterEntry, + appendHistory, + buildOpeningCampChatContext, + sortOptions, + buildContinueAdventureOption, + sanitizeOptions: sanitizeStoryOptions, + resolveNpcInteractionDecision, + }); + const { + displayedOptions, + canRefreshOptions, + handleRefreshOptions, + goalUi, + clearStoryGoalOptionUi, + } = useStoryGoalOptionCoordinator({ + gameState, + currentStory, + }); + const { + handleChoice, + battleRewardUi, + npcUi, + inventoryUi, + clearStoryInteractionUi, + } = useStoryInteractionCoordinator({ + gameState, + isLoading, + interactionConfig, + runtimeSupport, + buildResolvedChoiceState, + playResolvedChoice, + buildStoryFromResponse: runtimeController.buildStoryFromResponse, + getResolvedSceneHostileNpcs, + getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene, + startOpeningAdventure: runtimeController.startOpeningAdventure, + isContinueAdventureOption, + isCampTravelHomeOption, + isInitialCompanionEncounter, + isRegularNpcEncounter, + isNpcEncounter, + npcPreviewTalkFunctionId, + fallbackCompanionName, + turnVisualMs, + }); + const { questUi, resetStoryState, hydrateStoryState, travelToSceneFromMap } = + useStoryGoalSessionCoordinator({ + gameState, + isLoading, + setGameState, + setCurrentStory, + setAiError, + setIsLoading, + commitGeneratedState, + buildFallbackStoryForState, + resetPreparedOpeningAdventure, + clearStoryGoalOptionUi, + clearStoryInteractionUi, + clearCharacterChatModal, + }); + + return { + displayedOptions, + canRefreshOptions, + handleRefreshOptions, + handleChoice, + resetStoryState, + hydrateStoryState, + travelToSceneFromMap, + battleRewardUi, + questUi, + goalUi, + npcUi, + inventoryUi, + }; +} diff --git a/src/hooks/story/useStoryGoalOptionCoordinator.test.ts b/src/hooks/story/useStoryGoalOptionCoordinator.test.ts new file mode 100644 index 00000000..76b34553 --- /dev/null +++ b/src/hooks/story/useStoryGoalOptionCoordinator.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createClearStoryGoalOptionUi } from './useStoryGoalOptionCoordinator'; + +describe('useStoryGoalOptionCoordinator helpers', () => { + it('clears story goal and option ui together', () => { + const calls: string[] = []; + const clearStoryGoalOptionUi = createClearStoryGoalOptionUi({ + resetStoryOptions: vi.fn(() => calls.push('options')), + resetGoalPulseTracking: vi.fn(() => calls.push('goal')), + }); + + clearStoryGoalOptionUi(); + + expect(calls).toEqual(['options', 'goal']); + }); +}); diff --git a/src/hooks/story/useStoryGoalOptionCoordinator.ts b/src/hooks/story/useStoryGoalOptionCoordinator.ts new file mode 100644 index 00000000..d34eb331 --- /dev/null +++ b/src/hooks/story/useStoryGoalOptionCoordinator.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react'; + +import type { GameState, StoryMoment } from '../../types'; +import { useStoryOptions } from '../useStoryOptions'; +import { useStoryGoalFlow } from './goalFlow'; + +export function createClearStoryGoalOptionUi(params: { + resetStoryOptions: () => void; + resetGoalPulseTracking: () => void; +}) { + return () => { + params.resetStoryOptions(); + params.resetGoalPulseTracking(); + }; +} + +export function useStoryGoalOptionCoordinator(params: { + gameState: GameState; + currentStory: StoryMoment | null; +}) { + const { runtimeGoalStack, goalUi, resetGoalPulseTracking } = useStoryGoalFlow( + params.gameState, + ); + const { + displayedOptions, + canRefreshOptions, + handleRefreshOptions, + resetStoryOptions, + } = useStoryOptions(params.currentStory, runtimeGoalStack); + + const clearStoryGoalOptionUi = useCallback( + createClearStoryGoalOptionUi({ + resetStoryOptions, + resetGoalPulseTracking, + }), + [resetGoalPulseTracking, resetStoryOptions], + ); + + return { + displayedOptions, + canRefreshOptions, + handleRefreshOptions, + goalUi, + clearStoryGoalOptionUi, + }; +} + +export type StoryGoalOptionCoordinatorResult = ReturnType< + typeof useStoryGoalOptionCoordinator +>; diff --git a/src/hooks/story/useStoryGoalSessionCoordinator.test.ts b/src/hooks/story/useStoryGoalSessionCoordinator.test.ts new file mode 100644 index 00000000..80a64ace --- /dev/null +++ b/src/hooks/story/useStoryGoalSessionCoordinator.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createClearStoryRuntimeUi } from './useStoryGoalSessionCoordinator'; + +describe('useStoryGoalSessionCoordinator helpers', () => { + it('clears story runtime ui in the expected order', () => { + const calls: string[] = []; + const clearStoryRuntimeUi = createClearStoryRuntimeUi({ + clearStoryGoalOptionUi: vi.fn(() => calls.push('goal-option')), + clearStoryInteractionUi: vi.fn(() => calls.push('interaction')), + setAiError: vi.fn((value) => calls.push(`ai:${String(value)}`)), + setIsLoading: vi.fn((value) => calls.push(`loading:${String(value)}`)), + resetPreparedOpeningAdventure: vi.fn(() => calls.push('opening')), + clearCharacterChatModal: vi.fn(() => calls.push('chat')), + }); + + clearStoryRuntimeUi(); + + expect(calls).toEqual([ + 'goal-option', + 'interaction', + 'ai:null', + 'loading:false', + 'opening', + 'chat', + ]); + }); +}); diff --git a/src/hooks/story/useStoryGoalSessionCoordinator.ts b/src/hooks/story/useStoryGoalSessionCoordinator.ts new file mode 100644 index 00000000..ee13f478 --- /dev/null +++ b/src/hooks/story/useStoryGoalSessionCoordinator.ts @@ -0,0 +1,94 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react'; + +import type { StoryMoment, GameState, Character } from '../../types'; +import type { CommitGeneratedState } from '../generatedState'; +import { createStorySessionActions } from './sessionActions'; + +type BuildFallbackStoryForState = ( + state: GameState, + character: Character, + fallbackText?: string, +) => StoryMoment; + +export function createClearStoryRuntimeUi(params: { + clearStoryGoalOptionUi: () => void; + clearStoryInteractionUi: () => void; + setAiError: Dispatch>; + setIsLoading: Dispatch>; + resetPreparedOpeningAdventure: () => void; + clearCharacterChatModal: () => void; +}) { + return () => { + params.clearStoryGoalOptionUi(); + params.clearStoryInteractionUi(); + params.setAiError(null); + params.setIsLoading(false); + params.resetPreparedOpeningAdventure(); + params.clearCharacterChatModal(); + }; +} + +export function useStoryGoalSessionCoordinator(params: { + gameState: GameState; + isLoading: boolean; + setGameState: Dispatch>; + setCurrentStory: Dispatch>; + setAiError: Dispatch>; + setIsLoading: Dispatch>; + commitGeneratedState: CommitGeneratedState; + buildFallbackStoryForState: BuildFallbackStoryForState; + resetPreparedOpeningAdventure: () => void; + clearStoryGoalOptionUi: () => void; + clearStoryInteractionUi: () => void; + clearCharacterChatModal: () => void; +}) { + const clearStoryRuntimeUi = useCallback( + createClearStoryRuntimeUi({ + clearStoryGoalOptionUi: params.clearStoryGoalOptionUi, + clearStoryInteractionUi: params.clearStoryInteractionUi, + setAiError: params.setAiError, + setIsLoading: params.setIsLoading, + resetPreparedOpeningAdventure: params.resetPreparedOpeningAdventure, + clearCharacterChatModal: params.clearCharacterChatModal, + }), + [ + params.clearCharacterChatModal, + params.clearStoryGoalOptionUi, + params.clearStoryInteractionUi, + params.resetPreparedOpeningAdventure, + params.setAiError, + params.setIsLoading, + ], + ); + + const { + acknowledgeQuestCompletion, + claimQuestReward, + resetStoryState, + hydrateStoryState, + travelToSceneFromMap, + } = createStorySessionActions({ + gameState: params.gameState, + isLoading: params.isLoading, + setGameState: params.setGameState, + setCurrentStory: params.setCurrentStory, + clearStoryRuntimeUi, + commitGeneratedState: params.commitGeneratedState, + buildFallbackStoryForState: params.buildFallbackStoryForState, + }); + + return { + questUi: { + acknowledgeQuestCompletion, + claimQuestReward, + }, + resetStoryState, + hydrateStoryState, + travelToSceneFromMap, + clearStoryRuntimeUi, + }; +} + +export type StoryGoalSessionCoordinatorResult = ReturnType< + typeof useStoryGoalSessionCoordinator +>; diff --git a/src/hooks/story/useStoryInteractionCoordinator.test.ts b/src/hooks/story/useStoryInteractionCoordinator.test.ts new file mode 100644 index 00000000..55672dd6 --- /dev/null +++ b/src/hooks/story/useStoryInteractionCoordinator.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createClearStoryInteractionUi } from './useStoryInteractionCoordinator'; + +describe('useStoryInteractionCoordinator helpers', () => { + it('clears interaction ui in the expected order', () => { + const calls: string[] = []; + const clearStoryInteractionUi = createClearStoryInteractionUi({ + clearStoryChoiceUi: vi.fn(() => calls.push('choice')), + clearNpcInteractionUi: vi.fn(() => calls.push('npc')), + }); + + clearStoryInteractionUi(); + + expect(calls).toEqual(['choice', 'npc']); + }); +}); diff --git a/src/hooks/story/useStoryInteractionCoordinator.ts b/src/hooks/story/useStoryInteractionCoordinator.ts new file mode 100644 index 00000000..2f0b8792 --- /dev/null +++ b/src/hooks/story/useStoryInteractionCoordinator.ts @@ -0,0 +1,203 @@ +import { useCallback } from 'react'; + +import type { + Character, + Encounter, + GameState, + StoryMoment, + StoryOption, +} from '../../types'; +import type { ResolvedChoiceState } from '../combat/resolvedChoice'; +import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow'; +import { useTreasureFlow } from '../useTreasureFlow'; +import { useStoryInventoryActions } from './inventoryActions'; +import { createStoryNpcEncounterActions } from './npcEncounterActions'; +import { useStoryNpcInteractionFlow } from './npcInteraction'; +import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator'; +import type { StoryRuntimeSupport } from './storyRuntimeSupport'; +import type { + ChoiceRuntimeController, + StoryChoiceCoordinatorParams, +} from './storyChoiceCoordinator'; +import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator'; + +type StoryInteractionCoordinatorParams = { + gameState: GameState; + isLoading: boolean; + interactionConfig: StoryInteractionCoordinatorConfig; + runtimeSupport: StoryRuntimeSupport; + buildResolvedChoiceState: ( + state: GameState, + option: StoryOption, + character: Character, + ) => ResolvedChoiceState; + playResolvedChoice: ( + state: GameState, + option: StoryOption, + character: Character, + resolvedChoice: ResolvedChoiceState, + sync?: ResolvedChoicePlaybackSync, + ) => Promise; + buildStoryFromResponse: ChoiceRuntimeController['buildStoryFromResponse']; + getResolvedSceneHostileNpcs: ( + state: GameState, + ) => GameState['sceneHostileNpcs']; + getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene']; + startOpeningAdventure: StoryChoiceCoordinatorParams['runtimeController']['startOpeningAdventure']; + isContinueAdventureOption: (option: StoryOption) => boolean; + isCampTravelHomeOption: (option: StoryOption) => boolean; + isInitialCompanionEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isRegularNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + isNpcEncounter: ( + encounter: GameState['currentEncounter'], + ) => encounter is Encounter; + npcPreviewTalkFunctionId: string; + fallbackCompanionName: string; + turnVisualMs: number; +}; + +export function createClearStoryInteractionUi(params: { + clearStoryChoiceUi: () => void; + clearNpcInteractionUi: () => void; +}) { + return () => { + params.clearStoryChoiceUi(); + params.clearNpcInteractionUi(); + }; +} + +export function useStoryInteractionCoordinator({ + gameState, + isLoading, + interactionConfig, + runtimeSupport, + buildResolvedChoiceState, + playResolvedChoice, + buildStoryFromResponse, + getResolvedSceneHostileNpcs, + getCampCompanionTravelScene, + startOpeningAdventure, + isContinueAdventureOption, + isCampTravelHomeOption, + isInitialCompanionEncounter, + isRegularNpcEncounter, + isNpcEncounter, + npcPreviewTalkFunctionId, + fallbackCompanionName, + turnVisualMs, +}: StoryInteractionCoordinatorParams) { + const { buildNpcStory } = runtimeSupport; + + const { handleTreasureInteraction } = useTreasureFlow( + interactionConfig.treasureFlow, + ); + const { inventoryUi } = useStoryInventoryActions( + interactionConfig.inventoryFlow, + ); + const npcInteractionFlow = useStoryNpcInteractionFlow( + interactionConfig.npcInteractionFlow, + ); + const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } = + createStoryNpcEncounterActions({ + ...interactionConfig.npcEncounterActions, + npcInteractionFlow, + }); + const choiceRuntimeController: Parameters< + typeof useStoryChoiceCoordinator + >[0]['runtimeController'] = { + currentStory: interactionConfig.npcEncounterActions.currentStory, + buildStoryContextFromState: + interactionConfig.npcEncounterActions.buildStoryContextFromState, + buildStoryFromResponse: ( + state: GameState, + character: Character, + response: StoryMoment, + availableOptions: StoryOption[] | null, + optionCatalog?: StoryOption[] | null, + ) => + buildStoryFromResponse( + state, + character, + response, + availableOptions, + optionCatalog, + ), + buildFallbackStoryForState: + interactionConfig.npcEncounterActions.buildFallbackStoryForState, + generateStoryForState: async (params) => + interactionConfig.npcEncounterActions.generateStoryForState(params), + getAvailableOptionsForState: + interactionConfig.npcEncounterActions.getAvailableOptionsForState, + getCampCompanionTravelScene: (state, character) => + getCampCompanionTravelScene(state, character), + startOpeningAdventure: () => startOpeningAdventure(), + commitGeneratedStateWithEncounterEntry: async ( + entryState, + resolvedState, + character, + actionText, + resultText, + lastFunctionId, + ) => { + await interactionConfig.npcEncounterActions.commitGeneratedStateWithEncounterEntry( + entryState, + resolvedState, + character, + actionText, + resultText, + lastFunctionId, + ); + }, + }; + const { handleChoice, battleRewardUi, clearStoryChoiceUi } = + useStoryChoiceCoordinator({ + gameState, + isLoading, + setGameState: interactionConfig.npcEncounterActions.setGameState, + setCurrentStory: interactionConfig.npcEncounterActions.setCurrentStory, + setAiError: interactionConfig.npcEncounterActions.setAiError, + setIsLoading: interactionConfig.npcEncounterActions.setIsLoading, + buildResolvedChoiceState, + playResolvedChoice, + getStoryGenerationHostileNpcs: + interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs, + getResolvedSceneHostileNpcs, + runtimeController: choiceRuntimeController, + runtimeSupport, + enterNpcInteraction, + handleNpcInteraction, + handleTreasureInteraction, + finalizeNpcBattleResult, + sortOptions: interactionConfig.npcEncounterActions.sortOptions, + buildContinueAdventureOption: + interactionConfig.npcEncounterActions.buildContinueAdventureOption, + isContinueAdventureOption, + isCampTravelHomeOption, + isInitialCompanionEncounter, + isRegularNpcEncounter, + isNpcEncounter, + npcPreviewTalkFunctionId, + fallbackCompanionName, + turnVisualMs, + }); + + const clearStoryInteractionUi = useCallback( + createClearStoryInteractionUi({ + clearStoryChoiceUi, + clearNpcInteractionUi: npcInteractionFlow.clearNpcInteractionUi, + }), + [clearStoryChoiceUi, npcInteractionFlow.clearNpcInteractionUi], + ); + + return { + handleChoice, + battleRewardUi, + npcUi: npcInteractionFlow.npcUi, + inventoryUi, + clearStoryInteractionUi, + }; +} diff --git a/src/hooks/story/useStoryRuntimeController.ts b/src/hooks/story/useStoryRuntimeController.ts new file mode 100644 index 00000000..6fb6666e --- /dev/null +++ b/src/hooks/story/useStoryRuntimeController.ts @@ -0,0 +1,200 @@ +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; + +import { generateInitialStory, generateNextStep } from '../../services/aiService'; +import type { StoryGenerationContext } from '../../services/aiTypes'; +import type { Character, GameState, StoryMoment, StoryOption } from '../../types'; +import { buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState } from './openingAdventure'; +import { + appendStoryHistory, + createStoryProgressionActions, +} from './progressionActions'; +import { + buildCampCompanionOpeningResultText, + buildInitialCompanionDialogueText, + createCampCompanionStoryHelpers, +} from './storyCampCompanion'; +import { useStoryBootstrap } from './storyBootstrap'; +import { + createStoryStateResolvers, + getStoryGenerationHostileNpcs, + isInitialCompanionEncounter, + isNpcEncounter, +} from './storyEncounterState'; +import { getNpcEncounterKey } from './storyGenerationState'; +import { + buildDialogueStoryMoment, + buildStoryFromResponse as buildStoryFromResponseFromPresentation, + getTypewriterDelay, + hasRenderableDialogueTurns, +} from './storyPresentation'; +import { buildNpcStory } from './storyRuntimeSupport'; +import { createGenerateStoryForState } from './storyRequestRuntime'; +import type { StoryContextBuilderExtras } from './storyContextBuilder'; + +type BuildStoryContextFromState = ( + state: GameState, + extras?: StoryContextBuilderExtras, +) => StoryGenerationContext; + +export function useStoryRuntimeController(params: { + gameState: GameState; + setGameState: Dispatch>; + buildStoryContextFromState: BuildStoryContextFromState; +}) { + const { gameState, setGameState, buildStoryContextFromState } = params; + + const [currentStory, setCurrentStory] = useState(null); + const [aiError, setAiError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const { + getCampCompanionTravelScene, + buildCampCompanionOpeningOptions, + inferOpeningCampFollowupOptions, + buildOpeningCampChatContext, + buildCampCompanionIdleStory, + } = useMemo( + () => + createCampCompanionStoryHelpers({ + buildNpcStory, + buildStoryContextFromState, + getStoryGenerationHostileNpcs, + getNpcEncounterKey, + generateNextStep, + }), + [buildStoryContextFromState], + ); + + const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo( + () => + createStoryStateResolvers({ + buildCampCompanionIdleOptions: buildCampCompanionIdleStory, + buildNpcStory, + }), + [buildCampCompanionIdleStory], + ); + + const buildStoryFromResponse = useCallback( + ( + state: GameState, + character: Character, + response: StoryMoment, + availableOptions: StoryOption[] | null, + optionCatalog: StoryOption[] | null = null, + ) => + buildStoryFromResponseFromPresentation({ + state, + character, + response, + availableOptions, + optionCatalog, + }), + [], + ); + + const generateStoryForState = useMemo( + () => + createGenerateStoryForState({ + currentStory, + getAvailableOptionsForState, + getStoryGenerationHostileNpcs, + buildStoryContextFromState, + buildStoryFromResponse, + requestInitialStory: generateInitialStory, + requestNextStep: generateNextStep, + onServerOptionCatalogLoadError: (error) => { + console.warn( + '[useStoryGeneration] failed to load server runtime option catalog', + error, + ); + }, + }), + [ + buildStoryContextFromState, + buildStoryFromResponse, + currentStory, + getAvailableOptionsForState, + ], + ); + + const appendHistory = useCallback(appendStoryHistory, []); + + const prepareOpeningAdventure = useCallback( + (state: GameState, character: Character) => + buildPreparedOpeningAdventureState({ + state, + character, + getNpcEncounterKey, + appendHistory, + buildCampCompanionOpeningOptions, + buildCampCompanionOpeningResultText, + buildInitialCompanionDialogueText, + }), + [appendHistory, buildCampCompanionOpeningOptions], + ); + + const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } = + createStoryProgressionActions({ + gameState, + setGameState, + setCurrentStory, + setAiError, + setIsLoading, + generateStoryForState, + buildFallbackStoryForState, + }); + + const { + preparedOpeningAdventure, + startOpeningAdventure, + resetPreparedOpeningAdventure, + } = useStoryBootstrap({ + gameState, + currentStory, + isLoading, + setGameState, + setCurrentStory, + setAiError, + setIsLoading, + prepareOpeningAdventure, + getNpcEncounterKey, + buildFallbackStoryForState, + generateStoryForState, + buildDialogueStoryMoment, + buildStoryContextFromState, + getStoryGenerationHostileNpcs, + hasRenderableDialogueTurns, + inferOpeningCampFollowupOptions, + getTypewriterDelay, + isNpcEncounter, + isInitialCompanionEncounter, + }); + + return { + currentStory, + setCurrentStory, + aiError, + setAiError, + isLoading, + setIsLoading, + preparedOpeningAdventure, + startOpeningAdventure, + resetPreparedOpeningAdventure, + buildStoryContextFromState, + buildDialogueStoryMoment, + getTypewriterDelay, + getCampCompanionTravelScene, + buildOpeningCampChatContext, + getAvailableOptionsForState, + buildFallbackStoryForState, + buildStoryFromResponse, + generateStoryForState, + commitGeneratedState, + commitGeneratedStateWithEncounterEntry, + appendHistory, + }; +} + +export type StoryRuntimeControllerResult = ReturnType< + typeof useStoryRuntimeController +>; diff --git a/src/hooks/useGamePersistence.ts b/src/hooks/useGamePersistence.ts index 2ddd3b0f..22108055 100644 --- a/src/hooks/useGamePersistence.ts +++ b/src/hooks/useGamePersistence.ts @@ -1,150 +1,18 @@ -import {useCallback, useEffect, useState} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; -import { getCharacterMaxHp, getCharacterMaxMana } from '../data/characterPresets'; -import { normalizeRoster } from '../data/companionRoster'; -import { getInitialPlayerCurrency } from '../data/economy'; -import { - applyEquipmentLoadoutToState, - buildInitialEquipmentLoadout, - createEmptyEquipmentLoadout, -} from '../data/equipmentEffects'; -import { normalizeNpcPersistentState } from '../data/npcInteractions'; -import { normalizeQuestLogEntries } from '../data/questFlow'; -import { normalizeGameRuntimeStats } from '../data/runtimeStats'; -import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews'; -import type { SavedGameSnapshot } from '../persistence/gameSaveStorage'; +import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes'; +import { isAbortError } from '../services/apiClient'; import { deleteSaveSnapshot, getSaveSnapshot, putSaveSnapshot, } from '../services/storageService'; -import { - applyStoryEngineMigration, - buildSaveMigrationManifest, -} from '../services/storyEngine/saveMigrationManifest'; -import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine'; -import { GameState, StoryMoment } from '../types'; -import { BottomTab } from './useGameFlow'; +import type { GameState, StoryMoment } from '../types'; +import type { BottomTab } from './useGameFlow'; +import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator'; const AUTO_SAVE_DELAY_MS = 400; -function normalizeSavedStory(story: StoryMoment | null) { - if (!story) return null; - return { - ...story, - streaming: false, - } satisfies StoryMoment; -} - -function normalizeCharacterChats(gameState: GameState) { - const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [ - characterId, - { - history: Array.isArray(record?.history) - ? record.history - .filter(turn => turn && typeof turn.text === 'string' && (turn.speaker === 'player' || turn.speaker === 'character')) - .map(turn => ({ - speaker: turn.speaker, - text: turn.text, - })) - : [], - summary: typeof record?.summary === 'string' ? record.summary : '', - updatedAt: typeof record?.updatedAt === 'string' ? record.updatedAt : null, - }, - ] as const); - - return Object.fromEntries(entries); -} - -function normalizeSavedGameState(gameState: GameState) { - const migrationManifest = buildSaveMigrationManifest({ - version: 'story-engine-v5', - }); - const migratedState = applyStoryEngineMigration({ - state: gameState, - manifest: migrationManifest, - }); - const normalizedRoster = normalizeRoster(gameState.roster ?? [], gameState.companions ?? []); - const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && migratedState.currentEncounter?.kind === 'treasure' - ? ensureSceneEncounterPreview({ - ...migratedState, - currentEncounter: null, - sceneHostileNpcs: [], - inBattle: false, - } as GameState) - : migratedState; - const normalizedRuntimeStats = normalizeGameRuntimeStats(normalizedEncounterState.runtimeStats, { - isActiveRun: Boolean( - normalizedEncounterState.playerCharacter && - normalizedEncounterState.currentScene === 'Story' - ), - }); - const normalizedCommonState = { - ...normalizedEncounterState, - customWorldProfile: normalizedEncounterState.customWorldProfile ?? null, - runtimeStats: normalizedRuntimeStats, - storyEngineMemory: - normalizedEncounterState.storyEngineMemory ?? - createEmptyStoryEngineMemoryState(), - chapterState: - normalizedEncounterState.chapterState - ?? normalizedEncounterState.storyEngineMemory?.currentChapter - ?? null, - campaignState: - normalizedEncounterState.campaignState - ?? normalizedEncounterState.storyEngineMemory?.campaignState - ?? null, - activeScenarioPackId: - normalizedEncounterState.activeScenarioPackId - ?? normalizedEncounterState.customWorldProfile?.scenarioPackId - ?? null, - activeCampaignPackId: - normalizedEncounterState.activeCampaignPackId - ?? normalizedEncounterState.customWorldProfile?.campaignPackId - ?? null, - npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false, - playerCurrency: typeof gameState.playerCurrency === 'number' - ? gameState.playerCurrency - : getInitialPlayerCurrency(gameState.worldType), - quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []), - roster: normalizedRoster, - npcStates: Object.fromEntries( - Object.entries(normalizedEncounterState.npcStates ?? {}).map(([npcId, npcState]) => [ - npcId, - normalizeNpcPersistentState(npcState), - ]), - ), - characterChats: normalizeCharacterChats(normalizedEncounterState), - activeBuildBuffs: normalizedEncounterState.activeBuildBuffs ?? [], - } satisfies GameState; - - if (!normalizedEncounterState.playerCharacter) { - return { - ...normalizedCommonState, - playerEquipment: createEmptyEquipmentLoadout(), - } satisfies GameState; - } - - const resolvedEquipment = normalizedEncounterState.playerEquipment - ? normalizedEncounterState.playerEquipment - : buildInitialEquipmentLoadout(normalizedEncounterState.playerCharacter); - - const playerMaxHp = getCharacterMaxHp( - normalizedEncounterState.playerCharacter, - normalizedEncounterState.worldType, - normalizedEncounterState.customWorldProfile, - ); - - return applyEquipmentLoadoutToState({ - ...normalizedCommonState, - playerMaxHp, - playerHp: Math.min(normalizedEncounterState.playerHp, playerMaxHp), - playerMaxMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter), - playerMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter), - playerEquipment: createEmptyEquipmentLoadout(), - } as GameState, resolvedEquipment); -} - function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) { return ( gameState.currentScene === 'Story' && @@ -154,6 +22,22 @@ function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) { ); } +function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab { + if (bottomTab === 'character' || bottomTab === 'inventory') { + return bottomTab; + } + + return 'adventure'; +} + +function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) { + return { + gameState: snapshot.gameState, + currentStory: snapshot.currentStory ?? null, + bottomTab: normalizeBottomTab(snapshot.bottomTab), + }; +} + export function useGamePersistence({ gameState, bottomTab, @@ -174,93 +58,202 @@ export function useGamePersistence({ resetStoryState: () => void; }) { const [hasSavedGame, setHasSavedGame] = useState(false); - const [savedSnapshot, setSavedSnapshot] = useState(null); + const [savedSnapshot, setSavedSnapshot] = + useState(null); + const [isHydratingSnapshot, setIsHydratingSnapshot] = useState(true); + const [isPersistingSnapshot, setIsPersistingSnapshot] = useState(false); + const [persistenceError, setPersistenceError] = useState(null); + const hydrateControllerRef = useRef(null); + const saveControllerRef = useRef(null); + const saveRequestIdRef = useRef(0); + + const abortActiveSave = useCallback(() => { + saveControllerRef.current?.abort(); + saveControllerRef.current = null; + setIsPersistingSnapshot(false); + }, []); + + const persistSnapshot = useCallback( + async (params: { + payload: { + gameState: GameState; + bottomTab: BottomTab; + currentStory: StoryMoment | null; + }; + logLabel: string; + }) => { + abortActiveSave(); + + const requestId = saveRequestIdRef.current + 1; + saveRequestIdRef.current = requestId; + const controller = new AbortController(); + saveControllerRef.current = controller; + setIsPersistingSnapshot(true); + setPersistenceError(null); + + try { + const snapshot = await putSaveSnapshot( + { + gameState: params.payload.gameState, + bottomTab: params.payload.bottomTab, + currentStory: params.payload.currentStory, + }, + { signal: controller.signal }, + ); + + if (saveRequestIdRef.current !== requestId) { + return null; + } + + setSavedSnapshot(snapshot); + setHasSavedGame(true); + return snapshot; + } catch (error) { + if (isAbortError(error)) { + return null; + } + + const message = + error instanceof Error ? error.message : '远端存档同步失败'; + if (saveRequestIdRef.current === requestId) { + setPersistenceError(message); + } + console.warn(`[useGamePersistence] ${params.logLabel}`, error); + return null; + } finally { + if (saveControllerRef.current === controller) { + saveControllerRef.current = null; + setIsPersistingSnapshot(false); + } + } + }, + [abortActiveSave], + ); useEffect(() => { - let isActive = true; + const controller = new AbortController(); + hydrateControllerRef.current = controller; + setIsHydratingSnapshot(true); - void getSaveSnapshot() + void getSaveSnapshot({ signal: controller.signal }) .then((snapshot) => { - if (!isActive) return; setSavedSnapshot(snapshot); setHasSavedGame(Boolean(snapshot)); + setPersistenceError(null); }) .catch((error) => { - console.warn('[useGamePersistence] failed to load remote snapshot', error); + if (isAbortError(error)) { + return; + } + const message = + error instanceof Error ? error.message : '读取远端存档失败'; + setPersistenceError(message); + console.warn( + '[useGamePersistence] failed to load remote snapshot', + error, + ); + }) + .finally(() => { + if (hydrateControllerRef.current === controller) { + hydrateControllerRef.current = null; + setIsHydratingSnapshot(false); + } }); return () => { - isActive = false; + controller.abort(); + if (hydrateControllerRef.current === controller) { + hydrateControllerRef.current = null; + } }; }, []); + useEffect( + () => () => { + hydrateControllerRef.current?.abort(); + saveControllerRef.current?.abort(); + saveControllerRef.current = null; + }, + [], + ); + useEffect(() => { - const canPersist = !isLoading && canPersistSnapshot(gameState, currentStory); + const canPersist = + !isLoading && canPersistSnapshot(gameState, currentStory); if (!canPersist) return; const timeoutId = window.setTimeout(() => { - void putSaveSnapshot({ - gameState, - bottomTab, - currentStory, - }) - .then((snapshot) => { - setSavedSnapshot(snapshot); - setHasSavedGame(true); - }) - .catch((error) => { - console.warn('[useGamePersistence] failed to autosave remote snapshot', error); - }); + void persistSnapshot({ + payload: { + gameState, + bottomTab, + currentStory, + }, + logLabel: 'failed to autosave remote snapshot', + }); }, AUTO_SAVE_DELAY_MS); return () => window.clearTimeout(timeoutId); - }, [bottomTab, currentStory, gameState, isLoading]); + }, [bottomTab, currentStory, gameState, isLoading, persistSnapshot]); - const saveCurrentGame = useCallback(async (override?: { - gameState?: GameState; - bottomTab?: BottomTab; - currentStory?: StoryMoment | null; - }) => { - const nextGameState = override?.gameState ?? gameState; - const nextBottomTab = override?.bottomTab ?? bottomTab; - const nextStory = override?.currentStory ?? currentStory; + const saveCurrentGame = useCallback( + async (override?: { + gameState?: GameState; + bottomTab?: BottomTab; + currentStory?: StoryMoment | null; + }) => { + const nextGameState = override?.gameState ?? gameState; + const nextBottomTab = override?.bottomTab ?? bottomTab; + const nextStory = override?.currentStory ?? currentStory; - if (!canPersistSnapshot(nextGameState, nextStory)) { - return false; - } + if (!canPersistSnapshot(nextGameState, nextStory)) { + return false; + } - try { - const snapshot = await putSaveSnapshot({ - gameState: nextGameState, - bottomTab: nextBottomTab, - currentStory: nextStory, + const snapshot = await persistSnapshot({ + payload: { + gameState: nextGameState, + bottomTab: nextBottomTab, + currentStory: nextStory, + }, + logLabel: 'failed to save remote snapshot', }); - setSavedSnapshot(snapshot); - setHasSavedGame(true); - return true; - } catch (error) { - console.warn('[useGamePersistence] failed to save remote snapshot', error); - return false; - } - }, [bottomTab, currentStory, gameState]); + + return Boolean(snapshot); + }, + [bottomTab, currentStory, gameState, persistSnapshot], + ); const clearSavedGame = useCallback(async () => { + abortActiveSave(); + try { await deleteSaveSnapshot(); + setPersistenceError(null); } catch (error) { - console.warn('[useGamePersistence] failed to delete remote snapshot', error); + console.warn( + '[useGamePersistence] failed to delete remote snapshot', + error, + ); } setSavedSnapshot(null); setHasSavedGame(false); - }, []); + }, [abortActiveSave]); const continueSavedGame = useCallback(async () => { - const snapshot = savedSnapshot ?? await getSaveSnapshot().catch((error) => { - console.warn('[useGamePersistence] failed to refetch remote snapshot', error); - return null; - }); + const snapshot = + savedSnapshot ?? + (await getSaveSnapshot().catch((error) => { + if (!isAbortError(error)) { + console.warn( + '[useGamePersistence] failed to refetch remote snapshot', + error, + ); + } + return null; + })); if (!snapshot) { setSavedSnapshot(null); setHasSavedGame(false); @@ -268,16 +261,44 @@ export function useGamePersistence({ } resetStoryState(); - setGameState(normalizeSavedGameState(snapshot.gameState)); - setBottomTab(snapshot.bottomTab ?? 'adventure'); - hydrateStoryState(normalizeSavedStory(snapshot.currentStory)); + const fallbackHydration = resolveRemoteSnapshotState(snapshot); + + const resumedState = await resumeServerRuntimeStory(snapshot).catch( + (error) => { + if (!isAbortError(error)) { + console.warn( + '[useGamePersistence] failed to refresh runtime story state from server', + error, + ); + } + + return { + hydratedSnapshot: fallbackHydration, + nextStory: fallbackHydration.currentStory, + }; + }, + ); + + setGameState(resumedState.hydratedSnapshot.gameState); + setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab)); + hydrateStoryState(resumedState.nextStory); setSavedSnapshot(snapshot); setHasSavedGame(true); + setPersistenceError(null); return true; - }, [hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState]); + }, [ + hydrateStoryState, + resetStoryState, + savedSnapshot, + setBottomTab, + setGameState, + ]); return { hasSavedGame, + isHydratingSnapshot, + isPersistingSnapshot, + persistenceError, saveCurrentGame, continueSavedGame, clearSavedGame, diff --git a/src/hooks/useGameSettings.ts b/src/hooks/useGameSettings.ts index 43b0e1b4..a6ec75c3 100644 --- a/src/hooks/useGameSettings.ts +++ b/src/hooks/useGameSettings.ts @@ -4,37 +4,71 @@ import { clampVolume, DEFAULT_MUSIC_VOLUME, } from '../persistence/gameSettingsStorage'; +import { isAbortError } from '../services/apiClient'; import { getSettings, putSettings } from '../services/storageService'; +const SETTINGS_SYNC_DELAY_MS = 180; + export function useGameSettings() { const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME); const [hasHydratedSettings, setHasHydratedSettings] = useState(false); + const [isHydratingSettings, setIsHydratingSettings] = useState(true); + const [isPersistingSettings, setIsPersistingSettings] = useState(false); + const [settingsError, setSettingsError] = useState(null); const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME); + const hydrateControllerRef = useRef(null); + const persistControllerRef = useRef(null); + const persistRequestIdRef = useRef(0); + + const abortActivePersist = useCallback(() => { + persistControllerRef.current?.abort(); + persistControllerRef.current = null; + setIsPersistingSettings(false); + }, []); useEffect(() => { - let isActive = true; + const controller = new AbortController(); + hydrateControllerRef.current = controller; + setIsHydratingSettings(true); - void getSettings() + void getSettings({ signal: controller.signal }) .then((settings) => { - if (!isActive) return; const nextVolume = clampVolume(settings.musicVolume); lastSyncedVolumeRef.current = nextVolume; setMusicVolumeState(nextVolume); + setSettingsError(null); }) .catch((error) => { + if (isAbortError(error)) { + return; + } + const message = + error instanceof Error ? error.message : '读取远端设置失败'; + setSettingsError(message); console.warn('[useGameSettings] failed to load remote settings', error); }) .finally(() => { - if (isActive) { + if (hydrateControllerRef.current === controller) { + hydrateControllerRef.current = null; + setIsHydratingSettings(false); setHasHydratedSettings(true); } }); return () => { - isActive = false; + controller.abort(); + if (hydrateControllerRef.current === controller) { + hydrateControllerRef.current = null; + } }; }, []); + useEffect(() => () => { + hydrateControllerRef.current?.abort(); + persistControllerRef.current?.abort(); + persistControllerRef.current = null; + }, []); + useEffect(() => { if (!hasHydratedSettings) { return; @@ -44,21 +78,49 @@ export function useGameSettings() { return; } - let isActive = true; + const timeoutId = window.setTimeout(() => { + abortActivePersist(); - void putSettings({musicVolume}) - .then((settings) => { - if (!isActive) return; - lastSyncedVolumeRef.current = clampVolume(settings.musicVolume); - }) - .catch((error) => { - console.warn('[useGameSettings] failed to persist remote settings', error); - }); + const requestId = persistRequestIdRef.current + 1; + persistRequestIdRef.current = requestId; + const controller = new AbortController(); + persistControllerRef.current = controller; + setIsPersistingSettings(true); + setSettingsError(null); - return () => { - isActive = false; - }; - }, [hasHydratedSettings, musicVolume]); + void putSettings({ musicVolume }, { signal: controller.signal }) + .then((settings) => { + if (persistRequestIdRef.current !== requestId) { + return; + } + + const nextVolume = clampVolume(settings.musicVolume); + lastSyncedVolumeRef.current = nextVolume; + setMusicVolumeState((currentValue) => + currentValue === nextVolume ? currentValue : nextVolume, + ); + }) + .catch((error) => { + if (isAbortError(error)) { + return; + } + const message = + error instanceof Error ? error.message : '保存远端设置失败'; + if (persistRequestIdRef.current === requestId) { + setSettingsError(message); + } + console.warn('[useGameSettings] failed to persist remote settings', error); + }) + .finally(() => { + if (persistControllerRef.current === controller) { + persistControllerRef.current = null; + setIsPersistingSettings(false); + } + }); + }, SETTINGS_SYNC_DELAY_MS); + + return () => window.clearTimeout(timeoutId); + }, [abortActivePersist, hasHydratedSettings, musicVolume]); const setMusicVolume = useCallback((value: number) => { setMusicVolumeState(clampVolume(value)); @@ -67,5 +129,9 @@ export function useGameSettings() { return { musicVolume, setMusicVolume, + hasHydratedSettings, + isHydratingSettings, + isPersistingSettings, + settingsError, }; } diff --git a/src/hooks/useGameShellRuntime.ts b/src/hooks/useGameShellRuntime.ts new file mode 100644 index 00000000..7a304f84 --- /dev/null +++ b/src/hooks/useGameShellRuntime.ts @@ -0,0 +1,179 @@ +import { useEffect } from 'react'; + +import type { GameShellProps } from '../components/game-shell/types'; +import { activateRosterCompanion, benchActiveCompanion } from '../data/companionRoster'; +import { syncGameStatePlayTime } from '../data/runtimeStats'; +import { useBackgroundMusic } from './useBackgroundMusic'; +import { useCombatFlow } from './useCombatFlow'; +import { useGameFlow } from './useGameFlow'; +import { useGamePersistence } from './useGamePersistence'; +import { useGameSettings } from './useGameSettings'; +import { useNpcInteractionFlow } from './useNpcInteractionFlow'; +import { useStoryGeneration } from './useStoryGeneration'; + +export function useGameShellRuntime(): GameShellProps { + const { + gameState, + setGameState, + bottomTab, + setBottomTab, + isMapOpen, + setIsMapOpen, + resetGame, + handleCustomWorldSelect: selectCustomWorld, + handleBackToWorldSelect: backToWorldSelect, + handleCharacterSelect: selectCharacter, + } = useGameFlow(); + + const combatFlow = useCombatFlow({ + setGameState, + }); + + const storyFlow = useStoryGeneration({ + gameState, + setGameState, + buildResolvedChoiceState: combatFlow.buildResolvedChoiceState, + playResolvedChoice: combatFlow.playResolvedChoice, + }); + + const { companionRenderStates, buildCompanionRenderStates } = + useNpcInteractionFlow(gameState); + const settings = useGameSettings(); + + const persistence = useGamePersistence({ + gameState, + bottomTab, + currentStory: storyFlow.currentStory, + isLoading: storyFlow.isLoading, + setGameState, + setBottomTab, + hydrateStoryState: storyFlow.hydrateStoryState, + resetStoryState: storyFlow.resetStoryState, + }); + + useBackgroundMusic({ + active: Boolean( + gameState.playerCharacter && gameState.currentScene === 'Story', + ), + volume: settings.musicVolume, + }); + + useEffect(() => { + if (!gameState.playerCharacter || gameState.currentScene !== 'Story') { + return; + } + + const intervalId = window.setInterval(() => { + setGameState((currentState) => { + if ( + !currentState.playerCharacter || + currentState.currentScene !== 'Story' + ) { + return currentState; + } + + return syncGameStatePlayTime(currentState); + }); + }, 15000); + + return () => window.clearInterval(intervalId); + }, [gameState.currentScene, gameState.playerCharacter, setGameState]); + + const handleCustomWorldSelect = ( + customWorldProfile: Parameters[0], + ) => { + storyFlow.resetStoryState(); + selectCustomWorld(customWorldProfile); + }; + + const handleCharacterSelect = ( + character: Parameters[0], + ) => { + storyFlow.resetStoryState(); + selectCharacter(character); + }; + + const handleBackToWorldSelect = () => { + storyFlow.resetStoryState(); + backToWorldSelect(); + }; + + const handleContinueGame = () => { + void persistence.continueSavedGame(); + }; + + const handleStartNewGame = () => { + void persistence.clearSavedGame(); + storyFlow.resetStoryState(); + resetGame(); + }; + + const handleSaveAndExit = () => { + const syncedGameState = syncGameStatePlayTime(gameState); + void persistence.saveCurrentGame({ + gameState: syncedGameState, + bottomTab, + currentStory: storyFlow.currentStory, + }); + storyFlow.resetStoryState(); + resetGame(); + }; + + const handleBenchCompanion = (npcId: string) => { + setGameState((currentState) => benchActiveCompanion(currentState, npcId)); + }; + + const handleActivateRosterCompanion = ( + npcId: string, + swapNpcId?: string | null, + ) => { + setGameState((currentState) => + activateRosterCompanion(currentState, npcId, swapNpcId), + ); + }; + + return { + session: { + gameState, + currentStory: storyFlow.currentStory, + isLoading: storyFlow.isLoading, + aiError: storyFlow.aiError, + bottomTab, + setBottomTab, + isMapOpen, + setIsMapOpen, + }, + story: { + displayedOptions: storyFlow.displayedOptions, + canRefreshOptions: storyFlow.canRefreshOptions, + handleRefreshOptions: storyFlow.handleRefreshOptions, + handleChoice: storyFlow.handleChoice, + handleMapTravelToScene: storyFlow.travelToSceneFromMap, + npcUi: storyFlow.npcUi, + characterChatUi: storyFlow.characterChatUi, + inventoryUi: storyFlow.inventoryUi, + battleRewardUi: storyFlow.battleRewardUi, + questUi: storyFlow.questUi, + goalUi: storyFlow.goalUi, + }, + entry: { + hasSavedGame: persistence.hasSavedGame, + handleContinueGame, + handleStartNewGame, + handleSaveAndExit, + handleCustomWorldSelect, + handleBackToWorldSelect, + handleCharacterSelect, + }, + companions: { + companionRenderStates, + buildCompanionRenderStates, + onBenchCompanion: handleBenchCompanion, + onActivateRosterCompanion: handleActivateRosterCompanion, + }, + audio: { + musicVolume: settings.musicVolume, + onMusicVolumeChange: settings.setMusicVolume, + }, + }; +} diff --git a/src/hooks/useStoryGeneration.ts b/src/hooks/useStoryGeneration.ts index f6c23676..75e8dc0f 100644 --- a/src/hooks/useStoryGeneration.ts +++ b/src/hooks/useStoryGeneration.ts @@ -1,144 +1,36 @@ import type { Dispatch, SetStateAction } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - getCharacterAdventureOpening, - getCharacterById, - getCharacterHomeSceneId, -} from '../data/characterPresets'; -import { - buildCampTravelHomeOption, buildContinueAdventureOption, - buildNpcPreviewTalkOption, isCampTravelHomeOption, isContinueAdventureOption, - NPC_CHAT_FUNCTION, - NPC_FIGHT_FUNCTION, - NPC_LEAVE_FUNCTION, NPC_PREVIEW_TALK_FUNCTION, - NPC_RECRUIT_FUNCTION, - STORY_OPENING_CAMP_DIALOGUE_FUNCTION, } from '../data/functionCatalog'; -import { - buildInitialNpcState, - buildNpcEncounterStoryMoment, - describeNpcAffinityInWords, - getNpcConversationDirective, - isNpcFirstMeaningfulContact, - normalizeNpcPersistentState, -} from '../data/npcInteractions'; -import { applyQuestProgressFromTreasure } from '../data/questFlow'; -import { incrementGameRuntimeStats } from '../data/runtimeStats'; -import { - buildSceneEntityCatalogText, - getForwardScenePreset, - getScenePresetById, - getTravelScenePreset, - getWorldCampScenePreset, -} from '../data/scenePresets'; -import { - getDefaultFunctionIdsForContext, - resolveFunctionOption, - sortStoryOptionsByPriority, -} from '../data/stateFunctions'; -import { applyStoryReasoningRecovery } from '../data/storyRecovery'; -import { generateInitialStory, generateNextStep } from '../services/ai'; -import { hasMixedNarrativeLanguage } from '../services/narrativeLanguage'; -import { - buildFallbackActorNarrativeProfile, - normalizeActorNarrativeProfile, -} from '../services/storyEngine/actorNarrativeProfile'; -import { applyAdaptiveTuningToPromptContext } from '../services/storyEngine/adaptiveNarrativeTuner'; -import { compileCampaignFromWorldProfile } from '../services/storyEngine/campaignPackCompiler'; -import { - buildCampEvent, - evaluateCampEventOpportunity, -} from '../services/storyEngine/campEventDirector'; -import { - advanceChapterState, - resolveCurrentChapterState, -} from '../services/storyEngine/chapterDirector'; -import { - advanceCompanionArc, - buildCompanionArcStates, -} from '../services/storyEngine/companionArcDirector'; -import { syncNpcNarrativeState } from '../services/storyEngine/echoMemory'; -import { - buildGoalStackState, - createGoalPulseSnapshot, - deriveGoalPulseEvent, -} from '../services/storyEngine/goalDirector'; -import { resolveCurrentJourneyBeat } from '../services/storyEngine/journeyBeatPlanner'; -import { buildVisibilitySliceFromFacts } from '../services/storyEngine/knowledgeContract'; -import { buildKnowledgeGraph } from '../services/storyEngine/knowledgeGraph'; -import { buildRecentCarrierEchoes } from '../services/storyEngine/narrativeCarrierCatalog'; -import { buildChapterRecap } from '../services/storyEngine/recapDigest'; -import { resolveScenarioPack } from '../services/storyEngine/scenarioPackRegistry'; -import { buildSceneNarrativeDirective } from '../services/storyEngine/sceneNarrativeDirector'; -import { - buildSetpieceDirective, - evaluateSetpieceOpportunity, -} from '../services/storyEngine/setpieceDirector'; -import { buildChronicleSummary } from '../services/storyEngine/storyChronicle'; -import { buildThemePackFromWorldProfile } from '../services/storyEngine/themePack'; -import { - buildEncounterVisibilitySlice, - createEmptyStoryEngineMemoryState, -} from '../services/storyEngine/visibilityEngine'; -import { buildFallbackWorldStoryGraph } from '../services/storyEngine/worldStoryGraph'; -import { - Character, - Encounter, - GameState, - InventoryItem, - StoryDialogueTurn, - StoryMoment, - StoryOption, - WorldType, -} from '../types'; +import { sortStoryOptionsByPriority } from '../data/stateFunctions'; +import type { GameState, StoryOption } from '../types'; import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from './combat/escapeFlow'; import type { ResolvedChoiceState } from './combat/resolvedChoice'; +import { useCharacterChatFlow } from './story/characterChat'; +import { buildStoryContextFromState } from './story/storyContextBuilder'; import { - buildFallbackStoryMoment, - normalizeSkillProbabilities, -} from './combatStoryUtils'; + getResolvedSceneHostileNpcs, + getStoryGenerationHostileNpcs, + isInitialCompanionEncounter, + isNpcEncounter, + isRegularNpcEncounter, +} from './story/storyEncounterState'; import { - getCharacterChatRecord, - useCharacterChatFlow, -} from './story/characterChat'; -import { createStoryChoiceActions } from './story/choiceActions'; -import { useStoryInventoryActions } from './story/inventoryActions'; -import { createStoryNpcEncounterActions } from './story/npcEncounterActions'; -import { useStoryNpcInteractionFlow } from './story/npcInteraction'; + getNpcEncounterKey, + resolveNpcInteractionDecision, +} from './story/storyGenerationState'; import { - buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState, - playOpeningAdventureSequence, - type PreparedOpeningAdventure, -} from './story/openingAdventure'; -import { - appendStoryHistory, - createStoryProgressionActions, -} from './story/progressionActions'; -import { createStorySessionActions } from './story/sessionActions'; -import { resolveNpcInteractionDecision } from './story/storyGenerationState'; -import { resolveStoryResponseOptions } from './story/storyResponseOptions'; -import type { - BattleRewardSummary, - BattleRewardUi, - GoalFlowUi, - QuestFlowUi, -} from './story/uiTypes'; -import { useStoryOptions } from './useStoryOptions'; -import { - buildTreasureStory, - isTreasureEncounter, - useTreasureFlow, -} from './useTreasureFlow'; + storyRuntimeSupport, +} from './story/storyRuntimeSupport'; +import { useStoryFlowCoordinator } from './story/useStoryFlowCoordinator'; +import { useStoryRuntimeController } from './story/useStoryRuntimeController'; +import type { BattleRewardUi, QuestFlowUi } from './story/uiTypes'; -const MIN_OPTION_POOL_SIZE = 6; const TURN_VISUAL_MS = 820; -const OPENING_CAMP_DIALOGUE_FUNCTION_ID = - STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id; const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id; const FALLBACK_COMPANION_NAME = '同伴'; @@ -159,985 +51,6 @@ export type { TradeModalState, } from './story/uiTypes'; -type CampCompanionEncounter = Encounter & { - specialBehavior: 'camp_companion'; -}; - -function dedupeStoryOptions(options: StoryOption[]) { - const seen = new Set(); - - return options.filter((option) => { - const identity = `${option.functionId}::${option.actionText}::${option.text}`; - if (seen.has(identity)) return false; - seen.add(identity); - return true; - }); -} - -function _buildLocalCharacterChatSummary( - character: Character, - history: Array<{ speaker: 'player' | 'character'; text: string }>, - previousSummary: string, -) { - const latestTurns = history - .slice(-4) - .map( - (turn) => - `${turn.speaker === 'player' ? '玩家' : character.name}:${turn.text}`, - ) - .join(' '); - - const currentSummary = latestTurns - ? `${character.name}在私下交谈里变得更愿意坦率回应。最近交流:${latestTurns}` - : `${character.name}愿意继续私下交谈,对玩家的信任也在慢慢加深。`; - if (!previousSummary) { - return currentSummary.slice(0, 118); - } - - return `${previousSummary} ${currentSummary}`.slice(0, 118); -} - -function _buildLocalCharacterChatSuggestions(character: Character) { - return [ - '我想听你把这件事再说得更明白一点。', - `${character.name},你现在真正担心的是什么?`, - '先把外面的局势放一放,我想更了解你一些。', - ]; -} - -function buildPartyRelationshipNotes(state: GameState) { - const lines: string[] = []; - const seenCharacterIds = new Set(); - - const appendNote = (characterId: string, roleLabel: string) => { - if (seenCharacterIds.has(characterId)) return; - const character = getCharacterById(characterId); - const summary = getCharacterChatRecord(state, characterId).summary.trim(); - if (hasMixedNarrativeLanguage(summary)) return; - if (!character || !summary) return; - - seenCharacterIds.add(characterId); - lines.push( - `- ${character.name} (${character.title} / ${roleLabel}): ${summary}`, - ); - }; - - state.companions.forEach((companion) => - appendNote(companion.characterId, '当前同行'), - ); - state.roster.forEach((companion) => - appendNote(companion.characterId, '营地待命'), - ); - - return lines.length > 0 ? lines.join('\n') : null; -} - -function describeScenePressureLevel( - pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined, -) { - switch (pressureLevel) { - case 'low': - return '低'; - case 'medium': - return '中'; - case 'high': - return '高'; - case 'extreme': - return '极高'; - default: - return null; - } -} - -function buildRecentConversationEventText(state: GameState) { - const recentText = state.storyHistory - .slice(-6) - .map((item) => item.text) - .join('\n'); - if ( - /击败|怪物|战斗|切磋|交手|脱身/u.test(recentText) - ) { - return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。'; - } - if (/携手|相助|帮你|并肩/u.test(recentText)) { - return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。'; - } - return null; -} - -function inferConversationSituation( - state: GameState, - extras: { - lastFunctionId?: string | null; - openingCampDialogue?: string | null; - }, -) { - if (state.inBattle) return 'shared_danger_coordination' as const; - if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID) - return 'camp_first_contact' as const; - if ( - state.currentEncounter?.specialBehavior === 'camp_companion' && - extras.openingCampDialogue?.trim() - ) { - return 'camp_followup' as const; - } - const recentText = state.storyHistory - .slice(-6) - .map((item) => item.text) - .join('\n'); - if ( - /击败|怪物|战斗|切磋|交手|脱身/u.test(recentText) - ) { - return 'post_battle_breath' as const; - } - if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id) - return 'private_followup' as const; - return 'first_contact_cautious' as const; -} - -function inferConversationPressure( - state: GameState, - situation: ReturnType, -) { - const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1); - if (state.inBattle || hpRatio < 0.35) return 'high' as const; - if ( - situation === 'post_battle_breath' || - situation === 'shared_danger_coordination' - ) - return 'medium' as const; - if (situation === 'camp_first_contact' || situation === 'camp_followup') - return 'low' as const; - return 'medium' as const; -} - -function describeConversationSituation( - situation: ReturnType, -) { - switch (situation) { - case 'camp_first_contact': - return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。'; - case 'camp_followup': - return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。'; - case 'post_battle_breath': - return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。'; - case 'shared_danger_coordination': - return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。'; - case 'private_followup': - return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。'; - default: - return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。'; - } -} - -function describeConversationTalkPriority( - situation: ReturnType, -) { - switch (situation) { - case 'camp_first_contact': - return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。'; - case 'camp_followup': - return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。'; - case 'post_battle_breath': - return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。'; - case 'shared_danger_coordination': - return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。'; - case 'private_followup': - return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。'; - default: - return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。'; - } -} - -function resolveEncounterNarrativeProfile(state: GameState) { - const encounter = state.currentEncounter; - if (!encounter || encounter.kind !== 'npc') { - return null; - } - if (encounter.narrativeProfile) { - return encounter.narrativeProfile; - } - if (!state.customWorldProfile) { - return null; - } - - const role = - state.customWorldProfile.storyNpcs.find((npc) => - npc.id === encounter.id || npc.name === encounter.npcName, - ) - ?? state.customWorldProfile.playableNpcs.find((npc) => - npc.id === encounter.id || npc.name === encounter.npcName, - ); - if (!role) { - return null; - } - - const themePack = - state.customWorldProfile.themePack - ?? buildThemePackFromWorldProfile(state.customWorldProfile); - const storyGraph = - state.customWorldProfile.storyGraph - ?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); - - return normalizeActorNarrativeProfile( - role.narrativeProfile, - buildFallbackActorNarrativeProfile(role, storyGraph, themePack), - ); -} - -function resolveActiveThreadIds( - state: GameState, - encounterNarrativeProfile: ReturnType, -) { - if (state.storyEngineMemory?.activeThreadIds?.length) { - return state.storyEngineMemory.activeThreadIds.slice(0, 4); - } - if (encounterNarrativeProfile?.relatedThreadIds.length) { - return encounterNarrativeProfile.relatedThreadIds.slice(0, 4); - } - if (!state.customWorldProfile) { - return []; - } - - const themePack = - state.customWorldProfile.themePack - ?? buildThemePackFromWorldProfile(state.customWorldProfile); - const storyGraph = - state.customWorldProfile.storyGraph - ?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); - - return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id); -} - -function buildStoryContextFromState( - state: GameState, - extras: { - pendingSceneEncounter?: boolean; - lastFunctionId?: string | null; - observeSignsRequested?: boolean; - recentActionResult?: string | null; - openingCampBackground?: string | null; - openingCampDialogue?: string | null; - encounterNpcStateOverride?: GameState['npcStates'][string] | null; - } = {}, -) { - const conversationSituation = inferConversationSituation(state, extras); - const conversationPressure = inferConversationPressure( - state, - conversationSituation, - ); - const recentSharedEvent = buildRecentConversationEventText(state); - const encounterNpcState = - state.currentEncounter?.kind === 'npc' - ? (() => { - const encounter = state.currentEncounter; - return extras.encounterNpcStateOverride - ?? state.npcStates[getNpcEncounterKey(encounter)] - ?? buildInitialNpcState(encounter, state.worldType, state); - })() - : null; - const encounterDirective = - state.currentEncounter?.kind === 'npc' - ? (() => { - const encounter = state.currentEncounter; - return encounterNpcState - ? getNpcConversationDirective(encounter, encounterNpcState) - : null; - })() - : null; - const isFirstMeaningfulContact = - state.currentEncounter?.kind === 'npc' - ? (() => { - const encounter = state.currentEncounter; - return encounterNpcState - ? isNpcFirstMeaningfulContact(encounter, encounterNpcState) - : false; - })() - : false; - const firstContactRelationStance = (() => { - if ( - !isFirstMeaningfulContact || - !state.currentEncounter || - state.currentEncounter.kind !== 'npc' - ) { - return null; - } - - const stance = encounterNpcState?.relationState?.stance ?? null; - if ( - stance === 'guarded' || - stance === 'neutral' || - stance === 'cooperative' || - stance === 'bonded' - ) { - return stance; - } - return null; - })(); - const encounterAffinityText = - state.currentEncounter?.kind === 'npc' - ? (() => { - const encounter = state.currentEncounter; - return encounterNpcState - ? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, { - recruited: encounterNpcState.recruited, - }) - : null; - })() - : null; - const baseSceneDescription = state.currentScenePreset?.description ?? null; - const sceneMutationDescription = [ - state.currentScenePreset?.mutationStateText - ? `最新世界变化:${state.currentScenePreset.mutationStateText}` - : null, - describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel) - ? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}` - : null, - ] - .filter(Boolean) - .join('\n'); - const observeSignsSceneDescription = - extras.observeSignsRequested && state.worldType - ? [ - baseSceneDescription, - sceneMutationDescription, - '当前可观察实体池:', - buildSceneEntityCatalogText( - state.worldType, - state.currentScenePreset?.id ?? null, - ), - ] - .filter(Boolean) - .join('\n') - : [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n'); - const storyEngineMemory = - state.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); - const knowledgeFacts = - state.customWorldProfile?.knowledgeFacts - ?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []); - const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state); - const activeThreadIds = resolveActiveThreadIds( - { - ...state, - storyEngineMemory, - } as GameState, - encounterNarrativeProfile, - ); - const visibilitySlice = - state.currentEncounter?.kind === 'npc' - ? (() => { - const relevantFacts = knowledgeFacts.filter((fact) => - fact.ownerActorIds.includes(state.currentEncounter?.id ?? '') - || fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '') - || fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)), - ); - return relevantFacts.length > 0 - ? buildVisibilitySliceFromFacts({ - facts: relevantFacts, - discoveredFactIds: [ - ...storyEngineMemory.discoveredFactIds, - ...(encounterNpcState?.revealedFacts ?? []), - ...(encounterNpcState?.seenBackstoryChapterIds ?? []).map( - (chapterId) => - relevantFacts.find((fact) => - fact.aliases?.includes(chapterId) || fact.id.includes(chapterId), - )?.id ?? '', - ), - ], - activeThreadIds, - disclosureStage: encounterDirective?.disclosureStage ?? null, - isFirstMeaningfulContact, - }) - : buildEncounterVisibilitySlice({ - narrativeProfile: encounterNarrativeProfile, - backstoryReveal: state.currentEncounter.backstoryReveal ?? null, - disclosureStage: encounterDirective?.disclosureStage ?? null, - isFirstMeaningfulContact, - seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [], - storyEngineMemory, - activeThreadIds, - }); - })() - : null; - const sceneNarrativeDirective = buildSceneNarrativeDirective({ - sceneId: state.currentScenePreset?.id ?? null, - sceneName: state.currentScenePreset?.name ?? null, - encounterId: state.currentEncounter?.id ?? null, - encounterName: state.currentEncounter?.npcName ?? null, - recentActions: state.storyHistory.slice(-3).map((moment) => moment.text), - activeThreadIds, - visibilitySlice, - encounterNarrativeProfile, - disclosureStage: encounterDirective?.disclosureStage ?? null, - isFirstMeaningfulContact, - affinity: encounterNpcState?.affinity ?? null, - }); - const chapterState = advanceChapterState({ - previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null, - nextChapter: resolveCurrentChapterState({ - state: { - ...state, - storyEngineMemory, - }, - }), - }); - const journeyBeat = resolveCurrentJourneyBeat({ - state: { - ...state, - chapterState, - storyEngineMemory: { - ...storyEngineMemory, - currentChapter: chapterState, - }, - } as GameState, - chapterState, - }); - const companionArcStates = advanceCompanionArc({ - previous: storyEngineMemory.companionArcStates, - next: buildCompanionArcStates({ - state, - reactions: storyEngineMemory.recentCompanionReactions, - }), - }); - const currentCampEvent = evaluateCampEventOpportunity({ - state, - chapterState, - journeyBeat, - companionArcStates, - }) - ? buildCampEvent({ - state, - chapterState, - journeyBeat, - companionArcStates, - }) - : null; - const setpieceDirective = evaluateSetpieceOpportunity({ - state, - chapterState, - journeyBeat, - }) - ? buildSetpieceDirective({ - state, - chapterState, - journeyBeat, - }) - : null; - const recentWorldMutations = storyEngineMemory.worldMutations ?? []; - const recentChronicleSummary = buildChronicleSummary({ - ...state, - chapterState, - storyEngineMemory: { - ...storyEngineMemory, - currentChapter: chapterState, - companionArcStates, - }, - } as GameState); - const compiledPacks = state.customWorldProfile - ? compileCampaignFromWorldProfile({ profile: state.customWorldProfile }) - : null; - const goalStack = buildGoalStackState({ - quests: state.quests, - worldType: state.worldType, - currentSceneId: state.currentScenePreset?.id ?? null, - chapterState, - journeyBeat, - setpieceDirective, - currentCampEvent, - currentSceneName: state.currentScenePreset?.name ?? null, - }); - const activeScenarioPack = - resolveScenarioPack(state.activeScenarioPackId) - ?? compiledPacks?.scenarioPack - ?? null; - const activeCampaignPack = compiledPacks?.campaignPack ?? null; - - const fallbackChapterRecap = buildChapterRecap({ - state: { ...state, chapterState } as GameState, - }); - const safeEncounterRelationshipSummary = - state.currentEncounter?.characterId - ? getCharacterChatRecord(state, state.currentEncounter.characterId) - .summary - .trim() - : ''; - - return applyAdaptiveTuningToPromptContext({ - context: { - playerHp: state.playerHp, - playerMaxHp: state.playerMaxHp, - playerMana: state.playerMana, - playerMaxMana: state.playerMaxMana, - inBattle: state.inBattle, - playerX: state.playerX, - playerFacing: state.playerFacing, - playerAnimation: state.animationState, - skillCooldowns: state.playerSkillCooldowns, - sceneId: state.currentScenePreset?.id ?? null, - sceneName: state.currentScenePreset?.name ?? null, - sceneDescription: observeSignsSceneDescription, - pendingSceneEncounter: extras.pendingSceneEncounter ?? false, - lastFunctionId: extras.lastFunctionId ?? null, - observeSignsRequested: extras.observeSignsRequested ?? false, - recentActionResult: extras.recentActionResult ?? null, - lastObserveSignsReport: - state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null) - ? (state.lastObserveSignsReport ?? null) - : null, - encounterKind: state.currentEncounter?.kind ?? null, - encounterName: state.currentEncounter?.npcName ?? null, - encounterDescription: state.currentEncounter?.npcDescription ?? null, - encounterContext: state.currentEncounter?.context ?? null, - encounterId: state.currentEncounter?.id ?? null, - encounterCharacterId: state.currentEncounter?.characterId ?? null, - encounterGender: state.currentEncounter?.gender ?? null, - encounterCustomProfile: state.currentEncounter - ? { - title: state.currentEncounter.title ?? '', - description: state.currentEncounter.npcDescription ?? '', - backstory: state.currentEncounter.backstory ?? '', - personality: state.currentEncounter.personality ?? '', - motivation: state.currentEncounter.motivation ?? '', - combatStyle: state.currentEncounter.combatStyle ?? '', - relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])], - tags: [...(state.currentEncounter.tags ?? [])], - backstoryReveal: state.currentEncounter.backstoryReveal, - skills: [...(state.currentEncounter.skills ?? [])], - initialItems: [...(state.currentEncounter.initialItems ?? [])], - imageSrc: state.currentEncounter.imageSrc, - visual: state.currentEncounter.visual, - narrativeProfile: state.currentEncounter.narrativeProfile, - } - : null, - encounterAffinity: encounterDirective?.affinity ?? null, - encounterAffinityText, - encounterStanceProfile: encounterNpcState?.stanceProfile ?? null, - encounterConversationStyle: encounterDirective?.style ?? null, - encounterDisclosureStage: encounterDirective?.disclosureStage ?? null, - encounterWarmthStage: encounterDirective?.warmthStage ?? null, - encounterAnswerMode: encounterDirective?.answerMode ?? null, - encounterAllowedTopics: encounterDirective?.allowTopics ?? null, - encounterBlockedTopics: encounterDirective?.blockedTopics ?? null, - isFirstMeaningfulContact, - firstContactRelationStance, - conversationSituation, - conversationPressure, - recentSharedEvent: - recentSharedEvent ?? describeConversationSituation(conversationSituation), - talkPriority: describeConversationTalkPriority(conversationSituation), - visibilitySlice, - sceneNarrativeDirective, - campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null, - actState: storyEngineMemory.actState ?? null, - chapterState, - journeyBeat, - goalStack, - currentCampEvent, - setpieceDirective, - activeScenarioPack, - activeCampaignPack, - encounterNarrativeProfile, - knowledgeFacts, - activeThreadIds, - companionArcStates, - companionResolutions: storyEngineMemory.companionResolutions ?? [], - consequenceLedger: storyEngineMemory.consequenceLedger ?? [], - authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null, - playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null, - recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [], - recentCarrierEchoes: buildRecentCarrierEchoes(state), - recentWorldMutations, - recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [], - recentChronicleSummary: - recentChronicleSummary.trim() && - !hasMixedNarrativeLanguage(recentChronicleSummary) - ? recentChronicleSummary - : fallbackChapterRecap, - narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null, - releaseGateReport: storyEngineMemory.releaseGateReport ?? null, - simulationRunResults: storyEngineMemory.simulationRunResults ?? [], - branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null, - encounterRelationshipSummary: state.currentEncounter?.characterId - ? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary) - ? safeEncounterRelationshipSummary || null - : null - : null, - partyRelationshipNotes: buildPartyRelationshipNotes(state), - customWorldProfile: state.customWorldProfile ?? null, - openingCampBackground: extras.openingCampBackground ?? null, - openingCampDialogue: extras.openingCampDialogue ?? null, - }, - profile: storyEngineMemory.playerStyleProfile ?? null, - }); -} - -function buildNpcPreviewStory( - state: GameState, - character: Character, - encounter: Encounter, - overrideText?: string, -): StoryMoment { - if (!state.worldType) { - return { - text: - overrideText ?? - `${encounter.npcName}正停在前方,像是在等你先决定要不要真正把注意力落到他身上。`, - options: [buildNpcPreviewTalkOption(encounter)], - }; - } - - const functionContext = { - worldType: state.worldType, - playerCharacter: character, - inBattle: false, - currentSceneId: state.currentScenePreset?.id ?? null, - currentSceneName: state.currentScenePreset?.name ?? null, - monsters: [], - playerHp: state.playerHp, - playerMaxHp: state.playerMaxHp, - playerMana: state.playerMana, - playerMaxMana: state.playerMaxMana, - }; - - const locationOptions = getDefaultFunctionIdsForContext(functionContext) - .filter((functionId) => functionId !== 'idle_call_out') - .map((functionId) => resolveFunctionOption(functionId, functionContext)) - .filter((option): option is StoryOption => Boolean(option)); - - return { - text: - overrideText ?? - `${encounter.npcName}出现在${state.currentScenePreset?.name ?? '前方道路'}附近,但你还没有真正把全部注意力落到对方身上。`, - options: [buildNpcPreviewTalkOption(encounter), ...locationOptions], - }; -} - -function getStoryGenerationHostileNpcs(state: GameState) { - return state.inBattle ? getResolvedSceneHostileNpcs(state) : []; -} - -function getResolvedSceneHostileNpcs(state: GameState) { - return state.sceneHostileNpcs; -} - -function sanitizeOptions( - options: StoryOption[], - character: Character, - state: GameState, -) { - const normalizedOptions = dedupeStoryOptions( - options.map((option) => normalizeSkillProbabilities(option, character)), - ); - - if (normalizedOptions.length === 0) { - return buildFallbackStoryMoment(state, character).options; - } - - if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) { - return normalizedOptions; - } - - return sortStoryOptionsByPriority( - dedupeStoryOptions([ - ...normalizedOptions, - ...buildFallbackStoryMoment(state, character).options, - ]).slice(0, MIN_OPTION_POOL_SIZE), - ); -} - -function escapeRegExp(value: string) { - const specialChars = [ - '\\', - '^', - '$', - '*', - '+', - '?', - '.', - '(', - ')', - '|', - '[', - ']', - '{', - '}', - ]; - return specialChars.reduce( - (escaped, char) => escaped.split(char).join('\\' + char), - value, - ); -} - -function normalizeDialogueSpeakerName(rawSpeakerName: string) { - return rawSpeakerName - .trim() - .replace( - /^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u, - '', - ) - .replace( - /[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u, - '', - ) - .replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '') - .trim(); -} - -function parseDialogueTurns( - text: string, - npcName: string, -): StoryDialogueTurn[] { - const turns: StoryDialogueTurn[] = []; - const dialogueColonPattern = '(?:\\uFF1A|:)'; - const playerPrefixPattern = new RegExp( - '^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' + - dialogueColonPattern + - '\\\\s*(.+)$', - 'u', - ); - const npcPrefixPattern = new RegExp( - '^' + - escapeRegExp(npcName) + - '\\\\s*' + - dialogueColonPattern + - '\\\\s*(.+)$', - 'u', - ); - const namedSpeakerPattern = new RegExp( - '^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', - 'u', - ); - const lines = text - .replace(/\r/g, '') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - - for (const line of lines) { - const playerMatch = line.match(playerPrefixPattern); - const playerText = playerMatch?.[1]?.trim(); - if (playerText) { - turns.push({ speaker: 'player', text: playerText }); - continue; - } - - const npcMatch = line.match(npcPrefixPattern); - const npcText = npcMatch?.[1]?.trim(); - if (npcText) { - turns.push({ speaker: 'npc', speakerName: npcName, text: npcText }); - continue; - } - - const namedSpeakerMatch = line.match(namedSpeakerPattern); - if (namedSpeakerMatch) { - const rawSpeakerName = namedSpeakerMatch[1]; - const rawSpeakerText = namedSpeakerMatch[2]; - if (!rawSpeakerName || !rawSpeakerText) { - continue; - } - - const speakerName = normalizeDialogueSpeakerName(rawSpeakerName); - const speakerText = rawSpeakerText.trim(); - - if (speakerName && speakerText) { - turns.push({ - speaker: speakerName === npcName ? 'npc' : 'companion', - speakerName, - text: speakerText, - }); - continue; - } - } - if (line.startsWith('你:') || line.startsWith('你:')) { - turns.push({ speaker: 'player', text: line.slice(2).trim() }); - continue; - } - - if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) { - turns.push({ - speaker: 'npc', - text: line.slice(npcName.length + 1).trim(), - }); - continue; - } - - if (line.startsWith('主角:') || line.startsWith('主角:')) { - turns.push({ speaker: 'player', text: line.slice(3).trim() }); - continue; - } - - if (turns.length > 0) { - const lastTurnIndex = turns.length - 1; - const lastTurn = turns[lastTurnIndex]; - if (lastTurn) { - turns[lastTurnIndex] = { - ...lastTurn, - text: lastTurn.text + line, - }; - } - } - } - - return turns.filter((turn) => turn.text.length > 0); -} - -function buildDialogueStoryMoment( - npcName: string, - text: string, - options: StoryOption[], - streaming = false, -): StoryMoment { - return { - text, - options, - displayMode: 'dialogue', - dialogue: parseDialogueTurns(text, npcName), - streaming, - }; -} - -function hasRenderableDialogueTurns(text: string, npcName: string) { - return parseDialogueTurns(text, npcName).length >= 2; -} - -function getTypewriterDelay(char: string) { - if (/[。!?!?]/u.test(char)) return 240; - if (/[,、;;:]/u.test(char)) return 150; - if (/\s/u.test(char)) return 45; - return 90; -} - -function isCampCompanionEncounter( - encounter: GameState['currentEncounter'], -): encounter is CampCompanionEncounter { - return Boolean( - encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion', - ); -} - -function isInitialCompanionEncounter( - encounter: GameState['currentEncounter'], -): encounter is Encounter { - return Boolean( - encounter?.kind === 'npc' && - encounter.specialBehavior === 'initial_companion', - ); -} - -function _buildInitialCompanionResultText( - character: Character, - encounter: Encounter, - worldType: WorldType | null, -) { - const opening = getCharacterAdventureOpening(character, worldType); - if (!opening) { - return `${encounter.npcName}从不远处走近,先静静打量着你的反应。`; - } - - return `${encounter.npcName}现身在你眼前,周围的局势也随之悄然变化。`; -} - -function buildInitialCompanionDialogueText( - character: Character, - encounter: Encounter, - worldType: WorldType | null, -) { - const opening = getCharacterAdventureOpening(character, worldType); - const surfaceHook = - opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。'; - const immediateConcern = - opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。'; - const guardedMotive = - opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。'; - - return [ - `你:${surfaceHook}`, - `${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`, - `你:${immediateConcern}`, - `${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`, - ].join('\n'); -} - -function buildCampCompanionOpeningResultText( - character: Character, - encounter: Encounter, - worldType: WorldType | null, -) { - const opening = getCharacterAdventureOpening(character, worldType); - const campSceneName = - worldType ? getWorldCampScenePreset(worldType)?.name ?? '归处' : '归处'; - if (!opening) { - return `${encounter.npcName} 已经来到你身边。在${campSceneName},你稍作停顿,决定下一步去向何方。`; - } - - return `${encounter.npcName} 在${campSceneName}来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`; -} - -function _buildCampCompanionChatResultText( - encounter: Encounter, - affinityGain: number, - nextAffinity: number, -) { - const teamworkText = - affinityGain > 0 - ? '你也对接下来的合作感到更加自信了一些。' - : '至少你们为接下来的行动重新调整了节奏。'; - return `${encounter.npcName}和你交换了一番想法,${describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`; -} - -function isNpcEncounter( - encounter: GameState['currentEncounter'], -): encounter is Encounter { - return Boolean(encounter?.kind === 'npc'); -} - -function isRegularNpcEncounter( - encounter: GameState['currentEncounter'], -): encounter is Encounter { - return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior); -} - -function getNpcEncounterKey(encounter: Encounter) { - return encounter.id ?? encounter.npcName; -} - -function cloneInventoryItemForOwner( - item: InventoryItem, - owner: 'player' | 'npc', - quantity = 1, -) { - const preserveIdentity = Boolean( - item.runtimeMetadata || - item.buildProfile || - item.equipmentSlotId || - item.statProfile || - item.attributeResonance, - ); - - return { - ...item, - id: preserveIdentity - ? `${owner}:${item.id}:${quantity}` - : `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`, - quantity, - runtimeMetadata: item.runtimeMetadata - ? { - ...item.runtimeMetadata, - seedKey: `${item.runtimeMetadata.seedKey}:${owner}`, - } - : item.runtimeMetadata, - }; -} - -function tickCooldownMap(cooldowns: Record) { - return Object.fromEntries( - Object.entries(cooldowns).map(([skillId, turns]) => [ - skillId, - Math.max(0, turns - 1), - ]), - ); -} - export function useStoryGeneration({ gameState, setGameState, @@ -1149,807 +62,54 @@ export function useStoryGeneration({ buildResolvedChoiceState: ( state: GameState, option: StoryOption, - character: Character, + character: NonNullable, ) => ResolvedChoiceState; playResolvedChoice: ( state: GameState, option: StoryOption, - character: Character, + character: NonNullable, resolvedChoice: ResolvedChoiceState, sync?: ResolvedChoicePlaybackSync, ) => Promise; }) { - const [currentStory, setCurrentStory] = useState(null); - const [aiError, setAiError] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [battleReward, setBattleReward] = useState( - null, - ); - const [preparedOpeningAdventure, setPreparedOpeningAdventure] = - useState(null); - const [goalPulse, setGoalPulse] = useState(null); - const previousGoalPulseSnapshotRef = - useRef | null>(null); const { characterChatUi, clearCharacterChatModal } = useCharacterChatFlow({ gameState, setGameState, buildStoryContextFromState, }); - const getResolvedNpcState = (state: GameState, encounter: Encounter) => - state.npcStates[getNpcEncounterKey(encounter)] ?? - buildInitialNpcState(encounter, state.worldType, state); - - const buildNpcStory = useCallback( - ( - state: GameState, - character: Character, - encounter: Encounter, - overrideText?: string, - ) => - buildNpcEncounterStoryMoment({ - state, - encounter, - npcState: getResolvedNpcState(state, encounter), - playerCharacter: character, - playerInventory: state.playerInventory, - activeQuests: state.quests, - scene: state.currentScenePreset, - partySize: state.companions.length, - overrideText, - worldType: state.worldType, - }), - [], - ); - - const getCampCompanionHomeScene = ( - state: GameState, - character: Character, - ) => { - if (!state.worldType) return null; - const sceneId = getCharacterHomeSceneId(state.worldType, character.id); - return getScenePresetById(state.worldType, sceneId); - }; - - const getCampCompanionTravelScene = useCallback( - (state: GameState, character: Character) => { - if (!state.worldType) return null; - - const campScene = getWorldCampScenePreset(state.worldType); - const homeScene = getCampCompanionHomeScene(state, character); - if ( - homeScene && - homeScene.id !== campScene?.id && - homeScene.id !== state.currentScenePreset?.id - ) { - return homeScene; - } - - const fallbackSceneId = - campScene?.id ?? state.currentScenePreset?.id ?? null; - return ( - getForwardScenePreset(state.worldType, fallbackSceneId) ?? - getTravelScenePreset(state.worldType, fallbackSceneId) ?? - homeScene - ); - }, - [], - ); - - const buildCampCompanionOpeningOptions = useCallback( - (state: GameState, character: Character, encounter: Encounter) => { - const targetScene = getCampCompanionTravelScene(state, character); - const baseOptions = buildNpcStory(state, character, encounter).options; - const chatOptions = baseOptions - .filter((option) => option.functionId === NPC_CHAT_FUNCTION.id) - .slice(0, 1); - const recruitOption = - baseOptions.find( - (option) => option.functionId === NPC_RECRUIT_FUNCTION.id, - ) ?? null; - const openingOptions = recruitOption - ? [...chatOptions, recruitOption] - : chatOptions; - - if (!targetScene) { - return openingOptions; - } - - return [...openingOptions, buildCampTravelHomeOption(targetScene.name)]; - }, - [buildNpcStory, getCampCompanionTravelScene], - ); - - const inferOpeningCampFollowupOptions = useCallback( - async ( - state: GameState, - character: Character, - baseOptions: StoryOption[], - openingBackground: string, - openingDialogue: string, - ) => { - if (!state.worldType || baseOptions.length === 0) { - return baseOptions; - } - - try { - const response = await generateNextStep( - state.worldType, - character, - getStoryGenerationHostileNpcs(state), - state.storyHistory, - '继续承接营地中的这段交谈,并整理出眼下最自然的后续行动。', - buildStoryContextFromState(state, { - openingCampBackground: openingBackground, - openingCampDialogue: openingDialogue, - }), - { - availableOptions: baseOptions, - }, - ); - - return sortStoryOptionsByPriority(response.options); - } catch (error) { - console.error('Failed to infer opening camp follow-up options:', error); - return baseOptions; - } - }, - [], - ); - - const buildOpeningCampChatContext = ( - state: GameState, - character: Character, - encounter: Encounter, - ) => { - if (encounter.specialBehavior !== 'camp_companion') { - return {}; - } - - const npcState = - state.npcStates[getNpcEncounterKey(encounter)] ?? - buildInitialNpcState(encounter, state.worldType, state); - if (npcState.chattedCount > 2) { - return {}; - } - - const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`; - let openingDialogue: string | null = null; - - for (let index = 0; index < state.storyHistory.length - 1; index += 1) { - const entry = state.storyHistory[index]; - if (!entry) { - continue; - } - if (entry.historyRole !== 'action' || entry.text !== openingActionText) { - continue; - } - - for ( - let nextIndex = index + 1; - nextIndex < state.storyHistory.length; - nextIndex += 1 - ) { - const nextEntry = state.storyHistory[nextIndex]; - if (!nextEntry) { - continue; - } - if (nextEntry.historyRole === 'action') { - break; - } - if (nextEntry.text.trim()) { - openingDialogue = nextEntry.text; - break; - } - } - - if (openingDialogue) { - break; - } - } - - if (!openingDialogue) { - return {}; - } - - return { - openingCampBackground: buildCampCompanionOpeningResultText( - character, - encounter, - state.worldType, - ), - openingCampDialogue: openingDialogue, - }; - }; - - const buildCampCompanionIdleOptions = useCallback( - ( - state: GameState, - character: Character, - encounter: Encounter, - overrideText?: string, - ): StoryMoment => { - const targetScene = getCampCompanionTravelScene(state, character); - const baseStory = buildNpcStory( - state, - character, - encounter, - overrideText, - ); - const filteredOptions = baseStory.options.filter( - (option) => - option.functionId !== NPC_LEAVE_FUNCTION.id && - option.functionId !== NPC_FIGHT_FUNCTION.id, - ); - - if (!targetScene) { - return { - ...baseStory, - options: filteredOptions, - }; - } - - return { - ...baseStory, - options: [ - ...filteredOptions.slice(0, 2), - buildCampTravelHomeOption(targetScene.name), - ...filteredOptions.slice(2), - ], - }; - }, - [buildNpcStory, getCampCompanionTravelScene], - ); - - const getAvailableOptionsForState = useCallback( - (state: GameState, character: Character) => { - if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) { - return buildCampCompanionIdleOptions( - state, - character, - state.currentEncounter, - ).options; - } - - if ( - isInitialCompanionEncounter(state.currentEncounter) && - !state.inBattle && - !state.npcInteractionActive - ) { - return buildNpcPreviewStory(state, character, state.currentEncounter) - .options; - } - - if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) { - if (!state.npcInteractionActive) { - return buildNpcPreviewStory(state, character, state.currentEncounter) - .options; - } - return buildNpcStory(state, character, state.currentEncounter).options; - } - - if (isNpcEncounter(state.currentEncounter) && !state.inBattle) { - return buildNpcStory(state, character, state.currentEncounter).options; - } - - if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) { - return buildTreasureStory(state, character, state.currentEncounter) - .options; - } - - return null; - }, - [buildCampCompanionIdleOptions, buildNpcStory], - ); - - const buildFallbackStoryForState = useCallback( - (state: GameState, character: Character, fallbackText?: string) => { - if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) { - return buildCampCompanionIdleOptions( - state, - character, - state.currentEncounter, - fallbackText, - ); - } - - if ( - isInitialCompanionEncounter(state.currentEncounter) && - !state.inBattle && - !state.npcInteractionActive - ) { - return buildNpcPreviewStory( - state, - character, - state.currentEncounter, - fallbackText, - ); - } - - if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) { - if (!state.npcInteractionActive) { - return buildNpcPreviewStory( - state, - character, - state.currentEncounter, - fallbackText, - ); - } - return buildNpcStory( - state, - character, - state.currentEncounter, - fallbackText, - ); - } - - if (isNpcEncounter(state.currentEncounter) && !state.inBattle) { - return buildNpcStory( - state, - character, - state.currentEncounter, - fallbackText, - ); - } - - if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) { - return buildTreasureStory( - state, - character, - state.currentEncounter, - fallbackText, - ); - } - - const fallback = buildFallbackStoryMoment(state, character); - return fallbackText - ? { - ...fallback, - text: fallbackText, - } - : fallback; - }, - [buildCampCompanionIdleOptions, buildNpcStory], - ); - - const buildStoryFromResponse = useCallback( - ( - state: GameState, - character: Character, - response: StoryMoment, - availableOptions: StoryOption[] | null, - optionCatalog: StoryOption[] | null = null, - ) => ({ - text: response.text, - options: resolveStoryResponseOptions({ - responseOptions: response.options, - availableOptions, - optionCatalog, - getSanitizedOptions: () => - sanitizeOptions(response.options, character, state), - }), - }), - [], - ); - - const generateStoryForState = useCallback( - async ({ - state, - character, - history, - choice, - lastFunctionId, - optionCatalog, - }: { - state: GameState; - character: Character; - history: StoryMoment[]; - choice?: string; - lastFunctionId?: string | null; - optionCatalog?: StoryOption[] | null; - }) => { - if (!state.worldType) { - throw new Error( - 'The current world is not initialized, so story generation cannot continue.', - ); - } - - const resolvedOptionCatalog = - optionCatalog && optionCatalog.length > 0 ? optionCatalog : null; - const availableOptions = resolvedOptionCatalog - ? null - : getAvailableOptionsForState(state, character); - const response = choice - ? await generateNextStep( - state.worldType, - character, - getStoryGenerationHostileNpcs(state), - history, - choice, - buildStoryContextFromState(state, { - lastFunctionId, - }), - availableOptions - ? { availableOptions } - : resolvedOptionCatalog - ? { optionCatalog: resolvedOptionCatalog } - : undefined, - ) - : await generateInitialStory( - state.worldType, - character, - getStoryGenerationHostileNpcs(state), - buildStoryContextFromState(state), - availableOptions - ? { availableOptions } - : resolvedOptionCatalog - ? { optionCatalog: resolvedOptionCatalog } - : undefined, - ); - - return buildStoryFromResponse( - state, - character, - { - text: response.storyText, - options: response.options, - }, - availableOptions, - resolvedOptionCatalog, - ); - }, - [buildStoryFromResponse, getAvailableOptionsForState], - ); - - const updateNpcState = ( - state: GameState, - encounter: Encounter, - updater: ( - npcState: ReturnType, - ) => ReturnType, - ) => ({ - ...state, - npcStates: { - ...state.npcStates, - [getNpcEncounterKey(encounter)]: normalizeNpcPersistentState( - syncNpcNarrativeState({ - encounter, - npcState: updater(getResolvedNpcState(state, encounter)), - customWorldProfile: state.customWorldProfile, - storyEngineMemory: state.storyEngineMemory, - }), - ), - }, - }); - - const updateQuestLog = ( - state: GameState, - updater: (quests: GameState['quests']) => GameState['quests'], - ) => ({ - ...state, - quests: updater(state.quests), - }); - - const incrementRuntimeStats = ( - state: GameState, - increments: Parameters[1], - ) => ({ - ...state, - runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments), - }); - - const progressTreasureQuest = (state: GameState, sceneId: string | null) => - updateQuestLog(state, (quests) => - applyQuestProgressFromTreasure(quests, sceneId), - ); - - const appendHistory = useCallback(appendStoryHistory, []); - - const prepareOpeningAdventure = useCallback( - (state: GameState, character: Character): PreparedOpeningAdventure | null => - buildPreparedOpeningAdventureState({ - state, - character, - getNpcEncounterKey, - appendHistory, - buildCampCompanionOpeningOptions, - buildCampCompanionOpeningResultText, - buildInitialCompanionDialogueText, - }), - [appendHistory, buildCampCompanionOpeningOptions], - ); - - useEffect(() => { - if ( - gameState.currentScene !== 'Story' || - !gameState.playerCharacter || - gameState.storyHistory.length > 0 || - currentStory || - !isNpcEncounter(gameState.currentEncounter) || - gameState.currentEncounter.specialBehavior !== 'initial_companion' - ) { - setPreparedOpeningAdventure(null); - return; - } - - setPreparedOpeningAdventure( - prepareOpeningAdventure(gameState, gameState.playerCharacter), - ); - }, [ - prepareOpeningAdventure, - currentStory, - gameState, - gameState.currentEncounter, - gameState.currentScene, - gameState.playerCharacter, - gameState.storyHistory, - ]); - - const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } = - createStoryProgressionActions({ - gameState, - setGameState, - setCurrentStory, - setAiError, - setIsLoading, - generateStoryForState, - buildFallbackStoryForState, - }); - - const startOpeningAdventure = useCallback(async () => { - if ( - !gameState.playerCharacter || - !isNpcEncounter(gameState.currentEncounter) - ) { - return; - } - - const encounter = gameState.currentEncounter; - if (encounter.specialBehavior !== 'initial_companion') { - return; - } - - const preparedStory = - preparedOpeningAdventure?.encounterKey === getNpcEncounterKey(encounter) - ? preparedOpeningAdventure - : prepareOpeningAdventure(gameState, gameState.playerCharacter); - - if (!preparedStory) { - return; - } - - await playOpeningAdventureSequence({ - gameState, - character: gameState.playerCharacter, - encounter, - preparedStory, - setGameState, - setCurrentStory, - setAiError, - setIsLoading, - buildDialogueStoryMoment, - buildStoryContextFromState, - getStoryGenerationHostileNpcs, - hasRenderableDialogueTurns, - inferOpeningCampFollowupOptions, - getTypewriterDelay, - }); - }, [ - gameState, - inferOpeningCampFollowupOptions, - prepareOpeningAdventure, - preparedOpeningAdventure, - setGameState, - ]); - - const { handleTreasureInteraction } = useTreasureFlow({ - gameState, - commitGeneratedState, - progressTreasureQuest, - }); - const { inventoryUi } = useStoryInventoryActions({ - gameState, - commitGeneratedState, - tickCooldowns: tickCooldownMap, - }); - const npcInteractionFlow = useStoryNpcInteractionFlow({ + const runtimeController = useStoryRuntimeController({ gameState, setGameState, - commitGeneratedState, - getNpcEncounterKey, - getResolvedNpcState, - updateNpcState, - cloneInventoryItemForOwner, - runtime: { - setCurrentStory, - setAiError, - setIsLoading, - buildStoryContextFromState, - buildFallbackStoryForState, - buildDialogueStoryMoment, - generateStoryForState, - getStoryGenerationHostileNpcs, - getTypewriterDelay, - }, + buildStoryContextFromState, }); - const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } = - createStoryNpcEncounterActions({ - gameState, - setGameState, - setCurrentStory, - setAiError, - setIsLoading, - commitGeneratedState, - commitGeneratedStateWithEncounterEntry, - appendHistory, - buildOpeningCampChatContext, - buildStoryContextFromState, - buildFallbackStoryForState, - buildDialogueStoryMoment, - generateStoryForState, - getStoryGenerationHostileNpcs, - getTypewriterDelay, - getAvailableOptionsForState, - sanitizeOptions, - sortOptions: sortStoryOptionsByPriority, - buildContinueAdventureOption, - getNpcEncounterKey, - getResolvedNpcState, - updateNpcState, - cloneInventoryItemForOwner, - resolveNpcInteractionDecision, - npcInteractionFlow, - }); - useEffect(() => { - const startStory = async () => { - if ( - gameState.currentScene !== 'Story' || - !gameState.worldType || - !gameState.playerCharacter || - currentStory || - isLoading - ) { - return; - } - - if ( - gameState.storyHistory.length === 0 && - isInitialCompanionEncounter(gameState.currentEncounter) && - !gameState.npcInteractionActive - ) { - setAiError(null); - void startOpeningAdventure(); - return; - } - - setIsLoading(true); - try { - setAiError(null); - const nextStory = await generateStoryForState({ - state: gameState, - character: gameState.playerCharacter, - history: [], - }); - setGameState(applyStoryReasoningRecovery(gameState)); - setCurrentStory(nextStory); - } catch (error) { - console.error('Failed to start story:', error); - setAiError(error instanceof Error ? error.message : '未知智能生成错误'); - setCurrentStory( - buildFallbackStoryForState(gameState, gameState.playerCharacter), - ); - } finally { - setIsLoading(false); - } - }; - - startStory(); - }, [ - buildFallbackStoryForState, - generateStoryForState, - currentStory, - gameState, - gameState.currentEncounter, - gameState.currentScene, - gameState.inBattle, - gameState.playerCharacter, - gameState.playerX, - gameState.worldType, - isLoading, - setGameState, - startOpeningAdventure, - ]); - - const runtimeGoalStack = useMemo( - () => - buildGoalStackState({ - quests: gameState.quests, - worldType: gameState.worldType, - currentSceneId: gameState.currentScenePreset?.id ?? null, - chapterState: - gameState.chapterState - ?? gameState.storyEngineMemory?.currentChapter - ?? null, - journeyBeat: gameState.storyEngineMemory?.currentJourneyBeat ?? null, - setpieceDirective: - gameState.storyEngineMemory?.currentSetpieceDirective ?? null, - currentCampEvent: - gameState.storyEngineMemory?.currentCampEvent ?? null, - currentSceneName: gameState.currentScenePreset?.name ?? null, - }), - [ - gameState.chapterState, - gameState.currentScenePreset?.id, - gameState.currentScenePreset?.name, - gameState.quests, - gameState.storyEngineMemory?.currentCampEvent, - gameState.storyEngineMemory?.currentChapter, - gameState.storyEngineMemory?.currentJourneyBeat, - gameState.storyEngineMemory?.currentSetpieceDirective, - gameState.worldType, - ], - ); - useEffect(() => { - const currentSnapshot = createGoalPulseSnapshot( - gameState.quests, - runtimeGoalStack, - ); - const previousSnapshot = previousGoalPulseSnapshotRef.current; - - if (!previousSnapshot) { - previousGoalPulseSnapshotRef.current = currentSnapshot; - return; - } - - const nextPulse = deriveGoalPulseEvent({ - previous: previousSnapshot, - quests: gameState.quests, - goalStack: runtimeGoalStack, - }); - if (nextPulse) { - setGoalPulse(nextPulse); - } - - previousGoalPulseSnapshotRef.current = currentSnapshot; - }, [ - gameState.quests, - runtimeGoalStack, - ]); const { displayedOptions, canRefreshOptions, handleRefreshOptions, - resetStoryOptions, - } = useStoryOptions(currentStory, runtimeGoalStack); - const { handleChoice } = createStoryChoiceActions({ + handleChoice, + resetStoryState, + hydrateStoryState, + travelToSceneFromMap, + battleRewardUi, + questUi, + goalUi, + npcUi, + inventoryUi, + } = useStoryFlowCoordinator({ gameState, - currentStory, - isLoading, setGameState, - setCurrentStory, - setAiError, - setIsLoading, - setBattleReward, buildResolvedChoiceState, playResolvedChoice, - buildStoryContextFromState, - buildStoryFromResponse, - buildFallbackStoryForState, - generateStoryForState, - getAvailableOptionsForState, getStoryGenerationHostileNpcs, getResolvedSceneHostileNpcs, - buildNpcStory, - updateQuestLog, - incrementRuntimeStats, - getCampCompanionTravelScene, - startOpeningAdventure, - enterNpcInteraction, - handleNpcInteraction, - handleTreasureInteraction, - commitGeneratedStateWithEncounterEntry, - finalizeNpcBattleResult, + runtimeController, + runtimeSupport: storyRuntimeSupport, + sortOptions: sortStoryOptionsByPriority, + buildContinueAdventureOption, + resolveNpcInteractionDecision, + clearCharacterChatModal, isContinueAdventureOption, isCampTravelHomeOption, isInitialCompanionEncounter, @@ -1959,70 +119,26 @@ export function useStoryGeneration({ fallbackCompanionName: FALLBACK_COMPANION_NAME, turnVisualMs: TURN_VISUAL_MS, }); - const dismissGoalPulse = useCallback(() => { - setGoalPulse(null); - }, []); - - const clearStoryRuntimeUi = useCallback(() => { - resetStoryOptions(); - setAiError(null); - setIsLoading(false); - setPreparedOpeningAdventure(null); - setBattleReward(null); - dismissGoalPulse(); - previousGoalPulseSnapshotRef.current = null; - npcInteractionFlow.clearNpcInteractionUi(); - clearCharacterChatModal(); - }, [ - clearCharacterChatModal, - dismissGoalPulse, - npcInteractionFlow, - resetStoryOptions, - ]); - - const { - acknowledgeQuestCompletion, - claimQuestReward, - resetStoryState, - hydrateStoryState, - travelToSceneFromMap, - } = createStorySessionActions({ - gameState, - isLoading, - setGameState, - setCurrentStory, - clearStoryRuntimeUi, - commitGeneratedState, - buildFallbackStoryForState, - }); return { - currentStory, - isLoading, - aiError, + currentStory: runtimeController.currentStory, + isLoading: runtimeController.isLoading, + aiError: runtimeController.aiError, displayedOptions, canRefreshOptions, handleRefreshOptions, handleChoice, - startOpeningAdventure, - isOpeningAdventureReady: Boolean(preparedOpeningAdventure), + startOpeningAdventure: runtimeController.startOpeningAdventure, + isOpeningAdventureReady: Boolean( + runtimeController.preparedOpeningAdventure, + ), resetStoryState, hydrateStoryState, travelToSceneFromMap, - battleRewardUi: { - reward: battleReward, - dismiss: () => setBattleReward(null), - } satisfies BattleRewardUi, - questUi: { - acknowledgeQuestCompletion, - claimQuestReward, - } satisfies QuestFlowUi, - goalUi: { - goalStack: runtimeGoalStack, - pulse: goalPulse, - dismissPulse: dismissGoalPulse, - } satisfies GoalFlowUi, - npcUi: npcInteractionFlow.npcUi, + battleRewardUi: battleRewardUi satisfies BattleRewardUi, + questUi: questUi satisfies QuestFlowUi, + goalUi, + npcUi, characterChatUi, inventoryUi, }; diff --git a/src/hooks/useTreasureFlow.ts b/src/hooks/useTreasureFlow.ts index 89fb4302..8e2a635a 100644 --- a/src/hooks/useTreasureFlow.ts +++ b/src/hooks/useTreasureFlow.ts @@ -1,97 +1,73 @@ import { useCallback } from 'react'; -import { addInventoryItems } from '../data/npcInteractions'; -import { - buildTreasureEncounterStoryMoment, - buildTreasureResultText, - resolveTreasureReward, -} from '../data/treasureInteractions'; -import { appendStoryEngineCarrierMemory } from '../services/storyEngine/echoMemory'; -import { Character, Encounter, GameState, StoryMoment, StoryOption } from '../types'; -import type {CommitGeneratedState} from './generatedState'; - -type ProgressTreasureQuest = (state: GameState, sceneId: string | null) => GameState; - -export function isTreasureEncounter(encounter: GameState['currentEncounter']): encounter is Encounter { - return Boolean(encounter?.kind === 'treasure'); -} - -export function buildTreasureStory( - state: GameState, - _character: Character, - encounter: Encounter, - overrideText?: string, -): StoryMoment { - return buildTreasureEncounterStoryMoment({ - state, - encounter, - overrideText, - }); -} +import { resolveServerRuntimeChoice } from './story/runtimeStoryCoordinator'; +import { Character, GameState, StoryMoment, StoryOption } from '../types'; export function useTreasureFlow({ gameState, - commitGeneratedState, - progressTreasureQuest, + runtime, }: { gameState: GameState; - commitGeneratedState: CommitGeneratedState; - progressTreasureQuest: ProgressTreasureQuest; + runtime: { + currentStory: StoryMoment | null; + setGameState: (state: GameState) => void; + setCurrentStory: (story: StoryMoment) => void; + setAiError: (message: string | null) => void; + setIsLoading: (loading: boolean) => void; + buildFallbackStoryForState: ( + state: GameState, + character: Character, + fallbackText?: string, + ) => StoryMoment; + }; }) { - const handleTreasureInteraction = useCallback((option: StoryOption) => { - if (!gameState.playerCharacter || option.interaction?.kind !== 'treasure' || gameState.currentEncounter?.kind !== 'treasure') { - return false; - } + const handleTreasureInteraction = useCallback( + async (option: StoryOption) => { + if ( + !gameState.playerCharacter || + option.interaction?.kind !== 'treasure' || + gameState.currentEncounter?.kind !== 'treasure' + ) { + return false; + } - const encounter = gameState.currentEncounter; - const action = option.interaction.action; - const reward = action === 'leave' - ? null - : resolveTreasureReward(gameState, encounter, action); - const progressedState = action === 'leave' - ? gameState - : progressTreasureQuest(gameState, gameState.currentScenePreset?.id ?? null); + runtime.setAiError(null); + runtime.setIsLoading(true); - const nextState: GameState = appendStoryEngineCarrierMemory({ - ...progressedState, - currentEncounter: null, - npcInteractionActive: false, - sceneHostileNpcs: [], - playerX: 0, - playerFacing: 'right' as const, - animationState: progressedState.animationState, - scrollWorld: false, - inBattle: false, - playerHp: reward - ? Math.min(progressedState.playerMaxHp, progressedState.playerHp + reward.hp) - : progressedState.playerHp, - playerMana: reward - ? Math.min(progressedState.playerMaxMana, progressedState.playerMana + reward.mana) - : progressedState.playerMana, - playerCurrency: reward - ? progressedState.playerCurrency + reward.currency - : progressedState.playerCurrency, - playerInventory: reward - ? addInventoryItems(progressedState.playerInventory, reward.items) - : progressedState.playerInventory, - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - }, reward?.items ?? []); + try { + const { hydratedSnapshot, nextStory } = + await resolveServerRuntimeChoice({ + gameState, + currentStory: runtime.currentStory, + option, + }); - void commitGeneratedState( - nextState, - gameState.playerCharacter, - option.actionText, - buildTreasureResultText(encounter, action, reward ?? undefined), - option.functionId, - ); - return true; - }, [commitGeneratedState, gameState, progressTreasureQuest]); + runtime.setGameState(hydratedSnapshot.gameState); + runtime.setCurrentStory(nextStory); + return true; + } catch (error) { + console.error( + 'Failed to resolve treasure runtime action on the server:', + error, + ); + runtime.setAiError( + error instanceof Error ? error.message : '宝藏动作执行失败', + ); + if (!runtime.currentStory) { + runtime.setCurrentStory( + runtime.buildFallbackStoryForState( + gameState, + gameState.playerCharacter, + ), + ); + } + return false; + } finally { + runtime.setIsLoading(false); + } + }, + [gameState, runtime], + ); return { handleTreasureInteraction, diff --git a/src/index.css b/src/index.css index 7d6f84db..9bf518ae 100644 --- a/src/index.css +++ b/src/index.css @@ -31,6 +31,58 @@ body { font-family: "Fusion Pixel", "Inter", ui-sans-serif, system-ui, sans-serif !important; } +.selection-hero-brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.selection-hero-brand--left { + align-items: flex-start; + text-align: left; +} + +.selection-hero-brand__title { + font-family: "Noto Serif SC", "Georgia", serif !important; + font-size: clamp(3rem, 10vw, 4.6rem); + font-weight: 700; + line-height: 0.95; + letter-spacing: 0.22em; + color: #fffdf7; + text-shadow: + 0 2px 0 rgba(0, 0, 0, 0.68), + 0 10px 28px rgba(0, 0, 0, 0.48), + 0 0 18px rgba(251, 191, 36, 0.12); +} + +.selection-hero-brand__subtitle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: clamp(0.55rem, 2vw, 0.95rem); + font-family: "Inter", ui-sans-serif, system-ui, sans-serif !important; + font-size: clamp(0.72rem, 2vw, 0.92rem); + font-weight: 600; + letter-spacing: 0.42em; + text-transform: uppercase; + color: rgba(228, 228, 231, 0.88); + text-shadow: 0 1px 10px rgba(0, 0, 0, 0.42); +} + +.selection-hero-brand--left .selection-hero-brand__subtitle { + justify-content: flex-start; +} + +.selection-hero-brand__subtitle::before, +.selection-hero-brand__subtitle::after { + content: ''; + width: clamp(1.75rem, 8vw, 3.2rem); + height: 1px; + background: linear-gradient(90deg, transparent, rgba(245, 158, 11, 0.72), transparent); + opacity: 0.82; +} + .fusion-pixel-app .story-top-tabs { display: none !important; } @@ -495,6 +547,18 @@ button { --ui-scale: 0.8; } + .selection-hero-brand { + gap: 0.6rem; + } + + .selection-hero-brand__title { + letter-spacing: 0.16em; + } + + .selection-hero-brand__subtitle { + letter-spacing: 0.28em; + } + .world-carousel { --world-card-height: 8.75rem; max-width: 100%; diff --git a/src/persistence/gameSaveStorage.ts b/src/persistence/gameSaveStorage.ts index 77ef6899..a175bdb0 100644 --- a/src/persistence/gameSaveStorage.ts +++ b/src/persistence/gameSaveStorage.ts @@ -1,58 +1,18 @@ +import { + type SavedGameSnapshot as SharedSavedGameSnapshot, + type SavedGameSnapshotInput as SharedSavedGameSnapshotInput, +} from '../../packages/shared/src/contracts/runtime'; import type {GameState, StoryMoment} from '../types'; import type {BottomTab} from '../types/navigation'; -import {isRecord, readStoredJson, removeStoredJson, writeStoredJson} from './storage'; -const SAVE_STORAGE_KEY = 'tavernrealms.save.v1'; -const SAVE_VERSION = 2; +export type SavedGameSnapshot = SharedSavedGameSnapshot< + GameState, + BottomTab, + StoryMoment +>; -export type SavedGameSnapshot = { - version: number; - savedAt: string; - gameState: GameState; - bottomTab: BottomTab; - currentStory: StoryMoment | null; -}; - -export type SavedGameSnapshotInput = Omit & { - savedAt?: string; -}; - -function parseSavedSnapshot(value: unknown): SavedGameSnapshot | null { - if (!isRecord(value)) { - return null; - } - - if (value.version !== SAVE_VERSION || typeof value.savedAt !== 'string') { - return null; - } - - if (!('gameState' in value) || !('bottomTab' in value) || !('currentStory' in value)) { - return null; - } - - return value as SavedGameSnapshot; -} - -export function readSavedSnapshot() { - return readStoredJson({ - key: SAVE_STORAGE_KEY, - parse: parseSavedSnapshot, - }); -} - -export function writeSavedSnapshot(snapshot: SavedGameSnapshotInput) { - return writeStoredJson({ - key: SAVE_STORAGE_KEY, - value: { - version: SAVE_VERSION, - savedAt: snapshot.savedAt ?? new Date().toISOString(), - gameState: snapshot.gameState, - bottomTab: snapshot.bottomTab, - currentStory: snapshot.currentStory, - } satisfies SavedGameSnapshot, - }); -} - -export function clearSavedSnapshot() { - removeStoredJson(SAVE_STORAGE_KEY); -} +export type SavedGameSnapshotInput = SharedSavedGameSnapshotInput< + GameState, + BottomTab, + StoryMoment +>; diff --git a/src/persistence/gameSettingsStorage.ts b/src/persistence/gameSettingsStorage.ts index 5cf473b1..74a011ea 100644 --- a/src/persistence/gameSettingsStorage.ts +++ b/src/persistence/gameSettingsStorage.ts @@ -1,12 +1,14 @@ +import { + DEFAULT_MUSIC_VOLUME, + type RuntimeSettings, +} from '../../packages/shared/src/contracts/runtime'; import {isRecord, readStoredJson, writeStoredJson} from './storage'; const SETTINGS_STORAGE_KEY = 'tavernrealms.settings.v1'; const SETTINGS_STORAGE_VERSION = 1; -export const DEFAULT_MUSIC_VOLUME = 0.42; -export type SavedGameSettings = { - musicVolume: number; -}; +export type SavedGameSettings = RuntimeSettings; +export { DEFAULT_MUSIC_VOLUME }; type StoredGameSettings = SavedGameSettings & { version: number; diff --git a/src/persistence/runtimeSnapshot.test.ts b/src/persistence/runtimeSnapshot.test.ts new file mode 100644 index 00000000..b88c8549 --- /dev/null +++ b/src/persistence/runtimeSnapshot.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import type { GameState, StoryMoment } from '../types'; +import { + resolveHydratedSnapshotState, +} from './runtimeSnapshot'; + +function createStory( + text: string, + streaming = false, +): StoryMoment { + return { + text, + options: [], + streaming, + }; +} + +describe('runtimeSnapshot', () => { + it('keeps server-hydrated snapshots unchanged', () => { + const snapshot = { + gameState: { + playerCharacter: { + id: 'sword-princess', + }, + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + runtimeActionVersion: 3, + runtimeSessionId: 'runtime-main', + } as GameState, + currentStory: createStory('服务端恢复故事'), + bottomTab: 'inventory', + }; + + expect(resolveHydratedSnapshotState(snapshot)).toBe(snapshot); + }); + + it('only applies minimal local shape hydration for non-hydrated legacy snapshots', () => { + const snapshot = { + gameState: { + worldType: 'WUXIA', + customWorldProfile: null, + playerCharacter: { + id: 'sword-princess', + }, + playerHp: 999, + playerMaxHp: 12, + playerMana: 999, + playerMaxMana: 12, + runtimeActionVersion: undefined, + runtimeSessionId: undefined, + playerEquipment: null, + } as unknown as GameState, + currentStory: createStory('旧快照故事', true), + bottomTab: 'unknown', + }; + + const hydrated = resolveHydratedSnapshotState(snapshot); + + expect(hydrated.bottomTab).toBe('adventure'); + expect(hydrated.currentStory?.streaming).toBe(false); + expect(hydrated.gameState.playerEquipment).toEqual({ + weapon: null, + armor: null, + relic: null, + }); + expect(hydrated.gameState.playerMaxHp).toBe(12); + expect(hydrated.gameState.playerHp).toBe(12); + expect(hydrated.gameState.playerMaxMana).toBe(12); + expect(hydrated.gameState.playerMana).toBe(12); + }); +}); diff --git a/src/persistence/runtimeSnapshot.ts b/src/persistence/runtimeSnapshot.ts new file mode 100644 index 00000000..61174212 --- /dev/null +++ b/src/persistence/runtimeSnapshot.ts @@ -0,0 +1,112 @@ +import type { GameState, StoryMoment } from '../types'; +import type { BottomTab } from '../types/navigation'; +import type { + HydratableGameState, + HydratedGameState, + HydratedSnapshotState, + SnapshotState, +} from './runtimeSnapshotTypes'; + +function normalizeBottomTab( + bottomTab: string | null | undefined, +): BottomTab { + return bottomTab === 'character' || bottomTab === 'inventory' + ? bottomTab + : 'adventure'; +} + +function normalizeEquipmentLoadout( + playerEquipment: HydratableGameState['playerEquipment'], +) { + if (!playerEquipment || typeof playerEquipment !== 'object') { + return null; + } + + return { + weapon: playerEquipment.weapon ?? null, + armor: playerEquipment.armor ?? null, + relic: playerEquipment.relic ?? null, + } satisfies GameState['playerEquipment']; +} + +function createEmptyEquipmentLoadout() { + return { + weapon: null, + armor: null, + relic: null, + } satisfies GameState['playerEquipment']; +} + +export function normalizeSavedStory(story: StoryMoment | null) { + if (!story) { + return null; + } + + return { + ...story, + streaming: false, + } satisfies StoryMoment; +} + +export function normalizeSavedGameState(gameState: GameState) { + const hydratableState = gameState as HydratableGameState; + const resolvedEquipment = normalizeEquipmentLoadout( + hydratableState.playerEquipment, + ); + + const playerMaxHp = Math.max(1, hydratableState.playerMaxHp); + const playerMaxMana = Math.max(1, hydratableState.playerMaxMana); + + return { + ...hydratableState, + playerMaxHp, + playerHp: Math.min(hydratableState.playerHp, playerMaxHp), + playerMaxMana, + playerMana: Math.min(hydratableState.playerMana, playerMaxMana), + playerEquipment: resolvedEquipment ?? createEmptyEquipmentLoadout(), + runtimeActionVersion: + typeof hydratableState.runtimeActionVersion === 'number' + ? hydratableState.runtimeActionVersion + : 0, + runtimeSessionId: + typeof hydratableState.runtimeSessionId === 'string' + ? hydratableState.runtimeSessionId + : null, + } satisfies HydratedGameState; +} + +export function hydrateSnapshotState(snapshot: { + gameState: GameState; + currentStory: StoryMoment | null; + bottomTab: string; +}): HydratedSnapshotState { + return { + gameState: normalizeSavedGameState(snapshot.gameState), + currentStory: normalizeSavedStory(snapshot.currentStory), + bottomTab: normalizeBottomTab(snapshot.bottomTab), + }; +} + +export function isHydratedSnapshotState( + snapshot: SnapshotState, +): snapshot is HydratedSnapshotState { + const { gameState, currentStory, bottomTab } = snapshot; + + return Boolean( + (bottomTab === 'adventure' || + bottomTab === 'character' || + bottomTab === 'inventory') && + (!currentStory || currentStory.streaming !== true) && + typeof gameState.runtimeActionVersion === 'number' && + (gameState.runtimeSessionId === null || + typeof gameState.runtimeSessionId === 'string') && + (!gameState.playerCharacter || + Boolean(gameState.playerEquipment && typeof gameState.playerEquipment === 'object')), + ); +} + +export function resolveHydratedSnapshotState(snapshot: SnapshotState) { + return isHydratedSnapshotState(snapshot) + ? snapshot + : hydrateSnapshotState(snapshot); +} diff --git a/src/persistence/runtimeSnapshotTypes.ts b/src/persistence/runtimeSnapshotTypes.ts new file mode 100644 index 00000000..64813202 --- /dev/null +++ b/src/persistence/runtimeSnapshotTypes.ts @@ -0,0 +1,31 @@ +import type { GameState, StoryMoment } from '../types'; +import type { BottomTab } from '../types/navigation'; +import type { SavedGameSnapshot } from './gameSaveStorage'; + +export type HydratableGameState = GameState & { + playerEquipment?: GameState['playerEquipment'] | null; +}; + +export type HydratedGameState = GameState & { + playerEquipment: GameState['playerEquipment']; + runtimeActionVersion: number; + runtimeSessionId: string | null; +}; + +export type SnapshotState = { + gameState: GameState; + currentStory: StoryMoment | null; + bottomTab: string; +}; + +export type HydratedSnapshotState = { + gameState: HydratedGameState; + currentStory: StoryMoment | null; + bottomTab: BottomTab; +}; + +export type HydratedSavedGameSnapshot = Omit< + SavedGameSnapshot, + 'gameState' | 'currentStory' | 'bottomTab' +> & + HydratedSnapshotState; diff --git a/src/services/ai.test.ts b/src/services/ai.test.ts index a8e20fdc..ea48d6da 100644 --- a/src/services/ai.test.ts +++ b/src/services/ai.test.ts @@ -935,7 +935,9 @@ describe('ai orchestration fallbacks', () => { '/api/custom-world/scene-image', expect.objectContaining({ method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), }), ); const [, request] = fetchMock.mock.calls[0] as [string, RequestInit]; diff --git a/src/services/ai.ts b/src/services/ai.ts index d8996819..29339628 100644 --- a/src/services/ai.ts +++ b/src/services/ai.ts @@ -26,6 +26,12 @@ import { WorldStoryGraph, WorldType, } from '../types'; +import type { + CustomWorldGenerationStep, + CustomWorldGenerationProgress, + GenerateCustomWorldProfileInput, + GenerateCustomWorldProfileOptions, +} from '../../packages/shared/src/contracts/runtime'; import { buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback, buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback, @@ -125,6 +131,12 @@ import { normalizeWorldStoryGraph, } from './storyEngine/worldStoryGraph'; +export type { + CustomWorldGenerationProgress, + GenerateCustomWorldProfileInput, + GenerateCustomWorldProfileOptions, +} from '../../packages/shared/src/contracts/runtime'; + export type { StoryGenerationContext, StoryRequestOptions, @@ -358,40 +370,6 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [ export type CustomWorldGenerationStageId = (typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id']; -export interface CustomWorldGenerationStep { - id: CustomWorldGenerationStageId; - label: string; - detail: string; - completed: number; - total: number; - status: 'pending' | 'active' | 'completed'; -} - -export interface CustomWorldGenerationProgress { - phaseId: CustomWorldGenerationStageId; - phaseLabel: string; - phaseDetail: string; - batchLabel?: string; - overallProgress: number; - completedWeight: number; - totalWeight: number; - elapsedMs: number; - estimatedRemainingMs: number | null; - activeStepIndex: number; - steps: CustomWorldGenerationStep[]; -} - -export interface GenerateCustomWorldProfileOptions { - onProgress?: (progress: CustomWorldGenerationProgress) => void; - signal?: AbortSignal; -} - -export interface GenerateCustomWorldProfileInput { - settingText: string; - creatorIntent?: CustomWorldCreatorIntent | null; - generationMode?: CustomWorldGenerationMode; -} - const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3; const FAST_CUSTOM_WORLD_STORY_COUNT = 8; const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4; @@ -450,7 +428,9 @@ function resolveCustomWorldGenerationInput( } const normalizedSettingText = input.settingText.trim(); - const creatorIntent = input.creatorIntent ?? null; + const creatorIntent = + (input.creatorIntent as CustomWorldCreatorIntent | null | undefined) ?? + null; const generationSeedText = creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent) ? buildCustomWorldCreatorIntentGenerationText(creatorIntent) diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 376f4597..90edf463 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -1,50 +1,56 @@ -import { parseApiErrorMessage } from '../editor/shared/jsonClient'; +import type { + AnswerCustomWorldSessionQuestionRequest, + CreateCustomWorldSessionRequest, + CustomWorldGenerationProgress, + CustomWorldSessionRecord, + CustomWorldSessionSummary, + GenerateCustomWorldProfileInput, + GenerateCustomWorldProfileOptions, +} from '../../packages/shared/src/contracts/runtime'; +import type { + CharacterChatReplyRequest, + CharacterChatSuggestionsRequest, + CharacterChatSummaryRequest, + NpcChatDialogueRequest, + NpcRecruitDialogueRequest, + PlainTextResponse, +} from '../../packages/shared/src/contracts/story'; +import { parseApiErrorMessage } from '../../packages/shared/src/http'; import type { AIResponse, Character, CharacterChatTurn, + CustomWorldProfile, Encounter, SceneHostileNpc, StoryMoment, WorldType, } from '../types'; import type { - CustomWorldGenerationProgress, + CustomWorldSceneImageRequest, CustomWorldSceneImageResult, - GenerateCustomWorldProfileInput, - GenerateCustomWorldProfileOptions, StoryGenerationContext, StoryRequestOptions, TextStreamOptions, } from './ai'; -import * as aiClient from './ai'; -import { - buildOfflineCharacterPanelChatReply, - buildOfflineCharacterPanelChatSuggestions, - buildOfflineCharacterPanelChatSummary, - buildOfflineNpcChatDialogue, - buildOfflineNpcRecruitDialogue, -} from './aiFallbacks'; import { fetchWithApiAuth, requestJson } from './apiClient'; -import { - buildCharacterPanelChatPrompt, - buildCharacterPanelChatSuggestionPrompt, - buildCharacterPanelChatSummaryPrompt, - CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, - CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, - CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, - type CharacterChatTargetStatus, -} from './characterChatPrompt'; +import { type CharacterChatTargetStatus } from './characterChatPrompt'; import { parseLineListContent } from './llmParsers'; -import { - buildNpcRecruitDialoguePrompt, - buildStrictNpcChatDialoguePrompt, - NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, - NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, -} from './prompt'; const RUNTIME_API_BASE = '/api/runtime'; +type LegacyAiModule = typeof import('./ai'); + +let legacyAiModulePromise: Promise | null = null; + +async function loadLegacyAiModule() { + if (!legacyAiModulePromise) { + legacyAiModulePromise = import('./ai'); + } + + return legacyAiModulePromise; +} + async function requestPostJson( url: string, payload: unknown, @@ -63,15 +69,15 @@ async function requestPostJson( async function requestPlainText( url: string, - payload: { systemPrompt: string; userPrompt: string }, + payload: unknown, fallbackMessage: string, ) { - return requestPostJson<{ text: string }>(url, payload, fallbackMessage); + return requestPostJson(url, payload, fallbackMessage); } async function requestPlainTextStream( url: string, - payload: { systemPrompt: string; userPrompt: string }, + payload: unknown, options: TextStreamOptions = {}, ) { const response = await fetchWithApiAuth(url, { @@ -135,21 +141,6 @@ async function requestPlainTextStream( return accumulatedText.trim(); } -function buildCharacterChatPromptContext(context: StoryGenerationContext) { - return { - playerHp: context.playerHp, - playerMaxHp: context.playerMaxHp, - playerMana: context.playerMana, - playerMaxMana: context.playerMaxMana, - inBattle: context.inBattle, - playerFacing: context.playerFacing, - playerAnimation: context.playerAnimation, - sceneName: context.sceneName ?? null, - sceneDescription: context.sceneDescription ?? null, - customWorldProfile: context.customWorldProfile ?? null, - }; -} - export async function generateInitialStory( world: WorldType, character: Character, @@ -158,6 +149,7 @@ export async function generateInitialStory( requestOptions: StoryRequestOptions = {}, ): Promise { if (typeof window === 'undefined') { + const aiClient = await loadLegacyAiModule(); return aiClient.generateInitialStory( world, character, @@ -167,32 +159,21 @@ export async function generateInitialStory( ); } - try { - return await requestJson( - `${RUNTIME_API_BASE}/story/initial`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - worldType: world, - character, - monsters, - context, - requestOptions, - }), - }, - '剧情开局生成失败', - ); - } catch (error) { - console.warn('[aiService] story/initial fell back to frontend implementation', error); - return aiClient.generateInitialStory( - world, - character, - monsters, - context, - requestOptions, - ); - } + return requestJson( + `${RUNTIME_API_BASE}/story/initial`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + worldType: world, + character, + monsters, + context, + requestOptions, + }), + }, + '剧情开局生成失败', + ); } export async function generateNextStep( @@ -205,6 +186,7 @@ export async function generateNextStep( requestOptions: StoryRequestOptions = {}, ): Promise { if (typeof window === 'undefined') { + const aiClient = await loadLegacyAiModule(); return aiClient.generateNextStep( world, character, @@ -216,36 +198,23 @@ export async function generateNextStep( ); } - try { - return await requestJson( - `${RUNTIME_API_BASE}/story/continue`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - worldType: world, - character, - monsters, - history, - choice, - context, - requestOptions, - }), - }, - '剧情续写失败', - ); - } catch (error) { - console.warn('[aiService] story/continue fell back to frontend implementation', error); - return aiClient.generateNextStep( - world, - character, - monsters, - history, - choice, - context, - requestOptions, - ); - } + return requestJson( + `${RUNTIME_API_BASE}/story/continue`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + worldType: world, + character, + monsters, + history, + choice, + context, + requestOptions, + }), + }, + '剧情续写失败', + ); } export async function generateCharacterPanelChatSuggestions( @@ -258,58 +227,37 @@ export async function generateCharacterPanelChatSuggestions( conversationSummary: string, targetStatus: CharacterChatTargetStatus, ) { - const fallbackSuggestions = - buildOfflineCharacterPanelChatSuggestions(targetCharacter); - const userPrompt = buildCharacterPanelChatSuggestionPrompt({ - world, + if (typeof window === 'undefined') { + const aiClient = await loadLegacyAiModule(); + return aiClient.generateCharacterPanelChatSuggestions( + world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + conversationSummary, + targetStatus, + ); + } + + const payload = { + worldType: world, playerCharacter, targetCharacter, storyHistory, - context: buildCharacterChatPromptContext(context), + context, conversationHistory, conversationSummary, targetStatus, - }); + } satisfies CharacterChatSuggestionsRequest; - if (typeof window === 'undefined') { - return aiClient.generateCharacterPanelChatSuggestions( - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - targetStatus, - ); - } - - try { - const { text } = await requestPlainText( - `${RUNTIME_API_BASE}/chat/character/suggestions`, - { - systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, - userPrompt, - }, - '角色聊天建议生成失败', - ); - const parsedSuggestions = parseLineListContent(text, 3); - return parsedSuggestions.length > 0 - ? [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3) - : fallbackSuggestions; - } catch (error) { - console.warn('[aiService] character suggestions fell back to frontend implementation', error); - return aiClient.generateCharacterPanelChatSuggestions( - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - targetStatus, - ); - } + const { text } = await requestPlainText( + `${RUNTIME_API_BASE}/chat/character/suggestions`, + payload, + '角色聊天建议生成失败', + ); + return parseLineListContent(text, 3); } export async function generateCharacterPanelChatSummary( @@ -322,64 +270,43 @@ export async function generateCharacterPanelChatSummary( previousSummary: string, targetStatus: CharacterChatTargetStatus, ) { - const fallbackSummary = buildOfflineCharacterPanelChatSummary( - targetCharacter, - conversationHistory, - previousSummary, - ); - const userPrompt = buildCharacterPanelChatSummaryPrompt({ - world, + if (typeof window === 'undefined') { + const aiClient = await loadLegacyAiModule(); + return aiClient.generateCharacterPanelChatSummary( + world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + previousSummary, + targetStatus, + ); + } + + const payload = { + worldType: world, playerCharacter, targetCharacter, storyHistory, - context: buildCharacterChatPromptContext(context), + context, conversationHistory, previousSummary, targetStatus, - }); + } satisfies CharacterChatSummaryRequest; - if (typeof window === 'undefined') { - return aiClient.generateCharacterPanelChatSummary( - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - previousSummary, - targetStatus, - ); - } - - try { - const { text } = await requestPlainText( - `${RUNTIME_API_BASE}/chat/character/summary`, - { - systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, - userPrompt, - }, - '角色聊天摘要生成失败', - ); - return text.trim() || fallbackSummary; - } catch (error) { - console.warn('[aiService] character summary fell back to frontend implementation', error); - return aiClient.generateCharacterPanelChatSummary( - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - previousSummary, - targetStatus, - ); - } + const { text } = await requestPlainText( + `${RUNTIME_API_BASE}/chat/character/summary`, + payload, + '角色聊天摘要生成失败', + ); + return text.trim(); } export async function generateCustomWorldProfile( input: GenerateCustomWorldProfileInput | string, options: GenerateCustomWorldProfileOptions = {}, -) { +): Promise { const normalizedInput = typeof input === 'string' ? { @@ -534,14 +461,13 @@ export async function generateCustomWorldProfile( throw new Error('自定义世界生成未返回结果'); } - return latestProfile as unknown as Awaited< - ReturnType - >; + return latestProfile as unknown as CustomWorldProfile; } export async function generateCustomWorldSceneImage( - ...args: Parameters + ...args: [CustomWorldSceneImageRequest] ) { + const aiClient = await loadLegacyAiModule(); return aiClient.generateCustomWorldSceneImage(...args); } @@ -550,41 +476,19 @@ export async function createCustomWorldSession(payload: { creatorIntent?: Record | null; generationMode: 'fast' | 'full'; }) { - return requestJson<{ - sessionId: string; - status: string; - questions: Array<{ - id: string; - label: string; - question: string; - answer?: string; - }>; - }>( + return requestJson( `${RUNTIME_API_BASE}/custom-world/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest), }, '创建自定义世界会话失败', ); } export async function getCustomWorldSession(sessionId: string) { - return requestJson<{ - sessionId: string; - status: string; - settingText: string; - generationMode: string; - questions: Array<{ - id: string; - label: string; - question: string; - answer?: string; - }>; - result?: Record; - lastError?: string; - }>( + return requestJson( `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET', @@ -597,21 +501,12 @@ export async function answerCustomWorldSessionQuestion( sessionId: string, payload: { questionId: string; answer: string }, ) { - return requestJson<{ - sessionId: string; - status: string; - questions: Array<{ - id: string; - label: string; - question: string; - answer?: string; - }>; - }>( + return requestJson( `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: JSON.stringify(payload satisfies AnswerCustomWorldSessionQuestionRequest), }, '提交自定义世界补充设定失败', ); @@ -629,65 +524,40 @@ export async function streamCharacterPanelChatReply( targetStatus: CharacterChatTargetStatus, options: TextStreamOptions = {}, ) { - const userPrompt = buildCharacterPanelChatPrompt({ - world, + if (typeof window === 'undefined') { + const aiClient = await loadLegacyAiModule(); + return aiClient.streamCharacterPanelChatReply( + world, + playerCharacter, + targetCharacter, + storyHistory, + context, + conversationHistory, + conversationSummary, + playerMessage, + targetStatus, + options, + ); + } + + const payload = { + worldType: world, playerCharacter, targetCharacter, storyHistory, - context: buildCharacterChatPromptContext(context), + context, conversationHistory, conversationSummary, playerMessage, targetStatus, - }); + } satisfies CharacterChatReplyRequest; - if (typeof window === 'undefined') { - return aiClient.streamCharacterPanelChatReply( - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - playerMessage, - targetStatus, - options, - ); - } - - try { - const reply = await requestPlainTextStream( - `${RUNTIME_API_BASE}/chat/character/reply/stream`, - { - systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, - userPrompt, - }, - options, - ); - return ( - reply.trim() || - buildOfflineCharacterPanelChatReply( - targetCharacter, - playerMessage, - conversationSummary, - ) - ); - } catch (error) { - console.warn('[aiService] character reply stream fell back to frontend implementation', error); - return aiClient.streamCharacterPanelChatReply( - world, - playerCharacter, - targetCharacter, - storyHistory, - context, - conversationHistory, - conversationSummary, - playerMessage, - targetStatus, - options, - ); - } + const reply = await requestPlainTextStream( + `${RUNTIME_API_BASE}/chat/character/reply/stream`, + payload, + options, + ); + return reply.trim(); } export async function streamNpcChatDialogue( @@ -701,8 +571,23 @@ export async function streamNpcChatDialogue( resultSummary: string, options: TextStreamOptions = {}, ) { - const userPrompt = buildStrictNpcChatDialoguePrompt( - world, + if (typeof window === 'undefined') { + const aiClient = await loadLegacyAiModule(); + return aiClient.streamNpcChatDialogue( + world, + character, + encounter, + monsters, + history, + context, + topic, + resultSummary, + options, + ); + } + + const payload = { + worldType: world, character, encounter, monsters, @@ -710,46 +595,14 @@ export async function streamNpcChatDialogue( context, topic, resultSummary, + } satisfies NpcChatDialogueRequest; + + const dialogue = await requestPlainTextStream( + `${RUNTIME_API_BASE}/chat/npc/dialogue/stream`, + payload, + options, ); - - if (typeof window === 'undefined') { - return aiClient.streamNpcChatDialogue( - world, - character, - encounter, - monsters, - history, - context, - topic, - resultSummary, - options, - ); - } - - try { - const dialogue = await requestPlainTextStream( - `${RUNTIME_API_BASE}/chat/npc/dialogue/stream`, - { - systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, - userPrompt, - }, - options, - ); - return dialogue.trim() || buildOfflineNpcChatDialogue(encounter, topic); - } catch (error) { - console.warn('[aiService] npc dialogue stream fell back to frontend implementation', error); - return aiClient.streamNpcChatDialogue( - world, - character, - encounter, - monsters, - history, - context, - topic, - resultSummary, - options, - ); - } + return dialogue.trim(); } export async function streamNpcRecruitDialogue( @@ -763,8 +616,23 @@ export async function streamNpcRecruitDialogue( recruitSummary: string, options: TextStreamOptions = {}, ) { - const userPrompt = buildNpcRecruitDialoguePrompt( - world, + if (typeof window === 'undefined') { + const aiClient = await loadLegacyAiModule(); + return aiClient.streamNpcRecruitDialogue( + world, + character, + encounter, + monsters, + history, + context, + invitationText, + recruitSummary, + options, + ); + } + + const payload = { + worldType: world, character, encounter, monsters, @@ -772,46 +640,14 @@ export async function streamNpcRecruitDialogue( context, invitationText, recruitSummary, + } satisfies NpcRecruitDialogueRequest; + + const dialogue = await requestPlainTextStream( + `${RUNTIME_API_BASE}/chat/npc/recruit/stream`, + payload, + options, ); - - if (typeof window === 'undefined') { - return aiClient.streamNpcRecruitDialogue( - world, - character, - encounter, - monsters, - history, - context, - invitationText, - recruitSummary, - options, - ); - } - - try { - const dialogue = await requestPlainTextStream( - `${RUNTIME_API_BASE}/chat/npc/recruit/stream`, - { - systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, - userPrompt, - }, - options, - ); - return dialogue.trim() || buildOfflineNpcRecruitDialogue(encounter); - } catch (error) { - console.warn('[aiService] npc recruit stream fell back to frontend implementation', error); - return aiClient.streamNpcRecruitDialogue( - world, - character, - encounter, - monsters, - history, - context, - invitationText, - recruitSummary, - options, - ); - } + return dialogue.trim(); } export type { diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts new file mode 100644 index 00000000..cfd465bf --- /dev/null +++ b/src/services/apiClient.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ApiClientError, + clearStoredAccessToken, + fetchWithApiAuth, + getStoredAccessToken, + requestJson, + setStoredAccessToken, +} from './apiClient'; + +function createMemoryStorage() { + const values = new Map(); + + return { + getItem(key: string) { + return values.has(key) ? values.get(key)! : null; + }, + setItem(key: string, value: string) { + values.set(key, value); + }, + removeItem(key: string) { + values.delete(key); + }, + clear() { + values.clear(); + }, + }; +} + +function createResponseMock(params: { + status: number; + body?: string; + headers?: Record; +}) { + const headers = new Map( + Object.entries(params.headers ?? {}).map(([key, value]) => [ + key.toLowerCase(), + value, + ]), + ); + + return { + status: params.status, + ok: params.status >= 200 && params.status < 300, + headers: { + get(name: string) { + return headers.get(name.toLowerCase()) ?? null; + }, + }, + text: vi.fn(async () => params.body ?? ''), + }; +} + +describe('apiClient', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('window', { + localStorage: createMemoryStorage(), + dispatchEvent: vi.fn(), + }); + fetchMock.mockReset(); + clearStoredAccessToken(); + }); + + it('attaches auth headers and clears stale tokens on unauthorized responses', async () => { + setStoredAccessToken('jwt-token'); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce(createResponseMock({ status: 401 })); + + const response = await fetchWithApiAuth('/api/protected', { method: 'GET' }); + + expect(response.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledWith( + '/api/protected', + expect.objectContaining({ + credentials: 'same-origin', + headers: expect.objectContaining({ + Authorization: 'Bearer jwt-token', + 'x-genarrative-response-envelope': 'v1', + }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/auth/refresh', + expect.objectContaining({ + method: 'POST', + credentials: 'same-origin', + }), + ); + expect(getStoredAccessToken()).toBe(''); + }); + + it('refreshes the access token once and retries the original request', async () => { + setStoredAccessToken('expired-token'); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { + token: 'fresh-token', + }, + error: null, + meta: { + apiVersion: '2026-04-08', + }, + }), + }), + ) + .mockResolvedValueOnce( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { + value: 7, + }, + error: null, + meta: { + apiVersion: '2026-04-08', + }, + }), + }), + ); + + const result = await requestJson<{ value: number }>( + '/api/runtime/protected', + { method: 'GET' }, + '读取受保护数据失败', + ); + + expect(result).toEqual({ value: 7 }); + expect(getStoredAccessToken()).toBe('fresh-token'); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/auth/refresh', + expect.objectContaining({ + method: 'POST', + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/runtime/protected', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer fresh-token', + }), + }), + ); + }); + + it('retries transient get requests before unwrapping the response envelope', async () => { + fetchMock + .mockRejectedValueOnce(new TypeError('network unavailable')) + .mockResolvedValueOnce( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { + value: 42, + }, + error: null, + meta: { + apiVersion: '2026-04-08', + }, + }), + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + + const result = await requestJson<{ value: number }>( + '/api/runtime/settings', + { method: 'GET' }, + '读取设置失败', + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result).toEqual({ value: 42 }); + }); + + it('surfaces response metadata through ApiClientError', async () => { + fetchMock.mockResolvedValueOnce( + createResponseMock({ + status: 503, + body: JSON.stringify({ + ok: false, + data: null, + error: { + code: 'UPSTREAM_ERROR', + message: '上游暂不可用', + details: { + scope: 'runtime', + }, + }, + meta: { + apiVersion: '2026-04-08', + requestId: 'req-body', + }, + }), + headers: { + 'Content-Type': 'application/json', + 'x-request-id': 'req-header', + 'x-route-version': 'runtime.v2', + }, + }), + ); + + let capturedError: unknown; + try { + await requestJson( + '/api/runtime/story/initial', + { method: 'POST' }, + '剧情生成失败', + ); + } catch (error) { + capturedError = error; + } + + expect(capturedError).toBeInstanceOf(ApiClientError); + expect(capturedError).toMatchObject({ + status: 503, + code: 'UPSTREAM_ERROR', + details: { + scope: 'runtime', + }, + meta: { + requestId: 'req-body', + routeVersion: 'runtime.v2', + }, + }); + }); +}); diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index bbdfa51e..e508c46a 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -1,9 +1,317 @@ -import { parseApiErrorMessage } from '../editor/shared/jsonClient'; +import { + API_RESPONSE_ENVELOPE_HEADER, + API_RESPONSE_ENVELOPE_VERSION, + API_VERSION, + type ApiErrorPayload, + type ApiMeta, + parseApiErrorMessage, + unwrapApiResponse, +} from '../../packages/shared/src/http'; const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1'; const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1'; const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1'; export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed'; +const REQUEST_ID_HEADER = 'x-request-id'; +const API_VERSION_HEADER = 'x-api-version'; +const ROUTE_VERSION_HEADER = 'x-route-version'; +const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504]; +const DEFAULT_SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +export type ApiRetryOptions = { + maxRetries?: number; + baseDelayMs?: number; + maxDelayMs?: number; + retryableStatusCodes?: number[]; + retryUnsafeMethods?: boolean; + allowRetryMethods?: string[]; +}; + +export type ApiRequestOptions = { + retry?: ApiRetryOptions; + skipAuth?: boolean; + omitEnvelopeHeader?: boolean; + skipRefresh?: boolean; +}; + +type ResolvedRetryOptions = { + maxRetries: number; + baseDelayMs: number; + maxDelayMs: number; + retryableStatusCodes: Set; + retryUnsafeMethods: boolean; + allowRetryMethods: Set; + method: string; +}; + +type ParsedApiErrorShape = { + code: string; + details: Record | null; + meta: Partial; +}; + +type RefreshTokenResponse = { + token: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function normalizeHeaders(headers?: HeadersInit) { + const nextHeaders: Record = {}; + + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + headers.forEach((value, key) => { + nextHeaders[key] = value; + }); + return nextHeaders; + } + + if (Array.isArray(headers)) { + for (const [key, value] of headers) { + nextHeaders[key] = value; + } + return nextHeaders; + } + + if (headers) { + Object.assign(nextHeaders, headers); + } + + return nextHeaders; +} + +function coerceMeta(value: unknown): Partial { + if (!isRecord(value)) { + return {}; + } + + return { + apiVersion: + typeof value.apiVersion === 'string' && value.apiVersion.trim() + ? value.apiVersion.trim() + : undefined, + requestId: + typeof value.requestId === 'string' && value.requestId.trim() + ? value.requestId.trim() + : undefined, + routeVersion: + typeof value.routeVersion === 'string' && value.routeVersion.trim() + ? value.routeVersion.trim() + : undefined, + operation: + typeof value.operation === 'string' && value.operation.trim() + ? value.operation.trim() + : value.operation === null + ? null + : undefined, + latencyMs: + typeof value.latencyMs === 'number' && Number.isFinite(value.latencyMs) + ? value.latencyMs + : undefined, + timestamp: + typeof value.timestamp === 'string' && value.timestamp.trim() + ? value.timestamp.trim() + : undefined, + }; +} + +function parseApiErrorShape(rawText: string): ParsedApiErrorShape | null { + if (!rawText.trim()) { + return null; + } + + try { + const parsed = JSON.parse(rawText) as + | { + error?: ApiErrorPayload; + meta?: Partial; + code?: string; + details?: Record | null; + } + | Record; + + if (isRecord(parsed.error)) { + return { + code: + typeof parsed.error.code === 'string' && parsed.error.code.trim() + ? parsed.error.code.trim() + : 'HTTP_ERROR', + details: + isRecord(parsed.error.details) || parsed.error.details === null + ? (parsed.error.details as Record | null) + : null, + meta: coerceMeta(parsed.meta), + }; + } + + if (typeof parsed.code === 'string' && parsed.code.trim()) { + return { + code: parsed.code.trim(), + details: + isRecord(parsed.details) || parsed.details === null + ? (parsed.details as Record | null) + : null, + meta: coerceMeta(parsed.meta), + }; + } + } catch { + // Ignore malformed json responses. + } + + return null; +} + +function createAbortError() { + if (typeof DOMException !== 'undefined') { + return new DOMException('The operation was aborted.', 'AbortError'); + } + + const error = new Error('The operation was aborted.'); + error.name = 'AbortError'; + return error; +} + +async function waitForRetry(ms: number, signal?: AbortSignal) { + if (ms <= 0) { + return; + } + + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + + const onAbort = () => { + cleanup(); + reject(signal?.reason ?? createAbortError()); + }; + + const cleanup = () => { + clearTimeout(timeoutId); + signal?.removeEventListener('abort', onAbort); + }; + + if (signal?.aborted) { + cleanup(); + reject(signal.reason ?? createAbortError()); + return; + } + + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +function resolveRetryOptions( + method: string, + retry?: ApiRetryOptions, +): ResolvedRetryOptions { + const normalizedMethod = method.toUpperCase(); + const defaultMaxRetries = DEFAULT_SAFE_RETRY_METHODS.has(normalizedMethod) + ? 1 + : 0; + + return { + maxRetries: + typeof retry?.maxRetries === 'number' && retry.maxRetries >= 0 + ? Math.floor(retry.maxRetries) + : defaultMaxRetries, + baseDelayMs: + typeof retry?.baseDelayMs === 'number' && retry.baseDelayMs > 0 + ? retry.baseDelayMs + : 250, + maxDelayMs: + typeof retry?.maxDelayMs === 'number' && retry.maxDelayMs > 0 + ? retry.maxDelayMs + : 1500, + retryableStatusCodes: new Set( + retry?.retryableStatusCodes?.length + ? retry.retryableStatusCodes + : DEFAULT_RETRYABLE_STATUS_CODES, + ), + retryUnsafeMethods: retry?.retryUnsafeMethods === true, + allowRetryMethods: new Set( + (retry?.allowRetryMethods ?? []).map((value) => value.toUpperCase()), + ), + method: normalizedMethod, + }; +} + +function shouldRetryResponse( + status: number, + attempt: number, + retry: ResolvedRetryOptions, +) { + if (attempt >= retry.maxRetries) { + return false; + } + + if (!retry.retryableStatusCodes.has(status)) { + return false; + } + + return ( + retry.retryUnsafeMethods || + DEFAULT_SAFE_RETRY_METHODS.has(retry.method) || + retry.allowRetryMethods.has(retry.method) + ); +} + +export function isAbortError(error: unknown) { + return ( + error instanceof Error && + (error.name === 'AbortError' || + (typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError')) + ); +} + +function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) { + if (attempt >= retry.maxRetries || isAbortError(error)) { + return false; + } + + return error instanceof TypeError; +} + +function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) { + return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt)); +} + +export class ApiClientError extends Error { + status: number; + code: string; + details: Record | null; + meta: ApiMeta; + responseText: string; + + constructor(params: { + message: string; + status: number; + code: string; + details?: Record | null; + meta?: Partial; + responseText?: string; + }) { + super(params.message); + this.name = 'ApiClientError'; + this.status = params.status; + this.code = params.code; + this.details = params.details ?? null; + this.meta = { + apiVersion: params.meta?.apiVersion ?? API_VERSION, + requestId: params.meta?.requestId, + routeVersion: params.meta?.routeVersion, + operation: params.meta?.operation, + latencyMs: params.meta?.latencyMs, + timestamp: params.meta?.timestamp, + }; + this.responseText = params.responseText ?? ''; + } +} function canUseLocalStorage() { return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; @@ -14,7 +322,14 @@ function emitAuthStateChange() { return; } - window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT)); + if (typeof CustomEvent === 'function') { + window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT)); + return; + } + + if (typeof Event === 'function') { + window.dispatchEvent(new Event(AUTH_STATE_EVENT)); + } } export function getStoredAccessToken() { @@ -25,7 +340,12 @@ export function getStoredAccessToken() { return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || ''; } -export function setStoredAccessToken(token: string) { +export function setStoredAccessToken( + token: string, + options: { + emit?: boolean; + } = {}, +) { if (!canUseLocalStorage()) { return; } @@ -36,16 +356,24 @@ export function setStoredAccessToken(token: string) { } else { window.localStorage.removeItem(ACCESS_TOKEN_KEY); } - emitAuthStateChange(); + if (options.emit !== false) { + emitAuthStateChange(); + } } -export function clearStoredAccessToken() { +export function clearStoredAccessToken( + options: { + emit?: boolean; + } = {}, +) { if (!canUseLocalStorage()) { return; } window.localStorage.removeItem(ACCESS_TOKEN_KEY); - emitAuthStateChange(); + if (options.emit !== false) { + emitAuthStateChange(); + } } export function getStoredAutoAuthCredentials() { @@ -88,56 +416,165 @@ export function clearStoredAutoAuthCredentials() { emitAuthStateChange(); } -function withAuthorizationHeaders(headers?: HeadersInit) { - const nextHeaders: Record = {}; - - if (headers instanceof Headers) { - headers.forEach((value, key) => { - nextHeaders[key] = value; - }); - } else if (Array.isArray(headers)) { - for (const [key, value] of headers) { - nextHeaders[key] = value; - } - } else if (headers) { - Object.assign(nextHeaders, headers); - } - +function withAuthorizationHeaders( + headers?: HeadersInit, + options: Pick = {}, +) { + const nextHeaders = normalizeHeaders(headers); const token = getStoredAccessToken(); - if (token) { + if (token && !options.skipAuth) { nextHeaders.Authorization = `Bearer ${token}`; } + if (!options.omitEnvelopeHeader) { + nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION; + } return nextHeaders; } +let refreshAccessTokenPromise: Promise | null = null; + +async function refreshAccessToken() { + if (refreshAccessTokenPromise) { + return refreshAccessTokenPromise; + } + + refreshAccessTokenPromise = (async () => { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'same-origin', + headers: { + [API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION, + }, + }); + + if (!response.ok) { + clearStoredAccessToken(); + throw await buildApiClientError(response, '刷新登录状态失败'); + } + + const responseText = await response.text(); + const payload = responseText + ? unwrapApiResponse( + JSON.parse(responseText) as RefreshTokenResponse, + ) + : null; + + if (!payload?.token?.trim()) { + clearStoredAccessToken(); + throw new Error('刷新登录状态失败'); + } + + setStoredAccessToken(payload.token, { emit: false }); + return payload.token; + })(); + + try { + return await refreshAccessTokenPromise; + } finally { + refreshAccessTokenPromise = null; + } +} + export async function fetchWithApiAuth( input: string, init: RequestInit = {}, + options: ApiRequestOptions = {}, ) { - const response = await fetch(input, { - credentials: 'same-origin', - ...init, - headers: withAuthorizationHeaders(init.headers), - }); + const method = (init.method ?? 'GET').toUpperCase(); + const retry = resolveRetryOptions(method, options.retry); + let attempt = 0; + let refreshAttempted = false; - if (response.status === 401) { - clearStoredAccessToken(); + for (;;) { + try { + const response = await fetch(input, { + credentials: 'same-origin', + ...init, + headers: withAuthorizationHeaders(init.headers, options), + }); + + if ( + response.status === 401 && + !options.skipAuth && + !options.skipRefresh && + !refreshAttempted + ) { + try { + await refreshAccessToken(); + refreshAttempted = true; + continue; + } catch { + clearStoredAccessToken(); + } + } else if (response.status === 401) { + clearStoredAccessToken(); + } + + if (!shouldRetryResponse(response.status, attempt, retry)) { + return response; + } + } catch (error) { + if (!shouldRetryError(error, attempt, retry)) { + throw error; + } + } + + attempt += 1; + await waitForRetry( + buildRetryDelayMs(attempt, retry), + init.signal ?? undefined, + ); } +} - return response; +async function buildApiClientError( + response: Response, + fallbackMessage: string, +) { + const responseText = await response.text(); + const parsedError = parseApiErrorShape(responseText); + + return new ApiClientError({ + message: parseApiErrorMessage(responseText, fallbackMessage), + status: response.status, + code: parsedError?.code ?? `HTTP_${response.status || 0}`, + details: parsedError?.details ?? null, + meta: { + apiVersion: + parsedError?.meta.apiVersion ?? + response.headers.get(API_VERSION_HEADER) ?? + API_VERSION, + requestId: + parsedError?.meta.requestId ?? + response.headers.get(REQUEST_ID_HEADER) ?? + undefined, + routeVersion: + parsedError?.meta.routeVersion ?? + response.headers.get(ROUTE_VERSION_HEADER) ?? + undefined, + operation: parsedError?.meta.operation, + latencyMs: parsedError?.meta.latencyMs, + timestamp: parsedError?.meta.timestamp, + }, + responseText, + }); } export async function requestJson( url: string, init: RequestInit, fallbackMessage: string, + options: ApiRequestOptions = {}, ): Promise { - const response = await fetchWithApiAuth(url, init); - const responseText = await response.text(); + const response = await fetchWithApiAuth(url, init, options); if (!response.ok) { - throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); + throw await buildApiClientError(response, fallbackMessage); } - return responseText ? (JSON.parse(responseText) as T) : (null as T); + const responseText = await response.text(); + + return responseText + ? unwrapApiResponse(JSON.parse(responseText) as T) + : (null as T); } diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index a34b55f9..6a1cac3b 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -5,15 +5,30 @@ const { requestJsonMock } = vi.hoisted(() => ({ })); import { + ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials, getStoredAccessToken, getStoredAutoAuthCredentials, + setStoredAccessToken, } from './apiClient'; import { authEntryWithStoredCredentials, + bindWechatPhone, + changePhoneNumber, + consumeAuthCallbackResult, createAutoAuthCredentials, ensureAutoAuthUser, + getAuthAuditLogs, + getAuthRiskBlocks, + getAuthSessions, + getCaptchaChallengeFromError, + liftAuthRiskBlock, + loginWithPhoneCode, + logoutAllAuthSessions, + revokeAuthSession, + sendPhoneLoginCode, + startWechatLogin, } from './authService'; function createMemoryStorage() { @@ -68,12 +83,17 @@ describe('authService auto auth', () => { user: { id: 'user_1', username: 'guest_abc123abc123', + displayName: 'guest_abc123abc123', + phoneNumberMasked: null, + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, }, }); const user = await authEntryWithStoredCredentials({ - username: 'guest_abc123abc123', - password: 'auto_secret_password', + username: ' guest_abc123abc123 ', + password: ' auto_secret_password ', }); expect(user.username).toBe('guest_abc123abc123'); @@ -82,6 +102,16 @@ describe('authService auto auth', () => { username: 'guest_abc123abc123', password: 'auto_secret_password', }); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/entry', + expect.objectContaining({ + body: JSON.stringify({ + username: 'guest_abc123abc123', + password: 'auto_secret_password', + }), + }), + '登录失败', + ); }); it('reuses stored auto credentials before generating a new account', async () => { @@ -92,6 +122,11 @@ describe('authService auto auth', () => { user: { id: 'user_saved', username: 'guest_saveduser01', + displayName: 'guest_saveduser01', + phoneNumberMasked: null, + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, }, }); @@ -110,4 +145,354 @@ describe('authService auto auth', () => { '登录失败', ); }); + + it('sends phone login code through the new auth endpoint', async () => { + requestJsonMock.mockResolvedValue({ + ok: true, + cooldownSeconds: 60, + expiresInSeconds: 300, + providerRequestId: 'mock-request-id', + }); + + const result = await sendPhoneLoginCode(' 138 0013 8000 '); + + expect(result.cooldownSeconds).toBe(60); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/phone/send-code', + expect.objectContaining({ + body: JSON.stringify({ + phone: '13800138000', + scene: 'login', + }), + }), + '发送验证码失败', + ); + }); + + it('sends phone change code with the correct scene', async () => { + requestJsonMock.mockResolvedValue({ + ok: true, + cooldownSeconds: 60, + expiresInSeconds: 300, + providerRequestId: 'mock-request-id', + }); + + await sendPhoneLoginCode('13900139000', 'change_phone'); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/phone/send-code', + expect.objectContaining({ + body: JSON.stringify({ + phone: '13900139000', + scene: 'change_phone', + }), + }), + '发送验证码失败', + ); + }); + + it('extracts captcha challenge details from api errors', () => { + expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull(); + + const captchaError = new ApiClientError({ + message: '需要完成人机校验', + status: 403, + code: 'CAPTCHA_REQUIRED', + details: { + captchaChallenge: { + challengeId: 'captcha_1', + promptText: '请输入图中的验证码后再获取短信验证码', + imageDataUrl: 'data:image/svg+xml;base64,abc', + expiresInSeconds: 180, + }, + }, + }); + + expect(getCaptchaChallengeFromError(captchaError)).toEqual({ + challengeId: 'captcha_1', + promptText: '请输入图中的验证码后再获取短信验证码', + imageDataUrl: 'data:image/svg+xml;base64,abc', + expiresInSeconds: 180, + }); + }); + + it('stores jwt after phone login', async () => { + requestJsonMock.mockResolvedValue({ + token: 'phone-jwt-token', + user: { + id: 'user_phone', + username: '138****8000', + displayName: '138****8000', + phoneNumberMasked: '138****8000', + loginMethod: 'phone', + bindingStatus: 'active', + wechatBound: false, + }, + }); + + const user = await loginWithPhoneCode('13800138000', '123456'); + + expect(user.username).toBe('138****8000'); + expect(getStoredAccessToken()).toBe('phone-jwt-token'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/phone/login', + expect.objectContaining({ + body: JSON.stringify({ + phone: '13800138000', + code: '123456', + }), + }), + '登录失败', + ); + }); + + it('binds wechat phone and stores jwt after activation', async () => { + requestJsonMock.mockResolvedValue({ + token: 'wechat-bind-token', + user: { + id: 'user_wechat', + username: '138****8000', + displayName: '138****8000', + phoneNumberMasked: '138****8000', + loginMethod: 'wechat', + bindingStatus: 'active', + wechatBound: true, + }, + }); + + const user = await bindWechatPhone('13800138000', '123456'); + + expect(user.wechatBound).toBe(true); + expect(getStoredAccessToken()).toBe('wechat-bind-token'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/wechat/bind-phone', + expect.objectContaining({ + body: JSON.stringify({ + phone: '13800138000', + code: '123456', + }), + }), + '绑定手机号失败', + ); + }); + + it('changes phone number without replacing the stored access token', async () => { + setStoredAccessToken('active-token'); + requestJsonMock.mockResolvedValue({ + user: { + id: 'user_phone', + username: '139****9000', + displayName: '139****9000', + phoneNumberMasked: '139****9000', + loginMethod: 'phone', + bindingStatus: 'active', + wechatBound: false, + }, + }); + + const user = await changePhoneNumber('13900139000', '123456'); + + expect(user.phoneNumberMasked).toBe('139****9000'); + expect(getStoredAccessToken()).toBe('active-token'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/phone/change', + expect.objectContaining({ + body: JSON.stringify({ + phone: '13900139000', + code: '123456', + }), + }), + '更换手机号失败', + ); + }); + + it('starts wechat login by navigating to backend authorization url', async () => { + const assignMock = vi.fn(); + vi.stubGlobal('window', { + localStorage: createMemoryStorage(), + dispatchEvent: vi.fn(), + location: { + pathname: '/', + hash: '', + search: '', + assign: assignMock, + }, + history: { + replaceState: vi.fn(), + }, + }); + requestJsonMock.mockResolvedValue({ + authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123', + }); + + await startWechatLogin(); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/wechat/start?redirectPath=%2F', + expect.objectContaining({ + method: 'GET', + }), + '微信登录暂不可用', + ); + expect(assignMock).toHaveBeenCalledWith( + '/api/auth/wechat/callback?mock_code=wx-user&state=state123', + ); + }); + + it('consumes auth callback hash and stores token', () => { + const replaceStateMock = vi.fn(); + vi.stubGlobal('window', { + localStorage: createMemoryStorage(), + dispatchEvent: vi.fn(), + location: { + pathname: '/', + search: '', + hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone', + }, + history: { + replaceState: replaceStateMock, + }, + }); + + const result = consumeAuthCallbackResult(); + + expect(result).toEqual({ + provider: 'wechat', + bindingStatus: 'pending_bind_phone', + error: null, + }); + expect(getStoredAccessToken()).toBe('wx-token'); + expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/'); + }); + + it('loads auth sessions from account center endpoint', async () => { + requestJsonMock.mockResolvedValue({ + sessions: [ + { + sessionId: 'usess_1', + clientType: 'browser', + clientLabel: '网页端浏览器', + userAgent: 'Mozilla/5.0', + ipMasked: '127.0.*.*', + isCurrent: true, + createdAt: '2026-04-09T10:00:00.000Z', + lastSeenAt: '2026-04-09T10:30:00.000Z', + expiresAt: '2026-05-09T10:30:00.000Z', + }, + ], + }); + + const sessions = await getAuthSessions(); + + expect(sessions).toHaveLength(1); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/sessions', + expect.objectContaining({ + method: 'GET', + }), + '读取登录设备失败', + ); + }); + + it('loads recent auth audit logs', async () => { + requestJsonMock.mockResolvedValue({ + logs: [ + { + id: 'audit_1', + eventType: 'phone_login', + title: '手机号登录', + detail: '使用手机号 138****8000 完成登录', + ipMasked: '127.0.*.*', + userAgent: 'Mozilla/5.0', + createdAt: '2026-04-09T10:30:00.000Z', + }, + ], + }); + + const logs = await getAuthAuditLogs(); + + expect(logs).toHaveLength(1); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/audit-logs', + expect.objectContaining({ + method: 'GET', + }), + '读取账号操作记录失败', + ); + }); + + it('loads current risk blocks', async () => { + requestJsonMock.mockResolvedValue({ + blocks: [ + { + scopeType: 'phone', + title: '手机号保护中', + detail: '该手机号因异常尝试已被临时保护,请约 30 分钟后再试', + expiresAt: '2026-04-09T11:00:00.000Z', + remainingSeconds: 1800, + }, + ], + }); + + const blocks = await getAuthRiskBlocks(); + + expect(blocks).toHaveLength(1); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/risk-blocks', + expect.objectContaining({ + method: 'GET', + }), + '读取安全状态失败', + ); + }); + + it('lifts a risk block by scope type', async () => { + requestJsonMock.mockResolvedValue({ + ok: true, + }); + + await liftAuthRiskBlock('phone'); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/risk-blocks/phone/lift', + expect.objectContaining({ + method: 'POST', + }), + '解除保护失败', + ); + }); + + it('revokes a remote auth session by id', async () => { + requestJsonMock.mockResolvedValue({ + ok: true, + }); + + await revokeAuthSession('usess_123'); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/sessions/usess_123/revoke', + expect.objectContaining({ + method: 'POST', + }), + '移除登录设备失败', + ); + }); + + it('clears local auth state after logout all sessions', async () => { + setStoredAccessToken('stale-token'); + requestJsonMock.mockResolvedValue({ + ok: true, + }); + + await logoutAllAuthSessions(); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/auth/logout-all', + expect.objectContaining({ + method: 'POST', + }), + '退出全部设备失败', + ); + expect(getStoredAccessToken()).toBe(''); + }); }); diff --git a/src/services/authService.ts b/src/services/authService.ts index f36c49b7..4d7a62f7 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,4 +1,26 @@ +import type { + AuthAuditLogEntry, + AuthAuditLogsResponse, + AuthCaptchaChallenge, + AuthEntryResponse, + AuthLiftRiskBlockResponse, + AuthLoginMethod, + AuthLogoutAllResponse, + AuthMeResponse, + AuthPhoneChangeResponse, + AuthPhoneLoginResponse, + AuthPhoneSendCodeResponse, + AuthRevokeSessionResponse, + AuthRiskBlocksResponse, + AuthRiskBlockSummary, + AuthSessionsResponse, + AuthSessionSummary, + AuthWechatBindPhoneResponse, + AuthWechatStartResponse, + LogoutResponse, +} from '../../packages/shared/src/contracts/auth'; import { + ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials, getStoredAutoAuthCredentials, @@ -7,19 +29,77 @@ import { setStoredAutoAuthCredentials, } from './apiClient'; -export type AuthUser = { - id: string; - username: string; -}; +export type { AuthUser } from '../../packages/shared/src/contracts/auth'; export type AutoAuthCredentials = { username: string; password: string; }; +export type AuthSessionSnapshot = { + user: import('../../packages/shared/src/contracts/auth').AuthUser | null; + availableLoginMethods: AuthLoginMethod[]; +}; +export type { AuthSessionSummary }; +export type { AuthCaptchaChallenge }; +export type { AuthAuditLogEntry }; +export type { AuthRiskBlockSummary }; + +export type ConsumedAuthCallback = { + provider: 'wechat' | 'unknown'; + bindingStatus: string | null; + error: string | null; +}; + +export function normalizePhoneInput(phoneInput: string) { + return phoneInput.replace(/[^\d+]/gu, '').trim(); +} + +export function getCaptchaChallengeFromError( + error: unknown, +): AuthCaptchaChallenge | null { + if ( + error instanceof ApiClientError && + error.code === 'CAPTCHA_REQUIRED' && + error.details && + typeof error.details === 'object' && + 'captchaChallenge' in error.details + ) { + const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge; + if ( + challenge && + typeof challenge === 'object' && + 'challengeId' in challenge && + 'promptText' in challenge && + 'imageDataUrl' in challenge && + 'expiresInSeconds' in challenge + ) { + return challenge as AuthCaptchaChallenge; + } + } + + return null; +} + +function normalizeCredentials(credentials: AutoAuthCredentials): AutoAuthCredentials { + return { + username: credentials.username.trim(), + password: credentials.password.trim(), + }; +} + function buildRandomSegment(length: number) { const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; - const bytes = crypto.getRandomValues(new Uint8Array(length)); + const cryptoApi = globalThis.crypto; + + if (!cryptoApi?.getRandomValues) { + return Array.from( + {length}, + () => alphabet[Math.floor(Math.random() * alphabet.length)], + ).join(''); + } + + const bytes = cryptoApi.getRandomValues(new Uint8Array(length)); return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(''); } @@ -31,20 +111,111 @@ export function createAutoAuthCredentials(): AutoAuthCredentials { }; } -export async function authEntry(username: string, password: string) { - const response = await requestJson<{ - token: string; - user: AuthUser; - }>( - '/api/auth/entry', +export function clearAuthSession() { + clearStoredAccessToken(); + clearStoredAutoAuthCredentials(); +} + +export async function sendPhoneLoginCode( + phone: string, + scene: 'login' | 'bind_phone' | 'change_phone' = 'login', + captcha?: { + challengeId?: string; + answer?: string; + }, +) { + const response = await requestJson( + '/api/auth/phone/send-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - username, - password, + phone: normalizePhoneInput(phone), + scene, + captchaChallengeId: captcha?.challengeId?.trim() || undefined, + captchaAnswer: captcha?.answer?.trim() || undefined, }), }, + '发送验证码失败', + ); + + return response; +} + +export async function loginWithPhoneCode(phone: string, code: string) { + const response = await requestJson( + '/api/auth/phone/login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: normalizePhoneInput(phone), + code: code.trim(), + }), + }, + '登录失败', + ); + + setStoredAccessToken(response.token); + return response.user; +} + +export async function bindWechatPhone(phone: string, code: string) { + const response = await requestJson( + '/api/auth/wechat/bind-phone', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: normalizePhoneInput(phone), + code: code.trim(), + }), + }, + '绑定手机号失败', + ); + + setStoredAccessToken(response.token); + return response.user; +} + +export async function changePhoneNumber(phone: string, code: string) { + const response = await requestJson( + '/api/auth/phone/change', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: normalizePhoneInput(phone), + code: code.trim(), + }), + }, + '更换手机号失败', + ); + + return response.user; +} + +export async function startWechatLogin() { + const response = await requestJson( + `/api/auth/wechat/start?redirectPath=${encodeURIComponent(window.location.pathname)}`, + { + method: 'GET', + }, + '微信登录暂不可用', + ); + + window.location.assign(response.authorizationUrl); +} + +export async function authEntry(username: string, password: string) { + const credentials = normalizeCredentials({ username, password }); + const response = await requestJson( + '/api/auth/entry', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }, '登录失败', ); @@ -55,8 +226,12 @@ export async function authEntry(username: string, password: string) { export async function authEntryWithStoredCredentials( credentials: AutoAuthCredentials, ) { - const user = await authEntry(credentials.username, credentials.password); - setStoredAutoAuthCredentials(credentials); + const normalizedCredentials = normalizeCredentials(credentials); + const user = await authEntry( + normalizedCredentials.username, + normalizedCredentials.password, + ); + setStoredAutoAuthCredentials(normalizedCredentials); return user; } @@ -71,10 +246,49 @@ export async function ensureAutoAuthUser() { }; } -export async function getCurrentAuthUser() { - const response = await requestJson<{ - user: AuthUser | null; - }>( +export function consumeAuthCallbackResult(): ConsumedAuthCallback | null { + if (typeof window === 'undefined') { + return null; + } + + const hash = window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : window.location.hash; + if (!hash) { + return null; + } + + const params = new URLSearchParams(hash); + const authToken = params.get('auth_token'); + const authError = params.get('auth_error'); + const providerValue = params.get('auth_provider'); + const bindingStatus = params.get('auth_binding_status'); + + if (!authToken && !authError) { + return null; + } + + if (authToken) { + setStoredAccessToken(authToken); + } + + if (typeof window.history?.replaceState === 'function') { + window.history.replaceState( + null, + '', + `${window.location.pathname}${window.location.search}`, + ); + } + + return { + provider: providerValue === 'wechat' ? 'wechat' : 'unknown', + bindingStatus, + error: authError, + }; +} + +export async function getCurrentAuthUser(): Promise { + const response = await requestJson( '/api/auth/me', { method: 'GET', @@ -82,12 +296,71 @@ export async function getCurrentAuthUser() { '读取当前用户失败', ); - return response.user; + return { + user: response.user, + availableLoginMethods: response.availableLoginMethods, + }; +} + +export async function getAuthSessions() { + const response = await requestJson( + '/api/auth/sessions', + { + method: 'GET', + }, + '读取登录设备失败', + ); + + return response.sessions; +} + +export async function revokeAuthSession(sessionId: string) { + await requestJson( + `/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, + { + method: 'POST', + }, + '移除登录设备失败', + ); +} + +export async function getAuthAuditLogs() { + const response = await requestJson( + '/api/auth/audit-logs', + { + method: 'GET', + }, + '读取账号操作记录失败', + ); + + return response.logs; +} + +export async function getAuthRiskBlocks() { + const response = await requestJson( + '/api/auth/risk-blocks', + { + method: 'GET', + }, + '读取安全状态失败', + ); + + return response.blocks; +} + +export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') { + await requestJson( + `/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`, + { + method: 'POST', + }, + '解除保护失败', + ); } export async function logoutAuthUser() { try { - await requestJson<{ ok: true }>( + await requestJson( '/api/auth/logout', { method: 'POST', @@ -95,7 +368,20 @@ export async function logoutAuthUser() { '退出登录失败', ); } finally { - clearStoredAccessToken(); - clearStoredAutoAuthCredentials(); + clearAuthSession(); + } +} + +export async function logoutAllAuthSessions() { + try { + await requestJson( + '/api/auth/logout-all', + { + method: 'POST', + }, + '退出全部设备失败', + ); + } finally { + clearAuthSession(); } } diff --git a/src/services/llmClient.ts b/src/services/llmClient.ts index 686137a4..04a972fe 100644 --- a/src/services/llmClient.ts +++ b/src/services/llmClient.ts @@ -29,7 +29,7 @@ function coerceBoolean(value: string | undefined) { function resolveHeaders(headers?: HeadersInit) { const nextHeaders: Record = {}; - if (headers instanceof Headers) { + if (typeof Headers !== 'undefined' && headers instanceof Headers) { headers.forEach((value, key) => { nextHeaders[key] = value; }); diff --git a/src/services/llmParsers.ts b/src/services/llmParsers.ts index e52ffab8..e985667c 100644 --- a/src/services/llmParsers.ts +++ b/src/services/llmParsers.ts @@ -1,28 +1,4 @@ -export function parseJsonResponseText(text: string) { - const trimmed = text.trim(); - if (!trimmed) { - throw new Error('LLM returned an empty response.'); - } - - const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu); - if (fencedMatch?.[1]) { - return JSON.parse(fencedMatch[1].trim()); - } - - const firstBrace = trimmed.indexOf('{'); - const lastBrace = trimmed.lastIndexOf('}'); - if (firstBrace >= 0 && lastBrace > firstBrace) { - return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1)); - } - - return JSON.parse(trimmed); -} - -export function parseLineListContent(text: string, maxItems = 3) { - return text - .replace(/\r/g, '') - .split('\n') - .map(line => line.trim().replace(/^[-*\d.)\s]+/u, '').trim()) - .filter(Boolean) - .slice(0, maxItems); -} +export { + parseJsonResponseText, + parseLineListContent, +} from '../../packages/shared/src/llm/parsers'; diff --git a/src/services/narrativeLanguage.ts b/src/services/narrativeLanguage.ts index 7d8fda84..897cc8e4 100644 --- a/src/services/narrativeLanguage.ts +++ b/src/services/narrativeLanguage.ts @@ -1,68 +1,4 @@ -const CJK_CHAR_PATTERN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/gu; -const LATIN_WORD_PATTERN = /[A-Za-z][A-Za-z'’-]{1,}/g; -const LATIN_FRAGMENT_PATTERN = - /[A-Za-z][A-Za-z0-9'"“”‘’()\-,:;!?/]*(?:\s+[A-Za-z0-9'"“”‘’()\-,:;!?/]+)+/gu; -const SAFE_LATIN_TOKENS = new Set([ - 'act', - 'ai', - 'boss', - 'cd', - 'hp', - 'json', - 'llm', - 'mp', - 'npc', - 'qa', - 'rpg', -]); - -function getCjkCharCount(text: string) { - return text.match(CJK_CHAR_PATTERN)?.length ?? 0; -} - -function getSignificantLatinWords(text: string) { - return (text.match(LATIN_WORD_PATTERN) ?? []) - .map((word) => word.toLowerCase()) - .filter( - (word) => word.length >= 4 && !SAFE_LATIN_TOKENS.has(word), - ); -} - -export function hasMixedNarrativeLanguage(text: string) { - const trimmed = text.trim(); - if (!trimmed) { - return false; - } - - const cjkCharCount = getCjkCharCount(trimmed); - const latinSentenceFragments = (trimmed.match(LATIN_FRAGMENT_PATTERN) ?? []) - .map((fragment) => fragment.trim()) - .filter((fragment) => fragment.split(/\s+/u).length >= 2); - const significantLatinWords = getSignificantLatinWords(trimmed); - - if (latinSentenceFragments.length > 0) { - return true; - } - - if (cjkCharCount > 0 && significantLatinWords.length >= 2) { - return true; - } - - return cjkCharCount === 0 && significantLatinWords.length >= 3; -} - -export function sanitizePromptNarrativeText( - text: string | null | undefined, - fallback: string | null = null, -) { - if (typeof text !== 'string') { - return fallback; - } - - const trimmed = text.trim(); - if (!trimmed) { - return fallback; - } - - return hasMixedNarrativeLanguage(trimmed) ? fallback : trimmed; -} +export { + hasMixedNarrativeLanguage, + sanitizePromptNarrativeText, +} from '../../packages/shared/src/llm/narrativeLanguage'; diff --git a/src/services/runtimeStoryService.test.ts b/src/services/runtimeStoryService.test.ts new file mode 100644 index 00000000..665e1737 --- /dev/null +++ b/src/services/runtimeStoryService.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { requestJsonMock } = vi.hoisted(() => ({ + requestJsonMock: vi.fn(), +})); + +vi.mock('./apiClient', async () => { + const actual = await vi.importActual('./apiClient'); + return { + ...actual, + requestJson: requestJsonMock, + }; +}); + +import { + buildStoryMomentFromRuntimeOptions, + getRuntimeClientVersion, + getRuntimeSessionId, + isServerRuntimeFunctionId, + isTask5RuntimeFunctionId, + resolveRuntimeStoryAction, + shouldUseServerRuntimeOptions, +} from './runtimeStoryService'; +import { AnimationState } from '../types'; + +describe('runtimeStoryService', () => { + beforeEach(() => { + requestJsonMock.mockReset(); + }); + + it('builds runtime action requests against the dedicated story endpoint', async () => { + requestJsonMock.mockResolvedValue({ + sessionId: 'runtime-main', + serverVersion: 2, + viewModel: {}, + presentation: { + actionText: '继续交谈', + resultText: '后端已结算', + storyText: '后端已结算', + options: [], + }, + patches: [], + snapshot: { + version: 2, + savedAt: '2026-04-08T00:00:00.000Z', + bottomTab: 'adventure', + gameState: {}, + currentStory: null, + }, + }); + + await resolveRuntimeStoryAction({ + sessionId: 'runtime-custom', + clientVersion: 9, + option: { + functionId: 'npc_chat', + actionText: '继续交谈', + }, + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/story/actions/resolve', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-custom', + clientVersion: 9, + action: { + type: 'story_choice', + functionId: 'npc_chat', + targetId: undefined, + payload: { + optionText: '继续交谈', + }, + }, + }), + }), + '执行运行时动作失败', + expect.any(Object), + ); + }); + + it('merges custom runtime payload fields into the action request body', async () => { + requestJsonMock.mockResolvedValue({ + sessionId: 'runtime-main', + serverVersion: 3, + viewModel: {}, + presentation: { + actionText: '使用凝神灵液', + resultText: '后端已结算物品使用', + storyText: '后端已结算物品使用', + options: [], + }, + patches: [], + snapshot: { + version: 3, + savedAt: '2026-04-08T00:00:00.000Z', + bottomTab: 'adventure', + gameState: {}, + currentStory: null, + }, + }); + + await resolveRuntimeStoryAction({ + option: { + functionId: 'inventory_use', + actionText: '使用凝神灵液', + }, + payload: { + itemId: 'focus-tonic', + }, + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/story/actions/resolve', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: undefined, + action: { + type: 'story_choice', + functionId: 'inventory_use', + targetId: undefined, + payload: { + optionText: '使用凝神灵液', + itemId: 'focus-tonic', + }, + }, + }), + }), + '执行运行时动作失败', + expect.any(Object), + ); + }); + + it('filters disabled runtime options when rebuilding a story moment', () => { + const story = buildStoryMomentFromRuntimeOptions({ + storyText: '服务端返回的新故事', + options: [ + { + functionId: 'npc_chat', + actionText: '继续交谈', + scope: 'npc', + }, + { + functionId: 'npc_recruit', + actionText: '邀请加入队伍', + scope: 'npc', + disabled: true, + reason: '队伍已满', + }, + ], + }); + + expect(story.text).toBe('服务端返回的新故事'); + expect(story.options).toHaveLength(1); + expect(story.options[0]?.functionId).toBe('npc_chat'); + }); + + it('recognizes server-runtime option pools for server-side legality checks', () => { + expect(isTask5RuntimeFunctionId('npc_chat')).toBe(true); + expect(isTask5RuntimeFunctionId('npc_trade')).toBe(false); + expect(isServerRuntimeFunctionId('npc_trade')).toBe(true); + expect(isServerRuntimeFunctionId('unknown_action')).toBe(false); + expect( + shouldUseServerRuntimeOptions([ + { + functionId: 'npc_chat', + actionText: '继续交谈', + text: '继续交谈', + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + ]), + ).toBe(true); + expect( + shouldUseServerRuntimeOptions([ + { + functionId: 'npc_trade', + actionText: '交易', + text: '交易', + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + ]), + ).toBe(true); + expect( + shouldUseServerRuntimeOptions([ + { + functionId: 'unknown_action', + actionText: '未知动作', + text: '未知动作', + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + }, + ]), + ).toBe(false); + expect(getRuntimeSessionId({ runtimeSessionId: '' })).toBe('runtime-main'); + expect(getRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3); + }); + + it('hydrates runtime option interaction metadata from the current encounter', () => { + const story = buildStoryMomentFromRuntimeOptions({ + storyText: '服务端返回的新故事', + gameState: { + currentEncounter: { + id: 'npc-merchant', + kind: 'npc', + npcName: '梁伯', + npcDescription: '沿街商贩', + npcAvatar: '', + context: '沿街商贩', + }, + } as never, + options: [ + { + functionId: 'npc_trade', + actionText: '交易', + scope: 'npc', + }, + ], + }); + + expect(story.options[0]?.interaction).toEqual({ + kind: 'npc', + npcId: 'npc-merchant', + action: 'trade', + }); + }); +}); diff --git a/src/services/runtimeStoryService.ts b/src/services/runtimeStoryService.ts new file mode 100644 index 00000000..9d082835 --- /dev/null +++ b/src/services/runtimeStoryService.ts @@ -0,0 +1,218 @@ +import type { + RuntimeStoryActionResponse, + RuntimeStoryChoicePayload, + RuntimeStoryOptionView, + ServerRuntimeFunctionId, + Task5RuntimeFunctionId, +} from '../../packages/shared/src/contracts/story'; +import { + SERVER_RUNTIME_FUNCTION_IDS, + TASK5_RUNTIME_FUNCTION_IDS, +} from '../../packages/shared/src/contracts/story'; +import type { + HydratedGameState, + HydratedSavedGameSnapshot, +} from '../persistence/runtimeSnapshotTypes'; +import type { GameState, StoryMoment, StoryOption } from '../types'; +import { AnimationState } from '../types'; +import { requestJson, type ApiRetryOptions } from './apiClient'; + +const RUNTIME_STORY_API_BASE = '/api/runtime/story'; +const DEFAULT_SESSION_ID = 'runtime-main'; +const RUNTIME_STORY_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 220, + maxDelayMs: 640, + retryUnsafeMethods: true, +}; + +const TASK5_RUNTIME_FUNCTION_ID_SET = new Set( + TASK5_RUNTIME_FUNCTION_IDS, +); +const SERVER_RUNTIME_FUNCTION_ID_SET = new Set([ + ...SERVER_RUNTIME_FUNCTION_IDS, +]); + +export type RuntimeStoryServiceOptions = { + signal?: AbortSignal; + retry?: ApiRetryOptions; +}; + +export type RuntimeStoryResponse = RuntimeStoryActionResponse< + HydratedGameState, + StoryMoment +>; +export type { RuntimeStoryChoicePayload }; + +function requestRuntimeStoryJson( + path: string, + init: RequestInit, + fallbackMessage: string, + options: RuntimeStoryServiceOptions = {}, +) { + return requestJson( + `${RUNTIME_STORY_API_BASE}${path}`, + { + ...init, + signal: options.signal, + }, + fallbackMessage, + { retry: options.retry ?? RUNTIME_STORY_RETRY }, + ); +} + +function buildRuntimeOptionInteraction( + option: RuntimeStoryOptionView, + gameState?: Pick, +): StoryOption['interaction'] { + const encounter = gameState?.currentEncounter; + + if (encounter?.kind === 'npc') { + const npcId = encounter.id ?? encounter.npcName; + const npcActionMap: Record = { + npc_chat: { kind: 'npc', npcId, action: 'chat' }, + npc_help: { kind: 'npc', npcId, action: 'help' }, + npc_fight: { kind: 'npc', npcId, action: 'fight' }, + npc_leave: { kind: 'npc', npcId, action: 'leave' }, + npc_recruit: { kind: 'npc', npcId, action: 'recruit' }, + npc_spar: { kind: 'npc', npcId, action: 'spar' }, + npc_trade: { kind: 'npc', npcId, action: 'trade' }, + npc_gift: { kind: 'npc', npcId, action: 'gift' }, + npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' }, + npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' }, + }; + + return npcActionMap[option.functionId]; + } + + if (encounter?.kind === 'treasure') { + const treasureActionMap: Record = { + treasure_secure: { kind: 'treasure', action: 'secure' }, + treasure_inspect: { kind: 'treasure', action: 'inspect' }, + treasure_leave: { kind: 'treasure', action: 'leave' }, + }; + + return treasureActionMap[option.functionId]; + } + + return undefined; +} + +function createRuntimeStoryOption( + option: RuntimeStoryOptionView, + gameState?: Pick, +): StoryOption { + const detailParts = [option.detailText, option.disabled ? option.reason : null] + .filter(Boolean) + .join(' '); + + return { + functionId: option.functionId, + actionText: option.actionText, + text: option.actionText, + detailText: detailParts || undefined, + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + interaction: buildRuntimeOptionInteraction(option, gameState), + }; +} + +export function getRuntimeSessionId(gameState: Pick) { + return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID; +} + +export function getRuntimeClientVersion( + gameState: Pick, +) { + return typeof gameState.runtimeActionVersion === 'number' + ? gameState.runtimeActionVersion + : undefined; +} + +export function isTask5RuntimeFunctionId( + functionId: string, +): functionId is Task5RuntimeFunctionId { + return TASK5_RUNTIME_FUNCTION_ID_SET.has(functionId); +} + +export function isServerRuntimeFunctionId( + functionId: string, +): functionId is ServerRuntimeFunctionId { + return SERVER_RUNTIME_FUNCTION_ID_SET.has(functionId); +} + +export function shouldUseServerRuntimeOptions(options: StoryOption[] | null) { + return Boolean( + options?.length && + options.every((option) => isServerRuntimeFunctionId(option.functionId)), + ); +} + +export function buildStoryMomentFromRuntimeOptions(params: { + storyText: string; + options: RuntimeStoryOptionView[]; + gameState?: Pick; +}) { + return { + text: params.storyText, + options: params.options + .filter((option) => !option.disabled) + .map((option) => createRuntimeStoryOption(option, params.gameState)), + } satisfies StoryMoment; +} + +export async function getRuntimeStoryState( + sessionId: string, + options: RuntimeStoryServiceOptions = {}, +) { + return requestRuntimeStoryJson( + `/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`, + { method: 'GET' }, + '读取运行时故事状态失败', + options, + ); +} + +export async function resolveRuntimeStoryAction( + params: { + sessionId?: string; + clientVersion?: number; + option: Pick; + targetId?: string; + payload?: RuntimeStoryChoicePayload; + }, + options: RuntimeStoryServiceOptions = {}, +) { + return requestRuntimeStoryJson( + '/actions/resolve', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: params.sessionId || DEFAULT_SESSION_ID, + clientVersion: params.clientVersion, + action: { + type: 'story_choice', + functionId: params.option.functionId, + targetId: params.targetId, + payload: { + optionText: params.option.actionText, + ...(params.payload ?? {}), + }, + }, + }), + }, + '执行运行时动作失败', + options, + ); +} + +export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) { + return response.snapshot as HydratedSavedGameSnapshot; +} diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 3b8579fe..453af3bb 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -1,78 +1,131 @@ import type { - SavedGameSnapshot, + BasicOkResult, + CustomWorldLibraryResponse, + RuntimeSettings, +} from '../../packages/shared/src/contracts/runtime'; +import type { SavedGameSnapshotInput, } from '../persistence/gameSaveStorage'; -import type { SavedGameSettings } from '../persistence/gameSettingsStorage'; +import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes'; import type { CustomWorldProfile } from '../types'; -import { requestJson } from './apiClient'; +import { type ApiRetryOptions,requestJson } from './apiClient'; const RUNTIME_API_BASE = '/api/runtime'; - -type CustomWorldLibraryResponse = { - profiles?: CustomWorldProfile[]; +const RUNTIME_READ_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 180, + maxDelayMs: 480, +}; +const RUNTIME_WRITE_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 240, + maxDelayMs: 640, + retryUnsafeMethods: true, }; -export async function getSaveSnapshot() { - return requestJson( - `${RUNTIME_API_BASE}/save/snapshot`, - { method: 'GET' }, - '读取存档失败', +export type RuntimeRequestOptions = { + signal?: AbortSignal; + retry?: ApiRetryOptions; +}; + +function requestRuntimeJson( + path: string, + init: RequestInit, + fallbackMessage: string, + options: RuntimeRequestOptions = {}, +) { + const method = (init.method ?? 'GET').toUpperCase(); + const retry = + options.retry ?? + (method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY); + + return requestJson( + `${RUNTIME_API_BASE}${path}`, + { + ...init, + signal: options.signal, + }, + fallbackMessage, + { retry }, ); } -export async function putSaveSnapshot(snapshot: SavedGameSnapshotInput) { - return requestJson( - `${RUNTIME_API_BASE}/save/snapshot`, +export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) { + return requestRuntimeJson( + '/save/snapshot', + { method: 'GET' }, + '读取存档失败', + options, + ); +} + +export async function putSaveSnapshot( + snapshot: SavedGameSnapshotInput, + options: RuntimeRequestOptions = {}, +) { + return requestRuntimeJson( + '/save/snapshot', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(snapshot), }, '保存存档失败', + options, ); } -export async function deleteSaveSnapshot() { - return requestJson<{ ok: true }>( - `${RUNTIME_API_BASE}/save/snapshot`, +export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) { + return requestRuntimeJson( + '/save/snapshot', { method: 'DELETE' }, '删除存档失败', + options, ); } -export async function getSettings() { - return requestJson( - `${RUNTIME_API_BASE}/settings`, +export async function getSettings(options: RuntimeRequestOptions = {}) { + return requestRuntimeJson( + '/settings', { method: 'GET' }, '读取设置失败', + options, ); } -export async function putSettings(settings: SavedGameSettings) { - return requestJson( - `${RUNTIME_API_BASE}/settings`, +export async function putSettings( + settings: RuntimeSettings, + options: RuntimeRequestOptions = {}, +) { + return requestRuntimeJson( + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings), }, '保存设置失败', + options, ); } -export async function listCustomWorldLibrary() { - const response = await requestJson( - `${RUNTIME_API_BASE}/custom-world-library`, +export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}) { + const response = await requestRuntimeJson>( + '/custom-world-library', { method: 'GET' }, '读取自定义世界库失败', + options, ); return Array.isArray(response?.profiles) ? response.profiles : []; } -export async function upsertCustomWorldProfile(profile: CustomWorldProfile) { - const response = await requestJson( - `${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profile.id)}`, +export async function upsertCustomWorldProfile( + profile: CustomWorldProfile, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson>( + `/custom-world-library/${encodeURIComponent(profile.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -81,17 +134,33 @@ export async function upsertCustomWorldProfile(profile: CustomWorldProfile) { }), }, '保存自定义世界失败', + options, ); return Array.isArray(response?.profiles) ? response.profiles : []; } -export async function deleteCustomWorldProfile(profileId: string) { - const response = await requestJson( - `${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profileId)}`, +export async function deleteCustomWorldProfile( + profileId: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson>( + `/custom-world-library/${encodeURIComponent(profileId)}`, { method: 'DELETE' }, '删除自定义世界失败', + options, ); return Array.isArray(response?.profiles) ? response.profiles : []; } + +export const runtimeStorageClient = { + getSaveSnapshot, + putSaveSnapshot, + deleteSaveSnapshot, + getSettings, + putSettings, + listCustomWorldLibrary, + upsertCustomWorldProfile, + deleteCustomWorldProfile, +}; diff --git a/src/tools/qwenSpriteSheetToolPersistence.ts b/src/tools/qwenSpriteSheetToolPersistence.ts index 5d928149..1f937e9c 100644 --- a/src/tools/qwenSpriteSheetToolPersistence.ts +++ b/src/tools/qwenSpriteSheetToolPersistence.ts @@ -1,9 +1,13 @@ -import { parseApiErrorMessage } from '../editor/shared/jsonClient'; +import { + ASSET_API_PATHS, + postApiJson, +} from '../editor/shared/editorApiClient'; -const QWEN_SPRITE_MASTER_API_PATH = '/api/qwen-sprite/master'; -const QWEN_SPRITE_SHEET_API_PATH = '/api/qwen-sprite/sheet'; -const QWEN_SPRITE_FRAME_REPAIR_API_PATH = '/api/qwen-sprite/frame-repair'; -const QWEN_SPRITE_SAVE_API_PATH = '/api/qwen-sprite/save'; +const QWEN_SPRITE_MASTER_API_PATH = ASSET_API_PATHS.qwenSpriteMaster; +const QWEN_SPRITE_SHEET_API_PATH = ASSET_API_PATHS.qwenSpriteSheet; +const QWEN_SPRITE_FRAME_REPAIR_API_PATH = + ASSET_API_PATHS.qwenSpriteFrameRepair; +const QWEN_SPRITE_SAVE_API_PATH = ASSET_API_PATHS.qwenSpriteSave; export type QwenSpriteImageDraft = { id: string; @@ -48,18 +52,7 @@ async function postJson( payload: Record, fallbackMessage: string, ) { - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const responseText = await response.text(); - - if (!response.ok) { - throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); - } - - return JSON.parse(responseText) as T; + return postApiJson(url, payload, fallbackMessage); } export async function generateQwenSpriteMaster( diff --git a/src/types/game.ts b/src/types/game.ts index aef852a3..c552f416 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -34,6 +34,8 @@ export interface GameState { worldType: WorldType | null; customWorldProfile: CustomWorldProfile | null; playerCharacter: Character | null; + runtimeSessionId?: string | null; + runtimeActionVersion?: number; runtimeStats: GameRuntimeStats; currentScene: string; storyHistory: StoryMoment[]; diff --git a/vite.config.ts b/vite.config.ts index 9b73eb77..906c0af4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,6 +55,16 @@ export default defineConfig(({mode}) => { changeOrigin: true, secure: false, }, + '/api/editor': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + }, + '/api/assets': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + }, '/api/custom-world/scene-image': { target: runtimeServerTarget, changeOrigin: true,