1
This commit is contained in:
64
.env.example
64
.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"
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
- UI设计需要兼顾网页端、移动端双端的使用体验,确保在不同设备上都能正常显示和操作,移动端优先考虑。
|
||||
- 不要在gitignore中添加.env.local文件。
|
||||
- 严格遵循简洁的代码风格
|
||||
- 前端只负责做表现,所有的逻辑、数据都放到Express后端进行运算和存储。
|
||||
- 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。
|
||||
- 禁止将功能说明描述类的文本默认写入UI界面中。
|
||||
|
||||
## 文档图谱
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
补充说明:
|
||||
|
||||
- `npm run dev` 会同时启动 Vite 与 Express 后端,适合完整联调。
|
||||
- 如果没有显式配置 `DATABASE_URL`,且本机 `PostgreSQL` 不可用,开发模式会自动回退到内存版 `pg-mem`,方便先跑通鉴权与存档主链。
|
||||
- 如果只想单独启动前端页面,可使用 `npm run dev:web`。
|
||||
|
||||
构建生产包:
|
||||
|
||||
```bash
|
||||
|
||||
444
docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md
Normal file
444
docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md
Normal file
@@ -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、流程和后端边界上。**
|
||||
@@ -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):自定义世界创作工具当前问题、体验断层和优化优先级审计。
|
||||
|
||||
## 推荐使用方式
|
||||
|
||||
|
||||
@@ -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 对照阅读。
|
||||
|
||||
@@ -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. 一条能延续到后面的余波
|
||||
|
||||
只有这样,当前项目的“场景章节化”才会从结构成立,进一步走到体验成立。
|
||||
@@ -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 壳层把边界钉死,再让服务端领域迁移、编辑器归口、前端瘦身分轨并行,最后由主流程壳层统一接入。**
|
||||
447
docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md
Normal file
447
docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md
Normal file
@@ -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 后端统一持有运行时真相,前端只负责表现和交互”的正式工程架构。**
|
||||
@@ -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):将后端化重构拆成可并行推进、尽量不冲突的任务流与协作顺序。
|
||||
|
||||
## 使用建议
|
||||
|
||||
|
||||
963
docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md
Normal file
963
docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md
Normal file
@@ -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. 用后端统一承接身份、绑定、会话、存档归属
|
||||
|
||||
这样改完之后,玩家进入游戏时感知到的将不再是:
|
||||
|
||||
- “先点开始再说”
|
||||
|
||||
而会更接近:
|
||||
|
||||
- “先登录我的正式账号,再继续我的冒险进度。”
|
||||
File diff suppressed because it is too large
Load Diff
106
docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md
Normal file
106
docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md
Normal file
@@ -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 本地插件不再是当前工具链入口。
|
||||
108
docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md
Normal file
108
docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md
Normal file
@@ -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 或热点入口发生明显演进,应优先更新这份文档,而不是让口径只停留在聊天记录里。
|
||||
@@ -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 统一走后端处理链
|
||||
425
docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md
Normal file
425
docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md
Normal file
@@ -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 为唯一真相源”。**
|
||||
@@ -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` 注入。
|
||||
|
||||
@@ -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
|
||||
@@ -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 生成角色形象与角色动画的技术路线。
|
||||
|
||||
@@ -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",
|
||||
|
||||
6
packages/shared/package.json
Normal file
6
packages/shared/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@genarrative/shared",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module"
|
||||
}
|
||||
157
packages/shared/src/assets/qwenSprite.ts
Normal file
157
packages/shared/src/assets/qwenSprite.ts
Normal file
@@ -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(' ');
|
||||
}
|
||||
158
packages/shared/src/contracts/auth.ts
Normal file
158
packages/shared/src/contracts/auth.ts
Normal file
@@ -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;
|
||||
};
|
||||
3
packages/shared/src/contracts/common.ts
Normal file
3
packages/shared/src/contracts/common.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type JsonArray = unknown[];
|
||||
124
packages/shared/src/contracts/runtime.ts
Normal file
124
packages/shared/src/contracts/runtime.ts
Normal file
@@ -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<TGameState, TBottomTab, TCurrentStory>,
|
||||
'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;
|
||||
};
|
||||
408
packages/shared/src/contracts/story.ts
Normal file
408
packages/shared/src/contracts/story.ts
Normal file
@@ -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<TWorldType extends string = string> = {
|
||||
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<TIntent = JsonObject> = {
|
||||
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<RuntimeStoryChoiceAction>;
|
||||
|
||||
export type RuntimeStoryActionResponse<
|
||||
TSnapshotGameState = JsonObject,
|
||||
TSnapshotCurrentStory = JsonObject,
|
||||
> = RuntimeActionResponse<
|
||||
RuntimeStoryViewModel,
|
||||
RuntimeStoryPresentation,
|
||||
RuntimeStoryPatch
|
||||
> & {
|
||||
snapshot: SavedGameSnapshot<
|
||||
TSnapshotGameState,
|
||||
string,
|
||||
TSnapshotCurrentStory
|
||||
>;
|
||||
};
|
||||
199
packages/shared/src/http.ts
Normal file
199
packages/shared/src/http.ts
Normal file
@@ -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<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type ApiMeta = {
|
||||
apiVersion: string;
|
||||
requestId?: string;
|
||||
routeVersion?: string;
|
||||
operation?: string | null;
|
||||
latencyMs?: number;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export type ApiSuccessResponse<T> = {
|
||||
ok: true;
|
||||
data: T;
|
||||
error: null;
|
||||
meta: ApiMeta;
|
||||
};
|
||||
|
||||
export type ApiErrorResponse = {
|
||||
ok: false;
|
||||
data: null;
|
||||
error: ApiErrorPayload;
|
||||
meta: ApiMeta;
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function buildApiMeta(meta: Partial<ApiMeta> = {}): 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<T>(
|
||||
data: T,
|
||||
meta: Partial<ApiMeta> = {},
|
||||
): ApiSuccessResponse<T> {
|
||||
return {
|
||||
ok: true,
|
||||
data,
|
||||
error: null,
|
||||
meta: buildApiMeta(meta),
|
||||
};
|
||||
}
|
||||
|
||||
export function createApiError(
|
||||
error: ApiErrorPayload,
|
||||
meta: Partial<ApiMeta> = {},
|
||||
): ApiErrorResponse {
|
||||
return {
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
meta: buildApiMeta(meta),
|
||||
};
|
||||
}
|
||||
|
||||
export function isApiResponse<T>(value: unknown): value is ApiResponse<T> {
|
||||
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<T>(value: ApiResponse<T> | T): T {
|
||||
if (!isApiResponse<T>(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;
|
||||
}
|
||||
8
packages/shared/src/index.ts
Normal file
8
packages/shared/src/index.ts
Normal file
@@ -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';
|
||||
66
packages/shared/src/llm/narrativeLanguage.ts
Normal file
66
packages/shared/src/llm/narrativeLanguage.ts
Normal file
@@ -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;
|
||||
}
|
||||
28
packages/shared/src/llm/parsers.ts
Normal file
28
packages/shared/src/llm/parsers.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
11
scripts/dev-server/README.md
Normal file
11
scripts/dev-server/README.md
Normal file
@@ -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/*` 编辑器写盘或资产生成接口。
|
||||
414
scripts/smoke-same-origin-stack.ts
Normal file
414
scripts/smoke-same-origin-stack.ts
Normal file
@@ -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<void>((resolve) => {
|
||||
child.process.once('exit', () => resolve());
|
||||
}),
|
||||
sleep(2000),
|
||||
]);
|
||||
|
||||
if (child.process.exitCode === null) {
|
||||
child.process.kill('SIGKILL');
|
||||
await new Promise<void>((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<void>((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<void>((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 id="root"><\/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);
|
||||
});
|
||||
287
scripts/smoke-server-node.ts
Normal file
287
scripts/smoke-server-node.ts
Normal file
@@ -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<T>(
|
||||
run: (options: { baseUrl: string }) => Promise<T>,
|
||||
) {
|
||||
const context = await createAppContext(createSmokeConfig());
|
||||
const app = createApp(context);
|
||||
const server = await new Promise<import('node:http').Server>((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<void>((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);
|
||||
});
|
||||
1132
server-node/package-lock.json
generated
1132
server-node/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown>) => express.RequestHandler;
|
||||
const createHttpLogger = pinoHttp as unknown as (
|
||||
options: Record<string, unknown>,
|
||||
) => 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<string, unknown> & { 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<string, unknown> & { 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;
|
||||
}
|
||||
|
||||
15
server-node/src/auth/authRequestContext.ts
Normal file
15
server-node/src/auth/authRequestContext.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
55
server-node/src/auth/phoneNumber.ts
Normal file
55
server-node/src/auth/phoneNumber.ts
Normal file
@@ -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;
|
||||
}
|
||||
96
server-node/src/auth/refreshSessionCookie.ts
Normal file
96
server-node/src/auth/refreshSessionCookie.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<T>(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));
|
||||
}
|
||||
|
||||
|
||||
6
server-node/src/bridges/legacyBuildRuntimeBridge.ts
Normal file
6
server-node/src/bridges/legacyBuildRuntimeBridge.ts
Normal file
@@ -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';
|
||||
25
server-node/src/bridges/legacyInventoryRuntimeBridge.ts
Normal file
25
server-node/src/bridges/legacyInventoryRuntimeBridge.ts
Normal file
@@ -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';
|
||||
26
server-node/src/bridges/legacyNpcTask6Bridge.ts
Normal file
26
server-node/src/bridges/legacyNpcTask6Bridge.ts
Normal file
@@ -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';
|
||||
15
server-node/src/bridges/legacyQuestProgressBridge.ts
Normal file
15
server-node/src/bridges/legacyQuestProgressBridge.ts
Normal file
@@ -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';
|
||||
9
server-node/src/bridges/legacyQuestRuntimeBridge.ts
Normal file
9
server-node/src/bridges/legacyQuestRuntimeBridge.ts
Normal file
@@ -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';
|
||||
6
server-node/src/bridges/legacyRuntimeItemBridge.ts
Normal file
6
server-node/src/bridges/legacyRuntimeItemBridge.ts
Normal file
@@ -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';
|
||||
@@ -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';
|
||||
3
server-node/src/bridges/legacyTreasureRuntimeBridge.ts
Normal file
3
server-node/src/bridges/legacyTreasureRuntimeBridge.ts
Normal file
@@ -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';
|
||||
@@ -7,9 +7,12 @@ export type AppConfig = {
|
||||
publicDir: string;
|
||||
logsDir: string;
|
||||
dataDir: string;
|
||||
sqlitePath: string;
|
||||
rawEnv: Record<string, string>;
|
||||
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<string, string | undefined>,
|
||||
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<string, string | undefined>,
|
||||
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',
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
163
server-node/src/db.test.ts
Normal file
163
server-node/src/db.test.ts
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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<TResult extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
params?: readonly unknown[],
|
||||
): Promise<QueryResult<TResult>>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
const db = new Database(config.sqlitePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(schemaSql);
|
||||
type QueryablePool = Pick<Pool, 'query' | 'end'>;
|
||||
|
||||
function wrapPool(pool: QueryablePool): AppDatabase {
|
||||
return {
|
||||
query<TResult extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
params: readonly unknown[] = [],
|
||||
) {
|
||||
return pool.query<TResult>(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<MigrationRow>(
|
||||
`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;
|
||||
}
|
||||
|
||||
192
server-node/src/db/migrations.ts
Normal file
192
server-node/src/db/migrations.ts
Normal file
@@ -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)`,
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<T> = {
|
||||
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<T>(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: T,
|
||||
): T | ApiSuccessEnvelope<T> {
|
||||
const meta = applyApiResponseHeaders(request, response);
|
||||
|
||||
if (!wantsApiEnvelope(request)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return createApiSuccess(data, buildSharedApiMeta(meta));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isStandardApiSuccessEnvelope(
|
||||
body: unknown,
|
||||
): body is ApiSuccessEnvelope<unknown> {
|
||||
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<T>(
|
||||
response: Response,
|
||||
data: T,
|
||||
statusCode = 200,
|
||||
) {
|
||||
response.status(statusCode);
|
||||
response.json(data);
|
||||
}
|
||||
|
||||
export function prepareApiResponse(
|
||||
request: Request,
|
||||
response: Response,
|
||||
options: {
|
||||
statusCode?: number;
|
||||
headers?: Record<string, string>;
|
||||
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<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
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<T>(value: T): T {
|
||||
|
||||
@@ -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('登录状态已失效,请重新登录');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
49
server-node/src/middleware/responseEnvelope.ts
Normal file
49
server-node/src/middleware/responseEnvelope.ts
Normal file
@@ -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();
|
||||
};
|
||||
10
server-node/src/middleware/routeMeta.ts
Normal file
10
server-node/src/middleware/routeMeta.ts
Normal file
@@ -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();
|
||||
};
|
||||
}
|
||||
30
server-node/src/migrate.ts
Normal file
30
server-node/src/migrate.ts
Normal file
@@ -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);
|
||||
});
|
||||
90
server-node/src/modules/ai/chatOrchestrator.ts
Normal file
90
server-node/src/modules/ai/chatOrchestrator.ts
Normal file
@@ -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),
|
||||
});
|
||||
}
|
||||
372
server-node/src/modules/ai/chatPromptBuilders.ts
Normal file
372
server-node/src/modules/ai/chatPromptBuilders.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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');
|
||||
}
|
||||
461
server-node/src/modules/ai/customWorldOrchestrator.ts
Normal file
461
server-node/src/modules/ai/customWorldOrchestrator.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
} from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
|
||||
type GeneratedProfile = Record<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
193
server-node/src/modules/ai/orchestrator.test.ts
Normal file
193
server-node/src/modules/ai/orchestrator.test.ts
Normal file
@@ -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<typeof generateInitialStoryFromOrchestrator>[4];
|
||||
type TestStoryOption = Awaited<
|
||||
ReturnType<typeof generateInitialStoryFromOrchestrator>
|
||||
>['options'][number];
|
||||
const TEST_WORLD = 'WUXIA' as Parameters<
|
||||
typeof generateInitialStoryFromOrchestrator
|
||||
>[1];
|
||||
type TestCharacter = Parameters<typeof generateInitialStoryFromOrchestrator>[2];
|
||||
|
||||
function createTestCharacter(overrides: Partial<TestCharacter> = {}) {
|
||||
return {
|
||||
...createTestPlayerCharacter<TestCharacter>(),
|
||||
...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'));
|
||||
});
|
||||
615
server-node/src/modules/ai/storyOrchestrator.ts
Normal file
615
server-node/src/modules/ai/storyOrchestrator.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, number>;
|
||||
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<PromptStoryOption> & { 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<string, unknown>;
|
||||
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<string, PromptStoryOption[]>();
|
||||
const consumedOptions = new Set<PromptStoryOption>();
|
||||
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<string, PromptStoryOption[]>();
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
163
server-node/src/modules/ai/storyPromptBuilders.ts
Normal file
163
server-node/src/modules/ai/storyPromptBuilders.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
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<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}) {
|
||||
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');
|
||||
}
|
||||
2505
server-node/src/modules/assets/characterAssetRoutes.ts
Normal file
2505
server-node/src/modules/assets/characterAssetRoutes.ts
Normal file
File diff suppressed because it is too large
Load Diff
907
server-node/src/modules/assets/qwenSpriteRoutes.ts
Normal file
907
server-node/src/modules/assets/qwenSpriteRoutes.ts
Normal file
@@ -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<string, unknown>);
|
||||
}
|
||||
|
||||
return new Promise<Record<string, unknown>>((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<string, unknown> {
|
||||
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<string, string>;
|
||||
bodyText?: string;
|
||||
} = {},
|
||||
) {
|
||||
return new Promise<{
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
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<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
return new Promise<{
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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> | 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;
|
||||
}
|
||||
272
server-node/src/modules/combat/combatResolutionService.ts
Normal file
272
server-node/src/modules/combat/combatResolutionService.ts
Normal file
@@ -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<string, CombatActionConfig> = {
|
||||
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<RuntimeStoryPatch, { type: 'npc_affinity_changed' }>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
141
server-node/src/modules/editor/editorRoutes.ts
Normal file
141
server-node/src/modules/editor/editorRoutes.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectPngAssetPaths(
|
||||
rootDir: string,
|
||||
relativeDir = 'Icons',
|
||||
): Promise<string[]> {
|
||||
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;
|
||||
}
|
||||
1
server-node/src/modules/inventory/index.ts
Normal file
1
server-node/src/modules/inventory/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './inventoryMutationService.js';
|
||||
@@ -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<RuntimeGameState['playerCharacter']>
|
||||
>();
|
||||
}
|
||||
|
||||
function buildItem(
|
||||
overrides: Partial<RuntimeInventoryItem> &
|
||||
Pick<RuntimeInventoryItem, 'id' | 'category' | 'name'>,
|
||||
): RuntimeInventoryItem {
|
||||
return {
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<RuntimeGameState> = {}): 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,
|
||||
);
|
||||
});
|
||||
458
server-node/src/modules/inventory/inventoryMutationService.ts
Normal file
458
server-node/src/modules/inventory/inventoryMutationService.ts
Normal file
@@ -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<typeof getEquipmentSlotFromItem>,
|
||||
null
|
||||
>;
|
||||
export type RuntimeInventoryUseEffect = Exclude<
|
||||
ReturnType<typeof resolveInventoryItemUseEffect>,
|
||||
null
|
||||
>;
|
||||
export type RuntimeForgeRecipeView = ReturnType<
|
||||
typeof getForgeRecipeViews
|
||||
>[number];
|
||||
export type RuntimeReforgeCostView = ReturnType<typeof getReforgeCostView>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
197
server-node/src/modules/inventory/inventoryStoryActionService.ts
Normal file
197
server-node/src/modules/inventory/inventoryStoryActionService.ts
Normal file
@@ -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<string>([
|
||||
'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<string, unknown>;
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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<string>([
|
||||
'npc_gift',
|
||||
'npc_trade',
|
||||
]);
|
||||
|
||||
type NpcInventoryStoryResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeInventoryItem = Parameters<typeof addInventoryItems>[1][number];
|
||||
type RuntimeGameState = Parameters<typeof syncNpcTradeInventory>[0];
|
||||
type RuntimeEncounter = Parameters<typeof buildInitialNpcState>[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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
261
server-node/src/modules/npc/npcInteractionService.ts
Normal file
261
server-node/src/modules/npc/npcInteractionService.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
}
|
||||
150
server-node/src/modules/npc/npcTask6Primitives.test.ts
Normal file
150
server-node/src/modules/npc/npcTask6Primitives.test.ts
Normal file
@@ -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<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
411
server-node/src/modules/npc/npcTask6Primitives.ts
Normal file
411
server-node/src/modules/npc/npcTask6Primitives.ts
Normal file
@@ -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<typeof buildRelationState>;
|
||||
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<typeof buildRuntimeInventoryStock>[1],
|
||||
);
|
||||
|
||||
const preservedInventory = npcState.tradeStockSignature
|
||||
? npcState.inventory.filter(
|
||||
(item) => item.runtimeMetadata?.generationChannel !== 'npc_trade',
|
||||
)
|
||||
: [];
|
||||
|
||||
return normalizeNpcPersistentState({
|
||||
...npcState,
|
||||
inventory: sortInventoryItems([...preservedInventory, ...runtimeStock]),
|
||||
tradeStockSignature,
|
||||
});
|
||||
}
|
||||
2
server-node/src/modules/quest/index.ts
Normal file
2
server-node/src/modules/quest/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './questProgressionService.js';
|
||||
export { generateQuestForNpcEncounter } from '../../services/questService.js';
|
||||
103
server-node/src/modules/quest/questProgressionService.test.ts
Normal file
103
server-node/src/modules/quest/questProgressionService.test.ts
Normal file
@@ -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<typeof buildQuestForEncounter>[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);
|
||||
});
|
||||
213
server-node/src/modules/quest/questProgressionService.ts
Normal file
213
server-node/src/modules/quest/questProgressionService.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
applyQuestProgressSignal,
|
||||
normalizeQuestLogEntries,
|
||||
} from '../../bridges/legacyQuestProgressBridge.js';
|
||||
|
||||
export type QuestLogEntry = Parameters<typeof normalizeQuestLogEntries>[0][number];
|
||||
export type QuestProgressSignal = Parameters<typeof applyQuestProgressSignal>[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);
|
||||
}
|
||||
84
server-node/src/modules/quest/questRuntimeSignalService.ts
Normal file
84
server-node/src/modules/quest/questRuntimeSignalService.ts
Normal file
@@ -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<string, unknown>;
|
||||
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,
|
||||
);
|
||||
}
|
||||
242
server-node/src/modules/quest/questStoryActionService.ts
Normal file
242
server-node/src/modules/quest/questStoryActionService.ts
Normal file
@@ -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<string>([
|
||||
'npc_quest_accept',
|
||||
'npc_quest_turn_in',
|
||||
]);
|
||||
|
||||
type QuestStoryResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeGameState = Parameters<typeof appendStoryEngineCarrierMemory>[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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
server-node/src/modules/quest/questTask6Bridge.ts
Normal file
17
server-node/src/modules/quest/questTask6Bridge.ts
Normal file
@@ -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';
|
||||
1246
server-node/src/modules/quest/runtimeQuestModule.ts
Normal file
1246
server-node/src/modules/quest/runtimeQuestModule.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
server-node/src/modules/runtime-item/index.ts
Normal file
2
server-node/src/modules/runtime-item/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './runtimeItemResolutionService.js';
|
||||
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';
|
||||
784
server-node/src/modules/runtime-item/runtimeItemModule.ts
Normal file
784
server-node/src/modules/runtime-item/runtimeItemModule.ts
Normal file
@@ -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<string | null | undefined>) {
|
||||
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<typeof buildDirectedRuntimeReward>[1],
|
||||
) {
|
||||
return flattenDirectedRuntimeRewardItems(
|
||||
buildDirectedRuntimeReward(context, options),
|
||||
);
|
||||
}
|
||||
@@ -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<typeof buildQuestRuntimeItemGenerationContext>[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);
|
||||
});
|
||||
@@ -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<typeof buildDirectedRuntimeReward>;
|
||||
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);
|
||||
}
|
||||
@@ -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<RuntimeStateLike['currentEncounter']>;
|
||||
|
||||
export type TreasureReward = {
|
||||
items: ReturnType<typeof flattenDirectedRuntimeRewardItems>;
|
||||
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<typeof buildDirectedRuntimeReward>[1]);
|
||||
|
||||
return {
|
||||
items: flattenDirectedRuntimeRewardItems(directed),
|
||||
hp: directed.hp ?? 0,
|
||||
mana: directed.mana ?? 0,
|
||||
currency: directed.currency ?? 0,
|
||||
storyHint: directed.storyHint,
|
||||
} satisfies TreasureReward;
|
||||
}
|
||||
@@ -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<string>([
|
||||
'treasure_inspect',
|
||||
'treasure_leave',
|
||||
'treasure_secure',
|
||||
]);
|
||||
|
||||
type TreasureStoryResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
toast?: string | null;
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeGameState = Parameters<typeof resolveTreasureReward>[0];
|
||||
type RuntimeEncounter = Parameters<typeof resolveTreasureReward>[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,
|
||||
};
|
||||
}
|
||||
211
server-node/src/modules/runtime/runtimeBuildModule.ts
Normal file
211
server-node/src/modules/runtime/runtimeBuildModule.ts
Normal file
@@ -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<TItem extends RuntimeInventoryItemLike = RuntimeInventoryItemLike> = {
|
||||
playerEquipment: RuntimeEquipmentLoadout<TItem>;
|
||||
activeBuildBuffs?: RuntimeBuildBuff[];
|
||||
playerCharacter?: RuntimeCharacterLike | null;
|
||||
};
|
||||
|
||||
export type BuildContributionRow = {
|
||||
label: string;
|
||||
source: 'buff' | 'weapon' | 'armor' | 'relic' | 'character';
|
||||
fitScore: number;
|
||||
sourceCoefficient: number;
|
||||
bonusDelta: number;
|
||||
attributeSimilarities: Record<string, number>;
|
||||
attributeWeights: Record<string, number>;
|
||||
attributeContributions: Record<string, number>;
|
||||
attributeModifierDeltas: Record<string, number>;
|
||||
};
|
||||
|
||||
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<TBuff extends RuntimeBuildBuff>(
|
||||
baseBuffs: TBuff[] | null | undefined,
|
||||
additions: TBuff[] | null | undefined,
|
||||
) {
|
||||
const merged = new Map<string, TBuff>();
|
||||
|
||||
[...(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<TItem extends RuntimeInventoryItemLike>(
|
||||
state: RuntimeGameStateLike<TItem>,
|
||||
character: RuntimeCharacterLike,
|
||||
) {
|
||||
const tags = new Set<string>();
|
||||
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>,
|
||||
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>,
|
||||
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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
75
server-node/src/modules/runtime/runtimeEconomyPrimitives.ts
Normal file
75
server-node/src/modules/runtime/runtimeEconomyPrimitives.ts
Normal file
@@ -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<RuntimeInventoryItemLike['rarity'], number> = {
|
||||
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));
|
||||
}
|
||||
211
server-node/src/modules/runtime/runtimeEquipmentModule.ts
Normal file
211
server-node/src/modules/runtime/runtimeEquipmentModule.ts
Normal file
@@ -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<TItem = RuntimeInventoryItemLike> = {
|
||||
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<ItemRarity, number> = {
|
||||
common: 0.06,
|
||||
uncommon: 0.1,
|
||||
rare: 0.14,
|
||||
epic: 0.2,
|
||||
legendary: 0.28,
|
||||
};
|
||||
|
||||
const ARMOR_HP_BONUS: Record<ItemRarity, number> = {
|
||||
common: 14,
|
||||
uncommon: 22,
|
||||
rare: 32,
|
||||
epic: 44,
|
||||
legendary: 58,
|
||||
};
|
||||
|
||||
const ARMOR_DAMAGE_MULTIPLIER: Record<ItemRarity, number> = {
|
||||
common: 0.97,
|
||||
uncommon: 0.94,
|
||||
rare: 0.9,
|
||||
epic: 0.86,
|
||||
legendary: 0.8,
|
||||
};
|
||||
|
||||
const RELIC_MANA_BONUS: Record<ItemRarity, number> = {
|
||||
common: 10,
|
||||
uncommon: 18,
|
||||
rare: 28,
|
||||
epic: 40,
|
||||
legendary: 54,
|
||||
};
|
||||
|
||||
const RELIC_DAMAGE_BONUS: Record<ItemRarity, number> = {
|
||||
common: 0.02,
|
||||
uncommon: 0.04,
|
||||
rare: 0.06,
|
||||
epic: 0.09,
|
||||
legendary: 0.12,
|
||||
};
|
||||
|
||||
export function createEmptyEquipmentLoadout<TItem = RuntimeInventoryItemLike>(): RuntimeEquipmentLoadout<TItem> {
|
||||
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<TItem extends RuntimeInventoryItemLike>(
|
||||
loadout: RuntimeEquipmentLoadout<TItem>,
|
||||
): 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>;
|
||||
},
|
||||
TItem extends RuntimeInventoryItemLike,
|
||||
>(state: TState, nextEquipment: RuntimeEquipmentLoadout<TItem>) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
468
server-node/src/modules/runtime/runtimeForgeModule.ts
Normal file
468
server-node/src/modules/runtime/runtimeForgeModule.ts
Normal file
@@ -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<TItem> = {
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
matches: (item: TItem) => boolean;
|
||||
};
|
||||
|
||||
type ForgeRecipeDefinition<TItem> = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'synthesis' | 'forge';
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
requirements: ForgeRequirement<TItem>[];
|
||||
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<RuntimeInventoryItemLike['statProfile']>;
|
||||
}) {
|
||||
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<TItem extends RuntimeInventoryItemLike>(
|
||||
name: string,
|
||||
quantity: number,
|
||||
): ForgeRequirement<TItem> {
|
||||
return {
|
||||
id: `name:${name}`,
|
||||
label: name,
|
||||
quantity,
|
||||
matches: (item) => item.name === name,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnyMaterialRequirement<TItem extends RuntimeInventoryItemLike>(
|
||||
id: string,
|
||||
label: string,
|
||||
quantity: number,
|
||||
): ForgeRequirement<TItem> {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
quantity,
|
||||
matches: (item) => item.tags.includes('material') || item.category.includes('材料'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildForgeRecipes<TItem extends RuntimeInventoryItemLike>() {
|
||||
return [
|
||||
{
|
||||
id: 'synthesis-refined-ingot',
|
||||
name: '压炼锭材',
|
||||
kind: 'synthesis',
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
requirements: [buildAnyMaterialRequirement<TItem>('material:any', '任意材料', 3)],
|
||||
createResult: () =>
|
||||
buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare') as TItem,
|
||||
},
|
||||
{
|
||||
id: 'forge-duelist-blade',
|
||||
name: '锻造 百炼追风剑',
|
||||
kind: 'forge',
|
||||
description: '围绕快剑、突进、追击构筑的轻灵主武器。',
|
||||
resultLabel: '百炼追风剑',
|
||||
currencyCost: 72,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement<TItem>('精炼锭材', 2),
|
||||
buildNamedMaterialRequirement<TItem>('快剑精粹', 1),
|
||||
],
|
||||
createResult: () =>
|
||||
buildEquipmentItem({
|
||||
name: '百炼追风剑',
|
||||
slot: 'weapon',
|
||||
rarity: 'epic',
|
||||
description: '为快剑与追身构筑准备的锻造兵刃。',
|
||||
role: '快剑',
|
||||
tags: ['快剑', '突进', '追击'],
|
||||
synergy: ['快剑', '突进', '追击'],
|
||||
statProfile: {
|
||||
maxManaBonus: 10,
|
||||
outgoingDamageBonus: 0.2,
|
||||
},
|
||||
}) as TItem,
|
||||
},
|
||||
] satisfies ForgeRecipeDefinition<TItem>[];
|
||||
}
|
||||
|
||||
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<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
requirement: ForgeRequirement<TItem>,
|
||||
) {
|
||||
return inventory
|
||||
.filter((item) => requirement.matches(item))
|
||||
.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
|
||||
function consumeRequirement<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
requirement: ForgeRequirement<TItem>,
|
||||
) {
|
||||
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<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
requirements: ForgeRequirement<TItem>[],
|
||||
) {
|
||||
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<RuntimeInventoryItemLike['rarity'], number> = {
|
||||
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<TItem extends RuntimeInventoryItemLike>(
|
||||
slot: RuntimeEquipmentSlotId | null,
|
||||
) {
|
||||
if (slot === 'relic') {
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement<TItem>('凝光纱', 1)],
|
||||
currencyCost: 52,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement<TItem>('精炼锭材', 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<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
playerCurrency = 0,
|
||||
worldType: string | null | undefined = null,
|
||||
) {
|
||||
return buildForgeRecipes<TItem>().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<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
recipeId: string,
|
||||
worldType: string | null | undefined,
|
||||
playerCurrency: number,
|
||||
) {
|
||||
const recipe = buildForgeRecipes<TItem>().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<TItem extends RuntimeInventoryItemLike>(
|
||||
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<TItem extends RuntimeInventoryItemLike>(
|
||||
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<TItem>(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<TItem extends RuntimeInventoryItemLike>(
|
||||
item: TItem,
|
||||
worldType: string | null | undefined,
|
||||
) {
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
const cost = getReforgeCost<TItem>(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('、')}。`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user